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