Files
bookhoard/internal/database/queries/queries.sql
T
John O'Keefe ebb7f4a5b0 feat(filters): panel removal + All-Books field-values fix (faceted plan Phase 8)
- Remove the Phase 7 facet-panel UI: SSR route, facet_panel.templ,
  FacetCountsForView, drawer panel block, panel TS + .facet-* CSS.
  The ENGINE stays (FacetCountsUnderFilters, all-facets endpoint,
  drift guard) — it becomes the Phase 9 builder's machinery.
- Fix the All-Books bug: field-values autocomplete (author/genre/
  tags/narrator/series/language) treats library_id as OPTIONAL —
  absent aggregates every VISIBLE library (family lens in-query);
  explicit out-of-set library_id is now 403, never a silent pass.
  The four scalar Search*Values queries get the nullable shape.
- toast.ts: suggestion fetches are exempt from error toasts
  (console-only) — a failed background hint never interrupts.
- TestFacetPanelPartial deleted with the behavior; TestFieldValuesAll-
  Libraries pins the fix (200 + cross-library aggregation, family
  lens, 403). Contract-pins changelog entry added.
2026-10-20 12:20:51 -04:00

4404 lines
165 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,
(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
INSERT INTO library_folders (library_id, folder_path) VALUES ($1, $2) 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
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 (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_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: 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 (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
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 (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: 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.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.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 (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 (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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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.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
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 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: 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)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *;
-- A2c §2.4: provenance + playlist flags on an existing collection without
-- touching the rest of the row.
-- name: UpdateCollectionViewSettings :exec
UPDATE collections SET view_settings = $2, updated_at = NOW() 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
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;
-- 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
-- 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
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
-- name: CountUnlinkedBooks :one
SELECT COUNT(*) as count
FROM unlinked_books
WHERE device_id = $1 AND resolved = false;
-- 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 queue rows across the caller's own devices.
-- 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;
-- Page list: unresolved queue rows for the caller's own devices.
-- 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
ORDER BY ub.last_seen_at DESC;
-- ============================================
-- 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.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.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.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.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.
-- name: GetVisibleLiveMediaForRules :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 (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
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.
SELECT library_id, series, COUNT(*) as book_count,
MAX(series_count) as total_in_series,
MAX(created_at) as last_entry_at
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 IS NOT NULL AND series != ''
GROUP BY library_id, series
-- Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
-- transaction share a single NOW() timestamp, leaving many groups tied on
-- MAX(created_at). Postgres does not guarantee tie order between
-- executions, so page boundaries could repeat or drop series. The GROUP BY
-- keys make the sort total and deterministic.
ORDER BY MAX(created_at) DESC, library_id, series
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
-- name: GetDistinctSeriesCount :one
SELECT COUNT(DISTINCT (library_id, series))::int
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 IS NOT NULL AND series != '';
-- 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
INSERT INTO processing_issues (media_item_id, library_id, issue_type, issue_description, severity)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (media_item_id, issue_type)
DO UPDATE SET issue_description = EXCLUDED.issue_description,
severity = EXCLUDED.severity,
resolved = false,
resolved_at = NULL
RETURNING *;
-- name: ListProcessingIssuesByLibrary :many
SELECT
pi.id,
pi.media_item_id,
pi.issue_type,
pi.issue_description,
pi.severity,
pi.resolved,
pi.resolved_at,
pi.created_at,
mi.title,
mi.file_path,
mi.format_group,
lt.name as library_type_name
FROM processing_issues pi
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: 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;