2668 lines
80 KiB
SQL
2668 lines
80 KiB
SQL
-- 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;
|