diff --git a/internal/database/querier.go b/internal/database/querier.go index b2f07a88..3eb75941 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -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). diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index bde82200..77fde77b 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -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' diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index bcbc48fe..8298b0de 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -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; diff --git a/internal/router/podcast_page.go b/internal/router/podcast_page.go index 6aef1c4e..786f6873 100644 --- a/internal/router/podcast_page.go +++ b/internal/router/podcast_page.go @@ -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 } diff --git a/internal/router/reader.go b/internal/router/reader.go index 7f899f27..a8057025 100644 --- a/internal/router/reader.go +++ b/internal/router/reader.go @@ -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 +} diff --git a/templates/audio_reader.templ b/templates/audio_reader.templ index 9dd5ef14..b9eb17c5 100644 --- a/templates/audio_reader.templ +++ b/templates/audio_reader.templ @@ -7,7 +7,32 @@ package templates //