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
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user