Skip to content

Commit 02f2839

Browse files
Merge pull request #749 from madeofpendletonwool/gpod
Gpod
2 parents dedda9d + 18a798a commit 02f2839

File tree

6 files changed

+717
-209
lines changed

6 files changed

+717
-209
lines changed

database_functions/migration_definitions.py

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3943,6 +3943,145 @@ def migration_037_fix_shared_episodes_schema(conn, db_type: str):
39433943
cursor.close()
39443944

39453945

3946+
@register_migration("107", "fix_gpodder_episode_actions_antennapod", "Fix existing GPodder episode actions to include Started and Total fields for AntennaPod compatibility", requires=["103"])
3947+
def migration_107_fix_gpodder_episode_actions(conn, db_type: str):
3948+
"""
3949+
Fix existing GPodder episode actions to be compatible with AntennaPod.
3950+
AntennaPod requires all play actions to have Started, Position, and Total fields.
3951+
This migration adds those fields by joining with the Episodes table to get duration.
3952+
"""
3953+
cursor = conn.cursor()
3954+
3955+
try:
3956+
logger.info("Starting GPodder episode actions fix for AntennaPod compatibility...")
3957+
3958+
if db_type == "postgresql":
3959+
# First, count how many actions need fixing
3960+
cursor.execute("""
3961+
SELECT COUNT(*)
3962+
FROM "GpodderSyncEpisodeActions"
3963+
WHERE action = 'play'
3964+
AND (started IS NULL OR total IS NULL OR started < 0 OR total <= 0)
3965+
""")
3966+
count_result = cursor.fetchone()
3967+
actions_to_fix = count_result[0] if count_result else 0
3968+
3969+
logger.info(f"Found {actions_to_fix} play actions that need fixing (PostgreSQL)")
3970+
3971+
if actions_to_fix > 0:
3972+
# Update from Episodes table join
3973+
logger.info("Updating episode actions with duration from Episodes table...")
3974+
cursor.execute("""
3975+
UPDATE "GpodderSyncEpisodeActions" AS gsa
3976+
SET
3977+
started = 0,
3978+
total = e.episodeduration
3979+
FROM "Episodes" e
3980+
WHERE gsa.action = 'play'
3981+
AND gsa.episodeurl = e.episodeurl
3982+
AND e.episodeduration IS NOT NULL
3983+
AND e.episodeduration > 0
3984+
AND (gsa.started IS NULL OR gsa.total IS NULL OR gsa.started < 0 OR gsa.total <= 0)
3985+
""")
3986+
conn.commit()
3987+
3988+
# Fallback: use Position as Total for episodes not in Episodes table
3989+
logger.info("Updating remaining actions using Position as fallback for Total...")
3990+
cursor.execute("""
3991+
UPDATE "GpodderSyncEpisodeActions"
3992+
SET
3993+
started = 0,
3994+
total = COALESCE(position, 1)
3995+
WHERE action = 'play'
3996+
AND (started IS NULL OR total IS NULL OR started < 0 OR total <= 0)
3997+
AND position IS NOT NULL
3998+
AND position > 0
3999+
""")
4000+
conn.commit()
4001+
4002+
# Final cleanup: set minimal valid values for any remaining invalid actions
4003+
logger.info("Final cleanup: setting minimal valid values for remaining invalid actions...")
4004+
cursor.execute("""
4005+
UPDATE "GpodderSyncEpisodeActions"
4006+
SET
4007+
started = 0,
4008+
total = 1
4009+
WHERE action = 'play'
4010+
AND (started IS NULL OR total IS NULL OR started < 0 OR total <= 0)
4011+
""")
4012+
conn.commit()
4013+
4014+
# Verify the fix
4015+
cursor.execute("""
4016+
SELECT COUNT(*)
4017+
FROM "GpodderSyncEpisodeActions"
4018+
WHERE action = 'play'
4019+
AND (started IS NULL OR total IS NULL OR started < 0 OR total <= 0 OR position <= 0)
4020+
""")
4021+
remaining_result = cursor.fetchone()
4022+
remaining_broken = remaining_result[0] if remaining_result else 0
4023+
4024+
logger.info(f"Fixed {actions_to_fix - remaining_broken} episode actions (PostgreSQL)")
4025+
if remaining_broken > 0:
4026+
logger.warning(f"{remaining_broken} actions still have invalid fields - these may need manual review")
4027+
else:
4028+
logger.info("No actions need fixing (PostgreSQL)")
4029+
4030+
else: # MySQL/MariaDB
4031+
# First, count how many actions need fixing
4032+
cursor.execute("""
4033+
SELECT COUNT(*)
4034+
FROM GpodderSyncEpisodeActions
4035+
WHERE Action = 'play'
4036+
AND (Started IS NULL OR Total IS NULL OR Started < 0 OR Total <= 0)
4037+
""")
4038+
count_result = cursor.fetchone()
4039+
actions_to_fix = count_result[0] if count_result else 0
4040+
4041+
logger.info(f"Found {actions_to_fix} play actions that need fixing (MySQL)")
4042+
4043+
if actions_to_fix > 0:
4044+
# MySQL: Update using JOIN
4045+
logger.info("Updating episode actions with duration from Episodes table...")
4046+
cursor.execute("""
4047+
UPDATE GpodderSyncEpisodeActions AS gsa
4048+
LEFT JOIN Episodes e ON gsa.EpisodeURL = e.EpisodeURL
4049+
AND e.EpisodeDuration IS NOT NULL
4050+
AND e.EpisodeDuration > 0
4051+
SET
4052+
gsa.Started = 0,
4053+
gsa.Total = COALESCE(e.EpisodeDuration, gsa.Position, 1)
4054+
WHERE gsa.Action = 'play'
4055+
AND (gsa.Started IS NULL OR gsa.Total IS NULL OR gsa.Started < 0 OR gsa.Total <= 0)
4056+
""")
4057+
conn.commit()
4058+
4059+
# Verify the fix
4060+
cursor.execute("""
4061+
SELECT COUNT(*)
4062+
FROM GpodderSyncEpisodeActions
4063+
WHERE Action = 'play'
4064+
AND (Started IS NULL OR Total IS NULL OR Started < 0 OR Total <= 0 OR Position <= 0)
4065+
""")
4066+
remaining_result = cursor.fetchone()
4067+
remaining_broken = remaining_result[0] if remaining_result else 0
4068+
4069+
logger.info(f"Fixed {actions_to_fix - remaining_broken} episode actions (MySQL)")
4070+
if remaining_broken > 0:
4071+
logger.warning(f"{remaining_broken} actions still have invalid fields - these may need manual review")
4072+
else:
4073+
logger.info("No actions need fixing (MySQL)")
4074+
4075+
logger.info("GPodder episode actions fix migration completed successfully")
4076+
logger.info("AntennaPod should now be able to sync episode actions correctly")
4077+
4078+
except Exception as e:
4079+
logger.error(f"Error in migration 107: {e}")
4080+
raise
4081+
finally:
4082+
cursor.close()
4083+
4084+
39464085
if __name__ == "__main__":
39474086
# Register all migrations and run them
39484087
register_all_migrations()

gpodder-api/internal/api/episode.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,10 @@ func getEpisodeActions(database *db.Database) gin.HandlerFunc {
102102
}
103103

104104
// Performance optimization: Add limits and optimize query structure
105-
const MAX_EPISODE_ACTIONS = 10000 // Reasonable limit for sync operations
106-
105+
const MAX_EPISODE_ACTIONS = 25000 // Limit raised to 25k to handle power users while preventing DoS
106+
107107
// Log query performance info
108-
log.Printf("[DEBUG] getEpisodeActions: Query for user %v with since=%d, device=%s, aggregated=%v",
108+
log.Printf("[DEBUG] getEpisodeActions: Query for user %v with since=%d, device=%s, aggregated=%v",
109109
userID, since, deviceName, aggregated)
110110

111111
// Build query based on parameters with performance optimizations
@@ -185,7 +185,7 @@ func getEpisodeActions(database *db.Database) gin.HandlerFunc {
185185
e.Timestamp = la.max_timestamp
186186
LEFT JOIN "GpodderDevices" d ON e.DeviceID = d.DeviceID
187187
WHERE e.UserID = $1
188-
ORDER BY e.Timestamp DESC
188+
ORDER BY e.Timestamp ASC
189189
LIMIT %d
190190
`, conditionsStr, MAX_EPISODE_ACTIONS)
191191
} else {
@@ -239,7 +239,7 @@ func getEpisodeActions(database *db.Database) gin.HandlerFunc {
239239
e.Timestamp = la.max_timestamp
240240
LEFT JOIN GpodderDevices d ON e.DeviceID = d.DeviceID
241241
WHERE e.UserID = ?
242-
ORDER BY e.Timestamp DESC
242+
ORDER BY e.Timestamp ASC
243243
LIMIT %d
244244
`, conditionsStr, MAX_EPISODE_ACTIONS)
245245
}
@@ -280,9 +280,12 @@ func getEpisodeActions(database *db.Database) gin.HandlerFunc {
280280
}
281281
}
282282

283+
// ORDER BY DESC (newest first) to prioritize recent actions
284+
// This ensures recent play state is synced first, even if total actions > limit
283285
queryParts = append(queryParts, "ORDER BY e.Timestamp DESC")
284-
286+
285287
// Add LIMIT for performance - prevents returning massive datasets
288+
// Clients should use the 'since' parameter to paginate through results
286289
if database.IsPostgreSQLDB() {
287290
queryParts = append(queryParts, fmt.Sprintf("LIMIT %d", MAX_EPISODE_ACTIONS))
288291
} else {

gpodder-api/internal/api/subscriptions.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,11 @@ func getSubscriptions(database *db.Database) gin.HandlerFunc {
230230
}
231231

232232
// Return subscriptions in gpodder format, ensuring backward compatibility
233+
// CRITICAL FIX for issue #636: Ensure arrays are never nil - AntennaPod requires arrays, not null
234+
if podcasts == nil {
235+
podcasts = []string{}
236+
}
237+
233238
response := gin.H{
234239
"add": podcasts,
235240
"remove": []string{},
@@ -367,6 +372,14 @@ func getSubscriptions(database *db.Database) gin.HandlerFunc {
367372
log.Printf("[WARNING] Error updating device last sync time: %v", err)
368373
}
369374

375+
// CRITICAL FIX for issue #636: Ensure arrays are never nil - AntennaPod requires arrays, not null
376+
if addList == nil {
377+
addList = []string{}
378+
}
379+
if removeList == nil {
380+
removeList = []string{}
381+
}
382+
370383
response := gin.H{
371384
"add": addList,
372385
"remove": removeList,

gpodder-api/internal/models/models.go

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package models
22

33
import (
4+
"encoding/json"
45
"time"
56
)
67

@@ -69,12 +70,55 @@ type EpisodeAction struct {
6970
Episode string `json:"episode"`
7071
Device string `json:"device,omitempty"`
7172
Action string `json:"action"`
72-
Timestamp interface{} `json:"timestamp"` // Accept any type
73+
Timestamp interface{} `json:"-"` // Accept any type internally, but customize JSON output
7374
Started *int `json:"started,omitempty"`
7475
Position *int `json:"position,omitempty"`
7576
Total *int `json:"total,omitempty"`
7677
}
7778

79+
// MarshalJSON customizes JSON serialization to format timestamp as ISO 8601 string
80+
// AntennaPod expects format: "yyyy-MM-dd'T'HH:mm:ss" (without Z timezone indicator)
81+
func (e EpisodeAction) MarshalJSON() ([]byte, error) {
82+
type Alias EpisodeAction
83+
84+
// Convert timestamp to Unix seconds
85+
var unixTimestamp int64
86+
switch t := e.Timestamp.(type) {
87+
case int64:
88+
unixTimestamp = t
89+
case int:
90+
unixTimestamp = int64(t)
91+
case float64:
92+
unixTimestamp = int64(t)
93+
default:
94+
// Default to current time if timestamp is invalid
95+
unixTimestamp = time.Now().Unix()
96+
}
97+
98+
// Format as ISO 8601 without timezone (AntennaPod requirement)
99+
timestampStr := time.Unix(unixTimestamp, 0).UTC().Format("2006-01-02T15:04:05")
100+
101+
return json.Marshal(&struct {
102+
Podcast string `json:"podcast"`
103+
Episode string `json:"episode"`
104+
Device string `json:"device,omitempty"`
105+
Action string `json:"action"`
106+
Timestamp string `json:"timestamp"`
107+
Started *int `json:"started,omitempty"`
108+
Position *int `json:"position,omitempty"`
109+
Total *int `json:"total,omitempty"`
110+
}{
111+
Podcast: e.Podcast,
112+
Episode: e.Episode,
113+
Device: e.Device,
114+
Action: e.Action,
115+
Timestamp: timestampStr,
116+
Started: e.Started,
117+
Position: e.Position,
118+
Total: e.Total,
119+
})
120+
}
121+
78122
// EpisodeActionResponse represents a response to episode action upload
79123
type EpisodeActionResponse struct {
80124
Timestamp int64 `json:"timestamp"`

0 commit comments

Comments
 (0)