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];
32
33/// Initial schema migration
34const MIGRATION_001: &str = r#"
35-- Jellyfin servers the user has connected to
36CREATE TABLE IF NOT EXISTS servers (
37    id TEXT PRIMARY KEY,
38    name TEXT NOT NULL,
39    url TEXT NOT NULL UNIQUE,
40    version TEXT,
41    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
42    last_connected_at TEXT
43);
44
45-- User accounts on Jellyfin servers
46CREATE TABLE IF NOT EXISTS users (
47    id TEXT PRIMARY KEY,
48    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
49    username TEXT NOT NULL,
50    access_token TEXT,
51    is_active INTEGER DEFAULT 0,
52    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
53    last_login_at TEXT,
54    UNIQUE(server_id, username)
55);
56
57-- Libraries/views from Jellyfin
58CREATE TABLE IF NOT EXISTS libraries (
59    id TEXT PRIMARY KEY,
60    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
61    name TEXT NOT NULL,
62    collection_type TEXT,
63    image_tag TEXT,
64    sort_order INTEGER DEFAULT 0,
65    synced_at TEXT,
66    UNIQUE(server_id, id)
67);
68
69-- Media items (movies, shows, episodes, albums, songs, artists)
70CREATE TABLE IF NOT EXISTS items (
71    id TEXT PRIMARY KEY,
72    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
73    library_id TEXT REFERENCES libraries(id) ON DELETE SET NULL,
74    parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
75
76    -- Core metadata
77    name TEXT NOT NULL,
78    sort_name TEXT,
79    original_title TEXT,
80    item_type TEXT NOT NULL,  -- Movie, Series, Episode, MusicAlbum, Audio, MusicArtist, etc.
81
82    -- Media info
83    overview TEXT,
84    tagline TEXT,
85    genres TEXT,  -- JSON array
86    tags TEXT,    -- JSON array
87    studios TEXT, -- JSON array
88
89    -- For episodes
90    series_id TEXT,
91    series_name TEXT,
92    season_id TEXT,
93    season_name TEXT,
94    index_number INTEGER,        -- Episode number
95    parent_index_number INTEGER, -- Season number
96
97    -- For music
98    album_id TEXT,
99    album_name TEXT,
100    album_artist TEXT,
101    artists TEXT,  -- JSON array
102
103    -- Dates
104    premiere_date TEXT,
105    production_year INTEGER,
106    date_created TEXT,
107
108    -- Runtime (ticks)
109    runtime_ticks INTEGER,
110
111    -- Images
112    primary_image_tag TEXT,
113    backdrop_image_tags TEXT,  -- JSON array
114
115    -- Ratings
116    community_rating REAL,
117    official_rating TEXT,
118
119    -- Sync metadata
120    synced_at TEXT,
121    etag TEXT,
122
123    UNIQUE(server_id, id)
124);
125
126-- Full-text search index for items
127CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
128    name,
129    overview,
130    album_name,
131    album_artist,
132    artists,
133    series_name,
134    content='items',
135    content_rowid='rowid'
136);
137
138-- Triggers to keep FTS index in sync
139CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN
140    INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
141    VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
142END;
143
144CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN
145    INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
146    VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
147END;
148
149CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN
150    INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
151    VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
152    INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
153    VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
154END;
155
156-- Media streams (audio/subtitle tracks)
157CREATE TABLE IF NOT EXISTS media_streams (
158    id INTEGER PRIMARY KEY AUTOINCREMENT,
159    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
160    stream_index INTEGER NOT NULL,
161    stream_type TEXT NOT NULL,  -- Audio, Subtitle, Video
162    codec TEXT,
163    language TEXT,
164    display_title TEXT,
165    is_default INTEGER DEFAULT 0,
166    is_forced INTEGER DEFAULT 0,
167    is_external INTEGER DEFAULT 0,
168    path TEXT,  -- For external subtitles
169    UNIQUE(item_id, stream_index)
170);
171
172-- User-specific data (watch progress, favorites)
173CREATE TABLE IF NOT EXISTS user_data (
174    id INTEGER PRIMARY KEY AUTOINCREMENT,
175    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
176    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
177
178    -- Playback state
179    playback_position_ticks INTEGER DEFAULT 0,
180    play_count INTEGER DEFAULT 0,
181    is_played INTEGER DEFAULT 0,
182    is_favorite INTEGER DEFAULT 0,
183
184    -- Timestamps
185    last_played_at TEXT,
186
187    -- Sync status
188    synced_at TEXT,
189    pending_sync INTEGER DEFAULT 0,  -- 1 if local changes need sync
190
191    UNIQUE(user_id, item_id)
192);
193
194-- Downloaded media files
195CREATE TABLE IF NOT EXISTS downloads (
196    id INTEGER PRIMARY KEY AUTOINCREMENT,
197    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
198    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
199
200    -- File info
201    file_path TEXT NOT NULL,
202    file_size INTEGER,
203    mime_type TEXT,
204
205    -- Download state
206    status TEXT DEFAULT 'pending',  -- pending, downloading, completed, failed, paused
207    progress REAL DEFAULT 0,        -- 0.0 to 1.0
208
209    -- Transcoding options used
210    bitrate INTEGER,
211    container TEXT,
212
213    -- Timestamps
214    queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
215    started_at TEXT,
216    completed_at TEXT,
217
218    -- Error tracking
219    error_message TEXT,
220    retry_count INTEGER DEFAULT 0,
221
222    UNIQUE(item_id, user_id)
223);
224
225-- Offline mutation queue (changes to sync back to server)
226CREATE TABLE IF NOT EXISTS sync_queue (
227    id INTEGER PRIMARY KEY AUTOINCREMENT,
228    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
229
230    -- Operation details
231    operation TEXT NOT NULL,  -- mark_played, mark_favorite, update_progress, etc.
232    item_id TEXT,
233    payload TEXT,  -- JSON data for the operation
234
235    -- Queue state
236    status TEXT DEFAULT 'pending',  -- pending, processing, completed, failed
237    retry_count INTEGER DEFAULT 0,
238
239    -- Timestamps
240    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
241    processed_at TEXT,
242
243    -- Error tracking
244    error_message TEXT
245);
246
247-- Cached thumbnails
248CREATE TABLE IF NOT EXISTS thumbnails (
249    id INTEGER PRIMARY KEY AUTOINCREMENT,
250    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
251    image_type TEXT NOT NULL,  -- Primary, Backdrop, Thumb, Logo, etc.
252    image_tag TEXT NOT NULL,
253    file_path TEXT NOT NULL,
254    width INTEGER,
255    height INTEGER,
256    cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
257    UNIQUE(item_id, image_type, image_tag)
258);
259
260-- User playlists (local + synced)
261CREATE TABLE IF NOT EXISTS playlists (
262    id TEXT PRIMARY KEY,
263    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
264    name TEXT NOT NULL,
265    is_local INTEGER DEFAULT 0,  -- 1 for local-only playlists
266    jellyfin_id TEXT,            -- NULL for local-only
267    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
268    updated_at TEXT
269);
270
271-- Playlist items
272CREATE TABLE IF NOT EXISTS playlist_items (
273    id INTEGER PRIMARY KEY AUTOINCREMENT,
274    playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
275    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
276    sort_order INTEGER NOT NULL,
277    added_at TEXT DEFAULT CURRENT_TIMESTAMP,
278    UNIQUE(playlist_id, item_id)
279);
280
281-- Indexes for common queries
282CREATE INDEX IF NOT EXISTS idx_items_server ON items(server_id);
283CREATE INDEX IF NOT EXISTS idx_items_library ON items(library_id);
284CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id);
285CREATE INDEX IF NOT EXISTS idx_items_type ON items(item_type);
286CREATE INDEX IF NOT EXISTS idx_items_album ON items(album_id);
287CREATE INDEX IF NOT EXISTS idx_items_series ON items(series_id);
288CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
289CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
290CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
291CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
292CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(status);
293CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
294"#;
295
296/// Migration to remove access_token column from users table
297/// Tokens are now stored in the system keyring (or encrypted file fallback)
298const MIGRATION_002: &str = r#"
299-- Remove access_token column from users table
300-- Tokens are now stored in secure storage (system keyring)
301
302-- SQLite doesn't support DROP COLUMN in older versions, so we recreate the table
303CREATE TABLE IF NOT EXISTS users_new (
304    id TEXT PRIMARY KEY,
305    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
306    username TEXT NOT NULL,
307    is_active INTEGER DEFAULT 0,
308    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
309    last_login_at TEXT,
310    UNIQUE(server_id, username)
311);
312
313-- Copy existing data (excluding access_token)
314INSERT OR IGNORE INTO users_new (id, server_id, username, is_active, created_at, last_login_at)
315SELECT id, server_id, username, is_active, created_at, last_login_at FROM users;
316
317-- Drop old table and rename new one
318DROP TABLE IF EXISTS users;
319ALTER TABLE users_new RENAME TO users;
320"#;
321
322/// Migration to relax foreign key constraints on user_data table
323/// Allows tracking playback progress for items not yet synced to local database
324const MIGRATION_003: &str = r#"
325-- Recreate user_data table without foreign key constraint on item_id
326-- This allows tracking playback progress for items that haven't been synced locally yet
327
328CREATE TABLE IF NOT EXISTS user_data_new (
329    id INTEGER PRIMARY KEY AUTOINCREMENT,
330    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
331    item_id TEXT NOT NULL,  -- No foreign key constraint - item may not be synced yet
332
333    -- Playback state
334    playback_position_ticks INTEGER DEFAULT 0,
335    play_count INTEGER DEFAULT 0,
336    is_played INTEGER DEFAULT 0,
337    is_favorite INTEGER DEFAULT 0,
338
339    -- Timestamps
340    last_played_at TEXT,
341
342    -- Sync status
343    synced_at TEXT,
344    pending_sync INTEGER DEFAULT 0,  -- 1 if local changes need sync
345
346    UNIQUE(user_id, item_id)
347);
348
349-- Copy existing data
350INSERT 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)
351SELECT id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync FROM user_data;
352
353-- Drop old table and rename new one
354DROP TABLE IF EXISTS user_data;
355ALTER TABLE user_data_new RENAME TO user_data;
356
357-- Recreate index
358CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
359CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
360"#;
361
362/// Migration to enhance downloads table with priority and bytes_downloaded
363const MIGRATION_004: &str = r#"
364-- Add priority column for queue ordering
365ALTER TABLE downloads ADD COLUMN priority INTEGER DEFAULT 0;
366
367-- Add bytes_downloaded for resume support
368ALTER TABLE downloads ADD COLUMN bytes_downloaded INTEGER DEFAULT 0;
369
370-- Create index for efficient queue processing (priority DESC, FIFO within same priority)
371CREATE INDEX IF NOT EXISTS idx_downloads_queue
372  ON downloads(status, priority DESC, queued_at ASC)
373  WHERE status IN ('pending', 'downloading');
374"#;
375
376/// Migration to relax foreign key constraint on downloads.item_id
377/// Allows downloading items that haven't been synced to local database yet
378const MIGRATION_005: &str = r#"
379-- Recreate downloads table without foreign key constraint on item_id
380-- This allows downloading items that haven't been synced locally yet
381
382CREATE TABLE IF NOT EXISTS downloads_new (
383    id INTEGER PRIMARY KEY AUTOINCREMENT,
384    item_id TEXT NOT NULL,  -- No foreign key constraint - item may not be synced yet
385    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
386
387    -- File info
388    file_path TEXT NOT NULL,
389    file_size INTEGER,
390    mime_type TEXT,
391
392    -- Download state
393    status TEXT DEFAULT 'pending',  -- pending, downloading, completed, failed, paused
394    progress REAL DEFAULT 0,        -- 0.0 to 1.0
395
396    -- Transcoding options used
397    bitrate INTEGER,
398    container TEXT,
399
400    -- Timestamps
401    queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
402    started_at TEXT,
403    completed_at TEXT,
404
405    -- Error tracking
406    error_message TEXT,
407    retry_count INTEGER DEFAULT 0,
408
409    -- Priority and progress tracking (from migration 004)
410    priority INTEGER DEFAULT 0,
411    bytes_downloaded INTEGER DEFAULT 0,
412
413    UNIQUE(item_id, user_id)
414);
415
416-- Copy existing data
417INSERT OR IGNORE INTO downloads_new (
418    id, item_id, user_id, file_path, file_size, mime_type, status, progress,
419    bitrate, container, queued_at, started_at, completed_at, error_message,
420    retry_count, priority, bytes_downloaded
421)
422SELECT
423    id, item_id, user_id, file_path, file_size, mime_type, status, progress,
424    bitrate, container, queued_at, started_at, completed_at, error_message,
425    retry_count, priority, bytes_downloaded
426FROM downloads;
427
428-- Drop old table and rename new one
429DROP TABLE IF EXISTS downloads;
430ALTER TABLE downloads_new RENAME TO downloads;
431
432-- Recreate indexes
433CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
434CREATE INDEX IF NOT EXISTS idx_downloads_queue
435  ON downloads(status, priority DESC, queued_at ASC)
436  WHERE status IN ('pending', 'downloading');
437"#;
438
439/// Migration to store item metadata directly in downloads table
440/// This eliminates dependency on items table being synced and fixes UUID display issues
441const MIGRATION_006: &str = r#"
442-- Add columns to store item metadata directly in downloads
443-- This ensures correct display even when items aren't synced locally
444ALTER TABLE downloads ADD COLUMN item_name TEXT;
445ALTER TABLE downloads ADD COLUMN artist_name TEXT;
446ALTER TABLE downloads ADD COLUMN album_name TEXT;
447"#;
448
449/// Migration to enhance thumbnail caching with LRU eviction support
450/// - Relaxes foreign key constraint on item_id (allows caching for items not yet synced)
451/// - Adds last_accessed for LRU eviction
452/// - Adds file_size for cache limit tracking
453/// - Creates cache_settings table for configurable limits
454const MIGRATION_007: &str = r#"
455-- Recreate thumbnails table without foreign key constraint on item_id
456-- and add LRU eviction support columns
457CREATE TABLE IF NOT EXISTS thumbnails_new (
458    id INTEGER PRIMARY KEY AUTOINCREMENT,
459    item_id TEXT NOT NULL,  -- No foreign key constraint - item may not be synced yet
460    image_type TEXT NOT NULL,  -- Primary, Backdrop, Thumb, Logo, etc.
461    image_tag TEXT NOT NULL,
462    file_path TEXT NOT NULL,
463    width INTEGER,
464    height INTEGER,
465    file_size INTEGER DEFAULT 0,  -- Size in bytes for cache limit tracking
466    cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
467    last_accessed TEXT DEFAULT CURRENT_TIMESTAMP,  -- For LRU eviction
468    UNIQUE(item_id, image_type, image_tag)
469);
470
471-- Copy existing data (if any)
472INSERT OR IGNORE INTO thumbnails_new (id, item_id, image_type, image_tag, file_path, width, height, cached_at)
473SELECT id, item_id, image_type, image_tag, file_path, width, height, cached_at FROM thumbnails;
474
475-- Drop old table and rename new one
476DROP TABLE IF EXISTS thumbnails;
477ALTER TABLE thumbnails_new RENAME TO thumbnails;
478
479-- Create indexes for efficient queries
480CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
481CREATE INDEX IF NOT EXISTS idx_thumbnails_lru ON thumbnails(last_accessed ASC);
482
483-- Cache settings table for configurable limits
484CREATE TABLE IF NOT EXISTS cache_settings (
485    key TEXT PRIMARY KEY,
486    value TEXT NOT NULL,
487    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
488);
489
490-- Insert default settings
491INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_limit_bytes', '1073741824');  -- 1GB default
492INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_enabled', 'true');
493"#;
494
495/// Migration to add video download support and item pinning
496/// - Adds video-specific metadata columns to downloads (series/episode info, quality preset)
497/// - Adds media_type to distinguish audio vs video downloads
498/// - Adds is_pinned column to items table for protecting metadata from cache clear
499const MIGRATION_008: &str = r#"
500-- Add video-specific metadata columns to downloads
501ALTER TABLE downloads ADD COLUMN series_name TEXT;
502ALTER TABLE downloads ADD COLUMN season_name TEXT;
503ALTER TABLE downloads ADD COLUMN episode_number INTEGER;
504ALTER TABLE downloads ADD COLUMN season_number INTEGER;
505ALTER TABLE downloads ADD COLUMN quality_preset TEXT DEFAULT 'original';
506ALTER TABLE downloads ADD COLUMN media_type TEXT DEFAULT 'audio';
507
508-- Add pinning support to items table
509-- Pinned items are protected from cache clear operations
510ALTER TABLE items ADD COLUMN is_pinned INTEGER DEFAULT 0;
511
512-- Index for efficiently finding pinned items
513CREATE INDEX IF NOT EXISTS idx_items_pinned ON items(is_pinned) WHERE is_pinned = 1;
514
515-- Index for efficiently querying downloads by series
516CREATE INDEX IF NOT EXISTS idx_downloads_series ON downloads(series_name) WHERE series_name IS NOT NULL;
517
518-- Index for filtering by media type
519CREATE INDEX IF NOT EXISTS idx_downloads_media_type ON downloads(media_type);
520"#;
521
522/// Migration to add people/cast caching support
523/// - Creates people table for caching actor/director/writer/etc info
524/// - Creates item_people junction table for many-to-many relationships
525/// - Adds indexes for efficient queries
526const MIGRATION_009: &str = r#"
527-- People table for caching cast/crew members
528CREATE TABLE IF NOT EXISTS people (
529    id TEXT PRIMARY KEY,
530    server_id TEXT NOT NULL,
531    name TEXT NOT NULL,
532    overview TEXT,
533    primary_image_tag TEXT,
534    premiere_date TEXT,      -- Birth date
535    end_date TEXT,           -- Death date
536    synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
537    UNIQUE(server_id, id)
538);
539
540-- Item-Person association table (many-to-many)
541-- Stores which people appear in which items, along with role info
542CREATE TABLE IF NOT EXISTS item_people (
543    id INTEGER PRIMARY KEY AUTOINCREMENT,
544    item_id TEXT NOT NULL,
545    person_id TEXT NOT NULL,
546    server_id TEXT NOT NULL,
547    person_type TEXT NOT NULL,  -- Actor, Director, Writer, Producer, Composer, etc.
548    role TEXT,                   -- Character name for actors
549    sort_order INTEGER DEFAULT 0,
550    synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
551    UNIQUE(item_id, person_id, person_type)
552);
553
554-- Indexes for efficient queries
555CREATE INDEX IF NOT EXISTS idx_people_server ON people(server_id);
556CREATE INDEX IF NOT EXISTS idx_people_name ON people(name);
557CREATE INDEX IF NOT EXISTS idx_item_people_item ON item_people(item_id);
558CREATE INDEX IF NOT EXISTS idx_item_people_person ON item_people(person_id);
559CREATE INDEX IF NOT EXISTS idx_item_people_type ON item_people(person_type);
560"#;
561
562/// Migration to add playback context tracking
563/// - Adds playback_context_type column to track if user played a container or single item
564/// - Adds playback_context_id column to store the container ID (album/playlist)
565/// - Adds index for efficient recently played queries
566const MIGRATION_010: &str = r#"
567-- Add playback context tracking to user_data
568-- Tracks whether user played a container (album/playlist) or single item
569ALTER TABLE user_data ADD COLUMN playback_context_type TEXT;
570ALTER TABLE user_data ADD COLUMN playback_context_id TEXT;
571
572-- Index for efficient recently played queries
573CREATE INDEX IF NOT EXISTS idx_user_data_last_played
574  ON user_data(user_id, last_played_at DESC)
575  WHERE last_played_at IS NOT NULL;
576"#;
577
578/// Migration to add user-specific player settings
579/// - Creates user_player_settings table for autoplay and audio preferences
580/// - Note: Sleep timer state is NOT persisted (cancelled on app close)
581/// - Autoplay settings control next episode behavior
582/// - Audio settings for crossfade, gapless playback, and volume normalization
583const MIGRATION_011: &str = r#"
584-- User-specific player settings (autoplay and audio settings)
585-- Sleep timer is NOT persisted here (maintained in-memory only)
586CREATE TABLE IF NOT EXISTS user_player_settings (
587    user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
588
589    -- Autoplay settings
590    autoplay_next_episode INTEGER DEFAULT 1,  -- 1 = enabled, 0 = disabled
591    autoplay_countdown_seconds INTEGER DEFAULT 10,  -- 5-30 seconds
592
593    -- Audio settings (crossfade, normalization)
594    crossfade_duration REAL DEFAULT 0.0,  -- 0-12 seconds
595    gapless_playback INTEGER DEFAULT 1,
596    normalize_volume INTEGER DEFAULT 0,
597    volume_level TEXT DEFAULT 'normal',  -- 'loud', 'normal', 'quiet'
598
599    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
600);
601
602-- Index for efficient user settings lookup
603CREATE INDEX IF NOT EXISTS idx_user_player_settings_user ON user_player_settings(user_id);
604"#;
605
606/// Migration to track download source (user-initiated vs auto-cached)
607/// - Adds download_source column to distinguish manual downloads from auto-caching
608/// - Enables color-coded UI display
609const MIGRATION_012: &str = r#"
610-- Add download source tracking
611-- Values: 'user' (explicit download), 'auto' (smart cache/queue precache)
612ALTER TABLE downloads ADD COLUMN download_source TEXT DEFAULT 'user';
613
614-- Index for filtering by source
615CREATE INDEX IF NOT EXISTS idx_downloads_source ON downloads(download_source);
616"#;
617
618/// Migration to add composite index for offline mode filtering
619/// - Adds index on (item_id, status) for efficient JOIN queries in OfflineRepository
620/// - Significantly improves performance when filtering items by download status
621const MIGRATION_013: &str = r#"
622-- Add composite index for offline mode filtering
623-- This speeds up queries that join items with downloads to show only downloaded content
624CREATE INDEX IF NOT EXISTS idx_downloads_item_status ON downloads(item_id, status);
625"#;
626
627/// Migration to add series audio track preferences
628/// - Stores user's preferred audio track per series
629/// - Matches tracks by display title and language across episodes
630/// - Falls back to default track if preferred track not found
631const MIGRATION_014: &str = r#"
632-- Series-specific audio track preferences
633-- When user changes audio track for an episode, remember preference for the series
634CREATE TABLE IF NOT EXISTS series_audio_preferences (
635    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
636    series_id TEXT NOT NULL,
637    server_id TEXT NOT NULL,
638
639    -- Audio track info for matching across episodes
640    audio_track_display_title TEXT,
641    audio_track_language TEXT,
642    audio_track_index INTEGER,
643
644    updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
645
646    PRIMARY KEY (user_id, series_id, server_id)
647);
648
649-- Index for efficient lookups
650CREATE INDEX IF NOT EXISTS idx_series_audio_prefs_user_series
651  ON series_audio_preferences(user_id, series_id);
652"#;
653
654/// Migration to add device ID storage
655/// - Creates app_settings table for app-wide configuration (device ID, etc.)
656/// - Device ID is generated once and persisted for Jellyfin server identification
657const MIGRATION_015: &str = r#"
658-- App-wide settings table for device ID and other app-level configuration
659-- Device ID is a unique identifier for this app installation
660-- Required for Jellyfin server communication and session tracking
661CREATE TABLE IF NOT EXISTS app_settings (
662    key TEXT PRIMARY KEY,
663    value TEXT NOT NULL,
664    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
665);
666
667-- Create index for efficient lookups (though key is already primary key)
668CREATE INDEX IF NOT EXISTS idx_app_settings_key ON app_settings(key);
669"#;
670
671/// Migration to add autoplay episode limit setting
672/// - Adds autoplay_max_episodes column to user_player_settings
673/// - 0 = unlimited (default), any positive value limits consecutive auto-plays
674const MIGRATION_016: &str = r#"
675ALTER TABLE user_player_settings
676ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0;
677"#;
678
679/// Migration to persist the resolved stream URL and target directory on each
680/// download row. This lets the backend queue pump start a pending download by
681/// itself (replaying the stored URL) once a concurrency slot frees up, instead
682/// of relying on the frontend to re-issue every queued item.
683const MIGRATION_017: &str = r#"
684-- Resolved download source URL and on-disk target directory, captured when the
685-- download is enqueued. Nullable: pre-existing rows and rows enqueued without a
686-- URL simply won't be auto-started by the pump.
687ALTER TABLE downloads ADD COLUMN stream_url TEXT;
688ALTER TABLE downloads ADD COLUMN target_dir TEXT;
689"#;
690
691/// Migration to record whether a cached item is a folder/container vs a playable
692/// leaf. Needed so channel items (which can be either) route to the player or to
693/// a browse list correctly. Existing cached rows predate the column and have an
694/// unknown folder flag, so we force a refresh by clearing their `synced_at`,
695/// causing the hybrid repository to re-fetch them from the server on next browse.
696const MIGRATION_018: &str = r#"
697ALTER TABLE items ADD COLUMN is_folder INTEGER DEFAULT 0;
698
699-- Force re-fetch of all cached items so is_folder is populated from the server.
700UPDATE items SET synced_at = NULL;
701"#;
702
703/// Migration to cache the full server genre catalog. Previously genres were
704/// derived on the fly from cached albums, which meant offline (and the hybrid
705/// cache-first race) only ever saw genres for the handful of locally-cached
706/// albums — collapsing the variety on the music/TV/movie landing pages. This
707/// table stores the complete genre list per library so offline has the real
708/// catalog and cache-first routing returns the right thing.
709const MIGRATION_019: &str = r#"
710CREATE TABLE IF NOT EXISTS genres (
711    id TEXT NOT NULL,
712    server_id TEXT NOT NULL,
713    library_id TEXT,
714    name TEXT NOT NULL,
715    album_count INTEGER,
716    synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
717    PRIMARY KEY (server_id, library_id, name)
718);
719
720CREATE INDEX IF NOT EXISTS idx_genres_scope ON genres(server_id, library_id);
721"#;
722
723/// Migration to index `items.season_id`.
724///
725/// Episodes link to their season via `season_id` (parent_id is NULL in the
726/// cache). The container-rollup queries used by the Downloaded browse and the
727/// disk-usage aggregation join `children.season_id = c.id`, which without this
728/// index degrades to an unindexable scan — a large synced catalog then makes
729/// the Downloaded page hang ("Loading your downloads…"). `parent_id`,
730/// `album_id`, and `series_id` were already indexed; this closes the gap.
731const MIGRATION_020: &str = r#"
732CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
733"#;
734
735/// Discard and rebuild the FTS index from the `items` table.
736///
737/// Until DR-110, `save_to_cache` used `INSERT OR REPLACE INTO items`. REPLACE
738/// deletes the conflicting row and inserts a new one, but SQLite only fires
739/// `AFTER DELETE` triggers on that implicit delete when `recursive_triggers` is
740/// enabled — it is not (storage/mod.rs sets only `foreign_keys` and
741/// `journal_mode`), so `items_ad` never ran and the old index row was orphaned.
742/// Worse, `items.id` is a `TEXT PRIMARY KEY`, so the replacement row also took a
743/// *fresh rowid* and `items_ai` appended a second entry. Every catalog pass
744/// therefore left another duplicate behind, and existing installs carry one
745/// stale entry per item per sync since the database was created.
746///
747/// This was invisible in results — the `JOIN items_fts fts ON fts.rowid =
748/// i.rowid` drops rowids that no longer exist — but it degrades `MATCH`
749/// permanently, and it becomes a *correctness* problem the moment rowids are
750/// freed and reused: a new item landing on a freed rowid inherits the orphan's
751/// index entry and matches queries for the deleted item's title. The DR-110
752/// deletion sweep frees rowids, so this rebuild must run before it.
753///
754/// `'rebuild'` is the FTS5 command for exactly this: it truncates the index and
755/// repopulates it from the external content table.
756///
757/// TRACES: UR-065 | DR-110
758const MIGRATION_021: &str = r#"
759INSERT INTO items_fts(items_fts) VALUES('rebuild');
760"#;
761
762/// Full-text index over `people`, mirroring `items_fts`.
763///
764/// People live in their own table (migration 009) rather than in `items`, and
765/// had no FTS index at all — so the People group UR-060 requires could only ever
766/// be filled by the server leg of search. With the local index now answering
767/// first, an actor's name has to be findable offline too.
768///
769/// TRACES: UR-065, UR-060 | DR-111
770const MIGRATION_022: &str = r#"
771CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
772    name,
773    overview,
774    content='people',
775    content_rowid='rowid'
776);
777
778CREATE TRIGGER IF NOT EXISTS people_ai AFTER INSERT ON people BEGIN
779    INSERT INTO people_fts(rowid, name, overview)
780    VALUES (new.rowid, new.name, new.overview);
781END;
782
783CREATE TRIGGER IF NOT EXISTS people_ad AFTER DELETE ON people BEGIN
784    INSERT INTO people_fts(people_fts, rowid, name, overview)
785    VALUES('delete', old.rowid, old.name, old.overview);
786END;
787
788CREATE TRIGGER IF NOT EXISTS people_au AFTER UPDATE ON people BEGIN
789    INSERT INTO people_fts(people_fts, rowid, name, overview)
790    VALUES('delete', old.rowid, old.name, old.overview);
791    INSERT INTO people_fts(rowid, name, overview)
792    VALUES (new.rowid, new.name, new.overview);
793END;
794
795-- Backfill for rows cached before this index existed.
796INSERT INTO people_fts(people_fts) VALUES('rebuild');
797"#;
798
799/// Give temporary downloads a life limit.
800///
801/// A cache entry is not a different kind of object from a download — it is a
802/// download with a shorter life. Modelling it as one `downloads` row with an
803/// expiry (rather than a parallel cache store) means there is a single storage
804/// accounting, a single eviction path, and no way for a cache and a download
805/// library to disagree about what is on disk.
806///
807/// `expires_at` is NULL for permanent rows, which is every row that exists
808/// today: `download_source` defaults to `'user'`, and a user's own download
809/// never expires. Only `'auto'` rows get a timestamp, and they are reclaimed by
810/// whichever comes first — the expiry passing, or LRU eviction under space
811/// pressure (DR-126).
812///
813/// TRACES: UR-071 | DR-127
814const MIGRATION_023: &str = r#"
815ALTER TABLE downloads ADD COLUMN expires_at TEXT;
816
817-- Reclaim scans filter on expiry among 'auto' rows; index both so the sweep
818-- stays cheap as the cache tier grows.
819CREATE INDEX IF NOT EXISTS idx_downloads_expiry
820    ON downloads(download_source, expires_at);
821"#;