//! Database schema and migrations //! //! TRACES: UR-002 | DR-012 | IR-013 /// List of migrations to apply in order. /// Each migration is a tuple of (name, sql). pub const MIGRATIONS: &[(&str, &str)] = &[ ("001_initial_schema", MIGRATION_001), ("002_remove_access_token", MIGRATION_002), ("003_relax_user_data_constraints", MIGRATION_003), ("004_enhance_downloads", MIGRATION_004), ("005_relax_downloads_fk", MIGRATION_005), ("006_downloads_metadata", MIGRATION_006), ("007_cache_metadata", MIGRATION_007), ("008_video_downloads", MIGRATION_008), ("009_people_tables", MIGRATION_009), ("010_playback_context", MIGRATION_010), ("011_user_player_settings", MIGRATION_011), ("012_download_source", MIGRATION_012), ("013_downloads_item_status_index", MIGRATION_013), ("014_series_audio_preferences", MIGRATION_014), ("015_device_id", MIGRATION_015), ("016_autoplay_max_episodes", MIGRATION_016), ("017_downloads_resume_url", MIGRATION_017), ("018_items_is_folder", MIGRATION_018), ("019_genres_cache", MIGRATION_019), ("020_items_season_index", MIGRATION_020), ("021_rebuild_items_fts", MIGRATION_021), ("022_people_fts", MIGRATION_022), ("023_downloads_expiry", MIGRATION_023), ("024_multi_user_profiles", MIGRATION_024), ]; /// Initial schema migration const MIGRATION_001: &str = r#" -- Jellyfin servers the user has connected to CREATE TABLE IF NOT EXISTS servers ( id TEXT PRIMARY KEY, name TEXT NOT NULL, url TEXT NOT NULL UNIQUE, version TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP, last_connected_at TEXT ); -- User accounts on Jellyfin servers CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, username TEXT NOT NULL, access_token TEXT, is_active INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP, last_login_at TEXT, UNIQUE(server_id, username) ); -- Libraries/views from Jellyfin CREATE TABLE IF NOT EXISTS libraries ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, name TEXT NOT NULL, collection_type TEXT, image_tag TEXT, sort_order INTEGER DEFAULT 0, synced_at TEXT, UNIQUE(server_id, id) ); -- Media items (movies, shows, episodes, albums, songs, artists) CREATE TABLE IF NOT EXISTS items ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, library_id TEXT REFERENCES libraries(id) ON DELETE SET NULL, parent_id TEXT REFERENCES items(id) ON DELETE CASCADE, -- Core metadata name TEXT NOT NULL, sort_name TEXT, original_title TEXT, item_type TEXT NOT NULL, -- Movie, Series, Episode, MusicAlbum, Audio, MusicArtist, etc. -- Media info overview TEXT, tagline TEXT, genres TEXT, -- JSON array tags TEXT, -- JSON array studios TEXT, -- JSON array -- For episodes series_id TEXT, series_name TEXT, season_id TEXT, season_name TEXT, index_number INTEGER, -- Episode number parent_index_number INTEGER, -- Season number -- For music album_id TEXT, album_name TEXT, album_artist TEXT, artists TEXT, -- JSON array -- Dates premiere_date TEXT, production_year INTEGER, date_created TEXT, -- Runtime (ticks) runtime_ticks INTEGER, -- Images primary_image_tag TEXT, backdrop_image_tags TEXT, -- JSON array -- Ratings community_rating REAL, official_rating TEXT, -- Sync metadata synced_at TEXT, etag TEXT, UNIQUE(server_id, id) ); -- Full-text search index for items CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5( name, overview, album_name, album_artist, artists, series_name, content='items', content_rowid='rowid' ); -- Triggers to keep FTS index in sync CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name) VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name); END; CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name) VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name); END; CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name) VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name); INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name) VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name); END; -- Media streams (audio/subtitle tracks) CREATE TABLE IF NOT EXISTS media_streams ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, stream_index INTEGER NOT NULL, stream_type TEXT NOT NULL, -- Audio, Subtitle, Video codec TEXT, language TEXT, display_title TEXT, is_default INTEGER DEFAULT 0, is_forced INTEGER DEFAULT 0, is_external INTEGER DEFAULT 0, path TEXT, -- For external subtitles UNIQUE(item_id, stream_index) ); -- User-specific data (watch progress, favorites) CREATE TABLE IF NOT EXISTS user_data ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, -- Playback state playback_position_ticks INTEGER DEFAULT 0, play_count INTEGER DEFAULT 0, is_played INTEGER DEFAULT 0, is_favorite INTEGER DEFAULT 0, -- Timestamps last_played_at TEXT, -- Sync status synced_at TEXT, pending_sync INTEGER DEFAULT 0, -- 1 if local changes need sync UNIQUE(user_id, item_id) ); -- Downloaded media files CREATE TABLE IF NOT EXISTS downloads ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- File info file_path TEXT NOT NULL, file_size INTEGER, mime_type TEXT, -- Download state status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused progress REAL DEFAULT 0, -- 0.0 to 1.0 -- Transcoding options used bitrate INTEGER, container TEXT, -- Timestamps queued_at TEXT DEFAULT CURRENT_TIMESTAMP, started_at TEXT, completed_at TEXT, -- Error tracking error_message TEXT, retry_count INTEGER DEFAULT 0, UNIQUE(item_id, user_id) ); -- Offline mutation queue (changes to sync back to server) CREATE TABLE IF NOT EXISTS sync_queue ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- Operation details operation TEXT NOT NULL, -- mark_played, mark_favorite, update_progress, etc. item_id TEXT, payload TEXT, -- JSON data for the operation -- Queue state status TEXT DEFAULT 'pending', -- pending, processing, completed, failed retry_count INTEGER DEFAULT 0, -- Timestamps created_at TEXT DEFAULT CURRENT_TIMESTAMP, processed_at TEXT, -- Error tracking error_message TEXT ); -- Cached thumbnails CREATE TABLE IF NOT EXISTS thumbnails ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, image_type TEXT NOT NULL, -- Primary, Backdrop, Thumb, Logo, etc. image_tag TEXT NOT NULL, file_path TEXT NOT NULL, width INTEGER, height INTEGER, cached_at TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(item_id, image_type, image_tag) ); -- User playlists (local + synced) CREATE TABLE IF NOT EXISTS playlists ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, name TEXT NOT NULL, is_local INTEGER DEFAULT 0, -- 1 for local-only playlists jellyfin_id TEXT, -- NULL for local-only created_at TEXT DEFAULT CURRENT_TIMESTAMP, updated_at TEXT ); -- Playlist items CREATE TABLE IF NOT EXISTS playlist_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE, item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE, sort_order INTEGER NOT NULL, added_at TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(playlist_id, item_id) ); -- Indexes for common queries CREATE INDEX IF NOT EXISTS idx_items_server ON items(server_id); CREATE INDEX IF NOT EXISTS idx_items_library ON items(library_id); CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id); CREATE INDEX IF NOT EXISTS idx_items_type ON items(item_type); CREATE INDEX IF NOT EXISTS idx_items_album ON items(album_id); CREATE INDEX IF NOT EXISTS idx_items_series ON items(series_id); CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id); CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id); CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id); CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status); CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(status); CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id); "#; /// Migration to remove access_token column from users table /// Tokens are now stored in the system keyring (or encrypted file fallback) const MIGRATION_002: &str = r#" -- Remove access_token column from users table -- Tokens are now stored in secure storage (system keyring) -- SQLite doesn't support DROP COLUMN in older versions, so we recreate the table CREATE TABLE IF NOT EXISTS users_new ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE, username TEXT NOT NULL, is_active INTEGER DEFAULT 0, created_at TEXT DEFAULT CURRENT_TIMESTAMP, last_login_at TEXT, UNIQUE(server_id, username) ); -- Copy existing data (excluding access_token) INSERT OR IGNORE INTO users_new (id, server_id, username, is_active, created_at, last_login_at) SELECT id, server_id, username, is_active, created_at, last_login_at FROM users; -- Drop old table and rename new one DROP TABLE IF EXISTS users; ALTER TABLE users_new RENAME TO users; "#; /// Migration to relax foreign key constraints on user_data table /// Allows tracking playback progress for items not yet synced to local database const MIGRATION_003: &str = r#" -- Recreate user_data table without foreign key constraint on item_id -- This allows tracking playback progress for items that haven't been synced locally yet CREATE TABLE IF NOT EXISTS user_data_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, item_id TEXT NOT NULL, -- No foreign key constraint - item may not be synced yet -- Playback state playback_position_ticks INTEGER DEFAULT 0, play_count INTEGER DEFAULT 0, is_played INTEGER DEFAULT 0, is_favorite INTEGER DEFAULT 0, -- Timestamps last_played_at TEXT, -- Sync status synced_at TEXT, pending_sync INTEGER DEFAULT 0, -- 1 if local changes need sync UNIQUE(user_id, item_id) ); -- Copy existing data INSERT OR IGNORE INTO user_data_new (id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync) SELECT id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync FROM user_data; -- Drop old table and rename new one DROP TABLE IF EXISTS user_data; ALTER TABLE user_data_new RENAME TO user_data; -- Recreate index CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id); CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id); "#; /// Migration to enhance downloads table with priority and bytes_downloaded const MIGRATION_004: &str = r#" -- Add priority column for queue ordering ALTER TABLE downloads ADD COLUMN priority INTEGER DEFAULT 0; -- Add bytes_downloaded for resume support ALTER TABLE downloads ADD COLUMN bytes_downloaded INTEGER DEFAULT 0; -- Create index for efficient queue processing (priority DESC, FIFO within same priority) CREATE INDEX IF NOT EXISTS idx_downloads_queue ON downloads(status, priority DESC, queued_at ASC) WHERE status IN ('pending', 'downloading'); "#; /// Migration to relax foreign key constraint on downloads.item_id /// Allows downloading items that haven't been synced to local database yet const MIGRATION_005: &str = r#" -- Recreate downloads table without foreign key constraint on item_id -- This allows downloading items that haven't been synced locally yet CREATE TABLE IF NOT EXISTS downloads_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, -- No foreign key constraint - item may not be synced yet user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, -- File info file_path TEXT NOT NULL, file_size INTEGER, mime_type TEXT, -- Download state status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused progress REAL DEFAULT 0, -- 0.0 to 1.0 -- Transcoding options used bitrate INTEGER, container TEXT, -- Timestamps queued_at TEXT DEFAULT CURRENT_TIMESTAMP, started_at TEXT, completed_at TEXT, -- Error tracking error_message TEXT, retry_count INTEGER DEFAULT 0, -- Priority and progress tracking (from migration 004) priority INTEGER DEFAULT 0, bytes_downloaded INTEGER DEFAULT 0, UNIQUE(item_id, user_id) ); -- Copy existing data INSERT OR IGNORE INTO downloads_new ( id, item_id, user_id, file_path, file_size, mime_type, status, progress, bitrate, container, queued_at, started_at, completed_at, error_message, retry_count, priority, bytes_downloaded ) SELECT id, item_id, user_id, file_path, file_size, mime_type, status, progress, bitrate, container, queued_at, started_at, completed_at, error_message, retry_count, priority, bytes_downloaded FROM downloads; -- Drop old table and rename new one DROP TABLE IF EXISTS downloads; ALTER TABLE downloads_new RENAME TO downloads; -- Recreate indexes CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status); CREATE INDEX IF NOT EXISTS idx_downloads_queue ON downloads(status, priority DESC, queued_at ASC) WHERE status IN ('pending', 'downloading'); "#; /// Migration to store item metadata directly in downloads table /// This eliminates dependency on items table being synced and fixes UUID display issues const MIGRATION_006: &str = r#" -- Add columns to store item metadata directly in downloads -- This ensures correct display even when items aren't synced locally ALTER TABLE downloads ADD COLUMN item_name TEXT; ALTER TABLE downloads ADD COLUMN artist_name TEXT; ALTER TABLE downloads ADD COLUMN album_name TEXT; "#; /// Migration to enhance thumbnail caching with LRU eviction support /// - Relaxes foreign key constraint on item_id (allows caching for items not yet synced) /// - Adds last_accessed for LRU eviction /// - Adds file_size for cache limit tracking /// - Creates cache_settings table for configurable limits const MIGRATION_007: &str = r#" -- Recreate thumbnails table without foreign key constraint on item_id -- and add LRU eviction support columns CREATE TABLE IF NOT EXISTS thumbnails_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, -- No foreign key constraint - item may not be synced yet image_type TEXT NOT NULL, -- Primary, Backdrop, Thumb, Logo, etc. image_tag TEXT NOT NULL, file_path TEXT NOT NULL, width INTEGER, height INTEGER, file_size INTEGER DEFAULT 0, -- Size in bytes for cache limit tracking cached_at TEXT DEFAULT CURRENT_TIMESTAMP, last_accessed TEXT DEFAULT CURRENT_TIMESTAMP, -- For LRU eviction UNIQUE(item_id, image_type, image_tag) ); -- Copy existing data (if any) INSERT OR IGNORE INTO thumbnails_new (id, item_id, image_type, image_tag, file_path, width, height, cached_at) SELECT id, item_id, image_type, image_tag, file_path, width, height, cached_at FROM thumbnails; -- Drop old table and rename new one DROP TABLE IF EXISTS thumbnails; ALTER TABLE thumbnails_new RENAME TO thumbnails; -- Create indexes for efficient queries CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id); CREATE INDEX IF NOT EXISTS idx_thumbnails_lru ON thumbnails(last_accessed ASC); -- Cache settings table for configurable limits CREATE TABLE IF NOT EXISTS cache_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); -- Insert default settings INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_limit_bytes', '1073741824'); -- 1GB default INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_enabled', 'true'); "#; /// Migration to add video download support and item pinning /// - Adds video-specific metadata columns to downloads (series/episode info, quality preset) /// - Adds media_type to distinguish audio vs video downloads /// - Adds is_pinned column to items table for protecting metadata from cache clear const MIGRATION_008: &str = r#" -- Add video-specific metadata columns to downloads ALTER TABLE downloads ADD COLUMN series_name TEXT; ALTER TABLE downloads ADD COLUMN season_name TEXT; ALTER TABLE downloads ADD COLUMN episode_number INTEGER; ALTER TABLE downloads ADD COLUMN season_number INTEGER; ALTER TABLE downloads ADD COLUMN quality_preset TEXT DEFAULT 'original'; ALTER TABLE downloads ADD COLUMN media_type TEXT DEFAULT 'audio'; -- Add pinning support to items table -- Pinned items are protected from cache clear operations ALTER TABLE items ADD COLUMN is_pinned INTEGER DEFAULT 0; -- Index for efficiently finding pinned items CREATE INDEX IF NOT EXISTS idx_items_pinned ON items(is_pinned) WHERE is_pinned = 1; -- Index for efficiently querying downloads by series CREATE INDEX IF NOT EXISTS idx_downloads_series ON downloads(series_name) WHERE series_name IS NOT NULL; -- Index for filtering by media type CREATE INDEX IF NOT EXISTS idx_downloads_media_type ON downloads(media_type); "#; /// Migration to add people/cast caching support /// - Creates people table for caching actor/director/writer/etc info /// - Creates item_people junction table for many-to-many relationships /// - Adds indexes for efficient queries const MIGRATION_009: &str = r#" -- People table for caching cast/crew members CREATE TABLE IF NOT EXISTS people ( id TEXT PRIMARY KEY, server_id TEXT NOT NULL, name TEXT NOT NULL, overview TEXT, primary_image_tag TEXT, premiere_date TEXT, -- Birth date end_date TEXT, -- Death date synced_at TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(server_id, id) ); -- Item-Person association table (many-to-many) -- Stores which people appear in which items, along with role info CREATE TABLE IF NOT EXISTS item_people ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, person_id TEXT NOT NULL, server_id TEXT NOT NULL, person_type TEXT NOT NULL, -- Actor, Director, Writer, Producer, Composer, etc. role TEXT, -- Character name for actors sort_order INTEGER DEFAULT 0, synced_at TEXT DEFAULT CURRENT_TIMESTAMP, UNIQUE(item_id, person_id, person_type) ); -- Indexes for efficient queries CREATE INDEX IF NOT EXISTS idx_people_server ON people(server_id); CREATE INDEX IF NOT EXISTS idx_people_name ON people(name); CREATE INDEX IF NOT EXISTS idx_item_people_item ON item_people(item_id); CREATE INDEX IF NOT EXISTS idx_item_people_person ON item_people(person_id); CREATE INDEX IF NOT EXISTS idx_item_people_type ON item_people(person_type); "#; /// Migration to add playback context tracking /// - Adds playback_context_type column to track if user played a container or single item /// - Adds playback_context_id column to store the container ID (album/playlist) /// - Adds index for efficient recently played queries const MIGRATION_010: &str = r#" -- Add playback context tracking to user_data -- Tracks whether user played a container (album/playlist) or single item ALTER TABLE user_data ADD COLUMN playback_context_type TEXT; ALTER TABLE user_data ADD COLUMN playback_context_id TEXT; -- Index for efficient recently played queries CREATE INDEX IF NOT EXISTS idx_user_data_last_played ON user_data(user_id, last_played_at DESC) WHERE last_played_at IS NOT NULL; "#; /// Migration to add user-specific player settings /// - Creates user_player_settings table for autoplay and audio preferences /// - Note: Sleep timer state is NOT persisted (cancelled on app close) /// - Autoplay settings control next episode behavior /// - Audio settings for crossfade, gapless playback, and volume normalization const MIGRATION_011: &str = r#" -- User-specific player settings (autoplay and audio settings) -- Sleep timer is NOT persisted here (maintained in-memory only) CREATE TABLE IF NOT EXISTS user_player_settings ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, -- Autoplay settings autoplay_next_episode INTEGER DEFAULT 1, -- 1 = enabled, 0 = disabled autoplay_countdown_seconds INTEGER DEFAULT 10, -- 5-30 seconds -- Audio settings (crossfade, normalization) crossfade_duration REAL DEFAULT 0.0, -- 0-12 seconds gapless_playback INTEGER DEFAULT 1, normalize_volume INTEGER DEFAULT 0, volume_level TEXT DEFAULT 'normal', -- 'loud', 'normal', 'quiet' updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); -- Index for efficient user settings lookup CREATE INDEX IF NOT EXISTS idx_user_player_settings_user ON user_player_settings(user_id); "#; /// Migration to track download source (user-initiated vs auto-cached) /// - Adds download_source column to distinguish manual downloads from auto-caching /// - Enables color-coded UI display const MIGRATION_012: &str = r#" -- Add download source tracking -- Values: 'user' (explicit download), 'auto' (smart cache/queue precache) ALTER TABLE downloads ADD COLUMN download_source TEXT DEFAULT 'user'; -- Index for filtering by source CREATE INDEX IF NOT EXISTS idx_downloads_source ON downloads(download_source); "#; /// Migration to add composite index for offline mode filtering /// - Adds index on (item_id, status) for efficient JOIN queries in OfflineRepository /// - Significantly improves performance when filtering items by download status const MIGRATION_013: &str = r#" -- Add composite index for offline mode filtering -- This speeds up queries that join items with downloads to show only downloaded content CREATE INDEX IF NOT EXISTS idx_downloads_item_status ON downloads(item_id, status); "#; /// Migration to add series audio track preferences /// - Stores user's preferred audio track per series /// - Matches tracks by display title and language across episodes /// - Falls back to default track if preferred track not found const MIGRATION_014: &str = r#" -- Series-specific audio track preferences -- When user changes audio track for an episode, remember preference for the series CREATE TABLE IF NOT EXISTS series_audio_preferences ( user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, series_id TEXT NOT NULL, server_id TEXT NOT NULL, -- Audio track info for matching across episodes audio_track_display_title TEXT, audio_track_language TEXT, audio_track_index INTEGER, updated_at TEXT DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, series_id, server_id) ); -- Index for efficient lookups CREATE INDEX IF NOT EXISTS idx_series_audio_prefs_user_series ON series_audio_preferences(user_id, series_id); "#; /// Migration to add device ID storage /// - Creates app_settings table for app-wide configuration (device ID, etc.) /// - Device ID is generated once and persisted for Jellyfin server identification const MIGRATION_015: &str = r#" -- App-wide settings table for device ID and other app-level configuration -- Device ID is a unique identifier for this app installation -- Required for Jellyfin server communication and session tracking CREATE TABLE IF NOT EXISTS app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); -- Create index for efficient lookups (though key is already primary key) CREATE INDEX IF NOT EXISTS idx_app_settings_key ON app_settings(key); "#; /// Migration to add autoplay episode limit setting /// - Adds autoplay_max_episodes column to user_player_settings /// - 0 = unlimited (default), any positive value limits consecutive auto-plays const MIGRATION_016: &str = r#" ALTER TABLE user_player_settings ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0; "#; /// Migration to persist the resolved stream URL and target directory on each /// download row. This lets the backend queue pump start a pending download by /// itself (replaying the stored URL) once a concurrency slot frees up, instead /// of relying on the frontend to re-issue every queued item. const MIGRATION_017: &str = r#" -- Resolved download source URL and on-disk target directory, captured when the -- download is enqueued. Nullable: pre-existing rows and rows enqueued without a -- URL simply won't be auto-started by the pump. ALTER TABLE downloads ADD COLUMN stream_url TEXT; ALTER TABLE downloads ADD COLUMN target_dir TEXT; "#; /// Migration to record whether a cached item is a folder/container vs a playable /// leaf. Needed so channel items (which can be either) route to the player or to /// a browse list correctly. Existing cached rows predate the column and have an /// unknown folder flag, so we force a refresh by clearing their `synced_at`, /// causing the hybrid repository to re-fetch them from the server on next browse. const MIGRATION_018: &str = r#" ALTER TABLE items ADD COLUMN is_folder INTEGER DEFAULT 0; -- Force re-fetch of all cached items so is_folder is populated from the server. UPDATE items SET synced_at = NULL; "#; /// Migration to cache the full server genre catalog. Previously genres were /// derived on the fly from cached albums, which meant offline (and the hybrid /// cache-first race) only ever saw genres for the handful of locally-cached /// albums — collapsing the variety on the music/TV/movie landing pages. This /// table stores the complete genre list per library so offline has the real /// catalog and cache-first routing returns the right thing. const MIGRATION_019: &str = r#" CREATE TABLE IF NOT EXISTS genres ( id TEXT NOT NULL, server_id TEXT NOT NULL, library_id TEXT, name TEXT NOT NULL, album_count INTEGER, synced_at TEXT DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (server_id, library_id, name) ); CREATE INDEX IF NOT EXISTS idx_genres_scope ON genres(server_id, library_id); "#; /// Migration to index `items.season_id`. /// /// Episodes link to their season via `season_id` (parent_id is NULL in the /// cache). The container-rollup queries used by the Downloaded browse and the /// disk-usage aggregation join `children.season_id = c.id`, which without this /// index degrades to an unindexable scan — a large synced catalog then makes /// the Downloaded page hang ("Loading your downloads…"). `parent_id`, /// `album_id`, and `series_id` were already indexed; this closes the gap. const MIGRATION_020: &str = r#" CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id); "#; /// Discard and rebuild the FTS index from the `items` table. /// /// Until DR-110, `save_to_cache` used `INSERT OR REPLACE INTO items`. REPLACE /// deletes the conflicting row and inserts a new one, but SQLite only fires /// `AFTER DELETE` triggers on that implicit delete when `recursive_triggers` is /// enabled — it is not (storage/mod.rs sets only `foreign_keys` and /// `journal_mode`), so `items_ad` never ran and the old index row was orphaned. /// Worse, `items.id` is a `TEXT PRIMARY KEY`, so the replacement row also took a /// *fresh rowid* and `items_ai` appended a second entry. Every catalog pass /// therefore left another duplicate behind, and existing installs carry one /// stale entry per item per sync since the database was created. /// /// This was invisible in results — the `JOIN items_fts fts ON fts.rowid = /// i.rowid` drops rowids that no longer exist — but it degrades `MATCH` /// permanently, and it becomes a *correctness* problem the moment rowids are /// freed and reused: a new item landing on a freed rowid inherits the orphan's /// index entry and matches queries for the deleted item's title. The DR-110 /// deletion sweep frees rowids, so this rebuild must run before it. /// /// `'rebuild'` is the FTS5 command for exactly this: it truncates the index and /// repopulates it from the external content table. /// /// TRACES: UR-065 | DR-110 const MIGRATION_021: &str = r#" INSERT INTO items_fts(items_fts) VALUES('rebuild'); "#; /// Full-text index over `people`, mirroring `items_fts`. /// /// People live in their own table (migration 009) rather than in `items`, and /// had no FTS index at all — so the People group UR-060 requires could only ever /// be filled by the server leg of search. With the local index now answering /// first, an actor's name has to be findable offline too. /// /// TRACES: UR-065, UR-060 | DR-111 const MIGRATION_022: &str = r#" CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5( name, overview, content='people', content_rowid='rowid' ); CREATE TRIGGER IF NOT EXISTS people_ai AFTER INSERT ON people BEGIN INSERT INTO people_fts(rowid, name, overview) VALUES (new.rowid, new.name, new.overview); END; CREATE TRIGGER IF NOT EXISTS people_ad AFTER DELETE ON people BEGIN INSERT INTO people_fts(people_fts, rowid, name, overview) VALUES('delete', old.rowid, old.name, old.overview); END; CREATE TRIGGER IF NOT EXISTS people_au AFTER UPDATE ON people BEGIN INSERT INTO people_fts(people_fts, rowid, name, overview) VALUES('delete', old.rowid, old.name, old.overview); INSERT INTO people_fts(rowid, name, overview) VALUES (new.rowid, new.name, new.overview); END; -- Backfill for rows cached before this index existed. INSERT INTO people_fts(people_fts) VALUES('rebuild'); "#; /// Give temporary downloads a life limit. /// /// A cache entry is not a different kind of object from a download — it is a /// download with a shorter life. Modelling it as one `downloads` row with an /// expiry (rather than a parallel cache store) means there is a single storage /// accounting, a single eviction path, and no way for a cache and a download /// library to disagree about what is on disk. /// /// `expires_at` is NULL for permanent rows, which is every row that exists /// today: `download_source` defaults to `'user'`, and a user's own download /// never expires. Only `'auto'` rows get a timestamp, and they are reclaimed by /// whichever comes first — the expiry passing, or LRU eviction under space /// pressure (DR-126). /// /// TRACES: UR-071 | DR-127 const MIGRATION_023: &str = r#" ALTER TABLE downloads ADD COLUMN expires_at TEXT; -- Reclaim scans filter on expiry among 'auto' rows; index both so the sweep -- stays cheap as the cache tier grows. CREATE INDEX IF NOT EXISTS idx_downloads_expiry ON downloads(download_source, expires_at); "#; /// Multi-user profiles: PIN gate, per-user cache visibility, and download grants. /// /// Three tables, one purpose each: /// /// - `user_pins` holds the switching gate. The PIN hash lives here rather than /// wrapping the access token, because a wrapped token would leave a locked /// profile unable to resume its own downloads or drain its own sync queue /// until someone typed the code. See DR-268 for why that trade was taken. /// - `user_item_visibility` records what the server has actually shown to each /// user. It is written as a byproduct of the cache write path, never rebuilt, /// so it cannot disagree with what the server returned. /// - `download_grants` separates the bytes from the claim on them, so one file /// can serve several profiles and is unlinked only when the last claim goes. /// /// The backfill is not optional. Every existing cache row and download predates /// the concept of a user; without it an upgrading install's library goes blank. /// It grants the *active* user only — other pre-existing rows re-populate from /// the server on next browse, which is strictly safer than handing every /// profile the whole cache. The `OR (SELECT COUNT(*) ...) = 0` arm covers an /// install whose single user somehow has `is_active = 0`. /// /// TRACES: UR-082, UR-083 | DR-268, DR-271, DR-272 const MIGRATION_024: &str = r#" CREATE TABLE IF NOT EXISTS user_pins ( user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, pin_hash TEXT NOT NULL, failed_count INTEGER NOT NULL DEFAULT 0, locked_until TEXT, updated_at TEXT DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS user_item_visibility ( user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, item_id TEXT NOT NULL, seen_at TEXT DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, item_id) ); CREATE INDEX IF NOT EXISTS idx_visibility_user ON user_item_visibility(user_id); CREATE TABLE IF NOT EXISTS user_libraries ( user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, library_id TEXT NOT NULL, seen_at TEXT DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, library_id) ); CREATE TABLE IF NOT EXISTS download_grants ( user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE, granted_at TEXT DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id, download_id) ); CREATE INDEX IF NOT EXISTS idx_download_grants_download ON download_grants(download_id); -- Backfill: the active user has seen everything already cached on this device. INSERT OR IGNORE INTO user_item_visibility (user_id, item_id) SELECT u.id, i.id FROM users u CROSS JOIN items i WHERE u.is_active = 1 OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0; INSERT OR IGNORE INTO user_libraries (user_id, library_id) SELECT u.id, l.id FROM users u CROSS JOIN libraries l WHERE u.is_active = 1 OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0; -- Downloads already record who asked for them, so every existing row becomes -- exactly one grant held by its original requester. INSERT OR IGNORE INTO download_grants (user_id, download_id) SELECT d.user_id, d.id FROM downloads d; "#; #[cfg(test)] mod migration_024_tests { use super::*; use rusqlite::{params, Connection}; /// Build a database at the schema version *before* multi-user profiles, so /// the backfill is exercised against rows that predate it — which is the /// only state that matters, and the one an in-memory database created from /// the full migration list can never reproduce. fn pre_024_db() -> Connection { let conn = Connection::open_in_memory().unwrap(); let upto = MIGRATIONS .iter() .position(|(name, _)| *name == "024_multi_user_profiles") .expect("migration 024 must be registered"); for (_, sql) in &MIGRATIONS[..upto] { conn.execute_batch(sql).unwrap(); } conn } fn seed(conn: &Connection, active_user: &str) { conn.execute( "INSERT INTO servers (id, name, url) VALUES ('s1', 'Test', 'http://localhost:8096')", [], ) .unwrap(); conn.execute( "INSERT INTO users (id, server_id, username, is_active) VALUES (?1, 's1', 'dad', 1)", params![active_user], ) .unwrap(); conn.execute( "INSERT INTO libraries (id, server_id, name) VALUES ('lib1', 's1', 'Movies')", [], ) .unwrap(); conn.execute( "INSERT INTO items (id, server_id, name, item_type) VALUES ('i1', 's1', 'A Movie', 'Movie')", [], ) .unwrap(); conn.execute( "INSERT INTO downloads (item_id, user_id, file_path, status) VALUES ('i1', ?1, 'downloads/a.mp4', 'completed')", params![active_user], ) .unwrap(); } fn apply_024(conn: &Connection) { let (_, sql) = MIGRATIONS .iter() .find(|(name, _)| *name == "024_multi_user_profiles") .unwrap(); conn.execute_batch(sql).unwrap(); } fn count(conn: &Connection, sql: &str) -> i64 { conn.query_row(sql, [], |r| r.get(0)).unwrap() } /// UT: upgrading an existing install does not blank its library. Without the /// backfill every cached item becomes invisible to the only user there is. #[test] fn backfill_keeps_the_existing_library_visible() { let conn = pre_024_db(); seed(&conn, "dad"); apply_024(&conn); assert_eq!( count( &conn, "SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad' AND item_id = 'i1'" ), 1, "the active user must still see what was already cached" ); assert_eq!( count( &conn, "SELECT COUNT(*) FROM user_libraries WHERE user_id = 'dad' AND library_id = 'lib1'" ), 1 ); } /// UT: an existing download becomes exactly one grant, held by whoever asked /// for it — the file is not orphaned and is not handed to anyone else. #[test] fn backfill_grants_downloads_to_their_requester() { let conn = pre_024_db(); seed(&conn, "dad"); apply_024(&conn); assert_eq!( count( &conn, "SELECT COUNT(*) FROM download_grants WHERE user_id = 'dad'" ), 1 ); } /// UT: a second profile added later starts with an empty view. The cache was /// filled by someone else's browsing and the server never showed it to them, /// so inheriting it is the leak this whole table exists to close. #[test] fn a_later_profile_inherits_nothing() { let conn = pre_024_db(); seed(&conn, "dad"); apply_024(&conn); conn.execute( "INSERT INTO users (id, server_id, username, is_active) VALUES ('kid', 's1', 'kid', 0)", [], ) .unwrap(); assert_eq!( count( &conn, "SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'kid'" ), 0, "a profile added after the upgrade must not inherit another's cache" ); assert_eq!( count( &conn, "SELECT COUNT(*) FROM download_grants WHERE user_id = 'kid'" ), 0 ); } /// UT: an install whose sole user somehow has `is_active = 0` still gets its /// library back — the fallback arm of the backfill. #[test] fn backfill_covers_an_install_with_no_active_flag() { let conn = pre_024_db(); seed(&conn, "dad"); conn.execute("UPDATE users SET is_active = 0", []).unwrap(); apply_024(&conn); assert_eq!( count( &conn, "SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad'" ), 1 ); } /// UT: removing a profile takes its per-user rows with it, so a re-added /// account starts clean rather than resuming someone's stale view. #[test] fn removing_a_profile_cascades_its_rows() { let conn = pre_024_db(); seed(&conn, "dad"); apply_024(&conn); conn.execute("PRAGMA foreign_keys = ON", []).unwrap(); conn.execute("DELETE FROM users WHERE id = 'dad'", []) .unwrap(); assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_item_visibility"), 0); assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_libraries"), 0); assert_eq!(count(&conn, "SELECT COUNT(*) FROM download_grants"), 0); } }