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="([^"]+)"[^>]*/>`)