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) } }