feat(podcasts): web UI — /podcasts page, feed detail, download lifecycle (task 1.9)

Rendering ownership holds throughout: every piece of server data is
templ-rendered; htmx swaps partials; the only TS is the Add-Feed modal
toggle + error surfacing (pure client-local state, bundled via
web/src/podcasts.ts with automatic bundle-hash stamping).

Sidebar: /podcasts nav entry (D13, between Series and Collections),
gated by a ShowPodcastsNav flag resolved centrally in
getTemplateUserWithTheme (one indexed COUNT — same class of
per-request lookup as the live-user guard). New 'podcast' icon.

/podcasts: header + Add Feed modal (htmx post → HX-Redirect to the
feed page; inline error surfacing on failure), subscription-scoped
search + facet bar (host/tag/language — server-side GET filters, no
JS), grid cards with download counts / new-episodes / complete /
explicit badges. Empty states for no-library (admin creates it first)
and no-subscriptions.

Feed detail: hero (artwork, host/publisher, tags, fetch state), action
row — Download All, Refresh, ORDER SELECTOR (D5 override), serial
one-click oldest-first suggestion (never silent), auto-download policy
select (D6), Unsubscribe (files retained), admin Remove Feed — gap
banner (D8 count), and the EPISODE LIST with the full lifecycle inline
(D8/D19): pending greyed + Download button (every non-downloaded
episode has one), queued/downloading pulse, failed RED + reason text +
Retry (same endpoint). The list is a self-refreshing partial: re-fetch
on the podcast:changed HX-Trigger and polls every 4s ONLY while the
server says downloads are active (it renders the polling variant).

API endpoints grew HX awareness: 204 + HX-Trigger podcast:changed (or
HX-Redirect for add/unsubscribe/remove) — the sanctioned Go-handler
response channels; JSON callers keep JSON. AddFeed result exposes the
fetch-error text for the redirect.

Dates render in the requesting user's timezone (D17) via
utils.FormatTimeInTimezone; episodes play through the existing
/readers/{id} audio reader (episodes ARE audiobook items — no new
player code day one). CSP tripwire (templates/csp_source_test.go)
passes: no inline scripts, no on*= handlers, no hx-on/js: vals.
This commit is contained in:
John O'Keefe committed 2026-10-29 16:17:55 -04:00
1 parent 54d7d6e7a1
commit 2235b00c59
19 files changed
+3540 -327

No files matched your search

+238 -10
View File
@@ -413,6 +413,18 @@ func (q *Queries) CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherM
return column_1, err
}
const CountPodcastEpisodesForFeed = `-- name: CountPodcastEpisodesForFeed :one
SELECT COUNT(*) FROM media_items
WHERE source_feed_id = $1 AND source = 'podcast_episode'
`
func (q *Queries) CountPodcastEpisodesForFeed(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, CountPodcastEpisodesForFeed, sourceFeedID)
var count int64
err := row.Scan(&count)
return count, err
}
const CountPodcastFeedSubscribers = `-- name: CountPodcastFeedSubscribers :one
SELECT COUNT(*) FROM podcast_subscriptions WHERE feed_id = $1
`
@@ -441,6 +453,18 @@ func (q *Queries) CountPodcastLibrariesForUser(ctx context.Context, userID pgtyp
return count, err
}
const CountQueuedEpisodes = `-- name: CountQueuedEpisodes :one
SELECT COUNT(*) FROM media_items
WHERE source = 'podcast_episode' AND download_state IN ('queued', 'downloading')
`
func (q *Queries) CountQueuedEpisodes(ctx context.Context) (int64, error) {
row := q.db.QueryRow(ctx, CountQueuedEpisodes)
var count int64
err := row.Scan(&count)
return count, err
}
const CountUndownloadedEpisodes = `-- name: CountUndownloadedEpisodes :one
SELECT COUNT(*) FROM media_items
WHERE source_feed_id = $1
@@ -2559,6 +2583,20 @@ func (q *Queries) DeleteUser(ctx context.Context, id pgtype.UUID) error {
return err
}
const DeleteUserSettingByKey = `-- name: DeleteUserSettingByKey :exec
DELETE FROM reader_settings WHERE user_id = $1 AND setting_key = $2
`
type DeleteUserSettingByKeyParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
SettingKey string `db:"setting_key" json:"setting_key"`
}
func (q *Queries) DeleteUserSettingByKey(ctx context.Context, arg DeleteUserSettingByKeyParams) error {
_, err := q.db.Exec(ctx, DeleteUserSettingByKey, arg.UserID, arg.SettingKey)
return err
}
const DeleteUserSystemCollection = `-- name: DeleteUserSystemCollection :exec
DELETE FROM collections
WHERE user_id = $1
@@ -8924,6 +8962,111 @@ func (q *Queries) GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID
return i, err
}
const GetNextQueuedEpisode = `-- name: GetNextQueuedEpisode :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 source = 'podcast_episode' AND download_state = 'queued'
ORDER BY updated_at ASC
LIMIT 1
`
// Sequential download worker's claim source (single worker, day one).
func (q *Queries) GetNextQueuedEpisode(ctx context.Context) (MediaItems, error) {
row := q.db.QueryRow(ctx, GetNextQueuedEpisode)
var i MediaItems
err := row.Scan(
&i.ID,
&i.LibraryID,
&i.Title,
&i.Subtitle,
&i.Author,
&i.Isbn,
&i.Description,
&i.FilePath,
&i.FileSize,
&i.MimeType,
&i.CoverImagePath,
&i.Series,
&i.SeriesNumber,
&i.Tags,
&i.Narrators,
&i.Authors,
&i.AuthorsSearch,
&i.Publishers,
&i.PublishersSearch,
&i.Abridged,
&i.Explicit,
&i.Asin,
&i.DatePublished,
&i.Publisher,
&i.Contributors,
&i.Language,
&i.Edition,
&i.PageCount,
&i.Genre,
&i.CopyrightYear,
&i.GoodreadsID,
&i.OpenlibraryID,
&i.GoogleBooksID,
&i.AddedByAdminID,
&i.CreatedAt,
&i.ImportedAt,
&i.UpdatedAt,
&i.FormatGroup,
&i.FormatMimetype,
&i.IsReflowable,
&i.HasFixedLayout,
&i.TotalCharacters,
&i.ChapterCount,
&i.EntitlementID,
&i.RevisionNumber,
&i.KoboContentID,
&i.KoboMetadata,
&i.MangaType,
&i.ReadingDirection,
&i.SeriesCount,
&i.Volume,
&i.Imprint,
&i.AgeRating,
&i.WebUrl,
&i.StoryArc,
&i.IsBlackAndWhite,
&i.MetadataNotes,
&i.CommunityRating,
&i.AlternateInfo,
&i.ScanInformation,
&i.Summary,
&i.MetadataOverrides,
&i.MissingScanCount,
&i.ArchivedAt,
&i.ChapterMetadata,
&i.LibraryTypeName,
&i.AudioMetadata,
&i.TagsSearch,
&i.ContributorsSearch,
&i.NarratorsSearch,
&i.FileSha256,
&i.OpfIdentifier,
&i.OpfUuid,
&i.HashConfidence,
&i.Source,
&i.SourceFeedID,
&i.EpisodeGuid,
&i.FeedPosition,
&i.SeasonNumber,
&i.EpisodeNumber,
&i.EnclosureUrl,
&i.DeclaredDurationSeconds,
&i.TranscriptUrl,
&i.ChaptersUrl,
&i.Hosts,
&i.Guests,
&i.DownloadState,
&i.DownloadError,
&i.DownloadAttempts,
)
return i, err
}
const GetNextRetryTime = `-- name: GetNextRetryTime :one
SELECT
CASE
@@ -9269,6 +9412,50 @@ func (q *Queries) GetPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaI
return i, err
}
const GetPodcastEpisodeProgress = `-- name: GetPodcastEpisodeProgress :many
SELECT rp.media_item_id,
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_feed_id = $2
AND mi.source = 'podcast_episode'
`
type GetPodcastEpisodeProgressParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
}
type GetPodcastEpisodeProgressRow struct {
MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"`
Percentage pgtype.Float8 `db:"percentage" json:"percentage"`
LastReadAt pgtype.Timestamptz `db:"last_read_at" json:"last_read_at"`
}
// Per-user progress for a feed's episodes (row badges + the Phase-2
// continue fields). reading_progress is UNIQUE per (media_item, user).
func (q *Queries) GetPodcastEpisodeProgress(ctx context.Context, arg GetPodcastEpisodeProgressParams) ([]GetPodcastEpisodeProgressRow, error) {
rows, err := q.db.Query(ctx, GetPodcastEpisodeProgress, arg.UserID, arg.SourceFeedID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetPodcastEpisodeProgressRow{}
for rows.Next() {
var i GetPodcastEpisodeProgressRow
if err := rows.Scan(&i.MediaItemID, &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 GetPodcastFeed = `-- name: GetPodcastFeed :one
SELECT id, library_id, feed_url, podcast_guid, title, description, host, publisher, owner_name, owner_email, language, tags, explicit, artwork_path, feed_type, medium, is_complete, etag, last_modified, last_fetched_at, last_build_date, created_by_admin_id, created_at, updated_at FROM podcast_feeds WHERE id = $1
`
@@ -11832,6 +12019,29 @@ func (q *Queries) GetUserReadingSessionsRange(ctx context.Context, arg GetUserRe
return items, nil
}
const GetUserSettingByKey = `-- name: GetUserSettingByKey :one
SELECT setting_value FROM reader_settings
WHERE user_id = $1 AND setting_key = $2
`
type GetUserSettingByKeyParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
SettingKey string `db:"setting_key" json:"setting_key"`
}
// ============================================================================
// GENERIC reader_settings KV (podcast order override, plan D5: key
// podcast_order:<feed_id>). The pre-existing reader_settings queries are
// hard-keyed to 'reader_settings'; these two are the generic form.
// ============================================================================
func (q *Queries) GetUserSettingByKey(ctx context.Context, arg GetUserSettingByKeyParams) ([]byte, error) {
row := q.db.QueryRow(ctx, GetUserSettingByKey, arg.UserID, arg.SettingKey)
var setting_value []byte
err := row.Scan(&setting_value)
return setting_value, err
}
const GetUserVisibleLibraries = `-- name: GetUserVisibleLibraries :many
SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, l.allow_member_feeds, lt.name as type_name, lt.description as type_description,
COALESCE(lv.is_visible, true) as is_visible
@@ -16819,7 +17029,7 @@ func (q *Queries) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaIt
const SearchMyPodcastEpisodes = `-- name: SearchMyPodcastEpisodes :many
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 source = 'podcast_episode'
AND ($1 = '' OR title ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%')
AND ($1::text = '' OR title ILIKE '%' || $1::text || '%' OR description ILIKE '%' || $1::text || '%')
ORDER BY feed_position ASC NULLS LAST, date_published ASC NULLS LAST
LIMIT 50
`
@@ -16828,8 +17038,8 @@ LIMIT 50
// un-downloaded AND historical rows (fetcher persists metadata-only).
// RLS already restricts to the caller's subscriptions — the explicit
// source filter keeps scan items out of podcast search.
func (q *Queries) SearchMyPodcastEpisodes(ctx context.Context, dollar_1 interface{}) ([]MediaItems, error) {
rows, err := q.db.Query(ctx, SearchMyPodcastEpisodes, dollar_1)
func (q *Queries) SearchMyPodcastEpisodes(ctx context.Context, searchQuery string) ([]MediaItems, error) {
rows, err := q.db.Query(ctx, SearchMyPodcastEpisodes, searchQuery)
if err != nil {
return nil, err
}
@@ -16947,16 +17157,16 @@ SELECT f.id, f.library_id, f.feed_url, f.podcast_guid, f.title, f.description, f
FROM podcast_subscriptions s
JOIN podcast_feeds f ON f.id = s.feed_id
WHERE s.user_id = $1
AND ($2 = '' OR f.title ILIKE '%' || $2 || '%'
OR COALESCE(s.display_name, '') ILIKE '%' || $2 || '%'
OR COALESCE(f.host, '') ILIKE '%' || $2 || '%'
OR COALESCE(f.publisher, '') ILIKE '%' || $2 || '%')
AND ($2::text = '' OR f.title ILIKE '%' || $2::text || '%'
OR COALESCE(s.display_name, '') ILIKE '%' || $2::text || '%'
OR COALESCE(f.host, '') ILIKE '%' || $2::text || '%'
OR COALESCE(f.publisher, '') ILIKE '%' || $2::text || '%')
ORDER BY COALESCE(s.display_name, f.title) ASC
`
type SearchMyPodcastFeedsParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
Column2 interface{} `db:"column_2" json:"column_2"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
SearchQuery string `db:"search_query" json:"search_query"`
}
type SearchMyPodcastFeedsRow struct {
@@ -16993,7 +17203,7 @@ type SearchMyPodcastFeedsRow struct {
// Feed-title AND subscription-display-name search (D14) + the facet
// fields the podcasts page filters on.
func (q *Queries) SearchMyPodcastFeeds(ctx context.Context, arg SearchMyPodcastFeedsParams) ([]SearchMyPodcastFeedsRow, error) {
rows, err := q.db.Query(ctx, SearchMyPodcastFeeds, arg.UserID, arg.Column2)
rows, err := q.db.Query(ctx, SearchMyPodcastFeeds, arg.UserID, arg.SearchQuery)
if err != nil {
return nil, err
}
@@ -20471,3 +20681,21 @@ func (q *Queries) UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBook
)
return i, err
}
const UpsertUserSettingByKey = `-- name: UpsertUserSettingByKey :exec
INSERT INTO reader_settings (user_id, setting_key, setting_value)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, setting_key)
DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = NOW()
`
type UpsertUserSettingByKeyParams struct {
UserID pgtype.UUID `db:"user_id" json:"user_id"`
SettingKey string `db:"setting_key" json:"setting_key"`
SettingValue []byte `db:"setting_value" json:"setting_value"`
}
func (q *Queries) UpsertUserSettingByKey(ctx context.Context, arg UpsertUserSettingByKeyParams) error {
_, err := q.db.Exec(ctx, UpsertUserSettingByKey, arg.UserID, arg.SettingKey, arg.SettingValue)
return err
}