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
+3799 -673

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']),
('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']),
('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;
-- 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)
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)
-- 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
-- -> hidden. No new index needed: library_visibility's
-- 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
FOR SELECT
USING (
@@ -1922,6 +2050,19 @@ CREATE POLICY p_media_items_visible ON media_items
AND lv.library_id = media_items.library_id
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
@@ -1942,7 +2083,59 @@ CREATE POLICY p_media_items_update ON media_items
CREATE POLICY p_media_items_delete ON media_items
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
-- 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).
-- ============================================================
+68 -14
View File
@@ -150,6 +150,7 @@ type Libraries struct {
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"`
AllowMemberFeeds bool `db:"allow_member_feeds" json:"allow_member_feeds"`
}
type LibraryFolders struct {
@@ -157,6 +158,7 @@ type LibraryFolders struct {
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
FolderPath string `db:"folder_path" json:"folder_path"`
CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"`
Purpose string `db:"purpose" json:"purpose"`
}
type LibraryTypes struct {
@@ -324,20 +326,35 @@ type MediaItems struct {
// Scan information from ComicInfo.xml (scanner group, resolution, etc.)
ScanInformation pgtype.Text `db:"scan_information" json:"scan_information"`
// Summary from ComicInfo.xml (may be merged with description from Calibre)
Summary pgtype.Text `db:"summary" json:"summary"`
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
TagsSearch []string `db:"tags_search" json:"tags_search"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"`
Summary pgtype.Text `db:"summary" json:"summary"`
MetadataOverrides []string `db:"metadata_overrides" json:"metadata_overrides"`
MissingScanCount int32 `db:"missing_scan_count" json:"missing_scan_count"`
ArchivedAt pgtype.Timestamptz `db:"archived_at" json:"archived_at"`
ChapterMetadata []byte `db:"chapter_metadata" json:"chapter_metadata"`
LibraryTypeName pgtype.Text `db:"library_type_name" json:"library_type_name"`
AudioMetadata []byte `db:"audio_metadata" json:"audio_metadata"`
TagsSearch []string `db:"tags_search" json:"tags_search"`
ContributorsSearch []string `db:"contributors_search" json:"contributors_search"`
NarratorsSearch []string `db:"narrators_search" json:"narrators_search"`
FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"`
OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"`
OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"`
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 {
@@ -405,6 +422,43 @@ type PanelData struct {
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.
type ProcessingIssues struct {
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
// future-hybrid excluded exclusion and an optional library scope.
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
// metadata.opf may only be written when NO other non-archived item
// 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)
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)
CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error)
// 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)
// ============================================================================
// 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
// ============================================================================
// 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
DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) 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)
DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error
DeleteSavedFilter(ctx context.Context, arg DeleteSavedFilterParams) (SavedFilters, error)
@@ -421,6 +448,17 @@ type Querier interface {
// Get OPDS tokens by device
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)
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
GetPopularBooks(ctx context.Context, arg GetPopularBooksParams) ([]GetPopularBooksRow, error)
// 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)
ListDevicesByType(ctx context.Context, deviceType string) ([]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
// second scan that archives them) or already archived and pending purge.
// 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)
ListMediaItemsMissingHash(ctx context.Context) ([]MediaItems, 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)
// DeviceID NULL = every device's pending items (the queue processor's
// enumerator passes NULL; a bare `device_id = NULL` never matches, which
// silently disabled the offline flush until 2026-10-17).
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
// row — an inner join would silently drop the ledger's newest kind.
// 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)
ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, 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
ListUnresolvedUnlinkedBooks(ctx context.Context, arg ListUnresolvedUnlinkedBooksParams) ([]ListUnresolvedUnlinkedBooksRow, error)
// 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)
// First consecutive scan that cannot find the file on disk.
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
// a move. Repoints the row (and refreshes file-level fields) so reading
// history follows the book; archive/missing state is cleared separately.
@@ -654,14 +715,30 @@ type Querier interface {
// Search Media Items queries
SearchMediaItems(ctx context.Context, arg SearchMediaItemsParams) ([]SearchMediaItemsRow, 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)
SearchSeriesValues(ctx context.Context, arg SearchSeriesValuesParams) ([]SearchSeriesValuesRow, error)
SearchTagsValues(ctx context.Context, arg SearchTagsValuesParams) ([]SearchTagsValuesRow, error)
SetDeviceSyncEnabled(ctx context.Context, arg SetDeviceSyncEnabledParams) (Devices, error)
// Library Visibility queries
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
SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error)
SubscribePodcastFeed(ctx context.Context, arg SubscribePodcastFeedParams) (PodcastSubscriptions, error)
SyncLibraryTypeExtensions(ctx context.Context, arg SyncLibraryTypeExtensionsParams) error
TombstoneMediaBookmarkByDedupKey(ctx context.Context, arg TombstoneMediaBookmarkByDedupKeyParams) error
TombstoneMediaBookmarkByID(ctx context.Context, arg TombstoneMediaBookmarkByIDParams) error
@@ -726,6 +803,20 @@ type Querier interface {
UpdateMediaNoteForSync(ctx context.Context, arg UpdateMediaNoteForSyncParams) (MediaNotes, error)
UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, 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)
UpdateReadingSpeed(ctx context.Context, arg UpdateReadingSpeedParams) (ReadingSpeed, 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.
UpsertMediaScanState(ctx context.Context, arg UpsertMediaScanStateParams) 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)
UpsertSystemSetting(ctx context.Context, arg UpsertSystemSettingParams) (SystemSettings, error)
// 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()
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;
+9
View File
@@ -29,6 +29,12 @@ const (
LibraryTypeComics = "comics"
LibraryTypeManga = "manga"
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{
@@ -43,6 +49,9 @@ var AllowedExtensions = map[string][]string{
// browser-playable lossless/next-gen containers join for Batch B
// metadata parity (.wma deliberately absent: browsers can't play it).
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{