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