mirror of
https://github.com/MrSleeps/jellyfin-scene-search.git
synced 2026-11-14 22:11:27 +00:00
504 lines
16 KiB
Python
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()
|