3c432473e5
Backend: - FastAPI app with 17 REST endpoints covering dashboard, monitoring, media index, file browser, and jobs - Reuses existing clients/domain/services unchanged - pydantic-settings config, dependency injection, CORS setup - Auto-generated OpenAPI docs at /docs Frontend: - Vite + React + TypeScript SPA - @tanstack/react-query for data fetching with polling - ag-grid-react for media table and file browser - recharts for monitoring charts - Tailwind CSS styling - 4 pages: Dashboard, Monitoring, Media, File Browser - Typed API client matching all backend endpoints Also: - docs/MIGRATION_PLAN.md with full architecture plan - Updated .gitignore for both subprojects - Streamlit app preserved for now (can coexist)
279 lines
10 KiB
Python
279 lines
10 KiB
Python
"""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
|