-- name: CreateUser :one INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, email, username, theme, first_name, last_name, role, 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 WHERE COALESCE(lv.is_visible, true) = true GROUP BY l.id; -- Media Items queries -- name: CreateMediaItem :one 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) 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) ON CONFLICT (library_id, file_path) DO UPDATE SET updated_at = NOW() RETURNING *; -- 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 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 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.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, updated_at = NOW() WHERE id = $1 RETURNING *; -- 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; -- 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 (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.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 (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 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 )) -- 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 -- 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) ) ) 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, '')), (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.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.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: SearchSeriesValues :many SELECT mi.series as value, COUNT(*) as count, word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, ''))::float8 as score FROM media_items mi JOIN libraries l ON mi.library_id = l.id LEFT JOIN library_visibility lv ON l.id = lv.library_id AND lv.user_id = sqlc.narg('user_id') WHERE COALESCE(lv.is_visible, true) = true AND mi.library_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.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 RETURNING *; -- name: DeleteMediaNote :exec DELETE FROM media_notes WHERE id = $1; -- 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 RETURNING *; -- name: DeleteMediaHighlight :exec DELETE FROM media_highlights WHERE id = $1; -- ============================================ -- 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 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; -- 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 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; -- 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 ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15 ) 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 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; -- 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.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, last_read_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW(), $18, $19, 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, 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; -- ============================================ -- 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 SELECT * FROM sync_queue WHERE device_id = $1 AND status = 'pending' ORDER BY priority ASC, created_at ASC LIMIT $2; -- 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; -- 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 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 *; -- 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 FROM collection_items ci JOIN media_items mi ON ci.media_item_id = mi.id WHERE ci.collection_id = $1 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; -- 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 -- name: GetDashboardPreferences :one SELECT * FROM user_dashboard_preferences WHERE user_id = sqlc.narg('user_id') AND (sqlc.narg('library_id')::uuid IS NULL OR 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 *; -- Dashboard collections queries -- name: GetSystemCollectionsForDashboard :many SELECT * FROM collections WHERE user_id = $1 AND is_system_collection = true AND show_on_dashboard = true 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 ORDER BY priority ASC; -- name: DeleteUserSystemCollection :exec DELETE FROM collections WHERE user_id = $1 AND name = $2 AND is_system_collection = true; -- 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) 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) 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) 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 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) ORDER BY ci.added_at DESC LIMIT sqlc.narg('limit'); -- 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) ORDER BY mi.created_at DESC; -- name: GetDistinctSeries :many SELECT 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 series IS NOT NULL AND series != '' GROUP BY series ORDER BY MAX(created_at) DESC LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset'); -- name: GetDistinctSeriesCount :one SELECT COUNT(DISTINCT series)::int FROM media_items WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid) 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 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 SELECT * FROM media_items WHERE series = sqlc.narg('series') 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 (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; -- 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;