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

+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"`