Phase 2: Docker and OIDC auth

This commit is contained in:
2026-05-04 13:50:53 +02:00
parent 47baee854b
commit 4226628d5a
71 changed files with 9722 additions and 1347 deletions
@@ -0,0 +1 @@
"""Worker entrypoints for background tasks."""
@@ -0,0 +1,200 @@
"""Subprocess worker that builds the media index.
The FastAPI app starts this worker as a separate Python process so the build can
be cooperatively canceled or force-killed without taking down the API server.
"""
from __future__ import annotations
import argparse
import logging
import os
import time
from pathlib import Path
from typing import Any
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.services.media_index import (
MediaIndex,
MediaIndexBuildCancelled,
build_media_index,
)
from media_library_viewer_api.logging_utils import configure_logging, describe_settings
logger = logging.getLogger(__name__)
def _set_build_metadata(index: MediaIndex, state: dict[str, Any]) -> None:
for key, value in state.items():
index.set_metadata(key, "" if value is None else value)
def _cancel_requested(index: MediaIndex) -> bool:
return index.status().build_cancel_requested
def _start_state(index: MediaIndex, pid: int, library_count: int) -> None:
_set_build_metadata(
index,
{
"build_running": True,
"build_stage": "starting",
"build_message": "Starting media index build",
"build_progress": None,
"build_items_processed": 0,
"build_items_total": 0,
"build_current_library": "",
"build_library_index": 0,
"build_libraries_total": library_count,
"build_library_progress": None,
"build_library_items_processed": 0,
"build_library_items_total": 0,
"build_elapsed_seconds": None,
"build_eta_seconds": None,
"build_library_elapsed_seconds": None,
"build_library_eta_seconds": None,
"build_cancel_requested": False,
"build_pid": pid,
"build_error": "",
},
)
def _progress_callback(index: MediaIndex, pid: int, state: dict[str, Any]) -> None:
_set_build_metadata(
index,
{
"build_running": True,
"build_stage": state.get("stage", ""),
"build_message": state.get("message", ""),
"build_progress": state.get("progress"),
"build_items_processed": state.get("processed", 0),
"build_items_total": state.get("total", 0),
"build_current_library": state.get("library", ""),
"build_library_index": state.get("library_index", 0),
"build_libraries_total": state.get("libraries_total", 0),
"build_library_progress": state.get("library_progress"),
"build_library_items_processed": state.get("library_processed", 0),
"build_library_items_total": state.get("library_total", 0),
"build_elapsed_seconds": state.get("elapsed_seconds"),
"build_eta_seconds": state.get("eta_seconds"),
"build_library_elapsed_seconds": state.get("library_elapsed_seconds"),
"build_library_eta_seconds": state.get("library_eta_seconds"),
"build_cancel_requested": False,
"build_pid": pid,
"build_error": "",
},
)
def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> int:
"""Run the media index build in a subprocess."""
settings = get_settings()
configure_logging(settings.log_level)
logger.info("Media index worker starting: %s", describe_settings(settings))
client = get_jellyfin_client()
user_id = get_user_id()
libraries = client.libraries(user_id)
final_index = MediaIndex(final_index_path)
staging_index = MediaIndex(staging_index_path)
pid = os.getpid()
started_at = time.perf_counter()
staging_path = Path(staging_index.db_path)
staging_path.unlink(missing_ok=True)
logger.info("Media index worker pid=%s libraries=%s", pid, len(libraries))
_start_state(final_index, pid, len(libraries))
try:
count = build_media_index(
client,
user_id,
libraries,
index=staging_index,
media_root=settings.media_root,
fallback_prefix=settings.path_prefix,
progress_callback=lambda state: _progress_callback(final_index, pid, state),
should_cancel=lambda: _cancel_requested(final_index),
)
# Swap the staging database into place atomically.
os.replace(staging_index.db_path, final_index.db_path)
completed_index = MediaIndex(final_index.db_path)
logger.info("Media index worker completed count=%s", count)
elapsed = time.perf_counter() - started_at
completed_status = completed_index.status()
_set_build_metadata(
completed_index,
{
"build_running": False,
"build_stage": "completed",
"build_message": "Media index build complete",
"build_progress": 1.0,
"build_items_processed": count,
"build_items_total": count,
"build_current_library": "",
"build_library_index": len(libraries),
"build_libraries_total": len(libraries),
"build_library_progress": 1.0,
"build_library_items_processed": 0,
"build_library_items_total": 0,
"build_elapsed_seconds": elapsed,
"build_eta_seconds": 0.0,
"build_library_elapsed_seconds": 0.0,
"build_library_eta_seconds": 0.0,
"build_cancel_requested": False,
"build_pid": "",
"build_error": "",
# Keep the duration reported by the staging build.
"build_duration_seconds": completed_status.build_duration_seconds or elapsed,
},
)
return 0
except MediaIndexBuildCancelled:
logger.info("Media index worker canceled")
_set_build_metadata(
final_index,
{
"build_running": False,
"build_stage": "canceled",
"build_message": "Media index build canceled",
"build_cancel_requested": False,
"build_pid": "",
"build_error": "",
},
)
return 130
except Exception as exc: # pragma: no cover - defensive subprocess error handling
logger.exception("Media index worker failed")
_set_build_metadata(
final_index,
{
"build_running": False,
"build_stage": "error",
"build_message": "Media index build failed",
"build_cancel_requested": False,
"build_pid": "",
"build_error": str(exc),
},
)
return 1
finally:
# If the build did not complete successfully, the staging DB is disposable.
if staging_path.exists() and staging_path != Path(final_index.db_path):
try:
staging_path.unlink()
except OSError:
pass
def main() -> int:
parser = argparse.ArgumentParser(description="Build the media index in a worker process")
parser.add_argument("--index-path", required=True)
parser.add_argument("--staging-path", required=True)
args = parser.parse_args()
return run_build(args.index_path, args.staging_path)
if __name__ == "__main__": # pragma: no cover
raise SystemExit(main())