Files
jellyfin-scene-search/scene_search/api.py
T
2026-10-09 13:17:19 +01:00

504 lines
16 KiB
Python

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()