Files
bookhoard/internal/database/queries/queries.sql
T
john-okeefe 209e9f2a3c feat: implement relative path storage and URL resolution for media files
- Add libraryService dependency to CollectionHandler and OPDSHandler for centralized path resolution
- Create internal/utils/mediaurl.go with ResolveMediaURL() function as single source of truth
- Update GetMediaItem and ListMediaItems handlers to return resolved URLs in API responses
- Update collection handlers (GetCollection, TestRules, PreviewCollection) to use resolved cover URLs
- Update progress handler (GetAllProgress) to use resolved cover URLs
- Add library_id to GetCollectionItems SQL query to enable URL resolution
- Refactor media scanner to store relative paths instead of absolute filesystem paths
- Add ResolveMediaPath() to LibraryService for resolving relative paths to absolute paths
- Add ServeFile endpoint at /uploads/library-:id/* for authenticated file serving
- Add MimeTypes map to library_service.go for consistent MIME type handling
- Update DownloadBook handler to use resolved filesystem paths
- Add getRelativePath() helper to MediaScanner for converting absolute to relative paths
- Use strings.EqualFold for case-insensitive path comparisons in zip extraction

This change enables the application to work with relative paths stored in the
database, making it portable across different server environments while
maintaining backward compatibility with existing absolute paths.
2026-02-27 16:51:44 -05:00

1722 lines
49 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,
(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;
-- 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;
-- 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 DESC;
-- 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)
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)
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: ListMediaItemsFiltered :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 mi.library_id = sqlc.narg('library_id')
AND COALESCE(lv.is_visible, true) = true
AND (sqlc.narg('author_filter') = '' OR mi.author ILIKE sqlc.narg('author_filter'))
AND (sqlc.narg('series_filter') = '' OR mi.series ILIKE sqlc.narg('series_filter'))
AND (sqlc.narg('genre_filter') = '' OR mi.genre = sqlc.narg('genre_filter'))
AND (sqlc.narg('language_filter') = '' OR mi.language = sqlc.narg('language_filter'))
AND (sqlc.narg('year_min') = 0 OR mi.copyright_year >= sqlc.narg('year_min'))
AND (sqlc.narg('year_max') = 0 OR mi.copyright_year <= sqlc.narg('year_max'))
AND (sqlc.narg('has_cover') = false OR mi.cover_image_path IS NOT NULL)
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,
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,
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: CountUserDevices :one
SELECT COUNT(*) FROM devices WHERE user_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: GetAllSystemSettings :many
SELECT setting_key, setting_value, description FROM system_settings ORDER BY 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 (
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: SearchMediaItemsFuzzy :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 (
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
)
)
ORDER BY
GREATEST(
word_similarity(sqlc.narg('search_query'), mi.title),
word_similarity(sqlc.narg('search_query'), COALESCE(mi.author, '')),
word_similarity(sqlc.narg('search_query'), COALESCE(mi.series, '')),
COALESCE(
(SELECT MAX(word_similarity(sqlc.narg('search_query'), tag))
FROM unnest(mi.tags_search) AS tag),
0
),
COALESCE(
(SELECT MAX(word_similarity(sqlc.narg('search_query'), contributor))
FROM unnest(mi.contributors_search) AS contributor),
0
)
) DESC,
mi.title ASC
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 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 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;
-- 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() - INTERVAL '7 days');
-- ============================================
-- 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.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,
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, NOW(), $17, $18, NOW()
)
ON CONFLICT (media_item_id, user_id)
DO UPDATE SET
percentage = EXCLUDED.percentage,
character_offset = EXCLUDED.character_offset,
epubcfi = EXCLUDED.epubcfi,
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: 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;
-- ============================================
-- 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
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
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 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)
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 = $1 AND library_id = $2;
-- 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 = $2
AND percentage > 0
AND percentage < 1
ORDER BY media_item_id, last_read_at DESC
) rp ON rp.media_item_id = mi.id
WHERE mi.library_id = $1
ORDER BY rp.last_read_at DESC
LIMIT $3;
-- name: GetRecentlyAddedItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
ORDER BY mi.created_at DESC
LIMIT $2;
-- 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 = $2
AND percentage >= 1
ORDER BY media_item_id, last_read_at DESC
) rp ON rp.media_item_id = mi.id
WHERE mi.library_id = $1
ORDER BY rp.last_read_at DESC
LIMIT $3;
-- name: GetNotStartedItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
AND NOT EXISTS (
SELECT 1 FROM reading_progress rp
WHERE rp.media_item_id = mi.id
AND rp.user_id = $2
AND rp.percentage > 0
)
ORDER BY mi.created_at DESC
LIMIT $3;
-- 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 = $1
AND mi.library_id = $2
ORDER BY ci.added_at DESC
LIMIT $3;
-- name: GetLibraryItems :many
SELECT mi.* FROM media_items mi
WHERE mi.library_id = $1
ORDER BY mi.created_at DESC;