Phase 2: Docker and OIDC auth
This commit is contained in:
@@ -7,14 +7,18 @@ through FastAPI to a React frontend without rewriting Jellyfin indexing logic.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.domain.media import display_media_row, normalize_media_item
|
||||
from media_library_viewer_api.path_utils import resolve_remote_media_path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Local generated database. It is ignored by git and can be rebuilt from
|
||||
# Jellyfin metadata whenever needed.
|
||||
@@ -42,6 +46,19 @@ SORT_COLUMNS = {
|
||||
}
|
||||
|
||||
|
||||
def _estimate_remaining_seconds(elapsed_seconds: float, progress: float | None) -> float | None:
|
||||
if progress is None:
|
||||
return None
|
||||
progress = max(0.0, min(1.0, progress))
|
||||
if progress <= 0.0:
|
||||
return None
|
||||
return max(0.0, elapsed_seconds * (1.0 - progress) / progress)
|
||||
|
||||
|
||||
class MediaIndexBuildCancelled(Exception):
|
||||
"""Raised when a media index build is requested to stop."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MediaIndexStatus:
|
||||
"""Lightweight status object displayed by the Media tab."""
|
||||
@@ -51,6 +68,25 @@ class MediaIndexStatus:
|
||||
updated_at: int | None = None
|
||||
updated_at_label: str = ""
|
||||
build_duration_seconds: float | None = None
|
||||
build_running: bool = False
|
||||
build_stage: str = ""
|
||||
build_message: str = ""
|
||||
build_progress: float | None = None
|
||||
build_items_processed: int = 0
|
||||
build_items_total: int = 0
|
||||
build_current_library: str = ""
|
||||
build_library_index: int = 0
|
||||
build_libraries_total: int = 0
|
||||
build_library_progress: float | None = None
|
||||
build_library_items_processed: int = 0
|
||||
build_library_items_total: int = 0
|
||||
build_elapsed_seconds: float | None = None
|
||||
build_eta_seconds: float | None = None
|
||||
build_library_elapsed_seconds: float | None = None
|
||||
build_library_eta_seconds: float | None = None
|
||||
build_cancel_requested: bool = False
|
||||
build_pid: int | None = None
|
||||
build_error: str = ""
|
||||
|
||||
|
||||
class MediaIndex:
|
||||
@@ -66,8 +102,10 @@ class MediaIndex:
|
||||
|
||||
def connect(self) -> sqlite3.Connection:
|
||||
"""Open a sqlite connection configured to return Row objects."""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn = sqlite3.connect(self.db_path, timeout=30)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
return conn
|
||||
|
||||
def init_schema(self) -> None:
|
||||
@@ -170,24 +208,68 @@ class MediaIndex:
|
||||
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()
|
||||
meta = {
|
||||
row[0]: row[1]
|
||||
for row in conn.execute("SELECT key, value FROM index_metadata").fetchall()
|
||||
}
|
||||
except sqlite3.Error:
|
||||
return MediaIndexStatus(exists=False)
|
||||
updated_at = int(updated_row[0]) if updated_row and str(updated_row[0]).isdigit() else None
|
||||
updated_at_raw = meta.get("updated_at", "")
|
||||
updated_at = int(updated_at_raw) if str(updated_at_raw).isdigit() else None
|
||||
label = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(updated_at)) if updated_at else ""
|
||||
duration_raw = meta.get("build_duration_seconds")
|
||||
build_duration = None
|
||||
if duration_row:
|
||||
if duration_raw is not None:
|
||||
try:
|
||||
build_duration = float(duration_row[0])
|
||||
build_duration = float(duration_raw)
|
||||
except (TypeError, ValueError):
|
||||
build_duration = None
|
||||
|
||||
def _bool(key: str, default: bool = False) -> bool:
|
||||
value = str(meta.get(key, str(default))).strip().lower()
|
||||
return value in {"1", "true", "yes", "on"}
|
||||
|
||||
def _int(key: str, default: int = 0) -> int:
|
||||
value = meta.get(key, default)
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def _float(key: str) -> float | None:
|
||||
value = meta.get(key)
|
||||
if value in (None, ""):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
return MediaIndexStatus(
|
||||
exists=True,
|
||||
item_count=item_count,
|
||||
updated_at=updated_at,
|
||||
updated_at_label=label,
|
||||
build_duration_seconds=build_duration,
|
||||
build_running=_bool("build_running"),
|
||||
build_stage=str(meta.get("build_stage", "")),
|
||||
build_message=str(meta.get("build_message", "")),
|
||||
build_progress=_float("build_progress"),
|
||||
build_items_processed=_int("build_items_processed"),
|
||||
build_items_total=_int("build_items_total"),
|
||||
build_current_library=str(meta.get("build_current_library", "")),
|
||||
build_library_index=_int("build_library_index"),
|
||||
build_libraries_total=_int("build_libraries_total"),
|
||||
build_library_progress=_float("build_library_progress"),
|
||||
build_library_items_processed=_int("build_library_items_processed"),
|
||||
build_library_items_total=_int("build_library_items_total"),
|
||||
build_elapsed_seconds=_float("build_elapsed_seconds"),
|
||||
build_eta_seconds=_float("build_eta_seconds"),
|
||||
build_library_elapsed_seconds=_float("build_library_elapsed_seconds"),
|
||||
build_library_eta_seconds=_float("build_library_eta_seconds"),
|
||||
build_cancel_requested=_bool("build_cancel_requested"),
|
||||
build_pid=_int("build_pid") or None,
|
||||
build_error=str(meta.get("build_error", "")),
|
||||
)
|
||||
|
||||
def query(
|
||||
@@ -245,18 +327,79 @@ def build_media_index(
|
||||
libraries: list[dict[str, Any]],
|
||||
index: MediaIndex | None = None,
|
||||
page_size: int = 500,
|
||||
media_root: str = "",
|
||||
fallback_prefix: str = "",
|
||||
progress_callback: Callable[[dict[str, Any]], None] | None = None,
|
||||
should_cancel: Callable[[], bool] | None = None,
|
||||
) -> 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:
|
||||
processed_total = 0
|
||||
expected_total = 0
|
||||
current_library_name = ""
|
||||
current_library_index = 0
|
||||
current_library_processed = 0
|
||||
current_library_total = 0
|
||||
current_library_started_at = started_at
|
||||
|
||||
def ensure_not_cancelled() -> None:
|
||||
if should_cancel and should_cancel():
|
||||
raise MediaIndexBuildCancelled()
|
||||
|
||||
def emit(stage: str, message: str) -> None:
|
||||
if not progress_callback:
|
||||
return
|
||||
elapsed_seconds = time.perf_counter() - started_at
|
||||
library_elapsed_seconds = time.perf_counter() - current_library_started_at
|
||||
overall_progress = (processed_total / expected_total) if expected_total else None
|
||||
library_progress = (current_library_processed / current_library_total) if current_library_total else None
|
||||
progress_callback(
|
||||
{
|
||||
"stage": stage,
|
||||
"message": message,
|
||||
"processed": processed_total,
|
||||
"total": expected_total,
|
||||
"progress": overall_progress,
|
||||
"elapsed_seconds": elapsed_seconds,
|
||||
"eta_seconds": _estimate_remaining_seconds(elapsed_seconds, overall_progress),
|
||||
"library": current_library_name,
|
||||
"library_index": current_library_index,
|
||||
"libraries_total": len(libraries),
|
||||
"library_processed": current_library_processed,
|
||||
"library_total": current_library_total,
|
||||
"library_progress": library_progress,
|
||||
"library_elapsed_seconds": library_elapsed_seconds if current_library_total else None,
|
||||
"library_eta_seconds": _estimate_remaining_seconds(library_elapsed_seconds, library_progress),
|
||||
}
|
||||
)
|
||||
|
||||
ensure_not_cancelled()
|
||||
logger.info("Media index build starting libraries=%s page_size=%s", len(libraries), page_size)
|
||||
emit("starting", "Starting media index build")
|
||||
for library_index, library in enumerate(libraries, start=1):
|
||||
library_id = library.get("Id")
|
||||
library_name = library.get("Name", "")
|
||||
current_library_name = library.get("Name", "")
|
||||
current_library_index = library_index
|
||||
current_library_processed = 0
|
||||
current_library_total = 0
|
||||
current_library_started_at = time.perf_counter()
|
||||
if not library_id:
|
||||
continue
|
||||
ensure_not_cancelled()
|
||||
logger.info(
|
||||
"Media index scanning library index=%s/%s name=%s id=%s",
|
||||
library_index,
|
||||
len(libraries),
|
||||
current_library_name or "Library",
|
||||
library_id,
|
||||
)
|
||||
emit("library-starting", f"Scanning {current_library_name or 'Library'}")
|
||||
start = 0
|
||||
discovered_library_total = None
|
||||
while True:
|
||||
ensure_not_cancelled()
|
||||
response = client.items(
|
||||
user_id=user_id,
|
||||
parent_id=library_id,
|
||||
@@ -267,12 +410,49 @@ def build_media_index(
|
||||
sort_by="SortName",
|
||||
sort_order="Ascending",
|
||||
)
|
||||
ensure_not_cancelled()
|
||||
items = response.get("Items", [])
|
||||
normalized_rows.extend(normalize_media_item(item, library_id, library_name) for item in items)
|
||||
if discovered_library_total is None:
|
||||
discovered_library_total = int(response.get("TotalRecordCount", len(items)))
|
||||
current_library_total = max(discovered_library_total, 0)
|
||||
expected_total += current_library_total
|
||||
normalized_rows.extend(
|
||||
{
|
||||
**row,
|
||||
"path": resolve_remote_media_path(row.get("path", ""), media_root, fallback_prefix),
|
||||
}
|
||||
for row in (
|
||||
normalize_media_item(item, library_id, current_library_name)
|
||||
for item in items
|
||||
)
|
||||
)
|
||||
processed_total += len(items)
|
||||
current_library_processed += len(items)
|
||||
start += len(items)
|
||||
ensure_not_cancelled()
|
||||
emit(
|
||||
"building",
|
||||
f"{current_library_name or 'Library'}: {current_library_processed} / {current_library_total or '?'} items",
|
||||
)
|
||||
logger.debug(
|
||||
"Media index progress library=%s processed=%s/%s total_processed=%s",
|
||||
current_library_name or "Library",
|
||||
current_library_processed,
|
||||
current_library_total,
|
||||
processed_total,
|
||||
)
|
||||
total = int(response.get("TotalRecordCount", start))
|
||||
if not items or start >= total:
|
||||
break
|
||||
ensure_not_cancelled()
|
||||
logger.info("Media index finalizing rows=%s", len(normalized_rows))
|
||||
emit("finalizing", "Writing index to disk")
|
||||
ensure_not_cancelled()
|
||||
count = index.replace_items(normalized_rows)
|
||||
index.set_metadata("build_duration_seconds", f"{time.perf_counter() - started_at:.3f}")
|
||||
duration = time.perf_counter() - started_at
|
||||
index.set_metadata("build_duration_seconds", f"{duration:.3f}")
|
||||
processed_total = count
|
||||
current_library_processed = current_library_total
|
||||
emit("completed", f"Indexed {count} items in {duration:.1f}s")
|
||||
logger.info("Media index build completed count=%s duration=%.2fs", count, duration)
|
||||
return count
|
||||
|
||||
Reference in New Issue
Block a user