feat(podcasts): chain playback + Continue section — sequence mode in the web player (phase 2 core)
D7 chain-by-default, delivered with minimal player surgery: - ReaderMetadata gains PodcastSequence: the neighboring DOWNLOADED episodes in the caller's effective order (D5 override applied, feed-native fallback; computed in the reader route for source='podcast_episode' items — RLS already scoped the reads; gaps never enter the chain, invariant 6). ?single=1 escapes chaining (the 'play single episode' affordance). - Audio reader renders the sequence into body[data-json] (AudioReaderConfigJSON — books produce the identical shape minus the sequence key, so the reader path is unchanged for them) and an 'Ep N/M' indicator + prev/next episode controls in the header. - audio.ts: the last-track 'ended' handler chains into the next episode's reader page (final progress push precedes navigation); back button exits episodes to their feed page. Books are untouched — no sequence context, zero behavior change. Day-one chaining is page-navigation based (progress persists; position restores). Continue aggregation (2.5): GetPodcastContinueForUser (in-progress episodes, most-recent-first, ≥95% excluded) feeds a Continue section on /podcasts — sorted most recently listened, cards show 'Ep N · title' — ahead of All Feeds. Cards prefer the continue line over the description.
This commit is contained in:
1 parent
2235b00c59
commit
28420d56ed
14 files changed
+740
-174
No files matched your search
@@ -1,13 +1,17 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/services"
|
||||
"bookhoard/internal/sync"
|
||||
"bookhoard/internal/utils"
|
||||
"bookhoard/templates"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
@@ -102,6 +106,18 @@ func registerReaderRoutes(cfg *Config) {
|
||||
TreatAsComic: handlers.ShouldTreatAsComic(libraryWithType.TypeName, mediaItem.FormatGroup),
|
||||
KeepScreenAwake: user.KeepScreenAwake,
|
||||
}
|
||||
// Podcast chaining (plan D7): episodes render the audio reader
|
||||
// with sequence context — the neighboring DOWNLOADED episodes in
|
||||
// the caller's effective order (override applied). RLS already
|
||||
// scoped the episode read; the subscription predicate covers the
|
||||
// neighbors the same way. ?single=1 escapes chaining (the "play
|
||||
// single episode" affordance).
|
||||
if mediaItem.Source == "podcast_episode" && mediaItem.SourceFeedID.Valid && c.QueryParam("single") != "1" {
|
||||
userPG := uuidToPGType(userUUID)
|
||||
if seq := buildPodcastSequenceContext(c, cfg, &mediaItem, userPG, libraryWithType.TypeName); seq != nil {
|
||||
metadata.PodcastSequence = seq
|
||||
}
|
||||
}
|
||||
// Render template
|
||||
var buf bytes.Buffer
|
||||
// Audiobooks get the audio player page — the foliate document
|
||||
@@ -144,3 +160,96 @@ func registerReaderRoutes(cfg *Config) {
|
||||
reader.GET("/settings", cfg.ReaderHandler.GetSettings)
|
||||
reader.PUT("/settings", cfg.ReaderHandler.UpdateSettings)
|
||||
}
|
||||
|
||||
// buildPodcastSequenceContext computes the episode chain payload (plan
|
||||
// D7): effective order — the D5 override applied, feed-native fallback —
|
||||
// DOWNLOADED episodes only (gaps never enter the queue), the current
|
||||
// episode's index, and prev/next reader URLs. RLS scopes the episode
|
||||
// reads; non-subscribers never reach this path in the first place.
|
||||
func buildPodcastSequenceContext(c *echo.Context, cfg *Config, item *database.MediaItems, userID pgtype.UUID, libraryType string) *templates.PodcastSequenceContext {
|
||||
ctx := c.Request().Context()
|
||||
eps, err := cfg.Queries.ListPodcastEpisodes(ctx, item.SourceFeedID)
|
||||
if err != nil || len(eps) == 0 {
|
||||
return nil
|
||||
}
|
||||
feed, err := cfg.Queries.GetPodcastFeed(ctx, item.SourceFeedID)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Effective order override (D5): reader_settings KV.
|
||||
order := "native"
|
||||
if row, err := cfg.Queries.GetUserSettingByKey(ctx, database.GetUserSettingByKeyParams{
|
||||
UserID: userID,
|
||||
SettingKey: "podcast_order:" + uuid.UUID(item.SourceFeedID.Bytes).String(),
|
||||
}); err == nil {
|
||||
var v struct {
|
||||
Order string `json:"order"`
|
||||
}
|
||||
if json.Unmarshal(row, &v) == nil && (v.Order == "oldest" || v.Order == "newest") {
|
||||
order = v.Order
|
||||
}
|
||||
}
|
||||
|
||||
type seqEp struct {
|
||||
id string
|
||||
title string
|
||||
date string
|
||||
pos int32
|
||||
}
|
||||
ordered := make([]seqEp, 0, len(eps))
|
||||
currentID := uuid.UUID(item.ID.Bytes).String()
|
||||
for i := range eps {
|
||||
ep := eps[i]
|
||||
if ep.DownloadState != "downloaded" {
|
||||
continue // un-downloaded episodes are skipped, not tracked (invariant 6)
|
||||
}
|
||||
date := ""
|
||||
if ep.DatePublished.Valid {
|
||||
date = ep.DatePublished.Time.Format("2006-01-02")
|
||||
}
|
||||
ordered = append(ordered, seqEp{
|
||||
id: uuid.UUID(ep.ID.Bytes).String(),
|
||||
title: ep.Title,
|
||||
date: date,
|
||||
pos: ep.FeedPosition.Int32,
|
||||
})
|
||||
}
|
||||
sort.SliceStable(ordered, func(a, b int) bool {
|
||||
ka := ordered[a].date + fmt.Sprintf("%06d", ordered[a].pos)
|
||||
kb := ordered[b].date + fmt.Sprintf("%06d", ordered[b].pos)
|
||||
if order == "newest" {
|
||||
return ka > kb
|
||||
}
|
||||
if order == "oldest" {
|
||||
return ka < kb
|
||||
}
|
||||
return ordered[a].pos < ordered[b].pos
|
||||
})
|
||||
|
||||
idx := -1
|
||||
for i, ep := range ordered {
|
||||
if ep.id == currentID {
|
||||
idx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx < 0 {
|
||||
return nil // current episode not downloaded (or not in sequence)
|
||||
}
|
||||
|
||||
seq := &templates.PodcastSequenceContext{
|
||||
FeedURL: "/podcasts/" + uuid.UUID(item.SourceFeedID.Bytes).String(),
|
||||
FeedTitle: feed.Title,
|
||||
EpisodeIndex: idx + 1,
|
||||
IndexTotal: len(ordered),
|
||||
}
|
||||
if idx > 0 {
|
||||
seq.PrevURL = "/readers/" + ordered[idx-1].id
|
||||
}
|
||||
if idx < len(ordered)-1 {
|
||||
seq.NextURL = "/readers/" + ordered[idx+1].id
|
||||
seq.NextTitle = ordered[idx+1].title
|
||||
}
|
||||
return seq
|
||||
}
|
||||
Reference in new issue
Block a user