1pub 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 ("027_items_container_id", MIGRATION_027),
35];
36
37const MIGRATION_001: &str = r#"
39-- Jellyfin servers the user has connected to
40CREATE TABLE IF NOT EXISTS servers (
41 id TEXT PRIMARY KEY,
42 name TEXT NOT NULL,
43 url TEXT NOT NULL UNIQUE,
44 version TEXT,
45 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
46 last_connected_at TEXT
47);
48
49-- User accounts on Jellyfin servers
50CREATE TABLE IF NOT EXISTS users (
51 id TEXT PRIMARY KEY,
52 server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
53 username TEXT NOT NULL,
54 access_token TEXT,
55 is_active INTEGER DEFAULT 0,
56 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
57 last_login_at TEXT,
58 UNIQUE(server_id, username)
59);
60
61-- Libraries/views from Jellyfin
62CREATE TABLE IF NOT EXISTS libraries (
63 id TEXT PRIMARY KEY,
64 server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
65 name TEXT NOT NULL,
66 collection_type TEXT,
67 image_tag TEXT,
68 sort_order INTEGER DEFAULT 0,
69 synced_at TEXT,
70 UNIQUE(server_id, id)
71);
72
73-- Media items (movies, shows, episodes, albums, songs, artists)
74CREATE TABLE IF NOT EXISTS items (
75 id TEXT PRIMARY KEY,
76 server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
77 library_id TEXT REFERENCES libraries(id) ON DELETE SET NULL,
78 parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
79
80 -- Core metadata
81 name TEXT NOT NULL,
82 sort_name TEXT,
83 original_title TEXT,
84 item_type TEXT NOT NULL, -- Movie, Series, Episode, MusicAlbum, Audio, MusicArtist, etc.
85
86 -- Media info
87 overview TEXT,
88 tagline TEXT,
89 genres TEXT, -- JSON array
90 tags TEXT, -- JSON array
91 studios TEXT, -- JSON array
92
93 -- For episodes
94 series_id TEXT,
95 series_name TEXT,
96 season_id TEXT,
97 season_name TEXT,
98 index_number INTEGER, -- Episode number
99 parent_index_number INTEGER, -- Season number
100
101 -- For music
102 album_id TEXT,
103 album_name TEXT,
104 album_artist TEXT,
105 artists TEXT, -- JSON array
106
107 -- Dates
108 premiere_date TEXT,
109 production_year INTEGER,
110 date_created TEXT,
111
112 -- Runtime (ticks)
113 runtime_ticks INTEGER,
114
115 -- Images
116 primary_image_tag TEXT,
117 backdrop_image_tags TEXT, -- JSON array
118
119 -- Ratings
120 community_rating REAL,
121 official_rating TEXT,
122
123 -- Sync metadata
124 synced_at TEXT,
125 etag TEXT,
126
127 UNIQUE(server_id, id)
128);
129
130-- Full-text search index for items
131CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
132 name,
133 overview,
134 album_name,
135 album_artist,
136 artists,
137 series_name,
138 content='items',
139 content_rowid='rowid'
140);
141
142-- Triggers to keep FTS index in sync
143CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN
144 INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
145 VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
146END;
147
148CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN
149 INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
150 VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
151END;
152
153CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN
154 INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
155 VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
156 INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
157 VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
158END;
159
160-- Media streams (audio/subtitle tracks)
161CREATE TABLE IF NOT EXISTS media_streams (
162 id INTEGER PRIMARY KEY AUTOINCREMENT,
163 item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
164 stream_index INTEGER NOT NULL,
165 stream_type TEXT NOT NULL, -- Audio, Subtitle, Video
166 codec TEXT,
167 language TEXT,
168 display_title TEXT,
169 is_default INTEGER DEFAULT 0,
170 is_forced INTEGER DEFAULT 0,
171 is_external INTEGER DEFAULT 0,
172 path TEXT, -- For external subtitles
173 UNIQUE(item_id, stream_index)
174);
175
176-- User-specific data (watch progress, favorites)
177CREATE TABLE IF NOT EXISTS user_data (
178 id INTEGER PRIMARY KEY AUTOINCREMENT,
179 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
180 item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
181
182 -- Playback state
183 playback_position_ticks INTEGER DEFAULT 0,
184 play_count INTEGER DEFAULT 0,
185 is_played INTEGER DEFAULT 0,
186 is_favorite INTEGER DEFAULT 0,
187
188 -- Timestamps
189 last_played_at TEXT,
190
191 -- Sync status
192 synced_at TEXT,
193 pending_sync INTEGER DEFAULT 0, -- 1 if local changes need sync
194
195 UNIQUE(user_id, item_id)
196);
197
198-- Downloaded media files
199CREATE TABLE IF NOT EXISTS downloads (
200 id INTEGER PRIMARY KEY AUTOINCREMENT,
201 item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
202 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
203
204 -- File info
205 file_path TEXT NOT NULL,
206 file_size INTEGER,
207 mime_type TEXT,
208
209 -- Download state
210 status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused
211 progress REAL DEFAULT 0, -- 0.0 to 1.0
212
213 -- Transcoding options used
214 bitrate INTEGER,
215 container TEXT,
216
217 -- Timestamps
218 queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
219 started_at TEXT,
220 completed_at TEXT,
221
222 -- Error tracking
223 error_message TEXT,
224 retry_count INTEGER DEFAULT 0,
225
226 UNIQUE(item_id, user_id)
227);
228
229-- Offline mutation queue (changes to sync back to server)
230CREATE TABLE IF NOT EXISTS sync_queue (
231 id INTEGER PRIMARY KEY AUTOINCREMENT,
232 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
233
234 -- Operation details
235 operation TEXT NOT NULL, -- mark_played, mark_favorite, update_progress, etc.
236 item_id TEXT,
237 payload TEXT, -- JSON data for the operation
238
239 -- Queue state
240 status TEXT DEFAULT 'pending', -- pending, processing, completed, failed
241 retry_count INTEGER DEFAULT 0,
242
243 -- Timestamps
244 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
245 processed_at TEXT,
246
247 -- Error tracking
248 error_message TEXT
249);
250
251-- Cached thumbnails
252CREATE TABLE IF NOT EXISTS thumbnails (
253 id INTEGER PRIMARY KEY AUTOINCREMENT,
254 item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
255 image_type TEXT NOT NULL, -- Primary, Backdrop, Thumb, Logo, etc.
256 image_tag TEXT NOT NULL,
257 file_path TEXT NOT NULL,
258 width INTEGER,
259 height INTEGER,
260 cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
261 UNIQUE(item_id, image_type, image_tag)
262);
263
264-- User playlists (local + synced)
265CREATE TABLE IF NOT EXISTS playlists (
266 id TEXT PRIMARY KEY,
267 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
268 name TEXT NOT NULL,
269 is_local INTEGER DEFAULT 0, -- 1 for local-only playlists
270 jellyfin_id TEXT, -- NULL for local-only
271 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
272 updated_at TEXT
273);
274
275-- Playlist items
276CREATE TABLE IF NOT EXISTS playlist_items (
277 id INTEGER PRIMARY KEY AUTOINCREMENT,
278 playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
279 item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
280 sort_order INTEGER NOT NULL,
281 added_at TEXT DEFAULT CURRENT_TIMESTAMP,
282 UNIQUE(playlist_id, item_id)
283);
284
285-- Indexes for common queries
286CREATE INDEX IF NOT EXISTS idx_items_server ON items(server_id);
287CREATE INDEX IF NOT EXISTS idx_items_library ON items(library_id);
288CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id);
289CREATE INDEX IF NOT EXISTS idx_items_type ON items(item_type);
290CREATE INDEX IF NOT EXISTS idx_items_album ON items(album_id);
291CREATE INDEX IF NOT EXISTS idx_items_series ON items(series_id);
292CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
293CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
294CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
295CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
296CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(status);
297CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
298"#;
299
300const MIGRATION_002: &str = r#"
303-- Remove access_token column from users table
304-- Tokens are now stored in secure storage (system keyring)
305
306-- SQLite doesn't support DROP COLUMN in older versions, so we recreate the table
307CREATE TABLE IF NOT EXISTS users_new (
308 id TEXT PRIMARY KEY,
309 server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
310 username TEXT NOT NULL,
311 is_active INTEGER DEFAULT 0,
312 created_at TEXT DEFAULT CURRENT_TIMESTAMP,
313 last_login_at TEXT,
314 UNIQUE(server_id, username)
315);
316
317-- Copy existing data (excluding access_token)
318INSERT OR IGNORE INTO users_new (id, server_id, username, is_active, created_at, last_login_at)
319SELECT id, server_id, username, is_active, created_at, last_login_at FROM users;
320
321-- Drop old table and rename new one
322DROP TABLE IF EXISTS users;
323ALTER TABLE users_new RENAME TO users;
324"#;
325
326const MIGRATION_003: &str = r#"
329-- Recreate user_data table without foreign key constraint on item_id
330-- This allows tracking playback progress for items that haven't been synced locally yet
331
332CREATE TABLE IF NOT EXISTS user_data_new (
333 id INTEGER PRIMARY KEY AUTOINCREMENT,
334 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
335 item_id TEXT NOT NULL, -- No foreign key constraint - item may not be synced yet
336
337 -- Playback state
338 playback_position_ticks INTEGER DEFAULT 0,
339 play_count INTEGER DEFAULT 0,
340 is_played INTEGER DEFAULT 0,
341 is_favorite INTEGER DEFAULT 0,
342
343 -- Timestamps
344 last_played_at TEXT,
345
346 -- Sync status
347 synced_at TEXT,
348 pending_sync INTEGER DEFAULT 0, -- 1 if local changes need sync
349
350 UNIQUE(user_id, item_id)
351);
352
353-- Copy existing data
354INSERT 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)
355SELECT id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync FROM user_data;
356
357-- Drop old table and rename new one
358DROP TABLE IF EXISTS user_data;
359ALTER TABLE user_data_new RENAME TO user_data;
360
361-- Recreate index
362CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
363CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
364"#;
365
366const MIGRATION_004: &str = r#"
368-- Add priority column for queue ordering
369ALTER TABLE downloads ADD COLUMN priority INTEGER DEFAULT 0;
370
371-- Add bytes_downloaded for resume support
372ALTER TABLE downloads ADD COLUMN bytes_downloaded INTEGER DEFAULT 0;
373
374-- Create index for efficient queue processing (priority DESC, FIFO within same priority)
375CREATE INDEX IF NOT EXISTS idx_downloads_queue
376 ON downloads(status, priority DESC, queued_at ASC)
377 WHERE status IN ('pending', 'downloading');
378"#;
379
380const MIGRATION_005: &str = r#"
383-- Recreate downloads table without foreign key constraint on item_id
384-- This allows downloading items that haven't been synced locally yet
385
386CREATE TABLE IF NOT EXISTS downloads_new (
387 id INTEGER PRIMARY KEY AUTOINCREMENT,
388 item_id TEXT NOT NULL, -- No foreign key constraint - item may not be synced yet
389 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
390
391 -- File info
392 file_path TEXT NOT NULL,
393 file_size INTEGER,
394 mime_type TEXT,
395
396 -- Download state
397 status TEXT DEFAULT 'pending', -- pending, downloading, completed, failed, paused
398 progress REAL DEFAULT 0, -- 0.0 to 1.0
399
400 -- Transcoding options used
401 bitrate INTEGER,
402 container TEXT,
403
404 -- Timestamps
405 queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
406 started_at TEXT,
407 completed_at TEXT,
408
409 -- Error tracking
410 error_message TEXT,
411 retry_count INTEGER DEFAULT 0,
412
413 -- Priority and progress tracking (from migration 004)
414 priority INTEGER DEFAULT 0,
415 bytes_downloaded INTEGER DEFAULT 0,
416
417 UNIQUE(item_id, user_id)
418);
419
420-- Copy existing data
421INSERT OR IGNORE INTO downloads_new (
422 id, item_id, user_id, file_path, file_size, mime_type, status, progress,
423 bitrate, container, queued_at, started_at, completed_at, error_message,
424 retry_count, priority, bytes_downloaded
425)
426SELECT
427 id, item_id, user_id, file_path, file_size, mime_type, status, progress,
428 bitrate, container, queued_at, started_at, completed_at, error_message,
429 retry_count, priority, bytes_downloaded
430FROM downloads;
431
432-- Drop old table and rename new one
433DROP TABLE IF EXISTS downloads;
434ALTER TABLE downloads_new RENAME TO downloads;
435
436-- Recreate indexes
437CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
438CREATE INDEX IF NOT EXISTS idx_downloads_queue
439 ON downloads(status, priority DESC, queued_at ASC)
440 WHERE status IN ('pending', 'downloading');
441"#;
442
443const MIGRATION_006: &str = r#"
446-- Add columns to store item metadata directly in downloads
447-- This ensures correct display even when items aren't synced locally
448ALTER TABLE downloads ADD COLUMN item_name TEXT;
449ALTER TABLE downloads ADD COLUMN artist_name TEXT;
450ALTER TABLE downloads ADD COLUMN album_name TEXT;
451"#;
452
453const MIGRATION_007: &str = r#"
459-- Recreate thumbnails table without foreign key constraint on item_id
460-- and add LRU eviction support columns
461CREATE TABLE IF NOT EXISTS thumbnails_new (
462 id INTEGER PRIMARY KEY AUTOINCREMENT,
463 item_id TEXT NOT NULL, -- No foreign key constraint - item may not be synced yet
464 image_type TEXT NOT NULL, -- Primary, Backdrop, Thumb, Logo, etc.
465 image_tag TEXT NOT NULL,
466 file_path TEXT NOT NULL,
467 width INTEGER,
468 height INTEGER,
469 file_size INTEGER DEFAULT 0, -- Size in bytes for cache limit tracking
470 cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
471 last_accessed TEXT DEFAULT CURRENT_TIMESTAMP, -- For LRU eviction
472 UNIQUE(item_id, image_type, image_tag)
473);
474
475-- Copy existing data (if any)
476INSERT OR IGNORE INTO thumbnails_new (id, item_id, image_type, image_tag, file_path, width, height, cached_at)
477SELECT id, item_id, image_type, image_tag, file_path, width, height, cached_at FROM thumbnails;
478
479-- Drop old table and rename new one
480DROP TABLE IF EXISTS thumbnails;
481ALTER TABLE thumbnails_new RENAME TO thumbnails;
482
483-- Create indexes for efficient queries
484CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
485CREATE INDEX IF NOT EXISTS idx_thumbnails_lru ON thumbnails(last_accessed ASC);
486
487-- Cache settings table for configurable limits
488CREATE TABLE IF NOT EXISTS cache_settings (
489 key TEXT PRIMARY KEY,
490 value TEXT NOT NULL,
491 updated_at TEXT DEFAULT CURRENT_TIMESTAMP
492);
493
494-- Insert default settings
495INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_limit_bytes', '1073741824'); -- 1GB default
496INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_enabled', 'true');
497"#;
498
499const MIGRATION_008: &str = r#"
504-- Add video-specific metadata columns to downloads
505ALTER TABLE downloads ADD COLUMN series_name TEXT;
506ALTER TABLE downloads ADD COLUMN season_name TEXT;
507ALTER TABLE downloads ADD COLUMN episode_number INTEGER;
508ALTER TABLE downloads ADD COLUMN season_number INTEGER;
509ALTER TABLE downloads ADD COLUMN quality_preset TEXT DEFAULT 'original';
510ALTER TABLE downloads ADD COLUMN media_type TEXT DEFAULT 'audio';
511
512-- Add pinning support to items table
513-- Pinned items are protected from cache clear operations
514ALTER TABLE items ADD COLUMN is_pinned INTEGER DEFAULT 0;
515
516-- Index for efficiently finding pinned items
517CREATE INDEX IF NOT EXISTS idx_items_pinned ON items(is_pinned) WHERE is_pinned = 1;
518
519-- Index for efficiently querying downloads by series
520CREATE INDEX IF NOT EXISTS idx_downloads_series ON downloads(series_name) WHERE series_name IS NOT NULL;
521
522-- Index for filtering by media type
523CREATE INDEX IF NOT EXISTS idx_downloads_media_type ON downloads(media_type);
524"#;
525
526const MIGRATION_009: &str = r#"
531-- People table for caching cast/crew members
532CREATE TABLE IF NOT EXISTS people (
533 id TEXT PRIMARY KEY,
534 server_id TEXT NOT NULL,
535 name TEXT NOT NULL,
536 overview TEXT,
537 primary_image_tag TEXT,
538 premiere_date TEXT, -- Birth date
539 end_date TEXT, -- Death date
540 synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
541 UNIQUE(server_id, id)
542);
543
544-- Item-Person association table (many-to-many)
545-- Stores which people appear in which items, along with role info
546CREATE TABLE IF NOT EXISTS item_people (
547 id INTEGER PRIMARY KEY AUTOINCREMENT,
548 item_id TEXT NOT NULL,
549 person_id TEXT NOT NULL,
550 server_id TEXT NOT NULL,
551 person_type TEXT NOT NULL, -- Actor, Director, Writer, Producer, Composer, etc.
552 role TEXT, -- Character name for actors
553 sort_order INTEGER DEFAULT 0,
554 synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
555 UNIQUE(item_id, person_id, person_type)
556);
557
558-- Indexes for efficient queries
559CREATE INDEX IF NOT EXISTS idx_people_server ON people(server_id);
560CREATE INDEX IF NOT EXISTS idx_people_name ON people(name);
561CREATE INDEX IF NOT EXISTS idx_item_people_item ON item_people(item_id);
562CREATE INDEX IF NOT EXISTS idx_item_people_person ON item_people(person_id);
563CREATE INDEX IF NOT EXISTS idx_item_people_type ON item_people(person_type);
564"#;
565
566const MIGRATION_010: &str = r#"
571-- Add playback context tracking to user_data
572-- Tracks whether user played a container (album/playlist) or single item
573ALTER TABLE user_data ADD COLUMN playback_context_type TEXT;
574ALTER TABLE user_data ADD COLUMN playback_context_id TEXT;
575
576-- Index for efficient recently played queries
577CREATE INDEX IF NOT EXISTS idx_user_data_last_played
578 ON user_data(user_id, last_played_at DESC)
579 WHERE last_played_at IS NOT NULL;
580"#;
581
582const MIGRATION_011: &str = r#"
588-- User-specific player settings (autoplay and audio settings)
589-- Sleep timer is NOT persisted here (maintained in-memory only)
590CREATE TABLE IF NOT EXISTS user_player_settings (
591 user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
592
593 -- Autoplay settings
594 autoplay_next_episode INTEGER DEFAULT 1, -- 1 = enabled, 0 = disabled
595 autoplay_countdown_seconds INTEGER DEFAULT 10, -- 5-30 seconds
596
597 -- Audio settings (crossfade, normalization)
598 crossfade_duration REAL DEFAULT 0.0, -- 0-12 seconds
599 gapless_playback INTEGER DEFAULT 1,
600 normalize_volume INTEGER DEFAULT 0,
601 volume_level TEXT DEFAULT 'normal', -- 'loud', 'normal', 'quiet'
602
603 updated_at TEXT DEFAULT CURRENT_TIMESTAMP
604);
605
606-- Index for efficient user settings lookup
607CREATE INDEX IF NOT EXISTS idx_user_player_settings_user ON user_player_settings(user_id);
608"#;
609
610const MIGRATION_012: &str = r#"
614-- Add download source tracking
615-- Values: 'user' (explicit download), 'auto' (smart cache/queue precache)
616ALTER TABLE downloads ADD COLUMN download_source TEXT DEFAULT 'user';
617
618-- Index for filtering by source
619CREATE INDEX IF NOT EXISTS idx_downloads_source ON downloads(download_source);
620"#;
621
622const MIGRATION_013: &str = r#"
626-- Add composite index for offline mode filtering
627-- This speeds up queries that join items with downloads to show only downloaded content
628CREATE INDEX IF NOT EXISTS idx_downloads_item_status ON downloads(item_id, status);
629"#;
630
631const MIGRATION_014: &str = r#"
636-- Series-specific audio track preferences
637-- When user changes audio track for an episode, remember preference for the series
638CREATE TABLE IF NOT EXISTS series_audio_preferences (
639 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
640 series_id TEXT NOT NULL,
641 server_id TEXT NOT NULL,
642
643 -- Audio track info for matching across episodes
644 audio_track_display_title TEXT,
645 audio_track_language TEXT,
646 audio_track_index INTEGER,
647
648 updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
649
650 PRIMARY KEY (user_id, series_id, server_id)
651);
652
653-- Index for efficient lookups
654CREATE INDEX IF NOT EXISTS idx_series_audio_prefs_user_series
655 ON series_audio_preferences(user_id, series_id);
656"#;
657
658const MIGRATION_015: &str = r#"
662-- App-wide settings table for device ID and other app-level configuration
663-- Device ID is a unique identifier for this app installation
664-- Required for Jellyfin server communication and session tracking
665CREATE TABLE IF NOT EXISTS app_settings (
666 key TEXT PRIMARY KEY,
667 value TEXT NOT NULL,
668 updated_at TEXT DEFAULT CURRENT_TIMESTAMP
669);
670
671-- Create index for efficient lookups (though key is already primary key)
672CREATE INDEX IF NOT EXISTS idx_app_settings_key ON app_settings(key);
673"#;
674
675const MIGRATION_016: &str = r#"
679ALTER TABLE user_player_settings
680ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0;
681"#;
682
683const MIGRATION_017: &str = r#"
688-- Resolved download source URL and on-disk target directory, captured when the
689-- download is enqueued. Nullable: pre-existing rows and rows enqueued without a
690-- URL simply won't be auto-started by the pump.
691ALTER TABLE downloads ADD COLUMN stream_url TEXT;
692ALTER TABLE downloads ADD COLUMN target_dir TEXT;
693"#;
694
695const MIGRATION_018: &str = r#"
701ALTER TABLE items ADD COLUMN is_folder INTEGER DEFAULT 0;
702
703-- Force re-fetch of all cached items so is_folder is populated from the server.
704UPDATE items SET synced_at = NULL;
705"#;
706
707const MIGRATION_019: &str = r#"
714CREATE TABLE IF NOT EXISTS genres (
715 id TEXT NOT NULL,
716 server_id TEXT NOT NULL,
717 library_id TEXT,
718 name TEXT NOT NULL,
719 album_count INTEGER,
720 synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
721 PRIMARY KEY (server_id, library_id, name)
722);
723
724CREATE INDEX IF NOT EXISTS idx_genres_scope ON genres(server_id, library_id);
725"#;
726
727const MIGRATION_020: &str = r#"
736CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
737"#;
738
739const MIGRATION_021: &str = r#"
763INSERT INTO items_fts(items_fts) VALUES('rebuild');
764"#;
765
766const MIGRATION_022: &str = r#"
775CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
776 name,
777 overview,
778 content='people',
779 content_rowid='rowid'
780);
781
782CREATE TRIGGER IF NOT EXISTS people_ai AFTER INSERT ON people BEGIN
783 INSERT INTO people_fts(rowid, name, overview)
784 VALUES (new.rowid, new.name, new.overview);
785END;
786
787CREATE TRIGGER IF NOT EXISTS people_ad AFTER DELETE ON people BEGIN
788 INSERT INTO people_fts(people_fts, rowid, name, overview)
789 VALUES('delete', old.rowid, old.name, old.overview);
790END;
791
792CREATE TRIGGER IF NOT EXISTS people_au AFTER UPDATE ON people BEGIN
793 INSERT INTO people_fts(people_fts, rowid, name, overview)
794 VALUES('delete', old.rowid, old.name, old.overview);
795 INSERT INTO people_fts(rowid, name, overview)
796 VALUES (new.rowid, new.name, new.overview);
797END;
798
799-- Backfill for rows cached before this index existed.
800INSERT INTO people_fts(people_fts) VALUES('rebuild');
801"#;
802
803const MIGRATION_023: &str = r#"
819ALTER TABLE downloads ADD COLUMN expires_at TEXT;
820
821-- Reclaim scans filter on expiry among 'auto' rows; index both so the sweep
822-- stays cheap as the cache tier grows.
823CREATE INDEX IF NOT EXISTS idx_downloads_expiry
824 ON downloads(download_source, expires_at);
825"#;
826
827const MIGRATION_024: &str = r#"
850CREATE TABLE IF NOT EXISTS user_pins (
851 user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
852 pin_hash TEXT NOT NULL,
853 failed_count INTEGER NOT NULL DEFAULT 0,
854 locked_until TEXT,
855 updated_at TEXT DEFAULT CURRENT_TIMESTAMP
856);
857
858CREATE TABLE IF NOT EXISTS user_item_visibility (
859 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
860 item_id TEXT NOT NULL,
861 seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
862 PRIMARY KEY (user_id, item_id)
863);
864
865CREATE INDEX IF NOT EXISTS idx_visibility_user ON user_item_visibility(user_id);
866
867CREATE TABLE IF NOT EXISTS user_libraries (
868 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
869 library_id TEXT NOT NULL,
870 seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
871 PRIMARY KEY (user_id, library_id)
872);
873
874CREATE TABLE IF NOT EXISTS download_grants (
875 user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
876 download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
877 granted_at TEXT DEFAULT CURRENT_TIMESTAMP,
878 PRIMARY KEY (user_id, download_id)
879);
880
881CREATE INDEX IF NOT EXISTS idx_download_grants_download ON download_grants(download_id);
882
883-- Backfill: the active user has seen everything already cached on this device.
884INSERT OR IGNORE INTO user_item_visibility (user_id, item_id)
885SELECT u.id, i.id
886FROM users u CROSS JOIN items i
887WHERE u.is_active = 1
888 OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
889
890INSERT OR IGNORE INTO user_libraries (user_id, library_id)
891SELECT u.id, l.id
892FROM users u CROSS JOIN libraries l
893WHERE u.is_active = 1
894 OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
895
896-- Downloads already record who asked for them, so every existing row becomes
897-- exactly one grant held by its original requester.
898INSERT OR IGNORE INTO download_grants (user_id, download_id)
899SELECT d.user_id, d.id FROM downloads d;
900"#;
901
902const MIGRATION_025: &str = r#"
923UPDATE items SET synced_at = NULL;
924"#;
925
926const MIGRATION_026: &str = r#"
942ALTER TABLE servers ADD COLUMN catalog_generation TEXT;
943"#;
944
945const MIGRATION_027: &str = r#"
975ALTER TABLE items ADD COLUMN container_id TEXT GENERATED ALWAYS AS (
976 CASE item_type
977 WHEN 'Episode' THEN COALESCE(season_id, series_id, parent_id)
978 WHEN 'Season' THEN COALESCE(series_id, parent_id)
979 WHEN 'Audio' THEN COALESCE(album_id, parent_id)
980 ELSE parent_id
981 END
982) VIRTUAL;
983
984CREATE INDEX IF NOT EXISTS idx_items_container ON items(container_id, sort_name, name);
985
986INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder, series_id, series_name)
987SELECT season_id, server_id, MAX(library_id), COALESCE(MAX(season_name), 'Season'), 'Season', 1,
988 MAX(series_id), MAX(series_name)
989FROM items
990WHERE item_type = 'Episode' AND season_id IS NOT NULL
991GROUP BY season_id;
992
993INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder)
994SELECT series_id, server_id, MAX(library_id), COALESCE(MAX(series_name), 'Series'), 'Series', 1
995FROM items
996WHERE item_type IN ('Episode', 'Season') AND series_id IS NOT NULL
997GROUP BY series_id;
998
999INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder, album_artist)
1000SELECT album_id, server_id, MAX(library_id), COALESCE(MAX(album_name), 'Album'), 'MusicAlbum', 1,
1001 MAX(album_artist)
1002FROM items
1003WHERE item_type = 'Audio' AND album_id IS NOT NULL
1004GROUP BY album_id;
1005"#;
1006
1007#[cfg(test)]
1008mod migration_027_tests {
1009 use super::*;
1010 use rusqlite::Connection;
1011
1012 fn pre_027_db() -> Connection {
1013 let conn = Connection::open_in_memory().unwrap();
1014 let upto = MIGRATIONS
1015 .iter()
1016 .position(|(name, _)| *name == "027_items_container_id")
1017 .expect("migration 027 must be registered");
1018 for (_, sql) in &MIGRATIONS[..upto] {
1019 conn.execute_batch(sql).unwrap();
1020 }
1021 conn.execute_batch(
1022 "INSERT INTO servers (id, name, url) VALUES ('s', 'S', 'http://s');
1023 -- An episode cached without its season or series rows.
1024 INSERT INTO items (id, server_id, name, item_type, parent_id, season_id, season_name,
1025 series_id, series_name, library_id)
1026 VALUES ('ep', 's', 'Pilot', 'Episode', NULL, 'season', 'Season 1',
1027 'show', 'Show', NULL);
1028 -- A track cached without its album.
1029 INSERT INTO items (id, server_id, name, item_type, album_id, album_name, album_artist)
1030 VALUES ('trk', 's', 'Song', 'Audio', 'alb', 'Record', 'Band');
1031 -- A folder child: its container is just its parent.
1032 INSERT INTO items (id, server_id, name, item_type) VALUES ('box', 's', 'Box', 'BoxSet');
1033 INSERT INTO items (id, server_id, name, item_type, parent_id)
1034 VALUES ('film', 's', 'Film', 'Movie', 'box');",
1035 )
1036 .unwrap();
1037 conn
1038 }
1039
1040 fn container(conn: &Connection, id: &str) -> Option<String> {
1041 conn.query_row("SELECT container_id FROM items WHERE id = ?1", [id], |r| {
1042 r.get(0)
1043 })
1044 .unwrap()
1045 }
1046
1047 #[test]
1049 fn every_item_resolves_to_its_logical_container() {
1050 let conn = pre_027_db();
1051 conn.execute_batch(MIGRATION_027).unwrap();
1052
1053 assert_eq!(container(&conn, "ep").as_deref(), Some("season"));
1054 assert_eq!(container(&conn, "season").as_deref(), Some("show"));
1055 assert_eq!(container(&conn, "trk").as_deref(), Some("alb"));
1056 assert_eq!(container(&conn, "film").as_deref(), Some("box"));
1057 assert_eq!(container(&conn, "show"), None);
1058 }
1059
1060 #[test]
1065 fn missing_containers_get_named_placeholders() {
1066 let conn = pre_027_db();
1067 conn.execute_batch(MIGRATION_027).unwrap();
1068
1069 let row = |id: &str| -> (String, String, Option<String>) {
1070 conn.query_row(
1071 "SELECT name, item_type, synced_at FROM items WHERE id = ?1",
1072 [id],
1073 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1074 )
1075 .unwrap()
1076 };
1077 assert_eq!(row("season"), ("Season 1".into(), "Season".into(), None));
1078 assert_eq!(row("show"), ("Show".into(), "Series".into(), None));
1079 assert_eq!(row("alb"), ("Record".into(), "MusicAlbum".into(), None));
1080 }
1081
1082 #[test]
1086 fn a_container_listing_is_an_ordered_index_range() {
1087 let conn = pre_027_db();
1088 conn.execute_batch(MIGRATION_027).unwrap();
1089 let plan: Vec<String> = conn
1090 .prepare(
1091 "EXPLAIN QUERY PLAN SELECT id FROM items
1092 WHERE container_id = ?1 ORDER BY sort_name, name",
1093 )
1094 .unwrap()
1095 .query_map(["season"], |r| r.get::<_, String>(3))
1096 .unwrap()
1097 .map(Result::unwrap)
1098 .collect();
1099 let plan = plan.join("\n");
1100 assert!(plan.contains("idx_items_container"), "{plan}");
1101 assert!(
1102 !plan.contains("TEMP B-TREE"),
1103 "listing needs a sort step:\n{plan}"
1104 );
1105 }
1106}
1107
1108#[cfg(test)]
1109mod migration_024_tests {
1110 use super::*;
1111 use rusqlite::{params, Connection};
1112
1113 fn pre_024_db() -> Connection {
1118 let conn = Connection::open_in_memory().unwrap();
1119 let upto = MIGRATIONS
1120 .iter()
1121 .position(|(name, _)| *name == "024_multi_user_profiles")
1122 .expect("migration 024 must be registered");
1123 for (_, sql) in &MIGRATIONS[..upto] {
1124 conn.execute_batch(sql).unwrap();
1125 }
1126 conn
1127 }
1128
1129 fn seed(conn: &Connection, active_user: &str) {
1130 conn.execute(
1131 "INSERT INTO servers (id, name, url) VALUES ('s1', 'Test', 'http://localhost:8096')",
1132 [],
1133 )
1134 .unwrap();
1135 conn.execute(
1136 "INSERT INTO users (id, server_id, username, is_active) VALUES (?1, 's1', 'dad', 1)",
1137 params![active_user],
1138 )
1139 .unwrap();
1140 conn.execute(
1141 "INSERT INTO libraries (id, server_id, name) VALUES ('lib1', 's1', 'Movies')",
1142 [],
1143 )
1144 .unwrap();
1145 conn.execute(
1146 "INSERT INTO items (id, server_id, name, item_type) VALUES ('i1', 's1', 'A Movie', 'Movie')",
1147 [],
1148 )
1149 .unwrap();
1150 conn.execute(
1151 "INSERT INTO downloads (item_id, user_id, file_path, status)
1152 VALUES ('i1', ?1, 'downloads/a.mp4', 'completed')",
1153 params![active_user],
1154 )
1155 .unwrap();
1156 }
1157
1158 fn apply_024(conn: &Connection) {
1159 let (_, sql) = MIGRATIONS
1160 .iter()
1161 .find(|(name, _)| *name == "024_multi_user_profiles")
1162 .unwrap();
1163 conn.execute_batch(sql).unwrap();
1164 }
1165
1166 fn count(conn: &Connection, sql: &str) -> i64 {
1167 conn.query_row(sql, [], |r| r.get(0)).unwrap()
1168 }
1169
1170 #[test]
1173 fn backfill_keeps_the_existing_library_visible() {
1174 let conn = pre_024_db();
1175 seed(&conn, "dad");
1176 apply_024(&conn);
1177
1178 assert_eq!(
1179 count(
1180 &conn,
1181 "SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad' AND item_id = 'i1'"
1182 ),
1183 1,
1184 "the active user must still see what was already cached"
1185 );
1186 assert_eq!(
1187 count(
1188 &conn,
1189 "SELECT COUNT(*) FROM user_libraries WHERE user_id = 'dad' AND library_id = 'lib1'"
1190 ),
1191 1
1192 );
1193 }
1194
1195 #[test]
1198 fn backfill_grants_downloads_to_their_requester() {
1199 let conn = pre_024_db();
1200 seed(&conn, "dad");
1201 apply_024(&conn);
1202
1203 assert_eq!(
1204 count(
1205 &conn,
1206 "SELECT COUNT(*) FROM download_grants WHERE user_id = 'dad'"
1207 ),
1208 1
1209 );
1210 }
1211
1212 #[test]
1216 fn a_later_profile_inherits_nothing() {
1217 let conn = pre_024_db();
1218 seed(&conn, "dad");
1219 apply_024(&conn);
1220
1221 conn.execute(
1222 "INSERT INTO users (id, server_id, username, is_active) VALUES ('kid', 's1', 'kid', 0)",
1223 [],
1224 )
1225 .unwrap();
1226
1227 assert_eq!(
1228 count(
1229 &conn,
1230 "SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'kid'"
1231 ),
1232 0,
1233 "a profile added after the upgrade must not inherit another's cache"
1234 );
1235 assert_eq!(
1236 count(
1237 &conn,
1238 "SELECT COUNT(*) FROM download_grants WHERE user_id = 'kid'"
1239 ),
1240 0
1241 );
1242 }
1243
1244 #[test]
1247 fn backfill_covers_an_install_with_no_active_flag() {
1248 let conn = pre_024_db();
1249 seed(&conn, "dad");
1250 conn.execute("UPDATE users SET is_active = 0", []).unwrap();
1251 apply_024(&conn);
1252
1253 assert_eq!(
1254 count(
1255 &conn,
1256 "SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad'"
1257 ),
1258 1
1259 );
1260 }
1261
1262 #[test]
1265 fn removing_a_profile_cascades_its_rows() {
1266 let conn = pre_024_db();
1267 seed(&conn, "dad");
1268 apply_024(&conn);
1269 conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
1270
1271 conn.execute("DELETE FROM users WHERE id = 'dad'", [])
1272 .unwrap();
1273
1274 assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_item_visibility"), 0);
1275 assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_libraries"), 0);
1276 assert_eq!(count(&conn, "SELECT COUNT(*) FROM download_grants"), 0);
1277 }
1278}