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