mirror of
https://github.com/magnus919/agent-skills.git
synced 2026-09-11 19:47:12 +03:00
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:
co-authored by
factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
parent
e10508b034
commit
dd97e846ec
@@ -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.
|
||||
@@ -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.
|
||||
Executable
+420
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user