feat(podcasts): schema, RLS policy extension, sqlc queries — feed core foundations
Podcast integration task 1.1 (plan: podcast-integration.md §6/§8, D20).
WHY: podcasts are a new first-class media domain — RSS feeds users
subscribe to personally, with episodes as media_items rows so progress,
stats and the audio player work unchanged. Visibility is per-user
(strict: only your subscriptions), which after the RLS migration
(2026-10-29) belongs in the POLICIES, not Go helpers.
Schema (idempotent — schema.sql re-executes every boot):
- 'podcasts' library type seeded (ON CONFLICT DO NOTHING, ARRAY[]::TEXT[]
cast required — bare ARRAY[] 42802s under ON_ERROR_STOP)
- podcast_feeds: per-library feed entity (UNIQUE(library_id, feed_url)),
full RSS/iTunes/Podcasting-2.0 metadata inventory, conditional-GET
validators, is_complete poll backoff
- podcast_subscriptions: the ONLY user-writable podcast state (display
name, auto-download policy); deliberately UNGOVERNED — it IS the read
policy's lookup table
- media_items episode columns: source/source_feed_id/episode_guid/
feed_position/season/episode/enclosure_url/declared_duration/
transcript_url/chapters_url/hosts/guests + download state machine
(pending|queued|downloading|downloaded|failed + error + attempts),
all partial-indexed so scan rows never touch them
- libraries.allow_member_feeds (first per-library knob); library_folders
.purpose ('podcast' download roots, scanner-skipped)
RLS (inside the labeled policy block):
- p_media_items_visible gains the podcast-episode subscription predicate
+ admin escape (source IS DISTINCT FROM 'podcast_episode' guard keeps
scan rows on the cheap path); unset identity still fails loud
- podcast_feeds governed identically: library-visibility join AND
subscriber-or-admin SELECT; permissive write stand-ins (mandatory at
ENABLE time; fetcher/worker declare first-admin, add-feed upsert uses
ON CONFLICT DO UPDATE RETURNING so creators never need SELECT on
foreign rows)
- Verified against dev DB: migration identity sees nothing, admin sees
all, member sees feeds/episodes only post-subscribe (probed in a
rolled-back tx — no rows persisted)
sqlc: 30 new queries (feed upsert/CRUD, subscriptions, episode
upsert-with-write-after-success-guard, download state transitions,
poller worklist, subscription-scoped search) — no visibility clauses
anywhere, per the D20 scoping model.
This commit is contained in:
1 parent
4ab4f9fa90
commit
0b7ad0f376
6 files changed
+3176
-50
No files matched your search
+195
-2
@@ -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).
|
||||||
-- ============================================================
|
-- ============================================================
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+2481
-48
File diff suppressed because it is too large.
Load diff
@@ -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;
|
||||||
@@ -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{
|
||||||
|
|||||||
Reference in new issue
Block a user