Skip to main content

jellytau_lib/storage/
schema.rs

1//! Database schema and migrations
2//!
3//! TRACES: UR-002 | DR-012 | IR-013
4
5/// List of migrations to apply in order.
6/// Each migration is a tuple of (name, sql).
7pub const MIGRATIONS: &[(&str, &str)] = &[
8    ("001_initial_schema", MIGRATION_001),
9    ("002_remove_access_token", MIGRATION_002),
10    ("003_relax_user_data_constraints", MIGRATION_003),
11    ("004_enhance_downloads", MIGRATION_004),
12    ("005_relax_downloads_fk", MIGRATION_005),
13    ("006_downloads_metadata", MIGRATION_006),
14    ("007_cache_metadata", MIGRATION_007),
15    ("008_video_downloads", MIGRATION_008),
16    ("009_people_tables", MIGRATION_009),
17    ("010_playback_context", MIGRATION_010),
18    ("011_user_player_settings", MIGRATION_011),
19    ("012_download_source", MIGRATION_012),
20    ("013_downloads_item_status_index", MIGRATION_013),
21    ("014_series_audio_preferences", MIGRATION_014),
22    ("015_device_id", MIGRATION_015),
23    ("016_autoplay_max_episodes", MIGRATION_016),
24    ("017_downloads_resume_url", MIGRATION_017),
25    ("018_items_is_folder", MIGRATION_018),
26    ("019_genres_cache", MIGRATION_019),
27    ("020_items_season_index", MIGRATION_020),
28    ("021_rebuild_items_fts", MIGRATION_021),
29    ("022_people_fts", MIGRATION_022),
30    ("023_downloads_expiry", MIGRATION_023),
31    ("024_multi_user_profiles", MIGRATION_024),
32    ("025_backfill_item_library_id", MIGRATION_025),
33    ("026_server_catalog_generation", MIGRATION_026),
34];
35
36/// Initial schema migration
37const MIGRATION_001: &str = r#"
38-- Jellyfin servers the user has connected to
39CREATE TABLE IF NOT EXISTS servers (
40    id TEXT PRIMARY KEY,
41    name TEXT NOT NULL,
42    url TEXT NOT NULL UNIQUE,
43    version TEXT,
44    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
45    last_connected_at TEXT
46);
47
48-- User accounts on Jellyfin servers
49CREATE TABLE IF NOT EXISTS users (
50    id TEXT PRIMARY KEY,
51    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
52    username TEXT NOT NULL,
53    access_token TEXT,
54    is_active INTEGER DEFAULT 0,
55    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
56    last_login_at TEXT,
57    UNIQUE(server_id, username)
58);
59
60-- Libraries/views from Jellyfin
61CREATE TABLE IF NOT EXISTS libraries (
62    id TEXT PRIMARY KEY,
63    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
64    name TEXT NOT NULL,
65    collection_type TEXT,
66    image_tag TEXT,
67    sort_order INTEGER DEFAULT 0,
68    synced_at TEXT,
69    UNIQUE(server_id, id)
70);
71
72-- Media items (movies, shows, episodes, albums, songs, artists)
73CREATE TABLE IF NOT EXISTS items (
74    id TEXT PRIMARY KEY,
75    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
76    library_id TEXT REFERENCES libraries(id) ON DELETE SET NULL,
77    parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
78
79    -- Core metadata
80    name TEXT NOT NULL,
81    sort_name TEXT,
82    original_title TEXT,
83    item_type TEXT NOT NULL,  -- Movie, Series, Episode, MusicAlbum, Audio, MusicArtist, etc.
84
85    -- Media info
86    overview TEXT,
87    tagline TEXT,
88    genres TEXT,  -- JSON array
89    tags TEXT,    -- JSON array
90    studios TEXT, -- JSON array
91
92    -- For episodes
93    series_id TEXT,
94    series_name TEXT,
95    season_id TEXT,
96    season_name TEXT,
97    index_number INTEGER,        -- Episode number
98    parent_index_number INTEGER, -- Season number
99
100    -- For music
101    album_id TEXT,
102    album_name TEXT,
103    album_artist TEXT,
104    artists TEXT,  -- JSON array
105
106    -- Dates
107    premiere_date TEXT,
108    production_year INTEGER,
109    date_created TEXT,
110
111    -- Runtime (ticks)
112    runtime_ticks INTEGER,
113
114    -- Images
115    primary_image_tag TEXT,
116    backdrop_image_tags TEXT,  -- JSON array
117
118    -- Ratings
119    community_rating REAL,
120    official_rating TEXT,
121
122    -- Sync metadata
123    synced_at TEXT,
124    etag TEXT,
125
126    UNIQUE(server_id, id)
127);
128
129-- Full-text search index for items
130CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
131    name,
132    overview,
133    album_name,
134    album_artist,
135    artists,
136    series_name,
137    content='items',
138    content_rowid='rowid'
139);
140
141-- Triggers to keep FTS index in sync
142CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN
143    INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
144    VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
145END;
146
147CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN
148    INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
149    VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
150END;
151
152CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN
153    INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
154    VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
155    INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
156    VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
157END;
158
159-- Media streams (audio/subtitle tracks)
160CREATE TABLE IF NOT EXISTS media_streams (
161    id INTEGER PRIMARY KEY AUTOINCREMENT,
162    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
163    stream_index INTEGER NOT NULL,
164    stream_type TEXT NOT NULL,  -- Audio, Subtitle, Video
165    codec TEXT,
166    language TEXT,
167    display_title TEXT,
168    is_default INTEGER DEFAULT 0,
169    is_forced INTEGER DEFAULT 0,
170    is_external INTEGER DEFAULT 0,
171    path TEXT,  -- For external subtitles
172    UNIQUE(item_id, stream_index)
173);
174
175-- User-specific data (watch progress, favorites)
176CREATE TABLE IF NOT EXISTS user_data (
177    id INTEGER PRIMARY KEY AUTOINCREMENT,
178    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
179    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
180
181    -- Playback state
182    playback_position_ticks INTEGER DEFAULT 0,
183    play_count INTEGER DEFAULT 0,
184    is_played INTEGER DEFAULT 0,
185    is_favorite INTEGER DEFAULT 0,
186
187    -- Timestamps
188    last_played_at TEXT,
189
190    -- Sync status
191    synced_at TEXT,
192    pending_sync INTEGER DEFAULT 0,  -- 1 if local changes need sync
193
194    UNIQUE(user_id, item_id)
195);
196
197-- Downloaded media files
198CREATE TABLE IF NOT EXISTS downloads (
199    id INTEGER PRIMARY KEY AUTOINCREMENT,
200    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
201    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
202
203    -- File info
204    file_path TEXT NOT NULL,
205    file_size INTEGER,
206    mime_type TEXT,
207
208    -- Download state
209    status TEXT DEFAULT 'pending',  -- pending, downloading, completed, failed, paused
210    progress REAL DEFAULT 0,        -- 0.0 to 1.0
211
212    -- Transcoding options used
213    bitrate INTEGER,
214    container TEXT,
215
216    -- Timestamps
217    queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
218    started_at TEXT,
219    completed_at TEXT,
220
221    -- Error tracking
222    error_message TEXT,
223    retry_count INTEGER DEFAULT 0,
224
225    UNIQUE(item_id, user_id)
226);
227
228-- Offline mutation queue (changes to sync back to server)
229CREATE TABLE IF NOT EXISTS sync_queue (
230    id INTEGER PRIMARY KEY AUTOINCREMENT,
231    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
232
233    -- Operation details
234    operation TEXT NOT NULL,  -- mark_played, mark_favorite, update_progress, etc.
235    item_id TEXT,
236    payload TEXT,  -- JSON data for the operation
237
238    -- Queue state
239    status TEXT DEFAULT 'pending',  -- pending, processing, completed, failed
240    retry_count INTEGER DEFAULT 0,
241
242    -- Timestamps
243    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
244    processed_at TEXT,
245
246    -- Error tracking
247    error_message TEXT
248);
249
250-- Cached thumbnails
251CREATE TABLE IF NOT EXISTS thumbnails (
252    id INTEGER PRIMARY KEY AUTOINCREMENT,
253    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
254    image_type TEXT NOT NULL,  -- Primary, Backdrop, Thumb, Logo, etc.
255    image_tag TEXT NOT NULL,
256    file_path TEXT NOT NULL,
257    width INTEGER,
258    height INTEGER,
259    cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
260    UNIQUE(item_id, image_type, image_tag)
261);
262
263-- User playlists (local + synced)
264CREATE TABLE IF NOT EXISTS playlists (
265    id TEXT PRIMARY KEY,
266    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
267    name TEXT NOT NULL,
268    is_local INTEGER DEFAULT 0,  -- 1 for local-only playlists
269    jellyfin_id TEXT,            -- NULL for local-only
270    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
271    updated_at TEXT
272);
273
274-- Playlist items
275CREATE TABLE IF NOT EXISTS playlist_items (
276    id INTEGER PRIMARY KEY AUTOINCREMENT,
277    playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
278    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
279    sort_order INTEGER NOT NULL,
280    added_at TEXT DEFAULT CURRENT_TIMESTAMP,
281    UNIQUE(playlist_id, item_id)
282);
283
284-- Indexes for common queries
285CREATE INDEX IF NOT EXISTS idx_items_server ON items(server_id);
286CREATE INDEX IF NOT EXISTS idx_items_library ON items(library_id);
287CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id);
288CREATE INDEX IF NOT EXISTS idx_items_type ON items(item_type);
289CREATE INDEX IF NOT EXISTS idx_items_album ON items(album_id);
290CREATE INDEX IF NOT EXISTS idx_items_series ON items(series_id);
291CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
292CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
293CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
294CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
295CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(status);
296CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
297"#;
298
299/// Migration to remove access_token column from users table
300/// Tokens are now stored in the system keyring (or encrypted file fallback)
301const MIGRATION_002: &str = r#"
302-- Remove access_token column from users table
303-- Tokens are now stored in secure storage (system keyring)
304
305-- SQLite doesn't support DROP COLUMN in older versions, so we recreate the table
306CREATE TABLE IF NOT EXISTS users_new (
307    id TEXT PRIMARY KEY,
308    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
309    username TEXT NOT NULL,
310    is_active INTEGER DEFAULT 0,
311    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
312    last_login_at TEXT,
313    UNIQUE(server_id, username)
314);
315
316-- Copy existing data (excluding access_token)
317INSERT OR IGNORE INTO users_new (id, server_id, username, is_active, created_at, last_login_at)
318SELECT id, server_id, username, is_active, created_at, last_login_at FROM users;
319
320-- Drop old table and rename new one
321DROP TABLE IF EXISTS users;
322ALTER TABLE users_new RENAME TO users;
323"#;
324
325/// Migration to relax foreign key constraints on user_data table
326/// Allows tracking playback progress for items not yet synced to local database
327const MIGRATION_003: &str = r#"
328-- Recreate user_data table without foreign key constraint on item_id
329-- This allows tracking playback progress for items that haven't been synced locally yet
330
331CREATE TABLE IF NOT EXISTS user_data_new (
332    id INTEGER PRIMARY KEY AUTOINCREMENT,
333    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
334    item_id TEXT NOT NULL,  -- No foreign key constraint - item may not be synced yet
335
336    -- Playback state
337    playback_position_ticks INTEGER DEFAULT 0,
338    play_count INTEGER DEFAULT 0,
339    is_played INTEGER DEFAULT 0,
340    is_favorite INTEGER DEFAULT 0,
341
342    -- Timestamps
343    last_played_at TEXT,
344
345    -- Sync status
346    synced_at TEXT,
347    pending_sync INTEGER DEFAULT 0,  -- 1 if local changes need sync
348
349    UNIQUE(user_id, item_id)
350);
351
352-- Copy existing data
353INSERT 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)
354SELECT id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync FROM user_data;
355
356-- Drop old table and rename new one
357DROP TABLE IF EXISTS user_data;
358ALTER TABLE user_data_new RENAME TO user_data;
359
360-- Recreate index
361CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
362CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
363"#;
364
365/// Migration to enhance downloads table with priority and bytes_downloaded
366const MIGRATION_004: &str = r#"
367-- Add priority column for queue ordering
368ALTER TABLE downloads ADD COLUMN priority INTEGER DEFAULT 0;
369
370-- Add bytes_downloaded for resume support
371ALTER TABLE downloads ADD COLUMN bytes_downloaded INTEGER DEFAULT 0;
372
373-- Create index for efficient queue processing (priority DESC, FIFO within same priority)
374CREATE INDEX IF NOT EXISTS idx_downloads_queue
375  ON downloads(status, priority DESC, queued_at ASC)
376  WHERE status IN ('pending', 'downloading');
377"#;
378
379/// Migration to relax foreign key constraint on downloads.item_id
380/// Allows downloading items that haven't been synced to local database yet
381const MIGRATION_005: &str = r#"
382-- Recreate downloads table without foreign key constraint on item_id
383-- This allows downloading items that haven't been synced locally yet
384
385CREATE TABLE IF NOT EXISTS downloads_new (
386    id INTEGER PRIMARY KEY AUTOINCREMENT,
387    item_id TEXT NOT NULL,  -- No foreign key constraint - item may not be synced yet
388    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
389
390    -- File info
391    file_path TEXT NOT NULL,
392    file_size INTEGER,
393    mime_type TEXT,
394
395    -- Download state
396    status TEXT DEFAULT 'pending',  -- pending, downloading, completed, failed, paused
397    progress REAL DEFAULT 0,        -- 0.0 to 1.0
398
399    -- Transcoding options used
400    bitrate INTEGER,
401    container TEXT,
402
403    -- Timestamps
404    queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
405    started_at TEXT,
406    completed_at TEXT,
407
408    -- Error tracking
409    error_message TEXT,
410    retry_count INTEGER DEFAULT 0,
411
412    -- Priority and progress tracking (from migration 004)
413    priority INTEGER DEFAULT 0,
414    bytes_downloaded INTEGER DEFAULT 0,
415
416    UNIQUE(item_id, user_id)
417);
418
419-- Copy existing data
420INSERT OR IGNORE INTO downloads_new (
421    id, item_id, user_id, file_path, file_size, mime_type, status, progress,
422    bitrate, container, queued_at, started_at, completed_at, error_message,
423    retry_count, priority, bytes_downloaded
424)
425SELECT
426    id, item_id, user_id, file_path, file_size, mime_type, status, progress,
427    bitrate, container, queued_at, started_at, completed_at, error_message,
428    retry_count, priority, bytes_downloaded
429FROM downloads;
430
431-- Drop old table and rename new one
432DROP TABLE IF EXISTS downloads;
433ALTER TABLE downloads_new RENAME TO downloads;
434
435-- Recreate indexes
436CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
437CREATE INDEX IF NOT EXISTS idx_downloads_queue
438  ON downloads(status, priority DESC, queued_at ASC)
439  WHERE status IN ('pending', 'downloading');
440"#;
441
442/// Migration to store item metadata directly in downloads table
443/// This eliminates dependency on items table being synced and fixes UUID display issues
444const MIGRATION_006: &str = r#"
445-- Add columns to store item metadata directly in downloads
446-- This ensures correct display even when items aren't synced locally
447ALTER TABLE downloads ADD COLUMN item_name TEXT;
448ALTER TABLE downloads ADD COLUMN artist_name TEXT;
449ALTER TABLE downloads ADD COLUMN album_name TEXT;
450"#;
451
452/// Migration to enhance thumbnail caching with LRU eviction support
453/// - Relaxes foreign key constraint on item_id (allows caching for items not yet synced)
454/// - Adds last_accessed for LRU eviction
455/// - Adds file_size for cache limit tracking
456/// - Creates cache_settings table for configurable limits
457const MIGRATION_007: &str = r#"
458-- Recreate thumbnails table without foreign key constraint on item_id
459-- and add LRU eviction support columns
460CREATE TABLE IF NOT EXISTS thumbnails_new (
461    id INTEGER PRIMARY KEY AUTOINCREMENT,
462    item_id TEXT NOT NULL,  -- No foreign key constraint - item may not be synced yet
463    image_type TEXT NOT NULL,  -- Primary, Backdrop, Thumb, Logo, etc.
464    image_tag TEXT NOT NULL,
465    file_path TEXT NOT NULL,
466    width INTEGER,
467    height INTEGER,
468    file_size INTEGER DEFAULT 0,  -- Size in bytes for cache limit tracking
469    cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
470    last_accessed TEXT DEFAULT CURRENT_TIMESTAMP,  -- For LRU eviction
471    UNIQUE(item_id, image_type, image_tag)
472);
473
474-- Copy existing data (if any)
475INSERT OR IGNORE INTO thumbnails_new (id, item_id, image_type, image_tag, file_path, width, height, cached_at)
476SELECT id, item_id, image_type, image_tag, file_path, width, height, cached_at FROM thumbnails;
477
478-- Drop old table and rename new one
479DROP TABLE IF EXISTS thumbnails;
480ALTER TABLE thumbnails_new RENAME TO thumbnails;
481
482-- Create indexes for efficient queries
483CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
484CREATE INDEX IF NOT EXISTS idx_thumbnails_lru ON thumbnails(last_accessed ASC);
485
486-- Cache settings table for configurable limits
487CREATE TABLE IF NOT EXISTS cache_settings (
488    key TEXT PRIMARY KEY,
489    value TEXT NOT NULL,
490    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
491);
492
493-- Insert default settings
494INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_limit_bytes', '1073741824');  -- 1GB default
495INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_enabled', 'true');
496"#;
497
498/// Migration to add video download support and item pinning
499/// - Adds video-specific metadata columns to downloads (series/episode info, quality preset)
500/// - Adds media_type to distinguish audio vs video downloads
501/// - Adds is_pinned column to items table for protecting metadata from cache clear
502const MIGRATION_008: &str = r#"
503-- Add video-specific metadata columns to downloads
504ALTER TABLE downloads ADD COLUMN series_name TEXT;
505ALTER TABLE downloads ADD COLUMN season_name TEXT;
506ALTER TABLE downloads ADD COLUMN episode_number INTEGER;
507ALTER TABLE downloads ADD COLUMN season_number INTEGER;
508ALTER TABLE downloads ADD COLUMN quality_preset TEXT DEFAULT 'original';
509ALTER TABLE downloads ADD COLUMN media_type TEXT DEFAULT 'audio';
510
511-- Add pinning support to items table
512-- Pinned items are protected from cache clear operations
513ALTER TABLE items ADD COLUMN is_pinned INTEGER DEFAULT 0;
514
515-- Index for efficiently finding pinned items
516CREATE INDEX IF NOT EXISTS idx_items_pinned ON items(is_pinned) WHERE is_pinned = 1;
517
518-- Index for efficiently querying downloads by series
519CREATE INDEX IF NOT EXISTS idx_downloads_series ON downloads(series_name) WHERE series_name IS NOT NULL;
520
521-- Index for filtering by media type
522CREATE INDEX IF NOT EXISTS idx_downloads_media_type ON downloads(media_type);
523"#;
524
525/// Migration to add people/cast caching support
526/// - Creates people table for caching actor/director/writer/etc info
527/// - Creates item_people junction table for many-to-many relationships
528/// - Adds indexes for efficient queries
529const MIGRATION_009: &str = r#"
530-- People table for caching cast/crew members
531CREATE TABLE IF NOT EXISTS people (
532    id TEXT PRIMARY KEY,
533    server_id TEXT NOT NULL,
534    name TEXT NOT NULL,
535    overview TEXT,
536    primary_image_tag TEXT,
537    premiere_date TEXT,      -- Birth date
538    end_date TEXT,           -- Death date
539    synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
540    UNIQUE(server_id, id)
541);
542
543-- Item-Person association table (many-to-many)
544-- Stores which people appear in which items, along with role info
545CREATE TABLE IF NOT EXISTS item_people (
546    id INTEGER PRIMARY KEY AUTOINCREMENT,
547    item_id TEXT NOT NULL,
548    person_id TEXT NOT NULL,
549    server_id TEXT NOT NULL,
550    person_type TEXT NOT NULL,  -- Actor, Director, Writer, Producer, Composer, etc.
551    role TEXT,                   -- Character name for actors
552    sort_order INTEGER DEFAULT 0,
553    synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
554    UNIQUE(item_id, person_id, person_type)
555);
556
557-- Indexes for efficient queries
558CREATE INDEX IF NOT EXISTS idx_people_server ON people(server_id);
559CREATE INDEX IF NOT EXISTS idx_people_name ON people(name);
560CREATE INDEX IF NOT EXISTS idx_item_people_item ON item_people(item_id);
561CREATE INDEX IF NOT EXISTS idx_item_people_person ON item_people(person_id);
562CREATE INDEX IF NOT EXISTS idx_item_people_type ON item_people(person_type);
563"#;
564
565/// Migration to add playback context tracking
566/// - Adds playback_context_type column to track if user played a container or single item
567/// - Adds playback_context_id column to store the container ID (album/playlist)
568/// - Adds index for efficient recently played queries
569const MIGRATION_010: &str = r#"
570-- Add playback context tracking to user_data
571-- Tracks whether user played a container (album/playlist) or single item
572ALTER TABLE user_data ADD COLUMN playback_context_type TEXT;
573ALTER TABLE user_data ADD COLUMN playback_context_id TEXT;
574
575-- Index for efficient recently played queries
576CREATE INDEX IF NOT EXISTS idx_user_data_last_played
577  ON user_data(user_id, last_played_at DESC)
578  WHERE last_played_at IS NOT NULL;
579"#;
580
581/// Migration to add user-specific player settings
582/// - Creates user_player_settings table for autoplay and audio preferences
583/// - Note: Sleep timer state is NOT persisted (cancelled on app close)
584/// - Autoplay settings control next episode behavior
585/// - Audio settings for crossfade, gapless playback, and volume normalization
586const MIGRATION_011: &str = r#"
587-- User-specific player settings (autoplay and audio settings)
588-- Sleep timer is NOT persisted here (maintained in-memory only)
589CREATE TABLE IF NOT EXISTS user_player_settings (
590    user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
591
592    -- Autoplay settings
593    autoplay_next_episode INTEGER DEFAULT 1,  -- 1 = enabled, 0 = disabled
594    autoplay_countdown_seconds INTEGER DEFAULT 10,  -- 5-30 seconds
595
596    -- Audio settings (crossfade, normalization)
597    crossfade_duration REAL DEFAULT 0.0,  -- 0-12 seconds
598    gapless_playback INTEGER DEFAULT 1,
599    normalize_volume INTEGER DEFAULT 0,
600    volume_level TEXT DEFAULT 'normal',  -- 'loud', 'normal', 'quiet'
601
602    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
603);
604
605-- Index for efficient user settings lookup
606CREATE INDEX IF NOT EXISTS idx_user_player_settings_user ON user_player_settings(user_id);
607"#;
608
609/// Migration to track download source (user-initiated vs auto-cached)
610/// - Adds download_source column to distinguish manual downloads from auto-caching
611/// - Enables color-coded UI display
612const MIGRATION_012: &str = r#"
613-- Add download source tracking
614-- Values: 'user' (explicit download), 'auto' (smart cache/queue precache)
615ALTER TABLE downloads ADD COLUMN download_source TEXT DEFAULT 'user';
616
617-- Index for filtering by source
618CREATE INDEX IF NOT EXISTS idx_downloads_source ON downloads(download_source);
619"#;
620
621/// Migration to add composite index for offline mode filtering
622/// - Adds index on (item_id, status) for efficient JOIN queries in OfflineRepository
623/// - Significantly improves performance when filtering items by download status
624const MIGRATION_013: &str = r#"
625-- Add composite index for offline mode filtering
626-- This speeds up queries that join items with downloads to show only downloaded content
627CREATE INDEX IF NOT EXISTS idx_downloads_item_status ON downloads(item_id, status);
628"#;
629
630/// Migration to add series audio track preferences
631/// - Stores user's preferred audio track per series
632/// - Matches tracks by display title and language across episodes
633/// - Falls back to default track if preferred track not found
634const MIGRATION_014: &str = r#"
635-- Series-specific audio track preferences
636-- When user changes audio track for an episode, remember preference for the series
637CREATE TABLE IF NOT EXISTS series_audio_preferences (
638    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
639    series_id TEXT NOT NULL,
640    server_id TEXT NOT NULL,
641
642    -- Audio track info for matching across episodes
643    audio_track_display_title TEXT,
644    audio_track_language TEXT,
645    audio_track_index INTEGER,
646
647    updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
648
649    PRIMARY KEY (user_id, series_id, server_id)
650);
651
652-- Index for efficient lookups
653CREATE INDEX IF NOT EXISTS idx_series_audio_prefs_user_series
654  ON series_audio_preferences(user_id, series_id);
655"#;
656
657/// Migration to add device ID storage
658/// - Creates app_settings table for app-wide configuration (device ID, etc.)
659/// - Device ID is generated once and persisted for Jellyfin server identification
660const MIGRATION_015: &str = r#"
661-- App-wide settings table for device ID and other app-level configuration
662-- Device ID is a unique identifier for this app installation
663-- Required for Jellyfin server communication and session tracking
664CREATE TABLE IF NOT EXISTS app_settings (
665    key TEXT PRIMARY KEY,
666    value TEXT NOT NULL,
667    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
668);
669
670-- Create index for efficient lookups (though key is already primary key)
671CREATE INDEX IF NOT EXISTS idx_app_settings_key ON app_settings(key);
672"#;
673
674/// Migration to add autoplay episode limit setting
675/// - Adds autoplay_max_episodes column to user_player_settings
676/// - 0 = unlimited (default), any positive value limits consecutive auto-plays
677const MIGRATION_016: &str = r#"
678ALTER TABLE user_player_settings
679ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0;
680"#;
681
682/// Migration to persist the resolved stream URL and target directory on each
683/// download row. This lets the backend queue pump start a pending download by
684/// itself (replaying the stored URL) once a concurrency slot frees up, instead
685/// of relying on the frontend to re-issue every queued item.
686const MIGRATION_017: &str = r#"
687-- Resolved download source URL and on-disk target directory, captured when the
688-- download is enqueued. Nullable: pre-existing rows and rows enqueued without a
689-- URL simply won't be auto-started by the pump.
690ALTER TABLE downloads ADD COLUMN stream_url TEXT;
691ALTER TABLE downloads ADD COLUMN target_dir TEXT;
692"#;
693
694/// Migration to record whether a cached item is a folder/container vs a playable
695/// leaf. Needed so channel items (which can be either) route to the player or to
696/// a browse list correctly. Existing cached rows predate the column and have an
697/// unknown folder flag, so we force a refresh by clearing their `synced_at`,
698/// causing the hybrid repository to re-fetch them from the server on next browse.
699const MIGRATION_018: &str = r#"
700ALTER TABLE items ADD COLUMN is_folder INTEGER DEFAULT 0;
701
702-- Force re-fetch of all cached items so is_folder is populated from the server.
703UPDATE items SET synced_at = NULL;
704"#;
705
706/// Migration to cache the full server genre catalog. Previously genres were
707/// derived on the fly from cached albums, which meant offline (and the hybrid
708/// cache-first race) only ever saw genres for the handful of locally-cached
709/// albums — collapsing the variety on the music/TV/movie landing pages. This
710/// table stores the complete genre list per library so offline has the real
711/// catalog and cache-first routing returns the right thing.
712const MIGRATION_019: &str = r#"
713CREATE TABLE IF NOT EXISTS genres (
714    id TEXT NOT NULL,
715    server_id TEXT NOT NULL,
716    library_id TEXT,
717    name TEXT NOT NULL,
718    album_count INTEGER,
719    synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
720    PRIMARY KEY (server_id, library_id, name)
721);
722
723CREATE INDEX IF NOT EXISTS idx_genres_scope ON genres(server_id, library_id);
724"#;
725
726/// Migration to index `items.season_id`.
727///
728/// Episodes link to their season via `season_id` (parent_id is NULL in the
729/// cache). The container-rollup queries used by the Downloaded browse and the
730/// disk-usage aggregation join `children.season_id = c.id`, which without this
731/// index degrades to an unindexable scan — a large synced catalog then makes
732/// the Downloaded page hang ("Loading your downloads…"). `parent_id`,
733/// `album_id`, and `series_id` were already indexed; this closes the gap.
734const MIGRATION_020: &str = r#"
735CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
736"#;
737
738/// Discard and rebuild the FTS index from the `items` table.
739///
740/// Until DR-110, `save_to_cache` used `INSERT OR REPLACE INTO items`. REPLACE
741/// deletes the conflicting row and inserts a new one, but SQLite only fires
742/// `AFTER DELETE` triggers on that implicit delete when `recursive_triggers` is
743/// enabled — it is not (storage/mod.rs sets only `foreign_keys` and
744/// `journal_mode`), so `items_ad` never ran and the old index row was orphaned.
745/// Worse, `items.id` is a `TEXT PRIMARY KEY`, so the replacement row also took a
746/// *fresh rowid* and `items_ai` appended a second entry. Every catalog pass
747/// therefore left another duplicate behind, and existing installs carry one
748/// stale entry per item per sync since the database was created.
749///
750/// This was invisible in results — the `JOIN items_fts fts ON fts.rowid =
751/// i.rowid` drops rowids that no longer exist — but it degrades `MATCH`
752/// permanently, and it becomes a *correctness* problem the moment rowids are
753/// freed and reused: a new item landing on a freed rowid inherits the orphan's
754/// index entry and matches queries for the deleted item's title. The DR-110
755/// deletion sweep frees rowids, so this rebuild must run before it.
756///
757/// `'rebuild'` is the FTS5 command for exactly this: it truncates the index and
758/// repopulates it from the external content table.
759///
760/// TRACES: UR-065 | DR-110
761const MIGRATION_021: &str = r#"
762INSERT INTO items_fts(items_fts) VALUES('rebuild');
763"#;
764
765/// Full-text index over `people`, mirroring `items_fts`.
766///
767/// People live in their own table (migration 009) rather than in `items`, and
768/// had no FTS index at all — so the People group UR-060 requires could only ever
769/// be filled by the server leg of search. With the local index now answering
770/// first, an actor's name has to be findable offline too.
771///
772/// TRACES: UR-065, UR-060 | DR-111
773const MIGRATION_022: &str = r#"
774CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
775    name,
776    overview,
777    content='people',
778    content_rowid='rowid'
779);
780
781CREATE TRIGGER IF NOT EXISTS people_ai AFTER INSERT ON people BEGIN
782    INSERT INTO people_fts(rowid, name, overview)
783    VALUES (new.rowid, new.name, new.overview);
784END;
785
786CREATE TRIGGER IF NOT EXISTS people_ad AFTER DELETE ON people BEGIN
787    INSERT INTO people_fts(people_fts, rowid, name, overview)
788    VALUES('delete', old.rowid, old.name, old.overview);
789END;
790
791CREATE TRIGGER IF NOT EXISTS people_au AFTER UPDATE ON people BEGIN
792    INSERT INTO people_fts(people_fts, rowid, name, overview)
793    VALUES('delete', old.rowid, old.name, old.overview);
794    INSERT INTO people_fts(rowid, name, overview)
795    VALUES (new.rowid, new.name, new.overview);
796END;
797
798-- Backfill for rows cached before this index existed.
799INSERT INTO people_fts(people_fts) VALUES('rebuild');
800"#;
801
802/// Give temporary downloads a life limit.
803///
804/// A cache entry is not a different kind of object from a download — it is a
805/// download with a shorter life. Modelling it as one `downloads` row with an
806/// expiry (rather than a parallel cache store) means there is a single storage
807/// accounting, a single eviction path, and no way for a cache and a download
808/// library to disagree about what is on disk.
809///
810/// `expires_at` is NULL for permanent rows, which is every row that exists
811/// today: `download_source` defaults to `'user'`, and a user's own download
812/// never expires. Only `'auto'` rows get a timestamp, and they are reclaimed by
813/// whichever comes first — the expiry passing, or LRU eviction under space
814/// pressure (DR-126).
815///
816/// TRACES: UR-071 | DR-127
817const MIGRATION_023: &str = r#"
818ALTER TABLE downloads ADD COLUMN expires_at TEXT;
819
820-- Reclaim scans filter on expiry among 'auto' rows; index both so the sweep
821-- stays cheap as the cache tier grows.
822CREATE INDEX IF NOT EXISTS idx_downloads_expiry
823    ON downloads(download_source, expires_at);
824"#;
825
826/// Multi-user profiles: PIN gate, per-user cache visibility, and download grants.
827///
828/// Three tables, one purpose each:
829///
830/// - `user_pins` holds the switching gate. The PIN hash lives here rather than
831///   wrapping the access token, because a wrapped token would leave a locked
832///   profile unable to resume its own downloads or drain its own sync queue
833///   until someone typed the code. See DR-268 for why that trade was taken.
834/// - `user_item_visibility` records what the server has actually shown to each
835///   user. It is written as a byproduct of the cache write path, never rebuilt,
836///   so it cannot disagree with what the server returned.
837/// - `download_grants` separates the bytes from the claim on them, so one file
838///   can serve several profiles and is unlinked only when the last claim goes.
839///
840/// The backfill is not optional. Every existing cache row and download predates
841/// the concept of a user; without it an upgrading install's library goes blank.
842/// It grants the *active* user only — other pre-existing rows re-populate from
843/// the server on next browse, which is strictly safer than handing every
844/// profile the whole cache. The `OR (SELECT COUNT(*) ...) = 0` arm covers an
845/// install whose single user somehow has `is_active = 0`.
846///
847/// TRACES: UR-082, UR-083 | DR-268, DR-271, DR-272
848const MIGRATION_024: &str = r#"
849CREATE TABLE IF NOT EXISTS user_pins (
850    user_id      TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
851    pin_hash     TEXT NOT NULL,
852    failed_count INTEGER NOT NULL DEFAULT 0,
853    locked_until TEXT,
854    updated_at   TEXT DEFAULT CURRENT_TIMESTAMP
855);
856
857CREATE TABLE IF NOT EXISTS user_item_visibility (
858    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
859    item_id TEXT NOT NULL,
860    seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
861    PRIMARY KEY (user_id, item_id)
862);
863
864CREATE INDEX IF NOT EXISTS idx_visibility_user ON user_item_visibility(user_id);
865
866CREATE TABLE IF NOT EXISTS user_libraries (
867    user_id    TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
868    library_id TEXT NOT NULL,
869    seen_at    TEXT DEFAULT CURRENT_TIMESTAMP,
870    PRIMARY KEY (user_id, library_id)
871);
872
873CREATE TABLE IF NOT EXISTS download_grants (
874    user_id     TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
875    download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
876    granted_at  TEXT DEFAULT CURRENT_TIMESTAMP,
877    PRIMARY KEY (user_id, download_id)
878);
879
880CREATE INDEX IF NOT EXISTS idx_download_grants_download ON download_grants(download_id);
881
882-- Backfill: the active user has seen everything already cached on this device.
883INSERT OR IGNORE INTO user_item_visibility (user_id, item_id)
884SELECT u.id, i.id
885FROM users u CROSS JOIN items i
886WHERE u.is_active = 1
887   OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
888
889INSERT OR IGNORE INTO user_libraries (user_id, library_id)
890SELECT u.id, l.id
891FROM users u CROSS JOIN libraries l
892WHERE u.is_active = 1
893   OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
894
895-- Downloads already record who asked for them, so every existing row becomes
896-- exactly one grant held by its original requester.
897INSERT OR IGNORE INTO download_grants (user_id, download_id)
898SELECT d.user_id, d.id FROM downloads d;
899"#;
900
901/// Force cached items to be re-fetched so `library_id` is populated.
902///
903/// `save_to_cache` bound `library_id` NULL for every row it wrote, so nothing in
904/// the cache knew which library it came from. The only available association was
905/// the `collection_type` ↔ `item_type` taxonomy, which cannot tell two libraries
906/// of the same type apart — a server with "TV" and "Shows" served both the same
907/// contents — and says nothing at all about a library whose type it does not map
908/// (Books, Photos, Collections, or a mixed library where Jellyfin sends no
909/// collection type).
910///
911/// The write path now records the library. Existing rows cannot be repaired
912/// locally — the association was never stored — so they are marked stale and
913/// re-fetched on next browse, exactly as MIGRATION_018 did for `is_folder`.
914///
915/// Deliberately does not delete anything: downloads, favourites and playback
916/// positions live in other tables and are untouched, and a cleared `synced_at`
917/// only means "ask the server again", so an offline user keeps browsing what
918/// they already had until the next successful fetch.
919///
920/// TRACES: UR-007 | DR-278
921const MIGRATION_025: &str = r#"
922UPDATE items SET synced_at = NULL;
923"#;
924
925/// Remember which server generation wrote the cached catalog.
926///
927/// The cache was version-blind: nothing recorded which Jellyfin generation
928/// produced a row, so a server upgraded underneath the app kept serving rows
929/// parsed under the previous generation's assumptions.
930///
931/// This deliberately does **not** clear `synced_at` the way MIGRATION_025 did.
932/// The column starts NULL, which reads as "no generation recorded yet", and the
933/// first connection after upgrading simply records what it finds. Invalidation
934/// happens only when the recorded generation actually *changes* — punishing
935/// every existing user with a full re-fetch for a server upgrade that has not
936/// happened would cost real bandwidth to defend against nothing. At the time of
937/// writing no installed server is on the newer generation at all.
938///
939/// TRACES: UR-085 | DR-284
940const MIGRATION_026: &str = r#"
941ALTER TABLE servers ADD COLUMN catalog_generation TEXT;
942"#;
943
944#[cfg(test)]
945mod migration_024_tests {
946    use super::*;
947    use rusqlite::{params, Connection};
948
949    /// Build a database at the schema version *before* multi-user profiles, so
950    /// the backfill is exercised against rows that predate it — which is the
951    /// only state that matters, and the one an in-memory database created from
952    /// the full migration list can never reproduce.
953    fn pre_024_db() -> Connection {
954        let conn = Connection::open_in_memory().unwrap();
955        let upto = MIGRATIONS
956            .iter()
957            .position(|(name, _)| *name == "024_multi_user_profiles")
958            .expect("migration 024 must be registered");
959        for (_, sql) in &MIGRATIONS[..upto] {
960            conn.execute_batch(sql).unwrap();
961        }
962        conn
963    }
964
965    fn seed(conn: &Connection, active_user: &str) {
966        conn.execute(
967            "INSERT INTO servers (id, name, url) VALUES ('s1', 'Test', 'http://localhost:8096')",
968            [],
969        )
970        .unwrap();
971        conn.execute(
972            "INSERT INTO users (id, server_id, username, is_active) VALUES (?1, 's1', 'dad', 1)",
973            params![active_user],
974        )
975        .unwrap();
976        conn.execute(
977            "INSERT INTO libraries (id, server_id, name) VALUES ('lib1', 's1', 'Movies')",
978            [],
979        )
980        .unwrap();
981        conn.execute(
982            "INSERT INTO items (id, server_id, name, item_type) VALUES ('i1', 's1', 'A Movie', 'Movie')",
983            [],
984        )
985        .unwrap();
986        conn.execute(
987            "INSERT INTO downloads (item_id, user_id, file_path, status)
988             VALUES ('i1', ?1, 'downloads/a.mp4', 'completed')",
989            params![active_user],
990        )
991        .unwrap();
992    }
993
994    fn apply_024(conn: &Connection) {
995        let (_, sql) = MIGRATIONS
996            .iter()
997            .find(|(name, _)| *name == "024_multi_user_profiles")
998            .unwrap();
999        conn.execute_batch(sql).unwrap();
1000    }
1001
1002    fn count(conn: &Connection, sql: &str) -> i64 {
1003        conn.query_row(sql, [], |r| r.get(0)).unwrap()
1004    }
1005
1006    /// UT: upgrading an existing install does not blank its library. Without the
1007    /// backfill every cached item becomes invisible to the only user there is.
1008    #[test]
1009    fn backfill_keeps_the_existing_library_visible() {
1010        let conn = pre_024_db();
1011        seed(&conn, "dad");
1012        apply_024(&conn);
1013
1014        assert_eq!(
1015            count(
1016                &conn,
1017                "SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad' AND item_id = 'i1'"
1018            ),
1019            1,
1020            "the active user must still see what was already cached"
1021        );
1022        assert_eq!(
1023            count(
1024                &conn,
1025                "SELECT COUNT(*) FROM user_libraries WHERE user_id = 'dad' AND library_id = 'lib1'"
1026            ),
1027            1
1028        );
1029    }
1030
1031    /// UT: an existing download becomes exactly one grant, held by whoever asked
1032    /// for it — the file is not orphaned and is not handed to anyone else.
1033    #[test]
1034    fn backfill_grants_downloads_to_their_requester() {
1035        let conn = pre_024_db();
1036        seed(&conn, "dad");
1037        apply_024(&conn);
1038
1039        assert_eq!(
1040            count(
1041                &conn,
1042                "SELECT COUNT(*) FROM download_grants WHERE user_id = 'dad'"
1043            ),
1044            1
1045        );
1046    }
1047
1048    /// UT: a second profile added later starts with an empty view. The cache was
1049    /// filled by someone else's browsing and the server never showed it to them,
1050    /// so inheriting it is the leak this whole table exists to close.
1051    #[test]
1052    fn a_later_profile_inherits_nothing() {
1053        let conn = pre_024_db();
1054        seed(&conn, "dad");
1055        apply_024(&conn);
1056
1057        conn.execute(
1058            "INSERT INTO users (id, server_id, username, is_active) VALUES ('kid', 's1', 'kid', 0)",
1059            [],
1060        )
1061        .unwrap();
1062
1063        assert_eq!(
1064            count(
1065                &conn,
1066                "SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'kid'"
1067            ),
1068            0,
1069            "a profile added after the upgrade must not inherit another's cache"
1070        );
1071        assert_eq!(
1072            count(
1073                &conn,
1074                "SELECT COUNT(*) FROM download_grants WHERE user_id = 'kid'"
1075            ),
1076            0
1077        );
1078    }
1079
1080    /// UT: an install whose sole user somehow has `is_active = 0` still gets its
1081    /// library back — the fallback arm of the backfill.
1082    #[test]
1083    fn backfill_covers_an_install_with_no_active_flag() {
1084        let conn = pre_024_db();
1085        seed(&conn, "dad");
1086        conn.execute("UPDATE users SET is_active = 0", []).unwrap();
1087        apply_024(&conn);
1088
1089        assert_eq!(
1090            count(
1091                &conn,
1092                "SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad'"
1093            ),
1094            1
1095        );
1096    }
1097
1098    /// UT: removing a profile takes its per-user rows with it, so a re-added
1099    /// account starts clean rather than resuming someone's stale view.
1100    #[test]
1101    fn removing_a_profile_cascades_its_rows() {
1102        let conn = pre_024_db();
1103        seed(&conn, "dad");
1104        apply_024(&conn);
1105        conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
1106
1107        conn.execute("DELETE FROM users WHERE id = 'dad'", [])
1108            .unwrap();
1109
1110        assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_item_visibility"), 0);
1111        assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_libraries"), 0);
1112        assert_eq!(count(&conn, "SELECT COUNT(*) FROM download_grants"), 0);
1113    }
1114}