diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 9afbad82..7cce408a 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -1159,3 +1159,84 @@ func TestLogoutClearsSession(t *testing.T) { } assert.True(t, cleared, "cookie clear must not depend on the request body") } + +// ---- §0 item 5: add-modal Download order — override + queue order ---- + +func TestPodcastAddModalDownloadOrder(t *testing.T) { + setup := setupTestServer(t) + lib, _ := createPodcastsLibrary(t, setup, "test AddOrder Podcasts") + + mux := http.NewServeMux() + mux.HandleFunc("/feed.xml", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/rss+xml") + _, _ = w.Write([]byte(podcastFixture("AddOrder Feed", map[string]string{ + "ao-1": "AddOrder One", "ao-2": "AddOrder Two", "ao-3": "AddOrder Three", + }))) + }) + srv := swapFeedServer(t, mux.ServeHTTP) + + form := url.Values{} + form.Set("library_id", uuid.UUID(lib.ID.Bytes).String()) + form.Set("url", srv.URL+"/feed.xml") + form.Set("download_all", "true") + form.Set("download_order", "reverse") // the add-modal dropdown (§0 item 5c) + resp, body := podcastAuthedForm(t, setup, setup.RegularToken, "POST", "/api/podcasts/feeds", form) + require.Equal(t, http.StatusCreated, resp, string(body)) + + regularID := getRegularUserID(t, setup.DB) + regularPG := pgtype.UUID{Bytes: regularID, Valid: true} + rctx := rls.WithUser(context.Background(), regularPG) + workerCtx := adminSeedCtx(t, setup) + + feeds, _ := setup.DB.ListPodcastFeedsForPolling(rctx) + var feedID pgtype.UUID + for _, f := range feeds { + if f.Title == "AddOrder Feed" { + feedID = f.ID + } + } + require.True(t, feedID.Valid, "subscribed feed must exist") + + // PIN 1: the override row exists (the dropdown IS the initial order). + row, err := setup.DB.GetUserSettingByKey(rctx, database.GetUserSettingByKeyParams{ + UserID: regularPG, SettingKey: "podcast_order:" + feedID.String(), + }) + require.NoError(t, err) + assert.Contains(t, string(row), "reverse", "PIN: the add-modal dropdown sets the caller's order override") + + // PIN 2: the queue follows the LISTENING order — with reverse, the + // tickets run position 3 → 1 (claim order = download order). Read + // the PERSISTED tickets rather than live queued rows: the shared + // dev server's worker races this test's queue and flips rows to + // failed (its container can't reach the fixture URLs) — tickets + // survive every state flip. + var tickets []struct { + ID string + Pos int64 + } + adminScope := database.NewScopedDB(setup.DBPool) + ticketRows, err := adminScope.Query(workerCtx, + `SELECT id::TEXT, download_queue_pos FROM media_items + WHERE source_feed_id = $1 AND download_queue_pos IS NOT NULL + ORDER BY download_queue_pos ASC`, feedID) + require.NoError(t, err) + for ticketRows.Next() { + var id string + var pos int64 + require.NoError(t, ticketRows.Scan(&id, &pos), "scan ticket") + tickets = append(tickets, struct { + ID string + Pos int64 + }{id, pos}) + } + ticketRows.Close() + require.Len(t, tickets, 3, "all 3 episodes got tickets") + eps, err := setup.DB.ListPodcastEpisodes(workerCtx, feedID) + require.NoError(t, err) + byID := map[string]int32{} + for _, ep := range eps { + byID[uuid.UUID(ep.ID.Bytes).String()] = ep.FeedPosition.Int32 + } + assert.Equal(t, int32(3), byID[tickets[0].ID], "PIN: reverse download order — position 3 claims first") + assert.Equal(t, int32(1), byID[tickets[2].ID]) +} diff --git a/internal/handlers/podcast.go b/internal/handlers/podcast.go index 2090c7f7..8308c964 100644 --- a/internal/handlers/podcast.go +++ b/internal/handlers/podcast.go @@ -90,6 +90,11 @@ type AddPodcastFeedRequest struct { LibraryID string `json:"library_id" form:"library_id" validate:"required"` URL string `json:"url" form:"url" validate:"required"` DownloadAll FlexBool `json:"download_all" form:"download_all"` + // DownloadOrder: the initial listening order for THIS feed, applied + // only with DownloadAll (§0 item 5c) — it both seeds the queue in + // listening order and sets the caller's order override. ""/"feed" = + // no override row. + DownloadOrder string `json:"download_order,omitempty" form:"download_order"` } // FlexBool binds checkbox-style booleans on every wire. echo bool-binds @@ -182,7 +187,23 @@ func (h *PodcastHandler) AddFeed(c *echo.Context) error { return podcastErrorResponse(c, err) } if req.DownloadAll && res.Feed != nil { - if _, err := h.worker.QueueFeedDownloads(c.Request().Context(), res.Feed.ID); err != nil { + // §0 item 5c: the add modal's order dropdown is the feed's + // INITIAL listening order — it IS the download order (the queue + // seeds in that order below). 'feed'/'native' = no override row. + order := "native" + switch req.DownloadOrder { + case "reverse", "oldest", "newest": + order = req.DownloadOrder + payload, _ := json.Marshal(map[string]string{"order": order}) + if err := h.db.UpsertUserSettingByKey(c.Request().Context(), database.UpsertUserSettingByKeyParams{ + UserID: user.ID, SettingKey: podcastOrderSettingKey(res.Feed.ID.String()), SettingValue: payload, + }); err != nil { + // subscription + queue stand; the dropdown just won't + // preselect it (surfaced on the next order set). + _ = err + } + } + if _, err := h.worker.QueueFeedDownloadsAs(c.Request().Context(), user.ID, res.Feed.ID); err != nil { // subscription stands; download-all failure surfaces via the // episode list states. } @@ -412,7 +433,8 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error { eidRaw := c.Param("eid") if eidRaw == "all" { // Download All affordance: exactly the non-downloaded set (pin). - queued, err := h.worker.QueueFeedDownloads(ctx, feedID) + // Queues in the CALLER's listening order (§0 item 5b). + queued, err := h.worker.QueueFeedDownloadsAs(ctx, user.ID, feedID) if err != nil { return podcastErrorResponse(c, err) } diff --git a/internal/services/podcast_worker.go b/internal/services/podcast_worker.go index ca161d69..18afc987 100644 --- a/internal/services/podcast_worker.go +++ b/internal/services/podcast_worker.go @@ -301,6 +301,15 @@ func (w *PodcastDownloadWorker) QueueEpisode(ctx context.Context, episodeID, fee // QueueFeedDownloads implements "Download All" (D8): queue exactly the // non-downloaded set — pending AND failed rows (retry is the same path). func (w *PodcastDownloadWorker) QueueFeedDownloads(ctx context.Context, feedID pgtype.UUID) (int, error) { + return w.QueueFeedDownloadsAs(ctx, pgtype.UUID{}, feedID) +} + +// QueueFeedDownloadsAs is Download All honoring the CALLER's listening +// order (§0 item 5b): episodes queue in the feed's effective order (the +// caller's override, feed-native fallback). Tickets (§0 item 4) make +// that queue order the download order — "getting the last chapter +// first doesn't help me". userID zero = no override (feed order). +func (w *PodcastDownloadWorker) QueueFeedDownloadsAs(ctx context.Context, userID, feedID pgtype.UUID) (int, error) { actx, err := w.svc.adminCtx(ctx) if err != nil { return 0, err @@ -313,6 +322,23 @@ func (w *PodcastDownloadWorker) QueueFeedDownloads(ctx context.Context, feedID p if err != nil { return 0, err } + + order := "native" + if userID.Valid { + order = w.orderOverrideFor(actx, userID, feedID) + } + // THE order helper (§0 item 5a) — same semantics as the sequence, + // reader chain, and page list; no per-surface sort drift. + OrderEpisodes(eps, order, + func(ep database.MediaItems) int32 { return ep.FeedPosition.Int32 }, + func(ep database.MediaItems) string { + if ep.DatePublished.Valid { + return ep.DatePublished.Time.Format("2006-01-02") + } + return "" + }, + ) + queued := 0 for _, ep := range eps { if _, err := w.db.SetPodcastEpisodeDownloadQueued(actx, ep.ID); err == nil { @@ -323,6 +349,24 @@ func (w *PodcastDownloadWorker) QueueFeedDownloads(ctx context.Context, feedID p return queued, nil } +// orderOverrideFor reads a user's podcast_order override via the +// reader_settings KV (the D5 storage; native when absent/invalid). +func (w *PodcastDownloadWorker) orderOverrideFor(ctx context.Context, userID, feedID pgtype.UUID) string { + row, err := w.db.GetUserSettingByKey(ctx, database.GetUserSettingByKeyParams{ + UserID: userID, SettingKey: "podcast_order:" + feedID.String(), + }) + if err != nil { + return "native" + } + var v struct { + Order string `json:"order"` + } + if json.Unmarshal(row, &v) != nil || !ValidPodcastEpisodeOrder(v.Order) { + return "native" + } + return v.Order +} + // ApplyAutoPolicies unions subscriber policies for a feed (D6: most // aggressive wins for what ARRIVES — deletion stays admin-only) and // queues the target set, respecting the auto-retry attempt cap. diff --git a/templates/podcasts.templ b/templates/podcasts.templ index 25b38e08..216f1f68 100644 --- a/templates/podcasts.templ +++ b/templates/podcasts.templ @@ -238,10 +238,24 @@ templ PodcastAddFeedModal(libraries []LibraryData) { } else if len(libraries) == 1 { } - +
+ + +
+
+ + +
diff --git a/templates/podcasts_templ.go b/templates/podcasts_templ.go index 1a481681..3a5f5eb4 100644 --- a/templates/podcasts_templ.go +++ b/templates/podcasts_templ.go @@ -704,12 +704,12 @@ func PodcastAddFeedModal(libraries []LibraryData) templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\"> ") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "\">") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "