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:
John O'Keefe committed 2026-10-29 16:25:44 -04:00
1 parent 2235b00c59
commit 28420d56ed
14 files changed
+740 -174

No files matched your search

+5
View File
@@ -455,6 +455,11 @@ type Querier interface {
// Get OPDS tokens by device
GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error)
GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, error)
// Continue aggregation (plan 2.5): the caller's IN-PROGRESS episodes
// (0 < pct < finishedThreshold) across ALL subscribed feeds, most
// recent first. Go groups per feed — the first row per feed IS the
// continue target.
GetPodcastContinueForUser(ctx context.Context, userID pgtype.UUID) ([]GetPodcastContinueForUserRow, error)
GetPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error)
// Per-user progress for a feed's episodes (row badges + the Phase-2
// continue fields). reading_progress is UNIQUE per (media_item, user).
+56
View File
@@ -9310,6 +9310,62 @@ func (q *Queries) GetPanelData(ctx context.Context, arg GetPanelDataParams) (Pan
return i, err
}
const GetPodcastContinueForUser = `-- name: GetPodcastContinueForUser :many
SELECT mi.source_feed_id,
mi.id,
mi.title,
mi.feed_position,
rp.percentage,
rp.last_read_at
FROM reading_progress rp
JOIN media_items mi ON mi.id = rp.media_item_id
WHERE rp.user_id = $1
AND mi.source = 'podcast_episode'
AND rp.percentage > 0
AND rp.percentage < 0.995
ORDER BY rp.last_read_at DESC
`
type GetPodcastContinueForUserRow struct {
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
ID pgtype.UUID `db:"id" json:"id"`
Title string `db:"title" json:"title"`
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
}
// Continue aggregation (plan 2.5): the caller's IN-PROGRESS episodes
// (0 < pct < finishedThreshold) across ALL subscribed feeds, most
// recent first. Go groups per feed — the first row per feed IS the
// continue target.
func (q *Queries) GetPodcastContinueForUser(ctx context.Context, userID pgtype.UUID) ([]GetPodcastContinueForUserRow, error) {
rows, err := q.db.Query(ctx, GetPodcastContinueForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetPodcastContinueForUserRow{}
for rows.Next() {
var i GetPodcastContinueForUserRow
if err := rows.Scan(
&i.SourceFeedID,
&i.ID,
&i.Title,
&i.FeedPosition,
&i.Percentage,
&i.LastReadAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const GetPodcastEpisode = `-- name: GetPodcastEpisode :one
SELECT id, library_id, title, subtitle, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, authors, authors_search, publishers, publishers_search, abridged, explicit, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, manga_type, reading_direction, series_count, volume, imprint, age_rating, web_url, story_arc, is_black_and_white, metadata_notes, community_rating, alternate_info, scan_information, summary, metadata_overrides, missing_scan_count, archived_at, chapter_metadata, library_type_name, audio_metadata, tags_search, contributors_search, narrators_search, file_sha256, opf_identifier, opf_uuid, hash_confidence, source, source_feed_id, episode_guid, feed_position, season_number, episode_number, enclosure_url, declared_duration_seconds, transcript_url, chapters_url, hosts, guests, download_state, download_error, download_attempts FROM media_items
WHERE id = $1 AND source = 'podcast_episode'
+19
View File
@@ -5009,3 +5009,22 @@ JOIN media_items mi ON mi.id = rp.media_item_id
WHERE rp.user_id = $1
AND mi.source_feed_id = $2
AND mi.source = 'podcast_episode';
-- name: GetPodcastContinueForUser :many
-- Continue aggregation (plan 2.5): the caller's IN-PROGRESS episodes
-- (0 < pct < finishedThreshold) across ALL subscribed feeds, most
-- recent first. Go groups per feed — the first row per feed IS the
-- continue target.
SELECT mi.source_feed_id,
mi.id,
mi.title,
mi.feed_position,
rp.percentage,
rp.last_read_at
FROM reading_progress rp
JOIN media_items mi ON mi.id = rp.media_item_id
WHERE rp.user_id = $1
AND mi.source = 'podcast_episode'
AND rp.percentage > 0
AND rp.percentage < 0.995
ORDER BY rp.last_read_at DESC;
+21
View File
@@ -107,6 +107,18 @@ func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPag
if err != nil {
return data, err
}
// Continue aggregation (plan 2.5): first in-progress episode per
// feed (the query is already most-recent-first).
continueByFeed := make(map[string]*database.GetPodcastContinueForUserRow)
if continueRows, err := cfg.Queries.GetPodcastContinueForUser(ctx, user.ID); err == nil {
for i := range continueRows {
row := &continueRows[i]
key := uuid.UUID(row.SourceFeedID.Bytes).String()
if _, ok := continueByFeed[key]; !ok {
continueByFeed[key] = row
}
}
}
for i := range feeds {
row := &feeds[i]
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, row.ID)
@@ -137,6 +149,14 @@ func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPag
AutoDownloadCount: int(row.AutoDownloadCount.Int32),
Subscribed: true,
})
if cont := continueByFeed[uuid.UUID(row.ID.Bytes).String()]; cont != nil {
card := &data.Feeds[len(data.Feeds)-1]
card.HasContinue = true
card.ContinueTitle = cont.Title
card.ContinueEpisode = int(cont.FeedPosition.Int32)
card.ContinuePercent = cont.Percentage.Float64
card.ContinueLastRead = cont.LastReadAt.Time
}
}
// Facet values from the unfiltered subscription list (D18).
@@ -178,6 +198,7 @@ func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPag
}
data.Feeds = filtered
data.Query = q
data.ContinueFeeds = templates.PodcastContinueFeeds(data.Feeds)
return data, nil
}
+109
View File
@@ -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
}