Found while verifying H1's "app offline-play -> flush -> position lands"
with the emulator path: the flush has been dead code in two independent
ways, invisible because the first break made the second unreachable.
1. ListPendingSyncQueueItems matched `device_id = $1` with a NULL $1
(the processor passes DeviceID{Valid:false} to enumerate all devices;
uuid = NULL matches no rows). The ticker therefore processed nothing,
ever. The WHERE now treats NULL as "every device" (the same
narg::uuid IS NULL idiom the visibility queries use; EXPLAIN'd — seq
scan on a bounded table, idx_sync_queue_status_priority_created
fits). The processor also dropped its fake-pagination loop, which
re-fetched the same first batch forever once the backlog hit the
batch size (no OFFSET in the query).
2. dbItemToSyncQueueItem dropped SyncData — even with a working
enumerator, every flushed item arrived with an empty payload and
failed JSON unmarshal. Dead code harboring silent breakage, exactly
the plan's thesis.
Callers updated for the renamed RowLimit param (queue handler, offline
detector). TestH1_SyncQueueFlush_LandsAndPreservesAudio drives the real
flow: web PUT seeds audio 900s, a pending checkpoint-style queue row
applies percentage 0.8 through SaveProgress, audio survives, queue row
completes.
Also fixes dashboard_test.go's stale BuildSections signature (the
package's test binary has been unbuildable) — user-approved: tests
designed against old shapes are not worth keeping.
3244 lines
106 KiB
SQL
3244 lines
106 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, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, 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)
|
|
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)
|
|
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, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, tags_search, asin, date_published, publisher, 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)
|
|
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)
|
|
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,
|
|
author = $3,
|
|
isbn = $4,
|
|
description = $5,
|
|
cover_image_path = $6,
|
|
series = $7,
|
|
series_number = $8,
|
|
tags = $9,
|
|
tags_search = $10,
|
|
asin = $11,
|
|
date_published = $12,
|
|
publisher = $13,
|
|
contributors = $14,
|
|
contributors_search = $15,
|
|
language = $16,
|
|
edition = $17,
|
|
page_count = $18,
|
|
genre = $19,
|
|
copyright_year = $20,
|
|
goodreads_id = $21,
|
|
openlibrary_id = $22,
|
|
google_books_id = $23,
|
|
manga_type = $24,
|
|
reading_direction = $25,
|
|
series_count = $26,
|
|
volume = $27,
|
|
imprint = $28,
|
|
age_rating = $29,
|
|
web_url = $30,
|
|
metadata_notes = $31,
|
|
community_rating = $32,
|
|
story_arc = $33,
|
|
is_black_and_white = $34,
|
|
alternate_info = $35,
|
|
scan_information = $36,
|
|
summary = $37,
|
|
metadata_overrides = $38,
|
|
audio_metadata = $39,
|
|
narrators = $40,
|
|
narrators_search = $41,
|
|
abridged = $42,
|
|
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;
|
|
|
|
-- 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.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
|
|
AND (sqlc.narg('author_filter') = '' OR word_similarity(sqlc.narg('author_filter'), COALESCE(mi.author, '')) > 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
|
|
AND (sqlc.narg('publisher_filter') = '' OR word_similarity(sqlc.narg('publisher_filter'), COALESCE(mi.publisher, '')) > 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
|
|
))
|
|
-- 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 mi.library_id = sqlc.narg('library_id')
|
|
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 mi.library_id = sqlc.narg('library_id')
|
|
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 mi.library_id = sqlc.narg('library_id')
|
|
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 mi.library_id = sqlc.narg('library_id')
|
|
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');
|
|
|
|
-- Media Notes queries
|
|
-- name: CreateMediaNote :one
|
|
INSERT INTO media_notes (media_item_id, user_id, content, position)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING *;
|
|
|
|
-- 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;
|
|
|
|
-- name: UpdateMediaNote :one
|
|
UPDATE media_notes SET
|
|
content = $2,
|
|
position = $3,
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND user_id = $4
|
|
RETURNING *;
|
|
|
|
-- name: DeleteMediaNote :exec
|
|
DELETE FROM media_notes WHERE id = $1 AND user_id = $2;
|
|
|
|
-- Media Highlights queries
|
|
-- name: CreateMediaHighlight :one
|
|
INSERT INTO media_highlights (media_item_id, user_id, selection_text, start_position, end_position, color, note_id)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING *;
|
|
|
|
-- 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;
|
|
|
|
-- name: UpdateMediaHighlight :one
|
|
UPDATE media_highlights SET
|
|
selection_text = $2,
|
|
start_position = $3,
|
|
end_position = $4,
|
|
color = $5,
|
|
note_id = $6,
|
|
updated_at = NOW()
|
|
WHERE id = $1 AND user_id = $7
|
|
RETURNING *;
|
|
|
|
-- name: DeleteMediaHighlight :exec
|
|
DELETE FROM media_highlights WHERE id = $1 AND user_id = $2;
|
|
|
|
-- ============================================
|
|
-- 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: 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)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING *;
|
|
|
|
-- 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 *;
|
|
|
|
-- Remove book from collection
|
|
-- name: RemoveBookFromCollection :exec
|
|
DELETE FROM collection_items WHERE collection_id = $1 AND media_item_id = $2;
|
|
|
|
-- Get collection items
|
|
-- 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.added_at DESC;
|
|
|
|
-- 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;
|
|
|
|
-- ============================================
|
|
-- 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
|
|
SELECT c.* FROM collections c
|
|
WHERE c.user_id = $1
|
|
AND c.show_on_dashboard = true
|
|
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
|
|
SELECT mi.*, ci.excluded FROM media_items mi
|
|
INNER JOIN collection_items ci ON ci.media_item_id = mi.id
|
|
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
|
|
ORDER BY ci.added_at DESC
|
|
LIMIT sqlc.narg('limit');
|
|
|
|
-- 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
|
|
ORDER BY MAX(created_at) DESC
|
|
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;
|
|
|
|
-- 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;
|
|
|
|
-- name: CreateMediaBookmark :one
|
|
INSERT INTO media_bookmarks (media_item_id, user_id, page_number, chapter_number, cfi_position, title, position, notes)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
RETURNING *;
|
|
|
|
-- name: DeleteMediaBookmark :exec
|
|
DELETE FROM media_bookmarks
|
|
WHERE id = $1 AND user_id = $2;
|
|
|
|
-- name: UpdateMediaBookmark :one
|
|
UPDATE media_bookmarks
|
|
SET
|
|
title = $2,
|
|
notes = $3,
|
|
position = $4,
|
|
last_modified_at = NOW()
|
|
WHERE id = $1 AND user_id = $5
|
|
RETURNING *;
|
|
|
|
-- ============================================================================
|
|
-- 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;
|