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];
    33
    34/// Initial schema migration
    35const 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
    297/// Migration to remove access_token column from users table
    298/// Tokens are now stored in the system keyring (or encrypted file fallback)
    299const 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
    323/// Migration to relax foreign key constraints on user_data table
    324/// Allows tracking playback progress for items not yet synced to local database
    325const 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
    363/// Migration to enhance downloads table with priority and bytes_downloaded
    364const 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
    377/// Migration to relax foreign key constraint on downloads.item_id
    378/// Allows downloading items that haven't been synced to local database yet
    379const 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
    440/// Migration to store item metadata directly in downloads table
    441/// This eliminates dependency on items table being synced and fixes UUID display issues
    442const 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
    450/// Migration to enhance thumbnail caching with LRU eviction support
    451/// - Relaxes foreign key constraint on item_id (allows caching for items not yet synced)
    452/// - Adds last_accessed for LRU eviction
    453/// - Adds file_size for cache limit tracking
    454/// - Creates cache_settings table for configurable limits
    455const 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
    496/// Migration to add video download support and item pinning
    497/// - Adds video-specific metadata columns to downloads (series/episode info, quality preset)
    498/// - Adds media_type to distinguish audio vs video downloads
    499/// - Adds is_pinned column to items table for protecting metadata from cache clear
    500const 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
    523/// Migration to add people/cast caching support
    524/// - Creates people table for caching actor/director/writer/etc info
    525/// - Creates item_people junction table for many-to-many relationships
    526/// - Adds indexes for efficient queries
    527const 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
    563/// Migration to add playback context tracking
    564/// - Adds playback_context_type column to track if user played a container or single item
    565/// - Adds playback_context_id column to store the container ID (album/playlist)
    566/// - Adds index for efficient recently played queries
    567const 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
    579/// Migration to add user-specific player settings
    580/// - Creates user_player_settings table for autoplay and audio preferences
    581/// - Note: Sleep timer state is NOT persisted (cancelled on app close)
    582/// - Autoplay settings control next episode behavior
    583/// - Audio settings for crossfade, gapless playback, and volume normalization
    584const 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
    607/// Migration to track download source (user-initiated vs auto-cached)
    608/// - Adds download_source column to distinguish manual downloads from auto-caching
    609/// - Enables color-coded UI display
    610const 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
    619/// Migration to add composite index for offline mode filtering
    620/// - Adds index on (item_id, status) for efficient JOIN queries in OfflineRepository
    621/// - Significantly improves performance when filtering items by download status
    622const 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
    628/// Migration to add series audio track preferences
    629/// - Stores user's preferred audio track per series
    630/// - Matches tracks by display title and language across episodes
    631/// - Falls back to default track if preferred track not found
    632const 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
    655/// Migration to add device ID storage
    656/// - Creates app_settings table for app-wide configuration (device ID, etc.)
    657/// - Device ID is generated once and persisted for Jellyfin server identification
    658const 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
    672/// Migration to add autoplay episode limit setting
    673/// - Adds autoplay_max_episodes column to user_player_settings
    674/// - 0 = unlimited (default), any positive value limits consecutive auto-plays
    675const MIGRATION_016: &str = r#"
    676ALTER TABLE user_player_settings
    677ADD COLUMN autoplay_max_episodes INTEGER DEFAULT 0;
    678"#;
    679
    680/// Migration to persist the resolved stream URL and target directory on each
    681/// download row. This lets the backend queue pump start a pending download by
    682/// itself (replaying the stored URL) once a concurrency slot frees up, instead
    683/// of relying on the frontend to re-issue every queued item.
    684const 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
    692/// Migration to record whether a cached item is a folder/container vs a playable
    693/// leaf. Needed so channel items (which can be either) route to the player or to
    694/// a browse list correctly. Existing cached rows predate the column and have an
    695/// unknown folder flag, so we force a refresh by clearing their `synced_at`,
    696/// causing the hybrid repository to re-fetch them from the server on next browse.
    697const 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
    704/// Migration to cache the full server genre catalog. Previously genres were
    705/// derived on the fly from cached albums, which meant offline (and the hybrid
    706/// cache-first race) only ever saw genres for the handful of locally-cached
    707/// albums — collapsing the variety on the music/TV/movie landing pages. This
    708/// table stores the complete genre list per library so offline has the real
    709/// catalog and cache-first routing returns the right thing.
    710const 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
    724/// Migration to index `items.season_id`.
    725///
    726/// Episodes link to their season via `season_id` (parent_id is NULL in the
    727/// cache). The container-rollup queries used by the Downloaded browse and the
    728/// disk-usage aggregation join `children.season_id = c.id`, which without this
    729/// index degrades to an unindexable scan — a large synced catalog then makes
    730/// the Downloaded page hang ("Loading your downloads…"). `parent_id`,
    731/// `album_id`, and `series_id` were already indexed; this closes the gap.
    732const MIGRATION_020: &str = r#"
    733CREATE INDEX IF NOT EXISTS idx_items_season ON items(season_id);
    734"#;
    735
    736/// Discard and rebuild the FTS index from the `items` table.
    737///
    738/// Until DR-110, `save_to_cache` used `INSERT OR REPLACE INTO items`. REPLACE
    739/// deletes the conflicting row and inserts a new one, but SQLite only fires
    740/// `AFTER DELETE` triggers on that implicit delete when `recursive_triggers` is
    741/// enabled — it is not (storage/mod.rs sets only `foreign_keys` and
    742/// `journal_mode`), so `items_ad` never ran and the old index row was orphaned.
    743/// Worse, `items.id` is a `TEXT PRIMARY KEY`, so the replacement row also took a
    744/// *fresh rowid* and `items_ai` appended a second entry. Every catalog pass
    745/// therefore left another duplicate behind, and existing installs carry one
    746/// stale entry per item per sync since the database was created.
    747///
    748/// This was invisible in results — the `JOIN items_fts fts ON fts.rowid =
    749/// i.rowid` drops rowids that no longer exist — but it degrades `MATCH`
    750/// permanently, and it becomes a *correctness* problem the moment rowids are
    751/// freed and reused: a new item landing on a freed rowid inherits the orphan's
    752/// index entry and matches queries for the deleted item's title. The DR-110
    753/// deletion sweep frees rowids, so this rebuild must run before it.
    754///
    755/// `'rebuild'` is the FTS5 command for exactly this: it truncates the index and
    756/// repopulates it from the external content table.
    757///
    758/// TRACES: UR-065 | DR-110
    759const MIGRATION_021: &str = r#"
    760INSERT INTO items_fts(items_fts) VALUES('rebuild');
    761"#;
    762
    763/// Full-text index over `people`, mirroring `items_fts`.
    764///
    765/// People live in their own table (migration 009) rather than in `items`, and
    766/// had no FTS index at all — so the People group UR-060 requires could only ever
    767/// be filled by the server leg of search. With the local index now answering
    768/// first, an actor's name has to be findable offline too.
    769///
    770/// TRACES: UR-065, UR-060 | DR-111
    771const 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
    800/// Give temporary downloads a life limit.
    801///
    802/// A cache entry is not a different kind of object from a download — it is a
    803/// download with a shorter life. Modelling it as one `downloads` row with an
    804/// expiry (rather than a parallel cache store) means there is a single storage
    805/// accounting, a single eviction path, and no way for a cache and a download
    806/// library to disagree about what is on disk.
    807///
    808/// `expires_at` is NULL for permanent rows, which is every row that exists
    809/// today: `download_source` defaults to `'user'`, and a user's own download
    810/// never expires. Only `'auto'` rows get a timestamp, and they are reclaimed by
    811/// whichever comes first — the expiry passing, or LRU eviction under space
    812/// pressure (DR-126).
    813///
    814/// TRACES: UR-071 | DR-127
    815const 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
    824/// Multi-user profiles: PIN gate, per-user cache visibility, and download grants.
    825///
    826/// Three tables, one purpose each:
    827///
    828/// - `user_pins` holds the switching gate. The PIN hash lives here rather than
    829///   wrapping the access token, because a wrapped token would leave a locked
    830///   profile unable to resume its own downloads or drain its own sync queue
    831///   until someone typed the code. See DR-268 for why that trade was taken.
    832/// - `user_item_visibility` records what the server has actually shown to each
    833///   user. It is written as a byproduct of the cache write path, never rebuilt,
    834///   so it cannot disagree with what the server returned.
    835/// - `download_grants` separates the bytes from the claim on them, so one file
    836///   can serve several profiles and is unlinked only when the last claim goes.
    837///
    838/// The backfill is not optional. Every existing cache row and download predates
    839/// the concept of a user; without it an upgrading install's library goes blank.
    840/// It grants the *active* user only — other pre-existing rows re-populate from
    841/// the server on next browse, which is strictly safer than handing every
    842/// profile the whole cache. The `OR (SELECT COUNT(*) ...) = 0` arm covers an
    843/// install whose single user somehow has `is_active = 0`.
    844///
    845/// TRACES: UR-082, UR-083 | DR-268, DR-271, DR-272
    846const 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    /// Build a database at the schema version *before* multi-user profiles, so
    905    /// the backfill is exercised against rows that predate it — which is the
    906    /// only state that matters, and the one an in-memory database created from
    907    /// the full migration list can never reproduce.
    908    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    /// UT: upgrading an existing install does not blank its library. Without the
    962    /// backfill every cached item becomes invisible to the only user there is.
    963    #[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    /// UT: an existing download becomes exactly one grant, held by whoever asked
    987    /// for it — the file is not orphaned and is not handed to anyone else.
    988    #[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    /// UT: a second profile added later starts with an empty view. The cache was
    1004    /// filled by someone else's browsing and the server never showed it to them,
    1005    /// so inheriting it is the leak this whole table exists to close.
    1006    #[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    /// UT: an install whose sole user somehow has `is_active = 0` still gets its
    1036    /// library back — the fallback arm of the backfill.
    1037    #[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    /// UT: removing a profile takes its per-user rows with it, so a re-added
    1054    /// account starts clean rather than resuming someone's stale view.
    1055    #[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}