feat(podcasts): Download All queues in the LISTENING order + add-modal order dropdown
Download All previously queued in feed-native order — 'getting the last chapter first doesn't help me'. Now the queue follows the feed's effective listening order (§0 item 5b): QueueFeedDownloadsAs reads the CALLER's order override and runs episodes through services.OrderEpisodes (same helper as the sequence/reader/page surfaces — the consolidation from item 5a landed with the reverse-order commit). Queue tickets (SEQUENCE-stamped, item 4) make that enqueue order the claim order, so chapters land in the order you'll actually listen. Add modal (§0 item 5c): with 'Download all episodes now' checked, a 'Download order' select appears (pure-CSS :checked sibling reveal — no JS island, CSP-clean). Its choice is the feed's INITIAL order override (reader_settings upsert; 'feed' = no row) AND the queue order for the initial batch. Per-book by design: the override IS the download order. Test TestPodcastAddModalDownloadOrder: add with download_all+reverse → the override row exists AND persisted tickets run position 3→1. Tickets are asserted (not live queued rows) because the shared dev server's worker legitimately races this test's queue — tickets survive every state flip.
This commit is contained in:
1 parent
0f3c4a8cec
commit
5c545534c2
7 files changed
+176
-9
No files matched your search
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user