845 lines
26 KiB
Go
845 lines
26 KiB
Go
package api
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"pinepods/gpodder-api/internal/db"
|
|
"pinepods/gpodder-api/internal/models"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// getEpisodeActions handles GET /api/2/episodes/{username}.json
|
|
func getEpisodeActions(database *db.Database) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
|
|
// Get user ID from middleware
|
|
userID, exists := c.Get("userID")
|
|
if !exists {
|
|
log.Printf("[ERROR] getEpisodeActions: userID not found in context")
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
return
|
|
}
|
|
|
|
// Parse query parameters
|
|
sinceStr := c.Query("since")
|
|
podcastURL := c.Query("podcast")
|
|
deviceName := c.Query("device")
|
|
aggregated := c.Query("aggregated") == "true"
|
|
|
|
// Get device ID if provided
|
|
var deviceID *int
|
|
if deviceName != "" {
|
|
var deviceIDInt int
|
|
var query string
|
|
|
|
if database.IsPostgreSQLDB() {
|
|
query = `
|
|
SELECT DeviceID FROM "GpodderDevices"
|
|
WHERE UserID = $1 AND DeviceName = $2 AND IsActive = true
|
|
`
|
|
} else {
|
|
query = `
|
|
SELECT DeviceID FROM GpodderDevices
|
|
WHERE UserID = ? AND DeviceName = ? AND IsActive = true
|
|
`
|
|
}
|
|
|
|
err := database.QueryRow(query, userID, deviceName).Scan(&deviceIDInt)
|
|
|
|
if err != nil {
|
|
if err != sql.ErrNoRows {
|
|
log.Printf("[ERROR] getEpisodeActions: Error getting device: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"})
|
|
return
|
|
}
|
|
// Device not found is not fatal if querying by device
|
|
} else {
|
|
deviceID = &deviceIDInt
|
|
}
|
|
}
|
|
|
|
var since int64 = 0
|
|
if sinceStr != "" {
|
|
var err error
|
|
since, err = strconv.ParseInt(sinceStr, 10, 64)
|
|
if err != nil {
|
|
log.Printf("[ERROR] getEpisodeActions: Invalid since parameter: %s", sinceStr)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid since parameter: must be a Unix timestamp"})
|
|
return
|
|
}
|
|
}
|
|
|
|
// Get the latest timestamp for the response
|
|
var latestTimestamp int64
|
|
var timestampQuery string
|
|
|
|
if database.IsPostgreSQLDB() {
|
|
timestampQuery = `
|
|
SELECT COALESCE(MAX(Timestamp), EXTRACT(EPOCH FROM NOW())::bigint)
|
|
FROM "GpodderSyncEpisodeActions"
|
|
WHERE UserID = $1
|
|
`
|
|
} else {
|
|
timestampQuery = `
|
|
SELECT COALESCE(MAX(Timestamp), UNIX_TIMESTAMP())
|
|
FROM GpodderSyncEpisodeActions
|
|
WHERE UserID = ?
|
|
`
|
|
}
|
|
|
|
err := database.QueryRow(timestampQuery, userID).Scan(&latestTimestamp)
|
|
|
|
if err != nil {
|
|
log.Printf("[ERROR] getEpisodeActions: Error getting latest timestamp: %v", err)
|
|
latestTimestamp = time.Now().Unix() // Fallback to current time
|
|
}
|
|
|
|
// Performance optimization: Add limits and optimize query structure
|
|
const MAX_EPISODE_ACTIONS = 10000 // Reasonable limit for sync operations
|
|
|
|
// Log query performance info
|
|
log.Printf("[DEBUG] getEpisodeActions: Query for user %v with since=%d, device=%s, aggregated=%v",
|
|
userID, since, deviceName, aggregated)
|
|
|
|
// Build query based on parameters with performance optimizations
|
|
var queryParts []string
|
|
|
|
if database.IsPostgreSQLDB() {
|
|
queryParts = []string{
|
|
"SELECT " +
|
|
"e.ActionID, e.UserID, e.DeviceID, e.PodcastURL, e.EpisodeURL, " +
|
|
"e.Action, e.Timestamp, e.Started, e.Position, e.Total, " +
|
|
"COALESCE(d.DeviceName, '') as DeviceName " +
|
|
"FROM \"GpodderSyncEpisodeActions\" e " +
|
|
"LEFT JOIN \"GpodderDevices\" d ON e.DeviceID = d.DeviceID " +
|
|
"WHERE e.UserID = $1",
|
|
}
|
|
} else {
|
|
queryParts = []string{
|
|
"SELECT " +
|
|
"e.ActionID, e.UserID, e.DeviceID, e.PodcastURL, e.EpisodeURL, " +
|
|
"e.Action, e.Timestamp, e.Started, e.Position, e.Total, " +
|
|
"COALESCE(d.DeviceName, '') as DeviceName " +
|
|
"FROM GpodderSyncEpisodeActions e " +
|
|
"LEFT JOIN GpodderDevices d ON e.DeviceID = d.DeviceID " +
|
|
"WHERE e.UserID = ?",
|
|
}
|
|
}
|
|
|
|
args := []interface{}{userID}
|
|
paramCount := 2
|
|
|
|
// For aggregated results, we need a more complex query
|
|
var query string
|
|
if aggregated {
|
|
if database.IsPostgreSQLDB() {
|
|
// Build conditions for the subquery
|
|
var conditions []string
|
|
|
|
if since > 0 {
|
|
conditions = append(conditions, fmt.Sprintf("AND e.Timestamp > $%d", paramCount))
|
|
args = append(args, since)
|
|
paramCount++
|
|
}
|
|
|
|
if podcastURL != "" {
|
|
conditions = append(conditions, fmt.Sprintf("AND e.PodcastURL = $%d", paramCount))
|
|
args = append(args, podcastURL)
|
|
paramCount++
|
|
}
|
|
|
|
if deviceID != nil {
|
|
conditions = append(conditions, fmt.Sprintf("AND e.DeviceID = $%d", paramCount))
|
|
args = append(args, *deviceID)
|
|
paramCount++
|
|
}
|
|
|
|
conditionsStr := strings.Join(conditions, " ")
|
|
|
|
query = fmt.Sprintf(`
|
|
WITH latest_actions AS (
|
|
SELECT
|
|
e.PodcastURL,
|
|
e.EpisodeURL,
|
|
MAX(e.Timestamp) as max_timestamp
|
|
FROM "GpodderSyncEpisodeActions" e
|
|
WHERE e.UserID = $1
|
|
%s
|
|
GROUP BY e.PodcastURL, e.EpisodeURL
|
|
)
|
|
SELECT
|
|
e.ActionID, e.UserID, e.DeviceID, e.PodcastURL, e.EpisodeURL,
|
|
e.Action, e.Timestamp, e.Started, e.Position, e.Total,
|
|
d.DeviceName
|
|
FROM "GpodderSyncEpisodeActions" e
|
|
JOIN latest_actions la ON
|
|
e.PodcastURL = la.PodcastURL AND
|
|
e.EpisodeURL = la.EpisodeURL AND
|
|
e.Timestamp = la.max_timestamp
|
|
LEFT JOIN "GpodderDevices" d ON e.DeviceID = d.DeviceID
|
|
WHERE e.UserID = $1
|
|
ORDER BY e.Timestamp DESC
|
|
LIMIT %d
|
|
`, conditionsStr, MAX_EPISODE_ACTIONS)
|
|
} else {
|
|
// For MySQL, we need to use ? placeholders and rebuild the argument list
|
|
args = []interface{}{userID} // Reset args to just include userID for now
|
|
|
|
// Build conditions for the subquery
|
|
var conditions []string
|
|
|
|
if since > 0 {
|
|
conditions = append(conditions, "AND e.Timestamp > ?")
|
|
args = append(args, since)
|
|
}
|
|
|
|
if podcastURL != "" {
|
|
conditions = append(conditions, "AND e.PodcastURL = ?")
|
|
args = append(args, podcastURL)
|
|
}
|
|
|
|
if deviceID != nil {
|
|
conditions = append(conditions, "AND e.DeviceID = ?")
|
|
args = append(args, *deviceID)
|
|
}
|
|
|
|
conditionsStr := strings.Join(conditions, " ")
|
|
|
|
// Need to duplicate userID in args for the second part of the query
|
|
mysqlArgs := make([]interface{}, len(args))
|
|
copy(mysqlArgs, args)
|
|
args = append(args, mysqlArgs...)
|
|
|
|
query = fmt.Sprintf(`
|
|
WITH latest_actions AS (
|
|
SELECT
|
|
e.PodcastURL,
|
|
e.EpisodeURL,
|
|
MAX(e.Timestamp) as max_timestamp
|
|
FROM GpodderSyncEpisodeActions e
|
|
WHERE e.UserID = ?
|
|
%s
|
|
GROUP BY e.PodcastURL, e.EpisodeURL
|
|
)
|
|
SELECT
|
|
e.ActionID, e.UserID, e.DeviceID, e.PodcastURL, e.EpisodeURL,
|
|
e.Action, e.Timestamp, e.Started, e.Position, e.Total,
|
|
d.DeviceName
|
|
FROM GpodderSyncEpisodeActions e
|
|
JOIN latest_actions la ON
|
|
e.PodcastURL = la.PodcastURL AND
|
|
e.EpisodeURL = la.EpisodeURL AND
|
|
e.Timestamp = la.max_timestamp
|
|
LEFT JOIN GpodderDevices d ON e.DeviceID = d.DeviceID
|
|
WHERE e.UserID = ?
|
|
ORDER BY e.Timestamp DESC
|
|
LIMIT %d
|
|
`, conditionsStr, MAX_EPISODE_ACTIONS)
|
|
}
|
|
} else {
|
|
// Simple query with ORDER BY
|
|
if database.IsPostgreSQLDB() {
|
|
if since > 0 {
|
|
queryParts = append(queryParts, fmt.Sprintf("AND e.Timestamp > $%d", paramCount))
|
|
args = append(args, since)
|
|
paramCount++
|
|
}
|
|
|
|
if podcastURL != "" {
|
|
queryParts = append(queryParts, fmt.Sprintf("AND e.PodcastURL = $%d", paramCount))
|
|
args = append(args, podcastURL)
|
|
paramCount++
|
|
}
|
|
|
|
if deviceID != nil {
|
|
queryParts = append(queryParts, fmt.Sprintf("AND e.DeviceID = $%d", paramCount))
|
|
args = append(args, *deviceID)
|
|
paramCount++
|
|
}
|
|
} else {
|
|
if since > 0 {
|
|
queryParts = append(queryParts, "AND e.Timestamp > ?")
|
|
args = append(args, since)
|
|
}
|
|
|
|
if podcastURL != "" {
|
|
queryParts = append(queryParts, "AND e.PodcastURL = ?")
|
|
args = append(args, podcastURL)
|
|
}
|
|
|
|
if deviceID != nil {
|
|
queryParts = append(queryParts, "AND e.DeviceID = ?")
|
|
args = append(args, *deviceID)
|
|
}
|
|
}
|
|
|
|
queryParts = append(queryParts, "ORDER BY e.Timestamp DESC")
|
|
|
|
// Add LIMIT for performance - prevents returning massive datasets
|
|
if database.IsPostgreSQLDB() {
|
|
queryParts = append(queryParts, fmt.Sprintf("LIMIT %d", MAX_EPISODE_ACTIONS))
|
|
} else {
|
|
queryParts = append(queryParts, fmt.Sprintf("LIMIT %d", MAX_EPISODE_ACTIONS))
|
|
}
|
|
|
|
query = strings.Join(queryParts, " ")
|
|
}
|
|
|
|
// Execute query with timing
|
|
startTime := time.Now()
|
|
rows, err := database.Query(query, args...)
|
|
queryDuration := time.Since(startTime)
|
|
|
|
if err != nil {
|
|
log.Printf("[ERROR] getEpisodeActions: Error querying episode actions (took %v): %v", queryDuration, err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get episode actions"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
log.Printf("[DEBUG] getEpisodeActions: Query executed in %v", queryDuration)
|
|
|
|
// Build response
|
|
actions := make([]models.EpisodeAction, 0)
|
|
for rows.Next() {
|
|
var action models.EpisodeAction
|
|
var deviceIDInt sql.NullInt64
|
|
var deviceName sql.NullString
|
|
var started sql.NullInt64
|
|
var position sql.NullInt64
|
|
var total sql.NullInt64
|
|
|
|
if err := rows.Scan(
|
|
&action.ActionID,
|
|
&action.UserID,
|
|
&deviceIDInt,
|
|
&action.Podcast,
|
|
&action.Episode,
|
|
&action.Action,
|
|
&action.Timestamp,
|
|
&started,
|
|
&position,
|
|
&total,
|
|
&deviceName,
|
|
); err != nil {
|
|
log.Printf("[ERROR] getEpisodeActions: Error scanning action row: %v", err)
|
|
continue
|
|
}
|
|
|
|
// Set optional fields if present
|
|
if deviceName.Valid {
|
|
action.Device = deviceName.String
|
|
}
|
|
|
|
if started.Valid {
|
|
startedInt := int(started.Int64)
|
|
action.Started = &startedInt
|
|
}
|
|
|
|
if position.Valid {
|
|
positionInt := int(position.Int64)
|
|
action.Position = &positionInt
|
|
}
|
|
|
|
if total.Valid {
|
|
totalInt := int(total.Int64)
|
|
action.Total = &totalInt
|
|
}
|
|
|
|
actions = append(actions, action)
|
|
}
|
|
|
|
if err = rows.Err(); err != nil {
|
|
log.Printf("[ERROR] getEpisodeActions: Error iterating rows: %v", err)
|
|
// Continue with what we've got so far
|
|
}
|
|
|
|
// Log performance results
|
|
totalDuration := time.Since(startTime)
|
|
log.Printf("[DEBUG] getEpisodeActions: Returning %d actions, total time: %v", len(actions), totalDuration)
|
|
|
|
// Return response in gpodder format
|
|
c.JSON(http.StatusOK, models.EpisodeActionsResponse{
|
|
Actions: actions,
|
|
Timestamp: latestTimestamp,
|
|
})
|
|
}
|
|
}
|
|
|
|
// uploadEpisodeActions handles POST /api/2/episodes/{username}.json
|
|
func uploadEpisodeActions(database *db.Database) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
|
|
// Get user ID from middleware
|
|
userID, exists := c.Get("userID")
|
|
if !exists {
|
|
log.Printf("[ERROR] uploadEpisodeActions: userID not found in context")
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"})
|
|
return
|
|
}
|
|
|
|
// Parse request - try both formats
|
|
var actions []models.EpisodeAction
|
|
|
|
// First try parsing as array directly
|
|
if err := c.ShouldBindJSON(&actions); err != nil {
|
|
// If that fails, try parsing as a wrapper object
|
|
var wrappedActions struct {
|
|
Actions []models.EpisodeAction `json:"actions"`
|
|
}
|
|
if err2 := c.ShouldBindJSON(&wrappedActions); err2 != nil {
|
|
log.Printf("[ERROR] uploadEpisodeActions: Error parsing request body: %v", err)
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body format"})
|
|
return
|
|
}
|
|
actions = wrappedActions.Actions
|
|
}
|
|
|
|
// Begin transaction
|
|
tx, err := database.Begin()
|
|
if err != nil {
|
|
log.Printf("[ERROR] uploadEpisodeActions: Error beginning transaction: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"})
|
|
return
|
|
}
|
|
defer func() {
|
|
if err != nil {
|
|
tx.Rollback()
|
|
return
|
|
}
|
|
}()
|
|
|
|
// Process actions
|
|
timestamp := time.Now().Unix()
|
|
updateURLs := make([][]string, 0)
|
|
|
|
for _, action := range actions {
|
|
// Validate action
|
|
if action.Podcast == "" || action.Episode == "" || action.Action == "" {
|
|
log.Printf("[WARNING] uploadEpisodeActions: Skipping invalid action: podcast=%s, episode=%s, action=%s",
|
|
action.Podcast, action.Episode, action.Action)
|
|
continue
|
|
}
|
|
|
|
// Clean URLs if needed
|
|
cleanPodcastURL, err := sanitizeURL(action.Podcast)
|
|
if err != nil {
|
|
log.Printf("[WARNING] uploadEpisodeActions: Error sanitizing podcast URL %s: %v", action.Podcast, err)
|
|
cleanPodcastURL = action.Podcast // Use original if sanitization fails
|
|
}
|
|
|
|
cleanEpisodeURL, err := sanitizeURL(action.Episode)
|
|
if err != nil {
|
|
log.Printf("[WARNING] uploadEpisodeActions: Error sanitizing episode URL %s: %v", action.Episode, err)
|
|
cleanEpisodeURL = action.Episode // Use original if sanitization fails
|
|
}
|
|
|
|
// Get or create device ID if provided
|
|
var deviceID sql.NullInt64
|
|
if action.Device != "" {
|
|
var query string
|
|
|
|
if database.IsPostgreSQLDB() {
|
|
query = `
|
|
SELECT DeviceID FROM "GpodderDevices"
|
|
WHERE UserID = $1 AND DeviceName = $2
|
|
`
|
|
} else {
|
|
query = `
|
|
SELECT DeviceID FROM GpodderDevices
|
|
WHERE UserID = ? AND DeviceName = ?
|
|
`
|
|
}
|
|
|
|
err := tx.QueryRow(query, userID, action.Device).Scan(&deviceID.Int64)
|
|
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
// Create the device if it doesn't exist
|
|
|
|
if database.IsPostgreSQLDB() {
|
|
query = `
|
|
INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync)
|
|
VALUES ($1, $2, 'other', true, CURRENT_TIMESTAMP)
|
|
RETURNING DeviceID
|
|
`
|
|
err = tx.QueryRow(query, userID, action.Device).Scan(&deviceID.Int64)
|
|
} else {
|
|
query = `
|
|
INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync)
|
|
VALUES (?, ?, 'other', true, CURRENT_TIMESTAMP)
|
|
`
|
|
result, err := tx.Exec(query, userID, action.Device, "other")
|
|
if err != nil {
|
|
log.Printf("[ERROR] uploadEpisodeActions: Error creating device: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
|
|
return
|
|
}
|
|
|
|
lastID, err := result.LastInsertId()
|
|
if err != nil {
|
|
log.Printf("[ERROR] uploadEpisodeActions: Error getting last insert ID: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
|
|
return
|
|
}
|
|
|
|
deviceID.Int64 = lastID
|
|
}
|
|
|
|
if err != nil {
|
|
log.Printf("[ERROR] uploadEpisodeActions: Error creating device: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"})
|
|
return
|
|
}
|
|
deviceID.Valid = true
|
|
} else {
|
|
log.Printf("[ERROR] uploadEpisodeActions: Error getting device ID: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"})
|
|
return
|
|
}
|
|
} else {
|
|
deviceID.Valid = true
|
|
}
|
|
}
|
|
|
|
// Parse timestamp from interface{} to int64
|
|
actionTimestamp := timestamp
|
|
if action.Timestamp != nil {
|
|
switch t := action.Timestamp.(type) {
|
|
case float64:
|
|
actionTimestamp = int64(t)
|
|
case int64:
|
|
actionTimestamp = t
|
|
case int:
|
|
actionTimestamp = int64(t)
|
|
case string:
|
|
// First try to parse as Unix timestamp
|
|
if ts, err := strconv.ParseInt(t, 10, 64); err == nil {
|
|
actionTimestamp = ts
|
|
} else {
|
|
// Try parsing as ISO date (2025-04-23T12:18:51)
|
|
if parsedTime, err := time.Parse(time.RFC3339, t); err == nil {
|
|
actionTimestamp = parsedTime.Unix()
|
|
log.Printf("[DEBUG] uploadEpisodeActions: Parsed ISO timestamp '%s' to Unix timestamp %d", t, actionTimestamp)
|
|
} else {
|
|
// Try some other common formats
|
|
formats := []string{
|
|
"2006-01-02T15:04:05Z",
|
|
"2006-01-02T15:04:05",
|
|
"2006-01-02 15:04:05",
|
|
"2006-01-02",
|
|
}
|
|
|
|
parsed := false
|
|
for _, format := range formats {
|
|
if parsedTime, err := time.Parse(format, t); err == nil {
|
|
actionTimestamp = parsedTime.Unix()
|
|
parsed = true
|
|
break
|
|
}
|
|
}
|
|
|
|
if !parsed {
|
|
log.Printf("[WARNING] uploadEpisodeActions: Could not parse timestamp '%s', using current time", t)
|
|
}
|
|
}
|
|
}
|
|
default:
|
|
log.Printf("[WARNING] uploadEpisodeActions: Unknown timestamp type, using current time")
|
|
}
|
|
}
|
|
|
|
// Insert action
|
|
var insertQuery string
|
|
|
|
if database.IsPostgreSQLDB() {
|
|
insertQuery = `
|
|
INSERT INTO "GpodderSyncEpisodeActions"
|
|
(UserID, DeviceID, PodcastURL, EpisodeURL, Action, Timestamp, Started, Position, Total)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
`
|
|
} else {
|
|
insertQuery = `
|
|
INSERT INTO GpodderSyncEpisodeActions
|
|
(UserID, DeviceID, PodcastURL, EpisodeURL, Action, Timestamp, Started, Position, Total)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`
|
|
}
|
|
|
|
_, err = tx.Exec(insertQuery,
|
|
userID,
|
|
deviceID,
|
|
cleanPodcastURL,
|
|
cleanEpisodeURL,
|
|
action.Action,
|
|
actionTimestamp,
|
|
action.Started,
|
|
action.Position,
|
|
action.Total)
|
|
|
|
if err != nil {
|
|
log.Printf("[ERROR] uploadEpisodeActions: Error inserting episode action: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save episode action"})
|
|
return
|
|
}
|
|
|
|
// Add to updateURLs if URLs were cleaned
|
|
if cleanPodcastURL != action.Podcast {
|
|
updateURLs = append(updateURLs, []string{action.Podcast, cleanPodcastURL})
|
|
}
|
|
if cleanEpisodeURL != action.Episode {
|
|
updateURLs = append(updateURLs, []string{action.Episode, cleanEpisodeURL})
|
|
}
|
|
|
|
// For play action with position > 0, update episode status in Pinepods database
|
|
if action.Action == "play" && action.Position != nil && *action.Position > 0 {
|
|
// Try to find episode ID in Episodes table
|
|
var episodeID int
|
|
var findEpisodeQuery string
|
|
|
|
if database.IsPostgreSQLDB() {
|
|
findEpisodeQuery = `
|
|
SELECT e.EpisodeID
|
|
FROM "Episodes" e
|
|
JOIN "Podcasts" p ON e.PodcastID = p.PodcastID
|
|
WHERE p.FeedURL = $1 AND e.EpisodeURL = $2 AND p.UserID = $3
|
|
`
|
|
} else {
|
|
findEpisodeQuery = `
|
|
SELECT e.EpisodeID
|
|
FROM Episodes e
|
|
JOIN Podcasts p ON e.PodcastID = p.PodcastID
|
|
WHERE p.FeedURL = ? AND e.EpisodeURL = ? AND p.UserID = ?
|
|
`
|
|
}
|
|
|
|
err := tx.QueryRow(findEpisodeQuery, cleanPodcastURL, cleanEpisodeURL, userID).Scan(&episodeID)
|
|
|
|
if err == nil { // Episode found
|
|
// Try to update existing history record
|
|
var updateHistoryQuery string
|
|
|
|
if database.IsPostgreSQLDB() {
|
|
updateHistoryQuery = `
|
|
UPDATE "UserEpisodeHistory"
|
|
SET ListenDuration = $1, ListenDate = $2
|
|
WHERE UserID = $3 AND EpisodeID = $4
|
|
`
|
|
} else {
|
|
updateHistoryQuery = `
|
|
UPDATE UserEpisodeHistory
|
|
SET ListenDuration = ?, ListenDate = ?
|
|
WHERE UserID = ? AND EpisodeID = ?
|
|
`
|
|
}
|
|
|
|
result, err := tx.Exec(updateHistoryQuery, action.Position, time.Unix(actionTimestamp, 0), userID, episodeID)
|
|
|
|
if err != nil {
|
|
log.Printf("[WARNING] uploadEpisodeActions: Error updating episode history: %v", err)
|
|
} else {
|
|
rowsAffected, _ := result.RowsAffected()
|
|
if rowsAffected == 0 {
|
|
// No history exists, create it
|
|
var insertHistoryQuery string
|
|
|
|
if database.IsPostgreSQLDB() {
|
|
insertHistoryQuery = `
|
|
INSERT INTO "UserEpisodeHistory"
|
|
(UserID, EpisodeID, ListenDuration, ListenDate)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (UserID, EpisodeID) DO UPDATE
|
|
SET ListenDuration = $3, ListenDate = $4
|
|
`
|
|
} else {
|
|
insertHistoryQuery = `
|
|
INSERT INTO UserEpisodeHistory
|
|
(UserID, EpisodeID, ListenDuration, ListenDate)
|
|
VALUES (?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE
|
|
ListenDuration = VALUES(ListenDuration), ListenDate = VALUES(ListenDate)
|
|
`
|
|
}
|
|
|
|
_, err = tx.Exec(insertHistoryQuery, userID, episodeID, action.Position, time.Unix(actionTimestamp, 0))
|
|
|
|
if err != nil {
|
|
log.Printf("[WARNING] uploadEpisodeActions: Error creating episode history: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Commit transaction
|
|
if err = tx.Commit(); err != nil {
|
|
log.Printf("[ERROR] uploadEpisodeActions: Error committing transaction: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"})
|
|
return
|
|
}
|
|
|
|
// Return response
|
|
c.JSON(http.StatusOK, models.EpisodeActionResponse{
|
|
Timestamp: timestamp,
|
|
UpdateURLs: updateURLs,
|
|
})
|
|
}
|
|
}
|
|
|
|
// getFavoriteEpisodes handles GET /api/2/favorites/{username}.json
|
|
func getFavoriteEpisodes(database *db.Database) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// Get user ID from middleware
|
|
userID, _ := c.Get("userID")
|
|
|
|
// Query for favorite episodes
|
|
// Here we identify favorites by checking for episodes with the "is_favorite" setting
|
|
var query string
|
|
var rows *sql.Rows
|
|
var err error
|
|
|
|
if database.IsPostgreSQLDB() {
|
|
query = `
|
|
SELECT
|
|
e.EpisodeTitle, e.EpisodeURL, e.EpisodeDescription, e.EpisodeArtwork,
|
|
p.PodcastName, p.FeedURL, e.EpisodePubDate
|
|
FROM "Episodes" e
|
|
JOIN "Podcasts" p ON e.PodcastID = p.PodcastID
|
|
JOIN "GpodderSyncSettings" s ON s.UserID = p.UserID
|
|
AND s.PodcastURL = p.FeedURL
|
|
AND s.EpisodeURL = e.EpisodeURL
|
|
WHERE s.UserID = $1
|
|
AND s.Scope = 'episode'
|
|
AND s.SettingKey = 'is_favorite'
|
|
AND s.SettingValue = 'true'
|
|
ORDER BY e.EpisodePubDate DESC
|
|
`
|
|
rows, err = database.Query(query, userID)
|
|
} else {
|
|
query = `
|
|
SELECT
|
|
e.EpisodeTitle, e.EpisodeURL, e.EpisodeDescription, e.EpisodeArtwork,
|
|
p.PodcastName, p.FeedURL, e.EpisodePubDate
|
|
FROM Episodes e
|
|
JOIN Podcasts p ON e.PodcastID = p.PodcastID
|
|
JOIN GpodderSyncSettings s ON s.UserID = p.UserID
|
|
AND s.PodcastURL = p.FeedURL
|
|
AND s.EpisodeURL = e.EpisodeURL
|
|
WHERE s.UserID = ?
|
|
AND s.Scope = 'episode'
|
|
AND s.SettingKey = 'is_favorite'
|
|
AND s.SettingValue = 'true'
|
|
ORDER BY e.EpisodePubDate DESC
|
|
`
|
|
rows, err = database.Query(query, userID)
|
|
}
|
|
|
|
if err != nil {
|
|
log.Printf("Error querying favorite episodes: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get favorite episodes"})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
// Build response
|
|
favorites := make([]models.Episode, 0)
|
|
for rows.Next() {
|
|
var episode models.Episode
|
|
var pubDate time.Time
|
|
if err := rows.Scan(
|
|
&episode.Title,
|
|
&episode.URL,
|
|
&episode.Description,
|
|
&episode.Website, // Using EpisodeArtwork for Website for now
|
|
&episode.PodcastTitle,
|
|
&episode.PodcastURL,
|
|
&pubDate,
|
|
); err != nil {
|
|
log.Printf("Error scanning favorite episode: %v", err)
|
|
continue
|
|
}
|
|
// Format the publication date in ISO 8601
|
|
episode.Released = pubDate.Format(time.RFC3339)
|
|
// Set MygpoLink (just a placeholder for now)
|
|
episode.MygpoLink = fmt.Sprintf("/episode/%s", episode.URL)
|
|
favorites = append(favorites, episode)
|
|
}
|
|
c.JSON(http.StatusOK, favorites)
|
|
}
|
|
}
|
|
|
|
// getEpisodeData handles GET /api/2/data/episode.json
|
|
// getEpisodeData handles GET /api/2/data/episode.json
|
|
func getEpisodeData(database *db.Database) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
// Parse query parameters
|
|
podcastURL := c.Query("podcast")
|
|
episodeURL := c.Query("url")
|
|
if podcastURL == "" || episodeURL == "" {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Both podcast and url parameters are required"})
|
|
return
|
|
}
|
|
|
|
// Query for episode data
|
|
var episode models.Episode
|
|
var pubDate time.Time
|
|
var query string
|
|
|
|
if database.IsPostgreSQLDB() {
|
|
query = `
|
|
SELECT
|
|
e.EpisodeTitle, e.EpisodeURL, e.EpisodeDescription, e.EpisodeArtwork,
|
|
p.PodcastName, p.FeedURL, e.EpisodePubDate
|
|
FROM "Episodes" e
|
|
JOIN "Podcasts" p ON e.PodcastID = p.PodcastID
|
|
WHERE p.FeedURL = $1 AND e.EpisodeURL = $2
|
|
LIMIT 1
|
|
`
|
|
} else {
|
|
query = `
|
|
SELECT
|
|
e.EpisodeTitle, e.EpisodeURL, e.EpisodeDescription, e.EpisodeArtwork,
|
|
p.PodcastName, p.FeedURL, e.EpisodePubDate
|
|
FROM Episodes e
|
|
JOIN Podcasts p ON e.PodcastID = p.PodcastID
|
|
WHERE p.FeedURL = ? AND e.EpisodeURL = ?
|
|
LIMIT 1
|
|
`
|
|
}
|
|
|
|
err := database.QueryRow(query, podcastURL, episodeURL).Scan(
|
|
&episode.Title,
|
|
&episode.URL,
|
|
&episode.Description,
|
|
&episode.Website, // Using EpisodeArtwork for Website for now
|
|
&episode.PodcastTitle,
|
|
&episode.PodcastURL,
|
|
&pubDate,
|
|
)
|
|
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "Episode not found"})
|
|
} else {
|
|
log.Printf("Error querying episode data: %v", err)
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get episode data"})
|
|
}
|
|
return
|
|
}
|
|
|
|
// Format the publication date in ISO 8601
|
|
episode.Released = pubDate.Format(time.RFC3339)
|
|
// Set MygpoLink (just a placeholder for now)
|
|
episode.MygpoLink = fmt.Sprintf("/episode/%s", episode.URL)
|
|
c.JSON(http.StatusOK, episode)
|
|
}
|
|
}
|