From 63008001c6eab191ea3b022ac0bc82aa43d428ed Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 31 Jan 2026 22:32:04 -0500 Subject: [PATCH] feat(db): Add kobo_shelves and device catalog tables - Add kobo_shelves table for Kobo device shelves management - Add device_shelf_mappings for collection<->device shelf mappings - Update device_catalogs with better ContentId tracking - Add indexes for performance --- database/schema/schema.sql | 171 +- internal/database/models.go | 115 +- internal/database/querier.go | 127 ++ internal/database/queries.sql.go | 2138 ++++++++++++++++++++++++- internal/database/queries/queries.sql | 423 ++++- 5 files changed, 2910 insertions(+), 64 deletions(-) diff --git a/database/schema/schema.sql b/database/schema/schema.sql index 1885ce1..205a74e 100644 --- a/database/schema/schema.sql +++ b/database/schema/schema.sql @@ -735,8 +735,8 @@ BEGIN -- If still not found, try to match by title + author IF media_uuid IS NULL THEN - SELECT id INTO media_uuid FROM media_items - WHERE title = book_record->>'title' + SELECT id INTO media_uuid FROM media_items + WHERE title = book_record->>'title' AND author = book_record->>'author' LIMIT 1; END IF; @@ -751,7 +751,7 @@ BEGIN conflict_detected := detect_conflict(media_uuid, book_record, 'koreader'); -- Check if progress exists - SELECT * INTO existing_progress FROM reading_progress + SELECT * INTO existing_progress FROM reading_progress WHERE media_item_id = media_uuid AND user_id = p_user_id; -- Update or insert progress @@ -816,8 +816,171 @@ BEGIN )); END IF; - RETURN QUERY SELECT media_uuid, TRUE, + RETURN QUERY SELECT media_uuid, TRUE, CASE WHEN conflict_detected THEN 'conflict detected' ELSE 'success' END; END LOOP; END; $$ LANGUAGE plpgsql; + +-- ============================================ +-- PHASE 1: UNIVERSAL BOOK IDENTIFIERS (Week 1) +-- ============================================ + +-- Add universal identifier columns to media_items table +ALTER TABLE media_items ADD COLUMN IF NOT EXISTS file_sha256 CHAR(64); +ALTER TABLE media_items ADD COLUMN IF NOT EXISTS opf_identifier VARCHAR(255); +ALTER TABLE media_items ADD COLUMN IF NOT EXISTS opf_uuid VARCHAR(255); +ALTER TABLE media_items ADD COLUMN IF NOT EXISTS hash_confidence VARCHAR(20); + +-- Create indexes for fast lookup +CREATE INDEX IF NOT EXISTS idx_media_items_sha256 ON media_items(file_sha256); +CREATE INDEX IF NOT EXISTS idx_media_items_opf_identifier ON media_items(opf_identifier); + +-- Create media_item_formats table (track all format versions with their hashes) +CREATE TABLE IF NOT EXISTS media_item_formats ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + format_type VARCHAR(10) NOT NULL, + file_path VARCHAR(500), + file_sha256 CHAR(64), + file_size_bytes BIGINT, + mime_type VARCHAR(100), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + converted_from_format_id UUID REFERENCES media_item_formats(id), + UNIQUE(media_item_id, format_type) +); + +CREATE INDEX IF NOT EXISTS idx_media_item_formats_media ON media_item_formats(media_item_id, format_type); +CREATE INDEX IF NOT EXISTS idx_media_item_formats_sha256 ON media_item_formats(file_sha256); + +-- Create device_file_aliases table (track file paths per device for cross-device matching) +CREATE TABLE IF NOT EXISTS device_file_aliases ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + file_path VARCHAR(500) NOT NULL, + file_sha256 CHAR(64), + confidence_score FLOAT DEFAULT 1.0, + last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(device_id, file_path) +); + +CREATE INDEX IF NOT EXISTS idx_device_file_aliases_media_device ON device_file_aliases(media_item_id, device_id); +CREATE INDEX IF NOT EXISTS idx_device_file_aliases_sha256 ON device_file_aliases(file_sha256); + +-- Create collections table (device-neutral collections) +CREATE TABLE IF NOT EXISTS collections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name VARCHAR(100) NOT NULL, + description TEXT, + color VARCHAR(7), + icon VARCHAR(50), + auto_assign_rules JSONB, + view_settings JSONB, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(user_id, name) +); + +CREATE INDEX IF NOT EXISTS idx_collections_user_id ON collections(user_id); + +-- Create collection_items table (which books belong to each collection) +CREATE TABLE IF NOT EXISTS collection_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + added_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + added_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + UNIQUE(collection_id, media_item_id) +); + +CREATE INDEX IF NOT EXISTS idx_collection_items_collection ON collection_items(collection_id); +CREATE INDEX IF NOT EXISTS idx_collection_items_media ON collection_items(media_item_id); + +-- Create device_shelf_mappings table (map Bookmann collections to device-specific shelf names) +CREATE TABLE IF NOT EXISTS device_shelf_mappings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + collection_id UUID NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + device_shelf_name VARCHAR(100), + sync_direction VARCHAR(20), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(collection_id, device_id) +); + +CREATE INDEX IF NOT EXISTS idx_device_shelf_mappings_collection ON device_shelf_mappings(collection_id); +CREATE INDEX IF NOT EXISTS idx_device_shelf_mappings_device ON device_shelf_mappings(device_id); + +-- Create device_catalogs table (track OPDS downloads and map Bookmann UUIDs to device ContentIds) +CREATE TABLE IF NOT EXISTS device_catalogs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + media_item_id UUID NOT NULL REFERENCES media_items(id) ON DELETE CASCADE, + bookmann_uuid UUID NOT NULL, + kobo_content_id VARCHAR(255) NOT NULL, + content_id_type VARCHAR(20), + available BOOLEAN DEFAULT TRUE, + delivery_date TIMESTAMP WITH TIME ZONE, + delivery_method VARCHAR(20), + UNIQUE(device_id, kobo_content_id) +); + +CREATE INDEX IF NOT EXISTS idx_device_catalogs_bookmann ON device_catalogs(bookmann_uuid); +CREATE INDEX IF NOT EXISTS idx_device_catalogs_kobo ON device_catalogs(kobo_content_id); + +-- Create system_config table (system-wide configuration) +CREATE TABLE IF NOT EXISTS system_config ( + key VARCHAR(100) PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_by UUID REFERENCES users(id) +); + +-- Pre-seeded values +INSERT INTO system_config (key, value) VALUES +('base_url', 'https://bookmann.example.com'), +('opds_base_url', 'https://bookmann.example.com/opds'), +('api_base_url', 'https://bookmann.example.com/api') +ON CONFLICT (key) DO NOTHING; + +-- Create opds_tokens table (device-specific OPDS access tokens) +CREATE TABLE IF NOT EXISTS opds_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + token VARCHAR(64) UNIQUE NOT NULL, + token_type VARCHAR(20), + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_opds_tokens_device ON opds_tokens(device_id); +CREATE INDEX IF NOT EXISTS idx_opds_tokens_token ON opds_tokens(token); + +-- Modify kobo_shelves table (reference collections instead of media_items directly) +ALTER TABLE kobo_shelves ADD COLUMN IF NOT EXISTS collection_id UUID REFERENCES collections(id); +ALTER TABLE kobo_shelves ADD COLUMN IF NOT EXISTS position_in_collection INTEGER; + +-- ============================================ +-- PHASE 6: UNLINKED BOOKS TRACKING (Week 3-4) +-- ============================================ + +-- Create unlinked_books table to track books that couldn't be auto-matched +CREATE TABLE IF NOT EXISTS unlinked_books ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + content_id VARCHAR(255) NOT NULL, + file_path VARCHAR(500), + title VARCHAR(255), + author VARCHAR(255), + confidence_score FLOAT DEFAULT 0.5, + resolved BOOLEAN DEFAULT FALSE, + media_item_id UUID REFERENCES media_items(id) ON DELETE SET NULL, + resolved_at TIMESTAMP WITH TIME ZONE, + resolution_method VARCHAR(50), + last_seen_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_unlinked_books_device ON unlinked_books(device_id); +CREATE INDEX IF NOT EXISTS idx_unlinked_books_content_id ON unlinked_books(content_id); +CREATE INDEX IF NOT EXISTS idx_unlinked_books_resolved ON unlinked_books(resolved); diff --git a/internal/database/models.go b/internal/database/models.go index fc95938..78978a4 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -8,6 +8,57 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type CollectionItems struct { + ID pgtype.UUID `db:"id" json:"id"` + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"` + AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"` +} + +type Collections struct { + ID pgtype.UUID `db:"id" json:"id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + Description pgtype.Text `db:"description" json:"description"` + Color pgtype.Text `db:"color" json:"color"` + Icon pgtype.Text `db:"icon" json:"icon"` + AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` + ViewSettings []byte `db:"view_settings" json:"view_settings"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` +} + +type DeviceCatalogs struct { + ID pgtype.UUID `db:"id" json:"id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + BookmannUuid pgtype.UUID `db:"bookmann_uuid" json:"bookmann_uuid"` + KoboContentID string `db:"kobo_content_id" json:"kobo_content_id"` + ContentIDType pgtype.Text `db:"content_id_type" json:"content_id_type"` + Available pgtype.Bool `db:"available" json:"available"` + DeliveryDate pgtype.Timestamptz `db:"delivery_date" json:"delivery_date"` + DeliveryMethod pgtype.Text `db:"delivery_method" json:"delivery_method"` +} + +type DeviceFileAliases struct { + ID pgtype.UUID `db:"id" json:"id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + FilePath string `db:"file_path" json:"file_path"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"` + LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"` +} + +type DeviceShelfMappings struct { + ID pgtype.UUID `db:"id" json:"id"` + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + DeviceShelfName pgtype.Text `db:"device_shelf_name" json:"device_shelf_name"` + SyncDirection pgtype.Text `db:"sync_direction" json:"sync_direction"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` +} + type Devices struct { ID pgtype.UUID `db:"id" json:"id"` UserID pgtype.UUID `db:"user_id" json:"user_id"` @@ -163,13 +214,15 @@ type KoboEntitlements struct { } type KoboShelves struct { - ID pgtype.UUID `db:"id" json:"id"` - DeviceID pgtype.UUID `db:"device_id" json:"device_id"` - MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` - ShelfName pgtype.Text `db:"shelf_name" json:"shelf_name"` - ShelfPosition pgtype.Int4 `db:"shelf_position" json:"shelf_position"` - AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"` - LastSyncedAt pgtype.Timestamptz `db:"last_synced_at" json:"last_synced_at"` + ID pgtype.UUID `db:"id" json:"id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + ShelfName pgtype.Text `db:"shelf_name" json:"shelf_name"` + ShelfPosition pgtype.Int4 `db:"shelf_position" json:"shelf_position"` + AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"` + LastSyncedAt pgtype.Timestamptz `db:"last_synced_at" json:"last_synced_at"` + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + PositionInCollection pgtype.Int4 `db:"position_in_collection" json:"position_in_collection"` } type Libraries struct { @@ -230,6 +283,18 @@ type MediaHighlights struct { DeviceSyncData []byte `db:"device_sync_data" json:"device_sync_data"` } +type MediaItemFormats struct { + ID pgtype.UUID `db:"id" json:"id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + FormatType string `db:"format_type" json:"format_type"` + FilePath pgtype.Text `db:"file_path" json:"file_path"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + FileSizeBytes pgtype.Int8 `db:"file_size_bytes" json:"file_size_bytes"` + MimeType pgtype.Text `db:"mime_type" json:"mime_type"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + ConvertedFromFormatID pgtype.UUID `db:"converted_from_format_id" json:"converted_from_format_id"` +} + type MediaItems struct { ID pgtype.UUID `db:"id" json:"id"` LibraryID pgtype.UUID `db:"library_id" json:"library_id"` @@ -277,6 +342,10 @@ type MediaItems struct { RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"` KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"` KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"` + OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"` + HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"` } type MediaNotes struct { @@ -306,6 +375,15 @@ type MediaRatings struct { UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` } +type OpdsTokens struct { + ID pgtype.UUID `db:"id" json:"id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + Token string `db:"token" json:"token"` + TokenType pgtype.Text `db:"token_type" json:"token_type"` + ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` +} + type ReadingHistory struct { ID pgtype.UUID `db:"id" json:"id"` UserID pgtype.UUID `db:"user_id" json:"user_id"` @@ -383,6 +461,29 @@ type SyncQueue struct { ProcessedAt pgtype.Timestamptz `db:"processed_at" json:"processed_at"` } +type SystemConfig struct { + Key string `db:"key" json:"key"` + Value string `db:"value" json:"value"` + UpdatedAt pgtype.Timestamptz `db:"updated_at" json:"updated_at"` + UpdatedBy pgtype.UUID `db:"updated_by" json:"updated_by"` +} + +type UnlinkedBooks struct { + ID pgtype.UUID `db:"id" json:"id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + ContentID string `db:"content_id" json:"content_id"` + FilePath pgtype.Text `db:"file_path" json:"file_path"` + Title pgtype.Text `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"` + Resolved pgtype.Bool `db:"resolved" json:"resolved"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"` + ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"` + LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` +} + type Users struct { ID pgtype.UUID `db:"id" json:"id"` Email string `db:"email" json:"email"` diff --git a/internal/database/querier.go b/internal/database/querier.go index 8581f5c..5b58a21 100644 --- a/internal/database/querier.go +++ b/internal/database/querier.go @@ -11,6 +11,9 @@ import ( ) type Querier interface { + // COLLECTION ITEMS QUERIES + // Add book to collection + AddBookToCollection(ctx context.Context, arg AddBookToCollectionParams) (CollectionItems, error) // ============================================ // KOBO SHELF MANAGEMENT QUERIES (Phase 4) // ============================================ @@ -20,16 +23,32 @@ type Querier interface { // Bulk update format group for all media items BulkUpdateFormatGroups(ctx context.Context) error BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdateProgressFromSyncParams) ([]interface{}, error) + // Cleanup expired OPDS tokens + CleanupExpiredOpdsTokens(ctx context.Context) error CleanupExpiredRefreshTokens(ctx context.Context) error ClearDeviceSyncQueue(ctx context.Context, deviceID pgtype.UUID) error ClearKoboShelf(ctx context.Context, deviceID pgtype.UUID) error ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfByNameParams) error + // Count unlinked books for a device + CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) CountUserDevices(ctx context.Context, userID pgtype.UUID) (int64, error) + // COLLECTIONS QUERIES + // Create collection + CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error) // ============================================ // PHASE 2: DEVICE MANAGEMENT & AUTH (Weeks 5-6) // ============================================ // Device Registration & Management CreateDevice(ctx context.Context, arg CreateDeviceParams) (Devices, error) + // DEVICE CATALOGS QUERIES + // Create device catalog entry + CreateDeviceCatalog(ctx context.Context, arg CreateDeviceCatalogParams) (DeviceCatalogs, error) + // DEVICE FILE ALIASES QUERIES + // Create device file alias + CreateDeviceFileAlias(ctx context.Context, arg CreateDeviceFileAliasParams) (DeviceFileAliases, error) + // DEVICE SHELF MAPPINGS QUERIES + // Create device shelf mapping + CreateDeviceShelfMapping(ctx context.Context, arg CreateDeviceShelfMappingParams) (DeviceShelfMappings, error) // Backward compatibility - Ebook Notes queries (using views) CreateEbookNote(ctx context.Context, arg CreateEbookNoteParams) (MediaNotes, error) // Libraries queries @@ -38,9 +57,15 @@ type Querier interface { CreateMediaHighlight(ctx context.Context, arg CreateMediaHighlightParams) (MediaHighlights, error) // Media Items queries CreateMediaItem(ctx context.Context, arg CreateMediaItemParams) (MediaItems, error) + // MEDIA ITEM FORMATS QUERIES + // Create media item format + CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error) // Media Notes queries CreateMediaNote(ctx context.Context, arg CreateMediaNoteParams) (MediaNotes, error) CreateMediaRating(ctx context.Context, arg CreateMediaRatingParams) (MediaRatings, error) + // OPDS TOKENS QUERIES + // Create OPDS token + CreateOpdsToken(ctx context.Context, arg CreateOpdsTokenParams) (OpdsTokens, error) // ============================================ // KOBO ENTITLEMENT QUERIES (Phase 4) // ============================================ @@ -54,26 +79,71 @@ type Querier interface { CreateSyncHistoryEntry(ctx context.Context, arg CreateSyncHistoryEntryParams) (SyncQueue, error) // Sync Queue Management CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueItemParams) (SyncQueue, error) + // ============================================ + // PHASE 6: ENHANCED KOBO SYNC (Week 3-4) + // ============================================ + // Create unlinked book entry + CreateUnlinkedBook(ctx context.Context, arg CreateUnlinkedBookParams) (UnlinkedBooks, error) CreateUser(ctx context.Context, arg CreateUserParams) (CreateUserRow, error) + // Delete collection + DeleteCollection(ctx context.Context, id pgtype.UUID) error DeleteDevice(ctx context.Context, id pgtype.UUID) error DeleteDeviceByToken(ctx context.Context, authToken string) error + // Delete device catalog entry + DeleteDeviceCatalog(ctx context.Context, id pgtype.UUID) error + // Delete device file alias + DeleteDeviceFileAlias(ctx context.Context, id pgtype.UUID) error + // Delete device shelf mapping + DeleteDeviceShelfMapping(ctx context.Context, id pgtype.UUID) error DeleteEbookNote(ctx context.Context, id pgtype.UUID) error DeleteKoboEntitlement(ctx context.Context, arg DeleteKoboEntitlementParams) error DeleteLibrary(ctx context.Context, id pgtype.UUID) error DeleteLibraryFolder(ctx context.Context, arg DeleteLibraryFolderParams) (LibraryFolders, error) DeleteMediaHighlight(ctx context.Context, id pgtype.UUID) error DeleteMediaItem(ctx context.Context, id pgtype.UUID) error + // Delete media item format + DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error DeleteMediaNote(ctx context.Context, id pgtype.UUID) error DeleteMediaRating(ctx context.Context, arg DeleteMediaRatingParams) error DeleteReadingProgress(ctx context.Context, arg DeleteReadingProgressParams) error DeleteSyncConflict(ctx context.Context, id pgtype.UUID) error DeleteSyncQueueItem(ctx context.Context, id pgtype.UUID) error + // Delete system config + DeleteSystemConfig(ctx context.Context, key string) error DeleteUser(ctx context.Context, id pgtype.UUID) error GenerateKoboEntitlementId(ctx context.Context) (interface{}, error) + // Get all system config + GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsForBookParams) ([]GetAnnotationsForBookRow, error) + // Get collection + GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error) + // Get collection items + GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error) + // Get collection with book count + GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID) (GetCollectionWithBookCountRow, error) + // Get collections by user + GetCollectionsByUser(ctx context.Context, userID pgtype.UUID) ([]Collections, error) + // Get collections for book + GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.UUID) ([]Collections, error) GetDevice(ctx context.Context, id pgtype.UUID) (Devices, error) GetDeviceByAuthToken(ctx context.Context, authToken string) (Devices, error) GetDeviceByIdentifier(ctx context.Context, deviceIdentifier string) (Devices, error) + // Get device catalog by Bookmann UUID + GetDeviceCatalogByBookmannUUID(ctx context.Context, arg GetDeviceCatalogByBookmannUUIDParams) (DeviceCatalogs, error) + // Get device catalog by Kobo ContentId + GetDeviceCatalogByKoboContentId(ctx context.Context, koboContentID string) (DeviceCatalogs, error) + // Get device catalog entries + GetDeviceCatalogEntries(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceCatalogEntriesRow, error) + // Get device file alias + GetDeviceFileAlias(ctx context.Context, arg GetDeviceFileAliasParams) (DeviceFileAliases, error) + // Get device file alias by SHA-256 + GetDeviceFileAliasBySHA256(ctx context.Context, fileSha256 pgtype.Text) (GetDeviceFileAliasBySHA256Row, error) + // Get device file aliases by device + GetDeviceFileAliasesByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceFileAliasesByDeviceRow, error) + // Get device shelf mapping + GetDeviceShelfMapping(ctx context.Context, arg GetDeviceShelfMappingParams) (DeviceShelfMappings, error) + // Get device shelf mappings + GetDeviceShelfMappings(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceShelfMappingsRow, error) GetFailedSyncQueueItems(ctx context.Context, limit int32) ([]SyncQueue, error) GetKoboEntitlementByContentId(ctx context.Context, arg GetKoboEntitlementByContentIdParams) (GetKoboEntitlementByContentIdRow, error) GetKoboEntitlementByEntitlementId(ctx context.Context, arg GetKoboEntitlementByEntitlementIdParams) (GetKoboEntitlementByEntitlementIdRow, error) @@ -81,6 +151,8 @@ type Querier interface { GetKoboShelfBookCount(ctx context.Context, deviceID pgtype.UUID) (int64, error) GetKoboShelfBooks(ctx context.Context, deviceID pgtype.UUID) ([]GetKoboShelfBooksRow, error) GetKoboShelfBooksByShelfName(ctx context.Context, arg GetKoboShelfBooksByShelfNameParams) ([]GetKoboShelfBooksByShelfNameRow, error) + // Get Kobo shelves by collection + GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShelvesByCollectionParams) ([]GetKoboShelvesByCollectionRow, error) GetLibrary(ctx context.Context, id pgtype.UUID) (GetLibraryRow, error) GetLibraryByFolder(ctx context.Context, folderPath string) (GetLibraryByFolderRow, error) GetLibraryFolders(ctx context.Context, libraryID pgtype.UUID) ([]LibraryFolders, error) @@ -98,11 +170,27 @@ type Querier interface { // ============================================ GetMediaItemByFilePathForSync(ctx context.Context, filePath string) (MediaItems, error) GetMediaItemByKoboContentId(ctx context.Context, koboContentID pgtype.Text) (MediaItems, error) + // Get media item by OPF identifier + GetMediaItemByOPFIdentifier(ctx context.Context, opfIdentifier pgtype.Text) (MediaItems, error) + // Get media item by OPF UUID + GetMediaItemByOPFUUID(ctx context.Context, opfUuid pgtype.Text) (MediaItems, error) + // Get media item by SHA-256 hash + GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItems, error) + // Get media item format by SHA-256 + GetMediaItemFormatBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItemFormats, error) + // Get media item format by type + GetMediaItemFormatByType(ctx context.Context, arg GetMediaItemFormatByTypeParams) (MediaItemFormats, error) + // Get media item formats + GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error) GetMediaNote(ctx context.Context, id pgtype.UUID) (MediaNotes, error) GetMediaNotes(ctx context.Context, arg GetMediaNotesParams) ([]MediaNotes, error) GetMediaRating(ctx context.Context, arg GetMediaRatingParams) (MediaRatings, error) GetMediaRatings(ctx context.Context, mediaItemID pgtype.UUID) ([]GetMediaRatingsRow, error) GetNextRetryTime(ctx context.Context) (interface{}, error) + // Get OPDS token + GetOpdsToken(ctx context.Context, token string) (GetOpdsTokenRow, error) + // Get OPDS tokens by device + GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error) // Get reading history for a user and book GetReadingHistory(ctx context.Context, arg GetReadingHistoryParams) ([]ReadingHistory, error) GetReadingProgress(ctx context.Context, arg GetReadingProgressParams) (ReadingProgress, error) @@ -112,8 +200,15 @@ type Querier interface { GetSyncConflict(ctx context.Context, id pgtype.UUID) (SyncConflicts, error) GetSyncQueueItem(ctx context.Context, id pgtype.UUID) (SyncQueue, error) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) (GetSyncQueueStatsRow, error) + // SYSTEM CONFIG QUERIES + // Get system config + GetSystemConfig(ctx context.Context, key string) (SystemConfig, error) // Get universal progress for a book GetUniversalProgress(ctx context.Context, arg GetUniversalProgressParams) (GetUniversalProgressRow, error) + // Get unlinked book by ContentId + GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinkedBookByContentIdParams) (UnlinkedBooks, error) + // Get unlinked books for a device + GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error) GetUser(ctx context.Context, id pgtype.UUID) (GetUserRow, error) GetUserByEmail(ctx context.Context, email string) (GetUserByEmailRow, error) GetUserByEmailOrUsername(ctx context.Context, email string) (GetUserByEmailOrUsernameRow, error) @@ -124,7 +219,11 @@ type Querier interface { GetUserProgressForBooks(ctx context.Context, arg GetUserProgressForBooksParams) ([]GetUserProgressForBooksRow, error) GetUserVisibleLibraries(ctx context.Context, userID pgtype.UUID) ([]GetUserVisibleLibrariesRow, error) IncrementSyncQueueAttempts(ctx context.Context, arg IncrementSyncQueueAttemptsParams) (SyncQueue, error) + // Check if book is in collection + IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error) IsBookOnKoboShelf(ctx context.Context, arg IsBookOnKoboShelfParams) (bool, error) + // Link unlinked book to media item + LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error) ListAllConflictsByUserAndStatus(ctx context.Context, arg ListAllConflictsByUserAndStatusParams) ([]ListAllConflictsByUserAndStatusRow, error) ListAllSyncQueueItems(ctx context.Context, arg ListAllSyncQueueItemsParams) ([]ListAllSyncQueueItemsRow, error) ListDevicesByType(ctx context.Context, deviceType string) ([]Devices, error) @@ -138,10 +237,18 @@ type Querier interface { ListSyncConflictsByMediaItem(ctx context.Context, arg ListSyncConflictsByMediaItemParams) ([]SyncConflicts, error) ListSyncConflictsByUser(ctx context.Context, userID pgtype.UUID) ([]ListSyncConflictsByUserRow, error) ListUsers(ctx context.Context) ([]ListUsersRow, error) + // Query media items by multiple identifiers with confidence scoring + QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error) + // Remove book from collection + RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error RemoveBookFromKoboShelf(ctx context.Context, arg RemoveBookFromKoboShelfParams) error ResolveSyncConflict(ctx context.Context, arg ResolveSyncConflictParams) (SyncConflicts, error) + // Resolve unlinked book + ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBookParams) (UnlinkedBooks, error) RevokeAllUserRefreshTokens(ctx context.Context, userID pgtype.UUID) error RevokeDevice(ctx context.Context, id pgtype.UUID) error + // Revoke OPDS token + RevokeOpdsToken(ctx context.Context, token string) error RevokeRefreshToken(ctx context.Context, token pgtype.UUID) error // Note: User ebook folders replaced by library folders system // Legacy folder management is now handled through libraries @@ -150,23 +257,43 @@ type Querier interface { SearchMediaItemsFuzzy(ctx context.Context, arg SearchMediaItemsFuzzyParams) ([]SearchMediaItemsFuzzyRow, error) // Library Visibility queries SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibilityParams) (LibraryVisibility, error) + // Set system config + SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error) + // Update collection + UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error) UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Devices, error) + // Update device catalog availability + UpdateDeviceCatalogAvailability(ctx context.Context, arg UpdateDeviceCatalogAvailabilityParams) error + // Update device file alias + UpdateDeviceFileAlias(ctx context.Context, arg UpdateDeviceFileAliasParams) (DeviceFileAliases, error) UpdateDeviceLastSeen(ctx context.Context, id pgtype.UUID) (Devices, error) UpdateDeviceLastSync(ctx context.Context, id pgtype.UUID) (Devices, error) + // Update device shelf mapping + UpdateDeviceShelfMapping(ctx context.Context, arg UpdateDeviceShelfMappingParams) (DeviceShelfMappings, error) UpdateDeviceSyncTimestamp(ctx context.Context, id pgtype.UUID) (Devices, error) UpdateEbookNote(ctx context.Context, arg UpdateEbookNoteParams) (MediaNotes, error) UpdateEmail(ctx context.Context, arg UpdateEmailParams) error UpdateKoboEntitlementRevision(ctx context.Context, arg UpdateKoboEntitlementRevisionParams) error UpdateKoboEntitlementStatus(ctx context.Context, arg UpdateKoboEntitlementStatusParams) error UpdateKoboShelfBookPosition(ctx context.Context, arg UpdateKoboShelfBookPositionParams) error + // Update Kobo shelves to support collections + UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error) UpdateLibrary(ctx context.Context, arg UpdateLibraryParams) (Libraries, error) UpdateMediaHighlight(ctx context.Context, arg UpdateMediaHighlightParams) (MediaHighlights, error) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams) (MediaItems, error) + // Update media item format + UpdateMediaItemFormat(ctx context.Context, arg UpdateMediaItemFormatParams) (MediaItemFormats, error) // ============================================ // PHASE 1: FORMAT DETECTION & PROGRESS (Week 2) // ============================================ // Update media item format group information UpdateMediaItemFormatGroup(ctx context.Context, arg UpdateMediaItemFormatGroupParams) error + // Media Items Admin Operations + // ============================================ + // PHASE 1: UNIVERSAL BOOK IDENTIFIERS (Week 1) + // ============================================ + // Update media item with universal identifiers + UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error) UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMediaItemKoboMetadataParams) (MediaItems, error) UpdateMediaNote(ctx context.Context, arg UpdateMediaNoteParams) (MediaNotes, error) UpdateMediaRating(ctx context.Context, arg UpdateMediaRatingParams) (MediaRatings, error) diff --git a/internal/database/queries.sql.go b/internal/database/queries.sql.go index 71ca907..bcfc13a 100644 --- a/internal/database/queries.sql.go +++ b/internal/database/queries.sql.go @@ -11,6 +11,35 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const AddBookToCollection = `-- 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 id, collection_id, media_item_id, added_at, added_by_user_id +` + +type AddBookToCollectionParams struct { + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"` +} + +// COLLECTION ITEMS QUERIES +// Add book to collection +func (q *Queries) AddBookToCollection(ctx context.Context, arg AddBookToCollectionParams) (CollectionItems, error) { + row := q.db.QueryRow(ctx, AddBookToCollection, arg.CollectionID, arg.MediaItemID, arg.AddedByUserID) + var i CollectionItems + err := row.Scan( + &i.ID, + &i.CollectionID, + &i.MediaItemID, + &i.AddedAt, + &i.AddedByUserID, + ) + return i, err +} + const AddBookToKoboShelf = `-- name: AddBookToKoboShelf :one INSERT INTO kobo_shelves (device_id, media_item_id, shelf_name, shelf_position) @@ -20,7 +49,7 @@ DO UPDATE SET shelf_name = EXCLUDED.shelf_name, shelf_position = EXCLUDED.shelf_position, last_synced_at = NOW() -RETURNING id, device_id, media_item_id, shelf_name, shelf_position, added_at, last_synced_at +RETURNING id, device_id, media_item_id, shelf_name, shelf_position, added_at, last_synced_at, collection_id, position_in_collection ` type AddBookToKoboShelfParams struct { @@ -49,6 +78,8 @@ func (q *Queries) AddBookToKoboShelf(ctx context.Context, arg AddBookToKoboShelf &i.ShelfPosition, &i.AddedAt, &i.LastSyncedAt, + &i.CollectionID, + &i.PositionInCollection, ) return i, err } @@ -121,6 +152,16 @@ func (q *Queries) BulkUpdateProgressFromSync(ctx context.Context, arg BulkUpdate return items, nil } +const CleanupExpiredOpdsTokens = `-- name: CleanupExpiredOpdsTokens :exec +DELETE FROM opds_tokens WHERE expires_at < NOW() +` + +// Cleanup expired OPDS tokens +func (q *Queries) CleanupExpiredOpdsTokens(ctx context.Context) error { + _, err := q.db.Exec(ctx, CleanupExpiredOpdsTokens) + return err +} + const CleanupExpiredRefreshTokens = `-- name: CleanupExpiredRefreshTokens :exec DELETE FROM refresh_tokens WHERE expires_at < NOW() OR (revoked_at IS NOT NULL AND revoked_at < NOW() - INTERVAL '7 days') ` @@ -162,6 +203,20 @@ func (q *Queries) ClearKoboShelfByName(ctx context.Context, arg ClearKoboShelfBy return err } +const CountUnlinkedBooks = `-- name: CountUnlinkedBooks :one +SELECT COUNT(*) as count +FROM unlinked_books +WHERE device_id = $1 AND resolved = false +` + +// Count unlinked books for a device +func (q *Queries) CountUnlinkedBooks(ctx context.Context, deviceID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, CountUnlinkedBooks, deviceID) + var count int64 + err := row.Scan(&count) + return count, err +} + const CountUserDevices = `-- name: CountUserDevices :one SELECT COUNT(*) FROM devices WHERE user_id = $1 ` @@ -173,6 +228,50 @@ func (q *Queries) CountUserDevices(ctx context.Context, userID pgtype.UUID) (int return count, err } +const CreateCollection = `-- 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 id, user_id, name, description, color, icon, auto_assign_rules, view_settings, created_at +` + +type CreateCollectionParams struct { + UserID pgtype.UUID `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + Description pgtype.Text `db:"description" json:"description"` + Color pgtype.Text `db:"color" json:"color"` + Icon pgtype.Text `db:"icon" json:"icon"` + AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` + ViewSettings []byte `db:"view_settings" json:"view_settings"` +} + +// COLLECTIONS QUERIES +// Create collection +func (q *Queries) CreateCollection(ctx context.Context, arg CreateCollectionParams) (Collections, error) { + row := q.db.QueryRow(ctx, CreateCollection, + arg.UserID, + arg.Name, + arg.Description, + arg.Color, + arg.Icon, + arg.AutoAssignRules, + arg.ViewSettings, + ) + var i Collections + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Color, + &i.Icon, + &i.AutoAssignRules, + &i.ViewSettings, + &i.CreatedAt, + ) + return i, err +} + const CreateDevice = `-- 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) @@ -228,6 +327,134 @@ func (q *Queries) CreateDevice(ctx context.Context, arg CreateDeviceParams) (Dev return i, err } +const CreateDeviceCatalog = `-- name: CreateDeviceCatalog :one + +INSERT INTO device_catalogs (device_id, media_item_id, bookmann_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 id, device_id, media_item_id, bookmann_uuid, kobo_content_id, content_id_type, available, delivery_date, delivery_method +` + +type CreateDeviceCatalogParams struct { + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + BookmannUuid pgtype.UUID `db:"bookmann_uuid" json:"bookmann_uuid"` + KoboContentID string `db:"kobo_content_id" json:"kobo_content_id"` + ContentIDType pgtype.Text `db:"content_id_type" json:"content_id_type"` + Available pgtype.Bool `db:"available" json:"available"` + DeliveryDate pgtype.Timestamptz `db:"delivery_date" json:"delivery_date"` + DeliveryMethod pgtype.Text `db:"delivery_method" json:"delivery_method"` +} + +// DEVICE CATALOGS QUERIES +// Create device catalog entry +func (q *Queries) CreateDeviceCatalog(ctx context.Context, arg CreateDeviceCatalogParams) (DeviceCatalogs, error) { + row := q.db.QueryRow(ctx, CreateDeviceCatalog, + arg.DeviceID, + arg.MediaItemID, + arg.BookmannUuid, + arg.KoboContentID, + arg.ContentIDType, + arg.Available, + arg.DeliveryDate, + arg.DeliveryMethod, + ) + var i DeviceCatalogs + err := row.Scan( + &i.ID, + &i.DeviceID, + &i.MediaItemID, + &i.BookmannUuid, + &i.KoboContentID, + &i.ContentIDType, + &i.Available, + &i.DeliveryDate, + &i.DeliveryMethod, + ) + return i, err +} + +const CreateDeviceFileAlias = `-- 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 id, media_item_id, device_id, file_path, file_sha256, confidence_score, last_seen_at +` + +type CreateDeviceFileAliasParams struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + FilePath string `db:"file_path" json:"file_path"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"` +} + +// DEVICE FILE ALIASES QUERIES +// Create device file alias +func (q *Queries) CreateDeviceFileAlias(ctx context.Context, arg CreateDeviceFileAliasParams) (DeviceFileAliases, error) { + row := q.db.QueryRow(ctx, CreateDeviceFileAlias, + arg.MediaItemID, + arg.DeviceID, + arg.FilePath, + arg.FileSha256, + arg.ConfidenceScore, + ) + var i DeviceFileAliases + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.DeviceID, + &i.FilePath, + &i.FileSha256, + &i.ConfidenceScore, + &i.LastSeenAt, + ) + return i, err +} + +const CreateDeviceShelfMapping = `-- 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 id, collection_id, device_id, device_shelf_name, sync_direction, created_at +` + +type CreateDeviceShelfMappingParams struct { + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + DeviceShelfName pgtype.Text `db:"device_shelf_name" json:"device_shelf_name"` + SyncDirection pgtype.Text `db:"sync_direction" json:"sync_direction"` +} + +// DEVICE SHELF MAPPINGS QUERIES +// Create device shelf mapping +func (q *Queries) CreateDeviceShelfMapping(ctx context.Context, arg CreateDeviceShelfMappingParams) (DeviceShelfMappings, error) { + row := q.db.QueryRow(ctx, CreateDeviceShelfMapping, + arg.CollectionID, + arg.DeviceID, + arg.DeviceShelfName, + arg.SyncDirection, + ) + var i DeviceShelfMappings + err := row.Scan( + &i.ID, + &i.CollectionID, + &i.DeviceID, + &i.DeviceShelfName, + &i.SyncDirection, + &i.CreatedAt, + ) + return i, err +} + const CreateEbookNote = `-- name: CreateEbookNote :one INSERT INTO media_notes (media_item_id, user_id, content, position) VALUES ($1, $2, $3, $4) @@ -360,7 +587,7 @@ func (q *Queries) CreateMediaHighlight(ctx context.Context, arg CreateMediaHighl const CreateMediaItem = `-- 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, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id) 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) -RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata +RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, file_sha256, opf_identifier, opf_uuid, hash_confidence ` type CreateMediaItemParams struct { @@ -460,6 +687,54 @@ func (q *Queries) CreateMediaItem(ctx context.Context, arg CreateMediaItemParams &i.RevisionNumber, &i.KoboContentID, &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, + ) + return i, err +} + +const CreateMediaItemFormat = `-- 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 id, media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, created_at, converted_from_format_id +` + +type CreateMediaItemFormatParams struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + FormatType string `db:"format_type" json:"format_type"` + FilePath pgtype.Text `db:"file_path" json:"file_path"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + FileSizeBytes pgtype.Int8 `db:"file_size_bytes" json:"file_size_bytes"` + MimeType pgtype.Text `db:"mime_type" json:"mime_type"` + ConvertedFromFormatID pgtype.UUID `db:"converted_from_format_id" json:"converted_from_format_id"` +} + +// MEDIA ITEM FORMATS QUERIES +// Create media item format +func (q *Queries) CreateMediaItemFormat(ctx context.Context, arg CreateMediaItemFormatParams) (MediaItemFormats, error) { + row := q.db.QueryRow(ctx, CreateMediaItemFormat, + arg.MediaItemID, + arg.FormatType, + arg.FilePath, + arg.FileSha256, + arg.FileSizeBytes, + arg.MimeType, + arg.ConvertedFromFormatID, + ) + var i MediaItemFormats + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.FormatType, + &i.FilePath, + &i.FileSha256, + &i.FileSizeBytes, + &i.MimeType, + &i.CreatedAt, + &i.ConvertedFromFormatID, ) return i, err } @@ -535,6 +810,41 @@ func (q *Queries) CreateMediaRating(ctx context.Context, arg CreateMediaRatingPa return i, err } +const CreateOpdsToken = `-- name: CreateOpdsToken :one + +INSERT INTO opds_tokens (device_id, token, token_type, expires_at) +VALUES ($1, $2, $3, $4) +RETURNING id, device_id, token, token_type, expires_at, created_at +` + +type CreateOpdsTokenParams struct { + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + Token string `db:"token" json:"token"` + TokenType pgtype.Text `db:"token_type" json:"token_type"` + ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"` +} + +// OPDS TOKENS QUERIES +// Create OPDS token +func (q *Queries) CreateOpdsToken(ctx context.Context, arg CreateOpdsTokenParams) (OpdsTokens, error) { + row := q.db.QueryRow(ctx, CreateOpdsToken, + arg.DeviceID, + arg.Token, + arg.TokenType, + arg.ExpiresAt, + ) + var i OpdsTokens + err := row.Scan( + &i.ID, + &i.DeviceID, + &i.Token, + &i.TokenType, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + const CreateOrUpdateKoboEntitlement = `-- name: CreateOrUpdateKoboEntitlement :one INSERT INTO kobo_entitlements (device_id, media_item_id, entitlement_id, content_id, revision_number, purchase_date, kobo_metadata) @@ -792,6 +1102,54 @@ func (q *Queries) CreateSyncQueueItem(ctx context.Context, arg CreateSyncQueueIt return i, err } +const CreateUnlinkedBook = `-- 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 id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at +` + +type CreateUnlinkedBookParams struct { + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + ContentID string `db:"content_id" json:"content_id"` + FilePath pgtype.Text `db:"file_path" json:"file_path"` + Title pgtype.Text `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"` +} + +// ============================================ +// PHASE 6: ENHANCED KOBO SYNC (Week 3-4) +// ============================================ +// Create unlinked book entry +func (q *Queries) CreateUnlinkedBook(ctx context.Context, arg CreateUnlinkedBookParams) (UnlinkedBooks, error) { + row := q.db.QueryRow(ctx, CreateUnlinkedBook, + arg.DeviceID, + arg.ContentID, + arg.FilePath, + arg.Title, + arg.Author, + arg.ConfidenceScore, + ) + var i UnlinkedBooks + err := row.Scan( + &i.ID, + &i.DeviceID, + &i.ContentID, + &i.FilePath, + &i.Title, + &i.Author, + &i.ConfidenceScore, + &i.Resolved, + &i.MediaItemID, + &i.ResolvedAt, + &i.ResolutionMethod, + &i.LastSeenAt, + &i.CreatedAt, + ) + return i, err +} + const CreateUser = `-- name: CreateUser :one INSERT INTO users (email, username, password_hash, first_name, last_name, theme, role) VALUES ($1, $2, $3, $4, $5, $6, $7) @@ -845,6 +1203,16 @@ func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (CreateU return i, err } +const DeleteCollection = `-- name: DeleteCollection :exec +DELETE FROM collections WHERE id = $1 +` + +// Delete collection +func (q *Queries) DeleteCollection(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, DeleteCollection, id) + return err +} + const DeleteDevice = `-- name: DeleteDevice :exec DELETE FROM devices WHERE id = $1 ` @@ -863,6 +1231,36 @@ func (q *Queries) DeleteDeviceByToken(ctx context.Context, authToken string) err return err } +const DeleteDeviceCatalog = `-- name: DeleteDeviceCatalog :exec +DELETE FROM device_catalogs WHERE id = $1 +` + +// Delete device catalog entry +func (q *Queries) DeleteDeviceCatalog(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, DeleteDeviceCatalog, id) + return err +} + +const DeleteDeviceFileAlias = `-- name: DeleteDeviceFileAlias :exec +DELETE FROM device_file_aliases WHERE id = $1 +` + +// Delete device file alias +func (q *Queries) DeleteDeviceFileAlias(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, DeleteDeviceFileAlias, id) + return err +} + +const DeleteDeviceShelfMapping = `-- name: DeleteDeviceShelfMapping :exec +DELETE FROM device_shelf_mappings WHERE id = $1 +` + +// Delete device shelf mapping +func (q *Queries) DeleteDeviceShelfMapping(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, DeleteDeviceShelfMapping, id) + return err +} + const DeleteEbookNote = `-- name: DeleteEbookNote :exec DELETE FROM media_notes WHERE id = $1 ` @@ -934,6 +1332,16 @@ func (q *Queries) DeleteMediaItem(ctx context.Context, id pgtype.UUID) error { return err } +const DeleteMediaItemFormat = `-- name: DeleteMediaItemFormat :exec +DELETE FROM media_item_formats WHERE id = $1 +` + +// Delete media item format +func (q *Queries) DeleteMediaItemFormat(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, DeleteMediaItemFormat, id) + return err +} + const DeleteMediaNote = `-- name: DeleteMediaNote :exec DELETE FROM media_notes WHERE id = $1 ` @@ -989,6 +1397,16 @@ func (q *Queries) DeleteSyncQueueItem(ctx context.Context, id pgtype.UUID) error return err } +const DeleteSystemConfig = `-- name: DeleteSystemConfig :exec +DELETE FROM system_config WHERE key = $1 +` + +// Delete system config +func (q *Queries) DeleteSystemConfig(ctx context.Context, key string) error { + _, err := q.db.Exec(ctx, DeleteSystemConfig, key) + return err +} + const DeleteUser = `-- name: DeleteUser :exec DELETE FROM users WHERE id = $1 ` @@ -1009,6 +1427,36 @@ func (q *Queries) GenerateKoboEntitlementId(ctx context.Context) (interface{}, e return entitlement_id, err } +const GetAllSystemConfig = `-- name: GetAllSystemConfig :many +SELECT key, value, updated_at, updated_by FROM system_config ORDER BY key +` + +// Get all system config +func (q *Queries) GetAllSystemConfig(ctx context.Context) ([]SystemConfig, error) { + rows, err := q.db.Query(ctx, GetAllSystemConfig) + if err != nil { + return nil, err + } + defer rows.Close() + items := []SystemConfig{} + for rows.Next() { + var i SystemConfig + if err := rows.Scan( + &i.Key, + &i.Value, + &i.UpdatedAt, + &i.UpdatedBy, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetAnnotationsForBook = `-- name: GetAnnotationsForBook :many SELECT mh.id, @@ -1097,6 +1545,192 @@ func (q *Queries) GetAnnotationsForBook(ctx context.Context, arg GetAnnotationsF return items, nil } +const GetCollection = `-- name: GetCollection :one +SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, created_at FROM collections WHERE id = $1 +` + +// Get collection +func (q *Queries) GetCollection(ctx context.Context, id pgtype.UUID) (Collections, error) { + row := q.db.QueryRow(ctx, GetCollection, id) + var i Collections + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Color, + &i.Icon, + &i.AutoAssignRules, + &i.ViewSettings, + &i.CreatedAt, + ) + return i, err +} + +const GetCollectionItems = `-- name: GetCollectionItems :many +SELECT ci.id, ci.collection_id, ci.media_item_id, ci.added_at, ci.added_by_user_id, mi.title, mi.author, mi.cover_image_path +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 +` + +type GetCollectionItemsRow struct { + ID pgtype.UUID `db:"id" json:"id"` + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"` + AddedByUserID pgtype.UUID `db:"added_by_user_id" json:"added_by_user_id"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + CoverImagePath pgtype.Text `db:"cover_image_path" json:"cover_image_path"` +} + +// Get collection items +func (q *Queries) GetCollectionItems(ctx context.Context, collectionID pgtype.UUID) ([]GetCollectionItemsRow, error) { + rows, err := q.db.Query(ctx, GetCollectionItems, collectionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetCollectionItemsRow{} + for rows.Next() { + var i GetCollectionItemsRow + if err := rows.Scan( + &i.ID, + &i.CollectionID, + &i.MediaItemID, + &i.AddedAt, + &i.AddedByUserID, + &i.Title, + &i.Author, + &i.CoverImagePath, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetCollectionWithBookCount = `-- name: GetCollectionWithBookCount :one +SELECT + c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.created_at, + 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 +` + +type GetCollectionWithBookCountRow struct { + ID pgtype.UUID `db:"id" json:"id"` + UserID pgtype.UUID `db:"user_id" json:"user_id"` + Name string `db:"name" json:"name"` + Description pgtype.Text `db:"description" json:"description"` + Color pgtype.Text `db:"color" json:"color"` + Icon pgtype.Text `db:"icon" json:"icon"` + AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` + ViewSettings []byte `db:"view_settings" json:"view_settings"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + BookCount int64 `db:"book_count" json:"book_count"` +} + +// Get collection with book count +func (q *Queries) GetCollectionWithBookCount(ctx context.Context, id pgtype.UUID) (GetCollectionWithBookCountRow, error) { + row := q.db.QueryRow(ctx, GetCollectionWithBookCount, id) + var i GetCollectionWithBookCountRow + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Color, + &i.Icon, + &i.AutoAssignRules, + &i.ViewSettings, + &i.CreatedAt, + &i.BookCount, + ) + return i, err +} + +const GetCollectionsByUser = `-- name: GetCollectionsByUser :many +SELECT id, user_id, name, description, color, icon, auto_assign_rules, view_settings, created_at FROM collections WHERE user_id = $1 ORDER BY created_at DESC +` + +// Get collections by user +func (q *Queries) GetCollectionsByUser(ctx context.Context, userID pgtype.UUID) ([]Collections, error) { + rows, err := q.db.Query(ctx, GetCollectionsByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Collections{} + for rows.Next() { + var i Collections + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Color, + &i.Icon, + &i.AutoAssignRules, + &i.ViewSettings, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetCollectionsForBook = `-- name: GetCollectionsForBook :many +SELECT c.id, c.user_id, c.name, c.description, c.color, c.icon, c.auto_assign_rules, c.view_settings, c.created_at +FROM collections c +JOIN collection_items ci ON c.id = ci.collection_id +WHERE ci.media_item_id = $1 +` + +// Get collections for book +func (q *Queries) GetCollectionsForBook(ctx context.Context, mediaItemID pgtype.UUID) ([]Collections, error) { + rows, err := q.db.Query(ctx, GetCollectionsForBook, mediaItemID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Collections{} + for rows.Next() { + var i Collections + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Color, + &i.Icon, + &i.AutoAssignRules, + &i.ViewSettings, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetDevice = `-- name: GetDevice :one SELECT id, user_id, device_name, device_type, device_identifier, auth_token, last_sync, last_seen, sync_enabled, auto_sync, sync_frequency_minutes, device_metadata, created_at, updated_at FROM devices WHERE id = $1 ` @@ -1175,6 +1809,295 @@ func (q *Queries) GetDeviceByIdentifier(ctx context.Context, deviceIdentifier st return i, err } +const GetDeviceCatalogByBookmannUUID = `-- name: GetDeviceCatalogByBookmannUUID :one +SELECT id, device_id, media_item_id, bookmann_uuid, kobo_content_id, content_id_type, available, delivery_date, delivery_method FROM device_catalogs WHERE device_id = $1 AND bookmann_uuid = $2 +` + +type GetDeviceCatalogByBookmannUUIDParams struct { + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + BookmannUuid pgtype.UUID `db:"bookmann_uuid" json:"bookmann_uuid"` +} + +// Get device catalog by Bookmann UUID +func (q *Queries) GetDeviceCatalogByBookmannUUID(ctx context.Context, arg GetDeviceCatalogByBookmannUUIDParams) (DeviceCatalogs, error) { + row := q.db.QueryRow(ctx, GetDeviceCatalogByBookmannUUID, arg.DeviceID, arg.BookmannUuid) + var i DeviceCatalogs + err := row.Scan( + &i.ID, + &i.DeviceID, + &i.MediaItemID, + &i.BookmannUuid, + &i.KoboContentID, + &i.ContentIDType, + &i.Available, + &i.DeliveryDate, + &i.DeliveryMethod, + ) + return i, err +} + +const GetDeviceCatalogByKoboContentId = `-- name: GetDeviceCatalogByKoboContentId :one +SELECT id, device_id, media_item_id, bookmann_uuid, kobo_content_id, content_id_type, available, delivery_date, delivery_method FROM device_catalogs WHERE kobo_content_id = $1 +` + +// Get device catalog by Kobo ContentId +func (q *Queries) GetDeviceCatalogByKoboContentId(ctx context.Context, koboContentID string) (DeviceCatalogs, error) { + row := q.db.QueryRow(ctx, GetDeviceCatalogByKoboContentId, koboContentID) + var i DeviceCatalogs + err := row.Scan( + &i.ID, + &i.DeviceID, + &i.MediaItemID, + &i.BookmannUuid, + &i.KoboContentID, + &i.ContentIDType, + &i.Available, + &i.DeliveryDate, + &i.DeliveryMethod, + ) + return i, err +} + +const GetDeviceCatalogEntries = `-- name: GetDeviceCatalogEntries :many +SELECT dc.id, dc.device_id, dc.media_item_id, dc.bookmann_uuid, dc.kobo_content_id, dc.content_id_type, dc.available, dc.delivery_date, dc.delivery_method, 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 +` + +type GetDeviceCatalogEntriesRow struct { + ID pgtype.UUID `db:"id" json:"id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + BookmannUuid pgtype.UUID `db:"bookmann_uuid" json:"bookmann_uuid"` + KoboContentID string `db:"kobo_content_id" json:"kobo_content_id"` + ContentIDType pgtype.Text `db:"content_id_type" json:"content_id_type"` + Available pgtype.Bool `db:"available" json:"available"` + DeliveryDate pgtype.Timestamptz `db:"delivery_date" json:"delivery_date"` + DeliveryMethod pgtype.Text `db:"delivery_method" json:"delivery_method"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` +} + +// Get device catalog entries +func (q *Queries) GetDeviceCatalogEntries(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceCatalogEntriesRow, error) { + rows, err := q.db.Query(ctx, GetDeviceCatalogEntries, deviceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetDeviceCatalogEntriesRow{} + for rows.Next() { + var i GetDeviceCatalogEntriesRow + if err := rows.Scan( + &i.ID, + &i.DeviceID, + &i.MediaItemID, + &i.BookmannUuid, + &i.KoboContentID, + &i.ContentIDType, + &i.Available, + &i.DeliveryDate, + &i.DeliveryMethod, + &i.Title, + &i.Author, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetDeviceFileAlias = `-- name: GetDeviceFileAlias :one +SELECT id, media_item_id, device_id, file_path, file_sha256, confidence_score, last_seen_at FROM device_file_aliases WHERE device_id = $1 AND file_path = $2 +` + +type GetDeviceFileAliasParams struct { + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + FilePath string `db:"file_path" json:"file_path"` +} + +// Get device file alias +func (q *Queries) GetDeviceFileAlias(ctx context.Context, arg GetDeviceFileAliasParams) (DeviceFileAliases, error) { + row := q.db.QueryRow(ctx, GetDeviceFileAlias, arg.DeviceID, arg.FilePath) + var i DeviceFileAliases + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.DeviceID, + &i.FilePath, + &i.FileSha256, + &i.ConfidenceScore, + &i.LastSeenAt, + ) + return i, err +} + +const GetDeviceFileAliasBySHA256 = `-- name: GetDeviceFileAliasBySHA256 :one +SELECT dfa.id, dfa.media_item_id, dfa.device_id, dfa.file_path, dfa.file_sha256, dfa.confidence_score, dfa.last_seen_at, 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 +` + +type GetDeviceFileAliasBySHA256Row struct { + ID pgtype.UUID `db:"id" json:"id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + FilePath string `db:"file_path" json:"file_path"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"` + LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` +} + +// Get device file alias by SHA-256 +func (q *Queries) GetDeviceFileAliasBySHA256(ctx context.Context, fileSha256 pgtype.Text) (GetDeviceFileAliasBySHA256Row, error) { + row := q.db.QueryRow(ctx, GetDeviceFileAliasBySHA256, fileSha256) + var i GetDeviceFileAliasBySHA256Row + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.DeviceID, + &i.FilePath, + &i.FileSha256, + &i.ConfidenceScore, + &i.LastSeenAt, + &i.Title, + &i.Author, + ) + return i, err +} + +const GetDeviceFileAliasesByDevice = `-- name: GetDeviceFileAliasesByDevice :many +SELECT dfa.id, dfa.media_item_id, dfa.device_id, dfa.file_path, dfa.file_sha256, dfa.confidence_score, dfa.last_seen_at, 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 +` + +type GetDeviceFileAliasesByDeviceRow struct { + ID pgtype.UUID `db:"id" json:"id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + FilePath string `db:"file_path" json:"file_path"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"` + LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` +} + +// Get device file aliases by device +func (q *Queries) GetDeviceFileAliasesByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceFileAliasesByDeviceRow, error) { + rows, err := q.db.Query(ctx, GetDeviceFileAliasesByDevice, deviceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetDeviceFileAliasesByDeviceRow{} + for rows.Next() { + var i GetDeviceFileAliasesByDeviceRow + if err := rows.Scan( + &i.ID, + &i.MediaItemID, + &i.DeviceID, + &i.FilePath, + &i.FileSha256, + &i.ConfidenceScore, + &i.LastSeenAt, + &i.Title, + &i.Author, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const GetDeviceShelfMapping = `-- name: GetDeviceShelfMapping :one +SELECT id, collection_id, device_id, device_shelf_name, sync_direction, created_at FROM device_shelf_mappings WHERE device_id = $1 AND collection_id = $2 +` + +type GetDeviceShelfMappingParams struct { + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` +} + +// Get device shelf mapping +func (q *Queries) GetDeviceShelfMapping(ctx context.Context, arg GetDeviceShelfMappingParams) (DeviceShelfMappings, error) { + row := q.db.QueryRow(ctx, GetDeviceShelfMapping, arg.DeviceID, arg.CollectionID) + var i DeviceShelfMappings + err := row.Scan( + &i.ID, + &i.CollectionID, + &i.DeviceID, + &i.DeviceShelfName, + &i.SyncDirection, + &i.CreatedAt, + ) + return i, err +} + +const GetDeviceShelfMappings = `-- name: GetDeviceShelfMappings :many +SELECT dsm.id, dsm.collection_id, dsm.device_id, dsm.device_shelf_name, dsm.sync_direction, dsm.created_at, 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 +` + +type GetDeviceShelfMappingsRow struct { + ID pgtype.UUID `db:"id" json:"id"` + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + DeviceShelfName pgtype.Text `db:"device_shelf_name" json:"device_shelf_name"` + SyncDirection pgtype.Text `db:"sync_direction" json:"sync_direction"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + CollectionName string `db:"collection_name" json:"collection_name"` + CollectionIcon pgtype.Text `db:"collection_icon" json:"collection_icon"` +} + +// Get device shelf mappings +func (q *Queries) GetDeviceShelfMappings(ctx context.Context, deviceID pgtype.UUID) ([]GetDeviceShelfMappingsRow, error) { + rows, err := q.db.Query(ctx, GetDeviceShelfMappings, deviceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetDeviceShelfMappingsRow{} + for rows.Next() { + var i GetDeviceShelfMappingsRow + if err := rows.Scan( + &i.ID, + &i.CollectionID, + &i.DeviceID, + &i.DeviceShelfName, + &i.SyncDirection, + &i.CreatedAt, + &i.CollectionName, + &i.CollectionIcon, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetFailedSyncQueueItems = `-- name: GetFailedSyncQueueItems :many SELECT id, device_id, media_item_id, sync_type, sync_data, priority, attempts, max_attempts, status, error_message, created_at, processed_at FROM sync_queue WHERE status = 'failed' AND attempts < max_attempts @@ -1403,7 +2326,7 @@ func (q *Queries) GetKoboShelfBookCount(ctx context.Context, deviceID pgtype.UUI } const GetKoboShelfBooks = `-- name: GetKoboShelfBooks :many -SELECT ks.id, ks.device_id, ks.media_item_id, ks.shelf_name, ks.shelf_position, ks.added_at, ks.last_synced_at, mi.title, mi.author, mi.file_path, mi.mime_type, mi.entitlement_id, mi.kobo_content_id, mi.revision_number +SELECT ks.id, ks.device_id, ks.media_item_id, ks.shelf_name, ks.shelf_position, ks.added_at, ks.last_synced_at, ks.collection_id, ks.position_in_collection, 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 @@ -1411,20 +2334,22 @@ ORDER BY ks.shelf_position ASC, ks.added_at ASC ` type GetKoboShelfBooksRow struct { - ID pgtype.UUID `db:"id" json:"id"` - DeviceID pgtype.UUID `db:"device_id" json:"device_id"` - MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` - ShelfName pgtype.Text `db:"shelf_name" json:"shelf_name"` - ShelfPosition pgtype.Int4 `db:"shelf_position" json:"shelf_position"` - AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"` - LastSyncedAt pgtype.Timestamptz `db:"last_synced_at" json:"last_synced_at"` - Title string `db:"title" json:"title"` - Author pgtype.Text `db:"author" json:"author"` - FilePath string `db:"file_path" json:"file_path"` - MimeType pgtype.Text `db:"mime_type" json:"mime_type"` - EntitlementID pgtype.Text `db:"entitlement_id" json:"entitlement_id"` - KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"` - RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"` + ID pgtype.UUID `db:"id" json:"id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + ShelfName pgtype.Text `db:"shelf_name" json:"shelf_name"` + ShelfPosition pgtype.Int4 `db:"shelf_position" json:"shelf_position"` + AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"` + LastSyncedAt pgtype.Timestamptz `db:"last_synced_at" json:"last_synced_at"` + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + PositionInCollection pgtype.Int4 `db:"position_in_collection" json:"position_in_collection"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + FilePath string `db:"file_path" json:"file_path"` + MimeType pgtype.Text `db:"mime_type" json:"mime_type"` + EntitlementID pgtype.Text `db:"entitlement_id" json:"entitlement_id"` + KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"` + RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"` } func (q *Queries) GetKoboShelfBooks(ctx context.Context, deviceID pgtype.UUID) ([]GetKoboShelfBooksRow, error) { @@ -1444,6 +2369,8 @@ func (q *Queries) GetKoboShelfBooks(ctx context.Context, deviceID pgtype.UUID) ( &i.ShelfPosition, &i.AddedAt, &i.LastSyncedAt, + &i.CollectionID, + &i.PositionInCollection, &i.Title, &i.Author, &i.FilePath, @@ -1463,7 +2390,7 @@ func (q *Queries) GetKoboShelfBooks(ctx context.Context, deviceID pgtype.UUID) ( } const GetKoboShelfBooksByShelfName = `-- name: GetKoboShelfBooksByShelfName :many -SELECT ks.id, ks.device_id, ks.media_item_id, ks.shelf_name, ks.shelf_position, ks.added_at, ks.last_synced_at, mi.title, mi.author, mi.file_path, mi.mime_type, mi.entitlement_id, mi.kobo_content_id, mi.revision_number +SELECT ks.id, ks.device_id, ks.media_item_id, ks.shelf_name, ks.shelf_position, ks.added_at, ks.last_synced_at, ks.collection_id, ks.position_in_collection, 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 @@ -1476,20 +2403,22 @@ type GetKoboShelfBooksByShelfNameParams struct { } type GetKoboShelfBooksByShelfNameRow struct { - ID pgtype.UUID `db:"id" json:"id"` - DeviceID pgtype.UUID `db:"device_id" json:"device_id"` - MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` - ShelfName pgtype.Text `db:"shelf_name" json:"shelf_name"` - ShelfPosition pgtype.Int4 `db:"shelf_position" json:"shelf_position"` - AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"` - LastSyncedAt pgtype.Timestamptz `db:"last_synced_at" json:"last_synced_at"` - Title string `db:"title" json:"title"` - Author pgtype.Text `db:"author" json:"author"` - FilePath string `db:"file_path" json:"file_path"` - MimeType pgtype.Text `db:"mime_type" json:"mime_type"` - EntitlementID pgtype.Text `db:"entitlement_id" json:"entitlement_id"` - KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"` - RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"` + ID pgtype.UUID `db:"id" json:"id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + ShelfName pgtype.Text `db:"shelf_name" json:"shelf_name"` + ShelfPosition pgtype.Int4 `db:"shelf_position" json:"shelf_position"` + AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"` + LastSyncedAt pgtype.Timestamptz `db:"last_synced_at" json:"last_synced_at"` + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + PositionInCollection pgtype.Int4 `db:"position_in_collection" json:"position_in_collection"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + FilePath string `db:"file_path" json:"file_path"` + MimeType pgtype.Text `db:"mime_type" json:"mime_type"` + EntitlementID pgtype.Text `db:"entitlement_id" json:"entitlement_id"` + KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"` + RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"` } func (q *Queries) GetKoboShelfBooksByShelfName(ctx context.Context, arg GetKoboShelfBooksByShelfNameParams) ([]GetKoboShelfBooksByShelfNameRow, error) { @@ -1509,6 +2438,8 @@ func (q *Queries) GetKoboShelfBooksByShelfName(ctx context.Context, arg GetKoboS &i.ShelfPosition, &i.AddedAt, &i.LastSyncedAt, + &i.CollectionID, + &i.PositionInCollection, &i.Title, &i.Author, &i.FilePath, @@ -1527,6 +2458,66 @@ func (q *Queries) GetKoboShelfBooksByShelfName(ctx context.Context, arg GetKoboS return items, nil } +const GetKoboShelvesByCollection = `-- name: GetKoboShelvesByCollection :many +SELECT ks.id, ks.device_id, ks.media_item_id, ks.shelf_name, ks.shelf_position, ks.added_at, ks.last_synced_at, ks.collection_id, ks.position_in_collection, 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 +` + +type GetKoboShelvesByCollectionParams struct { + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` +} + +type GetKoboShelvesByCollectionRow struct { + ID pgtype.UUID `db:"id" json:"id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + ShelfName pgtype.Text `db:"shelf_name" json:"shelf_name"` + ShelfPosition pgtype.Int4 `db:"shelf_position" json:"shelf_position"` + AddedAt pgtype.Timestamptz `db:"added_at" json:"added_at"` + LastSyncedAt pgtype.Timestamptz `db:"last_synced_at" json:"last_synced_at"` + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + PositionInCollection pgtype.Int4 `db:"position_in_collection" json:"position_in_collection"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` +} + +// Get Kobo shelves by collection +func (q *Queries) GetKoboShelvesByCollection(ctx context.Context, arg GetKoboShelvesByCollectionParams) ([]GetKoboShelvesByCollectionRow, error) { + rows, err := q.db.Query(ctx, GetKoboShelvesByCollection, arg.DeviceID, arg.CollectionID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetKoboShelvesByCollectionRow{} + for rows.Next() { + var i GetKoboShelvesByCollectionRow + if err := rows.Scan( + &i.ID, + &i.DeviceID, + &i.MediaItemID, + &i.ShelfName, + &i.ShelfPosition, + &i.AddedAt, + &i.LastSyncedAt, + &i.CollectionID, + &i.PositionInCollection, + &i.Title, + &i.Author, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetLibrary = `-- name: GetLibrary :one SELECT l.id, l.name, l.description, l.library_type_id, l.created_by_admin_id, l.created_at, l.updated_at, lt.name as type_name, lt.description as type_description FROM libraries l @@ -1798,7 +2789,7 @@ func (q *Queries) GetMediaHighlights(ctx context.Context, arg GetMediaHighlights } const GetMediaItem = `-- name: GetMediaItem :one -SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata FROM media_items WHERE id = $1 +SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE id = $1 ` func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, error) { @@ -1843,12 +2834,16 @@ func (q *Queries) GetMediaItem(ctx context.Context, id pgtype.UUID) (MediaItems, &i.RevisionNumber, &i.KoboContentID, &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, ) return i, err } const GetMediaItemByFilePath = `-- name: GetMediaItemByFilePath :one -SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata FROM media_items WHERE file_path = $1 +SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1 ` func (q *Queries) GetMediaItemByFilePath(ctx context.Context, filePath string) (MediaItems, error) { @@ -1893,13 +2888,17 @@ func (q *Queries) GetMediaItemByFilePath(ctx context.Context, filePath string) ( &i.RevisionNumber, &i.KoboContentID, &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, ) return i, err } const GetMediaItemByFilePathForSync = `-- name: GetMediaItemByFilePathForSync :one -SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata FROM media_items WHERE file_path = $1 +SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_path = $1 ` // ============================================ @@ -1947,12 +2946,16 @@ func (q *Queries) GetMediaItemByFilePathForSync(ctx context.Context, filePath st &i.RevisionNumber, &i.KoboContentID, &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, ) return i, err } const GetMediaItemByKoboContentId = `-- name: GetMediaItemByKoboContentId :one -SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata FROM media_items WHERE kobo_content_id = $1 +SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE kobo_content_id = $1 ` func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID pgtype.Text) (MediaItems, error) { @@ -1997,10 +3000,263 @@ func (q *Queries) GetMediaItemByKoboContentId(ctx context.Context, koboContentID &i.RevisionNumber, &i.KoboContentID, &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, ) return i, err } +const GetMediaItemByOPFIdentifier = `-- name: GetMediaItemByOPFIdentifier :one +SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE opf_identifier = $1 +` + +// Get media item by OPF identifier +func (q *Queries) GetMediaItemByOPFIdentifier(ctx context.Context, opfIdentifier pgtype.Text) (MediaItems, error) { + row := q.db.QueryRow(ctx, GetMediaItemByOPFIdentifier, opfIdentifier) + var i MediaItems + err := row.Scan( + &i.ID, + &i.LibraryID, + &i.Title, + &i.Author, + &i.Isbn, + &i.Description, + &i.FilePath, + &i.FileSize, + &i.MimeType, + &i.CoverImagePath, + &i.Series, + &i.SeriesNumber, + &i.Tags, + &i.Asin, + &i.DatePublished, + &i.Publisher, + &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.Genre, + &i.CopyrightYear, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.AddedByAdminID, + &i.CreatedAt, + &i.UpdatedAt, + &i.FormatGroup, + &i.FormatMimetype, + &i.IsReflowable, + &i.HasFixedLayout, + &i.TotalCharacters, + &i.ChapterCount, + &i.EntitlementID, + &i.RevisionNumber, + &i.KoboContentID, + &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, + ) + return i, err +} + +const GetMediaItemByOPFUUID = `-- name: GetMediaItemByOPFUUID :one +SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE opf_uuid = $1 +` + +// Get media item by OPF UUID +func (q *Queries) GetMediaItemByOPFUUID(ctx context.Context, opfUuid pgtype.Text) (MediaItems, error) { + row := q.db.QueryRow(ctx, GetMediaItemByOPFUUID, opfUuid) + var i MediaItems + err := row.Scan( + &i.ID, + &i.LibraryID, + &i.Title, + &i.Author, + &i.Isbn, + &i.Description, + &i.FilePath, + &i.FileSize, + &i.MimeType, + &i.CoverImagePath, + &i.Series, + &i.SeriesNumber, + &i.Tags, + &i.Asin, + &i.DatePublished, + &i.Publisher, + &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.Genre, + &i.CopyrightYear, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.AddedByAdminID, + &i.CreatedAt, + &i.UpdatedAt, + &i.FormatGroup, + &i.FormatMimetype, + &i.IsReflowable, + &i.HasFixedLayout, + &i.TotalCharacters, + &i.ChapterCount, + &i.EntitlementID, + &i.RevisionNumber, + &i.KoboContentID, + &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, + ) + return i, err +} + +const GetMediaItemBySHA256 = `-- name: GetMediaItemBySHA256 :one +SELECT id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, file_sha256, opf_identifier, opf_uuid, hash_confidence FROM media_items WHERE file_sha256 = $1 +` + +// Get media item by SHA-256 hash +func (q *Queries) GetMediaItemBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItems, error) { + row := q.db.QueryRow(ctx, GetMediaItemBySHA256, fileSha256) + var i MediaItems + err := row.Scan( + &i.ID, + &i.LibraryID, + &i.Title, + &i.Author, + &i.Isbn, + &i.Description, + &i.FilePath, + &i.FileSize, + &i.MimeType, + &i.CoverImagePath, + &i.Series, + &i.SeriesNumber, + &i.Tags, + &i.Asin, + &i.DatePublished, + &i.Publisher, + &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.Genre, + &i.CopyrightYear, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.AddedByAdminID, + &i.CreatedAt, + &i.UpdatedAt, + &i.FormatGroup, + &i.FormatMimetype, + &i.IsReflowable, + &i.HasFixedLayout, + &i.TotalCharacters, + &i.ChapterCount, + &i.EntitlementID, + &i.RevisionNumber, + &i.KoboContentID, + &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, + ) + return i, err +} + +const GetMediaItemFormatBySHA256 = `-- name: GetMediaItemFormatBySHA256 :one +SELECT id, media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, created_at, converted_from_format_id FROM media_item_formats WHERE file_sha256 = $1 +` + +// Get media item format by SHA-256 +func (q *Queries) GetMediaItemFormatBySHA256(ctx context.Context, fileSha256 pgtype.Text) (MediaItemFormats, error) { + row := q.db.QueryRow(ctx, GetMediaItemFormatBySHA256, fileSha256) + var i MediaItemFormats + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.FormatType, + &i.FilePath, + &i.FileSha256, + &i.FileSizeBytes, + &i.MimeType, + &i.CreatedAt, + &i.ConvertedFromFormatID, + ) + return i, err +} + +const GetMediaItemFormatByType = `-- name: GetMediaItemFormatByType :one +SELECT id, media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, created_at, converted_from_format_id FROM media_item_formats WHERE media_item_id = $1 AND format_type = $2 +` + +type GetMediaItemFormatByTypeParams struct { + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + FormatType string `db:"format_type" json:"format_type"` +} + +// Get media item format by type +func (q *Queries) GetMediaItemFormatByType(ctx context.Context, arg GetMediaItemFormatByTypeParams) (MediaItemFormats, error) { + row := q.db.QueryRow(ctx, GetMediaItemFormatByType, arg.MediaItemID, arg.FormatType) + var i MediaItemFormats + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.FormatType, + &i.FilePath, + &i.FileSha256, + &i.FileSizeBytes, + &i.MimeType, + &i.CreatedAt, + &i.ConvertedFromFormatID, + ) + return i, err +} + +const GetMediaItemFormats = `-- name: GetMediaItemFormats :many +SELECT id, media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, created_at, converted_from_format_id FROM media_item_formats WHERE media_item_id = $1 +` + +// Get media item formats +func (q *Queries) GetMediaItemFormats(ctx context.Context, mediaItemID pgtype.UUID) ([]MediaItemFormats, error) { + rows, err := q.db.Query(ctx, GetMediaItemFormats, mediaItemID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []MediaItemFormats{} + for rows.Next() { + var i MediaItemFormats + if err := rows.Scan( + &i.ID, + &i.MediaItemID, + &i.FormatType, + &i.FilePath, + &i.FileSha256, + &i.FileSizeBytes, + &i.MimeType, + &i.CreatedAt, + &i.ConvertedFromFormatID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetMediaNote = `-- name: GetMediaNote :one SELECT id, media_item_id, user_id, content, position, created_at, updated_at, percentage_location, character_start, character_end, epubcfi_location, chapter_reference, paragraph_reference, device_sync_data FROM media_notes WHERE id = $1 ` @@ -2159,6 +3415,73 @@ func (q *Queries) GetNextRetryTime(ctx context.Context) (interface{}, error) { return next_retry_time, err } +const GetOpdsToken = `-- name: GetOpdsToken :one +SELECT ot.id, ot.device_id, ot.token, ot.token_type, ot.expires_at, ot.created_at, 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() +` + +type GetOpdsTokenRow struct { + ID pgtype.UUID `db:"id" json:"id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + Token string `db:"token" json:"token"` + TokenType pgtype.Text `db:"token_type" json:"token_type"` + ExpiresAt pgtype.Timestamptz `db:"expires_at" json:"expires_at"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + DeviceName string `db:"device_name" json:"device_name"` + DeviceType string `db:"device_type" json:"device_type"` +} + +// Get OPDS token +func (q *Queries) GetOpdsToken(ctx context.Context, token string) (GetOpdsTokenRow, error) { + row := q.db.QueryRow(ctx, GetOpdsToken, token) + var i GetOpdsTokenRow + err := row.Scan( + &i.ID, + &i.DeviceID, + &i.Token, + &i.TokenType, + &i.ExpiresAt, + &i.CreatedAt, + &i.DeviceName, + &i.DeviceType, + ) + return i, err +} + +const GetOpdsTokensByDevice = `-- name: GetOpdsTokensByDevice :many +SELECT id, device_id, token, token_type, expires_at, created_at FROM opds_tokens WHERE device_id = $1 AND expires_at > NOW() +` + +// Get OPDS tokens by device +func (q *Queries) GetOpdsTokensByDevice(ctx context.Context, deviceID pgtype.UUID) ([]OpdsTokens, error) { + rows, err := q.db.Query(ctx, GetOpdsTokensByDevice, deviceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []OpdsTokens{} + for rows.Next() { + var i OpdsTokens + if err := rows.Scan( + &i.ID, + &i.DeviceID, + &i.Token, + &i.TokenType, + &i.ExpiresAt, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetReadingHistory = `-- name: GetReadingHistory :many SELECT id, user_id, media_item_id, device_id, progress_percentage, reading_session_start, reading_session_end, pages_read, time_spent_seconds, device_metadata, created_at FROM reading_history @@ -2415,6 +3738,25 @@ func (q *Queries) GetSyncQueueStats(ctx context.Context, deviceID pgtype.UUID) ( return i, err } +const GetSystemConfig = `-- name: GetSystemConfig :one + +SELECT key, value, updated_at, updated_by FROM system_config WHERE key = $1 +` + +// SYSTEM CONFIG QUERIES +// Get system config +func (q *Queries) GetSystemConfig(ctx context.Context, key string) (SystemConfig, error) { + row := q.db.QueryRow(ctx, GetSystemConfig, key) + var i SystemConfig + err := row.Scan( + &i.Key, + &i.Value, + &i.UpdatedAt, + &i.UpdatedBy, + ) + return i, err +} + const GetUniversalProgress = `-- name: GetUniversalProgress :one SELECT rp.id, @@ -2526,6 +3868,100 @@ func (q *Queries) GetUniversalProgress(ctx context.Context, arg GetUniversalProg return i, err } +const GetUnlinkedBookByContentId = `-- name: GetUnlinkedBookByContentId :one +SELECT id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at FROM unlinked_books WHERE device_id = $1 AND content_id = $2 +` + +type GetUnlinkedBookByContentIdParams struct { + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + ContentID string `db:"content_id" json:"content_id"` +} + +// Get unlinked book by ContentId +func (q *Queries) GetUnlinkedBookByContentId(ctx context.Context, arg GetUnlinkedBookByContentIdParams) (UnlinkedBooks, error) { + row := q.db.QueryRow(ctx, GetUnlinkedBookByContentId, arg.DeviceID, arg.ContentID) + var i UnlinkedBooks + err := row.Scan( + &i.ID, + &i.DeviceID, + &i.ContentID, + &i.FilePath, + &i.Title, + &i.Author, + &i.ConfidenceScore, + &i.Resolved, + &i.MediaItemID, + &i.ResolvedAt, + &i.ResolutionMethod, + &i.LastSeenAt, + &i.CreatedAt, + ) + return i, err +} + +const GetUnlinkedBooksByDevice = `-- name: GetUnlinkedBooksByDevice :many +SELECT ub.id, ub.device_id, ub.content_id, ub.file_path, ub.title, ub.author, ub.confidence_score, ub.resolved, ub.media_item_id, ub.resolved_at, ub.resolution_method, ub.last_seen_at, ub.created_at, 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 +` + +type GetUnlinkedBooksByDeviceRow struct { + ID pgtype.UUID `db:"id" json:"id"` + DeviceID pgtype.UUID `db:"device_id" json:"device_id"` + ContentID string `db:"content_id" json:"content_id"` + FilePath pgtype.Text `db:"file_path" json:"file_path"` + Title pgtype.Text `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"` + Resolved pgtype.Bool `db:"resolved" json:"resolved"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + ResolvedAt pgtype.Timestamptz `db:"resolved_at" json:"resolved_at"` + ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"` + LastSeenAt pgtype.Timestamptz `db:"last_seen_at" json:"last_seen_at"` + CreatedAt pgtype.Timestamptz `db:"created_at" json:"created_at"` + DeviceName string `db:"device_name" json:"device_name"` + DeviceType string `db:"device_type" json:"device_type"` +} + +// Get unlinked books for a device +func (q *Queries) GetUnlinkedBooksByDevice(ctx context.Context, deviceID pgtype.UUID) ([]GetUnlinkedBooksByDeviceRow, error) { + rows, err := q.db.Query(ctx, GetUnlinkedBooksByDevice, deviceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetUnlinkedBooksByDeviceRow{} + for rows.Next() { + var i GetUnlinkedBooksByDeviceRow + if err := rows.Scan( + &i.ID, + &i.DeviceID, + &i.ContentID, + &i.FilePath, + &i.Title, + &i.Author, + &i.ConfidenceScore, + &i.Resolved, + &i.MediaItemID, + &i.ResolvedAt, + &i.ResolutionMethod, + &i.LastSeenAt, + &i.CreatedAt, + &i.DeviceName, + &i.DeviceType, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const GetUser = `-- name: GetUser :one SELECT id, email, username, theme, first_name, last_name, role, created_at, updated_at FROM users WHERE id = $1 ` @@ -2706,13 +4142,14 @@ SELECT mi.chapter_count, mi.entitlement_id, mi.revision_number, - mi.file_size + 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 +WHERE lv.user_id = $1 AND lv.is_visible = true - ORDER BY mi.title ASC - LIMIT 1000 +ORDER BY mi.title ASC +LIMIT 1000 ` type GetUserMediaItemsForSyncRow struct { @@ -2728,6 +4165,7 @@ type GetUserMediaItemsForSyncRow struct { EntitlementID pgtype.Text `db:"entitlement_id" json:"entitlement_id"` RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"` FileSize pgtype.Int8 `db:"file_size" json:"file_size"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` } func (q *Queries) GetUserMediaItemsForSync(ctx context.Context, userID pgtype.UUID) ([]GetUserMediaItemsForSyncRow, error) { @@ -2752,6 +4190,7 @@ func (q *Queries) GetUserMediaItemsForSync(ctx context.Context, userID pgtype.UU &i.EntitlementID, &i.RevisionNumber, &i.FileSize, + &i.FileSha256, ); err != nil { return nil, err } @@ -2949,6 +4388,26 @@ func (q *Queries) IncrementSyncQueueAttempts(ctx context.Context, arg IncrementS return i, err } +const IsBookInCollection = `-- name: IsBookInCollection :one +SELECT EXISTS( + SELECT 1 FROM collection_items + WHERE collection_id = $1 AND media_item_id = $2 +) as in_collection +` + +type IsBookInCollectionParams struct { + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` +} + +// Check if book is in collection +func (q *Queries) IsBookInCollection(ctx context.Context, arg IsBookInCollectionParams) (bool, error) { + row := q.db.QueryRow(ctx, IsBookInCollection, arg.CollectionID, arg.MediaItemID) + var in_collection bool + err := row.Scan(&in_collection) + return in_collection, err +} + const IsBookOnKoboShelf = `-- name: IsBookOnKoboShelf :one SELECT EXISTS( SELECT 1 FROM kobo_shelves @@ -2968,6 +4427,46 @@ func (q *Queries) IsBookOnKoboShelf(ctx context.Context, arg IsBookOnKoboShelfPa return on_shelf, err } +const LinkUnlinkedBook = `-- 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 id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at +` + +type LinkUnlinkedBookParams struct { + ID pgtype.UUID `db:"id" json:"id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"` +} + +// Link unlinked book to media item +func (q *Queries) LinkUnlinkedBook(ctx context.Context, arg LinkUnlinkedBookParams) (UnlinkedBooks, error) { + row := q.db.QueryRow(ctx, LinkUnlinkedBook, arg.ID, arg.MediaItemID, arg.ConfidenceScore) + var i UnlinkedBooks + err := row.Scan( + &i.ID, + &i.DeviceID, + &i.ContentID, + &i.FilePath, + &i.Title, + &i.Author, + &i.ConfidenceScore, + &i.Resolved, + &i.MediaItemID, + &i.ResolvedAt, + &i.ResolutionMethod, + &i.LastSeenAt, + &i.CreatedAt, + ) + return i, err +} + const ListAllConflictsByUserAndStatus = `-- name: ListAllConflictsByUserAndStatus :many SELECT sc.id, sc.media_item_id, sc.user_id, sc.conflict_type, sc.conflict_data, sc.resolution_status, sc.resolution_data, sc.resolved_by, sc.resolved_at, sc.created_at, mi.title, mi.author FROM sync_conflicts sc @@ -3233,7 +4732,7 @@ func (q *Queries) ListLibraries(ctx context.Context) ([]ListLibrariesRow, error) } const ListMediaItems = `-- name: ListMediaItems :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, l.name as library_name, lt.name as library_type_name +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.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 @@ -3284,6 +4783,10 @@ type ListMediaItemsRow struct { RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"` KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"` KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"` + OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"` + HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"` LibraryName string `db:"library_name" json:"library_name"` LibraryTypeName string `db:"library_type_name" json:"library_type_name"` } @@ -3336,6 +4839,10 @@ func (q *Queries) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) &i.RevisionNumber, &i.KoboContentID, &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, &i.LibraryName, &i.LibraryTypeName, ); err != nil { @@ -3350,7 +4857,7 @@ func (q *Queries) ListMediaItems(ctx context.Context, arg ListMediaItemsParams) } const ListMediaItemsByLibrary = `-- name: ListMediaItemsByLibrary :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, l.name as library_name, lt.name as library_type_name +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.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 @@ -3397,6 +4904,10 @@ type ListMediaItemsByLibraryRow struct { RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"` KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"` KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"` + OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"` + HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"` LibraryName string `db:"library_name" json:"library_name"` LibraryTypeName string `db:"library_type_name" json:"library_type_name"` } @@ -3449,6 +4960,10 @@ func (q *Queries) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype. &i.RevisionNumber, &i.KoboContentID, &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, &i.LibraryName, &i.LibraryTypeName, ); err != nil { @@ -3463,7 +4978,7 @@ func (q *Queries) ListMediaItemsByLibrary(ctx context.Context, libraryID pgtype. } const ListMediaItemsFiltered = `-- name: ListMediaItemsFiltered :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, l.name as library_name, lt.name as library_type_name +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.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 @@ -3560,6 +5075,10 @@ type ListMediaItemsFilteredRow struct { RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"` KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"` KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"` + OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"` + HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"` LibraryName string `db:"library_name" json:"library_name"` LibraryTypeName string `db:"library_type_name" json:"library_type_name"` } @@ -3625,6 +5144,10 @@ func (q *Queries) ListMediaItemsFiltered(ctx context.Context, arg ListMediaItems &i.RevisionNumber, &i.KoboContentID, &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, &i.LibraryName, &i.LibraryTypeName, ); err != nil { @@ -3639,7 +5162,7 @@ func (q *Queries) ListMediaItemsFiltered(ctx context.Context, arg ListMediaItems } const ListMediaItemsSorted = `-- name: ListMediaItemsSorted :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, l.name as library_name, lt.name as library_type_name +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.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 @@ -3759,6 +5282,10 @@ type ListMediaItemsSortedRow struct { RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"` KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"` KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"` + OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"` + HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"` LibraryName string `db:"library_name" json:"library_name"` LibraryTypeName string `db:"library_type_name" json:"library_type_name"` } @@ -3816,6 +5343,10 @@ func (q *Queries) ListMediaItemsSorted(ctx context.Context, arg ListMediaItemsSo &i.RevisionNumber, &i.KoboContentID, &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, &i.LibraryName, &i.LibraryTypeName, ); err != nil { @@ -4018,6 +5549,119 @@ func (q *Queries) ListUsers(ctx context.Context) ([]ListUsersRow, error) { return items, nil } +const QueryMediaItemsByIdentifiers = `-- 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 +` + +type QueryMediaItemsByIdentifiersParams struct { + Column1 pgtype.UUID `db:"column_1" json:"column_1"` + Column2 string `db:"column_2" json:"column_2"` + Column3 string `db:"column_3" json:"column_3"` + Column4 string `db:"column_4" json:"column_4"` + Column5 string `db:"column_5" json:"column_5"` + Column6 string `db:"column_6" json:"column_6"` + Column7 string `db:"column_7" json:"column_7"` +} + +type QueryMediaItemsByIdentifiersRow struct { + ID pgtype.UUID `db:"id" json:"id"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"` + OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"` + Isbn pgtype.Text `db:"isbn" json:"isbn"` + Asin pgtype.Text `db:"asin" json:"asin"` + Title string `db:"title" json:"title"` + Author pgtype.Text `db:"author" json:"author"` + FileSize pgtype.Int8 `db:"file_size" json:"file_size"` + HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"` + ConfidenceScore float64 `db:"confidence_score" json:"confidence_score"` +} + +// Query media items by multiple identifiers with confidence scoring +func (q *Queries) QueryMediaItemsByIdentifiers(ctx context.Context, arg QueryMediaItemsByIdentifiersParams) ([]QueryMediaItemsByIdentifiersRow, error) { + rows, err := q.db.Query(ctx, QueryMediaItemsByIdentifiers, + arg.Column1, + arg.Column2, + arg.Column3, + arg.Column4, + arg.Column5, + arg.Column6, + arg.Column7, + ) + if err != nil { + return nil, err + } + defer rows.Close() + items := []QueryMediaItemsByIdentifiersRow{} + for rows.Next() { + var i QueryMediaItemsByIdentifiersRow + if err := rows.Scan( + &i.ID, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.Isbn, + &i.Asin, + &i.Title, + &i.Author, + &i.FileSize, + &i.HashConfidence, + &i.ConfidenceScore, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const RemoveBookFromCollection = `-- name: RemoveBookFromCollection :exec +DELETE FROM collection_items WHERE collection_id = $1 AND media_item_id = $2 +` + +type RemoveBookFromCollectionParams struct { + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` +} + +// Remove book from collection +func (q *Queries) RemoveBookFromCollection(ctx context.Context, arg RemoveBookFromCollectionParams) error { + _, err := q.db.Exec(ctx, RemoveBookFromCollection, arg.CollectionID, arg.MediaItemID) + return err +} + const RemoveBookFromKoboShelf = `-- name: RemoveBookFromKoboShelf :exec DELETE FROM kobo_shelves WHERE device_id = $1 AND media_item_id = $2 ` @@ -4073,6 +5717,45 @@ func (q *Queries) ResolveSyncConflict(ctx context.Context, arg ResolveSyncConfli return i, err } +const ResolveUnlinkedBook = `-- name: ResolveUnlinkedBook :one +UPDATE unlinked_books +SET + resolved = true, + media_item_id = $2, + resolved_at = NOW(), + resolution_method = $3 +WHERE id = $1 +RETURNING id, device_id, content_id, file_path, title, author, confidence_score, resolved, media_item_id, resolved_at, resolution_method, last_seen_at, created_at +` + +type ResolveUnlinkedBookParams struct { + ID pgtype.UUID `db:"id" json:"id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + ResolutionMethod pgtype.Text `db:"resolution_method" json:"resolution_method"` +} + +// Resolve unlinked book +func (q *Queries) ResolveUnlinkedBook(ctx context.Context, arg ResolveUnlinkedBookParams) (UnlinkedBooks, error) { + row := q.db.QueryRow(ctx, ResolveUnlinkedBook, arg.ID, arg.MediaItemID, arg.ResolutionMethod) + var i UnlinkedBooks + err := row.Scan( + &i.ID, + &i.DeviceID, + &i.ContentID, + &i.FilePath, + &i.Title, + &i.Author, + &i.ConfidenceScore, + &i.Resolved, + &i.MediaItemID, + &i.ResolvedAt, + &i.ResolutionMethod, + &i.LastSeenAt, + &i.CreatedAt, + ) + return i, err +} + const RevokeAllUserRefreshTokens = `-- name: RevokeAllUserRefreshTokens :exec UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL ` @@ -4096,6 +5779,16 @@ func (q *Queries) RevokeDevice(ctx context.Context, id pgtype.UUID) error { return err } +const RevokeOpdsToken = `-- name: RevokeOpdsToken :exec +DELETE FROM opds_tokens WHERE token = $1 +` + +// Revoke OPDS token +func (q *Queries) RevokeOpdsToken(ctx context.Context, token string) error { + _, err := q.db.Exec(ctx, RevokeOpdsToken, token) + return err +} + const RevokeRefreshToken = `-- name: RevokeRefreshToken :exec UPDATE refresh_tokens SET revoked_at = NOW() WHERE token = $1 ` @@ -4107,7 +5800,7 @@ func (q *Queries) RevokeRefreshToken(ctx context.Context, token pgtype.UUID) err const SearchMediaItems = `-- name: SearchMediaItems :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, l.name as library_name, lt.name as library_type_name +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.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 @@ -4178,6 +5871,10 @@ type SearchMediaItemsRow struct { RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"` KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"` KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"` + OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"` + HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"` LibraryName string `db:"library_name" json:"library_name"` LibraryTypeName string `db:"library_type_name" json:"library_type_name"` } @@ -4238,6 +5935,10 @@ func (q *Queries) SearchMediaItems(ctx context.Context, arg SearchMediaItemsPara &i.RevisionNumber, &i.KoboContentID, &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, &i.LibraryName, &i.LibraryTypeName, ); err != nil { @@ -4252,7 +5953,7 @@ func (q *Queries) SearchMediaItems(ctx context.Context, arg SearchMediaItemsPara } const SearchMediaItemsFuzzy = `-- name: SearchMediaItemsFuzzy :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, l.name as library_name, lt.name as library_type_name +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.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 @@ -4323,6 +6024,10 @@ type SearchMediaItemsFuzzyRow struct { RevisionNumber pgtype.Int4 `db:"revision_number" json:"revision_number"` KoboContentID pgtype.Text `db:"kobo_content_id" json:"kobo_content_id"` KoboMetadata []byte `db:"kobo_metadata" json:"kobo_metadata"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"` + OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"` + HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"` LibraryName string `db:"library_name" json:"library_name"` LibraryTypeName string `db:"library_type_name" json:"library_type_name"` } @@ -4380,6 +6085,10 @@ func (q *Queries) SearchMediaItemsFuzzy(ctx context.Context, arg SearchMediaItem &i.RevisionNumber, &i.KoboContentID, &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, &i.LibraryName, &i.LibraryTypeName, ); err != nil { @@ -4424,6 +6133,85 @@ func (q *Queries) SetLibraryVisibility(ctx context.Context, arg SetLibraryVisibi return i, err } +const SetSystemConfig = `-- 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 key, value, updated_at, updated_by +` + +type SetSystemConfigParams struct { + Key string `db:"key" json:"key"` + Value string `db:"value" json:"value"` + UpdatedBy pgtype.UUID `db:"updated_by" json:"updated_by"` +} + +// Set system config +func (q *Queries) SetSystemConfig(ctx context.Context, arg SetSystemConfigParams) (SystemConfig, error) { + row := q.db.QueryRow(ctx, SetSystemConfig, arg.Key, arg.Value, arg.UpdatedBy) + var i SystemConfig + err := row.Scan( + &i.Key, + &i.Value, + &i.UpdatedAt, + &i.UpdatedBy, + ) + return i, err +} + +const UpdateCollection = `-- 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 id, user_id, name, description, color, icon, auto_assign_rules, view_settings, created_at +` + +type UpdateCollectionParams struct { + ID pgtype.UUID `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Description pgtype.Text `db:"description" json:"description"` + Color pgtype.Text `db:"color" json:"color"` + Icon pgtype.Text `db:"icon" json:"icon"` + AutoAssignRules []byte `db:"auto_assign_rules" json:"auto_assign_rules"` + ViewSettings []byte `db:"view_settings" json:"view_settings"` +} + +// Update collection +func (q *Queries) UpdateCollection(ctx context.Context, arg UpdateCollectionParams) (Collections, error) { + row := q.db.QueryRow(ctx, UpdateCollection, + arg.ID, + arg.Name, + arg.Description, + arg.Color, + arg.Icon, + arg.AutoAssignRules, + arg.ViewSettings, + ) + var i Collections + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Description, + &i.Color, + &i.Icon, + &i.AutoAssignRules, + &i.ViewSettings, + &i.CreatedAt, + ) + return i, err +} + const UpdateDevice = `-- name: UpdateDevice :one UPDATE devices SET @@ -4475,6 +6263,62 @@ func (q *Queries) UpdateDevice(ctx context.Context, arg UpdateDeviceParams) (Dev return i, err } +const UpdateDeviceCatalogAvailability = `-- name: UpdateDeviceCatalogAvailability :exec +UPDATE device_catalogs +SET available = $2 +WHERE id = $1 +` + +type UpdateDeviceCatalogAvailabilityParams struct { + ID pgtype.UUID `db:"id" json:"id"` + Available pgtype.Bool `db:"available" json:"available"` +} + +// Update device catalog availability +func (q *Queries) UpdateDeviceCatalogAvailability(ctx context.Context, arg UpdateDeviceCatalogAvailabilityParams) error { + _, err := q.db.Exec(ctx, UpdateDeviceCatalogAvailability, arg.ID, arg.Available) + return err +} + +const UpdateDeviceFileAlias = `-- 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 id, media_item_id, device_id, file_path, file_sha256, confidence_score, last_seen_at +` + +type UpdateDeviceFileAliasParams struct { + ID pgtype.UUID `db:"id" json:"id"` + MediaItemID pgtype.UUID `db:"media_item_id" json:"media_item_id"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + ConfidenceScore pgtype.Float8 `db:"confidence_score" json:"confidence_score"` +} + +// Update device file alias +func (q *Queries) UpdateDeviceFileAlias(ctx context.Context, arg UpdateDeviceFileAliasParams) (DeviceFileAliases, error) { + row := q.db.QueryRow(ctx, UpdateDeviceFileAlias, + arg.ID, + arg.MediaItemID, + arg.FileSha256, + arg.ConfidenceScore, + ) + var i DeviceFileAliases + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.DeviceID, + &i.FilePath, + &i.FileSha256, + &i.ConfidenceScore, + &i.LastSeenAt, + ) + return i, err +} + const UpdateDeviceLastSeen = `-- name: UpdateDeviceLastSeen :one UPDATE devices SET @@ -4538,6 +6382,36 @@ func (q *Queries) UpdateDeviceLastSync(ctx context.Context, id pgtype.UUID) (Dev return i, err } +const UpdateDeviceShelfMapping = `-- name: UpdateDeviceShelfMapping :one +UPDATE device_shelf_mappings +SET + device_shelf_name = $2, + sync_direction = $3 +WHERE id = $1 +RETURNING id, collection_id, device_id, device_shelf_name, sync_direction, created_at +` + +type UpdateDeviceShelfMappingParams struct { + ID pgtype.UUID `db:"id" json:"id"` + DeviceShelfName pgtype.Text `db:"device_shelf_name" json:"device_shelf_name"` + SyncDirection pgtype.Text `db:"sync_direction" json:"sync_direction"` +} + +// Update device shelf mapping +func (q *Queries) UpdateDeviceShelfMapping(ctx context.Context, arg UpdateDeviceShelfMappingParams) (DeviceShelfMappings, error) { + row := q.db.QueryRow(ctx, UpdateDeviceShelfMapping, arg.ID, arg.DeviceShelfName, arg.SyncDirection) + var i DeviceShelfMappings + err := row.Scan( + &i.ID, + &i.CollectionID, + &i.DeviceID, + &i.DeviceShelfName, + &i.SyncDirection, + &i.CreatedAt, + ) + return i, err +} + const UpdateDeviceSyncTimestamp = `-- name: UpdateDeviceSyncTimestamp :one UPDATE devices SET @@ -4672,6 +6546,40 @@ func (q *Queries) UpdateKoboShelfBookPosition(ctx context.Context, arg UpdateKob return err } +const UpdateKoboShelfCollection = `-- name: UpdateKoboShelfCollection :one +UPDATE kobo_shelves +SET + collection_id = $2, + position_in_collection = $3, + last_synced_at = NOW() +WHERE id = $1 +RETURNING id, device_id, media_item_id, shelf_name, shelf_position, added_at, last_synced_at, collection_id, position_in_collection +` + +type UpdateKoboShelfCollectionParams struct { + ID pgtype.UUID `db:"id" json:"id"` + CollectionID pgtype.UUID `db:"collection_id" json:"collection_id"` + PositionInCollection pgtype.Int4 `db:"position_in_collection" json:"position_in_collection"` +} + +// Update Kobo shelves to support collections +func (q *Queries) UpdateKoboShelfCollection(ctx context.Context, arg UpdateKoboShelfCollectionParams) (KoboShelves, error) { + row := q.db.QueryRow(ctx, UpdateKoboShelfCollection, arg.ID, arg.CollectionID, arg.PositionInCollection) + var i KoboShelves + err := row.Scan( + &i.ID, + &i.DeviceID, + &i.MediaItemID, + &i.ShelfName, + &i.ShelfPosition, + &i.AddedAt, + &i.LastSyncedAt, + &i.CollectionID, + &i.PositionInCollection, + ) + return i, err +} + const UpdateLibrary = `-- name: UpdateLibrary :one UPDATE libraries SET name = $2, @@ -4783,7 +6691,7 @@ UPDATE media_items SET google_books_id = $21, updated_at = NOW() WHERE id = $1 -RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata +RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, file_sha256, opf_identifier, opf_uuid, hash_confidence ` type UpdateMediaItemParams struct { @@ -4874,6 +6782,53 @@ func (q *Queries) UpdateMediaItem(ctx context.Context, arg UpdateMediaItemParams &i.RevisionNumber, &i.KoboContentID, &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, + ) + return i, err +} + +const UpdateMediaItemFormat = `-- 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 id, media_item_id, format_type, file_path, file_sha256, file_size_bytes, mime_type, created_at, converted_from_format_id +` + +type UpdateMediaItemFormatParams struct { + ID pgtype.UUID `db:"id" json:"id"` + FilePath pgtype.Text `db:"file_path" json:"file_path"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + FileSizeBytes pgtype.Int8 `db:"file_size_bytes" json:"file_size_bytes"` + MimeType pgtype.Text `db:"mime_type" json:"mime_type"` +} + +// Update media item format +func (q *Queries) UpdateMediaItemFormat(ctx context.Context, arg UpdateMediaItemFormatParams) (MediaItemFormats, error) { + row := q.db.QueryRow(ctx, UpdateMediaItemFormat, + arg.ID, + arg.FilePath, + arg.FileSha256, + arg.FileSizeBytes, + arg.MimeType, + ) + var i MediaItemFormats + err := row.Scan( + &i.ID, + &i.MediaItemID, + &i.FormatType, + &i.FilePath, + &i.FileSha256, + &i.FileSizeBytes, + &i.MimeType, + &i.CreatedAt, + &i.ConvertedFromFormatID, ) return i, err } @@ -4919,6 +6874,89 @@ func (q *Queries) UpdateMediaItemFormatGroup(ctx context.Context, arg UpdateMedi return err } +const UpdateMediaItemIdentifiers = `-- 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 id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, file_sha256, opf_identifier, opf_uuid, hash_confidence +` + +type UpdateMediaItemIdentifiersParams struct { + ID pgtype.UUID `db:"id" json:"id"` + FileSha256 pgtype.Text `db:"file_sha256" json:"file_sha256"` + OpfIdentifier pgtype.Text `db:"opf_identifier" json:"opf_identifier"` + OpfUuid pgtype.Text `db:"opf_uuid" json:"opf_uuid"` + HashConfidence pgtype.Text `db:"hash_confidence" json:"hash_confidence"` +} + +// Media Items Admin Operations +// ============================================ +// PHASE 1: UNIVERSAL BOOK IDENTIFIERS (Week 1) +// ============================================ +// Update media item with universal identifiers +func (q *Queries) UpdateMediaItemIdentifiers(ctx context.Context, arg UpdateMediaItemIdentifiersParams) (MediaItems, error) { + row := q.db.QueryRow(ctx, UpdateMediaItemIdentifiers, + arg.ID, + arg.FileSha256, + arg.OpfIdentifier, + arg.OpfUuid, + arg.HashConfidence, + ) + var i MediaItems + err := row.Scan( + &i.ID, + &i.LibraryID, + &i.Title, + &i.Author, + &i.Isbn, + &i.Description, + &i.FilePath, + &i.FileSize, + &i.MimeType, + &i.CoverImagePath, + &i.Series, + &i.SeriesNumber, + &i.Tags, + &i.Asin, + &i.DatePublished, + &i.Publisher, + &i.Contributors, + &i.Language, + &i.Edition, + &i.PageCount, + &i.Genre, + &i.CopyrightYear, + &i.GoodreadsID, + &i.OpenlibraryID, + &i.GoogleBooksID, + &i.AddedByAdminID, + &i.CreatedAt, + &i.UpdatedAt, + &i.FormatGroup, + &i.FormatMimetype, + &i.IsReflowable, + &i.HasFixedLayout, + &i.TotalCharacters, + &i.ChapterCount, + &i.EntitlementID, + &i.RevisionNumber, + &i.KoboContentID, + &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, + ) + return i, err +} + const UpdateMediaItemKoboMetadata = `-- name: UpdateMediaItemKoboMetadata :one UPDATE media_items SET entitlement_id = $2, @@ -4927,7 +6965,7 @@ SET entitlement_id = $2, kobo_metadata = $5, updated_at = NOW() WHERE id = $1 -RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata +RETURNING id, library_id, title, author, isbn, description, file_path, file_size, mime_type, cover_image_path, series, series_number, tags, asin, date_published, publisher, contributors, language, edition, page_count, genre, copyright_year, goodreads_id, openlibrary_id, google_books_id, added_by_admin_id, created_at, updated_at, format_group, format_mimetype, is_reflowable, has_fixed_layout, total_characters, chapter_count, entitlement_id, revision_number, kobo_content_id, kobo_metadata, file_sha256, opf_identifier, opf_uuid, hash_confidence ` type UpdateMediaItemKoboMetadataParams struct { @@ -4986,6 +7024,10 @@ func (q *Queries) UpdateMediaItemKoboMetadata(ctx context.Context, arg UpdateMed &i.RevisionNumber, &i.KoboContentID, &i.KoboMetadata, + &i.FileSha256, + &i.OpfIdentifier, + &i.OpfUuid, + &i.HashConfidence, ) return i, err } diff --git a/internal/database/queries/queries.sql b/internal/database/queries/queries.sql index 195772c..16e1fb6 100644 --- a/internal/database/queries/queries.sql +++ b/internal/database/queries/queries.sql @@ -919,13 +919,14 @@ SELECT mi.chapter_count, mi.entitlement_id, mi.revision_number, - mi.file_size + 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 +WHERE lv.user_id = $1 AND lv.is_visible = true - ORDER BY mi.title ASC - LIMIT 1000; +ORDER BY mi.title ASC +LIMIT 1000; -- name: CheckForProgressConflicts :one SELECT COUNT(*) as conflict_count @@ -1054,4 +1055,416 @@ RETURNING *; -- name: GetMediaItemByKoboContentId :one SELECT * FROM media_items WHERE kobo_content_id = $1; - -- Media Items Admin Operations \ No newline at end of file +-- Media Items Admin Operations + +-- ============================================ +-- PHASE 1: UNIVERSAL BOOK IDENTIFIERS (Week 1) +-- ============================================ + +-- 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 +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, bookmann_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 Bookmann UUID +-- name: GetDeviceCatalogByBookmannUUID :one +SELECT * FROM device_catalogs WHERE device_id = $1 AND bookmann_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; + +-- ============================================ +-- PHASE 6: ENHANCED KOBO SYNC (Week 3-4) +-- ============================================ + +-- 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 *; \ No newline at end of file