diff --git a/cmd/server/tests/podcast_test.go b/cmd/server/tests/podcast_test.go index 5084520b..7a2c0b8d 100644 --- a/cmd/server/tests/podcast_test.go +++ b/cmd/server/tests/podcast_test.go @@ -812,6 +812,9 @@ func TestPodcastSmartCollections(t *testing.T) { feedAID := uuid.UUID(feedA.ID.Bytes).String() + // Sequence config rides a data-json attribute — templ escapes quotes. + esc := func(s string) string { return strings.ReplaceAll(s, `"`, """) } + // Smart collection with the briefing's AND shape, created through // the real API. code, body := podcastAuthed(t, setup, regToken, "POST", "/api/collections", @@ -892,6 +895,61 @@ func TestPodcastSmartCollections(t *testing.T) { assert.Contains(t, string(body), "SmartColl A Played", "played but downloaded — the downloaded rule alone doesn't exclude it") assert.NotContains(t, string(body), "SmartColl B Ep") }) + + // ---- ACCEPTANCE (plan item 2.5): the briefing PLAYS back-to-back ---- + + // One more briefing match so the chain has a next hop. + aNew2 := upsertTestEpisode(t, setup, feedA, marker+"-new2", "SmartColl A New Two", 5) + adminScopeExec(t, setup, adminCtxValue(t, setup), ` + UPDATE media_items SET download_state = 'downloaded', file_size = 1000, + date_published = CURRENT_DATE WHERE id = $1`, aNew2.ID) + + t.Run("acceptance_briefing_plays_back_to_back", func(t *testing.T) { + // Back to ALL: today's unplayed feed-A episodes = aNew + aNew2, + // chained in native feed order. Playing the FIRST match (the + // header's Play-as-Sequence target) must chain into the second. + code, body := podcastAuthed(t, setup, regToken, "PUT", "/api/collections/"+collID+"/match-mode", `{"mode":"all"}`) + require.Equal(t, http.StatusOK, code) + + // The detail page (smart branch, All Libraries view) renders + // episode cards with the play affordance + the header button. + // (The library-scoped view filters matches to that library — the + // switcher contract, same as books.) + req, _ := http.NewRequest("GET", setup.Server.URL+"/collections/"+collID, nil) + req.Header.Set("Authorization", "Bearer "+regToken) + req.AddCookie(&http.Cookie{Name: "selectedLibrary", Value: "__all__"}) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + var buf bytes.Buffer + _, _ = buf.ReadFrom(resp.Body) + require.Equal(t, http.StatusOK, resp.StatusCode) + html := buf.String() + assert.Contains(t, html, "Play as Sequence", "smart collections play as a sequence too") + assert.Contains(t, html, "?queue=collection:"+collID, "smart episode cards chain through the queue param") + + // Start from the first briefing match (feed position 1): index 1 + // of 2, next = the second, both hops carrying the queue param. + code, body = podcastAuthed(t, setup, regToken, "GET", + "/readers/"+uuid.UUID(aNew.ID.Bytes).String()+"?queue=collection:"+collID, "") + require.Equal(t, http.StatusOK, code) + html = string(body) + assert.Contains(t, html, esc(`"source":"collection"`)) + assert.Contains(t, html, esc(`"episode_index":1`)) + assert.Contains(t, html, esc(`"index_total":2`), "exactly the briefing set: aOld fails <7d, aPlayed fails unplayed, aPending is un-downloaded, bEp is RLS-dropped") + assert.Contains(t, html, esc(`"next_url":"/readers/`+uuid.UUID(aNew2.ID.Bytes).String()+`?queue=collection:`+collID+`"`)) + assert.NotContains(t, html, `id="player-order-select"`, "computed order — the order control stays hidden (R1)") + }) + + t.Run("acceptance_stale_briefing_match_falls_back", func(t *testing.T) { + // aPlayed is DOWNLOADED but no longer matches the briefing + // (played). Opening its reader with the queue param: the smart + // chain can't place it → feed-queue fallback, not a broken page. + code, body := podcastAuthed(t, setup, regToken, "GET", + "/readers/"+uuid.UUID(aPlayed.ID.Bytes).String()+"?queue=collection:"+collID, "") + require.Equal(t, http.StatusOK, code) + assert.Contains(t, string(body), esc(`"source":"feed"`), "a match that aged out of the briefing falls back to the feed queue") + }) } // ---- download worker: state machine, idempotency, file layout ---- diff --git a/internal/router/helpers.go b/internal/router/helpers.go index 20777105..0fe2fa1f 100644 --- a/internal/router/helpers.go +++ b/internal/router/helpers.go @@ -308,6 +308,17 @@ func (cfg *Config) collectionBooksForScope(c *echo.Context, userUUID uuid.UUID, return nil, isSmart, "books_load_error" } books = handlers.BookCardsFromMediaItems(matches, libTypes) + // Episode-awareness for smart matches (item 2.5): episode cards + // carry the play affordance (chain via ?queue=collection:). + // BookCardsFromMediaItems preserves match order, so a parallel + // walk is enough; feed titles stay empty here (batch lookup is + // polish, not acceptance). + for i := range books { + if i < len(matches) && matches[i].Source == "podcast_episode" { + books[i].IsEpisode = true + books[i].DownloadState = matches[i].DownloadState + } + } } else if collection.IsSystemCollection.Bool && collection.QueryType.Valid && collection.QueryType.String != "" && collection.QueryType.String != "filter" { dashboardSvc := services.NewDashboardService(cfg.Queries) sections, secErr := dashboardSvc.GetDashboardSections(c.Request().Context(), userUUID, libUUID, 1000, []string{}, []string{}) diff --git a/internal/router/reader.go b/internal/router/reader.go index c7f2588f..51c05cf3 100644 --- a/internal/router/reader.go +++ b/internal/router/reader.go @@ -282,6 +282,13 @@ func buildCollectionSequenceContext(c *echo.Context, cfg *Config, item *database if err != nil { return nil } + // Smart collections compute their membership live (R1: computed + // order, never written back) — evaluate the rules for THIS viewer + // and chain the downloaded episode matches in native feed order + // (R3: every listening order routes through OrderEpisodes). + if len(coll.AutoAssignRules) > 0 { + return buildSmartCollectionSequenceContext(c, cfg, item, userID, coll) + } // Downloaded episodes in collection order (SQL orders; RLS scopes // the join — unsubscribed feeds drop out and the chain skips them). eps, err := cfg.Queries.GetCollectionEpisodesOrdered(ctx, pgtype.UUID{Bytes: collID, Valid: true}) @@ -318,3 +325,86 @@ func buildCollectionSequenceContext(c *echo.Context, cfg *Config, item *database } return seq } + +// buildSmartCollectionSequenceContext chains a SMART collection's live +// membership (plan item 2.5 acceptance: "Daily Fiction" → Play = +// today's episodes back-to-back). The rules evaluate for the requesting +// user (mode from the collection; family lens via the visible set); +// episode matches that are downloaded chain in native feed order. +// Books and un-downloaded matches never enter the queue (invariant 6). +func buildSmartCollectionSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, coll database.Collections) *templates.PodcastSequenceContext { + ctx := c.Request().Context() + colService := cfg.CollectionHandler.CollectionService() + visibleIDs, err := cfg.CollectionHandler.UserVisibleLibraryIDs(c) + if err != nil { + visibleIDs = nil + } + matches, err := colService.EvaluateSmartCollection(ctx, colService.ParseRules(coll.AutoAssignRules), services.SmartEvalOptions{ + VisibleIDs: visibleIDs, + MatchMode: coll.RuleMatchMode, + ViewerUserID: userID, + }) + if err != nil { + return nil + } + + type seqEp struct { + id string + title string + date string + pos int32 + } + ordered := make([]seqEp, 0, len(matches)) + currentID := uuid.UUID(item.ID.Bytes).String() + for _, m := range matches { + if m.Source != "podcast_episode" || m.DownloadState != "downloaded" { + continue + } + date := "" + if m.DatePublished.Valid { + date = m.DatePublished.Time.Format("2006-01-02") + } + ordered = append(ordered, seqEp{ + id: uuid.UUID(m.ID.Bytes).String(), + title: m.Title, + date: date, + pos: m.FeedPosition.Int32, + }) + } + if len(ordered) == 0 { + return nil + } + services.OrderEpisodes(ordered, "native", + func(e seqEp) int32 { return e.pos }, + func(e seqEp) string { return e.date }, + ) + + idx := -1 + for i, ep := range ordered { + if ep.id == currentID { + idx = i + break + } + } + if idx < 0 { + return nil + } + + collID := uuid.UUID(coll.ID.Bytes).String() + queueParam := "?queue=collection:" + collID + seq := &templates.PodcastSequenceContext{ + FeedURL: "/collections/" + collID, + FeedTitle: coll.Name, + Source: "collection", + EpisodeIndex: idx + 1, + IndexTotal: len(ordered), + } + if idx > 0 { + seq.PrevURL = "/readers/" + ordered[idx-1].id + queueParam + } + if idx < len(ordered)-1 { + seq.NextURL = "/readers/" + ordered[idx+1].id + queueParam + seq.NextTitle = ordered[idx+1].title + } + return seq +}