Phase 2: Docker and OIDC auth
This commit is contained in:
@@ -1,45 +1,262 @@
|
||||
"""Media router — index status, build, and query."""
|
||||
"""Media router — index status, build, stop, and query."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
|
||||
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||
from media_library_viewer_api.services.media_index import MediaIndex, build_media_index
|
||||
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
|
||||
from media_library_viewer_api.services.media_index import MediaIndex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/media", tags=["media"])
|
||||
_build_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_media_index() -> MediaIndex:
|
||||
return MediaIndex()
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
|
||||
"""Return media index status (exists, count, last updated, build duration)."""
|
||||
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 _staging_db_path(index: MediaIndex) -> Path:
|
||||
return index.db_path.with_name(f"{index.db_path.stem}.building{index.db_path.suffix}")
|
||||
|
||||
|
||||
def _pid_is_alive(pid: int | None) -> bool:
|
||||
if not pid:
|
||||
return False
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
|
||||
|
||||
def _clean_stale_build_state(index: MediaIndex) -> Any:
|
||||
status = index.status()
|
||||
if status.build_running and not _pid_is_alive(status.build_pid):
|
||||
logger.warning("Detected stale media build state pid=%s", status.build_pid)
|
||||
_set_build_metadata(
|
||||
index,
|
||||
{
|
||||
"build_running": False,
|
||||
"build_stage": "stale",
|
||||
"build_message": "Previous media index build stopped unexpectedly",
|
||||
"build_cancel_requested": False,
|
||||
"build_pid": "",
|
||||
"build_error": "Worker process is no longer running",
|
||||
},
|
||||
)
|
||||
status = index.status()
|
||||
return status
|
||||
|
||||
|
||||
def _serialize_status(status: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"exists": status.exists,
|
||||
"item_count": status.item_count,
|
||||
"updated_at": status.updated_at,
|
||||
"updated_at_label": status.updated_at_label,
|
||||
"build_duration_seconds": status.build_duration_seconds,
|
||||
"build_running": status.build_running,
|
||||
"build_stage": status.build_stage,
|
||||
"build_message": status.build_message,
|
||||
"build_progress": status.build_progress,
|
||||
"build_items_processed": status.build_items_processed,
|
||||
"build_items_total": status.build_items_total,
|
||||
"build_current_library": status.build_current_library,
|
||||
"build_library_index": status.build_library_index,
|
||||
"build_libraries_total": status.build_libraries_total,
|
||||
"build_library_progress": status.build_library_progress,
|
||||
"build_library_items_processed": status.build_library_items_processed,
|
||||
"build_library_items_total": status.build_library_items_total,
|
||||
"build_elapsed_seconds": status.build_elapsed_seconds,
|
||||
"build_eta_seconds": status.build_eta_seconds,
|
||||
"build_library_elapsed_seconds": status.build_library_elapsed_seconds,
|
||||
"build_library_eta_seconds": status.build_library_eta_seconds,
|
||||
"build_cancel_requested": status.build_cancel_requested,
|
||||
"build_pid": status.build_pid,
|
||||
"build_error": status.build_error,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/build")
|
||||
def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
|
||||
return [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"media_library_viewer_api.workers.media_index_worker",
|
||||
"--index-path",
|
||||
str(final_db_path),
|
||||
"--staging-path",
|
||||
str(staging_db_path),
|
||||
]
|
||||
|
||||
|
||||
def _start_worker(index: MediaIndex) -> subprocess.Popen[bytes]:
|
||||
staging_path = _staging_db_path(index)
|
||||
staging_path.unlink(missing_ok=True)
|
||||
return subprocess.Popen(
|
||||
_worker_command(index.db_path, staging_path),
|
||||
start_new_session=True,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
|
||||
"""Return media index status (exists, count, build progress, and errors)."""
|
||||
with _build_lock:
|
||||
media_status = _clean_stale_build_state(index)
|
||||
logger.info("Media status requested running=%s stage=%s", media_status.build_running, media_status.build_stage)
|
||||
return _serialize_status(media_status)
|
||||
|
||||
|
||||
@router.post("/build", status_code=status.HTTP_202_ACCEPTED)
|
||||
def post_build_index(
|
||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||
user_id: str = Depends(get_user_id),
|
||||
index: MediaIndex = Depends(get_media_index),
|
||||
) -> dict[str, Any]:
|
||||
"""Rebuild the media index from Jellyfin."""
|
||||
libraries = client.libraries(user_id)
|
||||
count = build_media_index(client, user_id, libraries, index)
|
||||
return {"indexed_items": count}
|
||||
"""Start a media index build in a subprocess worker."""
|
||||
with _build_lock:
|
||||
current_status = _clean_stale_build_state(index)
|
||||
if current_status.build_running and _pid_is_alive(current_status.build_pid):
|
||||
logger.warning("Media build already running pid=%s", current_status.build_pid)
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Media index build already in progress")
|
||||
|
||||
libraries = client.libraries(user_id)
|
||||
logger.info("Starting media index build user_id=%s libraries=%s", user_id, len(libraries))
|
||||
process = _start_worker(index)
|
||||
_set_build_metadata(
|
||||
index,
|
||||
{
|
||||
"build_running": True,
|
||||
"build_stage": "queued",
|
||||
"build_message": "Media index build queued",
|
||||
"build_progress": None,
|
||||
"build_items_processed": 0,
|
||||
"build_items_total": 0,
|
||||
"build_current_library": "",
|
||||
"build_library_index": 0,
|
||||
"build_libraries_total": len(libraries),
|
||||
"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": process.pid,
|
||||
"build_error": "",
|
||||
},
|
||||
)
|
||||
media_status = index.status()
|
||||
logger.info("Media index build started pid=%s", process.pid)
|
||||
return {"status": "started", **_serialize_status(media_status)}
|
||||
|
||||
|
||||
@router.post("/stop", status_code=status.HTTP_202_ACCEPTED)
|
||||
def stop_build(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
|
||||
"""Request cooperative cancellation of a running media index build."""
|
||||
with _build_lock:
|
||||
media_status = _clean_stale_build_state(index)
|
||||
if not media_status.build_running:
|
||||
logger.warning("Stop requested but no media build is running")
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="No media index build is running")
|
||||
|
||||
logger.info("Cooperative stop requested for media build pid=%s", media_status.build_pid)
|
||||
_set_build_metadata(
|
||||
index,
|
||||
{
|
||||
"build_running": True,
|
||||
"build_stage": "canceling",
|
||||
"build_message": "Stopping media index build...",
|
||||
"build_cancel_requested": True,
|
||||
"build_error": "",
|
||||
},
|
||||
)
|
||||
media_status = index.status()
|
||||
logger.info("Media stop requested acknowledged")
|
||||
return {"status": "stop_requested", **_serialize_status(media_status)}
|
||||
|
||||
|
||||
@router.post("/force-stop", status_code=status.HTTP_202_ACCEPTED)
|
||||
def force_stop_build(index: MediaIndex = Depends(get_media_index)) -> dict[str, Any]:
|
||||
"""Terminate the media index worker process if it is stuck."""
|
||||
with _build_lock:
|
||||
media_status = _clean_stale_build_state(index)
|
||||
if not media_status.build_running:
|
||||
logger.warning("Force stop requested but no media build is running")
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="No media index build is running")
|
||||
|
||||
pid = media_status.build_pid
|
||||
if not pid or not _pid_is_alive(pid):
|
||||
logger.warning("Force stop requested but build worker is not alive pid=%s", pid)
|
||||
_set_build_metadata(
|
||||
index,
|
||||
{
|
||||
"build_running": False,
|
||||
"build_stage": "stale",
|
||||
"build_message": "Media index worker is not running",
|
||||
"build_cancel_requested": False,
|
||||
"build_pid": "",
|
||||
"build_error": "Worker process is not running",
|
||||
},
|
||||
)
|
||||
media_status = index.status()
|
||||
return {"status": "already_stopped", **_serialize_status(media_status)}
|
||||
|
||||
logger.info("Force stopping media build pid=%s", pid)
|
||||
try:
|
||||
os.killpg(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
||||
|
||||
deadline = time.time() + 3.0
|
||||
while time.time() < deadline and _pid_is_alive(pid):
|
||||
time.sleep(0.1)
|
||||
|
||||
if _pid_is_alive(pid):
|
||||
try:
|
||||
os.killpg(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except PermissionError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(exc)) from exc
|
||||
|
||||
_set_build_metadata(
|
||||
index,
|
||||
{
|
||||
"build_running": False,
|
||||
"build_stage": "force-stopped",
|
||||
"build_message": "Media index build force stopped",
|
||||
"build_cancel_requested": False,
|
||||
"build_pid": "",
|
||||
"build_error": "",
|
||||
},
|
||||
)
|
||||
media_status = index.status()
|
||||
return {"status": "force_stopped", **_serialize_status(media_status)}
|
||||
|
||||
|
||||
@router.get("/query")
|
||||
@@ -57,13 +274,25 @@ def query_media(
|
||||
index: MediaIndex = Depends(get_media_index),
|
||||
) -> dict[str, Any]:
|
||||
"""Query the media index with filters, sorting, and pagination."""
|
||||
# If no library IDs provided, use all libraries
|
||||
# If no library IDs provided, use all libraries.
|
||||
library_ids = [lid.strip() for lid in libraries.split(",") if lid.strip()] if libraries else None
|
||||
if not library_ids:
|
||||
all_libs = client.libraries(user_id)
|
||||
library_ids = [lib["Id"] for lib in all_libs]
|
||||
|
||||
media_types = [t.strip() for t in types.split(",") if t.strip()]
|
||||
logger.info(
|
||||
"Media query user_id=%s libraries=%s types=%s search=%s hdr=%s sort=%s/%s limit=%s offset=%s",
|
||||
user_id,
|
||||
len(library_ids or []),
|
||||
",".join(media_types),
|
||||
search or "<none>",
|
||||
hdr_filter,
|
||||
sort_key,
|
||||
sort_order,
|
||||
limit,
|
||||
offset,
|
||||
)
|
||||
|
||||
rows, total = index.query(
|
||||
library_ids=library_ids,
|
||||
@@ -76,6 +305,7 @@ def query_media(
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
logger.info("Media query returned total=%s rows=%s", total, len(rows))
|
||||
return {
|
||||
"items": rows,
|
||||
"total": total,
|
||||
|
||||
Reference in New Issue
Block a user