feat(podcasts): schema, RLS policy extension, sqlc queries — feed core foundations
Podcast integration task 1.1 (plan: podcast-integration.md §6/§8, D20).
WHY: podcasts are a new first-class media domain — RSS feeds users
subscribe to personally, with episodes as media_items rows so progress,
stats and the audio player work unchanged. Visibility is per-user
(strict: only your subscriptions), which after the RLS migration
(2026-10-29) belongs in the POLICIES, not Go helpers.
Schema (idempotent — schema.sql re-executes every boot):
- 'podcasts' library type seeded (ON CONFLICT DO NOTHING, ARRAY[]::TEXT[]
cast required — bare ARRAY[] 42802s under ON_ERROR_STOP)
- podcast_feeds: per-library feed entity (UNIQUE(library_id, feed_url)),
full RSS/iTunes/Podcasting-2.0 metadata inventory, conditional-GET
validators, is_complete poll backoff
- podcast_subscriptions: the ONLY user-writable podcast state (display
name, auto-download policy); deliberately UNGOVERNED — it IS the read
policy's lookup table
- media_items episode columns: source/source_feed_id/episode_guid/
feed_position/season/episode/enclosure_url/declared_duration/
transcript_url/chapters_url/hosts/guests + download state machine
(pending|queued|downloading|downloaded|failed + error + attempts),
all partial-indexed so scan rows never touch them
- libraries.allow_member_feeds (first per-library knob); library_folders
.purpose ('podcast' download roots, scanner-skipped)
RLS (inside the labeled policy block):
- p_media_items_visible gains the podcast-episode subscription predicate
+ admin escape (source IS DISTINCT FROM 'podcast_episode' guard keeps
scan rows on the cheap path); unset identity still fails loud
- podcast_feeds governed identically: library-visibility join AND
subscriber-or-admin SELECT; permissive write stand-ins (mandatory at
ENABLE time; fetcher/worker declare first-admin, add-feed upsert uses
ON CONFLICT DO UPDATE RETURNING so creators never need SELECT on
foreign rows)
- Verified against dev DB: migration identity sees nothing, admin sees
all, member sees feeds/episodes only post-subscribe (probed in a
rolled-back tx — no rows persisted)
sqlc: 30 new queries (feed upsert/CRUD, subscriptions, episode
upsert-with-write-after-success-guard, download state transitions,
poller worklist, subscription-scoped search) — no visibility clauses
anywhere, per the D20 scoping model.
This commit is contained in:
1 parent
4ab4f9fa90
commit
0b7ad0f376
6 files changed
+3799
-673
No files matched your search
@@ -4590,3 +4590,338 @@ SET file_size = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, narrators, abridged, 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;
|
||||
|
||||
-- ============================================================================
|
||||
-- PODCASTS / RSS FEEDS (plan ~/.opencode/plan/podcast-integration.md §6/§8)
|
||||
-- Visibility is RLS-enforced (schema policy block) — NO query here carries
|
||||
-- visibility clauses. Feed reads run under the acting user's identity
|
||||
-- (subscriber-or-admin policy); the fetcher/worker run as first-admin.
|
||||
-- The fetcher/download worker is the SOLE writer of feed/episode metadata;
|
||||
-- users write only podcast_subscriptions rows.
|
||||
-- ============================================================================
|
||||
|
||||
-- name: CreatePodcastFeed :one
|
||||
-- Add-feed upsert keyed on the per-library UNIQUE(library_id, feed_url):
|
||||
-- a second subscriber adding the same URL attaches to the existing row
|
||||
-- instead of erroring. DO UPDATE (not DO NOTHING) so the RETURNING works
|
||||
-- even when the conflicting row is invisible to the actor under RLS
|
||||
-- (non-admin creators cannot SELECT other users' feeds). Metadata columns
|
||||
-- are filled by the fetcher right after; this only establishes identity.
|
||||
INSERT INTO podcast_feeds (library_id, feed_url, title, created_by_admin_id)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (library_id, feed_url) DO UPDATE SET updated_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetPodcastFeed :one
|
||||
SELECT * FROM podcast_feeds WHERE id = $1;
|
||||
|
||||
-- name: ListMyPodcastFeeds :many
|
||||
-- The podcasts page: the acting user's subscriptions with feed metadata
|
||||
-- and the user-owned subscription fields. Facet values derive in Go.
|
||||
SELECT f.*,
|
||||
s.display_name,
|
||||
s.auto_download,
|
||||
s.auto_download_count,
|
||||
s.created_at AS subscribed_at
|
||||
FROM podcast_subscriptions s
|
||||
JOIN podcast_feeds f ON f.id = s.feed_id
|
||||
WHERE s.user_id = $1
|
||||
ORDER BY COALESCE(s.display_name, f.title) ASC;
|
||||
|
||||
-- name: GetPodcastSubscription :one
|
||||
SELECT * FROM podcast_subscriptions WHERE user_id = $1 AND feed_id = $2;
|
||||
|
||||
-- name: SubscribePodcastFeed :one
|
||||
INSERT INTO podcast_subscriptions (user_id, feed_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (user_id, feed_id) DO NOTHING
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdatePodcastSubscription :one
|
||||
-- User-owned fields only (D9): display name + auto-download policy.
|
||||
UPDATE podcast_subscriptions
|
||||
SET display_name = $3,
|
||||
auto_download = $4,
|
||||
auto_download_count = $5
|
||||
WHERE user_id = $1 AND feed_id = $2
|
||||
RETURNING *;
|
||||
|
||||
-- name: DeletePodcastSubscription :execrows
|
||||
-- Unsubscribe NEVER deletes feed/episode/file state (D6).
|
||||
DELETE FROM podcast_subscriptions WHERE user_id = $1 AND feed_id = $2;
|
||||
|
||||
-- name: ListFeedSubscribers :many
|
||||
-- Download-worker policy union (D6): most aggressive policy wins for
|
||||
-- what arrives. Runs under an admin identity (background job).
|
||||
SELECT user_id, auto_download, auto_download_count
|
||||
FROM podcast_subscriptions
|
||||
WHERE feed_id = $1;
|
||||
|
||||
-- name: CountPodcastFeedSubscribers :one
|
||||
SELECT COUNT(*) FROM podcast_subscriptions WHERE feed_id = $1;
|
||||
|
||||
-- name: ListPodcastFeedsForPolling :many
|
||||
-- Poller worklist (admin identity): every feed with its validators and
|
||||
-- the back-off flag (is_complete feeds poll rarely, never disappear).
|
||||
SELECT * FROM podcast_feeds ORDER BY last_fetched_at ASC NULLS FIRST;
|
||||
|
||||
-- name: UpdatePodcastFeedFetch :one
|
||||
-- Post-fetch bookkeeping: validators for conditional GETs, the fetch
|
||||
-- timestamp, and the feed's own last-build marker.
|
||||
UPDATE podcast_feeds
|
||||
SET etag = $2,
|
||||
last_modified = $3,
|
||||
last_fetched_at = NOW(),
|
||||
last_build_date = $4,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdatePodcastFeedMetadata :one
|
||||
-- Fetcher-only full metadata refresh (parse result). podcast_guid is
|
||||
-- set once a feed declares it; new_feed_url handling is a dedicated
|
||||
-- query below.
|
||||
UPDATE podcast_feeds
|
||||
SET title = $2,
|
||||
description = $3,
|
||||
host = $4,
|
||||
publisher = $5,
|
||||
owner_name = $6,
|
||||
owner_email = $7,
|
||||
language = $8,
|
||||
tags = $9,
|
||||
explicit = $10,
|
||||
feed_type = $11,
|
||||
medium = $12,
|
||||
is_complete = $13,
|
||||
podcast_guid = COALESCE($14, podcast_guid),
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING *;
|
||||
|
||||
-- name: UpdatePodcastFeedArtwork :exec
|
||||
UPDATE podcast_feeds SET artwork_path = $2, updated_at = NOW() WHERE id = $1;
|
||||
|
||||
-- name: UpdatePodcastFeedRedirect :exec
|
||||
-- itunes:new-feed-url (or a permanent HTTP redirect chain): the feed has
|
||||
-- MOVED. Rewrite feed_url so future polls hit the new location; the row
|
||||
-- (and every subscription/episode) stays put.
|
||||
UPDATE podcast_feeds
|
||||
SET feed_url = $2,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetPodcastFeedByGuid :one
|
||||
-- URL-migration dedupe: same canonical guid within the library reuses
|
||||
-- the existing row.
|
||||
SELECT * FROM podcast_feeds
|
||||
WHERE library_id = $1 AND podcast_guid = $2
|
||||
LIMIT 1;
|
||||
|
||||
-- name: DeletePodcastFeed :execrows
|
||||
-- Admin-only removal (handler checks role); episodes cascade via
|
||||
-- media_items.source_feed_id ON DELETE CASCADE.
|
||||
DELETE FROM podcast_feeds WHERE id = $1;
|
||||
|
||||
-- name: CountPodcastLibrariesForUser :one
|
||||
-- Sidebar/page gate (D13): >=1 podcasts-type library the user can see
|
||||
-- (COALESCE(is_visible,true) semantics — mirrors GetUserVisibleLibraries).
|
||||
SELECT COUNT(*)
|
||||
FROM libraries l
|
||||
JOIN library_types lt ON lt.id = l.library_type_id AND lt.name = 'podcasts'
|
||||
LEFT JOIN library_visibility lv ON lv.library_id = l.id AND lv.user_id = $1
|
||||
WHERE COALESCE(lv.is_visible, true) = true;
|
||||
|
||||
-- name: GetPodcastsLibraryForUser :many
|
||||
-- The podcasts library/ies visible to the user (add-feed target list;
|
||||
-- a self-hosted instance typically has exactly one).
|
||||
SELECT l.id, l.name
|
||||
FROM libraries l
|
||||
JOIN library_types lt ON lt.id = l.library_type_id AND lt.name = 'podcasts'
|
||||
LEFT JOIN library_visibility lv ON lv.library_id = l.id AND lv.user_id = $1
|
||||
WHERE COALESCE(lv.is_visible, true) = true
|
||||
ORDER BY l.created_at ASC;
|
||||
|
||||
-- name: GetPodcastLibraryFolder :many
|
||||
-- The download root (purpose='podcast') of a podcasts library.
|
||||
SELECT lf.*
|
||||
FROM library_folders lf
|
||||
WHERE lf.library_id = $1 AND lf.purpose = 'podcast';
|
||||
|
||||
-- name: CreatePodcastLibraryFolder :one
|
||||
INSERT INTO library_folders (library_id, folder_path, purpose)
|
||||
VALUES ($1, $2, 'podcast')
|
||||
RETURNING *;
|
||||
|
||||
-- ============================================================================
|
||||
-- Podcast episodes — media_items rows with source='podcast_episode'
|
||||
-- ============================================================================
|
||||
|
||||
-- name: UpsertPodcastEpisode :one
|
||||
-- Fetcher-only upsert keyed on the partial unique index
|
||||
-- (source_feed_id, episode_guid). DO UPDATE refreshes FETCH-OWNED
|
||||
-- metadata only. Write-after-success guard (AGENTS.md gotcha #7):
|
||||
-- download state/error/attempts are NEVER touched here, and the
|
||||
-- downloaded file's facts (path/size/mime/cover) survive renumbering —
|
||||
-- only un-downloaded rows follow the re-derived file_path.
|
||||
-- authors_search mirrors authors (trigram search column).
|
||||
INSERT INTO media_items (
|
||||
library_id, title, subtitle, description, authors, authors_search,
|
||||
file_path, file_size, mime_type, cover_image_path, explicit,
|
||||
date_published, language, tags, genre,
|
||||
format_group, format_mimetype, is_reflowable, library_type_name,
|
||||
source, source_feed_id, episode_guid, feed_position,
|
||||
season_number, episode_number, enclosure_url,
|
||||
declared_duration_seconds, transcript_url, chapters_url,
|
||||
hosts, guests
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6,
|
||||
$7, $8, $9, $10, $11,
|
||||
$12, $13, $14, $15,
|
||||
'audiobook', $9, FALSE, 'podcasts',
|
||||
'podcast_episode', $16, $17, $18,
|
||||
$19, $20, $21,
|
||||
$22, $23, $24,
|
||||
$25, $26
|
||||
)
|
||||
ON CONFLICT (source_feed_id, episode_guid) WHERE source_feed_id IS NOT NULL DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
subtitle = EXCLUDED.subtitle,
|
||||
description = EXCLUDED.description,
|
||||
authors = EXCLUDED.authors,
|
||||
authors_search = EXCLUDED.authors_search,
|
||||
explicit = EXCLUDED.explicit,
|
||||
date_published = EXCLUDED.date_published,
|
||||
language = EXCLUDED.language,
|
||||
tags = EXCLUDED.tags,
|
||||
genre = EXCLUDED.genre,
|
||||
season_number = EXCLUDED.season_number,
|
||||
episode_number = EXCLUDED.episode_number,
|
||||
enclosure_url = EXCLUDED.enclosure_url,
|
||||
declared_duration_seconds = EXCLUDED.declared_duration_seconds,
|
||||
transcript_url = EXCLUDED.transcript_url,
|
||||
chapters_url = EXCLUDED.chapters_url,
|
||||
hosts = EXCLUDED.hosts,
|
||||
guests = EXCLUDED.guests,
|
||||
feed_position = EXCLUDED.feed_position,
|
||||
cover_image_path = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.cover_image_path ELSE EXCLUDED.cover_image_path END,
|
||||
file_path = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.file_path ELSE EXCLUDED.file_path END,
|
||||
file_size = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.file_size ELSE EXCLUDED.file_size END,
|
||||
mime_type = CASE WHEN media_items.download_state = 'downloaded' THEN media_items.mime_type ELSE EXCLUDED.mime_type END,
|
||||
updated_at = NOW()
|
||||
RETURNING *;
|
||||
|
||||
-- name: ListPodcastEpisodes :many
|
||||
-- Feed detail / sequence source. RLS scopes to subscriber-or-admin;
|
||||
-- ordering: native position, then pub date, then guid (stable across
|
||||
-- re-fetches for historical rows whose position went stale).
|
||||
SELECT * FROM media_items
|
||||
WHERE source_feed_id = $1 AND source = 'podcast_episode'
|
||||
ORDER BY feed_position ASC NULLS LAST, date_published ASC NULLS LAST, episode_guid ASC;
|
||||
|
||||
-- name: GetPodcastEpisode :one
|
||||
SELECT * FROM media_items
|
||||
WHERE id = $1 AND source = 'podcast_episode';
|
||||
|
||||
-- name: SetPodcastEpisodeDownloadQueued :execrows
|
||||
-- Worker claim: pending|failed -> queued. Attempts incremented at
|
||||
-- claim time so a crashed worker still counts the try.
|
||||
UPDATE media_items
|
||||
SET download_state = 'queued',
|
||||
download_error = NULL,
|
||||
download_attempts = download_attempts + 1,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
AND source = 'podcast_episode'
|
||||
AND download_state IN ('pending', 'failed');
|
||||
|
||||
-- name: SetPodcastEpisodeDownloading :exec
|
||||
UPDATE media_items
|
||||
SET download_state = 'downloading', updated_at = NOW()
|
||||
WHERE id = $1 AND source = 'podcast_episode';
|
||||
|
||||
-- name: MarkPodcastEpisodeDownloaded :one
|
||||
-- SUCCESS-only write (invariant 7): the file is on disk and probed.
|
||||
-- ffprobe facts (real duration via audio_metadata, precise size/mime)
|
||||
-- reconcile the feed's declared values.
|
||||
UPDATE media_items
|
||||
SET download_state = 'downloaded',
|
||||
download_error = NULL,
|
||||
file_path = $2,
|
||||
file_size = $3,
|
||||
mime_type = $4,
|
||||
cover_image_path = COALESCE($5, cover_image_path),
|
||||
audio_metadata = $6,
|
||||
chapter_count = $7,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND source = 'podcast_episode'
|
||||
RETURNING *;
|
||||
|
||||
-- name: MarkPodcastEpisodeFailed :exec
|
||||
-- Failure/rejection write: state + reason + whether a human already
|
||||
-- intervened (manual_retry resets the auto-retry budget). Existing
|
||||
-- metadata/file facts are never cleared.
|
||||
UPDATE media_items
|
||||
SET download_state = 'failed',
|
||||
download_error = $2,
|
||||
download_attempts = CASE WHEN $3::boolean THEN 0 ELSE download_attempts END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND source = 'podcast_episode';
|
||||
|
||||
-- name: SetPodcastEpisodePending :exec
|
||||
-- Manual retry (same endpoint as download): reset to pending so the
|
||||
-- worker re-claims it; the reason text clears with the fresh attempt.
|
||||
UPDATE media_items
|
||||
SET download_state = 'pending',
|
||||
download_error = NULL,
|
||||
download_attempts = 0,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1 AND source = 'podcast_episode';
|
||||
|
||||
-- name: ListUndownloadedEpisodes :many
|
||||
-- Download All + policy union worklist.
|
||||
SELECT * FROM media_items
|
||||
WHERE source_feed_id = $1
|
||||
AND source = 'podcast_episode'
|
||||
AND download_state <> 'downloaded'
|
||||
ORDER BY feed_position ASC NULLS LAST, date_published ASC NULLS LAST, episode_guid ASC;
|
||||
|
||||
-- name: CountUndownloadedEpisodes :one
|
||||
SELECT COUNT(*) FROM media_items
|
||||
WHERE source_feed_id = $1
|
||||
AND source = 'podcast_episode'
|
||||
AND download_state <> 'downloaded';
|
||||
|
||||
-- name: CountDownloadedEpisodes :one
|
||||
SELECT COUNT(*) FROM media_items
|
||||
WHERE source_feed_id = $1
|
||||
AND source = 'podcast_episode'
|
||||
AND download_state = 'downloaded';
|
||||
|
||||
-- name: SearchMyPodcastEpisodes :many
|
||||
-- Subscription-scoped episode search (D14): title + description cover
|
||||
-- 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.
|
||||
SELECT * FROM media_items
|
||||
WHERE source = 'podcast_episode'
|
||||
AND ($1 = '' OR title ILIKE '%' || $1 || '%' OR description ILIKE '%' || $1 || '%')
|
||||
ORDER BY feed_position ASC NULLS LAST, date_published ASC NULLS LAST
|
||||
LIMIT 50;
|
||||
|
||||
-- name: SearchMyPodcastFeeds :many
|
||||
-- Feed-title AND subscription-display-name search (D14) + the facet
|
||||
-- fields the podcasts page filters on.
|
||||
SELECT f.*,
|
||||
s.display_name,
|
||||
s.auto_download,
|
||||
s.auto_download_count,
|
||||
s.created_at AS subscribed_at
|
||||
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 || '%')
|
||||
ORDER BY COALESCE(s.display_name, f.title) ASC;
|
||||
Reference in new issue
Block a user