collections gains filter_tree JSONB (idempotent in-place; same qf DSL as saved filters — the scope key included). IsCollectionSmart is the ONE detection (tree OR legacy rules), and EvaluateSmartMembership is the ONE evaluator: tree-bearing collections run the composed engine via ComposedLiveMedia (full media rows, so the collection surfaces keep their episode-card stamping, feed positions for the replay chain, and the family lens rides the mandatory head), while legacy rule arrays keep per-item FieldMatches until rewritten — one call surface, two storage epochs. All smart surfaces (detail JSON, counts, detail page, reader chain) dispatch through it; the search service is injected into the collection subsystem (variadic — main.go builds one shared instance; the test harness mirrors it). Write path: CreateCollection/UpdateCollection accept the tree, validated against the engine grammar at the HANDLER (an invalid tree is a 400, never a stored render-time 500) and exposed in the envelope (pin widened in TestContractPin_CollectionsJSON + changelog). Update semantics: an edit form that doesn't send a tree keeps the stored one. RulesToFilterTree converts legacy rules + match mode at upgrade time: episode rules map mechanically (feed→in-list, played/downloaded→ booleans, published_within→published_date+within_days), tree root mode absorbs rule_match_mode, episode rules force the episodes scope, and legacy starts_with/ends_with fall back to contains (documented). Read-compat by design: nothing is migrated at boot — legacy arrays evaluate exactly as before until the collection is rewritten. Pinned by TestRulesToFilterTree (unit conversion) + TestEpisodeCollectionsFilterTree (a tree collection evaluates through the engine, the chain follows the tree's live membership, invalid trees 400 at write, the tree wins when legacy rules coexist, and an edit without a tree keeps it).
5179 lines
200 KiB
SQL
5179 lines
200 KiB
SQL
-- name: CreateUser :one
|
|
INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role, timezone)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING id, email, username, theme, first_name, last_name, role, timezone, created_at, updated_at;
|
|
|
|
-- name: GetUserByEmail :one
|
|
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE email = $1;
|
|
|
|
-- name: GetUserByUsername :one
|
|
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE username = $1;
|
|
|
|
-- name: GetUserByEmailOrUsername :one
|
|
SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE email = $1 OR username = $1;
|
|
|
|
-- name: GetUserForLogin :one
|
|
SELECT id, email, username, password_hash, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE email = $1 OR username = $1;
|
|
|
|
-- name: GetUser :one
|
|
SELECT
|
|
u.id,
|
|
u.email,
|
|
u.username,
|
|
u.theme,
|
|
u.first_name,
|
|
u.last_name,
|
|
u.role,
|
|
u.max_devices,
|
|
u.created_at,
|
|
u.updated_at,
|
|
u.timezone,
|
|
u.default_audio_speed,
|
|
u.keep_screen_awake,
|
|
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
|
FROM users u
|
|
WHERE u.id = $1;
|
|
|
|
-- name: GetUserPasswordHash :one
|
|
SELECT password_hash FROM users WHERE id = $1;
|
|
|
|
-- name: ListUsers :many
|
|
SELECT
|
|
u.id,
|
|
u.email,
|
|
u.username,
|
|
u.theme,
|
|
u.first_name,
|
|
u.last_name,
|
|
u.role,
|
|
u.max_devices,
|
|
u.created_at,
|
|
u.updated_at,
|
|
(SELECT COUNT(*) FROM devices WHERE user_id = u.id) as device_count
|
|
FROM users u
|
|
ORDER BY u.created_at DESC;
|
|
|
|
-- Library Types queries
|
|
-- name: GetLibraryTypes :many
|
|
SELECT * FROM library_types ORDER BY name;
|
|
|
|
-- name: GetLibraryType :one
|
|
SELECT * FROM library_types WHERE id = $1;
|
|
|
|
-- name: GetLibraryTypeByName :one
|
|
SELECT * FROM library_types WHERE name = $1;
|
|
|
|
-- name: SyncLibraryTypeExtensions :exec
|
|
UPDATE library_types SET allowed_extensions = $2 WHERE name = $1;
|
|
|
|
-- Libraries queries
|
|
-- name: CreateLibrary :one
|
|
INSERT INTO libraries (name, description, library_type_id, created_by_admin_id)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING *;
|
|
|
|
-- name: GetLibrary :one
|
|
SELECT l.*, lt.name as type_name, lt.description as type_description
|
|
FROM libraries l
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
WHERE l.id = $1;
|
|
|
|
-- name: ListLibraries :many
|
|
SELECT l.*, lt.name as type_name, lt.description as type_description
|
|
FROM libraries l
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
ORDER BY l.created_at DESC;
|
|
|
|
-- name: UpdateLibrary :one
|
|
UPDATE libraries SET
|
|
name = $2,
|
|
description = $3,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: DeleteLibrary :exec
|
|
DELETE FROM libraries WHERE id = $1;
|
|
|
|
-- Library Folders queries
|
|
-- name: AddLibraryFolder :one
|
|
-- purpose: 'scan' (FS-walker root, default) | 'podcast' (download root;
|
|
-- scanner-skipped, written by the podcast download worker instead).
|
|
INSERT INTO library_folders (library_id, folder_path, purpose) VALUES ($1, $2, $3) RETURNING *;
|
|
|
|
-- name: GetLibraryFolders :many
|
|
SELECT * FROM library_folders WHERE library_id = $1 ORDER BY created_at;
|
|
|
|
-- name: DeleteLibraryFolder :one
|
|
DELETE FROM library_folders WHERE library_id = $1 AND folder_path = $2 RETURNING *;
|
|
|
|
-- name: GetLibraryByFolder :one
|
|
SELECT lf.library_id, l.* FROM library_folders lf
|
|
JOIN libraries l ON lf.library_id = l.id
|
|
WHERE lf.folder_path = $1;
|
|
|
|
-- name: GetLibraryByFolderPathPrefix :one
|
|
SELECT lf.library_id, lf.folder_path, l.* FROM library_folders lf
|
|
JOIN libraries l ON lf.library_id = l.id
|
|
WHERE $1 LIKE lf.folder_path || '%'
|
|
ORDER BY LENGTH(lf.folder_path) DESC
|
|
LIMIT 1;
|
|
|
|
-- Library Visibility queries
|
|
-- name: SetLibraryVisibility :one
|
|
INSERT INTO library_visibility (user_id, library_id, is_visible)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (user_id, library_id)
|
|
DO UPDATE SET
|
|
is_visible = EXCLUDED.is_visible,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: GetLibraryVisibility :one
|
|
SELECT * FROM library_visibility WHERE user_id = $1 AND library_id = $2;
|
|
|
|
-- name: GetUserVisibleLibraries :many
|
|
SELECT l.*, lt.name as type_name, lt.description as type_description,
|
|
COALESCE(lv.is_visible, true) as is_visible
|
|
FROM libraries l
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
ORDER BY l.created_at ASC;
|
|
|
|
-- name: GetVisibleLibraryMediaCounts :many
|
|
SELECT l.id, COUNT(mi.id) as media_count
|
|
FROM libraries l
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = $1
|
|
LEFT JOIN media_items mi ON mi.library_id = l.id AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND mi.missing_scan_count = 0
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
GROUP BY l.id;
|
|
|
|
-- Media Items queries
|
|
-- name: CreateMediaItem :one
|
|
-- narrators/narrators_search are NOT NULL DEFAULT '{}' (A2): callers MUST
|
|
-- pass empty slices, never Go nil (an explicit NULL INSERT bypasses the
|
|
-- column default and 23502s). The API create handler normalizes nil.
|
|
INSERT INTO media_items (library_id, title, subtitle, authors, authors_search, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publishers, publishers_search, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, 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, library_type_name, audio_metadata, narrators, narrators_search, abridged, explicit)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52)
|
|
ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: CreateMediaItemIfNew :one
|
|
-- API create path: race-safe create-or-detect. On a duplicate
|
|
-- (library_id, file_path) this inserts NOTHING and returns no row
|
|
-- (pgx.ErrNoRows) — the handler then answers 200 with the existing row
|
|
-- and a duplicate flag instead of pretending it created something.
|
|
-- narrators NOT NULL: callers pass empty slices, never nil (see
|
|
-- CreateMediaItem note).
|
|
INSERT INTO media_items (library_id, title, subtitle, authors, authors_search, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publishers, publishers_search, contributors, contributors_search, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, imported_at, 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, library_type_name, audio_metadata, narrators, narrators_search, abridged, explicit)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52)
|
|
ON CONFLICT (library_id, file_path) DO NOTHING
|
|
RETURNING *;
|
|
|
|
-- name: GetMediaItemByLibraryAndPath :one
|
|
SELECT * FROM media_items WHERE library_id = $1 AND file_path = $2;
|
|
|
|
-- name: GetMediaItem :one
|
|
SELECT * FROM media_items WHERE id = $1;
|
|
|
|
-- name: ListMediaItems :many
|
|
-- Podcasts exclusion (plan §9): generic book surfaces never list
|
|
-- podcast episodes (library_type_name is the denormalized, trigger/
|
|
-- fetcher-maintained discriminator; NULL rows predate it and stay).
|
|
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
WHERE mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts')
|
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
|
ORDER BY mi.created_at DESC LIMIT $1 OFFSET $2;
|
|
|
|
-- name: ListMediaItemsByLibrary :many
|
|
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
WHERE mi.library_id = $1
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
ORDER BY mi.created_at DESC;
|
|
|
|
-- name: ListMediaItemsByLibraryIncludingArchived :many
|
|
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
WHERE mi.library_id = $1
|
|
ORDER BY mi.created_at DESC;
|
|
|
|
-- name: ListMediaItemsSorted :many
|
|
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
WHERE mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.library_id = sqlc.narg('library_id')
|
|
ORDER BY
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'title DESC' THEN mi.title
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'author ASC' THEN COALESCE(mi.author, '')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'author DESC' THEN COALESCE(mi.author, '')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'created_at ASC' THEN mi.created_at
|
|
ELSE '1970-01-01'::timestamp
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'created_at DESC' THEN mi.created_at
|
|
ELSE '1970-01-01'::timestamp
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'series ASC' THEN COALESCE(mi.series, '')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'series DESC' THEN COALESCE(mi.series, '')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'date_published ASC' THEN COALESCE(mi.date_published::text, '')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'date_published DESC' THEN COALESCE(mi.date_published::text, '')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'copyright_year ASC' THEN COALESCE(mi.copyright_year::text, '0')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'copyright_year DESC' THEN COALESCE(mi.copyright_year::text, '0')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'genre ASC' THEN COALESCE(mi.genre, '')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'genre DESC' THEN COALESCE(mi.genre, '')
|
|
ELSE ''
|
|
END DESC,
|
|
mi.created_at DESC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: UpdateMediaItem :one
|
|
UPDATE media_items SET
|
|
title = $2,
|
|
subtitle = $3,
|
|
authors = $4,
|
|
authors_search = $5,
|
|
isbn = $6,
|
|
description = $7,
|
|
cover_image_path = $8,
|
|
series = $9,
|
|
series_number = $10,
|
|
tags = $11,
|
|
tags_search = $12,
|
|
asin = $13,
|
|
date_published = $14,
|
|
publishers = $15,
|
|
publishers_search = $16,
|
|
contributors = $17,
|
|
contributors_search = $18,
|
|
language = $19,
|
|
edition = $20,
|
|
page_count = $21,
|
|
genre = $22,
|
|
copyright_year = $23,
|
|
goodreads_id = $24,
|
|
openlibrary_id = $25,
|
|
google_books_id = $26,
|
|
manga_type = $27,
|
|
reading_direction = $28,
|
|
series_count = $29,
|
|
volume = $30,
|
|
imprint = $31,
|
|
age_rating = $32,
|
|
web_url = $33,
|
|
metadata_notes = $34,
|
|
community_rating = $35,
|
|
story_arc = $36,
|
|
is_black_and_white = $37,
|
|
alternate_info = $38,
|
|
scan_information = $39,
|
|
summary = $40,
|
|
metadata_overrides = $41,
|
|
audio_metadata = $42,
|
|
narrators = $43,
|
|
narrators_search = $44,
|
|
abridged = $45,
|
|
explicit = $46,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: ClearMediaItemMetadataOverrides :exec
|
|
-- Reset an item to scanned defaults: clears per-field user overrides so the
|
|
-- next rescan can freely overwrite user-customized metadata.
|
|
UPDATE media_items SET metadata_overrides = '{}', updated_at = NOW()
|
|
WHERE id = $1;
|
|
|
|
-- name: MarkMediaItemMissing :exec
|
|
-- First consecutive scan that cannot find the file on disk.
|
|
UPDATE media_items SET missing_scan_count = missing_scan_count + 1, updated_at = NOW()
|
|
WHERE id = $1;
|
|
|
|
-- name: MoveMediaItemFilePath :exec
|
|
-- 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.
|
|
UPDATE media_items SET file_path = $2, file_size = $3, missing_scan_count = 0,
|
|
archived_at = NULL, updated_at = NOW()
|
|
WHERE id = $1;
|
|
|
|
-- name: ArchiveMediaItem :exec
|
|
-- Second consecutive missing scan: hide the item from all browsing while
|
|
-- preserving reading history in case the file returns.
|
|
UPDATE media_items SET archived_at = NOW(), updated_at = NOW()
|
|
WHERE id = $1;
|
|
|
|
-- name: MoveMediaItemToLibrary :exec
|
|
-- Cross-library move: identical content reappeared in a different library of
|
|
-- the same type while the old file vanished. Repoints the row so reading
|
|
-- history, annotations and collections follow the book; archive/missing
|
|
-- state is cleared. library_type_name is refreshed by trigger.
|
|
UPDATE media_items
|
|
SET library_id = $2,
|
|
file_path = $3,
|
|
file_size = $4,
|
|
archived_at = NULL,
|
|
missing_scan_count = 0,
|
|
updated_at = NOW()
|
|
WHERE id = $1;
|
|
|
|
-- name: ClearMediaItemArchive :exec
|
|
-- File is back on disk (by path or content hash): restore visibility and
|
|
-- reset the missing-scan counter. No-op for items that were never archived.
|
|
UPDATE media_items SET archived_at = NULL, missing_scan_count = 0, updated_at = NOW()
|
|
WHERE id = $1 AND (archived_at IS NOT NULL OR missing_scan_count > 0);
|
|
|
|
-- name: PurgeExpiredArchivedMediaItems :many
|
|
-- Retention sweep at library-scan time: hard-delete archived items older
|
|
-- than the cutoff. Cascades remove reading history with the row.
|
|
DELETE FROM media_items
|
|
WHERE archived_at IS NOT NULL AND archived_at < $1
|
|
RETURNING id, title;
|
|
|
|
-- name: PurgeAllArchivedMediaItems :many
|
|
-- Manual bulk purge from the library admin page.
|
|
DELETE FROM media_items
|
|
WHERE archived_at IS NOT NULL
|
|
RETURNING id, title;
|
|
|
|
-- name: CountArchivedMediaItems :one
|
|
SELECT COUNT(*) FROM media_items WHERE archived_at IS NOT NULL;
|
|
|
|
-- name: ListHiddenMediaItems :many
|
|
-- 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
|
|
-- item with identical content (same SHA-256) so the UI can offer merging the
|
|
-- archived row's reading data into its active twin instead of waiting for a
|
|
-- file that will never return.
|
|
SELECT mi.*, l.name as library_name, lt.name as library_type_name,
|
|
twin.id AS match_id, COALESCE(twin.title, '') AS match_title,
|
|
twin_library.name AS match_library_name
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
LEFT JOIN LATERAL (
|
|
SELECT t.id, t.title, t.library_id
|
|
FROM media_items t
|
|
JOIN libraries tl ON t.library_id = tl.id
|
|
JOIN library_types tlt ON tl.library_type_id = tlt.id
|
|
WHERE t.file_sha256 = mi.file_sha256
|
|
AND t.id <> mi.id
|
|
AND t.archived_at IS NULL
|
|
AND t.missing_scan_count = 0
|
|
ORDER BY (tlt.id = lt.id) DESC,
|
|
(tl.id = l.id) DESC,
|
|
(SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = t.id) DESC,
|
|
t.created_at ASC
|
|
LIMIT 1
|
|
) twin ON true
|
|
LEFT JOIN libraries twin_library ON twin_library.id = twin.library_id
|
|
WHERE mi.archived_at IS NOT NULL OR mi.missing_scan_count > 0
|
|
ORDER BY mi.archived_at NULLS LAST, mi.updated_at DESC;
|
|
|
|
-- name: DeleteMediaItem :exec
|
|
DELETE FROM media_items WHERE id = $1;
|
|
|
|
-- name: GetMediaItemByFilePath :one
|
|
SELECT * FROM media_items WHERE file_path = $1 AND library_id = $2;
|
|
|
|
-- GetScanStateByLibrary: the scanner's ONE bulk query per library scan
|
|
-- (faceted plan Part 2 §2) — every item's existence + scanner-owned
|
|
-- facts + sidecar manifest in a single pass. Replaces the per-file
|
|
-- GetMediaItemByFilePath flood (~3,094 queries per scan in prod).
|
|
-- Lean projection: metadata blobs (audio_metadata, chapter_metadata,
|
|
-- authors, description...) are NEVER loaded — the skip path never
|
|
-- needs them, and the change path re-reads the full row.
|
|
-- archived_at + missing_scan_count + file_sha256 ride along so the
|
|
-- skip path's archive-restore and hash self-heal checks stay
|
|
-- DB-free too.
|
|
-- name: GetScanStateByLibrary :many
|
|
SELECT
|
|
mi.id,
|
|
mi.file_path,
|
|
mi.file_size,
|
|
mi.archived_at,
|
|
mi.missing_scan_count,
|
|
mi.file_sha256,
|
|
s.manifest
|
|
FROM media_items mi
|
|
LEFT JOIN media_scan_state s ON s.media_item_id = mi.id
|
|
WHERE mi.library_id = sqlc.narg('library_id');
|
|
|
|
-- UpsertMediaScanState stores a book's sidecar manifest fingerprint
|
|
-- (faceted plan Part 2 §3). Written on backfill (first sighting —
|
|
-- no re-extraction), on repoint reconciliation (stat pairs refreshed,
|
|
-- content identical), and after a manifest-gated re-extract.
|
|
-- name: UpsertMediaScanState :exec
|
|
INSERT INTO media_scan_state (media_item_id, manifest)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (media_item_id) DO UPDATE SET manifest = EXCLUDED.manifest;
|
|
|
|
-- GetMediaScanState reads one item's manifest (per-item rescan paths
|
|
-- and the manifest integration tests).
|
|
-- name: GetMediaScanState :one
|
|
SELECT media_item_id, manifest FROM media_scan_state WHERE media_item_id = $1;
|
|
|
|
-- name: GetMediaItemByFilePathAnyLibrary :one
|
|
SELECT * FROM media_items WHERE file_path = $1 LIMIT 1;
|
|
|
|
-- name: GetReadingProgress :one
|
|
SELECT * FROM reading_progress WHERE media_item_id = $1 AND user_id = $2;
|
|
|
|
-- name: UpdateReadingProgress :one
|
|
INSERT INTO reading_progress (media_item_id, user_id, current_page, total_pages, last_read_at)
|
|
VALUES ($1, $2, $3, $4, NOW())
|
|
ON CONFLICT (media_item_id, user_id)
|
|
DO UPDATE SET
|
|
current_page = EXCLUDED.current_page,
|
|
total_pages = EXCLUDED.total_pages,
|
|
last_read_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: DeleteReadingProgress :exec
|
|
DELETE FROM reading_progress WHERE media_item_id = $1 AND user_id = $2;
|
|
|
|
-- name: UpdateUserTheme :exec
|
|
UPDATE users SET theme = $2, updated_at = NOW() WHERE id = $1;
|
|
|
|
-- name: UpdateUserProfile :exec
|
|
UPDATE users SET first_name = $2, last_name = $3, updated_at = NOW() WHERE id = $1;
|
|
|
|
-- name: UpdateUsername :exec
|
|
UPDATE users SET username = $2, updated_at = NOW() WHERE id = $1;
|
|
|
|
-- name: UpdateEmail :exec
|
|
UPDATE users SET email = $2, updated_at = NOW() WHERE id = $1;
|
|
|
|
-- name: UpdatePassword :exec
|
|
UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1;
|
|
|
|
-- name: UpdateUserMaxDevices :one
|
|
UPDATE users SET max_devices = $2, updated_at = NOW() WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateUserRole :one
|
|
UPDATE users SET role = $2, updated_at = NOW() WHERE id = $1
|
|
RETURNING id, email, username, role;
|
|
|
|
-- name: UpdateUserTimezone :exec
|
|
UPDATE users SET timezone = $2, updated_at = NOW() WHERE id = $1;
|
|
|
|
-- name: UpdateUserDefaultAudioSpeed :exec
|
|
UPDATE users SET default_audio_speed = $2, updated_at = NOW() WHERE id = $1;
|
|
|
|
-- name: UpdateUserKeepScreenAwake :exec
|
|
UPDATE users SET keep_screen_awake = $2, updated_at = NOW() WHERE id = $1;
|
|
|
|
-- Per-book playback speed preference (player plan §8).
|
|
|
|
-- name: GetAudioPlaybackSpeed :one
|
|
SELECT speed FROM audio_playback_speed
|
|
WHERE user_id = $1 AND media_item_id = $2;
|
|
|
|
-- name: UpsertAudioPlaybackSpeed :one
|
|
INSERT INTO audio_playback_speed (user_id, media_item_id, speed)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (user_id, media_item_id)
|
|
DO UPDATE SET speed = EXCLUDED.speed, updated_at = NOW()
|
|
RETURNING speed;
|
|
|
|
-- name: GetSystemTimezone :one
|
|
SELECT setting_value FROM system_settings WHERE setting_key = 'default_timezone';
|
|
|
|
-- name: CountUserDevices :one
|
|
SELECT COUNT(*) FROM devices WHERE user_id = $1;
|
|
|
|
-- name: GetFirstAdminExclude :one
|
|
SELECT id, email, username, theme, first_name, last_name, role, max_devices, created_at, updated_at FROM users
|
|
WHERE role = 'admin' AND id != $1
|
|
ORDER BY created_at ASC
|
|
LIMIT 1;
|
|
|
|
-- name: GetFirstAdmin :one
|
|
SELECT id FROM users
|
|
WHERE role = 'admin'
|
|
ORDER BY created_at ASC
|
|
LIMIT 1;
|
|
|
|
-- name: CountAdmins :one
|
|
SELECT COUNT(*) FROM users WHERE role = 'admin';
|
|
|
|
-- name: ReassignLibraries :exec
|
|
UPDATE libraries SET created_by_admin_id = $2, updated_at = NOW() WHERE created_by_admin_id = $1;
|
|
|
|
-- name: ReassignMediaItems :exec
|
|
UPDATE media_items SET added_by_admin_id = $2 WHERE added_by_admin_id = $1;
|
|
|
|
-- name: DeleteUser :exec
|
|
DELETE FROM users WHERE id = $1;
|
|
|
|
-- System Settings queries
|
|
-- name: GetSystemSetting :one
|
|
SELECT setting_value FROM system_settings WHERE setting_key = $1;
|
|
|
|
-- name: UpdateSystemSetting :exec
|
|
UPDATE system_settings SET setting_value = $2, updated_at = NOW() WHERE setting_key = $1;
|
|
|
|
-- name: UpsertSystemSetting :one
|
|
INSERT INTO system_settings (setting_key, setting_value, description, setting_type, min_value, max_value, requires_restart, category)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (setting_key) DO UPDATE
|
|
SET setting_value = EXCLUDED.setting_value,
|
|
description = EXCLUDED.description,
|
|
setting_type = EXCLUDED.setting_type,
|
|
min_value = EXCLUDED.min_value,
|
|
max_value = EXCLUDED.max_value,
|
|
requires_restart = EXCLUDED.requires_restart,
|
|
category = EXCLUDED.category,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: GetSystemSettingFull :one
|
|
SELECT * FROM system_settings WHERE setting_key = $1;
|
|
|
|
-- name: GetAllSystemSettings :many
|
|
SELECT setting_key, setting_value, description FROM system_settings ORDER BY setting_key;
|
|
|
|
-- name: GetAllSystemSettingsFull :many
|
|
SELECT * FROM system_settings ORDER BY category, setting_key;
|
|
|
|
-- name: CreateMediaRating :one
|
|
INSERT INTO media_ratings (media_item_id, user_id, rating)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (media_item_id, user_id)
|
|
DO UPDATE SET
|
|
rating = EXCLUDED.rating,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: GetMediaRating :one
|
|
SELECT * FROM media_ratings WHERE media_item_id = $1 AND user_id = $2;
|
|
|
|
-- name: GetMediaRatings :many
|
|
SELECT mr.*, u.username
|
|
FROM media_ratings mr
|
|
JOIN users u ON mr.user_id = u.id
|
|
WHERE mr.media_item_id = $1
|
|
ORDER BY mr.created_at DESC;
|
|
|
|
-- name: UpdateMediaRating :one
|
|
UPDATE media_ratings SET
|
|
rating = $3,
|
|
updated_at = NOW()
|
|
WHERE media_item_id = $1 AND user_id = $2
|
|
RETURNING *;
|
|
|
|
-- name: DeleteMediaRating :exec
|
|
DELETE FROM media_ratings WHERE media_item_id = $1 AND user_id = $2;
|
|
|
|
-- name: UpsertMediaReview :one
|
|
INSERT INTO media_reviews (media_item_id, user_id, review)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (media_item_id, user_id)
|
|
DO UPDATE SET
|
|
review = EXCLUDED.review,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: GetMediaReview :one
|
|
SELECT * FROM media_reviews WHERE media_item_id = $1 AND user_id = $2;
|
|
|
|
-- name: DeleteMediaReview :exec
|
|
DELETE FROM media_reviews WHERE media_item_id = $1 AND user_id = $2;
|
|
|
|
-- KOReader stats conversion: the device pushes its CUMULATIVE total reading
|
|
-- seconds for a book; the watermark is the highest total already converted,
|
|
-- read from the koreader sessions' own device_metadata. Delta-only insertion
|
|
-- means a reinstall of the device cannot double-count, and sub-threshold
|
|
-- deltas are never lost (they stay folded into the next push's delta).
|
|
-- name: GetKoreaderStatWatermark :one
|
|
SELECT COALESCE(MAX((device_metadata->>'total_seconds')::bigint), 0) AS total_seconds
|
|
FROM reading_history
|
|
WHERE user_id = $1
|
|
AND media_item_id = $2
|
|
AND device_metadata->>'source' = 'koreader';
|
|
|
|
-- Search Media Items queries
|
|
-- name: SearchMediaItems :many
|
|
SELECT mi.*, l.name as library_name, lt.name as library_type_name
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (
|
|
mi.title ILIKE sqlc.narg('search_pattern') OR
|
|
mi.author ILIKE sqlc.narg('search_pattern') OR
|
|
mi.series ILIKE sqlc.narg('search_pattern') OR
|
|
sqlc.narg('search_pattern') = ANY(mi.tags_search) OR
|
|
sqlc.narg('search_pattern') = ANY(mi.contributors_search)
|
|
)
|
|
ORDER BY
|
|
CASE
|
|
WHEN mi.title ILIKE sqlc.narg('search_pattern') THEN 1
|
|
WHEN mi.author ILIKE sqlc.narg('search_pattern') THEN 2
|
|
WHEN mi.series ILIKE sqlc.narg('search_pattern') THEN 3
|
|
WHEN sqlc.narg('search_pattern') = ANY(mi.tags_search) THEN 4
|
|
ELSE 5
|
|
END,
|
|
mi.title ASC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: SearchMediaItemsUnified :many
|
|
SELECT
|
|
mi.id,
|
|
mi.library_id,
|
|
mi.title,
|
|
mi.author,
|
|
mi.isbn,
|
|
mi.description,
|
|
mi.file_path,
|
|
mi.file_size,
|
|
mi.mime_type,
|
|
mi.cover_image_path,
|
|
mi.series,
|
|
mi.series_number,
|
|
mi.tags,
|
|
mi.asin,
|
|
mi.date_published,
|
|
mi.publisher,
|
|
mi.contributors,
|
|
mi.language,
|
|
mi.edition,
|
|
mi.page_count,
|
|
mi.genre,
|
|
mi.copyright_year,
|
|
mi.goodreads_id,
|
|
mi.openlibrary_id,
|
|
mi.google_books_id,
|
|
mi.added_by_admin_id,
|
|
mi.created_at,
|
|
mi.updated_at,
|
|
mi.format_group,
|
|
mi.format_mimetype,
|
|
mi.is_reflowable,
|
|
mi.has_fixed_layout,
|
|
mi.total_characters,
|
|
mi.chapter_count,
|
|
mi.entitlement_id,
|
|
mi.revision_number,
|
|
mi.kobo_content_id,
|
|
mi.kobo_metadata,
|
|
mi.tags_search,
|
|
mi.contributors_search,
|
|
mi.narrators,
|
|
mi.narrators_search,
|
|
mi.abridged,
|
|
mi.subtitle,
|
|
mi.explicit,
|
|
mi.authors,
|
|
mi.authors_search,
|
|
mi.publishers,
|
|
mi.publishers_search,
|
|
mi.audio_metadata,
|
|
mi.file_sha256,
|
|
mi.opf_identifier,
|
|
mi.opf_uuid,
|
|
mi.hash_confidence,
|
|
l.name as library_name,
|
|
lt.name as library_type_name,
|
|
-- Viewer's own progress (search-result badges): NULL when the
|
|
-- requester never opened the book. Reuses the SAME requester param
|
|
-- the visibility join binds; UNIQUE(media_item, user) keeps the
|
|
-- join row-neutral.
|
|
rp.percentage as viewer_percentage
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND (sqlc.narg('library_id')::uuid IS NULL
|
|
OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
-- Fuzzy author filter — per-element against the authors[] array (A2c
|
|
-- §4.1): a co-authored book matches ANY one of its authors, and a
|
|
-- comma-joined param is multi-select (OR within the facet).
|
|
AND (sqlc.narg('author_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
|
|
JOIN unnest(mi.authors) AS a ON true
|
|
WHERE word_similarity(f, COALESCE(a, '')) > 0.3
|
|
))
|
|
-- Fuzzy series filter
|
|
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
|
-- Fuzzy genre filter
|
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
|
-- Fuzzy publisher filter — per-element against publishers[] (A2c §4.1).
|
|
AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
|
|
JOIN unnest(mi.publishers) AS p ON true
|
|
WHERE word_similarity(f, COALESCE(p, '')) > 0.3
|
|
))
|
|
-- Fuzzy language filter
|
|
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
|
-- Tags filter (NEW - fuzzy match against tags array)
|
|
AND (sqlc.narg('tags_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
|
))
|
|
-- Narrator filter (A2a — arrays use the *_search column)
|
|
AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
|
))
|
|
-- Facet registry Batch A additions (services/facet_registry.go is the
|
|
-- Go-side source of truth for these params). Comma = OR-within for
|
|
-- every text facet; scalar-fuzzy uses word_similarity per value,
|
|
-- exact enums use ANY(), bools are presence-bound (NULL = unset).
|
|
AND (sqlc.narg('story_arc_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('imprint_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('edition_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ',')))
|
|
AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ',')))
|
|
AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ',')))
|
|
AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ',')))
|
|
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
|
|
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
|
|
-- Year range (exact) - prioritize date_published, fallback to copyright_year
|
|
AND (
|
|
sqlc.narg('year_min') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
|
|
)
|
|
AND (
|
|
sqlc.narg('year_max') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
|
|
)
|
|
-- Boolean (exact)
|
|
AND (
|
|
sqlc.narg('has_cover')::bool IS NULL OR -- Not specified = show all
|
|
(sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
(sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
-- Search query (fuzzy or exact based on quotes)
|
|
AND (
|
|
sqlc.narg('search_query') = '' OR
|
|
-- Fuzzy search (default)
|
|
sqlc.narg('is_exact_search') = false AND (
|
|
word_similarity(sqlc.narg('search_query'), mi.title) > 0.3 OR
|
|
word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3 OR
|
|
word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3 OR
|
|
EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('search_query'), tag) > 0.3
|
|
LIMIT 1
|
|
) OR
|
|
EXISTS (
|
|
SELECT 1 FROM unnest(mi.contributors_search) AS contributor
|
|
WHERE word_similarity(sqlc.narg('search_query'), contributor) > 0.3
|
|
LIMIT 1
|
|
) OR
|
|
EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity(sqlc.narg('search_query'), narrator) > 0.3
|
|
LIMIT 1
|
|
)
|
|
) OR
|
|
-- Exact search (with quotes)
|
|
-- Keeping old pattern commented out in case we want wildcard exact back. See search.go line 62
|
|
-- sqlc.narg('is_exact_search') = true AND (
|
|
-- mi.title ILIKE sqlc.narg('search_pattern') OR
|
|
-- mi.author ILIKE sqlc.narg('search_pattern') OR
|
|
-- mi.series ILIKE sqlc.narg('search_pattern') OR
|
|
-- sqlc.narg('search_pattern') = ANY(mi.tags_search) OR
|
|
-- sqlc.narg('search_pattern') = ANY(mi.contributors_search)
|
|
-- )
|
|
-- Exact search (with quotes) - true exact match, not substring
|
|
sqlc.narg('is_exact_search') = true AND (
|
|
mi.title = sqlc.narg('search_query') OR
|
|
COALESCE(mi.author, '') = sqlc.narg('search_query') OR
|
|
COALESCE(mi.series, '') = sqlc.narg('search_query') OR
|
|
sqlc.narg('search_query') = ANY(mi.tags_search) OR
|
|
sqlc.narg('search_query') = ANY(mi.contributors_search) OR
|
|
sqlc.narg('search_query') = ANY(mi.narrators_search)
|
|
)
|
|
)
|
|
ORDER BY
|
|
-- Primary sort: relevance score when searching
|
|
CASE
|
|
WHEN sqlc.narg('search_query') != '' THEN
|
|
GREATEST(
|
|
CASE WHEN sqlc.narg('is_exact_search') = false THEN
|
|
word_similarity(sqlc.narg('search_query'), mi.title)
|
|
ELSE 0 END,
|
|
CASE WHEN sqlc.narg('is_exact_search') = false THEN
|
|
word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, ''))
|
|
ELSE 0 END,
|
|
word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')),
|
|
word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')),
|
|
word_similarity(sqlc.narg('publisher_filter'), COALESCE(mi.publisher, '')),
|
|
(SELECT MAX(word_similarity(sqlc.narg('tags_filter'), tag))
|
|
FROM unnest(mi.tags_search) AS tag)
|
|
)
|
|
ELSE 0
|
|
END DESC,
|
|
-- Secondary sort: user-specified sort parameter
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'title ASC' THEN mi.title
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'title DESC' THEN mi.title
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'author ASC' THEN COALESCE(mi.author, '')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'author DESC' THEN COALESCE(mi.author, '')
|
|
ELSE ''
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'created_at ASC' THEN mi.created_at
|
|
ELSE '1970-01-01'::timestamp
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'created_at DESC' THEN mi.created_at
|
|
ELSE '1970-01-01'::timestamp
|
|
END DESC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'page_count ASC' THEN COALESCE(mi.page_count::text, '0')
|
|
ELSE ''
|
|
END ASC,
|
|
CASE
|
|
WHEN sqlc.narg('sort') = 'page_count DESC' THEN COALESCE(mi.page_count::text, '0')
|
|
ELSE ''
|
|
END DESC,
|
|
-- Tertiary sort: title (default fallback)
|
|
mi.title ASC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: SearchEpisodesForPicker :many
|
|
-- Episode picker scope (plan item 2.3): YOUR subscriptions' episodes.
|
|
-- The podcasts exclusion in SearchMediaItemsUnified stays (books
|
|
-- surfaces never list podcasts — plan §9); THIS query is the episode
|
|
-- picker's dedicated scope. Visibility is the engine's job (D20): the
|
|
-- media_items policy drops episodes from unsubscribed feeds and the
|
|
-- podcast_feeds join is governed the same way — non-subscribers see
|
|
-- nothing here without any Go-side clause.
|
|
SELECT mi.id, mi.title, mi.author, mi.cover_image_path, mi.library_id,
|
|
mi.format_group, mi.download_state,
|
|
pf.title AS feed_title
|
|
FROM media_items mi
|
|
JOIN podcast_feeds pf ON mi.source_feed_id = pf.id
|
|
WHERE mi.source = 'podcast_episode'
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.arg('q')::text = '' OR mi.title ILIKE '%' || sqlc.arg('q')::text || '%')
|
|
ORDER BY pf.title ASC, mi.feed_position ASC NULLS LAST
|
|
LIMIT 60;
|
|
|
|
-- name: SearchAuthorValues :many
|
|
SELECT
|
|
mi.author as value,
|
|
COUNT(*) as count,
|
|
word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, ''))::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')) > 0.3
|
|
AND mi.author IS NOT NULL
|
|
AND mi.author != ''
|
|
GROUP BY mi.author, word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, ''))::float8
|
|
ORDER BY score DESC, count DESC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: SearchGenreValues :many
|
|
SELECT
|
|
mi.genre as value,
|
|
COUNT(*) as count,
|
|
word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, ''))::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, '')) > 0.3
|
|
AND mi.genre IS NOT NULL
|
|
AND mi.genre != ''
|
|
GROUP BY mi.genre, word_similarity(sqlc.narg('search_query'), COALESCE(mi.genre, ''))::float8
|
|
ORDER BY score DESC, count DESC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: SearchTagsValues :many
|
|
SELECT
|
|
tag::TEXT as value,
|
|
COUNT(*) as count,
|
|
word_similarity(sqlc.narg('search_query'), tag)::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
CROSS JOIN LATERAL unnest(mi.tags_search) AS tag
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND tag IS NOT NULL
|
|
AND word_similarity(sqlc.narg('search_query'), tag) > 0.3
|
|
GROUP BY tag, word_similarity(sqlc.narg('search_query'), tag)::float8
|
|
ORDER BY word_similarity(sqlc.narg('search_query'), tag)::float8 DESC, COUNT DESC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: SearchNarratorValues :many
|
|
SELECT
|
|
narrator::TEXT as value,
|
|
COUNT(*) as count,
|
|
word_similarity(sqlc.narg('search_query'), narrator)::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
CROSS JOIN LATERAL unnest(mi.narrators) AS narrator
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND narrator IS NOT NULL
|
|
AND word_similarity(sqlc.narg('search_query'), narrator) > 0.3
|
|
GROUP BY narrator, word_similarity(sqlc.narg('search_query'), narrator)::float8
|
|
ORDER BY word_similarity(sqlc.narg('search_query'), narrator)::float8 DESC, COUNT DESC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: SearchSeriesValues :many
|
|
SELECT
|
|
mi.series as value,
|
|
COUNT(*) as count,
|
|
word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, ''))::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')) > 0.3
|
|
AND mi.series IS NOT NULL
|
|
AND mi.series != ''
|
|
GROUP BY mi.series, word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, ''))::float8
|
|
ORDER BY score DESC, count DESC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: SearchLanguageValues :many
|
|
SELECT
|
|
mi.language as value,
|
|
COUNT(*) as count,
|
|
word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, ''))::float8 as score
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, '')) > 0.3
|
|
AND mi.language IS NOT NULL
|
|
AND mi.language != ''
|
|
GROUP BY mi.language, word_similarity(sqlc.narg('search_query'), COALESCE(mi.language, ''))::float8
|
|
ORDER BY score DESC, count DESC
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- Facet-value listings (faceted-navigation Batch C v2, faceted plan
|
|
-- Phase 7): ALL listable facets' top values + counts in ONE statement —
|
|
-- one round trip serves the filter drawer's panel, with classic faceted
|
|
-- semantics: each arm OMITS its own facet's filter block and carries
|
|
-- every OTHER registered facet's block (copied mechanically from
|
|
-- SearchMediaItemsUnified — keep the two in lockstep), so with filters
|
|
-- active each facet counts under the others, and with no filters the
|
|
-- blocks are neutral and the counts are the v1 global-per-library ones.
|
|
-- One query serves both cases.
|
|
--
|
|
-- Visibility: same family-lens join as the search query — a user's
|
|
-- hidden-library rows are absent; the handler 403s an out-of-set
|
|
-- explicit library_id. library_id is nullable: NULL = the All-Libraries
|
|
-- view (no scope beyond the lens). Bool/range facets (has_cover,
|
|
-- explicit, abridged, year) filter every arm but list no values — they
|
|
-- render as toggles/inputs, not lists.
|
|
--
|
|
-- Per-arm LIMIT (parens required: ORDER BY/LIMIT inside a compound
|
|
-- select bind per-arm only in parenthesized subqueries). The same
|
|
-- sqlc.narg names repeat across arms; sqlc binds them once.
|
|
|
|
-- name: FacetCountsUnderFilters :many
|
|
(SELECT 'author'::text AS facet, a::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
CROSS JOIN LATERAL unnest(mi.authors) AS a
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
-- author's own block omitted: a facet counts under the others
|
|
AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
|
))
|
|
AND (sqlc.narg('tags_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
|
))
|
|
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
|
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
|
AND (sqlc.narg('story_arc_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('imprint_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('edition_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ',')))
|
|
AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ',')))
|
|
AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ',')))
|
|
AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ',')))
|
|
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
|
|
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
|
|
AND (
|
|
sqlc.narg('year_min') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
|
|
)
|
|
AND (
|
|
sqlc.narg('year_max') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
|
|
)
|
|
AND (
|
|
sqlc.narg('has_cover')::bool IS NULL OR
|
|
(sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
(sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND a IS NOT NULL AND a != ''
|
|
GROUP BY a
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT sqlc.narg('limit'))
|
|
UNION ALL
|
|
(SELECT 'publisher'::text AS facet, p::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
CROSS JOIN LATERAL unnest(mi.publishers) AS p
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('author_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
|
))
|
|
AND (sqlc.narg('tags_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
|
))
|
|
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
|
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
|
AND (sqlc.narg('story_arc_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('imprint_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('edition_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ',')))
|
|
AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ',')))
|
|
AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ',')))
|
|
AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ',')))
|
|
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
|
|
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
|
|
AND (
|
|
sqlc.narg('year_min') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
|
|
)
|
|
AND (
|
|
sqlc.narg('year_max') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
|
|
)
|
|
AND (
|
|
sqlc.narg('has_cover')::bool IS NULL OR
|
|
(sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
(sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND p IS NOT NULL AND p != ''
|
|
GROUP BY p
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT sqlc.narg('limit'))
|
|
UNION ALL
|
|
(SELECT 'narrator'::text AS facet, n::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
CROSS JOIN LATERAL unnest(mi.narrators) AS n
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('author_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('tags_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
|
))
|
|
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
|
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
|
AND (sqlc.narg('story_arc_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('imprint_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('edition_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ',')))
|
|
AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ',')))
|
|
AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ',')))
|
|
AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ',')))
|
|
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
|
|
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
|
|
AND (
|
|
sqlc.narg('year_min') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
|
|
)
|
|
AND (
|
|
sqlc.narg('year_max') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
|
|
)
|
|
AND (
|
|
sqlc.narg('has_cover')::bool IS NULL OR
|
|
(sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
(sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND n IS NOT NULL AND n != ''
|
|
GROUP BY n
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT sqlc.narg('limit'))
|
|
UNION ALL
|
|
(SELECT 'tags'::text AS facet, t::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
CROSS JOIN LATERAL unnest(mi.tags) AS t
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('author_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
|
))
|
|
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
|
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
|
AND (sqlc.narg('story_arc_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('imprint_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('edition_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ',')))
|
|
AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ',')))
|
|
AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ',')))
|
|
AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ',')))
|
|
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
|
|
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
|
|
AND (
|
|
sqlc.narg('year_min') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
|
|
)
|
|
AND (
|
|
sqlc.narg('year_max') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
|
|
)
|
|
AND (
|
|
sqlc.narg('has_cover')::bool IS NULL OR
|
|
(sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
(sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND t IS NOT NULL AND t != ''
|
|
GROUP BY t
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT sqlc.narg('limit'))
|
|
UNION ALL
|
|
(SELECT 'series'::text AS facet, mi.series::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('author_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
|
))
|
|
AND (sqlc.narg('tags_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
|
))
|
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
|
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
|
AND (sqlc.narg('story_arc_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('imprint_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('edition_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ',')))
|
|
AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ',')))
|
|
AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ',')))
|
|
AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ',')))
|
|
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
|
|
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
|
|
AND (
|
|
sqlc.narg('year_min') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
|
|
)
|
|
AND (
|
|
sqlc.narg('year_max') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
|
|
)
|
|
AND (
|
|
sqlc.narg('has_cover')::bool IS NULL OR
|
|
(sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
(sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.series IS NOT NULL AND mi.series != ''
|
|
GROUP BY mi.series
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT sqlc.narg('limit'))
|
|
UNION ALL
|
|
(SELECT 'genre'::text AS facet, mi.genre::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('author_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
|
))
|
|
AND (sqlc.narg('tags_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
|
))
|
|
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
|
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
|
AND (sqlc.narg('story_arc_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('imprint_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('edition_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ',')))
|
|
AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ',')))
|
|
AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ',')))
|
|
AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ',')))
|
|
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
|
|
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
|
|
AND (
|
|
sqlc.narg('year_min') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
|
|
)
|
|
AND (
|
|
sqlc.narg('year_max') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
|
|
)
|
|
AND (
|
|
sqlc.narg('has_cover')::bool IS NULL OR
|
|
(sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
(sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.genre IS NOT NULL AND mi.genre != ''
|
|
GROUP BY mi.genre
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT sqlc.narg('limit'))
|
|
UNION ALL
|
|
(SELECT 'language'::text AS facet, mi.language::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('author_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
|
))
|
|
AND (sqlc.narg('tags_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
|
))
|
|
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
|
AND (sqlc.narg('story_arc_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('imprint_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('edition_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ',')))
|
|
AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ',')))
|
|
AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ',')))
|
|
AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ',')))
|
|
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
|
|
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
|
|
AND (
|
|
sqlc.narg('year_min') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
|
|
)
|
|
AND (
|
|
sqlc.narg('year_max') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
|
|
)
|
|
AND (
|
|
sqlc.narg('has_cover')::bool IS NULL OR
|
|
(sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
(sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.language IS NOT NULL AND mi.language != ''
|
|
GROUP BY mi.language
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT sqlc.narg('limit'))
|
|
UNION ALL
|
|
(SELECT 'story_arc'::text AS facet, mi.story_arc::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('author_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
|
))
|
|
AND (sqlc.narg('tags_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
|
))
|
|
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
|
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
|
AND (sqlc.narg('imprint_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('edition_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ',')))
|
|
AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ',')))
|
|
AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ',')))
|
|
AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ',')))
|
|
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
|
|
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
|
|
AND (
|
|
sqlc.narg('year_min') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
|
|
)
|
|
AND (
|
|
sqlc.narg('year_max') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
|
|
)
|
|
AND (
|
|
sqlc.narg('has_cover')::bool IS NULL OR
|
|
(sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
(sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.story_arc IS NOT NULL AND mi.story_arc != ''
|
|
GROUP BY mi.story_arc
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT sqlc.narg('limit'))
|
|
UNION ALL
|
|
(SELECT 'imprint'::text AS facet, mi.imprint::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('author_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
|
))
|
|
AND (sqlc.narg('tags_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
|
))
|
|
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
|
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
|
AND (sqlc.narg('story_arc_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('edition_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ',')))
|
|
AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ',')))
|
|
AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ',')))
|
|
AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ',')))
|
|
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
|
|
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
|
|
AND (
|
|
sqlc.narg('year_min') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
|
|
)
|
|
AND (
|
|
sqlc.narg('year_max') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
|
|
)
|
|
AND (
|
|
sqlc.narg('has_cover')::bool IS NULL OR
|
|
(sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
(sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.imprint IS NOT NULL AND mi.imprint != ''
|
|
GROUP BY mi.imprint
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT sqlc.narg('limit'))
|
|
UNION ALL
|
|
(SELECT 'edition'::text AS facet, mi.edition::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('author_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
|
))
|
|
AND (sqlc.narg('tags_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
|
))
|
|
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
|
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
|
AND (sqlc.narg('story_arc_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('imprint_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ',')))
|
|
AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ',')))
|
|
AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ',')))
|
|
AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ',')))
|
|
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
|
|
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
|
|
AND (
|
|
sqlc.narg('year_min') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
|
|
)
|
|
AND (
|
|
sqlc.narg('year_max') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
|
|
)
|
|
AND (
|
|
sqlc.narg('has_cover')::bool IS NULL OR
|
|
(sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
(sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.edition IS NOT NULL AND mi.edition != ''
|
|
GROUP BY mi.edition
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT sqlc.narg('limit'))
|
|
UNION ALL
|
|
(SELECT 'age_rating'::text AS facet, mi.age_rating::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('author_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
|
))
|
|
AND (sqlc.narg('tags_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
|
))
|
|
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
|
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
|
AND (sqlc.narg('story_arc_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('imprint_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('edition_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ',')))
|
|
AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ',')))
|
|
AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ',')))
|
|
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
|
|
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
|
|
AND (
|
|
sqlc.narg('year_min') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
|
|
)
|
|
AND (
|
|
sqlc.narg('year_max') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
|
|
)
|
|
AND (
|
|
sqlc.narg('has_cover')::bool IS NULL OR
|
|
(sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
(sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.age_rating IS NOT NULL AND mi.age_rating != ''
|
|
GROUP BY mi.age_rating
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT sqlc.narg('limit'))
|
|
UNION ALL
|
|
(SELECT 'format_group'::text AS facet, mi.format_group::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('author_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
|
))
|
|
AND (sqlc.narg('tags_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
|
))
|
|
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
|
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
|
AND (sqlc.narg('story_arc_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('imprint_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('edition_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ',')))
|
|
AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ',')))
|
|
AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ',')))
|
|
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
|
|
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
|
|
AND (
|
|
sqlc.narg('year_min') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
|
|
)
|
|
AND (
|
|
sqlc.narg('year_max') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
|
|
)
|
|
AND (
|
|
sqlc.narg('has_cover')::bool IS NULL OR
|
|
(sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
(sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.format_group IS NOT NULL AND mi.format_group != ''
|
|
GROUP BY mi.format_group
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT sqlc.narg('limit'))
|
|
UNION ALL
|
|
(SELECT 'manga_type'::text AS facet, mi.manga_type::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('author_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
|
))
|
|
AND (sqlc.narg('tags_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
|
))
|
|
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
|
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
|
AND (sqlc.narg('story_arc_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('imprint_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('edition_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ',')))
|
|
AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ',')))
|
|
AND (sqlc.narg('reading_direction_filter') = '' OR mi.reading_direction = ANY(string_to_array(sqlc.narg('reading_direction_filter'), ',')))
|
|
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
|
|
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
|
|
AND (
|
|
sqlc.narg('year_min') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
|
|
)
|
|
AND (
|
|
sqlc.narg('year_max') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
|
|
)
|
|
AND (
|
|
sqlc.narg('has_cover')::bool IS NULL OR
|
|
(sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
(sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.manga_type IS NOT NULL AND mi.manga_type != '' AND mi.manga_type != 'unknown'
|
|
GROUP BY mi.manga_type
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT sqlc.narg('limit'))
|
|
UNION ALL
|
|
(SELECT 'reading_direction'::text AS facet, mi.reading_direction::text AS value, COUNT(*)::bigint AS count
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id')
|
|
WHERE COALESCE(lv.is_visible, true) = true
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('author_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('author_filter'), ',')) AS f
|
|
JOIN unnest(mi.authors) AS ae ON true
|
|
WHERE word_similarity(f, COALESCE(ae, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('publisher_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('publisher_filter'), ',')) AS f
|
|
JOIN unnest(mi.publishers) AS pe ON true
|
|
WHERE word_similarity(f, COALESCE(pe, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('narrator_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.narrators_search) AS narrator
|
|
WHERE word_similarity(sqlc.narg('narrator_filter'), narrator) > 0.3
|
|
))
|
|
AND (sqlc.narg('tags_filter') = '' OR EXISTS (
|
|
SELECT 1 FROM unnest(mi.tags_search) AS tag
|
|
WHERE word_similarity(sqlc.narg('tags_filter'), tag) > 0.3
|
|
))
|
|
AND (sqlc.narg('series_filter') = '' OR word_similarity(sqlc.narg('series_filter'), COALESCE(mi.series, '')) > 0.3)
|
|
AND (sqlc.narg('genre_filter') = '' OR word_similarity(sqlc.narg('genre_filter'), COALESCE(mi.genre, '')) > 0.3)
|
|
AND (sqlc.narg('language_filter') = '' OR word_similarity(sqlc.narg('language_filter'), COALESCE(mi.language, '')) > 0.3)
|
|
AND (sqlc.narg('story_arc_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('story_arc_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.story_arc, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('imprint_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('imprint_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.imprint, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('edition_filter') = '' OR EXISTS (
|
|
SELECT 1
|
|
FROM unnest(string_to_array(sqlc.narg('edition_filter'), ',')) AS f
|
|
WHERE word_similarity(f, COALESCE(mi.edition, '')) > 0.3
|
|
))
|
|
AND (sqlc.narg('age_rating_filter') = '' OR mi.age_rating = ANY(string_to_array(sqlc.narg('age_rating_filter'), ',')))
|
|
AND (sqlc.narg('format_group_filter') = '' OR mi.format_group = ANY(string_to_array(sqlc.narg('format_group_filter'), ',')))
|
|
AND (sqlc.narg('manga_type_filter') = '' OR mi.manga_type = ANY(string_to_array(sqlc.narg('manga_type_filter'), ',')))
|
|
AND (sqlc.narg('explicit_filter')::bool IS NULL OR mi.explicit = sqlc.narg('explicit_filter')::bool)
|
|
AND (sqlc.narg('abridged_filter')::bool IS NULL OR mi.abridged = sqlc.narg('abridged_filter')::bool)
|
|
AND (
|
|
sqlc.narg('year_min') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) >= sqlc.narg('year_min') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year >= sqlc.narg('year_min'))
|
|
)
|
|
AND (
|
|
sqlc.narg('year_max') = 0 OR
|
|
EXTRACT(YEAR FROM mi.date_published) <= sqlc.narg('year_max') OR
|
|
(mi.date_published IS NULL AND mi.copyright_year <= sqlc.narg('year_max'))
|
|
)
|
|
AND (
|
|
sqlc.narg('has_cover')::bool IS NULL OR
|
|
(sqlc.narg('has_cover')::bool IS TRUE AND mi.cover_image_path IS NOT NULL) OR
|
|
(sqlc.narg('has_cover')::bool IS FALSE AND mi.cover_image_path IS NULL)
|
|
)
|
|
AND mi.reading_direction IS NOT NULL AND mi.reading_direction != '' AND mi.reading_direction != 'auto'
|
|
GROUP BY mi.reading_direction
|
|
ORDER BY count DESC, value ASC
|
|
LIMIT sqlc.narg('limit'));
|
|
|
|
|
|
-- Media Notes queries
|
|
-- name: GetMediaNote :one
|
|
SELECT * FROM media_notes WHERE id = $1;
|
|
|
|
-- name: GetMediaNotes :many
|
|
SELECT * FROM media_notes WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
|
|
|
|
-- Media Highlights queries
|
|
-- name: GetMediaHighlight :one
|
|
SELECT * FROM media_highlights WHERE id = $1;
|
|
|
|
-- name: GetMediaHighlights :many
|
|
SELECT * FROM media_highlights WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE ORDER BY created_at DESC;
|
|
|
|
-- ============================================
|
|
-- ANNOTATION SYNC QUERIES (highlights)
|
|
-- ============================================
|
|
|
|
-- name: GetMediaHighlightByDedupKey :one
|
|
SELECT * FROM media_highlights
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
|
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
|
LIMIT 1;
|
|
|
|
-- name: CreateMediaHighlightFull :one
|
|
INSERT INTO media_highlights (
|
|
media_item_id, user_id, selection_text,
|
|
start_position, end_position, color, note_text,
|
|
percentage_start, percentage_end,
|
|
epubcfi_start, epubcfi_end,
|
|
chapter_reference,
|
|
dedup_key, last_modified_at, last_modified_source,
|
|
device_sync_data
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
|
|
) RETURNING *;
|
|
|
|
-- name: UpdateMediaHighlightForSync :one
|
|
UPDATE media_highlights SET
|
|
selection_text = $2,
|
|
start_position = $3,
|
|
end_position = $4,
|
|
color = $5,
|
|
note_text = $6,
|
|
percentage_start = $7,
|
|
percentage_end = $8,
|
|
epubcfi_start = $9,
|
|
epubcfi_end = $10,
|
|
chapter_reference = $11,
|
|
last_modified_at = $12,
|
|
last_modified_source = $13,
|
|
device_sync_data = $14,
|
|
updated_at = NOW(),
|
|
deleted = FALSE,
|
|
deleted_at = NULL
|
|
WHERE id = $1 AND user_id = $15
|
|
RETURNING *;
|
|
|
|
-- name: TombstoneMediaHighlightByDedupKey :exec
|
|
UPDATE media_highlights SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
|
|
|
|
-- name: TombstoneMediaHighlightByID :exec
|
|
UPDATE media_highlights SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $2;
|
|
|
|
-- name: PurgeExpiredHighlightTombstones :exec
|
|
DELETE FROM media_highlights WHERE deleted = TRUE AND deleted_at < $1;
|
|
|
|
-- ============================================
|
|
-- ANNOTATION SYNC QUERIES (notes)
|
|
-- ============================================
|
|
|
|
-- name: GetMediaNoteByDedupKey :one
|
|
SELECT * FROM media_notes
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
|
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
|
LIMIT 1;
|
|
|
|
-- name: CreateMediaNoteFull :one
|
|
INSERT INTO media_notes (
|
|
media_item_id, user_id, content, position,
|
|
percentage_location, character_start, character_end,
|
|
epubcfi_location, chapter_reference, paragraph_reference,
|
|
dedup_key, last_modified_at, last_modified_source,
|
|
device_sync_data
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14
|
|
) RETURNING *;
|
|
|
|
-- name: UpdateMediaNoteForSync :one
|
|
UPDATE media_notes SET
|
|
content = $2,
|
|
position = $3,
|
|
percentage_location = $4,
|
|
character_start = $5,
|
|
character_end = $6,
|
|
epubcfi_location = $7,
|
|
chapter_reference = $8,
|
|
paragraph_reference = $9,
|
|
last_modified_at = $10,
|
|
last_modified_source = $11,
|
|
device_sync_data = $12,
|
|
updated_at = NOW(),
|
|
deleted = FALSE,
|
|
deleted_at = NULL
|
|
WHERE id = $1 AND user_id = $13
|
|
RETURNING *;
|
|
|
|
-- name: TombstoneMediaNoteByDedupKey :exec
|
|
UPDATE media_notes SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
|
|
|
|
-- name: TombstoneMediaNoteByID :exec
|
|
UPDATE media_notes SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $2;
|
|
|
|
-- name: PurgeExpiredNoteTombstones :exec
|
|
DELETE FROM media_notes WHERE deleted = TRUE AND deleted_at < $1;
|
|
|
|
-- ============================================
|
|
-- ANNOTATION SYNC QUERIES (bookmarks)
|
|
-- ============================================
|
|
|
|
-- name: GetMediaBookmarkByDedupKey :one
|
|
SELECT * FROM media_bookmarks
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3
|
|
ORDER BY deleted ASC, deleted_at DESC NULLS LAST
|
|
LIMIT 1;
|
|
|
|
-- name: CreateMediaBookmarkFull :one
|
|
INSERT INTO media_bookmarks (
|
|
media_item_id, user_id, page_number, chapter_number,
|
|
cfi_position, title, position, notes,
|
|
percentage_location, epubcfi_location, chapter_reference,
|
|
dedup_key, last_modified_at, last_modified_source,
|
|
device_sync_data, origin_source
|
|
) VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
|
|
) RETURNING *;
|
|
|
|
-- name: UpdateMediaBookmarkForSync :one
|
|
UPDATE media_bookmarks SET
|
|
page_number = $2,
|
|
chapter_number = $3,
|
|
cfi_position = $4,
|
|
title = $5,
|
|
position = $6,
|
|
notes = $7,
|
|
percentage_location = $8,
|
|
epubcfi_location = $9,
|
|
chapter_reference = $10,
|
|
last_modified_at = $11,
|
|
last_modified_source = $12,
|
|
device_sync_data = $13,
|
|
created_at = created_at,
|
|
deleted = FALSE,
|
|
deleted_at = NULL
|
|
WHERE id = $1 AND user_id = $14
|
|
RETURNING *;
|
|
|
|
-- name: TombstoneMediaBookmarkByDedupKey :exec
|
|
UPDATE media_bookmarks SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE user_id = $1 AND media_item_id = $2 AND dedup_key = $3 AND deleted = FALSE;
|
|
|
|
-- name: TombstoneMediaBookmarkByID :exec
|
|
UPDATE media_bookmarks SET
|
|
deleted = TRUE,
|
|
deleted_at = NOW(),
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $2;
|
|
|
|
-- name: PurgeExpiredBookmarkTombstones :exec
|
|
DELETE FROM media_bookmarks WHERE deleted = TRUE AND deleted_at < $1;
|
|
|
|
-- ============================================
|
|
-- ANNOTATION SERVE QUERIES
|
|
-- ============================================
|
|
|
|
-- name: GetActiveAnnotationsForBook :many
|
|
SELECT
|
|
mh.id,
|
|
mh.selection_text,
|
|
mh.start_position,
|
|
mh.end_position,
|
|
mh.color,
|
|
mh.created_at,
|
|
mh.updated_at,
|
|
'highlight' as annotation_type,
|
|
mh.percentage_start,
|
|
mh.percentage_end,
|
|
mh.epubcfi_start,
|
|
mh.epubcfi_end,
|
|
mh.note_text,
|
|
mh.dedup_key,
|
|
mh.last_modified_at,
|
|
mh.last_modified_source
|
|
FROM media_highlights mh
|
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = FALSE
|
|
UNION ALL
|
|
SELECT
|
|
mn.id,
|
|
mn.content,
|
|
mn.position,
|
|
NULL as end_position,
|
|
NULL as color,
|
|
mn.created_at,
|
|
mn.updated_at,
|
|
'note' as annotation_type,
|
|
mn.percentage_location as percentage_start,
|
|
NULL as percentage_end,
|
|
mn.epubcfi_location as epubcfi_start,
|
|
NULL as epubcfi_end,
|
|
NULL as note_text,
|
|
mn.dedup_key,
|
|
mn.last_modified_at,
|
|
mn.last_modified_source
|
|
FROM media_notes mn
|
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = FALSE
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: GetTombstonedAnnotationsForBook :many
|
|
SELECT
|
|
mh.id,
|
|
mh.dedup_key,
|
|
'highlight' as annotation_type,
|
|
mh.device_sync_data,
|
|
mh.deleted_at,
|
|
mh.start_position,
|
|
mh.end_position,
|
|
mh.epubcfi_start,
|
|
mh.epubcfi_end
|
|
FROM media_highlights mh
|
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE AND mh.deleted_at > $3
|
|
UNION ALL
|
|
SELECT
|
|
mn.id,
|
|
mn.dedup_key,
|
|
'note' as annotation_type,
|
|
mn.device_sync_data,
|
|
mn.deleted_at,
|
|
mn.position as start_position,
|
|
NULL as end_position,
|
|
mn.epubcfi_location as epubcfi_start,
|
|
NULL as epubcfi_end
|
|
FROM media_notes mn
|
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE AND mn.deleted_at > $3
|
|
UNION ALL
|
|
SELECT
|
|
mb.id,
|
|
mb.dedup_key,
|
|
'bookmark' as annotation_type,
|
|
mb.device_sync_data,
|
|
mb.deleted_at,
|
|
mb.position as start_position,
|
|
NULL as end_position,
|
|
mb.cfi_position as epubcfi_start,
|
|
NULL as epubcfi_end
|
|
FROM media_bookmarks mb
|
|
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE AND mb.deleted_at > $3
|
|
ORDER BY deleted_at DESC;
|
|
|
|
-- ============================================
|
|
-- ANNOTATION HISTORY (deleted-annotation archive)
|
|
-- ============================================
|
|
|
|
-- Lists every currently-tombstoned annotation for a book regardless of the
|
|
-- tombstone TTL: this backs the book page's "recently deleted" history where
|
|
-- users can restore or permanently remove entries. Rows whose tombstones have
|
|
-- been purged by the daily maintenance sweep no longer exist at all.
|
|
-- name: ListDeletedAnnotationsForBook :many
|
|
SELECT
|
|
mh.id,
|
|
mh.dedup_key,
|
|
'highlight' as annotation_type,
|
|
mh.selection_text as display_text,
|
|
mh.note_text as secondary_text,
|
|
mh.color,
|
|
mh.deleted_at,
|
|
mh.created_at
|
|
FROM media_highlights mh
|
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND mh.deleted = TRUE
|
|
UNION ALL
|
|
SELECT
|
|
mn.id,
|
|
mn.dedup_key,
|
|
'note' as annotation_type,
|
|
mn.content as display_text,
|
|
NULL::text as secondary_text,
|
|
NULL::text as color,
|
|
mn.deleted_at,
|
|
mn.created_at
|
|
FROM media_notes mn
|
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND mn.deleted = TRUE
|
|
UNION ALL
|
|
SELECT
|
|
mb.id,
|
|
mb.dedup_key,
|
|
'bookmark' as annotation_type,
|
|
mb.title as display_text,
|
|
mb.notes as secondary_text,
|
|
NULL::text as color,
|
|
mb.deleted_at,
|
|
mb.created_at
|
|
FROM media_bookmarks mb
|
|
WHERE mb.media_item_id = $1 AND mb.user_id = $2 AND mb.deleted = TRUE
|
|
ORDER BY deleted_at DESC;
|
|
|
|
-- name: RestoreMediaHighlightByID :execrows
|
|
UPDATE media_highlights SET
|
|
deleted = FALSE,
|
|
deleted_at = NULL,
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
|
|
|
-- name: RestoreMediaNoteByID :execrows
|
|
UPDATE media_notes SET
|
|
deleted = FALSE,
|
|
deleted_at = NULL,
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
|
|
|
-- name: RestoreMediaBookmarkByID :execrows
|
|
UPDATE media_bookmarks SET
|
|
deleted = FALSE,
|
|
deleted_at = NULL,
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
|
|
|
-- Permanent removal from the history (distinct from the TTL-driven purge,
|
|
-- which is maintenance). Scoped to the owning user and book.
|
|
-- name: PurgeMediaHighlightByID :execrows
|
|
DELETE FROM media_highlights
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
|
|
|
-- name: PurgeMediaNoteByID :execrows
|
|
DELETE FROM media_notes
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
|
|
|
-- name: PurgeMediaBookmarkByID :execrows
|
|
DELETE FROM media_bookmarks
|
|
WHERE id = $1 AND user_id = $2 AND media_item_id = $3 AND deleted = TRUE;
|
|
|
|
-- Refresh Tokens queries
|
|
-- name: CreateRefreshToken :one
|
|
INSERT INTO refresh_tokens (user_id, token, expires_at)
|
|
VALUES ($1, $2, $3)
|
|
RETURNING *;
|
|
|
|
-- name: GetRefreshToken :one
|
|
SELECT rt.*, u.email, u.username, u.role
|
|
FROM refresh_tokens rt
|
|
JOIN users u ON rt.user_id = u.id
|
|
WHERE rt.token = $1 AND rt.revoked_at IS NULL AND rt.expires_at > NOW();
|
|
|
|
-- name: RevokeRefreshToken :exec
|
|
UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1;
|
|
|
|
-- name: RevokeAllUserRefreshTokens :exec
|
|
UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL;
|
|
|
|
-- name: CleanupExpiredRefreshTokens :exec
|
|
DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - make_interval(secs => $1::double precision));
|
|
|
|
-- ============================================
|
|
-- FORMAT DETECTION & PROGRESS
|
|
-- ============================================
|
|
|
|
-- Update media item format group information
|
|
-- name: UpdateMediaItemFormatGroup :exec
|
|
UPDATE media_items
|
|
SET
|
|
format_group = $2,
|
|
format_mimetype = $3,
|
|
is_reflowable = $4,
|
|
has_fixed_layout = $5,
|
|
total_characters = $6,
|
|
chapter_count = $7,
|
|
updated_at = NOW()
|
|
WHERE id = $1;
|
|
|
|
-- Bulk update format group for all media items
|
|
-- name: BulkUpdateFormatGroups :exec
|
|
UPDATE media_items m
|
|
SET
|
|
format_group = detect_format_group(m.mime_type, m.file_path),
|
|
format_mimetype = m.mime_type,
|
|
is_reflowable = (detect_format_group(m.mime_type, m.file_path) = 'reflowable'),
|
|
has_fixed_layout = (detect_format_group(m.mime_type, m.file_path) IN ('fixed_layout', 'comic_archive')),
|
|
updated_at = NOW()
|
|
WHERE m.format_group IS NULL OR m.format_group = 'unknown';
|
|
|
|
-- Get universal progress for a book
|
|
-- name: GetUniversalProgress :one
|
|
SELECT
|
|
rp.id,
|
|
rp.media_item_id,
|
|
rp.user_id,
|
|
rp.current_page,
|
|
rp.total_pages,
|
|
rp.last_read_at,
|
|
rp.percentage,
|
|
rp.character_offset,
|
|
rp.epubcfi,
|
|
rp.context_text,
|
|
rp.chapter,
|
|
rp.chapter_progress,
|
|
rp.audio_offset_seconds,
|
|
rp.viewport_x,
|
|
rp.viewport_y,
|
|
rp.zoom_level,
|
|
rp.scroll_position_x,
|
|
rp.scroll_position_y,
|
|
rp.panel_number,
|
|
rp.reading_mode,
|
|
rp.last_sync_device,
|
|
rp.last_sync_source,
|
|
rp.last_sync_timestamp,
|
|
rp.conflict_detected,
|
|
rp.conflict_resolved,
|
|
mi.format_group,
|
|
mi.format_mimetype,
|
|
mi.is_reflowable,
|
|
mi.has_fixed_layout,
|
|
mi.total_characters,
|
|
mi.chapter_count
|
|
FROM reading_progress rp
|
|
JOIN media_items mi ON rp.media_item_id = mi.id
|
|
WHERE rp.media_item_id = $1 AND rp.user_id = $2;
|
|
|
|
-- Update universal progress
|
|
-- name: UpdateUniversalProgress :one
|
|
INSERT INTO reading_progress (
|
|
media_item_id,
|
|
user_id,
|
|
percentage,
|
|
character_offset,
|
|
epubcfi,
|
|
context_text,
|
|
chapter,
|
|
chapter_progress,
|
|
viewport_x,
|
|
viewport_y,
|
|
zoom_level,
|
|
scroll_position_x,
|
|
scroll_position_y,
|
|
panel_number,
|
|
reading_mode,
|
|
last_sync_device,
|
|
last_sync_source,
|
|
last_sync_timestamp,
|
|
current_page,
|
|
total_pages,
|
|
audio_offset_seconds,
|
|
last_read_at
|
|
)
|
|
VALUES (
|
|
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, $20, NOW()
|
|
)
|
|
ON CONFLICT (media_item_id, user_id)
|
|
DO UPDATE SET
|
|
percentage = EXCLUDED.percentage,
|
|
character_offset = EXCLUDED.character_offset,
|
|
epubcfi = EXCLUDED.epubcfi,
|
|
context_text = EXCLUDED.context_text,
|
|
chapter = EXCLUDED.chapter,
|
|
chapter_progress = EXCLUDED.chapter_progress,
|
|
viewport_x = EXCLUDED.viewport_x,
|
|
viewport_y = EXCLUDED.viewport_y,
|
|
zoom_level = EXCLUDED.zoom_level,
|
|
scroll_position_x = EXCLUDED.scroll_position_x,
|
|
scroll_position_y = EXCLUDED.scroll_position_y,
|
|
panel_number = EXCLUDED.panel_number,
|
|
reading_mode = EXCLUDED.reading_mode,
|
|
last_sync_device = EXCLUDED.last_sync_device,
|
|
last_sync_source = EXCLUDED.last_sync_source,
|
|
last_sync_timestamp = EXCLUDED.last_sync_timestamp,
|
|
current_page = EXCLUDED.current_page,
|
|
total_pages = EXCLUDED.total_pages,
|
|
audio_offset_seconds = EXCLUDED.audio_offset_seconds,
|
|
last_read_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- Get reading history for a user and book
|
|
-- name: GetReadingHistory :many
|
|
SELECT *
|
|
FROM reading_history
|
|
WHERE user_id = $1 AND media_item_id = $2
|
|
ORDER BY created_at DESC
|
|
LIMIT $3;
|
|
|
|
-- Create reading history entry
|
|
-- name: CreateReadingHistory :one
|
|
INSERT INTO reading_history (
|
|
user_id,
|
|
media_item_id,
|
|
device_id,
|
|
progress_percentage,
|
|
reading_session_start,
|
|
reading_session_end,
|
|
pages_read,
|
|
time_spent_seconds,
|
|
device_metadata
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING *;
|
|
|
|
-- Analytics queries
|
|
|
|
-- Get user reading history for analytics
|
|
-- name: GetUserReadingHistory :many
|
|
SELECT
|
|
rh.id,
|
|
rh.user_id,
|
|
rh.media_item_id,
|
|
rh.device_id,
|
|
rh.progress_percentage,
|
|
rh.reading_session_start,
|
|
rh.reading_session_end,
|
|
rh.pages_read,
|
|
rh.time_spent_seconds,
|
|
rh.device_metadata,
|
|
rh.created_at,
|
|
mi.title,
|
|
mi.author,
|
|
d.device_name,
|
|
d.device_type
|
|
FROM reading_history rh
|
|
JOIN media_items mi ON rh.media_item_id = mi.id
|
|
LEFT JOIN devices d ON rh.device_id = d.id
|
|
WHERE rh.user_id = $1
|
|
AND rh.created_at >= $2
|
|
AND rh.created_at <= $3
|
|
ORDER BY rh.created_at DESC;
|
|
|
|
-- Get user device usage statistics
|
|
-- name: GetUserDeviceUsage :many
|
|
SELECT
|
|
d.id,
|
|
d.device_name,
|
|
d.device_type,
|
|
COUNT(*) as sync_count,
|
|
MAX(rh.created_at) as last_sync,
|
|
SUM(rh.time_spent_seconds) as total_time_seconds
|
|
FROM devices d
|
|
JOIN reading_history rh ON rh.device_id = d.id
|
|
WHERE d.user_id = $1
|
|
GROUP BY d.id, d.device_name, d.device_type
|
|
ORDER BY sync_count DESC;
|
|
|
|
-- Get most popular books for a user
|
|
-- name: GetPopularBooks :many
|
|
SELECT
|
|
mi.id,
|
|
mi.title,
|
|
mi.author,
|
|
COUNT(*) as read_count,
|
|
AVG(rh.progress_percentage) as avg_completion,
|
|
MAX(rh.created_at) as last_read
|
|
FROM media_items mi
|
|
JOIN reading_history rh ON rh.media_item_id = mi.id
|
|
WHERE rh.user_id = $1
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
GROUP BY mi.id, mi.title, mi.author
|
|
ORDER BY read_count DESC
|
|
LIMIT $2;
|
|
|
|
-- STATISTICS (reading sessions) — time spent, pages, streaks, breakdowns.
|
|
-- Every stats query scopes to the requesting user's VISIBLE libraries
|
|
-- (family lens at the source) and single scan of reading_history.
|
|
|
|
-- Full session rows for a range (summary math runs in Go: tz buckets,
|
|
-- streaks, finished-book detection). The bucket/range key is the session
|
|
-- END instant (the push time; NULL falls back to created_at) — the same
|
|
-- value the capture stamps every write with.
|
|
-- name: GetUserReadingSessionsRange :many
|
|
SELECT
|
|
rh.id, rh.media_item_id, rh.device_id, rh.progress_percentage,
|
|
rh.reading_session_start, rh.reading_session_end,
|
|
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at,
|
|
mi.title, mi.author, mi.library_id, mi.cover_image_path,
|
|
mi.library_type_name
|
|
FROM reading_history rh
|
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
|
WHERE rh.user_id = sqlc.arg('user_id')
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
|
ORDER BY rh.created_at ASC;
|
|
|
|
-- Per-library aggregation for the same range.
|
|
-- name: GetUserLibraryBreakdown :many
|
|
SELECT
|
|
l.id::uuid AS library_id,
|
|
l.name,
|
|
COUNT(*)::int AS session_count,
|
|
SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds,
|
|
SUM(COALESCE(rh.pages_read, 0))::int AS total_pages,
|
|
COUNT(DISTINCT rh.media_item_id)::int AS book_count
|
|
FROM reading_history rh
|
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
|
JOIN libraries l ON l.id = mi.library_id
|
|
WHERE rh.user_id = sqlc.arg('user_id')
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
|
GROUP BY l.id, l.name
|
|
ORDER BY total_seconds DESC;
|
|
|
|
-- Most-read books for a range (minutes), for the top list.
|
|
-- name: GetTopBooksByMinutes :many
|
|
SELECT
|
|
mi.id::uuid AS media_item_id,
|
|
mi.title,
|
|
mi.author,
|
|
mi.library_id,
|
|
mi.cover_image_path,
|
|
SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds,
|
|
SUM(COALESCE(rh.pages_read, 0))::int AS total_pages,
|
|
COUNT(*)::int AS session_count,
|
|
MAX(rh.created_at) AS last_read
|
|
FROM reading_history rh
|
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
|
WHERE rh.user_id = sqlc.arg('user_id')
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
|
GROUP BY mi.id, mi.title, mi.author, mi.library_id, mi.cover_image_path
|
|
ORDER BY total_seconds DESC
|
|
LIMIT sqlc.arg('limit');
|
|
|
|
-- Lifetime session rows for ONE book (the book-detail stats card).
|
|
-- name: GetBookReadingSessions :many
|
|
SELECT
|
|
rh.id, rh.device_id, rh.progress_percentage,
|
|
rh.reading_session_start, rh.reading_session_end,
|
|
rh.pages_read, rh.time_spent_seconds, rh.device_metadata, rh.created_at
|
|
FROM reading_history rh
|
|
WHERE rh.user_id = sqlc.arg('user_id')
|
|
AND rh.media_item_id = sqlc.arg('media_item_id')
|
|
ORDER BY rh.created_at ASC;
|
|
|
|
-- Distinct ACTIVE days (any captured session) bucketed in the user's
|
|
-- timezone — the streak calendar. Scoped to a long look-back so the
|
|
-- "longest streak" and a current streak longer than the selected range
|
|
-- are both real.
|
|
-- name: GetUserReadingActiveDays :many
|
|
SELECT DISTINCT (COALESCE(rh.reading_session_end, rh.created_at) AT TIME ZONE sqlc.arg('tz')::text)::date::text AS day
|
|
FROM reading_history rh
|
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
|
WHERE rh.user_id = sqlc.arg('user_id')
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
|
ORDER BY day ASC;
|
|
|
|
-- Device existence probe for capture: the web/app progress paths
|
|
-- historically stuff the user id into DeviceID (it only fed a broadcast
|
|
-- label before stats); a capture row must only reference a REAL device.
|
|
-- name: DeviceExistsForUser :one
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM devices
|
|
WHERE id = sqlc.arg('device_id') AND user_id = sqlc.arg('user_id')
|
|
) AS has_device;
|
|
|
|
-- Per-device reading breakdown for a range. LEFT JOIN so pushes that
|
|
-- carry no device (web reader until it identifies) still appear as one
|
|
-- "unattributed" bucket — device minutes must reconcile with the totals.
|
|
-- name: GetUserDeviceBreakdown :many
|
|
SELECT
|
|
d.id,
|
|
d.device_name,
|
|
d.device_type,
|
|
COUNT(*)::int AS session_count,
|
|
SUM(COALESCE(rh.time_spent_seconds, 0))::int AS total_seconds,
|
|
SUM(COALESCE(rh.pages_read, 0))::int AS total_pages,
|
|
COUNT(DISTINCT rh.media_item_id)::int AS book_count
|
|
FROM reading_history rh
|
|
JOIN media_items mi ON mi.id = rh.media_item_id
|
|
LEFT JOIN devices d ON d.id = rh.device_id
|
|
WHERE rh.user_id = sqlc.arg('user_id')
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) >= sqlc.arg('start_time')
|
|
AND COALESCE(rh.reading_session_end, rh.created_at) <= sqlc.arg('end_time')
|
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
|
GROUP BY d.id, d.device_name, d.device_type
|
|
ORDER BY total_seconds DESC;
|
|
|
|
|
|
|
|
-- ============================================
|
|
-- DEVICE MANAGEMENT & AUTH
|
|
-- ============================================
|
|
|
|
-- Device Registration & Management
|
|
-- name: CreateDevice :one
|
|
INSERT INTO devices (user_id, device_name, device_type, device_identifier, auth_token, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING *;
|
|
|
|
-- name: GetDevice :one
|
|
SELECT * FROM devices WHERE id = $1;
|
|
|
|
-- name: GetDeviceByIdentifier :one
|
|
SELECT * FROM devices WHERE device_identifier = $1;
|
|
|
|
-- name: GetDeviceByAuthToken :one
|
|
SELECT * FROM devices WHERE auth_token = $1;
|
|
|
|
-- name: ListDevicesByUser :many
|
|
SELECT * FROM devices WHERE user_id = $1 ORDER BY created_at DESC;
|
|
|
|
-- name: ListDevicesByType :many
|
|
SELECT * FROM devices WHERE device_type = $1 ORDER BY created_at DESC;
|
|
|
|
-- name: UpdateDevice :one
|
|
UPDATE devices
|
|
SET
|
|
device_name = $2,
|
|
sync_enabled = $3,
|
|
auto_sync = $4,
|
|
sync_frequency_minutes = $5,
|
|
device_metadata = $6,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateDeviceLastSync :one
|
|
UPDATE devices
|
|
SET
|
|
last_sync = NOW(),
|
|
last_seen = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: SetDeviceSyncEnabled :one
|
|
UPDATE devices
|
|
SET
|
|
sync_enabled = $2,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateDeviceLastSeen :one
|
|
UPDATE devices
|
|
SET
|
|
last_seen = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: UpdateDeviceAuthToken :one
|
|
UPDATE devices
|
|
SET
|
|
auth_token = $2,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: RevokeDevice :exec
|
|
UPDATE devices
|
|
SET
|
|
auth_token = NULL,
|
|
sync_enabled = false,
|
|
updated_at = NOW()
|
|
WHERE id = $1;
|
|
|
|
-- name: DetachReadingHistoryFromDevice :exec
|
|
-- Device deletion (deregister on logout): detach the device's reading
|
|
-- history instead of deleting it — these rows are real captured stats
|
|
-- (the Users section reads them); reading_history.device_id is NO ACTION
|
|
-- so a blind device delete 500s once any history anchors to the device.
|
|
-- device_id is nullable: NULL keeps the session, loses the association.
|
|
UPDATE reading_history SET device_id = NULL WHERE device_id = $1;
|
|
|
|
-- name: DeleteDevice :exec
|
|
DELETE FROM devices WHERE id = $1;
|
|
|
|
-- name: DeleteDeviceByToken :exec
|
|
DELETE FROM devices WHERE auth_token = $1;
|
|
|
|
-- Sync Queue Management
|
|
-- name: CreateSyncQueueItem :one
|
|
INSERT INTO sync_queue (device_id, media_item_id, sync_type, sync_data, priority, max_attempts, status)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING *;
|
|
|
|
-- name: GetSyncQueueItem :one
|
|
SELECT * FROM sync_queue WHERE id = $1;
|
|
|
|
-- name: ListPendingSyncQueueItems :many
|
|
-- 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).
|
|
SELECT * FROM sync_queue
|
|
WHERE (@device_id::uuid IS NULL OR device_id = @device_id::uuid) AND status = 'pending'
|
|
ORDER BY priority ASC, created_at ASC
|
|
LIMIT @row_limit::int;
|
|
|
|
-- name: UpdateSyncQueueItemStatus :one
|
|
UPDATE sync_queue
|
|
SET
|
|
status = $2,
|
|
attempts = attempts + 1,
|
|
error_message = $3,
|
|
processed_at = CASE WHEN $2::varchar = 'completed' THEN NOW() ELSE NULL END
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: DeleteSyncQueueItem :exec
|
|
DELETE FROM sync_queue WHERE id = $1;
|
|
|
|
-- name: ClearDeviceSyncQueue :exec
|
|
DELETE FROM sync_queue WHERE device_id = $1;
|
|
|
|
-- name: GetFailedSyncQueueItems :many
|
|
SELECT * FROM sync_queue
|
|
WHERE status = 'failed' AND attempts < max_attempts
|
|
ORDER BY priority ASC, created_at ASC
|
|
LIMIT $1;
|
|
|
|
-- name: GetStuckSyncQueueItems :many
|
|
SELECT * FROM sync_queue
|
|
WHERE status = 'processing' AND created_at < NOW() - INTERVAL '1 hour'
|
|
ORDER BY priority ASC, created_at ASC;
|
|
|
|
-- name: IncrementSyncQueueAttempts :one
|
|
UPDATE sync_queue
|
|
SET
|
|
attempts = attempts + 1,
|
|
status = CASE
|
|
WHEN attempts + 1 >= max_attempts THEN 'failed'
|
|
ELSE 'pending'
|
|
END,
|
|
error_message = $2
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: GetSyncQueueStats :one
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE status = 'pending') as pending_count,
|
|
COUNT(*) FILTER (WHERE status = 'processing') as processing_count,
|
|
COUNT(*) FILTER (WHERE status = 'failed') as failed_count,
|
|
COUNT(*) FILTER (WHERE status = 'completed') as completed_count,
|
|
COUNT(*) as total_count
|
|
FROM sync_queue
|
|
WHERE device_id = $1;
|
|
|
|
-- name: ListAllSyncQueueItems :many
|
|
SELECT
|
|
sq.*,
|
|
d.device_name,
|
|
d.device_type,
|
|
u.email as user_email,
|
|
mi.title as media_title
|
|
FROM sync_queue sq
|
|
JOIN devices d ON sq.device_id = d.id
|
|
JOIN users u ON d.user_id = u.id
|
|
LEFT JOIN media_items mi ON sq.media_item_id = mi.id
|
|
ORDER BY sq.priority ASC, sq.created_at DESC
|
|
LIMIT $1 OFFSET $2;
|
|
|
|
-- name: GetNextRetryTime :one
|
|
SELECT
|
|
CASE
|
|
WHEN attempts = 0 THEN NOW()
|
|
WHEN attempts = 1 THEN NOW() + INTERVAL '1 minute'
|
|
WHEN attempts = 2 THEN NOW() + INTERVAL '5 minutes'
|
|
WHEN attempts = 3 THEN NOW() + INTERVAL '15 minutes'
|
|
WHEN attempts = 4 THEN NOW() + INTERVAL '1 hour'
|
|
ELSE NOW() + INTERVAL '24 hours'
|
|
END as next_retry_time;
|
|
|
|
-- Conflict Resolution
|
|
-- name: CreateSyncConflict :one
|
|
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING *;
|
|
|
|
-- name: CreateAutoResolvedSyncConflict :one
|
|
INSERT INTO sync_conflicts (media_item_id, user_id, conflict_type, conflict_data, resolution_status, resolution_data, resolved_at)
|
|
VALUES ($1, $2, $3, $4, 'auto_resolved', $5, NOW())
|
|
RETURNING *;
|
|
|
|
-- name: GetSyncConflict :one
|
|
SELECT * FROM sync_conflicts WHERE id = $1;
|
|
|
|
-- name: ListSyncConflictsByUser :many
|
|
SELECT sc.*, mi.title, mi.author
|
|
FROM sync_conflicts sc
|
|
JOIN media_items mi ON sc.media_item_id = mi.id
|
|
WHERE sc.user_id = $1 AND sc.resolution_status = 'unresolved'
|
|
ORDER BY sc.created_at DESC;
|
|
|
|
-- name: ListSyncConflictsByMediaItem :many
|
|
SELECT * FROM sync_conflicts
|
|
WHERE media_item_id = $1 AND user_id = $2
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: ResolveSyncConflict :one
|
|
UPDATE sync_conflicts
|
|
SET
|
|
resolution_status = $2,
|
|
resolution_data = $3,
|
|
resolved_by = $4,
|
|
resolved_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: DeleteSyncConflict :exec
|
|
DELETE FROM sync_conflicts WHERE id = $1;
|
|
|
|
-- name: ListAllConflictsByUserAndStatus :many
|
|
SELECT sc.*, mi.title, mi.author
|
|
FROM sync_conflicts sc
|
|
JOIN media_items mi ON sc.media_item_id = mi.id
|
|
WHERE sc.user_id = $1 AND sc.resolution_status = $2
|
|
ORDER BY sc.created_at DESC;
|
|
|
|
-- name: ListConflictsByUser :many
|
|
SELECT sc.*, mi.title, mi.author
|
|
FROM sync_conflicts sc
|
|
JOIN media_items mi ON sc.media_item_id = mi.id
|
|
WHERE sc.user_id = $1
|
|
ORDER BY sc.created_at DESC;
|
|
|
|
-- name: HasRecentConflictResolution :one
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM sync_conflicts
|
|
WHERE media_item_id = $1
|
|
AND user_id = $2
|
|
AND resolution_status != 'unresolved'
|
|
AND resolved_at > NOW() - INTERVAL '10 minutes'
|
|
);
|
|
|
|
-- ============================================
|
|
-- KOREADER SYNC PROTOCOL
|
|
-- ============================================
|
|
|
|
-- name: GetMediaItemByFilePathForSync :one
|
|
SELECT * FROM media_items WHERE file_path = $1;
|
|
|
|
-- name: GetUserProgressForBooks :many
|
|
SELECT
|
|
rp.media_item_id,
|
|
rp.user_id,
|
|
rp.percentage,
|
|
rp.character_offset,
|
|
rp.epubcfi,
|
|
rp.chapter,
|
|
rp.chapter_progress,
|
|
rp.current_page,
|
|
rp.total_pages,
|
|
rp.last_read_at,
|
|
rp.last_sync_device,
|
|
rp.last_sync_source,
|
|
mi.title,
|
|
mi.author,
|
|
mi.file_path
|
|
FROM reading_progress rp
|
|
JOIN media_items mi ON rp.media_item_id = mi.id
|
|
WHERE rp.user_id = $1
|
|
AND rp.media_item_id = ANY($2::uuid[])
|
|
ORDER BY rp.last_read_at DESC;
|
|
|
|
-- name: BulkUpdateProgressFromSync :many
|
|
SELECT * FROM bulk_update_progress_from_koreader($1::uuid, $2::jsonb);
|
|
|
|
-- name: GetAnnotationsForBook :many
|
|
SELECT
|
|
mh.id,
|
|
mh.selection_text,
|
|
mh.start_position,
|
|
mh.end_position,
|
|
mh.color,
|
|
mh.created_at,
|
|
mh.updated_at,
|
|
'highlight' as annotation_type,
|
|
mh.percentage_start,
|
|
mh.percentage_end,
|
|
mh.epubcfi_start,
|
|
mh.epubcfi_end
|
|
FROM media_highlights mh
|
|
WHERE mh.media_item_id = $1 AND mh.user_id = $2 AND COALESCE(mh.deleted, FALSE) = FALSE
|
|
UNION ALL
|
|
SELECT
|
|
mn.id,
|
|
mn.content,
|
|
mn.position,
|
|
NULL as end_position,
|
|
NULL as color,
|
|
mn.created_at,
|
|
mn.updated_at,
|
|
'note' as annotation_type,
|
|
mn.percentage_location as percentage_start,
|
|
NULL as percentage_end,
|
|
mn.epubcfi_location as epubcfi_start,
|
|
NULL as epubcfi_end
|
|
FROM media_notes mn
|
|
WHERE mn.media_item_id = $1 AND mn.user_id = $2 AND COALESCE(mn.deleted, FALSE) = FALSE
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: UpdateDeviceSyncTimestamp :one
|
|
UPDATE devices
|
|
SET
|
|
last_sync = NOW(),
|
|
last_seen = NOW(),
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: CreateSyncHistoryEntry :one
|
|
INSERT INTO sync_queue (device_id, media_item_id, sync_type, sync_data, priority, status)
|
|
VALUES ($1, $2, 'koreader_progress', $3, 5, 'completed')
|
|
RETURNING *;
|
|
|
|
-- name: GetUserMediaItemsForSync :many
|
|
SELECT
|
|
mi.id,
|
|
mi.title,
|
|
mi.author,
|
|
mi.file_path,
|
|
mi.mime_type,
|
|
mi.page_count,
|
|
mi.format_group,
|
|
mi.total_characters,
|
|
mi.chapter_count,
|
|
mi.entitlement_id,
|
|
mi.revision_number,
|
|
mi.file_size,
|
|
mi.file_sha256
|
|
FROM media_items mi
|
|
JOIN library_visibility lv ON mi.library_id = lv.library_id
|
|
WHERE lv.user_id = $1
|
|
AND lv.is_visible = true
|
|
ORDER BY mi.title ASC
|
|
LIMIT 1000;
|
|
|
|
-- name: CheckForProgressConflicts :one
|
|
SELECT COUNT(*) as conflict_count
|
|
FROM reading_progress
|
|
WHERE media_item_id = $1
|
|
AND user_id = $2
|
|
AND last_sync_timestamp > NOW() - INTERVAL '5 minutes'
|
|
AND last_sync_source != $3;
|
|
|
|
-- ============================================
|
|
-- KOBO SHELF MANAGEMENT QUERIES
|
|
-- ============================================
|
|
|
|
-- name: AddBookToKoboShelf :one
|
|
INSERT INTO kobo_shelves (device_id, media_item_id, shelf_name, shelf_position)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (device_id, media_item_id)
|
|
DO UPDATE SET
|
|
shelf_name = EXCLUDED.shelf_name,
|
|
shelf_position = EXCLUDED.shelf_position,
|
|
last_synced_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: RemoveBookFromKoboShelf :exec
|
|
DELETE FROM kobo_shelves WHERE device_id = $1 AND media_item_id = $2;
|
|
|
|
-- name: GetKoboShelfBooks :many
|
|
SELECT ks.*, mi.title, mi.author, mi.file_path, mi.mime_type, mi.entitlement_id, mi.kobo_content_id, mi.revision_number
|
|
FROM kobo_shelves ks
|
|
JOIN media_items mi ON ks.media_item_id = mi.id
|
|
WHERE ks.device_id = $1
|
|
ORDER BY ks.shelf_position ASC, ks.added_at ASC;
|
|
|
|
-- name: GetKoboShelfBooksByShelfName :many
|
|
SELECT ks.*, mi.title, mi.author, mi.file_path, mi.mime_type, mi.entitlement_id, mi.kobo_content_id, mi.revision_number
|
|
FROM kobo_shelves ks
|
|
JOIN media_items mi ON ks.media_item_id = mi.id
|
|
WHERE ks.device_id = $1 AND ks.shelf_name = $2
|
|
ORDER BY ks.shelf_position ASC, ks.added_at ASC;
|
|
|
|
-- name: UpdateKoboShelfBookPosition :exec
|
|
UPDATE kobo_shelves
|
|
SET shelf_position = $3, last_synced_at = NOW()
|
|
WHERE device_id = $1 AND media_item_id = $2;
|
|
|
|
-- name: ClearKoboShelf :exec
|
|
DELETE FROM kobo_shelves WHERE device_id = $1;
|
|
|
|
-- name: ClearKoboShelfByName :exec
|
|
DELETE FROM kobo_shelves WHERE device_id = $1 AND shelf_name = $2;
|
|
|
|
-- name: IsBookOnKoboShelf :one
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM kobo_shelves
|
|
WHERE device_id = $1 AND media_item_id = $2
|
|
) as on_shelf;
|
|
|
|
-- name: GetKoboShelfBookCount :one
|
|
SELECT COUNT(*) as book_count
|
|
FROM kobo_shelves
|
|
WHERE device_id = $1;
|
|
|
|
-- ============================================
|
|
-- KOBO ENTITLEMENT QUERIES
|
|
-- ============================================
|
|
|
|
-- name: CreateOrUpdateKoboEntitlement :one
|
|
INSERT INTO kobo_entitlements (device_id, media_item_id, entitlement_id, content_id, revision_number, purchase_date, kobo_metadata)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
ON CONFLICT (device_id, entitlement_id)
|
|
DO UPDATE SET
|
|
content_id = EXCLUDED.content_id,
|
|
revision_number = EXCLUDED.revision_number,
|
|
purchase_date = COALESCE(EXCLUDED.purchase_date, kobo_entitlements.purchase_date),
|
|
book_status = 'installed',
|
|
sync_status = 'synced',
|
|
kobo_metadata = EXCLUDED.kobo_metadata,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: GetKoboEntitlementsForDevice :many
|
|
SELECT ke.*, mi.title, mi.author, mi.file_path
|
|
FROM kobo_entitlements ke
|
|
JOIN media_items mi ON ke.media_item_id = mi.id
|
|
WHERE ke.device_id = $1
|
|
ORDER BY ke.accession_date DESC;
|
|
|
|
-- name: GetKoboEntitlementByContentId :one
|
|
SELECT ke.*, mi.title, mi.author, mi.file_path
|
|
FROM kobo_entitlements ke
|
|
JOIN media_items mi ON ke.media_item_id = mi.id
|
|
WHERE ke.device_id = $1 AND ke.content_id = $2;
|
|
|
|
-- name: GetKoboEntitlementByEntitlementId :one
|
|
SELECT ke.*, mi.title, mi.author, mi.file_path
|
|
FROM kobo_entitlements ke
|
|
JOIN media_items mi ON ke.media_item_id = mi.id
|
|
WHERE ke.device_id = $1 AND ke.entitlement_id = $2;
|
|
|
|
-- name: UpdateKoboEntitlementStatus :exec
|
|
UPDATE kobo_entitlements
|
|
SET book_status = $3, sync_status = 'synced', updated_at = NOW()
|
|
WHERE device_id = $1 AND entitlement_id = $2;
|
|
|
|
-- name: UpdateKoboEntitlementRevision :exec
|
|
UPDATE kobo_entitlements
|
|
SET revision_number = $3, updated_at = NOW()
|
|
WHERE device_id = $1 AND entitlement_id = $2;
|
|
|
|
-- name: DeleteKoboEntitlement :exec
|
|
DELETE FROM kobo_entitlements WHERE device_id = $1 AND entitlement_id = $2;
|
|
|
|
-- name: GenerateKoboEntitlementId :one
|
|
SELECT 'kobo_' || uuid_generate_v4()::TEXT as entitlement_id;
|
|
|
|
-- name: UpdateMediaItemKoboMetadata :one
|
|
UPDATE media_items
|
|
SET entitlement_id = $2,
|
|
kobo_content_id = $3,
|
|
revision_number = COALESCE($4, revision_number) + 1,
|
|
kobo_metadata = $5,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: GetMediaItemByKoboContentId :one
|
|
SELECT * FROM media_items WHERE kobo_content_id = $1;
|
|
|
|
-- Media Items Admin Operations
|
|
|
|
-- ============================================
|
|
-- UNIVERSAL BOOK IDENTIFIERS
|
|
-- ============================================
|
|
|
|
-- Update media item with universal identifiers
|
|
-- name: UpdateMediaItemIdentifiers :one
|
|
UPDATE media_items
|
|
SET
|
|
file_sha256 = $2,
|
|
opf_identifier = $3,
|
|
opf_uuid = $4,
|
|
hash_confidence = $5,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- Get media item by SHA-256 hash
|
|
-- name: GetMediaItemBySHA256 :one
|
|
SELECT * FROM media_items WHERE file_sha256 = $1;
|
|
|
|
-- Get media item by SHA-256 hash within a specific library (content dedup)
|
|
-- name: GetMediaItemBySHA256AndLibrary :one
|
|
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2;
|
|
|
|
-- name: GetMediaItemsBySHA256AndSameLibraryType :many
|
|
-- Cross-library move detection: identical content registered in a different
|
|
-- library of the same type (ebooks stay with ebooks, comics with comics).
|
|
-- Candidates may be active copies (deliberate duplicates - leave alone) or
|
|
-- hidden/archived rows whose file vanished (move candidates - the scanner
|
|
-- verifies disk state before repointing).
|
|
SELECT mi.*, lt.name AS library_type_name
|
|
FROM media_items mi
|
|
JOIN libraries l ON mi.library_id = l.id
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
JOIN libraries target_lib ON target_lib.id = sqlc.arg('library_id')
|
|
WHERE mi.file_sha256 = sqlc.arg('file_sha256')
|
|
AND mi.library_id <> sqlc.arg('library_id')
|
|
AND lt.id = target_lib.library_type_id
|
|
ORDER BY (mi.archived_at IS NOT NULL) DESC,
|
|
mi.missing_scan_count DESC,
|
|
mi.created_at ASC;
|
|
|
|
-- List all media items sharing a SHA-256 hash within a library (hash conflict group)
|
|
-- name: ListMediaItemsBySHA256AndLibrary :many
|
|
SELECT * FROM media_items WHERE file_sha256 = $1 AND library_id = $2 ORDER BY file_path;
|
|
|
|
-- List media items that have no stored SHA-256 (imported before hashing existed)
|
|
-- name: ListMediaItemsMissingHash :many
|
|
SELECT * FROM media_items WHERE file_sha256 IS NULL ORDER BY created_at;
|
|
|
|
-- Find content-duplicate groups (same library + SHA-256, more than one row)
|
|
-- name: FindHashConflictGroups :many
|
|
SELECT library_id, file_sha256, COUNT(*) AS dup_count
|
|
FROM media_items
|
|
WHERE file_sha256 IS NOT NULL
|
|
GROUP BY library_id, file_sha256
|
|
HAVING COUNT(*) > 1;
|
|
|
|
-- Per-item user-data counts, used when choosing which duplicate copy to keep
|
|
-- name: GetMediaItemUsageCounts :one
|
|
SELECT
|
|
(SELECT COUNT(*) FROM reading_progress rp WHERE rp.media_item_id = $1) AS progress_count,
|
|
(SELECT COUNT(*) FROM media_highlights mh WHERE mh.media_item_id = $1) AS highlights_count,
|
|
(SELECT COUNT(*) FROM media_bookmarks mb WHERE mb.media_item_id = $1) AS bookmarks_count,
|
|
(SELECT COUNT(*) FROM media_notes mn WHERE mn.media_item_id = $1) AS notes_count,
|
|
(SELECT COUNT(*) FROM collection_items ci WHERE ci.media_item_id = $1) AS collections_count;
|
|
|
|
-- HASH CONFLICTS QUERIES
|
|
|
|
-- Record a pending hash conflict (no-op if the group is already tracked, so
|
|
-- resolved groups stay resolved and are never re-flagged)
|
|
-- name: CreateHashConflict :exec
|
|
INSERT INTO hash_conflicts (library_id, file_sha256)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT (library_id, file_sha256) DO NOTHING;
|
|
|
|
-- name: ListPendingHashConflicts :many
|
|
SELECT hc.id, hc.library_id, hc.file_sha256, hc.created_at,
|
|
l.name AS library_name,
|
|
COUNT(mi.id) AS item_count
|
|
FROM hash_conflicts hc
|
|
JOIN libraries l ON l.id = hc.library_id
|
|
LEFT JOIN media_items mi ON mi.library_id = hc.library_id AND mi.file_sha256 = hc.file_sha256
|
|
WHERE hc.status = 'pending'
|
|
GROUP BY hc.id, hc.library_id, hc.file_sha256, hc.created_at, l.name
|
|
ORDER BY hc.created_at;
|
|
|
|
-- name: GetHashConflict :one
|
|
SELECT * FROM hash_conflicts WHERE id = $1;
|
|
|
|
-- name: ResolveHashConflict :exec
|
|
UPDATE hash_conflicts
|
|
SET status = 'resolved',
|
|
resolution = $2,
|
|
resolved_by = $3,
|
|
resolved_at = NOW()
|
|
WHERE id = $1;
|
|
|
|
-- Re-parent all child rows of p_source onto p_target (defined in schema.sql)
|
|
-- name: ReparentMediaItemChildren :exec
|
|
SELECT reparent_media_item_children($1::uuid, $2::uuid);
|
|
|
|
-- Get media item by OPF identifier
|
|
-- name: GetMediaItemByOPFIdentifier :one
|
|
SELECT * FROM media_items WHERE opf_identifier = $1;
|
|
|
|
-- Get media item by OPF UUID
|
|
-- name: GetMediaItemByOPFUUID :one
|
|
SELECT * FROM media_items WHERE opf_uuid = $1;
|
|
|
|
-- Query media items by multiple identifiers with confidence scoring
|
|
-- name: QueryMediaItemsByIdentifiers :many
|
|
SELECT
|
|
mi.id,
|
|
mi.file_sha256,
|
|
mi.opf_identifier,
|
|
mi.opf_uuid,
|
|
mi.isbn,
|
|
mi.asin,
|
|
mi.title,
|
|
mi.author,
|
|
mi.file_size,
|
|
mi.hash_confidence,
|
|
CASE
|
|
WHEN $1::uuid IS NOT NULL AND mi.id = $1::uuid THEN 1.0
|
|
WHEN mi.opf_uuid IS NOT NULL AND mi.opf_uuid = $2::text THEN 0.95
|
|
WHEN mi.file_sha256 IS NOT NULL AND mi.file_sha256 = $3::text THEN 0.9
|
|
WHEN mi.opf_identifier IS NOT NULL AND mi.opf_identifier = $4::text THEN 0.85
|
|
WHEN mi.isbn IS NOT NULL AND mi.isbn = $5::text THEN 0.8
|
|
WHEN mi.asin IS NOT NULL AND mi.asin = $6::text THEN 0.8
|
|
ELSE 0.5
|
|
END as confidence_score
|
|
FROM media_items mi
|
|
WHERE
|
|
($1::uuid IS NULL OR mi.id = $1::uuid)
|
|
OR ($2::text IS NULL OR mi.opf_uuid = $2::text)
|
|
OR ($3::text IS NULL OR mi.file_sha256 = $3::text)
|
|
OR ($4::text IS NULL OR mi.opf_identifier = $4::text)
|
|
OR ($5::text IS NULL OR mi.isbn = $5::text)
|
|
OR ($6::text IS NULL OR mi.asin = $6::text)
|
|
OR ($7::text IS NULL OR mi.title ILIKE '%' || $7::text || '%')
|
|
ORDER BY confidence_score DESC;
|
|
|
|
-- MEDIA ITEM FORMATS QUERIES
|
|
|
|
-- Create media item format
|
|
-- name: CreateMediaItemFormat :one
|
|
INSERT INTO media_item_formats (media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, converted_from_format_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
ON CONFLICT (media_item_id, format_type) DO UPDATE SET
|
|
file_path = EXCLUDED.file_path,
|
|
file_sha256 = EXCLUDED.file_sha256,
|
|
file_size_bytes = EXCLUDED.file_size_bytes,
|
|
mime_type = EXCLUDED.mime_type
|
|
RETURNING *;
|
|
|
|
-- Get media item formats
|
|
-- name: GetMediaItemFormats :many
|
|
SELECT * FROM media_item_formats WHERE media_item_id = $1;
|
|
|
|
-- Get media item format by type
|
|
-- name: GetMediaItemFormatByType :one
|
|
SELECT * FROM media_item_formats WHERE media_item_id = $1 AND format_type = $2;
|
|
|
|
-- Get media item format by SHA-256
|
|
-- name: GetMediaItemFormatBySHA256 :one
|
|
SELECT * FROM media_item_formats WHERE file_sha256 = $1;
|
|
|
|
-- Update media item format
|
|
-- name: UpdateMediaItemFormat :one
|
|
UPDATE media_item_formats
|
|
SET
|
|
file_path = $2,
|
|
file_sha256 = $3,
|
|
file_size_bytes = $4,
|
|
mime_type = $5
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- Delete media item format
|
|
-- name: DeleteMediaItemFormat :exec
|
|
DELETE FROM media_item_formats WHERE id = $1;
|
|
|
|
-- DEVICE FILE ALIASES QUERIES
|
|
|
|
-- Create device file alias
|
|
-- name: CreateDeviceFileAlias :one
|
|
INSERT INTO device_file_aliases (media_item_id, device_id, file_path, file_sha256, confidence_score)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING *;
|
|
|
|
-- Get device file alias
|
|
-- name: GetDeviceFileAlias :one
|
|
SELECT * FROM device_file_aliases WHERE device_id = $1 AND file_path = $2;
|
|
|
|
-- Get device file aliases by device
|
|
-- name: GetDeviceFileAliasesByDevice :many
|
|
SELECT dfa.*, mi.title, mi.author
|
|
FROM device_file_aliases dfa
|
|
JOIN media_items mi ON dfa.media_item_id = mi.id
|
|
WHERE dfa.device_id = $1
|
|
ORDER BY dfa.last_seen_at DESC;
|
|
|
|
-- Get device file alias by SHA-256
|
|
-- name: GetDeviceFileAliasBySHA256 :one
|
|
SELECT dfa.*, mi.title, mi.author
|
|
FROM device_file_aliases dfa
|
|
JOIN media_items mi ON dfa.media_item_id = mi.id
|
|
WHERE dfa.file_sha256 = $1;
|
|
|
|
-- Update device file alias
|
|
-- name: UpdateDeviceFileAlias :one
|
|
UPDATE device_file_aliases
|
|
SET
|
|
media_item_id = $2,
|
|
file_sha256 = $3,
|
|
confidence_score = $4,
|
|
last_seen_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- Delete device file alias
|
|
-- name: DeleteDeviceFileAlias :exec
|
|
DELETE FROM device_file_aliases WHERE id = $1;
|
|
|
|
-- COLLECTIONS QUERIES
|
|
|
|
-- Create collection
|
|
-- name: CreateCollection :one
|
|
INSERT INTO collections (user_id, name, description, color, icon, auto_assign_rules, view_settings, show_on_dashboard, filter_tree)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
RETURNING *;
|
|
|
|
-- A2c §2.4: provenance + playlist flags on an existing collection without
|
|
-- touching the rest of the row.
|
|
-- name: UpdateCollectionViewSettings :exec
|
|
-- (Historical bug, fixed 2026-10-30: this wrote `updated_at = NOW()` but
|
|
-- collections has no updated_at column — the scanner's series-import
|
|
-- call has been failing silently, its error discarded. Fixed by
|
|
-- dropping the phantom column write.)
|
|
UPDATE collections SET view_settings = $2 WHERE id = $1;
|
|
|
|
-- Smart-collection match semantics (plan item 2.4): flip any|all on its
|
|
-- own channel — the rules page toggle writes exactly this column.
|
|
-- name: UpdateCollectionMatchMode :exec
|
|
UPDATE collections SET rule_match_mode = $2 WHERE id = $1;
|
|
|
|
-- Get collection
|
|
-- name: GetCollection :one
|
|
SELECT * FROM collections WHERE id = $1;
|
|
|
|
-- Get collections by user
|
|
-- name: GetCollectionsByUser :many
|
|
SELECT * FROM collections WHERE user_id = $1 AND deleted_at IS NULL ORDER BY created_at DESC;
|
|
|
|
-- Get collection with book count
|
|
-- name: GetCollectionWithBookCount :one
|
|
SELECT
|
|
c.*,
|
|
COUNT(ci.id) as book_count
|
|
FROM collections c
|
|
LEFT JOIN collection_items ci ON c.id = ci.collection_id
|
|
WHERE c.id = $1
|
|
GROUP BY c.id;
|
|
|
|
-- Update collection
|
|
-- name: UpdateCollection :one
|
|
UPDATE collections
|
|
SET
|
|
name = $2,
|
|
description = $3,
|
|
color = $4,
|
|
icon = $5,
|
|
auto_assign_rules = $6,
|
|
view_settings = $7,
|
|
rule_match_mode = $8,
|
|
filter_tree = $9
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- Replace ONLY the auto_assign_rules document (rule CRUD: read-modify-write
|
|
-- of the JSONB without touching metadata stale clients did not send).
|
|
-- name: UpdateCollectionRulesJSON :one
|
|
UPDATE collections SET auto_assign_rules = $2 WHERE id = $1 RETURNING *;
|
|
|
|
-- Delete collection
|
|
-- name: DeleteCollection :exec
|
|
DELETE FROM collections WHERE id = $1;
|
|
|
|
-- COLLECTION ITEMS QUERIES
|
|
|
|
-- Add book to collection
|
|
-- name: AddBookToCollection :one
|
|
INSERT INTO collection_items (collection_id, media_item_id, added_by_user_id)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (collection_id, media_item_id) DO NOTHING
|
|
RETURNING *;
|
|
|
|
-- A2c: playlist upsert — position set ONLY on insert, so rescans and
|
|
-- re-adds never clobber the user's manual drag ordering.
|
|
-- name: AddBookToCollectionOrdered :one
|
|
INSERT INTO collection_items (collection_id, media_item_id, added_by_user_id, position)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (collection_id, media_item_id) DO NOTHING
|
|
RETURNING *;
|
|
|
|
-- A2c: full-order rewrite for the drag-to-reorder endpoint. The payload is
|
|
-- the complete ordered id list; positions become 1..n in payload order.
|
|
-- name: ReorderCollectionItems :exec
|
|
UPDATE collection_items AS ci
|
|
SET position = d.ord
|
|
FROM unnest(sqlc.arg('media_item_ids')::uuid[]) WITH ORDINALITY AS d(media_item_id, ord)
|
|
WHERE ci.collection_id = sqlc.arg('collection_id')::uuid
|
|
AND ci.media_item_id = d.media_item_id;
|
|
|
|
-- A2c §2.4: playlist-collection discovery — provenance key first so a user
|
|
-- rename can't spawn duplicates on the next scan.
|
|
-- name: GetCollectionByImportedSeries :one
|
|
SELECT * FROM collections
|
|
WHERE user_id = sqlc.arg('user_id')
|
|
AND view_settings->>'imported_from_series' = sqlc.arg('imported_from_series')::text
|
|
AND deleted_at IS NULL
|
|
ORDER BY created_at ASC
|
|
LIMIT 1;
|
|
|
|
-- name: GetCollectionByUserAndName :one
|
|
SELECT * FROM collections
|
|
WHERE user_id = $1 AND name = $2 AND deleted_at IS NULL
|
|
LIMIT 1;
|
|
|
|
-- Remove book from collection
|
|
-- name: RemoveBookFromCollection :exec
|
|
DELETE FROM collection_items WHERE collection_id = $1 AND media_item_id = $2;
|
|
|
|
-- Get collection items
|
|
-- A2c: position-first ordering (playlist reading order); un-positioned
|
|
-- legacy items keep added_at ASC after the positioned tail — NOT DESC,
|
|
-- which walked a playlist backwards.
|
|
-- name: GetCollectionItems :many
|
|
SELECT ci.*, mi.title, mi.author, mi.cover_image_path, mi.library_id, mi.format_group
|
|
FROM collection_items ci
|
|
JOIN media_items mi ON ci.media_item_id = mi.id
|
|
WHERE ci.collection_id = $1
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
|
|
|
|
-- name: GetCollectionItemsDetailed :many
|
|
-- Episode-collections detail (plan item 2.3): the collection's full
|
|
-- membership for the SSR detail page, with per-row visibility. LEFT
|
|
-- JOIN + RLS: rows whose media is invisible to the requester
|
|
-- (unsubscribed episode — or a family-lens hidden book) survive with
|
|
-- NULL media columns and visible=false, so the page can render an
|
|
-- "unavailable" hint instead of silently vanishing them. Rows visible
|
|
-- but archived/missing-scan keep the old INNER-JOIN behavior (dropped).
|
|
SELECT ci.media_item_id, ci.position, ci.added_at,
|
|
(mi.id IS NOT NULL)::bool AS visible,
|
|
mi.title, mi.author, mi.cover_image_path, mi.format_group,
|
|
mi.source, mi.download_state,
|
|
pf.title AS feed_title
|
|
FROM collection_items ci
|
|
LEFT JOIN media_items mi ON ci.media_item_id = mi.id
|
|
LEFT JOIN podcast_feeds pf ON mi.source_feed_id = pf.id
|
|
WHERE ci.collection_id = $1
|
|
AND (mi.id IS NULL OR (mi.archived_at IS NULL AND mi.missing_scan_count = 0))
|
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
|
|
|
|
-- name: GetCollectionEpisodesOrdered :many
|
|
-- The collection queue's downloaded episodes in collection order
|
|
-- (plan item 2.3 "Play collection as sequence"): position-first, then
|
|
-- added_at. INNER JOIN — the chain steps only through episodes the
|
|
-- requester can see (RLS drops unsubscribed feeds) and that are
|
|
-- downloaded (invariant 6: un-downloaded episodes are never tracked).
|
|
SELECT mi.id, mi.title, mi.feed_position, mi.date_published
|
|
FROM collection_items ci
|
|
JOIN media_items mi ON ci.media_item_id = mi.id
|
|
WHERE ci.collection_id = $1
|
|
AND mi.source = 'podcast_episode'
|
|
AND mi.download_state = 'downloaded'
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC;
|
|
|
|
-- Get collections for book
|
|
-- name: GetCollectionsForBook :many
|
|
SELECT c.*
|
|
FROM collections c
|
|
JOIN collection_items ci ON c.id = ci.collection_id
|
|
WHERE ci.media_item_id = $1
|
|
AND c.deleted_at IS NULL;
|
|
|
|
-- Check if book is in collection
|
|
-- name: IsBookInCollection :one
|
|
SELECT EXISTS(
|
|
SELECT 1 FROM collection_items
|
|
WHERE collection_id = $1 AND media_item_id = $2
|
|
) as in_collection;
|
|
|
|
-- DEVICE SHELF MAPPINGS QUERIES
|
|
|
|
-- Create device shelf mapping
|
|
-- name: CreateDeviceShelfMapping :one
|
|
INSERT INTO device_shelf_mappings (collection_id, device_id, device_shelf_name, sync_direction)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (collection_id, device_id)
|
|
DO UPDATE SET
|
|
device_shelf_name = EXCLUDED.device_shelf_name,
|
|
sync_direction = EXCLUDED.sync_direction
|
|
RETURNING *;
|
|
|
|
-- Get device shelf mappings
|
|
-- name: GetDeviceShelfMappings :many
|
|
SELECT dsm.*, c.name as collection_name, c.icon as collection_icon
|
|
FROM device_shelf_mappings dsm
|
|
JOIN collections c ON dsm.collection_id = c.id
|
|
WHERE dsm.device_id = $1;
|
|
|
|
-- Get device shelf mapping
|
|
-- name: GetDeviceShelfMapping :one
|
|
SELECT * FROM device_shelf_mappings WHERE device_id = $1 AND collection_id = $2;
|
|
|
|
-- Update device shelf mapping
|
|
-- name: UpdateDeviceShelfMapping :one
|
|
UPDATE device_shelf_mappings
|
|
SET
|
|
device_shelf_name = $2,
|
|
sync_direction = $3
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- Delete device shelf mapping
|
|
-- name: DeleteDeviceShelfMapping :exec
|
|
DELETE FROM device_shelf_mappings WHERE id = $1;
|
|
|
|
-- DEVICE CATALOGS QUERIES
|
|
|
|
-- Create device catalog entry
|
|
-- name: CreateDeviceCatalog :one
|
|
INSERT INTO device_catalogs (device_id, media_item_id, bookhoard_uuid, kobo_content_id, content_id_type, available, delivery_date, delivery_method)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
ON CONFLICT (device_id, kobo_content_id)
|
|
DO UPDATE SET
|
|
available = EXCLUDED.available,
|
|
delivery_date = COALESCE(EXCLUDED.delivery_date, device_catalogs.delivery_date),
|
|
delivery_method = EXCLUDED.delivery_method
|
|
RETURNING *;
|
|
|
|
-- Get device catalog by Bookhoard UUID
|
|
-- name: GetDeviceCatalogByBookhoardUUID :one
|
|
SELECT * FROM device_catalogs WHERE device_id = $1 AND bookhoard_uuid = $2;
|
|
|
|
-- Get device catalog by Kobo ContentId
|
|
-- name: GetDeviceCatalogByKoboContentId :one
|
|
SELECT * FROM device_catalogs WHERE kobo_content_id = $1;
|
|
|
|
-- Get device catalog entries
|
|
-- name: GetDeviceCatalogEntries :many
|
|
SELECT dc.*, mi.title, mi.author
|
|
FROM device_catalogs dc
|
|
JOIN media_items mi ON dc.media_item_id = mi.id
|
|
WHERE dc.device_id = $1 AND dc.available = true
|
|
ORDER BY dc.delivery_date DESC;
|
|
|
|
-- Update device catalog availability
|
|
-- name: UpdateDeviceCatalogAvailability :exec
|
|
UPDATE device_catalogs
|
|
SET available = $2
|
|
WHERE id = $1;
|
|
|
|
-- Delete device catalog entry
|
|
-- name: DeleteDeviceCatalog :exec
|
|
DELETE FROM device_catalogs WHERE id = $1;
|
|
|
|
-- SYSTEM CONFIG QUERIES
|
|
|
|
-- Get system config
|
|
-- name: GetSystemConfig :one
|
|
SELECT * FROM system_config WHERE key = $1;
|
|
|
|
-- Get all system config
|
|
-- name: GetAllSystemConfig :many
|
|
SELECT * FROM system_config ORDER BY key;
|
|
|
|
-- Set system config
|
|
-- name: SetSystemConfig :one
|
|
INSERT INTO system_config (key, value, updated_by)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (key)
|
|
DO UPDATE SET
|
|
value = EXCLUDED.value,
|
|
updated_by = EXCLUDED.updated_by,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- Delete system config
|
|
-- name: DeleteSystemConfig :exec
|
|
DELETE FROM system_config WHERE key = $1;
|
|
|
|
-- OPDS TOKENS QUERIES
|
|
|
|
-- Create OPDS token
|
|
-- name: CreateOpdsToken :one
|
|
INSERT INTO opds_tokens (device_id, token, token_type, expires_at)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING *;
|
|
|
|
-- Get OPDS token
|
|
-- name: GetOpdsToken :one
|
|
SELECT ot.*, d.device_name, d.device_type
|
|
FROM opds_tokens ot
|
|
JOIN devices d ON ot.device_id = d.id
|
|
WHERE ot.token = $1 AND ot.expires_at > NOW();
|
|
|
|
-- Get OPDS tokens by device
|
|
-- name: GetOpdsTokensByDevice :many
|
|
SELECT * FROM opds_tokens WHERE device_id = $1 AND expires_at > NOW();
|
|
|
|
-- Revoke OPDS token
|
|
-- name: RevokeOpdsToken :exec
|
|
DELETE FROM opds_tokens WHERE token = $1;
|
|
|
|
-- Cleanup expired OPDS tokens
|
|
-- name: CleanupExpiredOpdsTokens :exec
|
|
DELETE FROM opds_tokens WHERE expires_at < NOW();
|
|
|
|
-- Update Kobo shelves to support collections
|
|
-- name: UpdateKoboShelfCollection :one
|
|
UPDATE kobo_shelves
|
|
SET
|
|
collection_id = $2,
|
|
position_in_collection = $3,
|
|
last_synced_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- Get Kobo shelves by collection
|
|
-- name: GetKoboShelvesByCollection :many
|
|
SELECT ks.*, mi.title, mi.author
|
|
FROM kobo_shelves ks
|
|
JOIN media_items mi ON ks.media_item_id = mi.id
|
|
WHERE ks.device_id = $1 AND ks.collection_id = $2
|
|
ORDER BY ks.position_in_collection ASC, ks.shelf_position ASC;
|
|
|
|
-- ============================================
|
|
-- ENHANCED KOBO SYNC
|
|
-- ============================================
|
|
|
|
-- Create unlinked book entry
|
|
-- name: CreateUnlinkedBook :one
|
|
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING *;
|
|
|
|
-- Get unlinked books for a device (ignored rows stay hidden)
|
|
-- name: GetUnlinkedBooksByDevice :many
|
|
SELECT ub.*, d.device_name, d.device_type
|
|
FROM unlinked_books ub
|
|
JOIN devices d ON ub.device_id = d.id
|
|
WHERE ub.device_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
|
|
ORDER BY ub.last_seen_at DESC;
|
|
|
|
-- Get unlinked book by ContentId
|
|
-- name: GetUnlinkedBookByContentId :one
|
|
SELECT * FROM unlinked_books WHERE device_id = $1 AND content_id = $2;
|
|
|
|
-- Resolve unlinked book
|
|
-- name: ResolveUnlinkedBook :one
|
|
UPDATE unlinked_books
|
|
SET
|
|
resolved = true,
|
|
media_item_id = $2,
|
|
resolved_at = NOW(),
|
|
resolution_method = $3
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- Count unlinked books for a device (ignored rows stay hidden)
|
|
-- name: CountUnlinkedBooks :one
|
|
SELECT COUNT(*) as count
|
|
FROM unlinked_books
|
|
WHERE device_id = $1 AND resolved = false AND ignored_at IS NULL;
|
|
|
|
-- Link unlinked book to media item
|
|
-- name: LinkUnlinkedBook :one
|
|
UPDATE unlinked_books
|
|
SET
|
|
media_item_id = $2,
|
|
confidence_score = $3,
|
|
resolved = true,
|
|
resolved_at = NOW(),
|
|
resolution_method = 'manual_link'
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- Get unlinked book by ID
|
|
-- name: GetUnlinkedBookByID :one
|
|
SELECT * FROM unlinked_books WHERE id = $1;
|
|
|
|
-- Delete unlinked book
|
|
-- name: DeleteUnlinkedBook :exec
|
|
DELETE FROM unlinked_books WHERE id = $1;
|
|
|
|
-- List unresolved unlinked books with pagination
|
|
-- name: ListUnresolvedUnlinkedBooks :many
|
|
SELECT ub.*, d.device_name, d.device_type
|
|
FROM unlinked_books ub
|
|
JOIN devices d ON ub.device_id = d.id
|
|
WHERE ub.resolved = false
|
|
ORDER BY ub.last_seen_at DESC
|
|
LIMIT $1 OFFSET $2;
|
|
|
|
-- Ingest: insert or refresh the queue row for a device file that could not
|
|
-- be identified. One row per (device, content_id); re-syncs bump last_seen.
|
|
-- name: UpsertUnlinkedBook :one
|
|
INSERT INTO unlinked_books (device_id, content_id, file_path, title, author, confidence_score)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (device_id, content_id) DO UPDATE
|
|
SET file_path = EXCLUDED.file_path,
|
|
title = EXCLUDED.title,
|
|
author = EXCLUDED.author,
|
|
confidence_score = EXCLUDED.confidence_score,
|
|
last_seen_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- Banner count: unresolved, non-ignored queue rows across the caller's own
|
|
-- devices. Ignored rows must not nag from the dashboard badge.
|
|
-- name: CountUnresolvedUnlinkedBooksForUser :one
|
|
SELECT COUNT(*) as count
|
|
FROM unlinked_books ub
|
|
JOIN devices d ON ub.device_id = d.id
|
|
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL;
|
|
|
|
-- Page list: unresolved queue rows for the caller's own devices (ignored
|
|
-- rows are listed by ListIgnoredUnlinkedBooksForUser instead).
|
|
-- name: ListUnresolvedUnlinkedBooksForUser :many
|
|
SELECT ub.*, d.device_name, d.device_type
|
|
FROM unlinked_books ub
|
|
JOIN devices d ON ub.device_id = d.id
|
|
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NULL
|
|
ORDER BY ub.last_seen_at DESC;
|
|
|
|
-- Page list, ignored bucket: rows the user dismissed, kept for the
|
|
-- collapsible "Ignored" section so a misclick is recoverable.
|
|
-- name: ListIgnoredUnlinkedBooksForUser :many
|
|
SELECT ub.*, d.device_name, d.device_type
|
|
FROM unlinked_books ub
|
|
JOIN devices d ON ub.device_id = d.id
|
|
WHERE d.user_id = $1 AND ub.resolved = false AND ub.ignored_at IS NOT NULL
|
|
ORDER BY ub.ignored_at DESC;
|
|
|
|
-- Soft-dismiss one row (ownership is checked by the caller against the
|
|
-- device's user_id before calling this).
|
|
-- name: IgnoreUnlinkedBook :exec
|
|
UPDATE unlinked_books SET ignored_at = NOW() WHERE id = $1;
|
|
|
|
-- Undo a soft-dismiss.
|
|
-- name: UnignoreUnlinkedBook :exec
|
|
UPDATE unlinked_books SET ignored_at = NULL WHERE id = $1;
|
|
|
|
-- Fetch one row together with its device's owner for authorization.
|
|
-- name: GetUnlinkedBookWithOwner :one
|
|
SELECT ub.*, d.user_id, d.device_name, d.device_type
|
|
FROM unlinked_books ub
|
|
JOIN devices d ON ub.device_id = d.id
|
|
WHERE ub.id = $1;
|
|
|
|
-- ============================================
|
|
-- CAROUSEL-STYLE DASHBOARD
|
|
-- ============================================
|
|
|
|
-- Dashboard preferences queries
|
|
-- The handler routes by scope: a Valid library_id targets the per-library
|
|
-- table below; an absent one targets user_dashboard_preferences_global
|
|
-- (the "All Libraries" view). Never pass NULL here — the loose NULL match
|
|
-- was removed because it returned an arbitrary library's row.
|
|
-- name: GetDashboardPreferences :one
|
|
SELECT * FROM user_dashboard_preferences
|
|
WHERE user_id = sqlc.narg('user_id') AND library_id = sqlc.narg('library_id')::uuid;
|
|
|
|
-- name: UpsertDashboardPreferences :one
|
|
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_collections, collection_order, items_per_section)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
ON CONFLICT (user_id, library_id)
|
|
DO UPDATE SET
|
|
hidden_collections = EXCLUDED.hidden_collections,
|
|
collection_order = EXCLUDED.collection_order,
|
|
items_per_section = EXCLUDED.items_per_section,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: UpdateDashboardPreferences :one
|
|
UPDATE user_dashboard_preferences
|
|
SET hidden_collections = $2,
|
|
collection_order = $3,
|
|
items_per_section = $4,
|
|
updated_at = NOW()
|
|
WHERE user_id = $1 AND library_id = $5
|
|
RETURNING *;
|
|
|
|
-- Global (all-libraries) scope: exactly one row per user.
|
|
-- name: GetGlobalDashboardPreferences :one
|
|
SELECT user_id, hidden_collections, collection_order, items_per_section, updated_at FROM user_dashboard_preferences_global
|
|
WHERE user_id = $1;
|
|
|
|
-- name: UpsertGlobalDashboardPreferences :one
|
|
INSERT INTO user_dashboard_preferences_global (user_id, hidden_collections, collection_order, items_per_section)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (user_id)
|
|
DO UPDATE SET
|
|
hidden_collections = EXCLUDED.hidden_collections,
|
|
collection_order = EXCLUDED.collection_order,
|
|
items_per_section = EXCLUDED.items_per_section,
|
|
updated_at = NOW()
|
|
RETURNING user_id, hidden_collections, collection_order, items_per_section, updated_at;
|
|
|
|
-- Dashboard collections queries
|
|
-- name: GetSystemCollectionsForDashboard :many
|
|
SELECT * FROM collections
|
|
WHERE user_id = $1
|
|
AND is_system_collection = true
|
|
AND show_on_dashboard = true
|
|
AND deleted_at IS NULL
|
|
ORDER BY priority ASC;
|
|
|
|
-- name: GetUserCollectionsForDashboard :many
|
|
-- A2c (2026-10-19): user collections are dashboard-ELIGIBLE BY DEFAULT —
|
|
-- show_on_dashboard gated them forever-false (no writer ever set it on
|
|
-- user rows), and hiding is the dashboard-prefs job (hidden_collections).
|
|
-- The flag remains system-section provisioning state only.
|
|
SELECT c.* FROM collections c
|
|
WHERE c.user_id = $1
|
|
AND c.is_system_collection = false
|
|
AND c.deleted_at IS NULL
|
|
ORDER BY priority ASC;
|
|
|
|
-- name: DeleteUserSystemCollection :exec
|
|
DELETE FROM collections
|
|
WHERE user_id = $1
|
|
AND name = $2
|
|
AND is_system_collection = true;
|
|
|
|
-- System collections are permanent: identity is (user_id, query_type).
|
|
-- Restore/reset operate IN PLACE (the row id — and everything referencing
|
|
-- it: shelf mappings, prefs order, links — never churns). Deleted sections
|
|
-- are TOMBSTONED (deleted_at) and stay invisible until restored.
|
|
|
|
-- name: GetSystemCollectionByQueryType :one
|
|
-- Unfiltered by design: restore/seed must resolve tombstoned rows too.
|
|
SELECT * FROM collections
|
|
WHERE user_id = $1
|
|
AND query_type = $2
|
|
AND is_system_collection = true;
|
|
|
|
-- name: TombstoneSystemCollection :exec
|
|
UPDATE collections SET deleted_at = NOW() WHERE id = $1;
|
|
|
|
-- name: RestoreSystemCollectionRow :one
|
|
-- Full reset, in place: canonical metadata, back on the dashboard,
|
|
-- un-tombstoned. Item assignments are cleared by the caller ("full").
|
|
UPDATE collections
|
|
SET name = $2,
|
|
description = $3,
|
|
icon = $4,
|
|
color = $5,
|
|
priority = $6,
|
|
show_on_dashboard = true,
|
|
deleted_at = NULL
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: ClearCollectionItems :exec
|
|
DELETE FROM collection_items WHERE collection_id = $1;
|
|
|
|
-- name: CreateSystemCollection :one
|
|
INSERT INTO collections (user_id, name, description, icon, color, show_on_dashboard, query_type, priority, is_system_collection, auto_assign_rules)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, true, 'null'::jsonb)
|
|
RETURNING *;
|
|
|
|
-- name: ResetSystemCollectionMetadata :exec
|
|
UPDATE collections
|
|
SET description = $3,
|
|
icon = $4,
|
|
color = $5,
|
|
priority = $6
|
|
WHERE user_id = $1
|
|
AND name = $2
|
|
AND is_system_collection = true;
|
|
|
|
-- Smart section queries (for system collections)
|
|
-- name: GetContinueReadingItems :many
|
|
SELECT mi.* FROM media_items mi
|
|
INNER JOIN (
|
|
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
|
FROM reading_progress
|
|
WHERE user_id = sqlc.narg('user_id')
|
|
AND percentage > 0
|
|
AND percentage < 1
|
|
ORDER BY media_item_id, last_read_at DESC
|
|
) rp ON rp.media_item_id = mi.id
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
ORDER BY rp.last_read_at DESC
|
|
LIMIT sqlc.narg('limit');
|
|
|
|
-- name: GetRecentlyAddedItems :many
|
|
SELECT mi.* FROM media_items mi
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
ORDER BY mi.imported_at DESC NULLS LAST, mi.created_at DESC
|
|
LIMIT sqlc.narg('limit');
|
|
|
|
-- name: GetRecentlyReadItems :many
|
|
SELECT mi.* FROM media_items mi
|
|
INNER JOIN (
|
|
SELECT DISTINCT ON (media_item_id) media_item_id, last_read_at
|
|
FROM reading_progress
|
|
WHERE user_id = sqlc.narg('user_id')
|
|
AND percentage >= 1
|
|
ORDER BY media_item_id, last_read_at DESC
|
|
) rp ON rp.media_item_id = mi.id
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
ORDER BY rp.last_read_at DESC
|
|
LIMIT sqlc.narg('limit');
|
|
|
|
-- name: GetNotStartedItems :many
|
|
SELECT mi.* FROM media_items mi
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM reading_progress rp
|
|
WHERE rp.media_item_id = mi.id
|
|
AND rp.user_id = sqlc.narg('user_id')
|
|
AND rp.percentage > 0
|
|
)
|
|
ORDER BY mi.created_at DESC
|
|
LIMIT sqlc.narg('limit');
|
|
|
|
-- name: GetCollectionItemsForDashboard :many
|
|
-- The excluded filter lives here (was: callers filtered ci.excluded in
|
|
-- Go over a 69-field row + a 45-field converter to MediaItems; embed
|
|
-- returns the real row so neither exists).
|
|
-- A2c: position-first ordering; optional hide_finished — when the flag is
|
|
-- set AND a viewer is supplied, items the viewer finished (percentage
|
|
-- >= 0.999) drop out, turning the section into the reading queue head.
|
|
-- viewer_user_id NULL = no progress filter (never-finished semantics for
|
|
-- anonymous/legacy callers is NOT implied: hide_finished without a viewer
|
|
-- is a caller bug and shows everything).
|
|
SELECT sqlc.embed(mi), rp.percentage AS viewer_percentage FROM media_items mi
|
|
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
|
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
|
WHERE ci.collection_id = sqlc.narg('collection_id')
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (ci.excluded IS NOT TRUE)
|
|
AND (sqlc.narg('hide_finished')::boolean IS NOT TRUE OR COALESCE(rp.percentage, 0) < 0.999)
|
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
|
LIMIT sqlc.narg('limit');
|
|
|
|
-- A2c §3.4: the collection's reading queue — first N un-finished items in
|
|
-- position order. Strict playlist semantics: finished items drop out as
|
|
-- they complete, so the head IS "up next". Library visibility is enforced
|
|
-- by the family-lens array param (never NULL for user-facing callers).
|
|
-- name: GetCollectionUpNext :many
|
|
SELECT sqlc.embed(mi), rp.percentage AS viewer_percentage FROM media_items mi
|
|
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
|
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
|
WHERE ci.collection_id = sqlc.narg('collection_id')
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (ci.excluded IS NOT TRUE)
|
|
AND COALESCE(rp.percentage, 0) < 0.999
|
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
|
ORDER BY ci.position ASC NULLS LAST, ci.added_at ASC
|
|
LIMIT sqlc.narg('limit');
|
|
|
|
-- A2c §3.5: distinguish "playlist complete" from "genuinely empty" — the
|
|
-- dashboard renders a completion marker when hide_finished filtered
|
|
-- everything out of a non-empty collection.
|
|
-- name: CountCollectionItems :one
|
|
SELECT COUNT(*) FROM collection_items WHERE collection_id = $1;
|
|
|
|
-- Smart ("dynamic") collections: rules are evaluated LIVE at render, so
|
|
-- membership comes from the candidate book set instead of collection_items.
|
|
-- The set is scoped to the requesting user's visible libraries (family
|
|
-- lens enforced at the source) and live items only — the same filters the
|
|
-- static collection paths apply. The viewer's progress rides along (plan
|
|
-- item 2.4): the played/unplayed episode rule evaluates per-user, exactly
|
|
-- like the hide_finished predicate (percentage >= 0.999 = finished).
|
|
-- name: GetVisibleLiveMediaForRules :many
|
|
SELECT sqlc.embed(mi), COALESCE(rp.percentage, 0) AS viewer_percentage FROM media_items mi
|
|
LEFT JOIN reading_progress rp ON rp.media_item_id = mi.id AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR mi.library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
ORDER BY mi.title ASC;
|
|
|
|
-- Live item count for a STATIC collection (rule-free cards on the
|
|
-- collections page); same live filter as GetCollectionItems, plus the
|
|
-- future-hybrid excluded exclusion and an optional library scope.
|
|
-- name: CountCollectionLiveItems :one
|
|
SELECT COUNT(*)::int FROM collection_items ci
|
|
JOIN media_items mi ON ci.media_item_id = mi.id
|
|
WHERE ci.collection_id = $1
|
|
AND NOT COALESCE(ci.excluded, false)
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0;
|
|
|
|
-- name: GetLibraryItems :many
|
|
SELECT mi.* FROM media_items mi
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (mi.library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9: collections picker)
|
|
ORDER BY mi.created_at DESC;
|
|
|
|
-- name: GetDistinctSeries :many
|
|
-- Series are grouped PER LIBRARY: the same series name in two libraries
|
|
-- (novels vs manga adaptations, LN splits) stays two entries. Visible
|
|
-- filter enforces per-user library visibility at the source.
|
|
--
|
|
-- Series-page sort/filter contract (extensible registry in
|
|
-- services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
|
|
-- it filters in WHERE (the group key — counts stay true); AuthorQuery
|
|
-- and Status filter via HAVING so COUNT(*) keeps reporting the whole
|
|
-- series. ViewerUserId scopes the per-user progress aggregates (the rp
|
|
-- join can't multiply rows: reading_progress is UNIQUE per
|
|
-- media_item+user). Sort is a BOUND PARAM compared inside the CASE
|
|
-- cascade — never interpolated — and the GROUP BY tiebreaker stays
|
|
-- REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
|
|
-- transaction share a single NOW() timestamp, leaving many groups tied
|
|
-- on MAX(created_at), and Postgres does not guarantee tie order between
|
|
-- executions, so page boundaries could repeat or drop series.
|
|
SELECT library_id, series, COUNT(*) as book_count,
|
|
MAX(series_count) as total_in_series,
|
|
MAX(created_at) as last_entry_at,
|
|
COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) AS finished_count,
|
|
MAX(rp.last_read_at) AS last_read_at
|
|
FROM media_items
|
|
LEFT JOIN reading_progress rp
|
|
ON rp.media_item_id = media_items.id
|
|
AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
|
AND archived_at IS NULL
|
|
AND missing_scan_count = 0
|
|
AND (library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9: series pages)
|
|
AND series IS NOT NULL AND series != ''
|
|
-- Multi-field search (series page): the query matches the series
|
|
-- NAME, any book's AUTHOR, or any book's TITLE in the group. It is
|
|
-- a WHERE filter: a group qualifies when ANY of its rows matches,
|
|
-- so COUNT(*) keeps reporting the whole series.
|
|
AND (sqlc.narg('series_query')::text IS NULL OR
|
|
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
|
OR author ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
|
OR EXISTS (
|
|
SELECT 1 FROM media_items sib
|
|
WHERE sib.library_id = media_items.library_id
|
|
AND sib.series = media_items.series
|
|
AND sib.title ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
|
))
|
|
GROUP BY library_id, series
|
|
HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%'))
|
|
AND (
|
|
sqlc.narg('status')::text IS NULL
|
|
OR CASE sqlc.narg('status')::text
|
|
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
|
|
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
|
|
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
|
|
ELSE true
|
|
END
|
|
)
|
|
ORDER BY
|
|
CASE WHEN COALESCE(sqlc.narg('sort')::text, 'recent') = 'recent' THEN MAX(created_at) END DESC NULLS LAST,
|
|
CASE WHEN sqlc.narg('sort')::text = 'recent_asc' THEN MAX(created_at) END ASC NULLS LAST,
|
|
CASE WHEN sqlc.narg('sort')::text = 'name_asc' THEN series END ASC NULLS LAST,
|
|
CASE WHEN sqlc.narg('sort')::text = 'name_desc' THEN series END DESC NULLS LAST,
|
|
CASE WHEN sqlc.narg('sort')::text = 'author_asc' THEN MIN(COALESCE(author, '')) END ASC NULLS LAST,
|
|
CASE WHEN sqlc.narg('sort')::text = 'author_desc' THEN MIN(COALESCE(author, '')) END DESC NULLS LAST,
|
|
CASE WHEN sqlc.narg('sort')::text = 'books_desc' THEN COUNT(*) END DESC NULLS LAST,
|
|
CASE WHEN sqlc.narg('sort')::text = 'books_asc' THEN COUNT(*) END ASC NULLS LAST,
|
|
CASE WHEN sqlc.narg('sort')::text = 'progress_desc'
|
|
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
|
|
END DESC NULLS LAST,
|
|
CASE WHEN sqlc.narg('sort')::text = 'progress_asc'
|
|
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
|
|
END ASC NULLS LAST,
|
|
CASE WHEN sqlc.narg('sort')::text = 'last_read_desc' THEN MAX(rp.last_read_at) END DESC NULLS LAST,
|
|
CASE WHEN sqlc.narg('sort')::text = 'last_read_asc' THEN MAX(rp.last_read_at) END ASC NULLS LAST,
|
|
library_id, series
|
|
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
|
|
|
-- name: GetDistinctSeriesCount :one
|
|
-- Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
|
|
-- HAVING so the pagination total matches what the page query returns
|
|
-- under the same filters (Sort is a page-ORDER concern, not a set
|
|
-- membership one, so it is absent here by design).
|
|
SELECT COUNT(*)::int FROM (
|
|
SELECT library_id, series
|
|
FROM media_items
|
|
LEFT JOIN reading_progress rp
|
|
ON rp.media_item_id = media_items.id
|
|
AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
|
AND archived_at IS NULL
|
|
AND missing_scan_count = 0
|
|
AND (library_type_name IS DISTINCT FROM 'podcasts') -- podcasts exclusion (plan §9: series pages)
|
|
AND series IS NOT NULL AND series != ''
|
|
AND (sqlc.narg('series_query')::text IS NULL OR
|
|
series ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
|
OR author ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
|
OR EXISTS (
|
|
SELECT 1 FROM media_items sib
|
|
WHERE sib.library_id = media_items.library_id
|
|
AND sib.series = media_items.series
|
|
AND sib.title ILIKE '%' || sqlc.narg('series_query')::text || '%'
|
|
))
|
|
GROUP BY library_id, series
|
|
HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%'))
|
|
AND (
|
|
sqlc.narg('status')::text IS NULL
|
|
OR CASE sqlc.narg('status')::text
|
|
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
|
|
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
|
|
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
|
|
ELSE true
|
|
END
|
|
)
|
|
) groups;
|
|
|
|
-- name: CountOtherMediaItemsInDir :one
|
|
-- 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.
|
|
SELECT COUNT(*)::int
|
|
FROM media_items
|
|
WHERE library_id = sqlc.narg('library_id')::uuid
|
|
AND id != sqlc.narg('id')::uuid
|
|
AND archived_at IS NULL
|
|
AND file_path LIKE (sqlc.narg('dir_prefix')::text || '%');
|
|
|
|
-- name: GetSeriesCovers :many
|
|
SELECT cover_image_path, library_id
|
|
FROM media_items
|
|
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
|
|
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
|
AND archived_at IS NULL
|
|
AND missing_scan_count = 0
|
|
AND series = sqlc.narg('series') AND cover_image_path IS NOT NULL AND cover_image_path != ''
|
|
ORDER BY series_number ASC NULLS LAST
|
|
LIMIT sqlc.narg('limit');
|
|
|
|
-- name: GetSeriesBooks :many
|
|
-- Scoped to ONE library: same-name series in different libraries are
|
|
-- different stories (manga adaptations, LN splits). Callers must pass a
|
|
-- library_id and verify the requester can see that library.
|
|
SELECT * FROM media_items
|
|
WHERE series = sqlc.narg('series')
|
|
AND library_id = sqlc.narg('library_id')
|
|
AND archived_at IS NULL
|
|
AND missing_scan_count = 0
|
|
ORDER BY series_number ASC NULLS LAST, title ASC;
|
|
|
|
-- name: GetContinueSeriesItems :many
|
|
WITH user_series_progress AS (
|
|
SELECT mi.series,
|
|
MAX(mi.series_number) as max_read_number,
|
|
MAX(rp.last_read_at) as last_read_at
|
|
FROM reading_progress rp
|
|
JOIN media_items mi ON mi.id = rp.media_item_id
|
|
WHERE rp.user_id = sqlc.narg('user_id')
|
|
AND rp.percentage > 0
|
|
AND mi.series IS NOT NULL AND mi.series != ''
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
GROUP BY mi.series
|
|
),
|
|
next_books AS (
|
|
SELECT DISTINCT ON (mi.series) mi.*,
|
|
usp.last_read_at
|
|
FROM media_items mi
|
|
JOIN user_series_progress usp ON mi.series = usp.series
|
|
WHERE mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND (sqlc.narg('library_id')::uuid IS NULL OR mi.library_id = sqlc.narg('library_id')::uuid)
|
|
AND (mi.series_number > usp.max_read_number OR usp.max_read_number IS NULL)
|
|
ORDER BY mi.series, mi.series_number ASC NULLS LAST
|
|
)
|
|
SELECT * FROM next_books
|
|
ORDER BY last_read_at DESC NULLS LAST
|
|
LIMIT sqlc.narg('limit');
|
|
|
|
-- name: GetSavedFilters :many
|
|
SELECT * FROM saved_filters
|
|
WHERE user_id = @user_id AND resource_type = @resource_type
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: GetSavedFilterByID :one
|
|
SELECT * FROM saved_filters
|
|
WHERE id = @id AND user_id = @user_id;
|
|
|
|
-- name: CreateSavedFilter :one
|
|
INSERT INTO saved_filters (user_id, name, resource_type, filters)
|
|
VALUES (@user_id, @name, @resource_type, @filters)
|
|
RETURNING *;
|
|
|
|
-- name: UpdateSavedFilter :one
|
|
UPDATE saved_filters
|
|
SET name = @name,
|
|
filters = @filters,
|
|
updated_at = NOW()
|
|
WHERE id = @id AND user_id = @user_id
|
|
RETURNING *;
|
|
|
|
-- name: DeleteSavedFilter :one
|
|
DELETE FROM saved_filters
|
|
WHERE id = @id AND user_id = @user_id
|
|
RETURNING *;
|
|
|
|
-- name: GetPanelData :one
|
|
SELECT * FROM panel_data
|
|
WHERE media_item_id = $1 AND page_number = $2;
|
|
|
|
-- name: UpsertPanelData :one
|
|
INSERT INTO panel_data (media_item_id, page_number, detection_method, panels)
|
|
VALUES ($1, $2, $3, $4)
|
|
ON CONFLICT (media_item_id, page_number)
|
|
DO UPDATE SET
|
|
detection_method = EXCLUDED.detection_method,
|
|
panels = EXCLUDED.panels,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: UpdateMediaItemChapterMetadata :one
|
|
UPDATE media_items
|
|
SET chapter_metadata = $2, updated_at = NOW()
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: GetReadingSpeed :one
|
|
SELECT * FROM reading_speed
|
|
WHERE user_id = $1 AND media_item_id = $2;
|
|
|
|
-- name: CreateReadingSpeed :one
|
|
INSERT INTO reading_speed (user_id, media_item_id, pages_per_minute, pages_read, total_reading_minutes, last_read_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
RETURNING *;
|
|
|
|
-- name: UpdateReadingSpeed :one
|
|
UPDATE reading_speed
|
|
SET
|
|
pages_per_minute = $3,
|
|
pages_read = pages_read + $4,
|
|
total_reading_minutes = total_reading_minutes + $5,
|
|
last_read_at = $6,
|
|
updated_at = NOW()
|
|
WHERE user_id = $1 AND media_item_id = $2
|
|
RETURNING *;
|
|
|
|
-- name: GetDictionaryEntry :one
|
|
SELECT * FROM dictionary_cache
|
|
WHERE word = $1;
|
|
|
|
-- name: CreateDictionaryEntry :one
|
|
INSERT INTO dictionary_cache (word, definition, part_of_speech, example, etymology, accessed_at)
|
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
|
RETURNING *;
|
|
|
|
-- name: UpdateDictionaryAccessed :one
|
|
UPDATE dictionary_cache
|
|
SET accessed_at = NOW()
|
|
WHERE word = $1
|
|
RETURNING *;
|
|
|
|
-- name: GetReaderSettings :one
|
|
SELECT setting_value FROM reader_settings
|
|
WHERE user_id = $1 AND setting_key = 'reader_settings';
|
|
|
|
-- name: UpsertReaderSettings :one
|
|
INSERT INTO reader_settings (user_id, setting_key, setting_value)
|
|
VALUES ($1, 'reader_settings', $2)
|
|
ON CONFLICT (user_id, setting_key)
|
|
DO UPDATE SET
|
|
setting_value = EXCLUDED.setting_value,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: GetMediaBookmarks :many
|
|
SELECT * FROM media_bookmarks
|
|
WHERE media_item_id = $1 AND user_id = $2 AND COALESCE(deleted, FALSE) = FALSE
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: GetMediaBookmark :one
|
|
SELECT * FROM media_bookmarks WHERE id = $1;
|
|
|
|
-- ============================================================================
|
|
-- PROCESSING ISSUES QUERIES
|
|
-- ============================================================================
|
|
|
|
-- name: CreateProcessingIssue :one
|
|
-- Widened upsert (faceted plan Phase 11): media_item_id and file_path
|
|
-- are nullable — item-backed rows key on (item, library, type, ''),
|
|
-- row-less import failures on (zero-uuid, library, type, path). The
|
|
-- conflict target matches idx_processing_issues_dedup's expressions.
|
|
-- A repeat failure REOPENS a resolved row (resolved=false).
|
|
INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity, file_path)
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (COALESCE(media_item_id, '00000000-0000-0000-0000-000000000000'::uuid), library_id, issue_type, COALESCE(file_path, ''))
|
|
DO UPDATE SET issue_description = EXCLUDED.issue_description,
|
|
severity = EXCLUDED.severity,
|
|
resolved = false,
|
|
resolved_at = NULL
|
|
RETURNING *;
|
|
|
|
-- name: ListProcessingIssuesByLibrary :many
|
|
-- 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;
|
|
-- the display file path falls back to the issue's own for row-less rows.
|
|
SELECT
|
|
pi.id,
|
|
pi.media_item_id,
|
|
pi.issue_type,
|
|
pi.issue_description,
|
|
pi.severity,
|
|
pi.resolved,
|
|
pi.resolved_at,
|
|
pi.created_at,
|
|
COALESCE(mi.title, '') AS title,
|
|
COALESCE(mi.file_path, pi.file_path, '') AS file_path,
|
|
COALESCE(mi.format_group, '') AS format_group,
|
|
lt.name as library_type_name
|
|
FROM processing_issues pi
|
|
LEFT JOIN media_items mi ON pi.media_item_id = mi.id
|
|
JOIN libraries l ON pi.library_id = l.id
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
WHERE pi.library_id = $1
|
|
AND pi.resolved = false
|
|
ORDER BY
|
|
CASE pi.severity
|
|
WHEN 'error' THEN 1
|
|
WHEN 'warning' THEN 2
|
|
WHEN 'info' THEN 3
|
|
END,
|
|
pi.created_at DESC;
|
|
|
|
-- name: ResolveOpenProcessingIssuesForItem :execrows
|
|
-- Ledger self-heal (faceted plan Phase 11): a scan that processes the
|
|
-- item successfully closes its open issues (extraction failures heal).
|
|
UPDATE processing_issues
|
|
SET resolved = true,
|
|
resolved_at = NOW()
|
|
WHERE media_item_id = $1
|
|
AND resolved = false;
|
|
|
|
-- name: ResolveOpenProcessingIssuesForPath :execrows
|
|
-- Row-less issues resolve by (library, file_path) when the file finally
|
|
-- imports — the page heals itself without admin action.
|
|
UPDATE processing_issues
|
|
SET resolved = true,
|
|
resolved_at = NOW()
|
|
WHERE media_item_id IS NULL
|
|
AND library_id = $1
|
|
AND file_path = $2
|
|
AND resolved = false;
|
|
|
|
-- name: GetProcessingIssue :one
|
|
-- Single-issue read for the resolve/delete endpoints' family-lens gate.
|
|
SELECT * FROM processing_issues WHERE id = $1;
|
|
|
|
-- name: GetProcessingIssueStats :one
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE severity = 'error' AND resolved = false) as error_count,
|
|
COUNT(*) FILTER (WHERE severity = 'warning' AND resolved = false) as warning_count,
|
|
COUNT(*) FILTER (WHERE severity = 'info' AND resolved = false) as info_count
|
|
FROM processing_issues
|
|
WHERE library_id = $1;
|
|
|
|
-- name: ResolveProcessingIssue :one
|
|
UPDATE processing_issues
|
|
SET resolved = true,
|
|
resolved_at = NOW()
|
|
WHERE id = $1
|
|
AND media_item_id = $2
|
|
RETURNING *;
|
|
|
|
-- name: DeleteProcessingIssue :one
|
|
DELETE FROM processing_issues
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- ============================================================================
|
|
-- LIBRARY WITH TYPE INFO QUERIES
|
|
-- ============================================================================
|
|
|
|
-- name: GetLibraryWithType :one
|
|
SELECT
|
|
l.*,
|
|
lt.name as type_name,
|
|
lt.description as type_description,
|
|
lt.allowed_extensions
|
|
FROM libraries l
|
|
JOIN library_types lt ON l.library_type_id = lt.id
|
|
WHERE l.id = $1;
|
|
|
|
-- name: GetBooksByTag :many
|
|
SELECT * FROM media_items
|
|
WHERE library_id = $1 AND tags @> ARRAY[$2::text]
|
|
ORDER BY title ASC;
|
|
|
|
-- ============================================================================
|
|
-- LIBRARY USAGE STATS (the /stats Library tab; the StatisticsService
|
|
-- LibraryUsage aggregate). media_items INVENTORY aggregates — what is in
|
|
-- the library, not what was read. Every query carries BOTH filters:
|
|
-- $1 = single-library scope (NULL = all visible libraries — "All")
|
|
-- $2 = the requester's visible-library id set (NULL = no filter; the
|
|
-- default-ALLOW family lens). A non-visible $1 is REJECTED at the
|
|
-- service/handler layer with 403 — the SQL filter is defense only.
|
|
-- + the healthy-item filter, matched to GetVisibleLibraryMediaCounts:
|
|
-- archived_at IS NULL AND missing_scan_count = 0 (dead rows lie).
|
|
-- ============================================================================
|
|
|
|
-- name: GetLibraryUsageCards :one
|
|
WITH usage_scope AS (
|
|
SELECT mi.author, mi.file_size, mi.page_count, mi.narrators, mi.audio_metadata
|
|
FROM media_items mi
|
|
WHERE mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
|
)
|
|
SELECT
|
|
(SELECT COUNT(*) FROM usage_scope)::int AS items,
|
|
(SELECT COUNT(DISTINCT author) FROM usage_scope
|
|
WHERE author IS NOT NULL AND author <> '')::int AS authors,
|
|
(SELECT COUNT(DISTINCT n) FROM usage_scope s, unnest(s.narrators) AS n
|
|
WHERE n <> '')::int AS narrators,
|
|
(SELECT COALESCE(SUM(file_size), 0) FROM usage_scope)::bigint AS total_size_bytes,
|
|
(SELECT COALESCE(SUM(page_count), 0) FROM usage_scope)::bigint AS total_pages,
|
|
(SELECT COALESCE(
|
|
SUM((s.audio_metadata->>'duration_seconds')::float8), 0
|
|
)::bigint FROM usage_scope s
|
|
WHERE s.audio_metadata IS NOT NULL
|
|
AND s.audio_metadata ? 'duration_seconds') AS total_seconds;
|
|
|
|
-- name: GetLibraryUsageGenres :many
|
|
SELECT COALESCE(mi.genre, '') AS name, COUNT(*)::int AS row_count
|
|
FROM media_items mi
|
|
WHERE mi.genre IS NOT NULL AND mi.genre <> ''
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
|
GROUP BY mi.genre
|
|
ORDER BY row_count DESC, name ASC
|
|
LIMIT 5;
|
|
|
|
-- name: GetLibraryUsageAuthors :many
|
|
SELECT COALESCE(mi.author, '') AS name, COUNT(*)::int AS row_count
|
|
FROM media_items mi
|
|
WHERE mi.author IS NOT NULL AND mi.author <> ''
|
|
AND mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
|
GROUP BY mi.author
|
|
ORDER BY row_count DESC, name ASC
|
|
LIMIT 10;
|
|
|
|
-- name: GetLibraryUsageLongestItems :many
|
|
-- The Longest Items panel, one query both modes: $3 = true ranks by
|
|
-- AUDIO duration (audio present in scope), false by page_count (text).
|
|
SELECT
|
|
mi.id::uuid AS media_item_id,
|
|
mi.title,
|
|
COALESCE(mi.author, '') AS author,
|
|
mi.library_id,
|
|
mi.cover_image_path,
|
|
COALESCE(mi.file_size, 0)::bigint AS file_size_bytes,
|
|
COALESCE(mi.page_count, 0)::int AS page_count,
|
|
COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0)::bigint AS total_seconds
|
|
FROM media_items mi
|
|
WHERE mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
|
AND (CASE WHEN $3::bool
|
|
THEN COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0) > 0
|
|
ELSE COALESCE(mi.page_count, 0) > 0
|
|
END)
|
|
ORDER BY (CASE WHEN $3::bool
|
|
THEN COALESCE((mi.audio_metadata->>'duration_seconds')::float8, 0)
|
|
ELSE COALESCE(mi.page_count, 0)::float8
|
|
END) DESC, mi.title ASC
|
|
LIMIT 5;
|
|
|
|
-- name: GetLibraryUsageLargestItems :many
|
|
SELECT
|
|
mi.id::uuid AS media_item_id,
|
|
mi.title,
|
|
COALESCE(mi.author, '') AS author,
|
|
mi.library_id,
|
|
mi.cover_image_path,
|
|
COALESCE(mi.file_size, 0)::bigint AS file_size_bytes
|
|
FROM media_items mi
|
|
WHERE mi.archived_at IS NULL
|
|
AND mi.missing_scan_count = 0
|
|
AND COALESCE(mi.file_size, 0) > 0
|
|
AND ($1::uuid IS NULL OR mi.library_id = $1::uuid)
|
|
AND ($2::uuid[] IS NULL OR mi.library_id = ANY($2::uuid[]))
|
|
ORDER BY mi.file_size DESC, mi.title ASC
|
|
LIMIT 5;
|
|
|
|
-- name: UpdateMediaItemAudioBookPayload :one
|
|
-- Focused refresh for multi-file audiobooks (folder-of-tracks / zip
|
|
-- books): rescans recompute the track manifest and the aggregate facts
|
|
-- that follow from it. Editorial fields stay untouched — admins may have
|
|
-- edited title/narrators after ingest.
|
|
UPDATE media_items
|
|
SET file_size = $2,
|
|
mime_type = $3,
|
|
audio_metadata = $4,
|
|
chapter_count = $5,
|
|
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: ListAllPodcastFeedsAdmin :many
|
|
-- Admin all-podcasts view (§0 item 8 / Phase-4 item 1): EVERY feed from
|
|
-- EVERY user with its maintenance aggregates. Runs under the first-
|
|
-- admin identity (invariant 12 — same contract as the poller). Sorted
|
|
-- by disk usage DESC: the maintenance question is "what's eating the
|
|
-- disk", so the hogs surface first.
|
|
SELECT f.id, f.title, f.host, f.last_fetched_at, f.library_id,
|
|
l.name AS library_name,
|
|
(SELECT COUNT(*) FROM media_items m
|
|
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode') AS episode_total,
|
|
(SELECT COUNT(*) FROM media_items m
|
|
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
|
|
AND m.download_state = 'downloaded') AS episode_downloaded,
|
|
(SELECT COALESCE(SUM(m.file_size), 0)::BIGINT FROM media_items m
|
|
WHERE m.source_feed_id = f.id AND m.source = 'podcast_episode'
|
|
AND m.download_state = 'downloaded') AS disk_bytes,
|
|
(SELECT COUNT(*) FROM podcast_subscriptions s
|
|
WHERE s.feed_id = f.id) AS subscriber_count,
|
|
(SELECT COALESCE(string_agg(u.username, ', ' ORDER BY u.username), '')::TEXT
|
|
FROM podcast_subscriptions s
|
|
JOIN users u ON u.id = s.user_id
|
|
WHERE s.feed_id = f.id) AS subscriber_names
|
|
FROM podcast_feeds f
|
|
JOIN libraries l ON l.id = f.library_id
|
|
ORDER BY 9 DESC, f.title ASC;
|
|
|
|
-- name: ResetPodcastEpisodeFileDeleted :exec
|
|
-- Admin file deletion (§0 item 8): ONE downloaded episode's disk file
|
|
-- was removed — reset the row to pending and clear the download-derived
|
|
-- facts. file_path is deliberately KEPT: it is the fetch-maintained
|
|
-- layout (written at fetch time, not download time), so a re-download
|
|
-- lands on the same path. The poll upsert never touches download_state,
|
|
-- so a deleted file stays deleted (invariant 2: polling never deletes;
|
|
-- admins delete knowingly).
|
|
UPDATE media_items
|
|
SET download_state = 'pending',
|
|
download_error = NULL,
|
|
download_attempts = 0,
|
|
file_size = NULL,
|
|
mime_type = NULL,
|
|
cover_image_path = NULL,
|
|
audio_metadata = NULL,
|
|
chapter_count = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND source = 'podcast_episode' AND download_state = 'downloaded';
|
|
|
|
-- name: ResetPodcastFeedFilesDeleted :execrows
|
|
-- Same reset for EVERY downloaded episode of a feed (per-feed
|
|
-- "Delete files" action on the admin view).
|
|
UPDATE media_items
|
|
SET download_state = 'pending',
|
|
download_error = NULL,
|
|
download_attempts = 0,
|
|
file_size = NULL,
|
|
mime_type = NULL,
|
|
cover_image_path = NULL,
|
|
audio_metadata = NULL,
|
|
chapter_count = NULL,
|
|
updated_at = NOW()
|
|
WHERE source_feed_id = $1 AND source = 'podcast_episode'
|
|
AND download_state = 'downloaded';
|
|
|
|
-- 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
|
|
-- Enqueue: pending|failed -> queued, stamped with the NEXT QUEUE TICKET
|
|
-- (§0 items 4/5) so claim order = enqueue order (the effective
|
|
-- listening order the enqueue loop applied). Attempts increment at
|
|
-- enqueue so a crashed worker still counts the try.
|
|
UPDATE media_items
|
|
SET download_state = 'queued',
|
|
download_queue_pos = nextval('podcast_queue_seq'),
|
|
download_error = NULL,
|
|
download_attempts = download_attempts + 1,
|
|
updated_at = NOW()
|
|
WHERE id = $1
|
|
AND source = 'podcast_episode'
|
|
AND download_state IN ('pending', 'failed');
|
|
|
|
-- SetPodcastEpisodeDownloading RETIRED (§0 item 4): the pair
|
|
-- GetNextQueuedEpisode + SetPodcastEpisodeDownloading raced under
|
|
-- concurrency; ClaimQueuedPodcastEpisode does both atomically.
|
|
|
|
-- 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 (sqlc.arg('search_query')::text = '' OR title ILIKE '%' || sqlc.arg('search_query')::text || '%' OR description ILIKE '%' || sqlc.arg('search_query')::text || '%')
|
|
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 = sqlc.arg('user_id')
|
|
AND (sqlc.arg('search_query')::text = '' OR f.title ILIKE '%' || sqlc.arg('search_query')::text || '%'
|
|
OR COALESCE(s.display_name, '') ILIKE '%' || sqlc.arg('search_query')::text || '%'
|
|
OR COALESCE(f.host, '') ILIKE '%' || sqlc.arg('search_query')::text || '%'
|
|
OR COALESCE(f.publisher, '') ILIKE '%' || sqlc.arg('search_query')::text || '%')
|
|
ORDER BY COALESCE(s.display_name, f.title) ASC;
|
|
|
|
-- name: ListQueuedPodcastEpisodes :many
|
|
-- Fair-share candidate scan (§0 item 4): the download pool claims in
|
|
-- TICKET order (download_queue_pos = SEQUENCE nextval at enqueue — the
|
|
-- enqueue loop's ordering IS the download order, item 5). The worker
|
|
-- groups these per feed and prefers the least-recently-served feed.
|
|
SELECT id, source_feed_id FROM media_items
|
|
WHERE source = 'podcast_episode' AND download_state = 'queued'
|
|
ORDER BY download_queue_pos ASC NULLS LAST;
|
|
|
|
-- name: ClaimQueuedPodcastEpisode :one
|
|
-- Atomic claim (§0 item 4): converts queued -> downloading in ONE
|
|
-- statement. Two claimants racing on the same row: exactly one UPDATE
|
|
-- matches download_state='queued'; the loser gets no rows (ErrNoRows).
|
|
-- This is the SKIP-LOCKED guarantee without a second round-trip.
|
|
UPDATE media_items
|
|
SET download_state = 'downloading', updated_at = NOW()
|
|
WHERE id = $1
|
|
AND source = 'podcast_episode'
|
|
AND download_state = 'queued'
|
|
RETURNING media_items.*;
|
|
|
|
-- name: CountQueuedEpisodes :one
|
|
SELECT COUNT(*) FROM media_items
|
|
WHERE source = 'podcast_episode' AND download_state IN ('queued', 'downloading');
|
|
|
|
-- name: CountPodcastEpisodesForFeed :one
|
|
SELECT COUNT(*) FROM media_items
|
|
WHERE source_feed_id = $1 AND source = 'podcast_episode';
|
|
|
|
-- ============================================================================
|
|
-- GENERIC reader_settings KV (podcast order override, plan D5: key
|
|
-- podcast_order:<feed_id>). The pre-existing reader_settings queries are
|
|
-- hard-keyed to 'reader_settings'; these two are the generic form.
|
|
-- ============================================================================
|
|
|
|
-- name: GetUserSettingByKey :one
|
|
SELECT setting_value FROM reader_settings
|
|
WHERE user_id = $1 AND setting_key = $2;
|
|
|
|
-- name: UpsertUserSettingByKey :exec
|
|
INSERT INTO reader_settings (user_id, setting_key, setting_value)
|
|
VALUES ($1, $2, $3)
|
|
ON CONFLICT (user_id, setting_key)
|
|
DO UPDATE SET setting_value = EXCLUDED.setting_value, updated_at = NOW();
|
|
|
|
-- name: DeleteUserSettingByKey :exec
|
|
DELETE FROM reader_settings WHERE user_id = $1 AND setting_key = $2;
|
|
|
|
-- name: GetPodcastEpisodeProgress :many
|
|
-- Per-user progress for a feed's episodes (row badges + the Phase-2
|
|
-- continue fields). reading_progress is UNIQUE per (media_item, user).
|
|
SELECT rp.media_item_id,
|
|
rp.percentage,
|
|
rp.last_read_at
|
|
FROM reading_progress rp
|
|
JOIN media_items mi ON mi.id = rp.media_item_id
|
|
WHERE rp.user_id = $1
|
|
AND mi.source_feed_id = $2
|
|
AND mi.source = 'podcast_episode';
|
|
|
|
-- name: GetPodcastContinueForUser :many
|
|
-- Continue aggregation (plan 2.5): the caller's IN-PROGRESS episodes
|
|
-- (0 < pct < finishedThreshold) across ALL subscribed feeds, most
|
|
-- recent first. Go groups per feed — the first row per feed IS the
|
|
-- continue target.
|
|
SELECT mi.source_feed_id,
|
|
mi.id,
|
|
mi.title,
|
|
mi.feed_position,
|
|
rp.percentage,
|
|
rp.last_read_at
|
|
FROM reading_progress rp
|
|
JOIN media_items mi ON mi.id = rp.media_item_id
|
|
WHERE rp.user_id = $1
|
|
AND mi.source = 'podcast_episode'
|
|
AND rp.percentage > 0
|
|
AND rp.percentage < 0.995
|
|
ORDER BY rp.last_read_at DESC;
|