package api import ( "database/sql" "encoding/json" "fmt" "log" "net/http" "net/url" "regexp" "strconv" "strings" "time" "pinepods/gpodder-api/internal/db" "pinepods/gpodder-api/internal/models" "pinepods/gpodder-api/internal/utils" "github.com/gin-gonic/gin" ) // Maximum number of subscriptions per user const MAX_SUBSCRIPTIONS = 5000 // Limits for subscription sync to prevent overwhelming responses const MAX_SUBSCRIPTION_CHANGES = 5000 // Reasonable limit for subscription changes per sync // sanitizeURL cleans and validates a URL func sanitizeURL(rawURL string) (string, error) { // Trim leading/trailing whitespace trimmedURL := strings.TrimSpace(rawURL) // Check if URL is not empty if trimmedURL == "" { return "", fmt.Errorf("empty URL") } // Parse URL to validate format parsedURL, err := url.Parse(trimmedURL) if err != nil { return "", fmt.Errorf("invalid URL format: %w", err) } // Ensure the URL has a scheme, default to https if missing if parsedURL.Scheme == "" { parsedURL.Scheme = "https" } // Only allow http and https schemes if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { return "", fmt.Errorf("unsupported URL scheme: %s", parsedURL.Scheme) } // Ensure the URL has a host if parsedURL.Host == "" { return "", fmt.Errorf("URL missing host") } // Return the sanitized URL return parsedURL.String(), nil } // Fix for getSubscriptions function in subscriptions.go // Replace the entire getSubscriptions function with this implementation // getSubscriptions handles GET /api/2/subscriptions/{username}/{deviceid} func getSubscriptions(database *db.Database) gin.HandlerFunc { return func(c *gin.Context) { log.Printf("[DEBUG] getSubscriptions: Starting request processing - %s %s", c.Request.Method, c.Request.URL.Path) // Get user ID from middleware userID, exists := c.Get("userID") if !exists { log.Printf("[ERROR] getSubscriptions: userID not found in context") c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } log.Printf("[DEBUG] getSubscriptions: userID found: %v", userID) // Get device ID from URL - with fix for .json suffix deviceName := c.Param("deviceid") // Remove .json suffix if present if strings.HasSuffix(deviceName, ".json") { deviceName = strings.TrimSuffix(deviceName, ".json") } log.Printf("[DEBUG] getSubscriptions: Using device name: '%s'", deviceName) if deviceName == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required"}) return } // Check if this is a subscription changes request (has 'since' parameter) sinceStr := c.Query("since") if sinceStr != "" { // This is a subscription changes request var since int64 = 0 var err error since, err = strconv.ParseInt(sinceStr, 10, 64) if err != nil { log.Printf("[ERROR] getSubscriptions: Invalid since parameter: %s", sinceStr) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid since parameter"}) return } log.Printf("[DEBUG] getSubscriptions: Processing as subscription changes request with since: %d", since) // Get device ID from database var deviceID int 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 = database.QueryRow(query, userID, deviceName).Scan(&deviceID) if err != nil { if err == sql.ErrNoRows { // Device doesn't exist, create it log.Printf("[DEBUG] getSubscriptions: Device not found, creating new device") if database.IsPostgreSQLDB() { query = ` INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync) VALUES ($1, $2, 'other', true, CURRENT_TIMESTAMP) RETURNING DeviceID ` err = database.QueryRow(query, userID, deviceName).Scan(&deviceID) } else { query = ` INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync) VALUES (?, ?, 'other', true, CURRENT_TIMESTAMP) ` result, err := database.Exec(query, userID, deviceName) if err != nil { log.Printf("[ERROR] getSubscriptions: Failed to create 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] getSubscriptions: Failed to get last insert ID: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) return } deviceID = int(lastID) } if err != nil { log.Printf("[ERROR] getSubscriptions: Failed to create device: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) return } } else { log.Printf("[ERROR] getSubscriptions: Error getting device: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"}) return } } // If since is 0, this is likely the initial request and we should return all subscriptions if since == 0 { // Get all podcasts for this user var rows *sql.Rows if database.IsPostgreSQLDB() { query = `SELECT FeedURL FROM "Podcasts" WHERE UserID = $1` } else { query = `SELECT FeedURL FROM Podcasts WHERE UserID = ?` } rows, err = database.Query(query, userID) if err != nil { log.Printf("[ERROR] getSubscriptions: Error querying podcasts: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscriptions"}) return } defer rows.Close() // Build subscription list - ensure never nil podcasts := make([]string, 0) for rows.Next() { var url string if err := rows.Scan(&url); err != nil { log.Printf("[ERROR] getSubscriptions: Error scanning podcast URL: %v", err) continue } podcasts = append(podcasts, url) } if err = rows.Err(); err != nil { log.Printf("[ERROR] getSubscriptions: Error iterating podcast rows: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process subscriptions"}) return } // Update device's last sync time if database.IsPostgreSQLDB() { query = ` UPDATE "GpodderDevices" SET LastSync = CURRENT_TIMESTAMP WHERE DeviceID = $1 ` } else { query = ` UPDATE GpodderDevices SET LastSync = CURRENT_TIMESTAMP WHERE DeviceID = ? ` } _, err = database.Exec(query, deviceID) if err != nil { // Non-critical error, just log it log.Printf("[WARNING] Error updating device last sync time: %v", err) } // Return subscriptions in gpodder format, ensuring backward compatibility response := gin.H{ "add": podcasts, "remove": []string{}, "timestamp": time.Now().Unix(), } log.Printf("[DEBUG] getSubscriptions: Returning initial subscription list with %d podcasts", len(podcasts)) c.Header("Content-Type", "application/json") c.JSON(http.StatusOK, response) return } // Process actual changes since the timestamp // Query subscriptions added since the given timestamp - simplified for performance var addRows *sql.Rows if database.IsPostgreSQLDB() { query = ` SELECT s.PodcastURL FROM "GpodderSyncSubscriptions" s WHERE s.UserID = $1 AND s.DeviceID != $2 AND s.Timestamp > $3 AND s.Action = 'add' GROUP BY s.PodcastURL ORDER BY MAX(s.Timestamp) DESC LIMIT $4 ` log.Printf("[DEBUG] getSubscriptions: Executing add query with limit %d", MAX_SUBSCRIPTION_CHANGES) addRows, err = database.Query(query, userID, deviceID, since, MAX_SUBSCRIPTION_CHANGES) } else { query = ` SELECT s.PodcastURL FROM GpodderSyncSubscriptions s WHERE s.UserID = ? AND s.DeviceID != ? AND s.Timestamp > ? AND s.Action = 'add' GROUP BY s.PodcastURL ORDER BY MAX(s.Timestamp) DESC LIMIT ? ` log.Printf("[DEBUG] getSubscriptions: Executing add query with limit %d", MAX_SUBSCRIPTION_CHANGES) addRows, err = database.Query(query, userID, deviceID, since, MAX_SUBSCRIPTION_CHANGES) } if err != nil { log.Printf("[ERROR] getSubscriptions: Error querying podcasts to add: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscription changes"}) return } defer addRows.Close() // Ensure addList is never nil addList := make([]string, 0) for addRows.Next() { var url string if err := addRows.Scan(&url); err != nil { log.Printf("[ERROR] getSubscriptions: Error scanning podcast URL: %v", err) continue } addList = append(addList, url) } // Query subscriptions removed since the given timestamp - simplified for performance var removeRows *sql.Rows if database.IsPostgreSQLDB() { query = ` SELECT s.PodcastURL FROM "GpodderSyncSubscriptions" s WHERE s.UserID = $1 AND s.DeviceID != $2 AND s.Timestamp > $3 AND s.Action = 'remove' GROUP BY s.PodcastURL ORDER BY MAX(s.Timestamp) DESC LIMIT $4 ` removeRows, err = database.Query(query, userID, deviceID, since, MAX_SUBSCRIPTION_CHANGES) } else { query = ` SELECT s.PodcastURL FROM GpodderSyncSubscriptions s WHERE s.UserID = ? AND s.DeviceID != ? AND s.Timestamp > ? AND s.Action = 'remove' GROUP BY s.PodcastURL ORDER BY MAX(s.Timestamp) DESC LIMIT ? ` removeRows, err = database.Query(query, userID, deviceID, since, MAX_SUBSCRIPTION_CHANGES) } if err != nil { log.Printf("[ERROR] getSubscriptions: Error querying podcasts to remove: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscription changes"}) return } defer removeRows.Close() // Ensure removeList is never nil removeList := make([]string, 0) for removeRows.Next() { var url string if err := removeRows.Scan(&url); err != nil { log.Printf("[ERROR] getSubscriptions: Error scanning podcast URL: %v", err) continue } removeList = append(removeList, url) } timestamp := time.Now().Unix() // Update device's last sync time if database.IsPostgreSQLDB() { query = ` UPDATE "GpodderDevices" SET LastSync = CURRENT_TIMESTAMP WHERE DeviceID = $1 ` } else { query = ` UPDATE GpodderDevices SET LastSync = CURRENT_TIMESTAMP WHERE DeviceID = ? ` } _, err = database.Exec(query, deviceID) if err != nil { // Non-critical error, just log it log.Printf("[WARNING] Error updating device last sync time: %v", err) } response := gin.H{ "add": addList, "remove": removeList, "timestamp": timestamp, } log.Printf("[DEBUG] getSubscriptions: Returning subscription changes - add: %d, remove: %d, timestamp: %d", len(addList), len(removeList), timestamp) c.Header("Content-Type", "application/json") c.JSON(http.StatusOK, response) return } // Regular subscription list request // Get device ID from database var deviceID 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(&deviceID) if err != nil { if err == sql.ErrNoRows { // Device doesn't exist or is inactive log.Printf("[INFO] Device not found or inactive: UserID=%v, DeviceName=%s", userID, deviceName) // Create device automatically 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 = database.QueryRow(query, userID, deviceName).Scan(&deviceID) } else { query = ` INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync) VALUES (?, ?, 'other', true, CURRENT_TIMESTAMP) ` result, err := database.Exec(query, userID, deviceName) if err != nil { log.Printf("[ERROR] 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] Error getting last insert ID: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) return } deviceID = int(lastID) } if err != nil { log.Printf("[ERROR] Error creating device: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) return } log.Printf("[INFO] Created new device: UserID=%v, DeviceName=%s, DeviceID=%d", userID, deviceName, deviceID) // Return empty list for new device c.JSON(http.StatusOK, []string{}) return } log.Printf("[ERROR] Error getting device ID: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"}) return } // Get podcasts for this user var rows *sql.Rows if database.IsPostgreSQLDB() { query = `SELECT FeedURL FROM "Podcasts" WHERE UserID = $1` } else { query = `SELECT FeedURL FROM Podcasts WHERE UserID = ?` } rows, err = database.Query(query, userID) if err != nil { log.Printf("Error getting podcasts: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscriptions"}) return } defer rows.Close() // Build response - ensure never nil urls := make([]string, 0) for rows.Next() { var url string if err := rows.Scan(&url); err != nil { log.Printf("[ERROR] Error scanning podcast URL: %v", err) continue } urls = append(urls, url) } if err = rows.Err(); err != nil { log.Printf("Error iterating podcast rows: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process subscriptions"}) return } log.Printf("[DEBUG] Found %d podcast subscriptions in database for userID %v", len(urls), userID) // Update device's last sync time if database.IsPostgreSQLDB() { query = ` UPDATE "GpodderDevices" SET LastSync = CURRENT_TIMESTAMP WHERE DeviceID = $1 ` } else { query = ` UPDATE GpodderDevices SET LastSync = CURRENT_TIMESTAMP WHERE DeviceID = ? ` } _, err = database.Exec(query, deviceID) if err != nil { // Non-critical error, just log it log.Printf("Error updating device last sync time: %v", err) } // Log before returning log.Printf("[DEBUG] getSubscriptions: Returning %d subscription URLs to client", len(urls)) for i, url := range urls { if i < 5 { // Only log first 5 to avoid flooding logs log.Printf("[DEBUG] Subscription URL %d: %s", i, url) } } c.JSON(http.StatusOK, urls) } } // updateSubscriptions handles PUT /api/2/subscriptions/{username}/{deviceid}.json func updateSubscriptions(database *db.Database) gin.HandlerFunc { return func(c *gin.Context) { // Get user ID from middleware userID, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } // Get device ID from URL deviceName := c.Param("deviceid") if deviceName == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required"}) return } // Parse request body - should be a list of URLs var urls []string if err := c.ShouldBindJSON(&urls); err != nil { log.Printf("Error parsing request body: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: expected a JSON array of URLs"}) return } // Validate number of subscriptions if len(urls) > MAX_SUBSCRIPTIONS { c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("Too many subscriptions. Maximum allowed: %d", MAX_SUBSCRIPTIONS), }) return } // Get or create device var deviceID int 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 := database.QueryRow(query, userID, deviceName).Scan(&deviceID) if err != nil { if err == sql.ErrNoRows { // Device doesn't exist, create it if database.IsPostgreSQLDB() { query = ` INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync) VALUES ($1, $2, 'other', true, CURRENT_TIMESTAMP) RETURNING DeviceID ` err = database.QueryRow(query, userID, deviceName).Scan(&deviceID) } else { query = ` INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync) VALUES (?, ?, 'other', true, CURRENT_TIMESTAMP) ` result, err := database.Exec(query, userID, deviceName) if err != nil { log.Printf("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 getting last insert ID: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) return } deviceID = int(lastID) } if err != nil { log.Printf("Error creating device: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) return } } else { log.Printf("Error checking device existence: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check device"}) return } } // Begin transaction tx, err := database.Begin() if err != nil { log.Printf("Error beginning transaction: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"}) return } defer func() { if err != nil { tx.Rollback() } }() // Get existing subscriptions var rows *sql.Rows if database.IsPostgreSQLDB() { query = `SELECT FeedURL FROM "Podcasts" WHERE UserID = $1` } else { query = `SELECT FeedURL FROM Podcasts WHERE UserID = ?` } rows, err = tx.Query(query, userID) if err != nil { log.Printf("Error getting existing podcasts: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get existing subscriptions"}) return } // Build existing subscriptions map existing := make(map[string]bool) for rows.Next() { var url string if err := rows.Scan(&url); err != nil { log.Printf("Error scanning existing podcast URL: %v", err) continue } existing[url] = true } rows.Close() if err = rows.Err(); err != nil { log.Printf("Error iterating existing podcast rows: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process existing subscriptions"}) return } // Find URLs to add and remove toAdd := make([]string, 0) cleanURLMap := make(map[string]string) // Maps original URL to cleaned URL for _, url := range urls { // Clean and validate the URL cleanURL, err := sanitizeURL(url) if err != nil { log.Printf("Skipping invalid URL '%s': %v", url, err) continue } cleanURLMap[url] = cleanURL if !existing[cleanURL] { toAdd = append(toAdd, cleanURL) } // Remove from existing map to track what's left to delete delete(existing, cleanURL) } // Remaining URLs in 'existing' need to be removed toRemove := make([]string, 0, len(existing)) for url := range existing { toRemove = append(toRemove, url) } // Record subscription changes timestamp := time.Now().Unix() // Add new podcasts for _, url := range toAdd { // Insert into Podcasts table with minimal info if database.IsPostgreSQLDB() { query = ` INSERT INTO "Podcasts" (PodcastName, FeedURL, UserID) VALUES ($1, $2, $3) ON CONFLICT (UserID, FeedURL) DO NOTHING ` _, err = tx.Exec(query, url, url, userID) } else { query = ` INSERT IGNORE INTO Podcasts (PodcastName, FeedURL, UserID) VALUES (?, ?, ?) ` _, err = tx.Exec(query, url, url, userID) } if err != nil { log.Printf("Error adding podcast: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add podcast"}) return } // Record subscription change if database.IsPostgreSQLDB() { query = ` INSERT INTO "GpodderSyncSubscriptions" (UserID, DeviceID, PodcastURL, Action, Timestamp) VALUES ($1, $2, $3, 'add', $4) ` } else { query = ` INSERT INTO GpodderSyncSubscriptions (UserID, DeviceID, PodcastURL, Action, Timestamp) VALUES (?, ?, ?, 'add', ?) ` } _, err = tx.Exec(query, userID, deviceID, url, timestamp) if err != nil { log.Printf("Error recording subscription add: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record subscription change"}) return } } // Remove podcasts for _, url := range toRemove { // First delete related episodes and their dependencies to avoid foreign key constraint violations if database.IsPostgreSQLDB() { // Delete related data in correct order for PostgreSQL deleteQueries := []string{ `DELETE FROM "PlaylistContents" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, `DELETE FROM "UserEpisodeHistory" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, `DELETE FROM "DownloadedEpisodes" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, `DELETE FROM "SavedEpisodes" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, `DELETE FROM "EpisodeQueue" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, `DELETE FROM "YouTubeVideos" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2)`, `DELETE FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2)`, `DELETE FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2`, } for _, deleteQuery := range deleteQueries { _, err = tx.Exec(deleteQuery, userID, url) if err != nil { log.Printf("Error executing delete query: %v", err) break } } } else { // Delete related data in correct order for MySQL/MariaDB deleteQueries := []string{ `DELETE FROM PlaylistContents WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, `DELETE FROM UserEpisodeHistory WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, `DELETE FROM DownloadedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, `DELETE FROM SavedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, `DELETE FROM EpisodeQueue WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, `DELETE FROM YouTubeVideos WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?)`, `DELETE FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?)`, `DELETE FROM Podcasts WHERE UserID = ? AND FeedURL = ?`, } for _, deleteQuery := range deleteQueries { _, err = tx.Exec(deleteQuery, userID, url) if err != nil { log.Printf("Error executing delete query: %v", err) break } } } if err != nil { log.Printf("Error removing podcast: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove podcast"}) return } // Record subscription change if database.IsPostgreSQLDB() { query = ` INSERT INTO "GpodderSyncSubscriptions" (UserID, DeviceID, PodcastURL, Action, Timestamp) VALUES ($1, $2, $3, 'remove', $4) ` } else { query = ` INSERT INTO GpodderSyncSubscriptions (UserID, DeviceID, PodcastURL, Action, Timestamp) VALUES (?, ?, ?, 'remove', ?) ` } _, err = tx.Exec(query, userID, deviceID, url, timestamp) if err != nil { log.Printf("Error recording subscription remove: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record subscription change"}) return } } // Update device's last sync time if database.IsPostgreSQLDB() { query = ` UPDATE "GpodderDevices" SET LastSync = CURRENT_TIMESTAMP WHERE DeviceID = $1 ` } else { query = ` UPDATE GpodderDevices SET LastSync = CURRENT_TIMESTAMP WHERE DeviceID = ? ` } _, err = tx.Exec(query, deviceID) if err != nil { log.Printf("Error updating device last sync time: %v", err) // Non-critical error, continue with transaction } // Commit transaction if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"}) return } // Return success c.Status(http.StatusOK) } } // Updated version of uploadSubscriptionChanges to ensure update_urls is always in the response func uploadSubscriptionChanges(database *db.Database) gin.HandlerFunc { return func(c *gin.Context) { log.Printf("[DEBUG] uploadSubscriptionChanges: Processing request: %s %s", c.Request.Method, c.Request.URL.Path) // Get parameters userID, exists := c.Get("userID") if !exists { log.Printf("[ERROR] uploadSubscriptionChanges: userID not found in context") c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } username := c.Param("username") deviceName := c.Param("deviceid") // Remove .json suffix if present if strings.HasSuffix(deviceName, ".json") { deviceName = strings.TrimSuffix(deviceName, ".json") } log.Printf("[DEBUG] uploadSubscriptionChanges: For user %s (ID: %v), device: %s", username, userID, deviceName) // Parse request var changes models.SubscriptionChange if err := c.ShouldBindJSON(&changes); err != nil { log.Printf("[ERROR] uploadSubscriptionChanges: Failed to parse request body: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: expected a JSON object with 'add' and 'remove' arrays"}) return } log.Printf("[DEBUG] uploadSubscriptionChanges: Received changes - add: %d, remove: %d", len(changes.Add), len(changes.Remove)) // Validate request (ensure no duplicate URLs between add and remove) addMap := make(map[string]bool) for _, url := range changes.Add { addMap[url] = true } for _, url := range changes.Remove { if addMap[url] { c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("URL appears in both 'add' and 'remove' arrays: %s", url), }) return } } // Validate number of subscriptions if len(changes.Add) > MAX_SUBSCRIPTIONS || len(changes.Remove) > MAX_SUBSCRIPTIONS { c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("Too many subscriptions in request. Maximum allowed: %d", MAX_SUBSCRIPTIONS), }) return } // Get or create device var deviceID int 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 := database.QueryRow(query, userID, deviceName).Scan(&deviceID) if err != nil { if err == sql.ErrNoRows { // Device doesn't exist, create it log.Printf("[DEBUG] uploadSubscriptionChanges: Creating new device for user %v: %s", userID, deviceName) if database.IsPostgreSQLDB() { query = ` INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync) VALUES ($1, $2, 'other', true, CURRENT_TIMESTAMP) RETURNING DeviceID ` err = database.QueryRow(query, userID, deviceName).Scan(&deviceID) } else { query = ` INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync) VALUES (?, ?, 'other', true, CURRENT_TIMESTAMP) ` result, err := database.Exec(query, userID, deviceName) if err != nil { log.Printf("[ERROR] uploadSubscriptionChanges: 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] uploadSubscriptionChanges: Error getting last insert ID: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) return } deviceID = int(lastID) } if err != nil { log.Printf("[ERROR] uploadSubscriptionChanges: Error creating device: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) return } log.Printf("[DEBUG] uploadSubscriptionChanges: Created new device with ID: %d", deviceID) } else { log.Printf("[ERROR] uploadSubscriptionChanges: Error checking device existence: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check device"}) return } } else { log.Printf("[DEBUG] uploadSubscriptionChanges: Using existing device with ID: %d", deviceID) } // Begin transaction tx, err := database.Begin() if err != nil { log.Printf("[ERROR] uploadSubscriptionChanges: Error beginning transaction: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"}) return } defer func() { if err != nil { tx.Rollback() } }() // Process subscriptions to add timestamp := time.Now().Unix() updateURLs := make([][]string, 0) // Ensure never nil for _, url := range changes.Add { // Clean URL cleanURL, err := sanitizeURL(url) if err != nil { log.Printf("[WARNING] uploadSubscriptionChanges: Skipping invalid URL in 'add' array: %s - %v", url, err) continue } // Record changes to database if database.IsPostgreSQLDB() { query = ` INSERT INTO "GpodderSyncSubscriptions" (UserID, DeviceID, PodcastURL, Action, Timestamp) VALUES ($1, $2, $3, 'add', $4) ` } else { query = ` INSERT INTO GpodderSyncSubscriptions (UserID, DeviceID, PodcastURL, Action, Timestamp) VALUES (?, ?, ?, 'add', ?) ` } _, err = tx.Exec(query, userID, deviceID, cleanURL, timestamp) if err != nil { log.Printf("[ERROR] uploadSubscriptionChanges: Error recording subscription add: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record subscription change"}) return } // Check if podcast already exists for this user var podcastExists bool if database.IsPostgreSQLDB() { query = ` SELECT EXISTS(SELECT 1 FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2) ` } else { query = ` SELECT EXISTS(SELECT 1 FROM Podcasts WHERE UserID = ? AND FeedURL = ?) ` } err = tx.QueryRow(query, userID, cleanURL).Scan(&podcastExists) if err != nil { log.Printf("[ERROR] uploadSubscriptionChanges: Error checking podcast existence: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check podcast existence"}) return } // Add to Podcasts table if it doesn't exist if !podcastExists { // Fetch podcast metadata from the feed podcastValues, err := utils.GetPodcastValues(cleanURL, userID.(int), "", "") if err != nil { log.Printf("[WARNING] uploadSubscriptionChanges: Error fetching podcast metadata from %s: %v", cleanURL, err) // Continue with minimal data if we can't fetch full metadata } // Use default values if fetch failed if podcastValues == nil { // Insert minimal data if database.IsPostgreSQLDB() { query = ` INSERT INTO "Podcasts" (PodcastName, FeedURL, UserID) VALUES ($1, $2, $3) ` } else { query = ` INSERT INTO Podcasts (PodcastName, FeedURL, UserID) VALUES (?, ?, ?) ` } _, err = tx.Exec(query, cleanURL, cleanURL, userID) } else { // Insert with full metadata if database.IsPostgreSQLDB() { query = ` INSERT INTO "Podcasts" ( PodcastName, ArtworkURL, Author, Categories, Description, EpisodeCount, FeedURL, WebsiteURL, Explicit, UserID ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ` } else { query = ` INSERT INTO Podcasts ( PodcastName, ArtworkURL, Author, Categories, Description, EpisodeCount, FeedURL, WebsiteURL, Explicit, UserID ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` } explicit := 0 if podcastValues.Explicit { explicit = 1 } _, err = tx.Exec( query, podcastValues.Title, podcastValues.ArtworkURL, podcastValues.Author, podcastValues.Categories, podcastValues.Description, podcastValues.EpisodeCount, cleanURL, podcastValues.WebsiteURL, explicit, userID, ) } if err != nil { log.Printf("[ERROR] uploadSubscriptionChanges: Error adding podcast: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add podcast"}) return } } // If URL was cleaned, add to updateURLs if cleanURL != url { updateURLs = append(updateURLs, []string{url, cleanURL}) } } // Process subscriptions to remove for _, url := range changes.Remove { // Clean URL cleanURL, err := sanitizeURL(url) if err != nil { log.Printf("[WARNING] uploadSubscriptionChanges: Skipping invalid URL in 'remove' array: %s - %v", url, err) continue } // Record changes to database if database.IsPostgreSQLDB() { query = ` INSERT INTO "GpodderSyncSubscriptions" (UserID, DeviceID, PodcastURL, Action, Timestamp) VALUES ($1, $2, $3, 'remove', $4) ` } else { query = ` INSERT INTO GpodderSyncSubscriptions (UserID, DeviceID, PodcastURL, Action, Timestamp) VALUES (?, ?, ?, 'remove', ?) ` } _, err = tx.Exec(query, userID, deviceID, cleanURL, timestamp) if err != nil { log.Printf("[ERROR] uploadSubscriptionChanges: Error recording subscription remove: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record subscription change"}) return } // First delete related episodes and their dependencies to avoid foreign key constraint violations if database.IsPostgreSQLDB() { // Delete related data in correct order for PostgreSQL deleteQueries := []string{ `DELETE FROM "PlaylistContents" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, `DELETE FROM "UserEpisodeHistory" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, `DELETE FROM "DownloadedEpisodes" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, `DELETE FROM "SavedEpisodes" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, `DELETE FROM "EpisodeQueue" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, `DELETE FROM "YouTubeVideos" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2)`, `DELETE FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2)`, `DELETE FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2`, } for _, deleteQuery := range deleteQueries { _, err = tx.Exec(deleteQuery, userID, cleanURL) if err != nil { log.Printf("[ERROR] uploadSubscriptionChanges: Error executing delete query: %v", err) break } } } else { // Delete related data in correct order for MySQL/MariaDB deleteQueries := []string{ `DELETE FROM PlaylistContents WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, `DELETE FROM UserEpisodeHistory WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, `DELETE FROM DownloadedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, `DELETE FROM SavedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, `DELETE FROM EpisodeQueue WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, `DELETE FROM YouTubeVideos WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?)`, `DELETE FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?)`, `DELETE FROM Podcasts WHERE UserID = ? AND FeedURL = ?`, } for _, deleteQuery := range deleteQueries { _, err = tx.Exec(deleteQuery, userID, cleanURL) if err != nil { log.Printf("[ERROR] uploadSubscriptionChanges: Error executing delete query: %v", err) break } } } if err != nil { log.Printf("[ERROR] uploadSubscriptionChanges: Error removing podcast: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove podcast"}) return } // If URL was cleaned, add to updateURLs if cleanURL != url { updateURLs = append(updateURLs, []string{url, cleanURL}) } } // Update device's last sync time if database.IsPostgreSQLDB() { query = ` UPDATE "GpodderDevices" SET LastSync = CURRENT_TIMESTAMP WHERE DeviceID = $1 ` } else { query = ` UPDATE GpodderDevices SET LastSync = CURRENT_TIMESTAMP WHERE DeviceID = ? ` } _, err = tx.Exec(query, deviceID) if err != nil { log.Printf("[WARNING] uploadSubscriptionChanges: Error updating device last sync time: %v", err) // Non-critical error, continue with transaction } // Commit transaction if err := tx.Commit(); err != nil { log.Printf("[ERROR] uploadSubscriptionChanges: Error committing transaction: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"}) return } log.Printf("[DEBUG] uploadSubscriptionChanges: Successfully processed changes - add: %d, remove: %d", len(changes.Add), len(changes.Remove)) // CRITICAL: Always include update_urls in response, even if empty // AntennaPod specifically checks for existence of this field var response gin.H if updateURLs == nil || len(updateURLs) == 0 { // Ensure an empty array is returned, not null or missing response = gin.H{ "timestamp": timestamp, "update_urls": [][]string{}, // Empty array } } else { response = gin.H{ "timestamp": timestamp, "update_urls": updateURLs, } } log.Printf("[DEBUG] uploadSubscriptionChanges: Returning response with timestamp %d and %d update URLs", timestamp, len(updateURLs)) // Return response c.JSON(http.StatusOK, response) } } // getAllSubscriptions handles GET /api/2/subscriptions/{username}.json func getAllSubscriptions(database *db.Database) gin.HandlerFunc { return func(c *gin.Context) { // Get user ID from middleware userID, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } // Get all podcasts for this user var query string if database.IsPostgreSQLDB() { query = `SELECT FeedURL FROM "Podcasts" WHERE UserID = $1` } else { query = `SELECT FeedURL FROM Podcasts WHERE UserID = ?` } rows, err := database.Query(query, userID) if err != nil { log.Printf("Error getting podcasts: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscriptions"}) return } defer rows.Close() // Build response - ensure never nil urls := make([]string, 0) for rows.Next() { var url string if err := rows.Scan(&url); err != nil { log.Printf("Error scanning podcast URL: %v", err) continue } urls = append(urls, url) } if err = rows.Err(); err != nil { log.Printf("Error iterating podcast rows: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process subscriptions"}) return } c.JSON(http.StatusOK, urls) } } // getSubscriptionsSimple handles GET /subscriptions/{username}/{deviceid}.{format} func getSubscriptionsSimple(database *db.Database) gin.HandlerFunc { return func(c *gin.Context) { // Get format from URL format := c.Param("format") if format == "" { format = "json" // Default format } // Get user ID from middleware userID, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } // Get device ID from URL deviceName := c.Param("deviceid") if deviceName == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required"}) return } // Get device ID from database var deviceID int var err error if database.IsPostgreSQLDB() { err = database.QueryRow(` SELECT DeviceID FROM "GpodderDevices" WHERE UserID = $1 AND DeviceName = $2 AND IsActive = true `, userID, deviceName).Scan(&deviceID) } else { err = database.QueryRow(` SELECT DeviceID FROM GpodderDevices WHERE UserID = ? AND DeviceName = ? AND IsActive = true `, userID, deviceName).Scan(&deviceID) } if err != nil { if err == sql.ErrNoRows { // Device doesn't exist or is inactive, create it if database.IsPostgreSQLDB() { err = database.QueryRow(` INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync) VALUES ($1, $2, 'other', true, CURRENT_TIMESTAMP) RETURNING DeviceID `, userID, deviceName).Scan(&deviceID) } else { // For MySQL, define the query string first var query string = ` INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync) VALUES (?, ?, 'other', true, CURRENT_TIMESTAMP) ` result, err := database.Exec(query, userID, deviceName) if err != nil { log.Printf("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 getting last insert ID: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) return } deviceID = int(lastID) } if err != nil { log.Printf("Error creating device: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) return } // Return empty list for new device c.JSON(http.StatusOK, []string{}) return } log.Printf("Error getting device ID: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device"}) return } // Get podcasts for this user var rows *sql.Rows if database.IsPostgreSQLDB() { rows, err = database.Query(` SELECT FeedURL FROM "Podcasts" WHERE UserID = $1 `, userID) } else { rows, err = database.Query(` SELECT FeedURL FROM Podcasts WHERE UserID = ? `, userID) } if err != nil { log.Printf("Error getting podcasts: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get subscriptions"}) return } defer rows.Close() // Build response - ensure never nil urls := make([]string, 0) for rows.Next() { var url string if err := rows.Scan(&url); err != nil { log.Printf("Error scanning podcast URL: %v", err) continue } urls = append(urls, url) } if err = rows.Err(); err != nil { log.Printf("Error iterating podcast rows: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process subscriptions"}) return } // Update device's last sync time if database.IsPostgreSQLDB() { _, err = database.Exec(` UPDATE "GpodderDevices" SET LastSync = CURRENT_TIMESTAMP WHERE DeviceID = $1 `, deviceID) } else { _, err = database.Exec(` UPDATE GpodderDevices SET LastSync = CURRENT_TIMESTAMP WHERE DeviceID = ? `, deviceID) } if err != nil { // Non-critical error, just log it log.Printf("Error updating device last sync time: %v", err) } // Return in requested format switch format { case "json", "jsonp": if format == "jsonp" { // JSONP callback callback := c.Query("jsonp") if callback == "" { callback = "callback" // Default callback name } c.Header("Content-Type", "application/javascript") jsonData, err := json.Marshal(urls) if err != nil { log.Printf("Error marshaling JSON: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to marshal response"}) return } c.String(http.StatusOK, "%s(%s);", callback, string(jsonData)) } else { c.JSON(http.StatusOK, urls) } case "txt": // Plain text format c.Header("Content-Type", "text/plain") var sb strings.Builder for _, url := range urls { sb.WriteString(url) sb.WriteString("\n") } c.String(http.StatusOK, sb.String()) case "opml": // OPML format c.Header("Content-Type", "text/xml") var sb strings.Builder sb.WriteString(` gPodder Subscriptions `) for _, url := range urls { sb.WriteString(fmt.Sprintf(` `, url, url)) } sb.WriteString(` `) c.String(http.StatusOK, sb.String()) default: c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"}) } } } // updateSubscriptionsSimple handles PUT /subscriptions/{username}/{deviceid}.{format} func updateSubscriptionsSimple(database *db.Database) gin.HandlerFunc { return func(c *gin.Context) { // Get format from URL format := c.Param("format") if format == "" { format = "json" // Default format } // Get user ID from middleware userID, exists := c.Get("userID") if !exists { c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) return } // Get device ID from URL deviceName := c.Param("deviceid") if deviceName == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "Device ID is required"}) return } // Parse request body based on format var urls []string switch format { case "json", "jsonp": if err := c.ShouldBindJSON(&urls); err != nil { log.Printf("Error parsing JSON request body: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: expected a JSON array of URLs"}) return } case "txt": // Read as plain text, split by lines body, err := c.GetRawData() if err != nil { log.Printf("Error reading request body: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"}) return } lines := strings.Split(string(body), "\n") for _, line := range lines { line = strings.TrimSpace(line) if line != "" { urls = append(urls, line) } } case "opml": // Parse OPML format body, err := c.GetRawData() if err != nil { log.Printf("Error reading request body: %v", err) c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body"}) return } // Simple regex-based OPML parser (for a robust implementation, use proper XML parsing) opmlContent := string(body) matches := opmlOutlineRegex.FindAllStringSubmatch(opmlContent, -1) for _, match := range matches { if len(match) > 1 { url := match[1] if url != "" { urls = append(urls, url) } } } default: c.JSON(http.StatusBadRequest, gin.H{"error": "Unsupported format"}) return } // Validate number of subscriptions if len(urls) > MAX_SUBSCRIPTIONS { c.JSON(http.StatusBadRequest, gin.H{ "error": fmt.Sprintf("Too many subscriptions. Maximum allowed: %d", MAX_SUBSCRIPTIONS), }) return } // From here, use the same logic as updateSubscriptions // Get or create device, process changes, etc. var deviceID int var err error if database.IsPostgreSQLDB() { err = database.QueryRow(` SELECT DeviceID FROM "GpodderDevices" WHERE UserID = $1 AND DeviceName = $2 `, userID, deviceName).Scan(&deviceID) } else { err = database.QueryRow(` SELECT DeviceID FROM GpodderDevices WHERE UserID = ? AND DeviceName = ? `, userID, deviceName).Scan(&deviceID) } if err != nil { if err == sql.ErrNoRows { // Device doesn't exist, create it if database.IsPostgreSQLDB() { err = database.QueryRow(` INSERT INTO "GpodderDevices" (UserID, DeviceName, DeviceType, IsActive, LastSync) VALUES ($1, $2, 'other', true, CURRENT_TIMESTAMP) RETURNING DeviceID `, userID, deviceName).Scan(&deviceID) } else { // For MySQL, we need to use a different approach without RETURNING res, err := database.Exec(` INSERT INTO GpodderDevices (UserID, DeviceName, DeviceType, IsActive, LastSync) VALUES (?, ?, 'other', true, CURRENT_TIMESTAMP) `, userID, deviceName) if err != nil { log.Printf("Error creating device: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) return } // Get the last inserted ID lastID, err := res.LastInsertId() if err != nil { log.Printf("Error getting last insert ID: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get device ID"}) return } deviceID = int(lastID) } if err != nil { log.Printf("Error creating device: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create device"}) return } } else { log.Printf("Error checking device existence: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check device"}) return } } // Begin transaction tx, err := database.Begin() if err != nil { log.Printf("Error beginning transaction: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to begin transaction"}) return } defer func() { if err != nil { tx.Rollback() } }() // Get existing subscriptions var rows *sql.Rows if database.IsPostgreSQLDB() { rows, err = tx.Query(` SELECT FeedURL FROM "Podcasts" WHERE UserID = $1 `, userID) } else { rows, err = tx.Query(` SELECT FeedURL FROM Podcasts WHERE UserID = ? `, userID) } if err != nil { log.Printf("Error getting existing podcasts: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get existing subscriptions"}) return } // Build existing subscriptions map existing := make(map[string]bool) for rows.Next() { var url string if err := rows.Scan(&url); err != nil { log.Printf("Error scanning existing podcast URL: %v", err) continue } existing[url] = true } rows.Close() if err = rows.Err(); err != nil { log.Printf("Error iterating existing podcast rows: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process existing subscriptions"}) return } // Find URLs to add and remove toAdd := make([]string, 0) cleanURLMap := make(map[string]string) // Maps original URL to cleaned URL for _, url := range urls { // Clean and validate the URL cleanURL, err := sanitizeURL(url) if err != nil { log.Printf("Skipping invalid URL '%s': %v", url, err) continue } cleanURLMap[url] = cleanURL if !existing[cleanURL] { toAdd = append(toAdd, cleanURL) } // Remove from existing map to track what's left to delete delete(existing, cleanURL) } // Remaining URLs in 'existing' need to be removed toRemove := make([]string, 0, len(existing)) for url := range existing { toRemove = append(toRemove, url) } // Record subscription changes timestamp := time.Now().Unix() // Add new podcasts for _, url := range toAdd { // Insert into Podcasts table with minimal info if database.IsPostgreSQLDB() { _, err = tx.Exec(` INSERT INTO "Podcasts" (PodcastName, FeedURL, UserID) VALUES ($1, $2, $3) ON CONFLICT (UserID, FeedURL) DO NOTHING `, url, url, userID) } else { // For MySQL, use INSERT IGNORE _, err = tx.Exec(` INSERT IGNORE INTO Podcasts (PodcastName, FeedURL, UserID) VALUES (?, ?, ?) `, url, url, userID) } if err != nil { log.Printf("Error adding podcast: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to add podcast"}) return } // Record subscription change if database.IsPostgreSQLDB() { _, err = tx.Exec(` INSERT INTO "GpodderSyncSubscriptions" (UserID, DeviceID, PodcastURL, Action, Timestamp) VALUES ($1, $2, $3, 'add', $4) `, userID, deviceID, url, timestamp) } else { _, err = tx.Exec(` INSERT INTO GpodderSyncSubscriptions (UserID, DeviceID, PodcastURL, Action, Timestamp) VALUES (?, ?, ?, 'add', ?) `, userID, deviceID, url, timestamp) } if err != nil { log.Printf("Error recording subscription add: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record subscription change"}) return } } // Remove podcasts for _, url := range toRemove { // First delete related episodes and their dependencies to avoid foreign key constraint violations if database.IsPostgreSQLDB() { // Delete related data in correct order for PostgreSQL deleteQueries := []string{ `DELETE FROM "PlaylistContents" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, `DELETE FROM "UserEpisodeHistory" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, `DELETE FROM "DownloadedEpisodes" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, `DELETE FROM "SavedEpisodes" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, `DELETE FROM "EpisodeQueue" WHERE EpisodeID IN (SELECT EpisodeID FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2))`, `DELETE FROM "YouTubeVideos" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2)`, `DELETE FROM "Episodes" WHERE PodcastID IN (SELECT PodcastID FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2)`, `DELETE FROM "Podcasts" WHERE UserID = $1 AND FeedURL = $2`, } for _, deleteQuery := range deleteQueries { _, err = tx.Exec(deleteQuery, userID, url) if err != nil { log.Printf("Error executing delete query: %v", err) break } } } else { // Delete related data in correct order for MySQL/MariaDB deleteQueries := []string{ `DELETE FROM PlaylistContents WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, `DELETE FROM UserEpisodeHistory WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, `DELETE FROM DownloadedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, `DELETE FROM SavedEpisodes WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, `DELETE FROM EpisodeQueue WHERE EpisodeID IN (SELECT EpisodeID FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?))`, `DELETE FROM YouTubeVideos WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?)`, `DELETE FROM Episodes WHERE PodcastID IN (SELECT PodcastID FROM Podcasts WHERE UserID = ? AND FeedURL = ?)`, `DELETE FROM Podcasts WHERE UserID = ? AND FeedURL = ?`, } for _, deleteQuery := range deleteQueries { _, err = tx.Exec(deleteQuery, userID, url) if err != nil { log.Printf("Error executing delete query: %v", err) break } } } if err != nil { log.Printf("Error removing podcast: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to remove podcast"}) return } // Record subscription change if database.IsPostgreSQLDB() { _, err = tx.Exec(` INSERT INTO "GpodderSyncSubscriptions" (UserID, DeviceID, PodcastURL, Action, Timestamp) VALUES ($1, $2, $3, 'remove', $4) `, userID, deviceID, url, timestamp) } else { _, err = tx.Exec(` INSERT INTO GpodderSyncSubscriptions (UserID, DeviceID, PodcastURL, Action, Timestamp) VALUES (?, ?, ?, 'remove', ?) `, userID, deviceID, url, timestamp) } if err != nil { log.Printf("Error recording subscription remove: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to record subscription change"}) return } } // Update device's last sync time if database.IsPostgreSQLDB() { _, err = tx.Exec(` UPDATE "GpodderDevices" SET LastSync = CURRENT_TIMESTAMP WHERE DeviceID = $1 `, deviceID) } else { _, err = tx.Exec(` UPDATE GpodderDevices SET LastSync = CURRENT_TIMESTAMP WHERE DeviceID = ? `, deviceID) } if err != nil { log.Printf("Error updating device last sync time: %v", err) // Non-critical error, continue with transaction } // Commit transaction if err := tx.Commit(); err != nil { log.Printf("Error committing transaction: %v", err) c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to commit changes"}) return } // Return success c.Status(http.StatusOK) } } // Regex for parsing OPML outline tags var opmlOutlineRegex = regexp.MustCompile(`]*xmlUrl="([^"]+)"[^>]*/>`)