improved jellyfin support
This commit is contained in:
+160
-35
@@ -15,6 +15,16 @@ Usage:
|
||||
-- --fps 5 --verbosity 2
|
||||
|
||||
Anything after "--" is passed through unchanged to scene_analyze.
|
||||
|
||||
Worker mode (--worker) polls the JRay plugin's Tasks/Pending endpoint for a
|
||||
random batch of items with no truth data yet, processing each in turn:
|
||||
|
||||
python scripts/run_from_jellyfin.py \\
|
||||
--jellyfin-url http://jellyfin.local:8096 \\
|
||||
--api-key YOUR_API_KEY \\
|
||||
--gallery whole_gallery.json \\
|
||||
--worker \\
|
||||
-- --fps 5
|
||||
"""
|
||||
|
||||
import argparse
|
||||
@@ -22,57 +32,71 @@ import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from filter_gallery import jf_get, find_item_id, fetch_cast_person_ids
|
||||
|
||||
|
||||
def fetch_item_path(base_url: str, api_key: str, item_id: str) -> tuple[str, str]:
|
||||
"""Return (Name, Path) for a Jellyfin item."""
|
||||
data = jf_get(base_url, api_key, f"/Items/{item_id}", Fields="Path")
|
||||
path = data.get("Path")
|
||||
data = jf_get(base_url, api_key, "/Items", Ids=item_id, Fields="Path", Recursive="true")
|
||||
items = data.get("Items", [])
|
||||
if not items:
|
||||
raise ValueError(f"Item {item_id} not found")
|
||||
path = items[0].get("Path")
|
||||
if not path:
|
||||
raise ValueError(f"Item {item_id} has no Path (not a single media file?)")
|
||||
return data.get("Name", item_id), path
|
||||
return items[0].get("Name", item_id), path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--jellyfin-url", required=True)
|
||||
parser.add_argument("--api-key", required=True)
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--item-id", help="Jellyfin item id of the title")
|
||||
group.add_argument("--title", help="Title to search for (uses first match)")
|
||||
parser.add_argument("--item-types", default="Movie,Episode",
|
||||
help="Item types to search when using --title (default: Movie,Episode)")
|
||||
parser.add_argument("--gallery", required=True,
|
||||
help="Global gallery.json built by make_jellyfin_gallery.py")
|
||||
parser.add_argument("--no-filter", action="store_true",
|
||||
help="Skip per-title cast filtering and pass --gallery through as-is")
|
||||
parser.add_argument("--output", default=None,
|
||||
help="scene_analyze output JSON (default: <title>.json)")
|
||||
parser.add_argument("--bin", default="build/scene_analyze",
|
||||
help="Path to scene_analyze binary (default: build/scene_analyze)")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Resolve and print the scene_analyze command without running it")
|
||||
args, extra = parser.parse_known_args()
|
||||
if extra and extra[0] == "--":
|
||||
extra = extra[1:]
|
||||
def fetch_pending_tasks(base_url: str, api_key: str, limit: int = 10) -> list[dict]:
|
||||
"""GET /Plugins/JRay/Tasks/Pending — a random batch of items with no truth data yet.
|
||||
|
||||
item_id = args.item_id
|
||||
if item_id is None:
|
||||
item_id = find_item_id(args.jellyfin_url, args.api_key, args.title, args.item_types.split(","))
|
||||
print(f"Resolved title to item id {item_id}", file=sys.stderr)
|
||||
Returns a list of {"item_id", "path", "name"}; an empty list means
|
||||
there's nothing left to do (for now).
|
||||
"""
|
||||
return jf_get(base_url, api_key, "/Plugins/JRay/Tasks/Pending", limit=limit)
|
||||
|
||||
|
||||
def push_truth(base_url: str, api_key: str, item_id: str, output_path: str) -> None:
|
||||
"""PUT the scene_analyze output JSON to the JRay plugin's Truth endpoint.
|
||||
|
||||
Requires an Administrator API key (reuses --api-key).
|
||||
"""
|
||||
url = base_url.rstrip("/") + f"/Plugins/JRay/Items/{item_id}/Truth"
|
||||
headers = {"X-Emby-Token": api_key, "Content-Type": "application/json"}
|
||||
|
||||
# --verbosity 1 (standard) adds a "frames" array with per-frame bbox/
|
||||
# similarity data for local debugging — the Truth schema only needs
|
||||
# schema_version/movie/sample_fps/anneal_sec/actors, and "frames" can
|
||||
# be tens of MB for a full episode, well past typical proxy body limits.
|
||||
payload = json.loads(Path(output_path).read_text())
|
||||
if payload.pop("frames", None) is not None:
|
||||
print(f"Stripped per-frame data before push ({output_path} keeps it locally)", file=sys.stderr)
|
||||
body = json.dumps(payload)
|
||||
|
||||
r = requests.put(url, headers=headers, data=body, timeout=30)
|
||||
if r.status_code == 204:
|
||||
print(f"Pushed {output_path} -> {url}", file=sys.stderr)
|
||||
return
|
||||
if r.status_code in (401, 403):
|
||||
raise RuntimeError(f"Push failed ({r.status_code}): API key needs Administrator rights for {url}")
|
||||
if r.status_code == 400:
|
||||
raise RuntimeError(f"Push failed (400 Bad Request): {r.text}")
|
||||
r.raise_for_status()
|
||||
|
||||
|
||||
def process_item(args, item_id: str, extra: list[str]) -> None:
|
||||
"""Resolve, analyze, and (unless --no-push) push truth for one Jellyfin item."""
|
||||
name, movie_path = fetch_item_path(args.jellyfin_url, args.api_key, item_id)
|
||||
print(f"Resolved {name!r} -> {movie_path}", file=sys.stderr)
|
||||
if not Path(movie_path).is_file():
|
||||
sys.exit(f"Resolved path does not exist on this filesystem: {movie_path}\n"
|
||||
f"(this tool must share Jellyfin's media mount)")
|
||||
raise RuntimeError(f"Resolved path does not exist on this filesystem: {movie_path} "
|
||||
f"(this tool must share Jellyfin's media mount)")
|
||||
|
||||
output = args.output or f"{name}.json"
|
||||
|
||||
@@ -91,8 +115,11 @@ def main():
|
||||
filtered_file.close()
|
||||
gallery_path = filtered_file.name
|
||||
|
||||
# Default to minimal output (just actor/scene windows, no per-frame
|
||||
# data) — that's all the Truth push needs. Placed before *extra so a
|
||||
# debug run can override with `-- --verbosity 1` (last flag wins).
|
||||
cmd = [args.bin, "--movie", movie_path, "--gallery", gallery_path,
|
||||
"--output", output, *extra]
|
||||
"--output", output, "--verbosity", "0", *extra]
|
||||
print("Running:", " ".join(cmd), file=sys.stderr)
|
||||
|
||||
if args.dry_run:
|
||||
@@ -104,6 +131,104 @@ def main():
|
||||
if filtered_file is not None:
|
||||
Path(filtered_file.name).unlink(missing_ok=True)
|
||||
|
||||
if not args.no_push:
|
||||
push_truth(args.jellyfin_url, args.api_key, item_id, output)
|
||||
|
||||
|
||||
def run_worker(args, extra: list[str]) -> None:
|
||||
"""Poll /Plugins/JRay/Tasks/Pending and process items until the backlog is empty.
|
||||
|
||||
The endpoint returns a random sample, so repeated polling naturally
|
||||
spreads work across the backlog without server-side task tracking. An
|
||||
empty array means there's nothing left to do (for now).
|
||||
"""
|
||||
while True:
|
||||
try:
|
||||
tasks = fetch_pending_tasks(args.jellyfin_url, args.api_key, args.poll_limit)
|
||||
except requests.HTTPError as e:
|
||||
print(f"Failed to fetch pending tasks: {e}", file=sys.stderr)
|
||||
tasks = []
|
||||
|
||||
if not tasks:
|
||||
print("No pending tasks.", file=sys.stderr)
|
||||
if args.once:
|
||||
return
|
||||
time.sleep(args.poll_interval)
|
||||
continue
|
||||
|
||||
for task in tasks:
|
||||
item_id = task["item_id"]
|
||||
label = task.get("name") or task.get("path") or item_id
|
||||
print(f"=== {label} ({item_id}) ===", file=sys.stderr)
|
||||
try:
|
||||
process_item(args, item_id, extra)
|
||||
except Exception as e:
|
||||
print(f"Failed to process {label!r} ({item_id}): {e}", file=sys.stderr)
|
||||
|
||||
if args.once:
|
||||
return
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--jellyfin-url", required=True)
|
||||
parser.add_argument("--api-key", required=True)
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument("--item-id", help="Jellyfin item id of the title")
|
||||
group.add_argument("--title", help="Title to search for (uses first match)")
|
||||
group.add_argument("--worker", action="store_true",
|
||||
help="Poll /Plugins/JRay/Tasks/Pending for work instead of "
|
||||
"processing a single --item-id/--title")
|
||||
parser.add_argument("--poll-limit", type=int, default=10,
|
||||
help="Tasks/Pending batch size for --worker (default: 10, max 100)")
|
||||
parser.add_argument("--poll-interval", type=float, default=60,
|
||||
help="Seconds to wait between polls when --worker finds nothing "
|
||||
"(default: 60)")
|
||||
parser.add_argument("--once", action="store_true",
|
||||
help="With --worker, process one batch and exit instead of "
|
||||
"looping forever")
|
||||
parser.add_argument("--item-types", default="Movie,Episode",
|
||||
help="Item types to search when using --title (default: Movie,Episode)")
|
||||
parser.add_argument("--gallery", required=True,
|
||||
help="Global gallery.json built by make_jellyfin_gallery.py")
|
||||
parser.add_argument("--no-filter", action="store_true",
|
||||
help="Skip per-title cast filtering and pass --gallery through as-is")
|
||||
parser.add_argument("--output", default=None,
|
||||
help="scene_analyze output JSON (default: <title>.json; "
|
||||
"ignored with --worker, which always uses <title>.json)")
|
||||
parser.add_argument("--bin", default="build/scene_analyze",
|
||||
help="Path to scene_analyze binary (default: build/scene_analyze)")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Resolve and print the scene_analyze command without running it")
|
||||
parser.add_argument("--no-push", action="store_true",
|
||||
help="Don't push results to the JRay plugin after the run "
|
||||
"(local-only/debug; --output is still written)")
|
||||
args, extra = parser.parse_known_args()
|
||||
if extra and extra[0] == "--":
|
||||
extra = extra[1:]
|
||||
|
||||
if args.worker:
|
||||
if args.output:
|
||||
sys.exit("--output is incompatible with --worker (each item needs its own file)")
|
||||
run_worker(args, extra)
|
||||
return
|
||||
|
||||
if not args.item_id and not args.title:
|
||||
sys.exit("one of --item-id, --title, or --worker is required")
|
||||
|
||||
item_id = args.item_id
|
||||
if item_id is None:
|
||||
item_id = find_item_id(args.jellyfin_url, args.api_key, args.title, args.item_types.split(","))
|
||||
print(f"Resolved title to item id {item_id}", file=sys.stderr)
|
||||
|
||||
try:
|
||||
process_item(args, item_id, extra)
|
||||
except RuntimeError as e:
|
||||
sys.exit(str(e))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user