refactor(skills): drop -cli suffix from six consumer-API skills

Rename ghost-cli, jira-cli, jellyfin-cli, openlibrary-cli, tmdb-cli,
and tempest-cli to ghost, jira, jellyfin, openlibrary, tmdb, and tempest
via git mv. Rewrite frontmatter name fields to match new directories,
rename bundled scripts preserving executable bits, update internal
invocation strings and README quick-start examples, and relocate the
jellyfin pytest suite to jellyfin/scripts/ with its SCRIPT constant now
resolving to the renamed sibling script.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
This commit is contained in:
Magnus Hedemark
2026-08-26 02:10:49 -04:00
co-authored by factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent e10508b034
commit dd97e846ec
20 changed files with 243 additions and 243 deletions
+44
View File
@@ -0,0 +1,44 @@
# Jellyfin Media Server from the Terminal
Query your Jellyfin media library — recently added movies and episodes, search and inspect items, browse library contents, see next-up episodes, and check server stats.
## Why Install This Skill
When your agent loads this skill, it can **navigate your home media server** without opening a browser. That means:
- **See what's new** — recently added movies and TV episodes
- **Search your library** — find any movie, show, or episode by keyword
- **Navigate your library** — inspect search results, browse collections, and page through items
- **See what is next** — find the next unwatched episodes for a user
- **Check server details** — server name, version, operating system, user count
## What You Get
| Directory | Purpose |
|-----------|---------|
| `SKILL.md` | Complete command reference with setup and examples |
| `scripts/jellyfin` | CLI tool for Jellyfin API operations |
## Quick Start
```bash
scripts/jellyfin --help
export JELLYFIN_URL="http://your-server:8096"
export JELLYFIN_API_KEY="your-api-key"
export JELLYFIN_USER_ID="your-jellyfin-user-id" # required by recent, next-up, and item
```
API key from Dashboard → API Keys in the Jellyfin admin panel.
```bash
scripts/jellyfin search --query "dune" --type Movie
scripts/jellyfin next-up --limit 5
```
## Triggers
Load this when asking about Jellyfin, media server content, recently added movies or TV, or browsing your home media library.
## Requirements
Python 3.8+ with `requests` library. Jellyfin server with API key; `recent`, `next-up`, and `item` also require a Jellyfin user ID.
+134
View File
@@ -0,0 +1,134 @@
---
name: jellyfin
description: Query your Jellyfin media server from the terminal — recently added media,
search, item details, next-up episodes, library browsing, server info, and stats. Use
when the user asks about Jellyfin, media server, movies, TV shows, next episodes, or
their media library.
license: MIT
compatibility: Requires JELLYFIN_URL (default http://localhost:8096) and JELLYFIN_API_KEY
env vars; `recent`, `next-up`, and `item` also require JELLYFIN_USER_ID or --user-id.
Python 3.8+ and the `requests` library. Generate an API key at Dashboard → API Keys in the Jellyfin admin panel.
metadata:
tags: jellyfin, media-server, movies, tv, episodes, recently-added, library, home-media,
api-client
sources: https://jellyfin.org/docs/general/clients/api, https://jellyfin.org/downloads
---
# jellyfin — Jellyfin Media Server from the Terminal
Query recently added movies and TV episodes, search and inspect media, browse libraries, see next-up episodes, check server info, and view library statistics — all from your Jellyfin server's REST API.
## Setup
1. Make sure your Jellyfin server is running and accessible.
2. Generate an API key in the Jellyfin Dashboard → **API Keys**`+` to create a new key.
3. Set these environment variables:
```bash
export JELLYFIN_URL="http://your-server:8096" # include protocol and port
export JELLYFIN_API_KEY="your-api-key-here"
export JELLYFIN_USER_ID="your-jellyfin-user-id" # required by recent, next-up, and item
```
Run the bundled CLI as `scripts/jellyfin`. `--help` and `--dry-run` work without credentials.
## Essential Commands
### info — Server information
```bash
scripts/jellyfin info # server name, version, OS, user count
scripts/jellyfin info --json # machine-readable
scripts/jellyfin --dry-run info # preview API requests
```
Shows: server name, version, operating system, number of users.
### recent — Recently added media
```bash
scripts/jellyfin recent # last 10 items added
scripts/jellyfin recent --limit 20 # more results
scripts/jellyfin recent --movies # only recently added movies
scripts/jellyfin recent --episodes # only recently added episodes
scripts/jellyfin recent --user-id USER_ID # override JELLYFIN_USER_ID
scripts/jellyfin recent --movies --limit 5 # top 5 recently added movies
scripts/jellyfin recent --json # machine-readable
```
Uses Jellyfin's current `/Items/Latest` endpoint. `--movies` and `--episodes` send `includeItemTypes` to the server, so the requested limit applies to the selected media type. Shows: name, type (Movie/Episode), production year, series name (for episodes), date added.
### search — Search your media library
```bash
scripts/jellyfin search --query "dune" # search everything
scripts/jellyfin search --query "dune" --type Movie # movies only
scripts/jellyfin search --query "star trek" --type Series,Episode
scripts/jellyfin search --query "inception" --limit 5 # top 5 results
scripts/jellyfin search --query "dune" --json # machine-readable
```
The `--type` flag accepts a comma-separated list of item types (e.g. `Movie,Series,Episode`).
### Navigation — Inspect media and browse libraries
```bash
scripts/jellyfin search --query "dune" --type Movie # find an item ID
scripts/jellyfin item --id ITEM_ID # inspect that item
scripts/jellyfin libraries # find a library ID
scripts/jellyfin browse --library-id LIBRARY_ID --type Movie --limit 20
scripts/jellyfin browse --library-id LIBRARY_ID --start-index 20
scripts/jellyfin next-up --limit 10 # next episodes for JELLYFIN_USER_ID
scripts/jellyfin next-up --user-id USER_ID --json
```
Use `search -> item` to look up a result's metadata, and `libraries -> browse` to page through a collection. `next-up` returns the next unwatched episodes for the selected user. `item` and `next-up` require `JELLYFIN_USER_ID` or `--user-id`; all three commands are read-only.
### libraries — List media libraries
```bash
scripts/jellyfin libraries # all configured libraries
scripts/jellyfin libraries --json # machine-readable
```
Shows: library name, collection type (movies, tvshows, music, etc.), library ID.
### stats — Library statistics
```bash
scripts/jellyfin stats # movie, series, episode, song counts
scripts/jellyfin stats --json # machine-readable
```
Shows: total count of movies, series, episodes, and songs in the library.
## Global Flags
These flags work anywhere in the command — before or after the subcommand:
```bash
scripts/jellyfin --json recent --limit 5 # JSON output
scripts/jellyfin recent --limit 5 --json # same result, after subcommand
scripts/jellyfin --dry-run search --query "dune" # preview request without API call
```
| Flag | Effect |
|------|--------|
| `--json` | Output machine-readable JSON instead of human-readable text |
| `--dry-run` | Show each request path and parameters without executing it |
## Known Gotchas
- **JELLYFIN_URL must include protocol and port** — Both are required, e.g. `http://192.168.1.100:8096`. A bare hostname or IP without `http://` and `:8096` will fail. The default is `http://localhost:8096`.
- **User-scoped commands require an explicit user** — Set `JELLYFIN_USER_ID` or pass `--user-id USER_ID` to `recent`, `next-up`, or `item`. The CLI never selects an administrator automatically. A real request without either value fails before network access; dry-run previews the request with a null user ID.
- **Recent type filtering is server-side** — `--movies` and `--episodes` become the `/Items/Latest` `includeItemTypes` parameter before `limit`; no local filtering is applied.
- **Search type values** — The `--type` flag for `search` uses Jellyfin item type names (e.g. `Movie`, `Series`, `Episode`, `MusicArtist`, `MusicAlbum`). Multiple types are comma-separated without spaces.
- **API key location** — Generate the key in the Jellyfin Dashboard under **Dashboard → API Keys**. The key is sent as the `X-Emby-Token` header.
- **Lazy auth** — `--help` and `--dry-run` work even when `JELLYFIN_URL` and `JELLYFIN_API_KEY` are not set. Dry-run reports request paths and parameters but never sends credentials or makes a network call.
- **No pagination** — Every command returns a single page of results. The CLI does not auto-paginate beyond the first response. Use `--limit` to control result size.
## References
- [scripts/jellyfin](scripts/jellyfin) — The bundled read-only CLI binary with `--json`, `--dry-run`, and lazy authentication.
- [Jellyfin API Docs](https://jellyfin.org/docs/general/clients/api) — Official API documentation.
- [Jellyfin Downloads](https://jellyfin.org/downloads) — Server download and setup guide.
+420
View File
@@ -0,0 +1,420 @@
#!/usr/bin/env python3
"""jellyfin — 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 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 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 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 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 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", description="Jellyfin media server CLI.",
epilog="Example: jellyfin 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 info")
re = sub.add_parser("recent", help="Recently added", description="Show recently added movies or episodes.", epilog="Example: jellyfin 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 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 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 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 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 libraries")
sub.add_parser("stats", help="Library statistics", description="Show media library item counts.", epilog="Example: jellyfin 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()
+221
View File
@@ -0,0 +1,221 @@
import contextlib
import importlib.machinery
import importlib.util
import io
import json
import pathlib
import subprocess
import unittest
SCRIPT = pathlib.Path(__file__).resolve().parent / "jellyfin"
LOADER = importlib.machinery.SourceFileLoader("jellyfin_cli", str(SCRIPT))
SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER)
jellyfin_cli = importlib.util.module_from_spec(SPEC)
LOADER.exec_module(jellyfin_cli)
class FakeClient:
def __init__(self, libraries=None, dry_run=False):
self.dry_run = dry_run
self.libraries = libraries
self.recent_calls = []
def get_libraries(self):
return self.libraries
def get_recent(self, user_id, limit=10, include_types=None):
self.recent_calls.append((user_id, limit, include_types))
return [{"Name": "Arrival", "Type": "Movie", "Id": "movie-1"}]
class NavigationFakeClient:
def __init__(self, dry_run=False):
self.dry_run = dry_run
self.next_up_calls = []
self.item_calls = []
self.items_calls = []
def get_next_up(self, user_id, limit=10):
self.next_up_calls.append((user_id, limit))
return {
"Items": [{"Name": "The Signal", "Type": "Episode", "Id": "episode-1",
"SeriesName": "Voyagers", "IndexNumber": 4}],
"StartIndex": 0,
"TotalRecordCount": 9,
}
def get_item(self, item_id, user_id):
self.item_calls.append((item_id, user_id))
return {"Name": "The Signal", "Type": "Episode", "Id": item_id,
"SeriesName": "Voyagers", "IndexNumber": 4,
"Overview": "A message arrives."}
def get_items(self, parent_id, types=None, limit=50, sort_by="SortName",
sort_order="Ascending", start_index=0):
self.items_calls.append((parent_id, types, limit, sort_by, sort_order, start_index))
return {
"Items": [{"Name": "Arrival", "Type": "Movie", "Id": "movie-1",
"ProductionYear": 2016}],
"StartIndex": start_index,
"TotalRecordCount": 1,
}
class JellyfinCliTests(unittest.TestCase):
def setUp(self):
self.flags = jellyfin_cli.GLOBAL_FLAGS
self.env_user_id = jellyfin_cli.ENV_USER_ID
jellyfin_cli.GLOBAL_FLAGS = {"json": True, "dry_run": False}
def tearDown(self):
jellyfin_cli.GLOBAL_FLAGS = self.flags
jellyfin_cli.ENV_USER_ID = self.env_user_id
def test_hardened_recent_and_libraries_contracts(self):
output = io.StringIO()
libraries = FakeClient(libraries={"Items": [{"Name": "Films", "Id": "lib-1", "CollectionType": "movies"}]})
with contextlib.redirect_stdout(output):
jellyfin_cli.cmd_libraries(libraries, [])
self.assertEqual(json.loads(output.getvalue())["libraries"][0]["name"], "Films")
calls = []
client = jellyfin_cli.JellyfinClient()
client._get = lambda path, params=None: calls.append((path, params)) or []
client.get_recent("user-1", limit=3, include_types=["Movie", "Episode"])
self.assertEqual(calls, [("/Items/Latest", {"userId": "user-1", "includeItemTypes": "Movie,Episode", "limit": 3, "fields": "DateCreated"})])
recent = FakeClient()
with contextlib.redirect_stdout(io.StringIO()):
jellyfin_cli.cmd_recent(recent, ["--user-id", "user-1", "--movies", "--limit", "2"])
self.assertEqual(recent.recent_calls, [("user-1", 2, ["Movie"])])
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
jellyfin_cli.cmd_recent(recent, ["--movies", "--episodes"])
result = subprocess.run(
[str(SCRIPT), "recent", "--movies", "--episodes"],
capture_output=True,
text=True,
)
self.assertEqual(result.returncode, 2)
self.assertIn("not allowed with argument", result.stderr)
def test_recent_requires_user_before_network_and_dry_run_previews_request(self):
jellyfin_cli.ENV_USER_ID = ""
client = FakeClient()
error = io.StringIO()
with contextlib.redirect_stderr(error), self.assertRaises(SystemExit):
jellyfin_cli.cmd_recent(client, [])
self.assertIn("JELLYFIN_USER_ID", error.getvalue())
self.assertEqual(client.recent_calls, [])
dry_run = FakeClient(dry_run=True)
output = io.StringIO()
with contextlib.redirect_stdout(output):
jellyfin_cli.cmd_recent(dry_run, ["--movies", "--limit", "2"])
self.assertEqual(json.loads(output.getvalue()), {
"dry_run": True,
"path": "/Items/Latest",
"params": {"userId": None, "includeItemTypes": "Movie", "limit": 2, "fields": "DateCreated"},
})
self.assertEqual(dry_run.recent_calls, [])
def test_navigation_client_contracts(self):
calls = []
client = jellyfin_cli.JellyfinClient()
client._get = lambda path, params=None: calls.append((path, params)) or {}
client.get_next_up("user-1", limit=3)
client.get_item("item-1", "user-1")
client.get_items("library-1", types=["Movie", "Series"], limit=4, start_index=2)
self.assertEqual(calls, [
("/Shows/NextUp", {"userId": "user-1", "limit": 3}),
("/Items/item-1", {"userId": "user-1"}),
("/Items", {"parentId": "library-1", "limit": 4, "sortBy": "SortName",
"sortOrder": "Ascending", "startIndex": 2, "recursive": True,
"includeItemTypes": "Movie,Series"}),
])
def test_next_up_item_and_browse_parse_results_as_json(self):
client = NavigationFakeClient()
output = io.StringIO()
with contextlib.redirect_stdout(output):
jellyfin_cli.cmd_next_up(client, ["--user-id", "user-1", "--limit", "3"])
self.assertEqual(json.loads(output.getvalue()), {
"items": [{"id": "episode-1", "name": "The Signal", "type": "Episode",
"series": "Voyagers", "episode_number": 4}],
"total_record_count": 9,
})
self.assertEqual(client.next_up_calls, [("user-1", 3)])
output = io.StringIO()
with contextlib.redirect_stdout(output):
jellyfin_cli.cmd_item(client, ["--id", "episode-1", "--user-id", "user-1"])
self.assertEqual(json.loads(output.getvalue()), {
"id": "episode-1", "name": "The Signal", "type": "Episode",
"series": "Voyagers", "episode_number": 4, "overview": "A message arrives.",
})
self.assertEqual(client.item_calls, [("episode-1", "user-1")])
output = io.StringIO()
with contextlib.redirect_stdout(output):
jellyfin_cli.cmd_browse(client, ["--library-id", "library-1", "--type", "Movie",
"--limit", "4", "--start-index", "2"])
self.assertEqual(json.loads(output.getvalue()), {
"items": [{"id": "movie-1", "name": "Arrival", "type": "Movie", "year": 2016}],
"start_index": 2,
"total_record_count": 1,
})
self.assertEqual(client.items_calls, [("library-1", ["Movie"], 4, "SortName", "Ascending", 2)])
def test_user_scoped_navigation_requires_user_before_network(self):
jellyfin_cli.ENV_USER_ID = ""
for handler, arguments in (
(jellyfin_cli.cmd_next_up, []),
(jellyfin_cli.cmd_item, ["--id", "item-1"]),
):
client = NavigationFakeClient()
with self.subTest(handler=handler.__name__), contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit):
handler(client, arguments)
self.assertEqual(client.next_up_calls, [])
self.assertEqual(client.item_calls, [])
def test_navigation_dry_runs_do_not_call_network_and_emit_requests(self):
cases = (
(jellyfin_cli.cmd_next_up, ["--limit", "3"], {"path": "/Shows/NextUp", "params": {"userId": None, "limit": 3}}),
(jellyfin_cli.cmd_item, ["--id", "item-1"], {"path": "/Items/item-1", "params": {"userId": None}}),
(jellyfin_cli.cmd_browse, ["--library-id", "library-1", "--type", "Movie,Series", "--limit", "4", "--start-index", "2"], {"path": "/Items", "params": {"parentId": "library-1", "limit": 4, "sortBy": "SortName", "sortOrder": "Ascending", "startIndex": 2, "recursive": True, "includeItemTypes": "Movie,Series"}}),
)
for handler, arguments, request in cases:
client = NavigationFakeClient(dry_run=True)
output = io.StringIO()
with self.subTest(handler=handler.__name__), contextlib.redirect_stdout(output):
handler(client, arguments)
self.assertEqual(json.loads(output.getvalue()), {"dry_run": True, **request})
self.assertEqual(client.next_up_calls, [])
self.assertEqual(client.item_calls, [])
self.assertEqual(client.items_calls, [])
def test_navigation_commands_dispatch_and_leaf_help_has_examples(self):
for command, arguments in (
("next-up", ["--user-id", "user-1", "--limit", "2"]),
("item", ["--id", "item-1", "--user-id", "user-1"]),
("browse", ["--library-id", "library-1", "--limit", "2"]),
):
result = subprocess.run([str(SCRIPT), "--json", "--dry-run", command, *arguments],
capture_output=True, text=True)
with self.subTest(command=command):
self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue(json.loads(result.stdout)["dry_run"])
help_result = subprocess.run([str(SCRIPT), command, "--help"], capture_output=True, text=True)
with self.subTest(help_command=command):
self.assertEqual(help_result.returncode, 0)
self.assertIn("Example:", help_result.stdout)
if __name__ == "__main__":
unittest.main()