mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-13 04:26:28 +03:00
421 lines
17 KiB
Python
Executable File
421 lines
17 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""jellyfin-cli — Jellyfin media server from the terminal.
|
|
|
|
Query recently added media, search your library, browse by collection,
|
|
and check server status. Requires JELLYFIN_URL and JELLYFIN_API_KEY.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import warnings
|
|
from typing import Any, Dict
|
|
|
|
warnings.simplefilter("ignore")
|
|
|
|
import requests
|
|
|
|
DEFAULT_SERVER = "http://localhost:8096"
|
|
ENV_URL = os.getenv("JELLYFIN_URL", DEFAULT_SERVER)
|
|
ENV_KEY = os.getenv("JELLYFIN_API_KEY", "")
|
|
ENV_USER_ID = os.getenv("JELLYFIN_USER_ID", "")
|
|
|
|
GLOBAL_FLAGS: Dict[str, Any] = {"json": False, "dry_run": False}
|
|
|
|
|
|
def die(msg, exit_code=1):
|
|
print(f"Error: {msg}", file=sys.stderr)
|
|
sys.exit(exit_code)
|
|
|
|
|
|
def emit(human, data):
|
|
if GLOBAL_FLAGS.get("json", False):
|
|
print(json.dumps(data, default=str))
|
|
else:
|
|
print(human)
|
|
|
|
|
|
def _preparse_global_flags(argv):
|
|
GLOBAL_BOOLS = {"--json", "--dry-run"}
|
|
flags, filtered = {}, [argv[0]]
|
|
i = 1
|
|
while i < len(argv):
|
|
arg = argv[i]
|
|
if arg in GLOBAL_BOOLS:
|
|
flags[arg.lstrip("-").replace("-", "_")] = True
|
|
i += 1
|
|
elif arg in ("--help", "-h"):
|
|
return flags, argv
|
|
elif arg == "--":
|
|
filtered.extend(argv[i:])
|
|
break
|
|
else:
|
|
filtered.append(arg)
|
|
i += 1
|
|
return flags, filtered
|
|
|
|
|
|
class JellyfinClient:
|
|
"""Jellyfin API client (v10.8+ compatible)."""
|
|
|
|
def __init__(self, url="", key="", dry_run=False):
|
|
self.url = (url or ENV_URL).rstrip("/")
|
|
self.key = key or ENV_KEY
|
|
self.dry_run = dry_run
|
|
|
|
def _get(self, path, params=None):
|
|
url = f"{self.url}{path}"
|
|
if self.dry_run:
|
|
return {"dry_run": True, "url": url, "params": params}
|
|
if not self.key:
|
|
die("JELLYFIN_API_KEY not set. Generate one in Dashboard → API Keys.")
|
|
try:
|
|
resp = requests.get(url, params=params,
|
|
headers={"X-Emby-Token": self.key, "Accept": "application/json"},
|
|
timeout=30)
|
|
except requests.ConnectionError as e:
|
|
die(f"Cannot connect to {self.url}: {e}")
|
|
if resp.status_code == 401:
|
|
die("Auth failed (401). Check JELLYFIN_API_KEY.")
|
|
if resp.status_code >= 400:
|
|
try:
|
|
detail = resp.json()
|
|
except Exception:
|
|
detail = resp.text[:200]
|
|
die(f"API error ({resp.status_code}): {detail}")
|
|
return resp.json()
|
|
|
|
def get_info(self):
|
|
return self._get("/System/Info")
|
|
|
|
def get_users(self):
|
|
return self._get("/Users")
|
|
|
|
def get_recent(self, user_id, limit=10, include_types=None):
|
|
params = {"userId": user_id, "fields": "DateCreated"}
|
|
if include_types:
|
|
params["includeItemTypes"] = ",".join(include_types)
|
|
params["limit"] = limit
|
|
return self._get("/Items/Latest", params=params)
|
|
|
|
def get_next_up(self, user_id, limit=10):
|
|
return self._get(f"/Shows/NextUp",
|
|
params={"userId": user_id, "limit": limit})
|
|
|
|
def search(self, query, limit=20, include_types=None):
|
|
params = {"searchTerm": query, "limit": limit, "recursive": True}
|
|
if include_types:
|
|
params["includeItemTypes"] = ",".join(include_types)
|
|
return self._get("/Search/Hints", params=params)
|
|
|
|
def get_libraries(self):
|
|
return self._get("/Library/MediaFolders")
|
|
|
|
def get_items(self, parent_id, types=None, limit=50, sort_by="SortName",
|
|
sort_order="Ascending", start_index=0):
|
|
params = {"parentId": parent_id, "limit": limit,
|
|
"sortBy": sort_by, "sortOrder": sort_order,
|
|
"startIndex": start_index, "recursive": True}
|
|
if types:
|
|
params["includeItemTypes"] = ",".join(types)
|
|
return self._get("/Items", params=params)
|
|
|
|
def get_item(self, item_id, user_id):
|
|
return self._get(f"/Items/{item_id}", params={"userId": user_id})
|
|
|
|
def get_seasons(self, series_id, user_id):
|
|
return self._get(f"/Shows/{series_id}/Seasons", params={"userId": user_id})
|
|
|
|
def get_episodes(self, series_id, season_id, user_id):
|
|
return self._get(f"/Shows/{series_id}/Episodes",
|
|
params={"seasonId": season_id, "userId": user_id})
|
|
|
|
def get_stats(self):
|
|
return self._get("/Items/Counts")
|
|
|
|
|
|
def fmt_date(ts):
|
|
if not ts:
|
|
return "?"
|
|
return ts[:10] if len(ts) > 10 else ts
|
|
|
|
|
|
def normalize_item(item):
|
|
"""Return available Jellyfin item metadata with stable CLI field names."""
|
|
fields = {
|
|
"id": item.get("Id"),
|
|
"name": item.get("Name"),
|
|
"type": item.get("Type"),
|
|
"year": item.get("ProductionYear"),
|
|
"series": item.get("SeriesName"),
|
|
"season_number": item.get("ParentIndexNumber"),
|
|
"episode_number": item.get("IndexNumber"),
|
|
"overview": item.get("Overview"),
|
|
"date_added": fmt_date(item["DateCreated"]) if item.get("DateCreated") else None,
|
|
"community_rating": item.get("CommunityRating"),
|
|
"official_rating": item.get("OfficialRating"),
|
|
"runtime_ticks": item.get("RunTimeTicks"),
|
|
}
|
|
return {key: value for key, value in fields.items() if value is not None and value != ""}
|
|
|
|
|
|
def cmd_info(client, args):
|
|
if client.dry_run:
|
|
return emit("[dry-run] GET /System/Info; GET /Users", {
|
|
"dry_run": True,
|
|
"requests": [
|
|
{"path": "/System/Info", "params": {}},
|
|
{"path": "/Users", "params": {}},
|
|
],
|
|
})
|
|
data = client.get_info() or {}
|
|
name = data.get("ServerName", "?")
|
|
version = data.get("Version", "?")
|
|
os_info = f"{data.get('OperatingSystem', '?')}"
|
|
users = len(client.get_users() or [])
|
|
emit(f"🖥️ {name} v{version}\n OS: {os_info}\n Users: {users}",
|
|
{"name": name, "version": version, "operating_system": os_info, "users": users})
|
|
|
|
|
|
def cmd_recent(client, args):
|
|
p = argparse.ArgumentParser(prog="jellyfin-cli recent")
|
|
p.add_argument("--limit", type=int, default=10)
|
|
media_type = p.add_mutually_exclusive_group()
|
|
media_type.add_argument("--movies", action="store_true")
|
|
media_type.add_argument("--episodes", action="store_true")
|
|
p.add_argument("--user-id", default=ENV_USER_ID,
|
|
help="Jellyfin user ID (default: JELLYFIN_USER_ID)")
|
|
parsed, _ = p.parse_known_args(args)
|
|
|
|
include_types = ["Episode"] if parsed.episodes else ["Movie"] if parsed.movies else None
|
|
if client.dry_run:
|
|
params = {"userId": parsed.user_id or None, "fields": "DateCreated"}
|
|
if include_types:
|
|
params["includeItemTypes"] = ",".join(include_types)
|
|
params["limit"] = parsed.limit
|
|
return emit("[dry-run] GET /Items/Latest " + json.dumps(params), {
|
|
"dry_run": True, "path": "/Items/Latest", "params": params,
|
|
})
|
|
|
|
if not parsed.user_id:
|
|
die("recent requires --user-id or JELLYFIN_USER_ID.")
|
|
|
|
items = client.get_recent(parsed.user_id, limit=parsed.limit,
|
|
include_types=include_types) or []
|
|
if not items:
|
|
return emit("No recent items.", {"items": []})
|
|
|
|
lines, out = [], []
|
|
for i in items:
|
|
name = i.get("Name", "?")
|
|
itype = i.get("Type", "?")
|
|
date = fmt_date(i.get("DateCreated", ""))
|
|
year = i.get("ProductionYear", "")
|
|
series = i.get("SeriesName", "")
|
|
series_str = f" [{series}]" if series else ""
|
|
lines.append(f" {name:45}{series_str} ({year}) {itype} added {date}")
|
|
out.append({"name": name, "type": itype, "year": year,
|
|
"series": series, "date_added": date, "id": i.get("Id")})
|
|
emit(f"Recently added:\n" + "\n".join(lines), {"items": out})
|
|
|
|
|
|
def cmd_search(client, args):
|
|
p = argparse.ArgumentParser(prog="jellyfin-cli search")
|
|
p.add_argument("--query", "-q", required=True)
|
|
p.add_argument("--type", help="Comma-separated types (Movie,Series,Episode)")
|
|
p.add_argument("--limit", type=int, default=20)
|
|
parsed, _ = p.parse_known_args(args)
|
|
|
|
types = parsed.type.split(",") if parsed.type else None
|
|
if client.dry_run:
|
|
params = {"searchTerm": parsed.query, "limit": parsed.limit, "recursive": True}
|
|
if types:
|
|
params["includeItemTypes"] = ",".join(types)
|
|
return emit("[dry-run] GET /Search/Hints " + json.dumps(params), {
|
|
"dry_run": True, "path": "/Search/Hints", "params": params,
|
|
})
|
|
|
|
data = client.search(parsed.query, limit=parsed.limit, include_types=types) or {}
|
|
hints = data.get("SearchHints", [])
|
|
if not hints:
|
|
return emit("No results.", {"results": []})
|
|
|
|
lines, out = [], []
|
|
for h in hints:
|
|
name = h.get("Name", "?")
|
|
itype = h.get("Type", "?")
|
|
year = h.get("ProductionYear", "")
|
|
series = h.get("Series", "")
|
|
series_str = f" [{series}]" if series else ""
|
|
lines.append(f" {name:45}{series_str} ({year}) [{itype}]")
|
|
out.append({"name": name, "type": itype, "year": year, "series": series, "id": h.get("ItemId")})
|
|
emit(f"{len(hints)} result(s):\n" + "\n".join(lines), {"results": out})
|
|
|
|
|
|
def cmd_next_up(client, args):
|
|
p = argparse.ArgumentParser(prog="jellyfin-cli next-up")
|
|
p.add_argument("--user-id", default=ENV_USER_ID)
|
|
p.add_argument("--limit", type=int, default=10)
|
|
parsed, _ = p.parse_known_args(args)
|
|
|
|
params = {"userId": parsed.user_id or None, "limit": parsed.limit}
|
|
if client.dry_run:
|
|
return emit("[dry-run] GET /Shows/NextUp " + json.dumps(params), {
|
|
"dry_run": True, "path": "/Shows/NextUp", "params": params,
|
|
})
|
|
if not parsed.user_id:
|
|
die("next-up requires --user-id or JELLYFIN_USER_ID.")
|
|
|
|
data = client.get_next_up(parsed.user_id, limit=parsed.limit) or {}
|
|
items = [normalize_item(item) for item in data.get("Items", [])]
|
|
lines = [f" {item.get('name', '?')} [{item.get('type', '?')}]" for item in items]
|
|
emit("Next up:\n" + "\n".join(lines) if lines else "No next-up episodes.", {
|
|
"items": items, "total_record_count": data.get("TotalRecordCount", 0),
|
|
})
|
|
|
|
|
|
def cmd_item(client, args):
|
|
p = argparse.ArgumentParser(prog="jellyfin-cli item")
|
|
p.add_argument("--id", required=True)
|
|
p.add_argument("--user-id", default=ENV_USER_ID)
|
|
parsed, _ = p.parse_known_args(args)
|
|
|
|
params = {"userId": parsed.user_id or None}
|
|
path = f"/Items/{parsed.id}"
|
|
if client.dry_run:
|
|
return emit("[dry-run] GET " + path + " " + json.dumps(params), {
|
|
"dry_run": True, "path": path, "params": params,
|
|
})
|
|
if not parsed.user_id:
|
|
die("item requires --user-id or JELLYFIN_USER_ID.")
|
|
|
|
item = normalize_item(client.get_item(parsed.id, parsed.user_id) or {})
|
|
emit(f"{item.get('name', '?')} [{item.get('type', '?')}]", item)
|
|
|
|
|
|
def cmd_browse(client, args):
|
|
p = argparse.ArgumentParser(prog="jellyfin-cli browse")
|
|
p.add_argument("--library-id", required=True)
|
|
p.add_argument("--type")
|
|
p.add_argument("--limit", type=int, default=50)
|
|
p.add_argument("--start-index", type=int, default=0)
|
|
parsed, _ = p.parse_known_args(args)
|
|
|
|
types = parsed.type.split(",") if parsed.type else None
|
|
params = {
|
|
"parentId": parsed.library_id, "limit": parsed.limit, "sortBy": "SortName",
|
|
"sortOrder": "Ascending", "startIndex": parsed.start_index, "recursive": True,
|
|
}
|
|
if types:
|
|
params["includeItemTypes"] = ",".join(types)
|
|
if client.dry_run:
|
|
return emit("[dry-run] GET /Items " + json.dumps(params), {
|
|
"dry_run": True, "path": "/Items", "params": params,
|
|
})
|
|
|
|
data = client.get_items(parsed.library_id, types=types, limit=parsed.limit,
|
|
start_index=parsed.start_index) or {}
|
|
items = [normalize_item(item) for item in data.get("Items", [])]
|
|
lines = [f" {item.get('name', '?')} [{item.get('type', '?')}]" for item in items]
|
|
emit("Browse results:\n" + "\n".join(lines) if lines else "No items found.", {
|
|
"items": items,
|
|
"start_index": data.get("StartIndex", parsed.start_index),
|
|
"total_record_count": data.get("TotalRecordCount", 0),
|
|
})
|
|
|
|
|
|
def cmd_libraries(client, args):
|
|
if client.dry_run:
|
|
return emit("[dry-run] GET /Library/MediaFolders {}", {
|
|
"dry_run": True, "path": "/Library/MediaFolders", "params": {},
|
|
})
|
|
data = client.get_libraries() or {}
|
|
libraries = data.get("Items", []) if isinstance(data, dict) else data
|
|
if not libraries:
|
|
return emit("No libraries found.", {"libraries": []})
|
|
lines, out = [], []
|
|
for lib in libraries:
|
|
name = lib.get("Name", "?")
|
|
lid = lib.get("Id", "?")
|
|
ctype = lib.get("CollectionType", "?")
|
|
lines.append(f" {name:30} [{ctype}] id={lid}")
|
|
out.append({"name": name, "id": lid, "type": ctype})
|
|
emit(f"{len(libraries)} libraries:\n" + "\n".join(lines), {"libraries": out})
|
|
|
|
|
|
def cmd_stats(client, args):
|
|
if client.dry_run:
|
|
return emit("[dry-run] GET /Items/Counts {}", {
|
|
"dry_run": True, "path": "/Items/Counts", "params": {},
|
|
})
|
|
data = client.get_stats() or {}
|
|
emit(f"📊 Library stats:\n"
|
|
f" Movies: {data.get('MovieCount', '?')}\n"
|
|
f" Series: {data.get('SeriesCount', '?')}\n"
|
|
f" Episodes: {data.get('EpisodeCount', '?')}\n"
|
|
f" Songs: {data.get('SongCount', '?')}",
|
|
{"movies": data.get("MovieCount"), "series": data.get("SeriesCount"),
|
|
"episodes": data.get("EpisodeCount"), "songs": data.get("SongCount")})
|
|
|
|
|
|
def main():
|
|
global GLOBAL_FLAGS
|
|
GLOBAL_FLAGS, filtered_argv = _preparse_global_flags(sys.argv)
|
|
if GLOBAL_FLAGS.get("json", False):
|
|
warnings.simplefilter("ignore")
|
|
|
|
parser = argparse.ArgumentParser(prog="jellyfin-cli", description="Jellyfin media server CLI.",
|
|
epilog="Example: jellyfin-cli search --query dune")
|
|
parser.add_argument("--json", action="store_true", help="Output machine-readable JSON")
|
|
parser.add_argument("--dry-run", action="store_true", help="Preview API requests without network access")
|
|
sub = parser.add_subparsers(dest="command")
|
|
sub.add_parser("info", help="Server info", description="Show Jellyfin server details.", epilog="Example: jellyfin-cli info")
|
|
re = sub.add_parser("recent", help="Recently added", description="Show recently added movies or episodes.", epilog="Example: jellyfin-cli recent --movies --limit 5")
|
|
re.add_argument("--limit", type=int, default=10, help="Maximum items to return (default: 10)")
|
|
media_type = re.add_mutually_exclusive_group()
|
|
media_type.add_argument("--movies", action="store_true", help="Show only movies")
|
|
media_type.add_argument("--episodes", action="store_true", help="Show only episodes")
|
|
re.add_argument("--user-id", help="Jellyfin user ID (default: JELLYFIN_USER_ID)")
|
|
se = sub.add_parser("search", help="Search media", description="Search the Jellyfin media library.", epilog="Example: jellyfin-cli search --query dune --type Movie")
|
|
se.add_argument("--query", "-q", required=True, help="Text to search for")
|
|
se.add_argument("--type", help="Comma-separated item types, such as Movie,Series")
|
|
se.add_argument("--limit", type=int, default=20, help="Maximum results to return (default: 20)")
|
|
nu = sub.add_parser("next-up", help="Next unwatched episodes", description="Show the next unwatched episodes for a Jellyfin user.", epilog="Example: jellyfin-cli next-up --user-id USER_ID --limit 5")
|
|
nu.add_argument("--user-id", help="Jellyfin user ID (default: JELLYFIN_USER_ID)")
|
|
nu.add_argument("--limit", type=int, default=10, help="Maximum episodes to return (default: 10)")
|
|
it = sub.add_parser("item", help="Show item details", description="Show metadata for one Jellyfin library item.", epilog="Example: jellyfin-cli item --id ITEM_ID --user-id USER_ID")
|
|
it.add_argument("--id", required=True, help="Jellyfin item ID")
|
|
it.add_argument("--user-id", help="Jellyfin user ID (default: JELLYFIN_USER_ID)")
|
|
br = sub.add_parser("browse", help="Browse a library", description="List items in a Jellyfin media library.", epilog="Example: jellyfin-cli browse --library-id LIBRARY_ID --type Movie --limit 20")
|
|
br.add_argument("--library-id", required=True, help="Jellyfin library ID")
|
|
br.add_argument("--type", help="Comma-separated item types, such as Movie,Series")
|
|
br.add_argument("--limit", type=int, default=50, help="Maximum items to return (default: 50)")
|
|
br.add_argument("--start-index", type=int, default=0, help="Zero-based result offset (default: 0)")
|
|
sub.add_parser("libraries", help="List libraries", description="List configured media libraries.", epilog="Example: jellyfin-cli libraries")
|
|
sub.add_parser("stats", help="Library statistics", description="Show media library item counts.", epilog="Example: jellyfin-cli stats")
|
|
|
|
args = parser.parse_args(filtered_argv[1:])
|
|
if not args.command:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
client = JellyfinClient(dry_run=GLOBAL_FLAGS.get("dry_run", False))
|
|
|
|
cmd_map = {
|
|
"info": cmd_info, "recent": cmd_recent, "search": cmd_search,
|
|
"next-up": cmd_next_up, "item": cmd_item, "browse": cmd_browse,
|
|
"libraries": cmd_libraries, "stats": cmd_stats,
|
|
}
|
|
handler = cmd_map.get(args.command)
|
|
if not handler:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
remaining = filtered_argv[filtered_argv.index(args.command) + 1:]
|
|
handler(client, remaining)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|