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