"""SQLite-backed media inventory service. This is the main step toward a frontend-agnostic architecture. The Streamlit UI asks this service to build/query an index, but the same class could be exposed through FastAPI to a React frontend without rewriting Jellyfin indexing logic. """ from __future__ import annotations import sqlite3 import time from dataclasses import dataclass from pathlib import Path from typing import Any, Iterable from clients.jellyfin import JellyfinClient from domain.media import display_media_row, normalize_media_item # Local generated database. It is ignored by git and can be rebuilt from # Jellyfin metadata whenever needed. DEFAULT_INDEX_PATH = Path(".cache/media_library_viewer/media_index.sqlite") MEDIA_TYPES = "Movie,Episode,Video" # Only values from this whitelist are interpolated into ORDER BY. User-selected # sort keys map to these known SQL snippets to avoid SQL injection. SORT_COLUMNS = { "title": "title COLLATE NOCASE", "series": "series COLLATE NOCASE", "season": "season_number", "episode": "episode", "type": "type COLLATE NOCASE", "year": "year", "runtime": "runtime_min", "size": "size_bytes", "bitrate": "bitrate_bps", "hdr": "hdr", "video": "video COLLATE NOCASE", "resolution": "height", "date_added": "date_added_ts", "library": "library_name COLLATE NOCASE", "path": "path COLLATE NOCASE", } @dataclass(frozen=True) class MediaIndexStatus: """Lightweight status object displayed by the Media tab.""" exists: bool item_count: int = 0 updated_at: int | None = None updated_at_label: str = "" build_duration_seconds: float | None = None class MediaIndex: """SQLite-backed media inventory. This class is UI-framework independent. Streamlit, a future FastAPI backend, or a React-facing API can all use this service. """ def __init__(self, db_path: Path | str = DEFAULT_INDEX_PATH): self.db_path = Path(db_path) self.db_path.parent.mkdir(parents=True, exist_ok=True) def connect(self) -> sqlite3.Connection: """Open a sqlite connection configured to return Row objects.""" conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row return conn def init_schema(self) -> None: """Create tables/indexes if this is the first use of the index.""" with self.connect() as conn: conn.executescript( """ CREATE TABLE IF NOT EXISTS media_items ( id TEXT PRIMARY KEY, title TEXT, series TEXT, season TEXT, season_number INTEGER, episode INTEGER, type TEXT, year INTEGER, runtime_ticks INTEGER, runtime_min INTEGER, size_bytes INTEGER, bitrate_bps INTEGER, hdr INTEGER, video TEXT, width INTEGER, height INTEGER, resolution TEXT, date_added TEXT, date_added_ts INTEGER, path TEXT, library_id TEXT, library_name TEXT ); CREATE TABLE IF NOT EXISTS index_metadata ( key TEXT PRIMARY KEY, value TEXT ); CREATE INDEX IF NOT EXISTS idx_media_type ON media_items(type); CREATE INDEX IF NOT EXISTS idx_media_library ON media_items(library_id); CREATE INDEX IF NOT EXISTS idx_media_title ON media_items(title COLLATE NOCASE); CREATE INDEX IF NOT EXISTS idx_media_series ON media_items(series COLLATE NOCASE); CREATE INDEX IF NOT EXISTS idx_media_date_added ON media_items(date_added_ts); CREATE INDEX IF NOT EXISTS idx_media_size ON media_items(size_bytes); CREATE INDEX IF NOT EXISTS idx_media_bitrate ON media_items(bitrate_bps); """ ) def set_metadata(self, key: str, value: str | int | float) -> None: """Store a small string metadata value, e.g. build duration.""" self.init_schema() with self.connect() as conn: conn.execute( "INSERT OR REPLACE INTO index_metadata (key, value) VALUES (?, ?)", (key, str(value)), ) def replace_items(self, rows: Iterable[dict[str, Any]]) -> int: """Atomically replace indexed media rows with a freshly built set.""" self.init_schema() row_list = list(rows) columns = [ "id", "title", "series", "season", "season_number", "episode", "type", "year", "runtime_ticks", "runtime_min", "size_bytes", "bitrate_bps", "hdr", "video", "width", "height", "resolution", "date_added", "date_added_ts", "path", "library_id", "library_name", ] placeholders = ",".join(["?"] * len(columns)) with self.connect() as conn: conn.execute("DELETE FROM media_items") conn.executemany( f"INSERT OR REPLACE INTO media_items ({','.join(columns)}) VALUES ({placeholders})", [[row.get(column) for column in columns] for row in row_list], ) conn.execute( "INSERT OR REPLACE INTO index_metadata (key, value) VALUES ('updated_at', ?)", (str(int(time.time())),), ) return len(row_list) def status(self) -> MediaIndexStatus: """Return existence, count, update time, and last build duration.""" if not self.db_path.exists(): return MediaIndexStatus(exists=False) try: with self.connect() as conn: item_count = int(conn.execute("SELECT COUNT(*) FROM media_items").fetchone()[0]) updated_row = conn.execute("SELECT value FROM index_metadata WHERE key='updated_at'").fetchone() duration_row = conn.execute("SELECT value FROM index_metadata WHERE key='build_duration_seconds'").fetchone() except sqlite3.Error: return MediaIndexStatus(exists=False) updated_at = int(updated_row[0]) if updated_row and str(updated_row[0]).isdigit() else None label = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(updated_at)) if updated_at else "" build_duration = None if duration_row: try: build_duration = float(duration_row[0]) except (TypeError, ValueError): build_duration = None return MediaIndexStatus( exists=True, item_count=item_count, updated_at=updated_at, updated_at_label=label, build_duration_seconds=build_duration, ) def query( self, library_id: str | None = None, library_ids: list[str] | None = None, media_types: list[str] | None = None, search: str = "", hdr_filter: str = "All", sort_key: str = "title", sort_order: str = "Ascending", limit: int = 100, offset: int = 0, ) -> tuple[list[dict[str, Any]], int]: """Query indexed media with full-index filters, sorting, and pagination.""" self.init_schema() where = [] params: list[Any] = [] if library_ids: where.append("library_id IN (" + ",".join(["?"] * len(library_ids)) + ")") params.extend(library_ids) elif library_id: where.append("library_id = ?") params.append(library_id) if media_types: where.append("type IN (" + ",".join(["?"] * len(media_types)) + ")") params.extend(media_types) if search: needle = f"%{search.lower()}%" where.append("(LOWER(title) LIKE ? OR LOWER(series) LIKE ? OR LOWER(path) LIKE ?)") params.extend([needle, needle, needle]) if hdr_filter == "HDR only": where.append("hdr = 1") elif hdr_filter == "SDR/unknown only": where.append("(hdr IS NULL OR hdr = 0)") where_sql = " WHERE " + " AND ".join(where) if where else "" sort_sql = SORT_COLUMNS.get(sort_key, SORT_COLUMNS["title"]) direction = "DESC" if sort_order == "Descending" else "ASC" # Always add stable tie-breakers. order_sql = f" ORDER BY {sort_sql} {direction}, series COLLATE NOCASE ASC, season_number ASC, episode ASC, title COLLATE NOCASE ASC" with self.connect() as conn: total = int(conn.execute("SELECT COUNT(*) FROM media_items" + where_sql, params).fetchone()[0]) rows = conn.execute( "SELECT * FROM media_items" + where_sql + order_sql + " LIMIT ? OFFSET ?", [*params, int(limit), int(offset)], ).fetchall() return [display_media_row(dict(row)) for row in rows], total def build_media_index( client: JellyfinClient, user_id: str, libraries: list[dict[str, Any]], index: MediaIndex | None = None, page_size: int = 500, ) -> int: """Fetch Jellyfin pages for all selected libraries and rebuild the index.""" index = index or MediaIndex() started_at = time.perf_counter() normalized_rows: list[dict[str, Any]] = [] for library in libraries: library_id = library.get("Id") library_name = library.get("Name", "") if not library_id: continue start = 0 while True: response = client.items( user_id=user_id, parent_id=library_id, start_index=start, limit=page_size, include_item_types=MEDIA_TYPES, recursive=True, sort_by="SortName", sort_order="Ascending", ) items = response.get("Items", []) normalized_rows.extend(normalize_media_item(item, library_id, library_name) for item in items) start += len(items) total = int(response.get("TotalRecordCount", start)) if not items or start >= total: break count = index.replace_items(normalized_rows) index.set_metadata("build_duration_seconds", f"{time.perf_counter() - started_at:.3f}") return count