from __future__ import annotations import hmac import threading from pathlib import Path from fastapi import Depends, FastAPI, Header, HTTPException, Query from fastapi.responses import FileResponse, Response from pydantic import BaseModel, Field from .caption_jobs import CaptionJobManager from .caption_editor import captioned_media, correct_caption from .config import settings from .database import connect, init_db from .frame_source import FrameSource from .index_jobs import IndexJobManager from .overview_jobs import OverviewJobManager from .overviews import overview_inventory, edit_overview from .search_service import SearchService WEB_INDEX_PATH = Path(__file__).parent / "web" / "index.html" WEB_OVERVIEWS_PATH = Path(__file__).parent / "web" / "overviews.html" WEB_CAPTIONS_PATH = Path(__file__).parent / "web" / "captions.html" app = FastAPI( title="Jellyfin Scene Search API", version="1.0.0", ) _service: SearchService | None = None _index_jobs: IndexJobManager | None = None _caption_jobs: CaptionJobManager | None = None _overview_jobs: OverviewJobManager | None = None _frame_source: FrameSource | None = None _job_start_lock = threading.Lock() class IndexRequest(BaseModel): library_ids: list[str] force: bool = False class CaptionCorrectionRequest(BaseModel): expected_caption: str caption: str = Field(min_length=1, max_length=12000) class CaptionRequest(BaseModel): library_ids: list[str] = Field(min_length=1) force: bool = False folder_path: str | None = None class OverviewEditRequest(BaseModel): expected_overview: str overview: str = Field(min_length=1, max_length=12000) class OverviewRequest(BaseModel): action: str media_id: str | None = None library_id: str | None = None limit: int = Field(default=20, ge=0, le=100000) min_captions: int = Field(default=1, ge=1) max_captions: int = Field(default=64, ge=1, le=64) style: str = "auto" model: str | None = None refresh_drafts: bool = False confirm_publish: bool = False def get_search_service() -> SearchService: global _service if _service is None: _service = SearchService( db_path=settings.db_path, meili_url=settings.meili_url, meili_master_key=settings.meili_master_key, meili_index=settings.meili_index, model_id=settings.model_id, device=settings.device, semantic_ratio=settings.hybrid_semantic_ratio, ) return _service def get_index_jobs() -> IndexJobManager: global _index_jobs if _index_jobs is None: _index_jobs = IndexJobManager(get_search_service()) return _index_jobs def get_caption_jobs() -> CaptionJobManager: global _caption_jobs if _caption_jobs is None: _caption_jobs = CaptionJobManager(get_search_service()) return _caption_jobs def get_overview_jobs() -> OverviewJobManager: global _overview_jobs if _overview_jobs is None: _overview_jobs = OverviewJobManager(settings.db_path) return _overview_jobs def get_frame_source() -> FrameSource: global _frame_source if _frame_source is None: _frame_source = FrameSource() return _frame_source def require_api_key( authorization: str | None = Header(default=None), x_scene_search_key: str | None = Header(default=None), ) -> None: expected = settings.scene_api_key if not expected: return supplied = x_scene_search_key if authorization and authorization.lower().startswith("bearer "): supplied = authorization[7:].strip() if not supplied or not hmac.compare_digest(supplied, expected): raise HTTPException(status_code=401, detail="Invalid API key") @app.get("/", include_in_schema=False) def web_dashboard() -> FileResponse: return FileResponse(WEB_INDEX_PATH, media_type="text/html") @app.get("/overviews", include_in_schema=False) def web_overviews() -> FileResponse: return FileResponse(WEB_OVERVIEWS_PATH, media_type="text/html") @app.get("/captions", include_in_schema=False) def web_captions() -> FileResponse: return FileResponse(WEB_CAPTIONS_PATH, media_type="text/html") @app.get("/api/health") def health() -> dict: return get_search_service().health() @app.get("/api/search", dependencies=[Depends(require_api_key)]) def search( q: str = Query(min_length=1, max_length=500), scene_limit: int = Query(default=50, ge=1, le=100), item_limit: int = Query(default=20, ge=1, le=50), ) -> dict: if get_index_jobs().running or get_caption_jobs().search_blocked: raise HTTPException( status_code=503, detail="A background media job is currently using the local GPU", ) try: return get_search_service().search( q, scene_limit=scene_limit, item_limit=item_limit, jellyfin_only=True, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc @app.get( "/api/jellyfin/libraries", dependencies=[Depends(require_api_key)], ) def jellyfin_libraries() -> dict: try: return {"libraries": get_index_jobs().libraries()} except Exception as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc @app.post( "/api/jellyfin/libraries/selection", dependencies=[Depends(require_api_key)], ) def jellyfin_library_selection(request: IndexRequest) -> dict: try: with _job_start_lock: if get_caption_jobs().running or get_overview_jobs().running: raise RuntimeError( "Captioning or Overview synchronisation is running. Stop it before changing indexed libraries." ) return get_index_jobs().apply_library_selection(request.library_ids) except RuntimeError as exc: message = str(exc) status = 409 if "running" in message.lower() else 400 raise HTTPException(status_code=status, detail=message) from exc except Exception as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc @app.post( "/api/jellyfin/index", dependencies=[Depends(require_api_key)], status_code=202, ) def jellyfin_index(request: IndexRequest) -> dict: try: with _job_start_lock: if get_caption_jobs().running or get_overview_jobs().running: raise RuntimeError( "Captioning or Overview synchronisation is running. Stop it before indexing." ) return get_index_jobs().start( request.library_ids, force=request.force, ) except RuntimeError as exc: message = str(exc) status = 409 if "running" in message.lower() else 400 raise HTTPException(status_code=status, detail=message) from exc except Exception as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc @app.get( "/api/jellyfin/index/status", dependencies=[Depends(require_api_key)], ) def jellyfin_index_status() -> dict: return get_index_jobs().status() @app.get( "/api/jellyfin/index/report", dependencies=[Depends(require_api_key)], ) def jellyfin_index_report( category: str | None = Query(default=None), offset: int = Query(default=0, ge=0), limit: int = Query(default=200, ge=1, le=1000), ) -> dict: if category not in {None, "failed", "removed", "indexed"}: raise HTTPException( status_code=400, detail="category must be failed, removed, indexed, or omitted", ) return get_index_jobs().report( category=category, offset=offset, limit=limit, ) @app.get("/api/caption/folders", dependencies=[Depends(require_api_key)]) def caption_folders(library_id: str = Query(min_length=1)) -> dict: """List indexed folder paths within a library, for optional caption batching.""" from pathlib import PurePosixPath with connect(settings.db_path) as con: paths = con.execute( """SELECT DISTINCT source_path FROM media WHERE source_kind = 'jellyfin' AND jellyfin_library_id = ? AND source_path IS NOT NULL AND source_path != ''""", (library_id,), ).fetchall() folders: set[str] = set() for item in paths: path = str(item["source_path"]).replace("\\", "/") parent = str(PurePosixPath(path).parent) if parent not in ("", ".", "/"): folders.add(parent) return {"folders": sorted(folders, key=str.casefold)} @app.post( "/api/caption", dependencies=[Depends(require_api_key)], status_code=202, ) def caption_start(request: CaptionRequest) -> dict: try: with _job_start_lock: if get_index_jobs().running or get_overview_jobs().running: raise RuntimeError( "Jellyfin indexing or Overview synchronisation is running. Stop it before captioning." ) return get_caption_jobs().start( request.library_ids, force=request.force, folder_path=request.folder_path, ) except RuntimeError as exc: message = str(exc) status = 409 if "running" in message.lower() else 400 raise HTTPException(status_code=status, detail=message) from exc except Exception as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc @app.get( "/api/caption/status", dependencies=[Depends(require_api_key)], ) def caption_status() -> dict: return get_caption_jobs().status() @app.post( "/api/caption/pause", dependencies=[Depends(require_api_key)], ) def caption_pause() -> dict: try: return get_caption_jobs().pause() except RuntimeError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.post( "/api/caption/resume", dependencies=[Depends(require_api_key)], ) def caption_resume() -> dict: try: return get_caption_jobs().resume() except RuntimeError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.post( "/api/caption/stop", dependencies=[Depends(require_api_key)], ) def caption_stop() -> dict: try: return get_caption_jobs().stop() except RuntimeError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.get("/api/captions/media/{media_id}", dependencies=[Depends(require_api_key)]) def captions_for_media( media_id: str, offset: int = Query(default=0, ge=0), limit: int = Query(default=40, ge=1, le=100), ) -> dict: try: return captioned_media(settings.db_path, media_id, offset=offset, limit=limit) except LookupError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc @app.patch("/api/captions/{kind}/{caption_id}", dependencies=[Depends(require_api_key)]) def captions_edit(kind: str, caption_id: str, request: CaptionCorrectionRequest) -> dict: try: with _job_start_lock: if (get_index_jobs().running or get_caption_jobs().running or get_overview_jobs().running): raise HTTPException( status_code=409, detail="Pause/stop indexing, captioning and Overview jobs before editing saved captions", ) return correct_caption( settings.db_path, kind=kind, caption_id=caption_id, expected_caption=request.expected_caption, caption=request.caption, ) except LookupError as exc: raise HTTPException(status_code=404, detail=str(exc)) from exc except (RuntimeError, ValueError) as exc: raise HTTPException( status_code=409 if isinstance(exc, RuntimeError) else 400, detail=str(exc), ) from exc @app.get("/api/overviews/items", dependencies=[Depends(require_api_key)]) def overview_items( library_id: str | None = None, offset: int = Query(default=0, ge=0), limit: int = Query(default=50, ge=1, le=100), ) -> dict: try: return overview_inventory( db_path=settings.db_path, library_id=library_id, offset=offset, limit=limit, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @app.patch("/api/overviews/{media_id}", dependencies=[Depends(require_api_key)]) def overview_edit(media_id: str, request: OverviewEditRequest) -> dict: if get_overview_jobs().running or get_index_jobs().running: raise HTTPException(status_code=409, detail="Wait until Overview/indexing jobs finish") try: return edit_overview( db_path=settings.db_path, media_id=media_id, expected_overview=request.expected_overview, overview=request.overview, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except RuntimeError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.get("/api/overviews/status", dependencies=[Depends(require_api_key)]) def overview_status() -> dict: return get_overview_jobs().status() @app.post("/api/overviews/start", dependencies=[Depends(require_api_key)], status_code=202) def overview_start(request: OverviewRequest) -> dict: if request.action in {"apply", "restore"} and not request.confirm_publish: raise HTTPException( status_code=400, detail="Explicit confirmation is required for Jellyfin metadata changes" ) try: with _job_start_lock: if get_index_jobs().running or get_caption_jobs().running: raise HTTPException( status_code=409, detail="Stop Jellyfin indexing/captioning before starting an Overview job", ) if get_overview_jobs().running: raise HTTPException(status_code=409, detail="An Overview job is already running") return get_overview_jobs().start( action=request.action, media_id=request.media_id, library_id=request.library_id, limit=request.limit, min_captions=request.min_captions, max_captions=request.max_captions, style=request.style, model=request.model, refresh_drafts=request.refresh_drafts, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc except RuntimeError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.post("/api/overviews/stop", dependencies=[Depends(require_api_key)]) def overview_stop() -> dict: try: return get_overview_jobs().stop() except RuntimeError as exc: raise HTTPException(status_code=409, detail=str(exc)) from exc @app.get( "/api/frames/{frame_id}/thumbnail", dependencies=[Depends(require_api_key)], ) def frame_thumbnail(frame_id: str) -> Response: init_db(settings.db_path) with connect(settings.db_path) as con: row = con.execute( """ SELECT f.frame_number, f.thumb_path, m.source_kind, m.jellyfin_item_id, m.jellyfin_media_source_id, m.trickplay_width FROM frames f JOIN media m ON m.id = f.media_id WHERE f.id = ? AND m.source_kind = 'jellyfin' """, (frame_id,), ).fetchone() if row is None: raise HTTPException(status_code=404, detail="Frame not found") try: content = get_frame_source().jpeg_bytes(row) except FileNotFoundError as exc: raise HTTPException( status_code=404, detail=f"Thumbnail file not found: {exc}", ) from exc except Exception as exc: raise HTTPException(status_code=503, detail=str(exc)) from exc return Response(content=content, media_type="image/jpeg") def main() -> None: import uvicorn uvicorn.run( "scene_search.api:app", host=settings.scene_api_host, port=settings.scene_api_port, workers=1, ) if __name__ == "__main__": main()