Phase 2: Docker and OIDC auth
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user