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:
John O'Keefe committed 2026-10-29 15:36:11 -04:00
1 parent 4ab4f9fa90
commit 0b7ad0f376
6 files changed
+3176 -50

No files matched your search

+195 -2
View File
@@ -18,7 +18,8 @@ INSERT INTO library_types (name, description, allowed_extensions) VALUES
('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']), ('ebooks', 'Ebook files including EPUB, PDF, MOBI, etc.', ARRAY['.epub', '.pdf', '.mobi', '.azw', '.azw3', '.txt', '.rtf', '.doc', '.docx', '.lit', '.fb2', '.pdb']),
('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']), ('comics', 'Comic book archives and image formats', ARRAY['.cbz', '.cbr', '.cb7', '.cbt', '.epub', '.pdf']),
('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '.tiff', '.tif']), ('manga', 'Manga files including archives and image folders', ARRAY['.cbz', '.cbr', '.epub', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.avif', '.tiff', '.tif']),
('audiobooks', 'Audiobook files; m4b first, breadth formats registered from day one', ARRAY['.m4b', '.m4a', '.mp3', '.flac']) ('audiobooks', 'Audiobook files; m4b first, breadth formats registered from day one', ARRAY['.m4b', '.m4a', '.mp3', '.flac']),
('podcasts', 'RSS podcast/feed libraries: feeds subscribe-and-download; the scanner never walks these folders', ARRAY[]::TEXT[])
ON CONFLICT (name) DO NOTHING; ON CONFLICT (name) DO NOTHING;
-- Create users table -- Create users table
@@ -1878,6 +1879,126 @@ CREATE INDEX IF NOT EXISTS idx_hash_conflicts_status ON hash_conflicts(status);
-- Widen for databases created before the resolution format settled (no-op otherwise) -- Widen for databases created before the resolution format settled (no-op otherwise)
ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50); ALTER TABLE hash_conflicts ALTER COLUMN resolution TYPE VARCHAR(50);
-- ============================================================
-- PODCASTS / RSS FEEDS
-- Plan: ~/.opencode/plan/podcast-integration.md (§6).
-- Feeds are per-library entities (UNIQUE(library_id, feed_url)); users
-- subscribe personally (podcast_subscriptions). Episodes are
-- media_items rows with source='podcast_episode' — per-episode
-- progress/stats for free — grouped by source_feed_id. Visibility is
-- RLS-enforced (see the policy block at the end of this file): an
-- episode/feed row is SELECT-visible only to subscribers of the feed
-- and to admins. The fetcher/download worker is the SOLE writer of
-- feed/episode metadata; users own only their subscription row and the
-- reader_settings order override. ALL DDL IDEMPOTENT: schema.sql
-- re-executes on EVERY boot.
-- ============================================================
-- First per-library boolean knob (plain column, not KV): when false,
-- only admins may add feeds to the library (members keep subscribe
-- rights to existing feeds).
ALTER TABLE libraries ADD COLUMN IF NOT EXISTS allow_member_feeds BOOLEAN NOT NULL DEFAULT TRUE;
-- Folder purpose: 'scan' (default; the FS walker's root) vs 'podcast'
-- (a podcasts library's download root — scanner skips it wholesale,
-- belt-and-braces on top of the library-type skip).
ALTER TABLE library_folders ADD COLUMN IF NOT EXISTS purpose VARCHAR(20) NOT NULL DEFAULT 'scan';
CREATE TABLE IF NOT EXISTS podcast_feeds (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
library_id UUID NOT NULL REFERENCES libraries(id) ON DELETE CASCADE,
feed_url TEXT NOT NULL,
-- podcast:guid — the feed's canonical identity; dedupes across URL
-- migrations (fetcher honors itunes:new-feed-url + redirects).
podcast_guid TEXT,
title VARCHAR(500) NOT NULL,
description TEXT,
-- Channel itunes:author (semantically the host) and itunes:owner.
host VARCHAR(500),
publisher VARCHAR(500),
owner_name VARCHAR(500),
owner_email VARCHAR(500),
language VARCHAR(50),
tags TEXT[] DEFAULT '{}',
explicit BOOLEAN NOT NULL DEFAULT FALSE,
artwork_path VARCHAR(500),
-- itunes:type: 'serial' | 'episodic'
feed_type VARCHAR(20),
-- podcast:medium: 'audiobook' | 'podcast' | 'music' | ...
medium VARCHAR(20),
-- itunes:complete: poller backs off once the publisher ends the feed.
is_complete BOOLEAN NOT NULL DEFAULT FALSE,
-- Conditional-GET validators (opaque; echoed verbatim).
etag VARCHAR(500),
last_modified VARCHAR(500),
last_fetched_at TIMESTAMPTZ,
last_build_date TIMESTAMPTZ,
created_by_admin_id UUID REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(library_id, feed_url)
);
CREATE INDEX IF NOT EXISTS idx_podcast_feeds_library ON podcast_feeds(library_id);
CREATE INDEX IF NOT EXISTS idx_podcast_feeds_guid ON podcast_feeds(podcast_guid);
-- User-owned subscription row: the ONLY podcast state a user writes.
-- display_name NULL renders as the feed title. auto_download:
-- 'off' | 'all' | 'newest' (+ auto_download_count for newest N).
CREATE TABLE IF NOT EXISTS podcast_subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
feed_id UUID NOT NULL REFERENCES podcast_feeds(id) ON DELETE CASCADE,
display_name VARCHAR(500),
auto_download VARCHAR(20) NOT NULL DEFAULT 'off',
auto_download_count INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(user_id, feed_id)
);
CREATE INDEX IF NOT EXISTS idx_podcast_subs_user ON podcast_subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_podcast_subs_feed ON podcast_subscriptions(feed_id);
-- Episode columns (NULL for scan-sourced items). See the plan §5 for
-- the full RSS/iTunes/Podcasting-2.0 field mapping — existing columns
-- (title, subtitle, description, authors, explicit, date_published,
-- cover_image_path, file_size, mime_type, language, genre/tags) carry
-- the shared fields; only genuinely podcast-only storage lives here.
ALTER TABLE media_items
ADD COLUMN IF NOT EXISTS source VARCHAR(20) NOT NULL DEFAULT 'scan',
ADD COLUMN IF NOT EXISTS source_feed_id UUID REFERENCES podcast_feeds(id) ON DELETE CASCADE,
-- Stable reorder-proof addressing key (invariant 3): progress
-- anchors on guid, never ordinal.
ADD COLUMN IF NOT EXISTS episode_guid TEXT,
-- Native order as of the latest fetch (renumbered per fetch).
ADD COLUMN IF NOT EXISTS feed_position INTEGER,
ADD COLUMN IF NOT EXISTS season_number INTEGER,
ADD COLUMN IF NOT EXISTS episode_number INTEGER,
ADD COLUMN IF NOT EXISTS enclosure_url TEXT,
-- itunes:duration claim (often HH:MM:SS or prose); display until
-- ffprobe reconciles after download.
ADD COLUMN IF NOT EXISTS declared_duration_seconds INTEGER,
ADD COLUMN IF NOT EXISTS transcript_url TEXT,
ADD COLUMN IF NOT EXISTS chapters_url TEXT,
ADD COLUMN IF NOT EXISTS hosts TEXT[] DEFAULT '{}',
ADD COLUMN IF NOT EXISTS guests TEXT[] DEFAULT '{}',
-- Download state machine: pending | queued | downloading |
-- downloaded | failed (+ reason text + attempt count). The episode
-- list renders this inline (D8/D19) — never Processing Issues.
ADD COLUMN IF NOT EXISTS download_state VARCHAR(20) NOT NULL DEFAULT 'pending',
ADD COLUMN IF NOT EXISTS download_error TEXT,
ADD COLUMN IF NOT EXISTS download_attempts INT NOT NULL DEFAULT 0;
-- Identity + ordering + worker-worklist indexes (all partial: scan
-- rows never touch these columns).
CREATE UNIQUE INDEX IF NOT EXISTS idx_media_items_feed_guid
ON media_items(source_feed_id, episode_guid) WHERE source_feed_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_media_items_feed_pos
ON media_items(source_feed_id, feed_position) WHERE source_feed_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_media_items_feed_undownloaded
ON media_items(source_feed_id) WHERE source_feed_id IS NOT NULL AND download_state <> 'downloaded';
-- ============================================================ -- ============================================================
-- RLS: PER-USER LIBRARY VISIBILITY (ONE SOURCE OF TRUTH) -- RLS: PER-USER LIBRARY VISIBILITY (ONE SOURCE OF TRUTH)
-- Plan: ~/.opencode/plan/server-rls-library-visibility.md REV 2. -- Plan: ~/.opencode/plan/server-rls-library-visibility.md REV 2.
@@ -1912,6 +2033,13 @@ ALTER TABLE media_items FORCE ROW LEVEL SECURITY;
-- semantics — row absent -> visible; row true -> visible; row false -- semantics — row absent -> visible; row true -> visible; row false
-- -> hidden. No new index needed: library_visibility's -- -> hidden. No new index needed: library_visibility's
-- UNIQUE(user_id, library_id) constraint already indexes the join. -- UNIQUE(user_id, library_id) constraint already indexes the join.
-- PODCAST EPISODES (plan D20): source='podcast_episode' rows carry the
-- ADDITIONAL subscription predicate — visible only to subscribers of
-- source_feed_id, or to admins (admins manage feeds regardless of
-- subscription; the escape also satisfies background jobs, which
-- declare a first-admin identity). Scan rows pass unchanged via the
-- IS DISTINCT FROM guard — the predicate costs them one NULL compare.
-- Unset identity FAILS LOUD, never fails open (see the block header).
CREATE POLICY p_media_items_visible ON media_items CREATE POLICY p_media_items_visible ON media_items
FOR SELECT FOR SELECT
USING ( USING (
@@ -1922,6 +2050,19 @@ CREATE POLICY p_media_items_visible ON media_items
AND lv.library_id = media_items.library_id AND lv.library_id = media_items.library_id
AND lv.is_visible = false AND lv.is_visible = false
) )
AND (
media_items.source IS DISTINCT FROM 'podcast_episode'
OR EXISTS (
SELECT 1 FROM podcast_subscriptions s
WHERE s.user_id = current_setting('app.user_id')::uuid
AND s.feed_id = media_items.source_feed_id
)
OR EXISTS (
SELECT 1 FROM users u
WHERE u.id = current_setting('app.user_id')::uuid
AND u.role = 'admin'
)
)
); );
-- WRITE policies: PERMISSIVE STAND-INS for the deferred scoped write -- WRITE policies: PERMISSIVE STAND-INS for the deferred scoped write
@@ -1942,7 +2083,59 @@ CREATE POLICY p_media_items_update ON media_items
CREATE POLICY p_media_items_delete ON media_items CREATE POLICY p_media_items_delete ON media_items
FOR DELETE USING (true); FOR DELETE USING (true);
-- Other tables: NONE. collection_items reaches media only via INNER -- Other tables: collection_items reaches media only via INNER
-- JOIN media_items (the join drops filtered rows). collections -- JOIN media_items (the join drops filtered rows). collections
-- metadata (book_count) is a separate counts question — plan A13. -- metadata (book_count) is a separate counts question — plan A13.
-- PODCAST FEEDS (plan D20): governed the same way. SELECT-visible to
-- subscribers of the feed (their subscription IS the grant — this is
-- the predicate's lookup table, podcast_subscriptions itself stays
-- UNGOVERNED) plus the library-visibility join (a hidden library
-- hides its feeds too), and to admins. WRITE policies are permissive
-- stand-ins for the same reason as media_items above: mandatory at
-- ENABLE time, and the fetcher/worker (first-admin identity) plus the
-- add-feed upsert (ON CONFLICT DO UPDATE needs no separate SELECT)
-- cover every legitimate writer.
DROP POLICY IF EXISTS p_podcast_feeds_visible ON podcast_feeds;
DROP POLICY IF EXISTS p_podcast_feeds_insert ON podcast_feeds;
DROP POLICY IF EXISTS p_podcast_feeds_update ON podcast_feeds;
DROP POLICY IF EXISTS p_podcast_feeds_delete ON podcast_feeds;
ALTER TABLE podcast_feeds ENABLE ROW LEVEL SECURITY;
ALTER TABLE podcast_feeds FORCE ROW LEVEL SECURITY;
CREATE POLICY p_podcast_feeds_visible ON podcast_feeds
FOR SELECT
USING (
current_setting('app.user_id') IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM library_visibility lv
WHERE lv.user_id = current_setting('app.user_id')::uuid
AND lv.library_id = podcast_feeds.library_id
AND lv.is_visible = false
)
AND (
EXISTS (
SELECT 1 FROM podcast_subscriptions s
WHERE s.user_id = current_setting('app.user_id')::uuid
AND s.feed_id = podcast_feeds.id
)
OR EXISTS (
SELECT 1 FROM users u
WHERE u.id = current_setting('app.user_id')::uuid
AND u.role = 'admin'
)
)
);
CREATE POLICY p_podcast_feeds_insert ON podcast_feeds
FOR INSERT WITH CHECK (true);
CREATE POLICY p_podcast_feeds_update ON podcast_feeds
FOR UPDATE USING (true) WITH CHECK (true);
CREATE POLICY p_podcast_feeds_delete ON podcast_feeds
FOR DELETE USING (true);
-- podcast_subscriptions: UNGOVERNED, deliberately — it IS the read
-- policy's lookup table, and a user must always see their own
-- subscription rows (subscribe -> visibility is a single INSERT).
-- ============================================================ -- ============================================================
+54
View File
@@ -150,6 +150,7 @@ type Libraries struct {
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"` CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
AllowMemberFeeds bool `db:"allow_member_feeds" json:"allow_member_feeds"`
} }
type LibraryFolders struct { type LibraryFolders struct {
@@ -157,6 +158,7 @@ type LibraryFolders struct {
LibraryID pgtype.UUID `db:"library_id" json:"library_id"` LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
FolderPath string `db:"folder_path" json:"folder_path"` FolderPath string `db:"folder_path" json:"folder_path"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
Purpose string `db:"purpose" json:"purpose"`
} }
type LibraryTypes struct { type LibraryTypes struct {
@@ -338,6 +340,21 @@ type MediaItems struct {
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"` OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"` OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"` HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
Source string `db:"source" json:"source"`
SourceFeedID pgtype.UUID `db:"source_feed_id" json:"source_feed_id"`
EpisodeGuid pgtype.Text `db:"episode_guid" json:"episode_guid"`
FeedPosition pgtype.Int4 `db:"feed_position" json:"feed_position"`
SeasonNumber pgtype.Int4 `db:"season_number" json:"season_number"`
EpisodeNumber pgtype.Int4 `db:"episode_number" json:"episode_number"`
EnclosureUrl pgtype.Text `db:"enclosure_url" json:"enclosure_url"`
DeclaredDurationSeconds pgtype.Int4 `db:"declared_duration_seconds" json:"declared_duration_seconds"`
TranscriptUrl pgtype.Text `db:"transcript_url" json:"transcript_url"`
ChaptersUrl pgtype.Text `db:"chapters_url" json:"chapters_url"`
Hosts []string `db:"hosts" json:"hosts"`
Guests []string `db:"guests" json:"guests"`
DownloadState string `db:"download_state" json:"download_state"`
DownloadError pgtype.Text `db:"download_error" json:"download_error"`
DownloadAttempts int32 `db:"download_attempts" json:"download_attempts"`
} }
type MediaNotes struct { type MediaNotes struct {
@@ -405,6 +422,43 @@ type PanelData struct {
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
} }
type PodcastFeeds struct {
ID pgtype.UUID `db:"id" json:"id"`
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
FeedUrl string `db:"feed_url" json:"feed_url"`
PodcastGuid pgtype.Text `db:"podcast_guid" json:"podcast_guid"`
Title string `db:"title" json:"title"`
Description pgtype.Text `db:"description" json:"description"`
Host pgtype.Text `db:"host" json:"host"`
Publisher pgtype.Text `db:"publisher" json:"publisher"`
OwnerName pgtype.Text `db:"owner_name" json:"owner_name"`
OwnerEmail pgtype.Text `db:"owner_email" json:"owner_email"`
Language pgtype.Text `db:"language" json:"language"`
Tags []string `db:"tags" json:"tags"`
Explicit bool `db:"explicit" json:"explicit"`
ArtworkPath pgtype.Text `db:"artwork_path" json:"artwork_path"`
FeedType pgtype.Text `db:"feed_type" json:"feed_type"`
Medium pgtype.Text `db:"medium" json:"medium"`
IsComplete bool `db:"is_complete" json:"is_complete"`
Etag pgtype.Text `db:"etag" json:"etag"`
LastModified pgtype.Text `db:"last_modified" json:"last_modified"`
LastFetchedAt pgtype.Timestamptz `db:"last_fetched_at" json:"last_fetched_at"`
LastBuildDate pgtype.Timestamptz `db:"last_build_date" json:"last_build_date"`
CreatedByAdminID pgtype.UUID `db:"created_by_admin_id" json:"created_by_admin_id"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"`
}
type PodcastSubscriptions struct {
ID pgtype.UUID `db:"id" json:"id"`
UserID pgtype.UUID `db:"user_id" json:"user_id"`
FeedID pgtype.UUID `db:"feed_id" json:"feed_id"`
DisplayName pgtype.Text `db:"display_name" json:"display_name"`
AutoDownload string `db:"auto_download" json:"auto_download"`
AutoDownloadCount pgtype.Int4 `db:"auto_download_count" json:"auto_download_count"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
}
// Tracks media that failed processing in a library: item-backed issues (library-type mismatches, metadata extraction failures) and row-less import failures (no media_items row exists; keyed by library + file_path). Auto-resolved by later successful scans. // Tracks media that failed processing in a library: item-backed issues (library-type mismatches, metadata extraction failures) and row-less import failures (no media_items row exists; keyed by library + file_path). Auto-resolved by later successful scans.
type ProcessingIssues struct { type ProcessingIssues struct {
ID pgtype.UUID `db:"id" json:"id"` ID pgtype.UUID `db:"id" json:"id"`
+102
View File
@@ -53,11 +53,17 @@ type Querier interface {
// collections page); same live filter as GetCollectionItems, plus the // collections page); same live filter as GetCollectionItems, plus the
// future-hybrid excluded exclusion and an optional library scope. // future-hybrid excluded exclusion and an optional library scope.
CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error) CountCollectionLiveItems(ctx context.Context, arg CountCollectionLiveItemsParams) (int32, error)
CountDownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
// Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped // Sibling-book count for sidecar naming (MetadataWriter): a dir-scoped
// metadata.opf may only be written when NO other non-archived item // metadata.opf may only be written when NO other non-archived item
// shares the directory; otherwise the sidecar goes to // shares the directory; otherwise the sidecar goes to
// <basename>.opf so one book's metadata can never poison dir-mates. // <basename>.opf so one book's metadata can never poison dir-mates.
CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, error) CountOtherMediaItemsInDir(ctx context.Context, arg CountOtherMediaItemsInDirParams) (int32, 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)
CountUndownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) (int64, error)
// Count unlinked books for a device (ignored rows stay hidden) // Count unlinked books for a device (ignored rows stay hidden)
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
// Banner count: unresolved, non-ignored queue rows across the caller's own // Banner count: unresolved, non-ignored queue rows across the caller's own
@@ -116,6 +122,22 @@ type Querier interface {
// ============================================ // ============================================
CreateOrUpdateKoboEntitlement(ctx context.Context, arg CreateOrUpdateKoboEntitlementParams) (KoboEntitlements, error) CreateOrUpdateKoboEntitlement(ctx context.Context, arg CreateOrUpdateKoboEntitlementParams) (KoboEntitlements, error)
// ============================================================================ // ============================================================================
// 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.
// ============================================================================
// 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.
CreatePodcastFeed(ctx context.Context, arg CreatePodcastFeedParams) (PodcastFeeds, error)
CreatePodcastLibraryFolder(ctx context.Context, arg CreatePodcastLibraryFolderParams) (LibraryFolders, error)
// ============================================================================
// PROCESSING ISSUES QUERIES // PROCESSING ISSUES QUERIES
// ============================================================================ // ============================================================================
// Widened upsert (faceted plan Phase 11): media_item_id and file_path // Widened upsert (faceted plan Phase 11): media_item_id and file_path
@@ -160,6 +182,11 @@ type Querier interface {
DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error
DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error DeleteMediaReview(ctx context.Context, arg DeleteMediaReviewParams) error
// Admin-only removal (handler checks role); episodes cascade via
// media_items.source_feed_id ON DELETE CASCADE.
DeletePodcastFeed(ctx context.Context, id pgtype.UUID) (int64, error)
// Unsubscribe NEVER deletes feed/episode/file state (D6).
DeletePodcastSubscription(ctx context.Context, arg DeletePodcastSubscriptionParams) (int64, error)
DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error) DeleteProcessingIssue(ctx context.Context, id pgtype.UUID) (ProcessingIssues, error)
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error) DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error)
@@ -421,6 +448,17 @@ type Querier interface {
// Get OPDS tokens by device // Get OPDS tokens by device
GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error) GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error)
GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, error) GetPanelData(ctx context.Context, arg GetPanelDataParams) (PanelData, error)
GetPodcastEpisode(ctx context.Context, id pgtype.UUID) (MediaItems, error)
GetPodcastFeed(ctx context.Context, id pgtype.UUID) (PodcastFeeds, error)
// URL-migration dedupe: same canonical guid within the library reuses
// the existing row.
GetPodcastFeedByGuid(ctx context.Context, arg GetPodcastFeedByGuidParams) (PodcastFeeds, error)
// The download root (purpose='podcast') of a podcasts library.
GetPodcastLibraryFolder(ctx context.Context, libraryID pgtype.UUID) ([]LibraryFolders, error)
GetPodcastSubscription(ctx context.Context, arg GetPodcastSubscriptionParams) (PodcastSubscriptions, error)
// The podcasts library/ies visible to the user (add-feed target list;
// a self-hosted instance typically has exactly one).
GetPodcastsLibraryForUser(ctx context.Context, userID pgtype.UUID) ([]GetPodcastsLibraryForUserRow, error)
// Get most popular books for a user // Get most popular books for a user
GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error) GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error)
// Single-issue read for the resolve/delete endpoints' family-lens gate. // Single-issue read for the resolve/delete endpoints' family-lens gate.
@@ -552,6 +590,9 @@ type Querier interface {
ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error) ListDeletedAnnotationsForBook(ctx context.Context, arg ListDeletedAnnotationsForBookParams) ([]ListDeletedAnnotationsForBookRow, error)
ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error) ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error)
ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error) ListDevicesByUser(ctx context.Context, userID pgtype.UUID) ([]Devices, error)
// Download-worker policy union (D6): most aggressive policy wins for
// what arrives. Runs under an admin identity (background job).
ListFeedSubscribers(ctx context.Context, feedID pgtype.UUID) ([]ListFeedSubscribersRow, error)
// Items hidden from libraries: either missing from disk (waiting on the // Items hidden from libraries: either missing from disk (waiting on the
// second scan that archives them) or already archived and pending purge. // second scan that archives them) or already archived and pending purge.
// Feeds the admin archived-items page. match_* columns identify an active // Feeds the admin archived-items page. match_* columns identify an active
@@ -571,11 +612,21 @@ type Querier interface {
// List media items that have no stored SHA-256 (imported before hashing existed) // List media items that have no stored SHA-256 (imported before hashing existed)
ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, error) ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, error)
ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error) ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSortedParams) ([]ListMediaItemsSortedRow, error)
// The podcasts page: the acting user's subscriptions with feed metadata
// and the user-owned subscription fields. Facet values derive in Go.
ListMyPodcastFeeds(ctx context.Context, userID pgtype.UUID) ([]ListMyPodcastFeedsRow, error)
ListPendingHashConflicts(ctx context.Context) ([]ListPendingHashConflictsRow, error) ListPendingHashConflicts(ctx context.Context) ([]ListPendingHashConflictsRow, error)
// DeviceID NULL = every device's pending items (the queue processor's // DeviceID NULL = every device's pending items (the queue processor's
// enumerator passes NULL; a bare `device_id = NULL` never matches, which // enumerator passes NULL; a bare `device_id = NULL` never matches, which
// silently disabled the offline flush until 2026-10-17). // silently disabled the offline flush until 2026-10-17).
ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error) ListPendingSyncQueueItems(ctx context.Context, arg ListPendingSyncQueueItemsParams) ([]SyncQueue, error)
// 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).
ListPodcastEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) ([]MediaItems, error)
// Poller worklist (admin identity): every feed with its validators and
// the back-off flag (is_complete feeds poll rarely, never disappear).
ListPodcastFeedsForPolling(ctx context.Context) ([]PodcastFeeds, error)
// LEFT JOIN (Phase 11): row-less import failures have NO media_items // LEFT JOIN (Phase 11): row-less import failures have NO media_items
// row — an inner join would silently drop the ledger's newest kind. // row — an inner join would silently drop the ledger's newest kind.
// Item-side projections COALESCE to keep them non-nullable strings; // Item-side projections COALESCE to keep them non-nullable strings;
@@ -583,6 +634,8 @@ type Querier interface {
ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error) ListProcessingIssuesByLibrary(ctx context.Context, libraryID pgtype.UUID) ([]ListProcessingIssuesByLibraryRow, error)
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error) ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error)
ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error)
// Download All + policy union worklist.
ListUndownloadedEpisodes(ctx context.Context, sourceFeedID pgtype.UUID) ([]MediaItems, error)
// List unresolved unlinked books with pagination // List unresolved unlinked books with pagination
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error) ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
// Page list: unresolved queue rows for the caller's own devices (ignored // Page list: unresolved queue rows for the caller's own devices (ignored
@@ -591,6 +644,14 @@ type Querier interface {
ListUsers(ctx context.Context) ([]ListUsersRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error)
// First consecutive scan that cannot find the file on disk. // First consecutive scan that cannot find the file on disk.
MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error MarkMediaItemMissing(ctx context.Context, id pgtype.UUID) error
// 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.
MarkPodcastEpisodeDownloaded(ctx context.Context, arg MarkPodcastEpisodeDownloadedParams) (MediaItems, error)
// Failure/rejection write: state + reason + whether a human already
// intervened (manual_retry resets the auto-retry budget). Existing
// metadata/file facts are never cleared.
MarkPodcastEpisodeFailed(ctx context.Context, arg MarkPodcastEpisodeFailedParams) error
// Content reappeared at a new location while the old path vanished: treat as // Content reappeared at a new location while the old path vanished: treat as
// a move. Repoints the row (and refreshes file-level fields) so reading // a move. Repoints the row (and refreshes file-level fields) so reading
// history follows the book; archive/missing state is cleared separately. // history follows the book; archive/missing state is cleared separately.
@@ -654,14 +715,30 @@ type Querier interface {
// Search Media Items queries // Search Media Items queries
SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error) SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, error)
SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error) SearchMediaItemsUnified(ctx context.Context, arg SearchMediaItemsUnifiedParams) ([]SearchMediaItemsUnifiedRow, error)
// 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.
SearchMyPodcastEpisodes(ctx context.Context, dollar_1 interface{}) ([]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)
SearchNarratorValues(ctx context.Context, arg SearchNarratorValuesParams) ([]SearchNarratorValuesRow, error) SearchNarratorValues(ctx context.Context, arg SearchNarratorValuesParams) ([]SearchNarratorValuesRow, error)
SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error) SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error)
SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error) SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error)
SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error) SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error)
// Library Visibility queries // Library Visibility queries
SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error) SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error)
// Worker claim: pending|failed -> queued. Attempts incremented at
// claim time so a crashed worker still counts the try.
SetPodcastEpisodeDownloadQueued(ctx context.Context, id pgtype.UUID) (int64, error)
SetPodcastEpisodeDownloading(ctx context.Context, id pgtype.UUID) error
// Manual retry (same endpoint as download): reset to pending so the
// worker re-claims it; the reason text clears with the fresh attempt.
SetPodcastEpisodePending(ctx context.Context, id pgtype.UUID) error
// Set system config // Set system config
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error) SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
SubscribePodcastFeed(ctx context.Context, arg SubscribePodcastFeedParams) (PodcastSubscriptions, error)
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
TombstoneMediaBookmarkByID(ctx context.Context, arg TombstoneMediaBookmarkByIDParams) error TombstoneMediaBookmarkByID(ctx context.Context, arg TombstoneMediaBookmarkByIDParams) error
@@ -726,6 +803,20 @@ type Querier interface {
UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error) UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error)
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error) UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error)
UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error UpdatePassword(ctx context.Context, arg UpdatePasswordParams) error
UpdatePodcastFeedArtwork(ctx context.Context, arg UpdatePodcastFeedArtworkParams) error
// Post-fetch bookkeeping: validators for conditional GETs, the fetch
// timestamp, and the feed's own last-build marker.
UpdatePodcastFeedFetch(ctx context.Context, arg UpdatePodcastFeedFetchParams) (PodcastFeeds, error)
// 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.
UpdatePodcastFeedMetadata(ctx context.Context, arg UpdatePodcastFeedMetadataParams) (PodcastFeeds, error)
// 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.
UpdatePodcastFeedRedirect(ctx context.Context, arg UpdatePodcastFeedRedirectParams) error
// User-owned fields only (D9): display name + auto-download policy.
UpdatePodcastSubscription(ctx context.Context, arg UpdatePodcastSubscriptionParams) (PodcastSubscriptions, error)
UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error) UpdateReadingProgress(ctx context.Context, arg UpdateReadingProgressParams) (ReadingProgress, error)
UpdateReadingSpeed(ctx context.Context, arg UpdateReadingSpeedParams) (ReadingSpeed, error) UpdateReadingSpeed(ctx context.Context, arg UpdateReadingSpeedParams) (ReadingSpeed, error)
UpdateSavedFilter(ctx context.Context, arg UpdateSavedFilterParams) (SavedFilters, error) UpdateSavedFilter(ctx context.Context, arg UpdateSavedFilterParams) (SavedFilters, error)
@@ -751,6 +842,17 @@ type Querier interface {
// content identical), and after a manifest-gated re-extract. // content identical), and after a manifest-gated re-extract.
UpsertMediaScanState(ctx context.Context, arg UpsertMediaScanStateParams) error UpsertMediaScanState(ctx context.Context, arg UpsertMediaScanStateParams) error
UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error) UpsertPanelData(ctx context.Context, arg UpsertPanelDataParams) (PanelData, error)
// ============================================================================
// Podcast episodes — media_items rows with source='podcast_episode'
// ============================================================================
// 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).
UpsertPodcastEpisode(ctx context.Context, arg UpsertPodcastEpisodeParams) (MediaItems, error)
UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error) UpsertReaderSettings(ctx context.Context, arg UpsertReaderSettingsParams) (ReaderSettings, error)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error) UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
// Ingest: insert or refresh the queue row for a device file that could not // Ingest: insert or refresh the queue row for a device file that could not
File diff suppressed because it is too large. Load diff
+335
View File
@@ -4590,3 +4590,338 @@ SET file_size = $2,
updated_at = NOW() updated_at = NOW()
WHERE id = $1 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; 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;
+9
View File
@@ -29,6 +29,12 @@ const (
LibraryTypeComics = "comics" LibraryTypeComics = "comics"
LibraryTypeManga = "manga" LibraryTypeManga = "manga"
LibraryTypeAudiobooks = "audiobooks" LibraryTypeAudiobooks = "audiobooks"
// Podcasts libraries are feed containers (RSS subscribe+download),
// not FS-scanned media: AllowedExtensions is deliberately empty —
// the scanner skips these folders wholesale (podcast downloads are
// written by the fetcher's download worker, never discovered by
// the walker). See podcast_service.go.
LibraryTypePodcasts = "podcasts"
) )
var AllowedExtensions = map[string][]string{ var AllowedExtensions = map[string][]string{
@@ -43,6 +49,9 @@ var AllowedExtensions = map[string][]string{
// browser-playable lossless/next-gen containers join for Batch B // browser-playable lossless/next-gen containers join for Batch B
// metadata parity (.wma deliberately absent: browsers can't play it). // metadata parity (.wma deliberately absent: browsers can't play it).
LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac", ".zip", ".opus", ".ogg", ".oga", ".aac", ".wav"}, LibraryTypeAudiobooks: {".m4b", ".m4a", ".mp3", ".flac", ".zip", ".opus", ".ogg", ".oga", ".aac", ".wav"},
// Empty BY DESIGN: nothing in a podcasts library is scanner-facing.
// The scanner additionally skips these folders outright.
LibraryTypePodcasts: {},
} }
var MimeTypes = map[string]string{ var MimeTypes = map[string]string{