Skip to main content

jellytau_lib/storage/
schema.rs

1//! Database schema and migrations
2//!
3//! TRACES: UR-002 | DR-012 | IR-013
4
5/// List of migrations to apply in order.
6/// Each migration is a tuple of (name, sql).
7pub const MIGRATIONS: &[(&str, &str)] = &[
8    ("001_initial_schema", MIGRATION_001),
9    ("002_remove_access_token", MIGRATION_002),
10    ("003_relax_user_data_constraints", MIGRATION_003),
11    ("004_enhance_downloads", MIGRATION_004),
12    ("005_relax_downloads_fk", MIGRATION_005),
13    ("006_downloads_metadata", MIGRATION_006),
14    ("007_cache_metadata", MIGRATION_007),
15    ("008_video_downloads", MIGRATION_008),
16    ("009_people_tables", MIGRATION_009),
17    ("010_playback_context", MIGRATION_010),
18    ("011_user_player_settings", MIGRATION_011),
19    ("012_download_source", MIGRATION_012),
20    ("013_downloads_item_status_index", MIGRATION_013),
21    ("014_series_audio_preferences", MIGRATION_014),
22    ("015_device_id", MIGRATION_015),
23    ("016_autoplay_max_episodes", MIGRATION_016),
24    ("017_downloads_resume_url", MIGRATION_017),
25    ("018_items_is_folder", MIGRATION_018),
26    ("019_genres_cache", MIGRATION_019),
27    ("020_items_season_index", MIGRATION_020),
28    ("021_rebuild_items_fts", MIGRATION_021),
29    ("022_people_fts", MIGRATION_022),
30    ("023_downloads_expiry", MIGRATION_023),
31    ("024_multi_user_profiles", MIGRATION_024),
32    ("025_backfill_item_library_id", MIGRATION_025),
33    ("026_server_catalog_generation", MIGRATION_026),
34    ("027_items_container_id", MIGRATION_027),
35];
36
37/// Initial schema migration
38const MIGRATION_001: &str = r#"
39-- Jellyfin servers the user has connected to
40CREATE TABLE IF NOT EXISTS servers (
41    id TEXT PRIMARY KEY,
42    name TEXT NOT NULL,
43    url TEXT NOT NULL UNIQUE,
44    version TEXT,
45    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
46    last_connected_at TEXT
47);
48
49-- User accounts on Jellyfin servers
50CREATE TABLE IF NOT EXISTS users (
51    id TEXT PRIMARY KEY,
52    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
53    username TEXT NOT NULL,
54    access_token TEXT,
55    is_active INTEGER DEFAULT 0,
56    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
57    last_login_at TEXT,
58    UNIQUE(server_id, username)
59);
60
61-- Libraries/views from Jellyfin
62CREATE TABLE IF NOT EXISTS libraries (
63    id TEXT PRIMARY KEY,
64    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
65    name TEXT NOT NULL,
66    collection_type TEXT,
67    image_tag TEXT,
68    sort_order INTEGER DEFAULT 0,
69    synced_at TEXT,
70    UNIQUE(server_id, id)
71);
72
73-- Media items (movies, shows, episodes, albums, songs, artists)
74CREATE TABLE IF NOT EXISTS items (
75    id TEXT PRIMARY KEY,
76    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
77    library_id TEXT REFERENCES libraries(id) ON DELETE SET NULL,
78    parent_id TEXT REFERENCES items(id) ON DELETE CASCADE,
79
80    -- Core metadata
81    name TEXT NOT NULL,
82    sort_name TEXT,
83    original_title TEXT,
84    item_type TEXT NOT NULL,  -- Movie, Series, Episode, MusicAlbum, Audio, MusicArtist, etc.
85
86    -- Media info
87    overview TEXT,
88    tagline TEXT,
89    genres TEXT,  -- JSON array
90    tags TEXT,    -- JSON array
91    studios TEXT, -- JSON array
92
93    -- For episodes
94    series_id TEXT,
95    series_name TEXT,
96    season_id TEXT,
97    season_name TEXT,
98    index_number INTEGER,        -- Episode number
99    parent_index_number INTEGER, -- Season number
100
101    -- For music
102    album_id TEXT,
103    album_name TEXT,
104    album_artist TEXT,
105    artists TEXT,  -- JSON array
106
107    -- Dates
108    premiere_date TEXT,
109    production_year INTEGER,
110    date_created TEXT,
111
112    -- Runtime (ticks)
113    runtime_ticks INTEGER,
114
115    -- Images
116    primary_image_tag TEXT,
117    backdrop_image_tags TEXT,  -- JSON array
118
119    -- Ratings
120    community_rating REAL,
121    official_rating TEXT,
122
123    -- Sync metadata
124    synced_at TEXT,
125    etag TEXT,
126
127    UNIQUE(server_id, id)
128);
129
130-- Full-text search index for items
131CREATE VIRTUAL TABLE IF NOT EXISTS items_fts USING fts5(
132    name,
133    overview,
134    album_name,
135    album_artist,
136    artists,
137    series_name,
138    content='items',
139    content_rowid='rowid'
140);
141
142-- Triggers to keep FTS index in sync
143CREATE TRIGGER IF NOT EXISTS items_ai AFTER INSERT ON items BEGIN
144    INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
145    VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
146END;
147
148CREATE TRIGGER IF NOT EXISTS items_ad AFTER DELETE ON items BEGIN
149    INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
150    VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
151END;
152
153CREATE TRIGGER IF NOT EXISTS items_au AFTER UPDATE ON items BEGIN
154    INSERT INTO items_fts(items_fts, rowid, name, overview, album_name, album_artist, artists, series_name)
155    VALUES('delete', old.rowid, old.name, old.overview, old.album_name, old.album_artist, old.artists, old.series_name);
156    INSERT INTO items_fts(rowid, name, overview, album_name, album_artist, artists, series_name)
157    VALUES (new.rowid, new.name, new.overview, new.album_name, new.album_artist, new.artists, new.series_name);
158END;
159
160-- Media streams (audio/subtitle tracks)
161CREATE TABLE IF NOT EXISTS media_streams (
162    id INTEGER PRIMARY KEY AUTOINCREMENT,
163    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
164    stream_index INTEGER NOT NULL,
165    stream_type TEXT NOT NULL,  -- Audio, Subtitle, Video
166    codec TEXT,
167    language TEXT,
168    display_title TEXT,
169    is_default INTEGER DEFAULT 0,
170    is_forced INTEGER DEFAULT 0,
171    is_external INTEGER DEFAULT 0,
172    path TEXT,  -- For external subtitles
173    UNIQUE(item_id, stream_index)
174);
175
176-- User-specific data (watch progress, favorites)
177CREATE TABLE IF NOT EXISTS user_data (
178    id INTEGER PRIMARY KEY AUTOINCREMENT,
179    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
180    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
181
182    -- Playback state
183    playback_position_ticks INTEGER DEFAULT 0,
184    play_count INTEGER DEFAULT 0,
185    is_played INTEGER DEFAULT 0,
186    is_favorite INTEGER DEFAULT 0,
187
188    -- Timestamps
189    last_played_at TEXT,
190
191    -- Sync status
192    synced_at TEXT,
193    pending_sync INTEGER DEFAULT 0,  -- 1 if local changes need sync
194
195    UNIQUE(user_id, item_id)
196);
197
198-- Downloaded media files
199CREATE TABLE IF NOT EXISTS downloads (
200    id INTEGER PRIMARY KEY AUTOINCREMENT,
201    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
202    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
203
204    -- File info
205    file_path TEXT NOT NULL,
206    file_size INTEGER,
207    mime_type TEXT,
208
209    -- Download state
210    status TEXT DEFAULT 'pending',  -- pending, downloading, completed, failed, paused
211    progress REAL DEFAULT 0,        -- 0.0 to 1.0
212
213    -- Transcoding options used
214    bitrate INTEGER,
215    container TEXT,
216
217    -- Timestamps
218    queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
219    started_at TEXT,
220    completed_at TEXT,
221
222    -- Error tracking
223    error_message TEXT,
224    retry_count INTEGER DEFAULT 0,
225
226    UNIQUE(item_id, user_id)
227);
228
229-- Offline mutation queue (changes to sync back to server)
230CREATE TABLE IF NOT EXISTS sync_queue (
231    id INTEGER PRIMARY KEY AUTOINCREMENT,
232    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
233
234    -- Operation details
235    operation TEXT NOT NULL,  -- mark_played, mark_favorite, update_progress, etc.
236    item_id TEXT,
237    payload TEXT,  -- JSON data for the operation
238
239    -- Queue state
240    status TEXT DEFAULT 'pending',  -- pending, processing, completed, failed
241    retry_count INTEGER DEFAULT 0,
242
243    -- Timestamps
244    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
245    processed_at TEXT,
246
247    -- Error tracking
248    error_message TEXT
249);
250
251-- Cached thumbnails
252CREATE TABLE IF NOT EXISTS thumbnails (
253    id INTEGER PRIMARY KEY AUTOINCREMENT,
254    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
255    image_type TEXT NOT NULL,  -- Primary, Backdrop, Thumb, Logo, etc.
256    image_tag TEXT NOT NULL,
257    file_path TEXT NOT NULL,
258    width INTEGER,
259    height INTEGER,
260    cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
261    UNIQUE(item_id, image_type, image_tag)
262);
263
264-- User playlists (local + synced)
265CREATE TABLE IF NOT EXISTS playlists (
266    id TEXT PRIMARY KEY,
267    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
268    name TEXT NOT NULL,
269    is_local INTEGER DEFAULT 0,  -- 1 for local-only playlists
270    jellyfin_id TEXT,            -- NULL for local-only
271    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
272    updated_at TEXT
273);
274
275-- Playlist items
276CREATE TABLE IF NOT EXISTS playlist_items (
277    id INTEGER PRIMARY KEY AUTOINCREMENT,
278    playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
279    item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
280    sort_order INTEGER NOT NULL,
281    added_at TEXT DEFAULT CURRENT_TIMESTAMP,
282    UNIQUE(playlist_id, item_id)
283);
284
285-- Indexes for common queries
286CREATE INDEX IF NOT EXISTS idx_items_server ON items(server_id);
287CREATE INDEX IF NOT EXISTS idx_items_library ON items(library_id);
288CREATE INDEX IF NOT EXISTS idx_items_parent ON items(parent_id);
289CREATE INDEX IF NOT EXISTS idx_items_type ON items(item_type);
290CREATE INDEX IF NOT EXISTS idx_items_album ON items(album_id);
291CREATE INDEX IF NOT EXISTS idx_items_series ON items(series_id);
292CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
293CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
294CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
295CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
296CREATE INDEX IF NOT EXISTS idx_sync_queue_status ON sync_queue(status);
297CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
298"#;
299
300/// Migration to remove access_token column from users table
301/// Tokens are now stored in the system keyring (or encrypted file fallback)
302const MIGRATION_002: &str = r#"
303-- Remove access_token column from users table
304-- Tokens are now stored in secure storage (system keyring)
305
306-- SQLite doesn't support DROP COLUMN in older versions, so we recreate the table
307CREATE TABLE IF NOT EXISTS users_new (
308    id TEXT PRIMARY KEY,
309    server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
310    username TEXT NOT NULL,
311    is_active INTEGER DEFAULT 0,
312    created_at TEXT DEFAULT CURRENT_TIMESTAMP,
313    last_login_at TEXT,
314    UNIQUE(server_id, username)
315);
316
317-- Copy existing data (excluding access_token)
318INSERT OR IGNORE INTO users_new (id, server_id, username, is_active, created_at, last_login_at)
319SELECT id, server_id, username, is_active, created_at, last_login_at FROM users;
320
321-- Drop old table and rename new one
322DROP TABLE IF EXISTS users;
323ALTER TABLE users_new RENAME TO users;
324"#;
325
326/// Migration to relax foreign key constraints on user_data table
327/// Allows tracking playback progress for items not yet synced to local database
328const MIGRATION_003: &str = r#"
329-- Recreate user_data table without foreign key constraint on item_id
330-- This allows tracking playback progress for items that haven't been synced locally yet
331
332CREATE TABLE IF NOT EXISTS user_data_new (
333    id INTEGER PRIMARY KEY AUTOINCREMENT,
334    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
335    item_id TEXT NOT NULL,  -- No foreign key constraint - item may not be synced yet
336
337    -- Playback state
338    playback_position_ticks INTEGER DEFAULT 0,
339    play_count INTEGER DEFAULT 0,
340    is_played INTEGER DEFAULT 0,
341    is_favorite INTEGER DEFAULT 0,
342
343    -- Timestamps
344    last_played_at TEXT,
345
346    -- Sync status
347    synced_at TEXT,
348    pending_sync INTEGER DEFAULT 0,  -- 1 if local changes need sync
349
350    UNIQUE(user_id, item_id)
351);
352
353-- Copy existing data
354INSERT OR IGNORE INTO user_data_new (id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync)
355SELECT id, user_id, item_id, playback_position_ticks, play_count, is_played, is_favorite, last_played_at, synced_at, pending_sync FROM user_data;
356
357-- Drop old table and rename new one
358DROP TABLE IF EXISTS user_data;
359ALTER TABLE user_data_new RENAME TO user_data;
360
361-- Recreate index
362CREATE INDEX IF NOT EXISTS idx_user_data_user ON user_data(user_id);
363CREATE INDEX IF NOT EXISTS idx_user_data_item ON user_data(item_id);
364"#;
365
366/// Migration to enhance downloads table with priority and bytes_downloaded
367const MIGRATION_004: &str = r#"
368-- Add priority column for queue ordering
369ALTER TABLE downloads ADD COLUMN priority INTEGER DEFAULT 0;
370
371-- Add bytes_downloaded for resume support
372ALTER TABLE downloads ADD COLUMN bytes_downloaded INTEGER DEFAULT 0;
373
374-- Create index for efficient queue processing (priority DESC, FIFO within same priority)
375CREATE INDEX IF NOT EXISTS idx_downloads_queue
376  ON downloads(status, priority DESC, queued_at ASC)
377  WHERE status IN ('pending', 'downloading');
378"#;
379
380/// Migration to relax foreign key constraint on downloads.item_id
381/// Allows downloading items that haven't been synced to local database yet
382const MIGRATION_005: &str = r#"
383-- Recreate downloads table without foreign key constraint on item_id
384-- This allows downloading items that haven't been synced locally yet
385
386CREATE TABLE IF NOT EXISTS downloads_new (
387    id INTEGER PRIMARY KEY AUTOINCREMENT,
388    item_id TEXT NOT NULL,  -- No foreign key constraint - item may not be synced yet
389    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
390
391    -- File info
392    file_path TEXT NOT NULL,
393    file_size INTEGER,
394    mime_type TEXT,
395
396    -- Download state
397    status TEXT DEFAULT 'pending',  -- pending, downloading, completed, failed, paused
398    progress REAL DEFAULT 0,        -- 0.0 to 1.0
399
400    -- Transcoding options used
401    bitrate INTEGER,
402    container TEXT,
403
404    -- Timestamps
405    queued_at TEXT DEFAULT CURRENT_TIMESTAMP,
406    started_at TEXT,
407    completed_at TEXT,
408
409    -- Error tracking
410    error_message TEXT,
411    retry_count INTEGER DEFAULT 0,
412
413    -- Priority and progress tracking (from migration 004)
414    priority INTEGER DEFAULT 0,
415    bytes_downloaded INTEGER DEFAULT 0,
416
417    UNIQUE(item_id, user_id)
418);
419
420-- Copy existing data
421INSERT OR IGNORE INTO downloads_new (
422    id, item_id, user_id, file_path, file_size, mime_type, status, progress,
423    bitrate, container, queued_at, started_at, completed_at, error_message,
424    retry_count, priority, bytes_downloaded
425)
426SELECT
427    id, item_id, user_id, file_path, file_size, mime_type, status, progress,
428    bitrate, container, queued_at, started_at, completed_at, error_message,
429    retry_count, priority, bytes_downloaded
430FROM downloads;
431
432-- Drop old table and rename new one
433DROP TABLE IF EXISTS downloads;
434ALTER TABLE downloads_new RENAME TO downloads;
435
436-- Recreate indexes
437CREATE INDEX IF NOT EXISTS idx_downloads_status ON downloads(status);
438CREATE INDEX IF NOT EXISTS idx_downloads_queue
439  ON downloads(status, priority DESC, queued_at ASC)
440  WHERE status IN ('pending', 'downloading');
441"#;
442
443/// Migration to store item metadata directly in downloads table
444/// This eliminates dependency on items table being synced and fixes UUID display issues
445const MIGRATION_006: &str = r#"
446-- Add columns to store item metadata directly in downloads
447-- This ensures correct display even when items aren't synced locally
448ALTER TABLE downloads ADD COLUMN item_name TEXT;
449ALTER TABLE downloads ADD COLUMN artist_name TEXT;
450ALTER TABLE downloads ADD COLUMN album_name TEXT;
451"#;
452
453/// Migration to enhance thumbnail caching with LRU eviction support
454/// - Relaxes foreign key constraint on item_id (allows caching for items not yet synced)
455/// - Adds last_accessed for LRU eviction
456/// - Adds file_size for cache limit tracking
457/// - Creates cache_settings table for configurable limits
458const MIGRATION_007: &str = r#"
459-- Recreate thumbnails table without foreign key constraint on item_id
460-- and add LRU eviction support columns
461CREATE TABLE IF NOT EXISTS thumbnails_new (
462    id INTEGER PRIMARY KEY AUTOINCREMENT,
463    item_id TEXT NOT NULL,  -- No foreign key constraint - item may not be synced yet
464    image_type TEXT NOT NULL,  -- Primary, Backdrop, Thumb, Logo, etc.
465    image_tag TEXT NOT NULL,
466    file_path TEXT NOT NULL,
467    width INTEGER,
468    height INTEGER,
469    file_size INTEGER DEFAULT 0,  -- Size in bytes for cache limit tracking
470    cached_at TEXT DEFAULT CURRENT_TIMESTAMP,
471    last_accessed TEXT DEFAULT CURRENT_TIMESTAMP,  -- For LRU eviction
472    UNIQUE(item_id, image_type, image_tag)
473);
474
475-- Copy existing data (if any)
476INSERT OR IGNORE INTO thumbnails_new (id, item_id, image_type, image_tag, file_path, width, height, cached_at)
477SELECT id, item_id, image_type, image_tag, file_path, width, height, cached_at FROM thumbnails;
478
479-- Drop old table and rename new one
480DROP TABLE IF EXISTS thumbnails;
481ALTER TABLE thumbnails_new RENAME TO thumbnails;
482
483-- Create indexes for efficient queries
484CREATE INDEX IF NOT EXISTS idx_thumbnails_item ON thumbnails(item_id);
485CREATE INDEX IF NOT EXISTS idx_thumbnails_lru ON thumbnails(last_accessed ASC);
486
487-- Cache settings table for configurable limits
488CREATE TABLE IF NOT EXISTS cache_settings (
489    key TEXT PRIMARY KEY,
490    value TEXT NOT NULL,
491    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
492);
493
494-- Insert default settings
495INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_limit_bytes', '1073741824');  -- 1GB default
496INSERT OR IGNORE INTO cache_settings (key, value) VALUES ('image_cache_enabled', 'true');
497"#;
498
499/// Migration to add video download support and item pinning
500/// - Adds video-specific metadata columns to downloads (series/episode info, quality preset)
501/// - Adds media_type to distinguish audio vs video downloads
502/// - Adds is_pinned column to items table for protecting metadata from cache clear
503const MIGRATION_008: &str = r#"
504-- Add video-specific metadata columns to downloads
505ALTER TABLE downloads ADD COLUMN series_name TEXT;
506ALTER TABLE downloads ADD COLUMN season_name TEXT;
507ALTER TABLE downloads ADD COLUMN episode_number INTEGER;
508ALTER TABLE downloads ADD COLUMN season_number INTEGER;
509ALTER TABLE downloads ADD COLUMN quality_preset TEXT DEFAULT 'original';
510ALTER TABLE downloads ADD COLUMN media_type TEXT DEFAULT 'audio';
511
512-- Add pinning support to items table
513-- Pinned items are protected from cache clear operations
514ALTER TABLE items ADD COLUMN is_pinned INTEGER DEFAULT 0;
515
516-- Index for efficiently finding pinned items
517CREATE INDEX IF NOT EXISTS idx_items_pinned ON items(is_pinned) WHERE is_pinned = 1;
518
519-- Index for efficiently querying downloads by series
520CREATE INDEX IF NOT EXISTS idx_downloads_series ON downloads(series_name) WHERE series_name IS NOT NULL;
521
522-- Index for filtering by media type
523CREATE INDEX IF NOT EXISTS idx_downloads_media_type ON downloads(media_type);
524"#;
525
526/// Migration to add people/cast caching support
527/// - Creates people table for caching actor/director/writer/etc info
528/// - Creates item_people junction table for many-to-many relationships
529/// - Adds indexes for efficient queries
530const MIGRATION_009: &str = r#"
531-- People table for caching cast/crew members
532CREATE TABLE IF NOT EXISTS people (
533    id TEXT PRIMARY KEY,
534    server_id TEXT NOT NULL,
535    name TEXT NOT NULL,
536    overview TEXT,
537    primary_image_tag TEXT,
538    premiere_date TEXT,      -- Birth date
539    end_date TEXT,           -- Death date
540    synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
541    UNIQUE(server_id, id)
542);
543
544-- Item-Person association table (many-to-many)
545-- Stores which people appear in which items, along with role info
546CREATE TABLE IF NOT EXISTS item_people (
547    id INTEGER PRIMARY KEY AUTOINCREMENT,
548    item_id TEXT NOT NULL,
549    person_id TEXT NOT NULL,
550    server_id TEXT NOT NULL,
551    person_type TEXT NOT NULL,  -- Actor, Director, Writer, Producer, Composer, etc.
552    role TEXT,                   -- Character name for actors
553    sort_order INTEGER DEFAULT 0,
554    synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
555    UNIQUE(item_id, person_id, person_type)
556);
557
558-- Indexes for efficient queries
559CREATE INDEX IF NOT EXISTS idx_people_server ON people(server_id);
560CREATE INDEX IF NOT EXISTS idx_people_name ON people(name);
561CREATE INDEX IF NOT EXISTS idx_item_people_item ON item_people(item_id);
562CREATE INDEX IF NOT EXISTS idx_item_people_person ON item_people(person_id);
563CREATE INDEX IF NOT EXISTS idx_item_people_type ON item_people(person_type);
564"#;
565
566/// Migration to add playback context tracking
567/// - Adds playback_context_type column to track if user played a container or single item
568/// - Adds playback_context_id column to store the container ID (album/playlist)
569/// - Adds index for efficient recently played queries
570const MIGRATION_010: &str = r#"
571-- Add playback context tracking to user_data
572-- Tracks whether user played a container (album/playlist) or single item
573ALTER TABLE user_data ADD COLUMN playback_context_type TEXT;
574ALTER TABLE user_data ADD COLUMN playback_context_id TEXT;
575
576-- Index for efficient recently played queries
577CREATE INDEX IF NOT EXISTS idx_user_data_last_played
578  ON user_data(user_id, last_played_at DESC)
579  WHERE last_played_at IS NOT NULL;
580"#;
581
582/// Migration to add user-specific player settings
583/// - Creates user_player_settings table for autoplay and audio preferences
584/// - Note: Sleep timer state is NOT persisted (cancelled on app close)
585/// - Autoplay settings control next episode behavior
586/// - Audio settings for crossfade, gapless playback, and volume normalization
587const MIGRATION_011: &str = r#"
588-- User-specific player settings (autoplay and audio settings)
589-- Sleep timer is NOT persisted here (maintained in-memory only)
590CREATE TABLE IF NOT EXISTS user_player_settings (
591    user_id TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
592
593    -- Autoplay settings
594    autoplay_next_episode INTEGER DEFAULT 1,  -- 1 = enabled, 0 = disabled
595    autoplay_countdown_seconds INTEGER DEFAULT 10,  -- 5-30 seconds
596
597    -- Audio settings (crossfade, normalization)
598    crossfade_duration REAL DEFAULT 0.0,  -- 0-12 seconds
599    gapless_playback INTEGER DEFAULT 1,
600    normalize_volume INTEGER DEFAULT 0,
601    volume_level TEXT DEFAULT 'normal',  -- 'loud', 'normal', 'quiet'
602
603    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
604);
605
606-- Index for efficient user settings lookup
607CREATE INDEX IF NOT EXISTS idx_user_player_settings_user ON user_player_settings(user_id);
608"#;
609
610/// Migration to track download source (user-initiated vs auto-cached)
611/// - Adds download_source column to distinguish manual downloads from auto-caching
612/// - Enables color-coded UI display
613const MIGRATION_012: &str = r#"
614-- Add download source tracking
615-- Values: 'user' (explicit download), 'auto' (smart cache/queue precache)
616ALTER TABLE downloads ADD COLUMN download_source TEXT DEFAULT 'user';
617
618-- Index for filtering by source
619CREATE INDEX IF NOT EXISTS idx_downloads_source ON downloads(download_source);
620"#;
621
622/// Migration to add composite index for offline mode filtering
623/// - Adds index on (item_id, status) for efficient JOIN queries in OfflineRepository
624/// - Significantly improves performance when filtering items by download status
625const MIGRATION_013: &str = r#"
626-- Add composite index for offline mode filtering
627-- This speeds up queries that join items with downloads to show only downloaded content
628CREATE INDEX IF NOT EXISTS idx_downloads_item_status ON downloads(item_id, status);
629"#;
630
631/// Migration to add series audio track preferences
632/// - Stores user's preferred audio track per series
633/// - Matches tracks by display title and language across episodes
634/// - Falls back to default track if preferred track not found
635const MIGRATION_014: &str = r#"
636-- Series-specific audio track preferences
637-- When user changes audio track for an episode, remember preference for the series
638CREATE TABLE IF NOT EXISTS series_audio_preferences (
639    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
640    series_id TEXT NOT NULL,
641    server_id TEXT NOT NULL,
642
643    -- Audio track info for matching across episodes
644    audio_track_display_title TEXT,
645    audio_track_language TEXT,
646    audio_track_index INTEGER,
647
648    updated_at TEXT DEFAULT CURRENT_TIMESTAMP,
649
650    PRIMARY KEY (user_id, series_id, server_id)
651);
652
653-- Index for efficient lookups
654CREATE INDEX IF NOT EXISTS idx_series_audio_prefs_user_series
655  ON series_audio_preferences(user_id, series_id);
656"#;
657
658/// Migration to add device ID storage
659/// - Creates app_settings table for app-wide configuration (device ID, etc.)
660/// - Device ID is generated once and persisted for Jellyfin server identification
661const MIGRATION_015: &str = r#"
662-- App-wide settings table for device ID and other app-level configuration
663-- Device ID is a unique identifier for this app installation
664-- Required for Jellyfin server communication and session tracking
665CREATE TABLE IF NOT EXISTS app_settings (
666    key TEXT PRIMARY KEY,
667    value TEXT NOT NULL,
668    updated_at TEXT DEFAULT CURRENT_TIMESTAMP
669);
670
671-- Create index for efficient lookups (though key is already primary key)
672CREATE INDEX IF NOT EXISTS idx_app_settings_key ON app_settings(key);
673"#;
674
675/// Migration to add autoplay episode limit setting
676/// - Adds autoplay_max_episodes column to user_player_settings
677/// - 0 = unlimited (default), any positive value limits consecutive auto-plays
678const MIGRATION_016: &str = r#"
679ALTER TABLE user_player_settings
680ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0;
681"#;
682
683/// Migration to persist the resolved stream URL and target directory on each
684/// download row. This lets the backend queue pump start a pending download by
685/// itself (replaying the stored URL) once a concurrency slot frees up, instead
686/// of relying on the frontend to re-issue every queued item.
687const MIGRATION_017: &str = r#"
688-- Resolved download source URL and on-disk target directory, captured when the
689-- download is enqueued. Nullable: pre-existing rows and rows enqueued without a
690-- URL simply won't be auto-started by the pump.
691ALTER TABLE downloads ADD COLUMN stream_url TEXT;
692ALTER TABLE downloads ADD COLUMN target_dir TEXT;
693"#;
694
695/// Migration to record whether a cached item is a folder/container vs a playable
696/// leaf. Needed so channel items (which can be either) route to the player or to
697/// a browse list correctly. Existing cached rows predate the column and have an
698/// unknown folder flag, so we force a refresh by clearing their `synced_at`,
699/// causing the hybrid repository to re-fetch them from the server on next browse.
700const MIGRATION_018: &str = r#"
701ALTER TABLE items ADD COLUMN is_folder INTEGER DEFAULT 0;
702
703-- Force re-fetch of all cached items so is_folder is populated from the server.
704UPDATE items SET synced_at = NULL;
705"#;
706
707/// Migration to cache the full server genre catalog. Previously genres were
708/// derived on the fly from cached albums, which meant offline (and the hybrid
709/// cache-first race) only ever saw genres for the handful of locally-cached
710/// albums — collapsing the variety on the music/TV/movie landing pages. This
711/// table stores the complete genre list per library so offline has the real
712/// catalog and cache-first routing returns the right thing.
713const MIGRATION_019: &str = r#"
714CREATE TABLE IF NOT EXISTS genres (
715    id TEXT NOT NULL,
716    server_id TEXT NOT NULL,
717    library_id TEXT,
718    name TEXT NOT NULL,
719    album_count INTEGER,
720    synced_at TEXT DEFAULT CURRENT_TIMESTAMP,
721    PRIMARY KEY (server_id, library_id, name)
722);
723
724CREATE INDEX IF NOT EXISTS idx_genres_scope ON genres(server_id, library_id);
725"#;
726
727/// Migration to index `items.season_id`.
728///
729/// Episodes link to their season via `season_id` (parent_id is NULL in the
730/// cache). The container-rollup queries used by the Downloaded browse and the
731/// disk-usage aggregation join `children.season_id = c.id`, which without this
732/// index degrades to an unindexable scan — a large synced catalog then makes
733/// the Downloaded page hang ("Loading your downloads…"). `parent_id`,
734/// `album_id`, and `series_id` were already indexed; this closes the gap.
735const MIGRATION_020: &str = r#"
736CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
737"#;
738
739/// Discard and rebuild the FTS index from the `items` table.
740///
741/// Until DR-110, `save_to_cache` used `INSERT OR REPLACE INTO items`. REPLACE
742/// deletes the conflicting row and inserts a new one, but SQLite only fires
743/// `AFTER DELETE` triggers on that implicit delete when `recursive_triggers` is
744/// enabled — it is not (storage/mod.rs sets only `foreign_keys` and
745/// `journal_mode`), so `items_ad` never ran and the old index row was orphaned.
746/// Worse, `items.id` is a `TEXT PRIMARY KEY`, so the replacement row also took a
747/// *fresh rowid* and `items_ai` appended a second entry. Every catalog pass
748/// therefore left another duplicate behind, and existing installs carry one
749/// stale entry per item per sync since the database was created.
750///
751/// This was invisible in results — the `JOIN items_fts fts ON fts.rowid =
752/// i.rowid` drops rowids that no longer exist — but it degrades `MATCH`
753/// permanently, and it becomes a *correctness* problem the moment rowids are
754/// freed and reused: a new item landing on a freed rowid inherits the orphan's
755/// index entry and matches queries for the deleted item's title. The DR-110
756/// deletion sweep frees rowids, so this rebuild must run before it.
757///
758/// `'rebuild'` is the FTS5 command for exactly this: it truncates the index and
759/// repopulates it from the external content table.
760///
761/// TRACES: UR-065 | DR-110
762const MIGRATION_021: &str = r#"
763INSERT INTO items_fts(items_fts) VALUES('rebuild');
764"#;
765
766/// Full-text index over `people`, mirroring `items_fts`.
767///
768/// People live in their own table (migration 009) rather than in `items`, and
769/// had no FTS index at all — so the People group UR-060 requires could only ever
770/// be filled by the server leg of search. With the local index now answering
771/// first, an actor's name has to be findable offline too.
772///
773/// TRACES: UR-065, UR-060 | DR-111
774const MIGRATION_022: &str = r#"
775CREATE VIRTUAL TABLE IF NOT EXISTS people_fts USING fts5(
776    name,
777    overview,
778    content='people',
779    content_rowid='rowid'
780);
781
782CREATE TRIGGER IF NOT EXISTS people_ai AFTER INSERT ON people BEGIN
783    INSERT INTO people_fts(rowid, name, overview)
784    VALUES (new.rowid, new.name, new.overview);
785END;
786
787CREATE TRIGGER IF NOT EXISTS people_ad AFTER DELETE ON people BEGIN
788    INSERT INTO people_fts(people_fts, rowid, name, overview)
789    VALUES('delete', old.rowid, old.name, old.overview);
790END;
791
792CREATE TRIGGER IF NOT EXISTS people_au AFTER UPDATE ON people BEGIN
793    INSERT INTO people_fts(people_fts, rowid, name, overview)
794    VALUES('delete', old.rowid, old.name, old.overview);
795    INSERT INTO people_fts(rowid, name, overview)
796    VALUES (new.rowid, new.name, new.overview);
797END;
798
799-- Backfill for rows cached before this index existed.
800INSERT INTO people_fts(people_fts) VALUES('rebuild');
801"#;
802
803/// Give temporary downloads a life limit.
804///
805/// A cache entry is not a different kind of object from a download — it is a
806/// download with a shorter life. Modelling it as one `downloads` row with an
807/// expiry (rather than a parallel cache store) means there is a single storage
808/// accounting, a single eviction path, and no way for a cache and a download
809/// library to disagree about what is on disk.
810///
811/// `expires_at` is NULL for permanent rows, which is every row that exists
812/// today: `download_source` defaults to `'user'`, and a user's own download
813/// never expires. Only `'auto'` rows get a timestamp, and they are reclaimed by
814/// whichever comes first — the expiry passing, or LRU eviction under space
815/// pressure (DR-126).
816///
817/// TRACES: UR-071 | DR-127
818const MIGRATION_023: &str = r#"
819ALTER TABLE downloads ADD COLUMN expires_at TEXT;
820
821-- Reclaim scans filter on expiry among 'auto' rows; index both so the sweep
822-- stays cheap as the cache tier grows.
823CREATE INDEX IF NOT EXISTS idx_downloads_expiry
824    ON downloads(download_source, expires_at);
825"#;
826
827/// Multi-user profiles: PIN gate, per-user cache visibility, and download grants.
828///
829/// Three tables, one purpose each:
830///
831/// - `user_pins` holds the switching gate. The PIN hash lives here rather than
832///   wrapping the access token, because a wrapped token would leave a locked
833///   profile unable to resume its own downloads or drain its own sync queue
834///   until someone typed the code. See DR-268 for why that trade was taken.
835/// - `user_item_visibility` records what the server has actually shown to each
836///   user. It is written as a byproduct of the cache write path, never rebuilt,
837///   so it cannot disagree with what the server returned.
838/// - `download_grants` separates the bytes from the claim on them, so one file
839///   can serve several profiles and is unlinked only when the last claim goes.
840///
841/// The backfill is not optional. Every existing cache row and download predates
842/// the concept of a user; without it an upgrading install's library goes blank.
843/// It grants the *active* user only — other pre-existing rows re-populate from
844/// the server on next browse, which is strictly safer than handing every
845/// profile the whole cache. The `OR (SELECT COUNT(*) ...) = 0` arm covers an
846/// install whose single user somehow has `is_active = 0`.
847///
848/// TRACES: UR-082, UR-083 | DR-268, DR-271, DR-272
849const MIGRATION_024: &str = r#"
850CREATE TABLE IF NOT EXISTS user_pins (
851    user_id      TEXT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
852    pin_hash     TEXT NOT NULL,
853    failed_count INTEGER NOT NULL DEFAULT 0,
854    locked_until TEXT,
855    updated_at   TEXT DEFAULT CURRENT_TIMESTAMP
856);
857
858CREATE TABLE IF NOT EXISTS user_item_visibility (
859    user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
860    item_id TEXT NOT NULL,
861    seen_at TEXT DEFAULT CURRENT_TIMESTAMP,
862    PRIMARY KEY (user_id, item_id)
863);
864
865CREATE INDEX IF NOT EXISTS idx_visibility_user ON user_item_visibility(user_id);
866
867CREATE TABLE IF NOT EXISTS user_libraries (
868    user_id    TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
869    library_id TEXT NOT NULL,
870    seen_at    TEXT DEFAULT CURRENT_TIMESTAMP,
871    PRIMARY KEY (user_id, library_id)
872);
873
874CREATE TABLE IF NOT EXISTS download_grants (
875    user_id     TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
876    download_id INTEGER NOT NULL REFERENCES downloads(id) ON DELETE CASCADE,
877    granted_at  TEXT DEFAULT CURRENT_TIMESTAMP,
878    PRIMARY KEY (user_id, download_id)
879);
880
881CREATE INDEX IF NOT EXISTS idx_download_grants_download ON download_grants(download_id);
882
883-- Backfill: the active user has seen everything already cached on this device.
884INSERT OR IGNORE INTO user_item_visibility (user_id, item_id)
885SELECT u.id, i.id
886FROM users u CROSS JOIN items i
887WHERE u.is_active = 1
888   OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
889
890INSERT OR IGNORE INTO user_libraries (user_id, library_id)
891SELECT u.id, l.id
892FROM users u CROSS JOIN libraries l
893WHERE u.is_active = 1
894   OR (SELECT COUNT(*) FROM users WHERE is_active = 1) = 0;
895
896-- Downloads already record who asked for them, so every existing row becomes
897-- exactly one grant held by its original requester.
898INSERT OR IGNORE INTO download_grants (user_id, download_id)
899SELECT d.user_id, d.id FROM downloads d;
900"#;
901
902/// Force cached items to be re-fetched so `library_id` is populated.
903///
904/// `save_to_cache` bound `library_id` NULL for every row it wrote, so nothing in
905/// the cache knew which library it came from. The only available association was
906/// the `collection_type` ↔ `item_type` taxonomy, which cannot tell two libraries
907/// of the same type apart — a server with "TV" and "Shows" served both the same
908/// contents — and says nothing at all about a library whose type it does not map
909/// (Books, Photos, Collections, or a mixed library where Jellyfin sends no
910/// collection type).
911///
912/// The write path now records the library. Existing rows cannot be repaired
913/// locally — the association was never stored — so they are marked stale and
914/// re-fetched on next browse, exactly as MIGRATION_018 did for `is_folder`.
915///
916/// Deliberately does not delete anything: downloads, favourites and playback
917/// positions live in other tables and are untouched, and a cleared `synced_at`
918/// only means "ask the server again", so an offline user keeps browsing what
919/// they already had until the next successful fetch.
920///
921/// TRACES: UR-007 | DR-278
922const MIGRATION_025: &str = r#"
923UPDATE items SET synced_at = NULL;
924"#;
925
926/// Remember which server generation wrote the cached catalog.
927///
928/// The cache was version-blind: nothing recorded which Jellyfin generation
929/// produced a row, so a server upgraded underneath the app kept serving rows
930/// parsed under the previous generation's assumptions.
931///
932/// This deliberately does **not** clear `synced_at` the way MIGRATION_025 did.
933/// The column starts NULL, which reads as "no generation recorded yet", and the
934/// first connection after upgrading simply records what it finds. Invalidation
935/// happens only when the recorded generation actually *changes* — punishing
936/// every existing user with a full re-fetch for a server upgrade that has not
937/// happened would cost real bandwidth to defend against nothing. At the time of
938/// writing no installed server is on the newer generation at all.
939///
940/// TRACES: UR-085 | DR-284
941const MIGRATION_026: &str = r#"
942ALTER TABLE servers ADD COLUMN catalog_generation TEXT;
943"#;
944
945/// One canonical "which container lists this item" link, plus an index that
946/// serves a listing in display order.
947///
948/// Jellyfin's `ParentId` is the *storage* parent, not the logical one: in a
949/// series without season folders an episode's `ParentId` is the series while
950/// its `SeasonId` names a virtual season, and a cached episode may arrive
951/// without its season row at all. So listings matched children on four
952/// columns at once (`parent_id`, `album_id`, `season_id`, `series_id`). That
953/// was slow — the `OR` defeated the planner into walking the whole table — and
954/// wrong: every episode carries its series id, so a series answered with its
955/// seasons *and* all their episodes.
956///
957/// `container_id` resolves the logical container once, by rule: an episode
958/// belongs to its season (else its series, else its parent), a season to its
959/// series, a track to its album, anything else to its parent. It is a VIRTUAL
960/// generated column, so every write path — cache, downloads, catalog crawl —
961/// is covered without touching any of them, and it cannot drift from the
962/// columns it is computed from. The index covers the listing's
963/// `ORDER BY sort_name, name` (`sort_name` is usually NULL in the cache).
964///
965/// The placeholders keep offline navigation intact: an episode whose season
966/// or series row was never cached used to surface directly under the series
967/// through the `series_id` match. Now it lists under its season, so the season
968/// (and series, and a track's album) must exist. They are built from the
969/// names the child rows already carry, with `synced_at` NULL — they only show
970/// when a download makes them available, and a real row from the server
971/// replaces them wholesale (`save_to_cache` upserts every field).
972///
973/// TRACES: UR-002, UR-007 | DR-013
974const MIGRATION_027: &str = r#"
975ALTER TABLE items ADD COLUMN container_id TEXT GENERATED ALWAYS AS (
976    CASE item_type
977        WHEN 'Episode' THEN COALESCE(season_id, series_id, parent_id)
978        WHEN 'Season'  THEN COALESCE(series_id, parent_id)
979        WHEN 'Audio'   THEN COALESCE(album_id, parent_id)
980        ELSE parent_id
981    END
982) VIRTUAL;
983
984CREATE INDEX IF NOT EXISTS idx_items_container ON items(container_id, sort_name, name);
985
986INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder, series_id, series_name)
987SELECT season_id, server_id, MAX(library_id), COALESCE(MAX(season_name), 'Season'), 'Season', 1,
988       MAX(series_id), MAX(series_name)
989FROM items
990WHERE item_type = 'Episode' AND season_id IS NOT NULL
991GROUP BY season_id;
992
993INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder)
994SELECT series_id, server_id, MAX(library_id), COALESCE(MAX(series_name), 'Series'), 'Series', 1
995FROM items
996WHERE item_type IN ('Episode', 'Season') AND series_id IS NOT NULL
997GROUP BY series_id;
998
999INSERT OR IGNORE INTO items (id, server_id, library_id, name, item_type, is_folder, album_artist)
1000SELECT album_id, server_id, MAX(library_id), COALESCE(MAX(album_name), 'Album'), 'MusicAlbum', 1,
1001       MAX(album_artist)
1002FROM items
1003WHERE item_type = 'Audio' AND album_id IS NOT NULL
1004GROUP BY album_id;
1005"#;
1006
1007#[cfg(test)]
1008mod migration_027_tests {
1009    use super::*;
1010    use rusqlite::Connection;
1011
1012    fn pre_027_db() -> Connection {
1013        let conn = Connection::open_in_memory().unwrap();
1014        let upto = MIGRATIONS
1015            .iter()
1016            .position(|(name, _)| *name == "027_items_container_id")
1017            .expect("migration 027 must be registered");
1018        for (_, sql) in &MIGRATIONS[..upto] {
1019            conn.execute_batch(sql).unwrap();
1020        }
1021        conn.execute_batch(
1022            "INSERT INTO servers (id, name, url) VALUES ('s', 'S', 'http://s');
1023             -- An episode cached without its season or series rows.
1024             INSERT INTO items (id, server_id, name, item_type, parent_id, season_id, season_name,
1025                                series_id, series_name, library_id)
1026                 VALUES ('ep', 's', 'Pilot', 'Episode', NULL, 'season', 'Season 1',
1027                         'show', 'Show', NULL);
1028             -- A track cached without its album.
1029             INSERT INTO items (id, server_id, name, item_type, album_id, album_name, album_artist)
1030                 VALUES ('trk', 's', 'Song', 'Audio', 'alb', 'Record', 'Band');
1031             -- A folder child: its container is just its parent.
1032             INSERT INTO items (id, server_id, name, item_type) VALUES ('box', 's', 'Box', 'BoxSet');
1033             INSERT INTO items (id, server_id, name, item_type, parent_id)
1034                 VALUES ('film', 's', 'Film', 'Movie', 'box');",
1035        )
1036        .unwrap();
1037        conn
1038    }
1039
1040    fn container(conn: &Connection, id: &str) -> Option<String> {
1041        conn.query_row("SELECT container_id FROM items WHERE id = ?1", [id], |r| {
1042            r.get(0)
1043        })
1044        .unwrap()
1045    }
1046
1047    /// TRACES: UR-002, UR-007 | DR-013
1048    #[test]
1049    fn every_item_resolves_to_its_logical_container() {
1050        let conn = pre_027_db();
1051        conn.execute_batch(MIGRATION_027).unwrap();
1052
1053        assert_eq!(container(&conn, "ep").as_deref(), Some("season"));
1054        assert_eq!(container(&conn, "season").as_deref(), Some("show"));
1055        assert_eq!(container(&conn, "trk").as_deref(), Some("alb"));
1056        assert_eq!(container(&conn, "film").as_deref(), Some("box"));
1057        assert_eq!(container(&conn, "show"), None);
1058    }
1059
1060    /// Containers that were never cached get placeholders named from their
1061    /// children, so an offline episode is still reachable series → season.
1062    ///
1063    /// TRACES: UR-002, UR-007 | DR-013
1064    #[test]
1065    fn missing_containers_get_named_placeholders() {
1066        let conn = pre_027_db();
1067        conn.execute_batch(MIGRATION_027).unwrap();
1068
1069        let row = |id: &str| -> (String, String, Option<String>) {
1070            conn.query_row(
1071                "SELECT name, item_type, synced_at FROM items WHERE id = ?1",
1072                [id],
1073                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
1074            )
1075            .unwrap()
1076        };
1077        assert_eq!(row("season"), ("Season 1".into(), "Season".into(), None));
1078        assert_eq!(row("show"), ("Show".into(), "Series".into(), None));
1079        assert_eq!(row("alb"), ("Record".into(), "MusicAlbum".into(), None));
1080    }
1081
1082    /// Listing a container is one index range, already in display order.
1083    ///
1084    /// TRACES: UR-002, UR-007 | DR-013
1085    #[test]
1086    fn a_container_listing_is_an_ordered_index_range() {
1087        let conn = pre_027_db();
1088        conn.execute_batch(MIGRATION_027).unwrap();
1089        let plan: Vec<String> = conn
1090            .prepare(
1091                "EXPLAIN QUERY PLAN SELECT id FROM items
1092                 WHERE container_id = ?1 ORDER BY sort_name, name",
1093            )
1094            .unwrap()
1095            .query_map(["season"], |r| r.get::<_, String>(3))
1096            .unwrap()
1097            .map(Result::unwrap)
1098            .collect();
1099        let plan = plan.join("\n");
1100        assert!(plan.contains("idx_items_container"), "{plan}");
1101        assert!(
1102            !plan.contains("TEMP B-TREE"),
1103            "listing needs a sort step:\n{plan}"
1104        );
1105    }
1106}
1107
1108#[cfg(test)]
1109mod migration_024_tests {
1110    use super::*;
1111    use rusqlite::{params, Connection};
1112
1113    /// Build a database at the schema version *before* multi-user profiles, so
1114    /// the backfill is exercised against rows that predate it — which is the
1115    /// only state that matters, and the one an in-memory database created from
1116    /// the full migration list can never reproduce.
1117    fn pre_024_db() -> Connection {
1118        let conn = Connection::open_in_memory().unwrap();
1119        let upto = MIGRATIONS
1120            .iter()
1121            .position(|(name, _)| *name == "024_multi_user_profiles")
1122            .expect("migration 024 must be registered");
1123        for (_, sql) in &MIGRATIONS[..upto] {
1124            conn.execute_batch(sql).unwrap();
1125        }
1126        conn
1127    }
1128
1129    fn seed(conn: &Connection, active_user: &str) {
1130        conn.execute(
1131            "INSERT INTO servers (id, name, url) VALUES ('s1', 'Test', 'http://localhost:8096')",
1132            [],
1133        )
1134        .unwrap();
1135        conn.execute(
1136            "INSERT INTO users (id, server_id, username, is_active) VALUES (?1, 's1', 'dad', 1)",
1137            params![active_user],
1138        )
1139        .unwrap();
1140        conn.execute(
1141            "INSERT INTO libraries (id, server_id, name) VALUES ('lib1', 's1', 'Movies')",
1142            [],
1143        )
1144        .unwrap();
1145        conn.execute(
1146            "INSERT INTO items (id, server_id, name, item_type) VALUES ('i1', 's1', 'A Movie', 'Movie')",
1147            [],
1148        )
1149        .unwrap();
1150        conn.execute(
1151            "INSERT INTO downloads (item_id, user_id, file_path, status)
1152             VALUES ('i1', ?1, 'downloads/a.mp4', 'completed')",
1153            params![active_user],
1154        )
1155        .unwrap();
1156    }
1157
1158    fn apply_024(conn: &Connection) {
1159        let (_, sql) = MIGRATIONS
1160            .iter()
1161            .find(|(name, _)| *name == "024_multi_user_profiles")
1162            .unwrap();
1163        conn.execute_batch(sql).unwrap();
1164    }
1165
1166    fn count(conn: &Connection, sql: &str) -> i64 {
1167        conn.query_row(sql, [], |r| r.get(0)).unwrap()
1168    }
1169
1170    /// UT: upgrading an existing install does not blank its library. Without the
1171    /// backfill every cached item becomes invisible to the only user there is.
1172    #[test]
1173    fn backfill_keeps_the_existing_library_visible() {
1174        let conn = pre_024_db();
1175        seed(&conn, "dad");
1176        apply_024(&conn);
1177
1178        assert_eq!(
1179            count(
1180                &conn,
1181                "SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad' AND item_id = 'i1'"
1182            ),
1183            1,
1184            "the active user must still see what was already cached"
1185        );
1186        assert_eq!(
1187            count(
1188                &conn,
1189                "SELECT COUNT(*) FROM user_libraries WHERE user_id = 'dad' AND library_id = 'lib1'"
1190            ),
1191            1
1192        );
1193    }
1194
1195    /// UT: an existing download becomes exactly one grant, held by whoever asked
1196    /// for it — the file is not orphaned and is not handed to anyone else.
1197    #[test]
1198    fn backfill_grants_downloads_to_their_requester() {
1199        let conn = pre_024_db();
1200        seed(&conn, "dad");
1201        apply_024(&conn);
1202
1203        assert_eq!(
1204            count(
1205                &conn,
1206                "SELECT COUNT(*) FROM download_grants WHERE user_id = 'dad'"
1207            ),
1208            1
1209        );
1210    }
1211
1212    /// UT: a second profile added later starts with an empty view. The cache was
1213    /// filled by someone else's browsing and the server never showed it to them,
1214    /// so inheriting it is the leak this whole table exists to close.
1215    #[test]
1216    fn a_later_profile_inherits_nothing() {
1217        let conn = pre_024_db();
1218        seed(&conn, "dad");
1219        apply_024(&conn);
1220
1221        conn.execute(
1222            "INSERT INTO users (id, server_id, username, is_active) VALUES ('kid', 's1', 'kid', 0)",
1223            [],
1224        )
1225        .unwrap();
1226
1227        assert_eq!(
1228            count(
1229                &conn,
1230                "SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'kid'"
1231            ),
1232            0,
1233            "a profile added after the upgrade must not inherit another's cache"
1234        );
1235        assert_eq!(
1236            count(
1237                &conn,
1238                "SELECT COUNT(*) FROM download_grants WHERE user_id = 'kid'"
1239            ),
1240            0
1241        );
1242    }
1243
1244    /// UT: an install whose sole user somehow has `is_active = 0` still gets its
1245    /// library back — the fallback arm of the backfill.
1246    #[test]
1247    fn backfill_covers_an_install_with_no_active_flag() {
1248        let conn = pre_024_db();
1249        seed(&conn, "dad");
1250        conn.execute("UPDATE users SET is_active = 0", []).unwrap();
1251        apply_024(&conn);
1252
1253        assert_eq!(
1254            count(
1255                &conn,
1256                "SELECT COUNT(*) FROM user_item_visibility WHERE user_id = 'dad'"
1257            ),
1258            1
1259        );
1260    }
1261
1262    /// UT: removing a profile takes its per-user rows with it, so a re-added
1263    /// account starts clean rather than resuming someone's stale view.
1264    #[test]
1265    fn removing_a_profile_cascades_its_rows() {
1266        let conn = pre_024_db();
1267        seed(&conn, "dad");
1268        apply_024(&conn);
1269        conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
1270
1271        conn.execute("DELETE FROM users WHERE id = 'dad'", [])
1272            .unwrap();
1273
1274        assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_item_visibility"), 0);
1275        assert_eq!(count(&conn, "SELECT COUNT(*) FROM user_libraries"), 0);
1276        assert_eq!(count(&conn, "SELECT COUNT(*) FROM download_grants"), 0);
1277    }
1278}