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:
1 parent
54d7d6e7a1
commit
2235b00c59
19 files changed
+3540
-327
No files matched your search
@@ -61,10 +61,12 @@ type Querier interface {
|
||||
// shares the directory; otherwise the sidecar goes to
|
||||
// <basename>.opf so one book's metadata can never poison dir-mates.
|
||||
CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error)
|
||||
CountPodcastEpisodesForFeed(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||
CountPodcastFeedSubscribers(ctx context.Context, feedID pgtype.UUID) (int64, error)
|
||||
// Sidebar/page gate (D13): >=1 podcasts-type library the user can see
|
||||
// (COALESCE(is_visible,true) semantics — mirrors GetUserVisibleLibraries).
|
||||
CountPodcastLibrariesForUser(ctx context.Context, userID pgtype.UUID) (int64, error)
|
||||
CountQueuedEpisodes(ctx context.Context) (int64, error)
|
||||
CountUndownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
|
||||
// Count unlinked books for a device (ignored rows stay hidden)
|
||||
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
|
||||
@@ -199,6 +201,7 @@ type Querier interface {
|
||||
// Delete unlinked book
|
||||
DeleteUnlinkedBook(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteUser(ctx context.Context, id pgtype.UUID) error
|
||||
DeleteUserSettingByKey(ctx context.Context, arg DeleteUserSettingByKeyParams) error
|
||||
DeleteUserSystemCollection(ctx context.Context, arg DeleteUserSystemCollectionParams) error
|
||||
// Device deletion (deregister on logout): detach the device's reading
|
||||
// history instead of deleting it — these rows are real captured stats
|
||||
@@ -443,6 +446,8 @@ type Querier interface {
|
||||
// GetMediaScanState reads one item's manifest (per-item rescan paths
|
||||
// and the manifest integration tests).
|
||||
GetMediaScanState(ctx context.Context, mediaItemID pgtype.UUID) (MediaScanState, error)
|
||||
// Sequential download worker's claim source (single worker, day one).
|
||||
GetNextQueuedEpisode(ctx context.Context) (MediaItems, error)
|
||||
GetNextRetryTime(ctx context.Context) (interface{}, error)
|
||||
GetNotStartedItems(ctx context.Context, arg GetNotStartedItemsParams) ([]MediaItems, error)
|
||||
// Get OPDS token
|
||||
@@ -451,6 +456,9 @@ type Querier interface {
|
||||
GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error)
|
||||
GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, 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).
|
||||
GetPodcastEpisodeProgress(ctx context.Context, arg GetPodcastEpisodeProgressParams) ([]GetPodcastEpisodeProgressRow, error)
|
||||
GetPodcastFeed(ctx context.Context, id pgtype.UUID) (PodcastFeeds, error)
|
||||
// URL-migration dedupe: same canonical guid within the library reuses
|
||||
// the existing row.
|
||||
@@ -561,6 +569,12 @@ type Querier interface {
|
||||
// END instant (the push time; NULL falls back to created_at) — the same
|
||||
// value the capture stamps every write with.
|
||||
GetUserReadingSessionsRange(ctx context.Context, arg GetUserReadingSessionsRangeParams) ([]GetUserReadingSessionsRangeRow, error)
|
||||
// ============================================================================
|
||||
// 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.
|
||||
// ============================================================================
|
||||
GetUserSettingByKey(ctx context.Context, arg GetUserSettingByKeyParams) ([]byte, error)
|
||||
GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error)
|
||||
GetVisibleLibraryMediaCounts(ctx context.Context, userID pgtype.UUID) ([]GetVisibleLibraryMediaCountsRow, error)
|
||||
// Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
||||
@@ -724,7 +738,7 @@ type Querier interface {
|
||||
// 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.
|
||||
SearchMyPodcastEpisodes(ctx context.Context, dollar_1 interface{}) ([]MediaItems, error)
|
||||
SearchMyPodcastEpisodes(ctx context.Context, searchQuery string) ([]MediaItems, error)
|
||||
// Feed-title AND subscription-display-name search (D14) + the facet
|
||||
// fields the podcasts page filters on.
|
||||
SearchMyPodcastFeeds(ctx context.Context, arg SearchMyPodcastFeedsParams) ([]SearchMyPodcastFeedsRow, error)
|
||||
@@ -863,6 +877,7 @@ type Querier interface {
|
||||
// Ingest: insert or refresh the queue row for a device file that could not
|
||||
// be identified. One row per (device, content_id); re-syncs bump last_seen.
|
||||
UpsertUnlinkedBook(ctx context.Context, arg UpsertUnlinkedBookParams) (UnlinkedBooks, error)
|
||||
UpsertUserSettingByKey(ctx context.Context, arg UpsertUserSettingByKeyParams) error
|
||||
}
|
||||
|
||||
var _ Querier = (*Queries)(nil)
|
||||
@@ -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
|
||||
}
|
||||
@@ -4997,3 +4997,15 @@ DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = NOW();
|
||||
|
||||
-- name: DeleteUserSettingByKey :exec
|
||||
DELETE FROM reader_settings WHERE user_id = $1 AND setting_key = $2;
|
||||
|
||||
-- name: GetPodcastEpisodeProgress :many
|
||||
-- Per-user progress for a feed's episodes (row badges + the Phase-2
|
||||
-- continue fields). reading_progress is UNIQUE per (media_item, user).
|
||||
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';
|
||||
@@ -33,21 +33,39 @@ func NewPodcastHandler(db *database.Queries, svc *services.PodcastService, worke
|
||||
// ---- request/response shapes ----
|
||||
|
||||
type AddPodcastFeedRequest struct {
|
||||
LibraryID string `json:"library_id" validate:"required"`
|
||||
URL string `json:"url" validate:"required"`
|
||||
DownloadAll bool `json:"download_all"`
|
||||
LibraryID string `json:"library_id" form:"library_id" validate:"required"`
|
||||
URL string `json:"url" form:"url" validate:"required"`
|
||||
DownloadAll bool `json:"download_all" form:"download_all"`
|
||||
}
|
||||
|
||||
type UpdatePodcastSubscriptionRequest struct {
|
||||
DisplayName *string `json:"display_name"`
|
||||
AutoDownload *string `json:"auto_download" validate:"omitempty,oneof=off all newest"`
|
||||
AutoDownloadCount *int `json:"auto_download_count"`
|
||||
DisplayName *string `json:"display_name" form:"display_name"`
|
||||
AutoDownload *string `json:"auto_download" form:"auto_download" validate:"omitempty,oneof=off all newest"`
|
||||
AutoDownloadCount *int `json:"auto_download_count" form:"auto_download_count"`
|
||||
}
|
||||
|
||||
type DownloadEpisodesRequest struct {
|
||||
EpisodeIDs []string `json:"episode_ids"`
|
||||
}
|
||||
|
||||
// hxAccepted answers an htmx request with 204 + the podcast:changed
|
||||
// trigger — the episode-list partial re-fetches itself (its hx-trigger
|
||||
// listens for the event; polling covers download progress). JSON
|
||||
// callers keep the JSON body. HX-Redirect/HX-Trigger headers are the
|
||||
// sanctioned Go-handler response channels (CSP discipline).
|
||||
func hxAccepted(c *echo.Context, redirect string) error {
|
||||
if redirect != "" {
|
||||
c.Response().Header().Set("HX-Redirect", redirect)
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
c.Response().Header().Set("HX-Trigger", "podcast:changed")
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func isHXRequest(c *echo.Context) bool {
|
||||
return c.Request().Header.Get("HX-Request") == "true"
|
||||
}
|
||||
|
||||
// podcastErrorResponse maps service errors (incl. PodcastError statuses)
|
||||
// onto HTTP responses.
|
||||
func podcastErrorResponse(c *echo.Context, err error) error {
|
||||
@@ -94,6 +112,14 @@ func (h *PodcastHandler) AddFeed(c *echo.Context) error {
|
||||
// episode list states.
|
||||
}
|
||||
}
|
||||
if isHXRequest(c) {
|
||||
// Post-redirect-get: land on the new feed's page. Fetch errors
|
||||
// (subscription stands) surface via the flash query param.
|
||||
if res.FeedError() != "" {
|
||||
return hxAccepted(c, "/podcasts/"+uuidString(res.Feed.ID)+"?fetch_error=1")
|
||||
}
|
||||
return hxAccepted(c, "/podcasts/"+uuidString(res.Feed.ID))
|
||||
}
|
||||
return c.JSON(http.StatusCreated, res)
|
||||
}
|
||||
|
||||
@@ -223,6 +249,11 @@ func (h *PodcastHandler) UpdateSubscription(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return podcastErrorResponse(c, err)
|
||||
}
|
||||
if isHXRequest(c) {
|
||||
// Policy changes feed the auto-download union on the next poll.
|
||||
_, _ = h.worker.ApplyAutoPolicies(ctx, feedID)
|
||||
return hxAccepted(c, "")
|
||||
}
|
||||
return c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
@@ -242,6 +273,9 @@ func (h *PodcastHandler) DeleteSubscription(c *echo.Context) error {
|
||||
if rows == 0 {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "not subscribed"})
|
||||
}
|
||||
if isHXRequest(c) {
|
||||
return hxAccepted(c, "/podcasts")
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "unsubscribed (files retained)"})
|
||||
}
|
||||
|
||||
@@ -256,6 +290,9 @@ func (h *PodcastHandler) RefreshFeed(c *echo.Context) error {
|
||||
if err := h.podcasts.RefreshFeed(c.Request().Context(), feedID); err != nil {
|
||||
return podcastErrorResponse(c, err)
|
||||
}
|
||||
if isHXRequest(c) {
|
||||
return hxAccepted(c, "")
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "refreshed"})
|
||||
}
|
||||
|
||||
@@ -301,6 +338,9 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
|
||||
if err != nil {
|
||||
return podcastErrorResponse(c, err)
|
||||
}
|
||||
if isHXRequest(c) {
|
||||
return hxAccepted(c, "")
|
||||
}
|
||||
return c.JSON(http.StatusAccepted, map[string]interface{}{"queued": queued})
|
||||
}
|
||||
epID, err := parseUUID(eidRaw)
|
||||
@@ -310,6 +350,9 @@ func (h *PodcastHandler) DownloadEpisodes(c *echo.Context) error {
|
||||
if err := h.worker.QueueEpisode(ctx, epID, feedID); err != nil {
|
||||
return podcastErrorResponse(c, err)
|
||||
}
|
||||
if isHXRequest(c) {
|
||||
return hxAccepted(c, "")
|
||||
}
|
||||
return c.JSON(http.StatusAccepted, map[string]string{"message": "queued"})
|
||||
}
|
||||
|
||||
@@ -367,6 +410,9 @@ func (h *PodcastHandler) DeleteFeed(c *echo.Context) error {
|
||||
} else if rows == 0 {
|
||||
return c.JSON(http.StatusNotFound, map[string]string{"error": "feed not found"})
|
||||
}
|
||||
if isHXRequest(c) {
|
||||
return hxAccepted(c, "/podcasts")
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"message": "feed removed"})
|
||||
}
|
||||
|
||||
@@ -438,6 +484,9 @@ func (h *PodcastHandler) SetFeedOrder(c *echo.Context) error {
|
||||
return podcastErrorResponse(c, err)
|
||||
}
|
||||
}
|
||||
if isHXRequest(c) {
|
||||
return hxAccepted(c, "")
|
||||
}
|
||||
return c.JSON(http.StatusOK, map[string]string{"order": req.Order})
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,14 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
||||
token = cookie.Value
|
||||
}
|
||||
|
||||
// Sidebar /podcasts gate (plan D13): >=1 visible podcasts library.
|
||||
// One indexed COUNT per page render — the same class of per-request
|
||||
// lookup the live-user guard already does.
|
||||
showPodcasts := false
|
||||
if n, err := cfg.Queries.CountPodcastLibrariesForUser(c.Request().Context(), uuidToPGType(userUUID)); err == nil && n > 0 {
|
||||
showPodcasts = true
|
||||
}
|
||||
|
||||
return templates.User{
|
||||
ID: userID,
|
||||
Email: userEmail,
|
||||
@@ -68,6 +76,7 @@ func getTemplateUserWithTheme(c *echo.Context, cfg *Config) (templates.User, err
|
||||
CreatedAt: userDB.CreatedAt.Time,
|
||||
DefaultAudioSpeed: float64(userDB.DefaultAudioSpeed),
|
||||
KeepScreenAwake: userDB.KeepScreenAwake,
|
||||
ShowPodcastsNav: showPodcasts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
package router
|
||||
|
||||
// Podcast SSR pages (plan task 1.9): /podcasts (subscriptions grid +
|
||||
// add-feed wizard) and /podcasts/:id (feed detail with the inline
|
||||
// download lifecycle). Rendering ownership: ALL content is
|
||||
// templ-rendered server data; htmx swaps partials; the sanctioned JS is
|
||||
// the modal toggle + event delegation in web/src/podcasts.ts.
|
||||
|
||||
import (
|
||||
"bookhoard/internal/database"
|
||||
"bookhoard/internal/handlers"
|
||||
"bookhoard/internal/utils"
|
||||
"bookhoard/templates"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
"github.com/labstack/echo/v5"
|
||||
)
|
||||
|
||||
func registerPodcastPages(cfg *Config) {
|
||||
pages := cfg.Echo.Group("", createJWTMiddleware(cfg))
|
||||
|
||||
pages.GET("/podcasts", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
data, err := buildPodcastsPageData(c, cfg)
|
||||
if err != nil {
|
||||
data = &templates.PodcastsPageData{ErrorMsg: "Could not load subscriptions"}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := templates.PodcastsPage(user, *data, "/podcasts").Render(c.Request().Context(), &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
pages.GET("/podcasts/:id", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
data, err := buildPodcastDetailData(c, cfg, user)
|
||||
if err != nil {
|
||||
var buf bytes.Buffer
|
||||
_ = templates.ErrorPage("Feed not found", "not_found").Render(c.Request().Context(), &buf)
|
||||
return c.HTML(http.StatusNotFound, buf.String())
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := templates.PodcastDetailPage(user, *data).Render(c.Request().Context(), &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
|
||||
// Episode list partial — the htmx refresh unit (poll while
|
||||
// downloads are active; podcast:changed events swap it too).
|
||||
pages.GET("/podcasts/:id/episodes", func(c *echo.Context) error {
|
||||
user, err := getTemplateUserWithTheme(c, cfg)
|
||||
if err != nil {
|
||||
return renderErrorPage(c, "Error loading user", "user_load_error")
|
||||
}
|
||||
data, err := buildPodcastDetailData(c, cfg, user)
|
||||
if err != nil {
|
||||
return c.NoContent(http.StatusNotFound)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if templates.HasActiveDownloads(data.Episodes) {
|
||||
err = templates.PodcastEpisodeListPolling(*data).Render(c.Request().Context(), &buf)
|
||||
} else {
|
||||
err = templates.PodcastEpisodeList(*data).Render(c.Request().Context(), &buf)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.HTML(http.StatusOK, buf.String())
|
||||
})
|
||||
}
|
||||
|
||||
// buildPodcastsPageData assembles the /podcasts page: subscription
|
||||
// cards, facet values, server-side filters (q/host/tag/language).
|
||||
func buildPodcastsPageData(c *echo.Context, cfg *Config) (*templates.PodcastsPageData, error) {
|
||||
user := handlers.MustGetAuthenticatedUser(c)
|
||||
ctx := c.Request().Context()
|
||||
|
||||
libs, err := cfg.Queries.GetPodcastsLibraryForUser(ctx, user.ID)
|
||||
data := &templates.PodcastsPageData{HasLibrary: len(libs) > 0}
|
||||
if err != nil || len(libs) == 0 {
|
||||
return data, err
|
||||
}
|
||||
for _, lib := range libs {
|
||||
data.Libraries = append(data.Libraries, templates.LibraryData{
|
||||
ID: uuid.UUID(lib.ID.Bytes).String(),
|
||||
Name: lib.Name,
|
||||
TypeName: "podcasts",
|
||||
})
|
||||
}
|
||||
|
||||
feeds, err := cfg.Queries.ListMyPodcastFeeds(ctx, user.ID)
|
||||
if err != nil {
|
||||
return data, err
|
||||
}
|
||||
for i := range feeds {
|
||||
row := &feeds[i]
|
||||
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, row.ID)
|
||||
downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, row.ID)
|
||||
data.Feeds = append(data.Feeds, templates.PodcastFeedCard{
|
||||
ID: uuid.UUID(row.ID.Bytes).String(),
|
||||
LibraryID: uuid.UUID(row.LibraryID.Bytes).String(),
|
||||
Title: row.Title,
|
||||
DisplayName: row.DisplayName.String,
|
||||
Description: firstLine(row.Description.String, 220),
|
||||
Host: row.Host.String,
|
||||
Publisher: row.Publisher.String,
|
||||
Language: row.Language.String,
|
||||
Tags: row.Tags,
|
||||
Explicit: row.Explicit,
|
||||
ArtworkPath: row.ArtworkPath.String,
|
||||
FeedType: row.FeedType.String,
|
||||
Medium: row.Medium.String,
|
||||
IsComplete: row.IsComplete,
|
||||
FeedURL: row.FeedUrl,
|
||||
LastFetched: pgTimeDisplay(row.LastFetchedAt, utils.PgTextString(user.Timezone)),
|
||||
SubscribedAt: pgTimeDisplay(row.SubscribedAt, utils.PgTextString(user.Timezone)),
|
||||
EpisodeTotal: int(total),
|
||||
DownloadedN: int(downloaded),
|
||||
UndownloadedN: int(total) - int(downloaded),
|
||||
NewEpisodes: total-downloaded > 0,
|
||||
AutoDownload: row.AutoDownload,
|
||||
AutoDownloadCount: int(row.AutoDownloadCount.Int32),
|
||||
Subscribed: true,
|
||||
})
|
||||
}
|
||||
|
||||
// Facet values from the unfiltered subscription list (D18).
|
||||
allData := *data
|
||||
data.Facets = facetValuesFromCards(allData.Feeds)
|
||||
|
||||
// Server-side filters.
|
||||
q := strings.ToLower(strings.TrimSpace(c.QueryParam("q")))
|
||||
host := c.QueryParam("host")
|
||||
tag := c.QueryParam("tag")
|
||||
lang := c.QueryParam("language")
|
||||
filtered := data.Feeds[:0:0]
|
||||
for _, f := range data.Feeds {
|
||||
if q != "" {
|
||||
hay := strings.ToLower(f.Title + " " + f.DisplayName + " " + f.Host + " " + f.Publisher + " " + f.Description)
|
||||
if !strings.Contains(hay, q) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if host != "" && f.Host != host {
|
||||
continue
|
||||
}
|
||||
if lang != "" && !strings.EqualFold(f.Language, lang) {
|
||||
continue
|
||||
}
|
||||
if tag != "" {
|
||||
found := false
|
||||
for _, t := range f.Tags {
|
||||
if strings.EqualFold(t, tag) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, f)
|
||||
}
|
||||
data.Feeds = filtered
|
||||
data.Query = q
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// buildPodcastDetailData assembles the feed-detail page + episode list
|
||||
// partial. RLS scopes the lookup (non-subscribers get sqlc.ErrNoRows →
|
||||
// 404 semantics — no Go visibility plumbing, D20).
|
||||
func buildPodcastDetailData(c *echo.Context, cfg *Config, user templates.User) (*templates.PodcastDetailData, error) {
|
||||
actor := handlers.MustGetAuthenticatedUser(c)
|
||||
ctx := c.Request().Context()
|
||||
|
||||
feedID, err := parsePodcastUUID(c.Param("id"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
feed, err := cfg.Queries.GetPodcastFeed(ctx, feedID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub, _ := cfg.Queries.GetPodcastSubscription(ctx, database.GetPodcastSubscriptionParams{
|
||||
UserID: actor.ID, FeedID: feedID,
|
||||
})
|
||||
eps, err := cfg.Queries.ListPodcastEpisodes(ctx, feedID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
progressRows, _ := cfg.Queries.GetPodcastEpisodeProgress(ctx, database.GetPodcastEpisodeProgressParams{
|
||||
UserID: actor.ID, SourceFeedID: feedID,
|
||||
})
|
||||
progressByItem := make(map[string]struct {
|
||||
Pct float64
|
||||
LastRead pgtype.Timestamptz
|
||||
}, len(progressRows))
|
||||
for _, p := range progressRows {
|
||||
progressByItem[uuid.UUID(p.MediaItemID.Bytes).String()] = struct {
|
||||
Pct float64
|
||||
LastRead pgtype.Timestamptz
|
||||
}{p.Percentage.Float64, p.LastReadAt}
|
||||
}
|
||||
|
||||
total, _ := cfg.Queries.CountPodcastEpisodesForFeed(ctx, feedID)
|
||||
downloaded, _ := cfg.Queries.CountDownloadedEpisodes(ctx, feedID)
|
||||
|
||||
card := templates.PodcastFeedCard{
|
||||
ID: uuid.UUID(feed.ID.Bytes).String(),
|
||||
LibraryID: uuid.UUID(feed.LibraryID.Bytes).String(),
|
||||
Title: feed.Title,
|
||||
DisplayName: sub.DisplayName.String,
|
||||
Description: feed.Description.String,
|
||||
Host: feed.Host.String,
|
||||
Publisher: feed.Publisher.String,
|
||||
Language: feed.Language.String,
|
||||
Tags: feed.Tags,
|
||||
Explicit: feed.Explicit,
|
||||
ArtworkPath: feed.ArtworkPath.String,
|
||||
FeedType: feed.FeedType.String,
|
||||
Medium: feed.Medium.String,
|
||||
IsComplete: feed.IsComplete,
|
||||
FeedURL: feed.FeedUrl,
|
||||
LastFetched: pgTimeDisplay(feed.LastFetchedAt, user.Timezone),
|
||||
EpisodeTotal: int(total),
|
||||
DownloadedN: int(downloaded),
|
||||
UndownloadedN: int(total) - int(downloaded),
|
||||
NewEpisodes: total-downloaded > 0,
|
||||
AutoDownload: sub.AutoDownload,
|
||||
AutoDownloadCount: int(sub.AutoDownloadCount.Int32),
|
||||
Subscribed: sub.ID.Valid,
|
||||
}
|
||||
|
||||
rows := make([]templates.PodcastEpisodeRow, 0, len(eps))
|
||||
gaps := 0
|
||||
seenDownloadedFrom := false // walk reversed: gap = undownloaded with a downloaded LATER in order
|
||||
for i := len(eps) - 1; i >= 0; i-- {
|
||||
ep := &eps[i]
|
||||
downloadedEp := ep.DownloadState == "downloaded"
|
||||
if !downloadedEp && seenDownloadedFrom {
|
||||
gaps++
|
||||
}
|
||||
if downloadedEp {
|
||||
seenDownloadedFrom = true
|
||||
}
|
||||
}
|
||||
// Forward pass for the actual rows (native order — the list keeps
|
||||
// feed order; the ORDER selector drives the sequence endpoint, and
|
||||
// the order control re-renders this list via the override applied
|
||||
// below).
|
||||
order := podcastOrderForFeed(cfg, c, feedID.String())
|
||||
for i := range eps {
|
||||
ep := &eps[i]
|
||||
p := progressByItem[uuid.UUID(ep.ID.Bytes).String()]
|
||||
dateDisplay := ""
|
||||
if ep.DatePublished.Valid {
|
||||
dateDisplay = ep.DatePublished.Time.In(utils.UserLocation(user.Timezone)).Format("Jan 2, 2006")
|
||||
}
|
||||
rows = append(rows, templates.PodcastEpisodeRow{
|
||||
ID: uuid.UUID(ep.ID.Bytes).String(),
|
||||
FeedID: uuid.UUID(ep.SourceFeedID.Bytes).String(),
|
||||
Title: ep.Title,
|
||||
Description: firstLine(ep.Description.String, 200),
|
||||
Date: dateDisplay,
|
||||
Position: int(ep.FeedPosition.Int32),
|
||||
Duration: templates.FormatEpisodeDuration(int(ep.DeclaredDurationSeconds.Int32)),
|
||||
Size: templates.FormatEpisodeSize(ep.FileSize.Int64),
|
||||
PlayURL: "/readers/" + uuid.UUID(ep.ID.Bytes).String(),
|
||||
ProgressPct: p.Pct,
|
||||
Downloaded: ep.DownloadState == "downloaded",
|
||||
DownloadState: ep.DownloadState,
|
||||
DownloadError: ep.DownloadError.String,
|
||||
})
|
||||
}
|
||||
// Order override reshapes the LIST too (the sequence endpoint is the
|
||||
// strict downloaded-only view; the list shows everything in the
|
||||
// chosen order so pending episodes stay visible).
|
||||
if order == "oldest" || order == "newest" {
|
||||
sort.SliceStable(rows, func(a, b int) bool {
|
||||
ka := rows[a].Date + itoaPad(rows[a].Position)
|
||||
kb := rows[b].Date + itoaPad(rows[b].Position)
|
||||
if order == "newest" {
|
||||
return ka > kb
|
||||
}
|
||||
return ka < kb
|
||||
})
|
||||
}
|
||||
|
||||
return &templates.PodcastDetailData{
|
||||
Feed: card,
|
||||
Episodes: rows,
|
||||
GapCount: gaps,
|
||||
Order: order,
|
||||
IsAdmin: actor.Role == "admin",
|
||||
IsSerial: feed.FeedType.String == "serial" || feed.Medium.String == "audiobook",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// podcastOrderForFeed reads the caller's order override (D5).
|
||||
func podcastOrderForFeed(cfg *Config, c *echo.Context, feedID string) string {
|
||||
user := handlers.MustGetAuthenticatedUser(c)
|
||||
row, err := cfg.Queries.GetUserSettingByKey(c.Request().Context(), database.GetUserSettingByKeyParams{
|
||||
UserID: user.ID,
|
||||
SettingKey: "podcast_order:" + feedID,
|
||||
})
|
||||
if err != nil {
|
||||
return "native"
|
||||
}
|
||||
var v struct {
|
||||
Order string `json:"order"`
|
||||
}
|
||||
if err := json.Unmarshal(row, &v); err != nil {
|
||||
return "native"
|
||||
}
|
||||
switch v.Order {
|
||||
case "oldest", "newest":
|
||||
return v.Order
|
||||
}
|
||||
return "native"
|
||||
}
|
||||
|
||||
// facetValuesFromCards derives the filter bar values.
|
||||
func facetValuesFromCards(feeds []templates.PodcastFeedCard) map[string][]string {
|
||||
hosts, tags, langs := map[string]bool{}, map[string]bool{}, map[string]bool{}
|
||||
for _, f := range feeds {
|
||||
if f.Host != "" {
|
||||
hosts[f.Host] = true
|
||||
}
|
||||
for _, t := range f.Tags {
|
||||
tags[t] = true
|
||||
}
|
||||
if f.Language != "" {
|
||||
langs[f.Language] = true
|
||||
}
|
||||
}
|
||||
return map[string][]string{
|
||||
"hosts": sortedSet(hosts),
|
||||
"tags": sortedSet(tags),
|
||||
"language": sortedSet(langs),
|
||||
}
|
||||
}
|
||||
|
||||
func sortedSet(set map[string]bool) []string {
|
||||
out := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func firstLine(s string, max int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if i := strings.IndexAny(s, "\n\r"); i > 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if len(s) > max {
|
||||
s = s[:max] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func pgTimeDisplay(t pgtype.Timestamptz, tz string) string {
|
||||
if !t.Valid {
|
||||
return ""
|
||||
}
|
||||
return utils.FormatTimeInTimezone(t.Time, tz)
|
||||
}
|
||||
|
||||
func itoaPad(n int) string {
|
||||
return fmt.Sprintf("%06d", n)
|
||||
}
|
||||
|
||||
|
||||
// parsePodcastUUID parses an id path param (handlers.parseUUID is
|
||||
// package-private).
|
||||
func parsePodcastUUID(s string) (pgtype.UUID, error) {
|
||||
id, err := uuid.Parse(strings.TrimSpace(s))
|
||||
if err != nil {
|
||||
return pgtype.UUID{}, err
|
||||
}
|
||||
return pgtype.UUID{Bytes: id, Valid: true}, nil
|
||||
}
|
||||
@@ -354,6 +354,7 @@ func RegisterRoutes(cfg *Config) *handlers.Handler {
|
||||
registerReaderRoutes(cfg)
|
||||
registerWebSocketRoutes(cfg)
|
||||
registerFrontendRoutes(cfg)
|
||||
registerPodcastPages(cfg)
|
||||
registerDocumentationRoutes(cfg)
|
||||
|
||||
// Static bundles are served unhashed (reader.js, index.js), so the
|
||||
|
||||
Reference in new issue
Block a user