feat(skills): add jelly-media skill and MCP server for Jellyfin & Jellyseerr

This commit is contained in:
Moritz Graf
2026-09-10 10:09:05 +02:00
parent fbd934965d
commit 7aca5dfe70
3 changed files with 856 additions and 0 deletions
+5
View File
@@ -36,6 +36,11 @@ Audio dossier and podcast producer. Converts arbitrary research, summaries, note
- **Voices:** Primary Google Gemini 3.1 Flash TTS with automatic MiniMax Speech 02 HD fallback. German voices (`Puck` host/solo, `Aoede` co-host, `Charon` deep), English voices (`Zephyr` host/solo, `Aoede` co-host).
- **Tools:** Includes `scripts/text_to_audio.py` for parallel chunk synthesis, auto-fallback, and MP3 generation.
### [jelly-media](skills/jelly-media/SKILL.md)
Seamless Jellyfin and Jellyseerr integration. Enables natural-language discovery, stream & download URLs, living room playback dispatch, continue watching, and media requests.
- **Features:** Smart search cross-referenced with library availability, discover with genre & rating filters, direct stream & download links, Jellyfin active sessions & remote play, Jellyseerr requests.
- **Tools:** Standalone MCP server and CLI at `scripts/jelly_media.py`.
### [skill-creator](skills/skill-creator/SKILL.md)
Guidelines and scripts to help developers build high-quality, token-efficient Claude skills.
+65
View File
@@ -0,0 +1,65 @@
---
name: jelly-media
description: Seamless Jellyfin streaming and Jellyseerr media discovery & requests. Use when the user asks to search for movies/series, discover recommendations (e.g. "a cool new action movie"), get stream or direct download URLs, request media via Jellyseerr, control active playback on connected devices (TVs), or check what to resume watching.
---
# 🎬 Jelly Media (Jellyfin & Jellyseerr)
Full integration with **Jellyfin** (media streaming, direct downloads, active sessions, resume points) and **Jellyseerr** (TMDB search, intelligent discovery, download requests).
## 🛠️ MCP Tools & Capabilities
You have access to 8 dedicated MCP tools:
1. `jelly_search(query, media_type)`:
- Searches Jellyseerr and cross-references against your personal Jellyfin library.
- Categorizes every match as:
- 🟢 **AVAILABLE**: Ready in Jellyfin right now (includes web playback, stream, and direct download links).
- 🟡 **PROCESSING**: Already requested in Jellyseerr and downloading/importing.
- ⚪ **NOT_AVAILABLE**: Not in the library yet; can be requested with one tap.
- Automatically ranks **AVAILABLE** items on top.
2. `jelly_discover(genre, min_rating, year, sort_by, only_available, media_type)`:
- Natural language discovery for queries like: *"a cool new action movie with good ratings"*.
- Filter by genre (`action`, `comedy`, `sci-fi`, `thriller`, `drama`, etc.), `min_rating` (e.g. 7.0), and `year`.
- Prioritizes items already downloaded on Jellyfin.
3. `jelly_get_playback(item_id)`:
- Returns links for a specific Jellyfin item:
- Web client URL (opens player/details in Jellyfin web or app)
- Direct HTTP stream URL (plays directly in VLC, Infuse, MPV, Safari)
- Direct offline download URL (original media file for travel/offline)
4. `jelly_request(media_id, media_type, seasons)`:
- Submits a request to Jellyseerr under user account `mop`.
- Supports movies or specific TV seasons.
5. `jelly_list_sessions()`:
- Lists active devices connected to Jellyfin (e.g. Living Room TV, iPad, Web browser, Apple TV).
6. `jelly_remote_play(session_id, item_id)`:
- Triggers playback of a movie or episode directly on an active device.
7. `jelly_get_resume()`:
- Fetches what the user is currently watching with resume percentage, minutes elapsed, and direct stream links.
8. `jelly_get_latest(limit)`:
- Returns newly added movies and series in the library.
---
## 📱 Telegram Presentation Guidelines
When formatting recommendations or search results for Telegram:
- **Badge Availability Clearly**:
- 🟢 **Verfügbar** (Sofort streamen & downloaden)
- 🟡 **In Bearbeitung** (Bereits angefordert)
- ⚪ **Verfügbar auf Anfrage** (Kann in Jellyseerr angefordert werden)
- **Include Direct Links**:
- `[▶️ In Jellyfin ansehen](<web_details_url>)`
- `[📥 Direkt-Download](<download_url>)`
- `[📡 Stream-Link](<stream_url>)`
- **Provide Essential Metadata**:
- Title, Year, TMDB/Community rating (⭐), runtime, and a short 1-2 sentence synopsis.
- **Requesting**:
- If an item is NOT in the library, offer to request it: *"Möchtest du, dass ich das über Jellyseerr für dich anfordere?"*
+786
View File
@@ -0,0 +1,786 @@
#!/usr/bin/env python3
"""
Jelly Media MCP Server & CLI Tool
Integrates Jellyfin and Jellyseerr with OpenClaw via MCP stdio or direct CLI commands.
Supports username & password authentication with automatic session management and renewal.
"""
import os
import sys
import json
import ssl
import time
import urllib.request
import urllib.parse
import urllib.error
from typing import Dict, Any, List, Optional
# Default Configurations (Can be overridden via environment variables)
JELLYFIN_URL = os.environ.get("JELLYFIN_URL", "https://stream.paschke.dev").rstrip("/")
JELLYFIN_USERNAME = os.environ.get("JELLYFIN_USERNAME", "mop")
JELLYFIN_PASSWORD = os.environ.get("JELLYFIN_PASSWORD", "Hintenlinks1#")
JELLYSEERR_URL = os.environ.get("JELLYSEERR_URL", "https://jellyseer.paschke.dev").rstrip("/")
JELLYSEERR_USERNAME = os.environ.get("JELLYSEERR_USERNAME", "mop")
JELLYSEERR_PASSWORD = os.environ.get("JELLYSEERR_PASSWORD", "Hintenlinks1#")
CACHE_DIR = os.environ.get("JELLY_CACHE_DIR", "/tmp")
JF_CACHE_FILE = os.path.join(CACHE_DIR, "jellyfin_session.json")
JS_CACHE_FILE = os.path.join(CACHE_DIR, "jellyseerr_session.json")
# SSL Context
SSL_CTX = ssl.create_default_context()
GENRE_MAP = {
"action": 28,
"adventure": 12,
"animation": 16,
"comedy": 35,
"crime": 80,
"documentary": 99,
"drama": 18,
"family": 10751,
"fantasy": 14,
"history": 36,
"horror": 27,
"music": 10402,
"mystery": 9648,
"romance": 10749,
"sci-fi": 878,
"science fiction": 878,
"thriller": 53,
"war": 10752,
"western": 37,
}
class JellyClient:
def __init__(self):
self.jf_token: Optional[str] = None
self.jf_user_id: Optional[str] = None
self.jf_server_id: Optional[str] = None
self.js_cookie: Optional[str] = None
self.load_cache()
def load_cache(self):
if os.path.exists(JF_CACHE_FILE):
try:
with open(JF_CACHE_FILE, "r") as f:
data = json.load(f)
self.jf_token = data.get("token")
self.jf_user_id = data.get("user_id")
self.jf_server_id = data.get("server_id")
except Exception:
pass
if os.path.exists(JS_CACHE_FILE):
try:
with open(JS_CACHE_FILE, "r") as f:
data = json.load(f)
self.js_cookie = data.get("cookie")
except Exception:
pass
def save_cache(self):
try:
with open(JF_CACHE_FILE, "w") as f:
json.dump({
"token": self.jf_token,
"user_id": self.jf_user_id,
"server_id": self.jf_server_id,
"timestamp": time.time()
}, f)
except Exception:
pass
try:
with open(JS_CACHE_FILE, "w") as f:
json.dump({
"cookie": self.js_cookie,
"timestamp": time.time()
}, f)
except Exception:
pass
# --- JELLYFIN AUTH & REQUESTS ---
def auth_jellyfin(self) -> bool:
login_url = f"{JELLYFIN_URL}/Users/AuthenticateByName"
auth_header = "MediaBrowser Client=\"OpenClaw\", Device=\"OpenClaw\", DeviceId=\"openclaw-bot-1\", Version=\"1.0.0\""
payload = json.dumps({"Username": JELLYFIN_USERNAME, "Pw": JELLYFIN_PASSWORD}).encode("utf-8")
req = urllib.request.Request(
login_url,
data=payload,
headers={
"Content-Type": "application/json",
"Authorization": auth_header,
"User-Agent": "OpenClaw/1.0"
}
)
try:
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
data = json.loads(resp.read().decode("utf-8"))
self.jf_token = data.get("AccessToken")
self.jf_user_id = data.get("User", {}).get("Id")
self.jf_server_id = data.get("ServerId")
self.save_cache()
return True
except Exception as e:
sys.stderr.write(f"Jellyfin auth failed: {e}\n")
return False
def request_jellyfin(self, path: str, method: str = "GET", payload: Optional[Dict[str, Any]] = None) -> Any:
if not self.jf_token or not self.jf_user_id:
if not self.auth_jellyfin():
raise RuntimeError("Failed to authenticate with Jellyfin")
url = f"{JELLYFIN_URL}{path}"
auth_header = (
f"MediaBrowser Client=\"OpenClaw\", Device=\"OpenClaw\", DeviceId=\"openclaw-bot-1\", "
f"Version=\"1.0.0\", Token=\"{self.jf_token}\""
)
data_bytes = json.dumps(payload).encode("utf-8") if payload else None
req = urllib.request.Request(
url,
data=data_bytes,
headers={
"Content-Type": "application/json",
"Authorization": auth_header,
"X-Emby-Token": self.jf_token or "",
"User-Agent": "OpenClaw/1.0"
},
method=method
)
try:
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
if e.code == 401:
# Token expired, retry once
if self.auth_jellyfin():
auth_header = (
f"MediaBrowser Client=\"OpenClaw\", Device=\"OpenClaw\", DeviceId=\"openclaw-bot-1\", "
f"Version=\"1.0.0\", Token=\"{self.jf_token}\""
)
req.headers["Authorization"] = auth_header
req.headers["X-Emby-Token"] = self.jf_token or ""
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
raise
# --- JELLYSEERR AUTH & REQUESTS ---
def auth_jellyseerr(self) -> bool:
login_url = f"{JELLYSEERR_URL}/api/v1/auth/jellyfin"
payload = json.dumps({"username": JELLYSEERR_USERNAME, "password": JELLYSEERR_PASSWORD}).encode("utf-8")
req = urllib.request.Request(
login_url,
data=payload,
headers={"Content-Type": "application/json", "User-Agent": "OpenClaw/1.0"}
)
try:
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
cookies = resp.info().get_all("Set-Cookie") or []
for c in cookies:
if "connect.sid=" in c:
self.js_cookie = c.split(";")[0]
break
self.save_cache()
return bool(self.js_cookie)
except Exception as e:
sys.stderr.write(f"Jellyseerr auth failed: {e}\n")
return False
def request_jellyseerr(self, path: str, method: str = "GET", payload: Optional[Dict[str, Any]] = None) -> Any:
if not self.js_cookie:
if not self.auth_jellyseerr():
raise RuntimeError("Failed to authenticate with Jellyseerr")
url = f"{JELLYSEERR_URL}{path}"
data_bytes = json.dumps(payload).encode("utf-8") if payload else None
headers = {
"Content-Type": "application/json",
"Cookie": self.js_cookie or "",
"User-Agent": "OpenClaw/1.0"
}
req = urllib.request.Request(url, data=data_bytes, headers=headers, method=method)
try:
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
except urllib.error.HTTPError as e:
if e.code in (401, 403):
# Session expired, retry once
if self.auth_jellyseerr():
req.headers["Cookie"] = self.js_cookie or ""
with urllib.request.urlopen(req, context=SSL_CTX, timeout=15) as resp:
body = resp.read().decode("utf-8")
return json.loads(body) if body else {}
raise
# --- HIGH LEVEL TOOLS ---
def search_media(self, query: str, media_type: Optional[str] = None) -> List[Dict[str, Any]]:
"""Search Jellyseerr and cross-reference with Jellyfin library."""
results: List[Dict[str, Any]] = []
# 1. Search Jellyseerr
encoded_query = urllib.parse.quote(query)
js_data = self.request_jellyseerr(f"/api/v1/search?query={encoded_query}")
raw_results = js_data.get("results", [])
# 2. Search Jellyfin directly to match any items in library
jf_matches = {}
try:
encoded_jf = urllib.parse.quote(query)
jf_items = self.request_jellyfin(
f"/Users/{self.jf_user_id}/Items?searchTerm={encoded_jf}&Limit=20&Recursive=true&IncludeItemTypes=Movie,Series"
).get("Items", [])
for item in jf_items:
name_clean = item.get("Name", "").strip().lower()
jf_matches[name_clean] = item
except Exception as e:
sys.stderr.write(f"Jellyfin search error: {e}\n")
for item in raw_results:
mtype = item.get("mediaType")
if media_type and media_type != "all" and mtype != media_type:
continue
title = item.get("title") or item.get("name") or "Unknown"
year = (item.get("releaseDate") or item.get("firstAirDate") or "")[:4]
rating = item.get("voteAverage")
minfo = item.get("mediaInfo") or {}
status_code = minfo.get("status")
# Check availability
is_available = status_code == 5
is_processing = status_code in (2, 3, 4)
jf_item = jf_matches.get(title.strip().lower())
jf_item_id = None
if jf_item:
is_available = True
jf_item_id = jf_item.get("Id")
entry: Dict[str, Any] = {
"title": title,
"year": year,
"media_type": mtype,
"tmdb_id": item.get("id"),
"rating": rating,
"overview": item.get("overview"),
"status": "AVAILABLE" if is_available else ("PROCESSING" if is_processing else "NOT_AVAILABLE"),
"poster_url": f"https://image.tmdb.org/t/p/w500{item.get("posterPath")}" if item.get("posterPath") else None,
}
if is_available:
if not jf_item_id and minfo.get("jellyfinMediaId"):
jf_item_id = minfo.get("jellyfinMediaId")
if jf_item_id:
entry["jellyfin_item_id"] = jf_item_id
entry["playback"] = {
"web_details_url": f"{JELLYFIN_URL}/web/index.html#!/details?id={jf_item_id}&serverId={self.jf_server_id}",
"stream_url": f"{JELLYFIN_URL}/Videos/{jf_item_id}/stream?static=true&api_key={self.jf_token}",
"download_url": f"{JELLYFIN_URL}/Items/{jf_item_id}/Download?api_key={self.jf_token}"
}
results.append(entry)
# Sort: AVAILABLE first, then PROCESSING, then NOT_AVAILABLE
status_priority = {"AVAILABLE": 0, "PROCESSING": 1, "NOT_AVAILABLE": 2}
results.sort(key=lambda x: (status_priority.get(x["status"], 3), -(x.get("rating") or 0)))
return results
def discover_media(
self,
genre: Optional[str] = None,
min_rating: Optional[float] = None,
year: Optional[int] = None,
sort_by: str = "popularity.desc",
only_available: bool = False,
media_type: str = "movie"
) -> List[Dict[str, Any]]:
"""Discover movies or TV shows with rich filters, prioritizing available items."""
genre_id = None
if genre:
genre_lower = genre.lower().strip()
genre_id = GENRE_MAP.get(genre_lower)
if not genre_id and genre.isdigit():
genre_id = int(genre)
endpoint = "/api/v1/discover/movies" if media_type != "tv" else "/api/v1/discover/tv"
params: List[str] = [f"sortBy={sort_by}"]
if genre_id:
params.append(f"genre={genre_id}")
if year:
params.append(f"primaryReleaseDateGte={year}-01-01")
params.append(f"primaryReleaseDateLte={year}-12-31")
query_str = "&".join(params)
js_data = self.request_jellyseerr(f"{endpoint}?{query_str}")
raw_results = js_data.get("results", [])
results: List[Dict[str, Any]] = []
for item in raw_results:
rating = item.get("voteAverage") or 0.0
if min_rating and rating < min_rating:
continue
title = item.get("title") or item.get("name") or "Unknown"
rel_date = item.get("releaseDate") or item.get("firstAirDate") or ""
minfo = item.get("mediaInfo") or {}
status_code = minfo.get("status")
is_available = status_code == 5
is_processing = status_code in (2, 3, 4)
if only_available and not is_available:
continue
entry = {
"title": title,
"year": rel_date[:4] if rel_date else None,
"media_type": media_type,
"tmdb_id": item.get("id"),
"rating": rating,
"overview": item.get("overview"),
"status": "AVAILABLE" if is_available else ("PROCESSING" if is_processing else "NOT_AVAILABLE"),
"poster_url": f"https://image.tmdb.org/t/p/w500{item.get("posterPath")}" if item.get("posterPath") else None,
}
if is_available and minfo.get("jellyfinMediaId"):
jf_id = minfo.get("jellyfinMediaId")
entry["jellyfin_item_id"] = jf_id
entry["playback"] = {
"web_details_url": f"{JELLYFIN_URL}/web/index.html#!/details?id={jf_id}&serverId={self.jf_server_id}",
"stream_url": f"{JELLYFIN_URL}/Videos/{jf_id}/stream?static=true&api_key={self.jf_token}",
"download_url": f"{JELLYFIN_URL}/Items/{jf_id}/Download?api_key={self.jf_token}"
}
results.append(entry)
# Sort: AVAILABLE first
status_priority = {"AVAILABLE": 0, "PROCESSING": 1, "NOT_AVAILABLE": 2}
results.sort(key=lambda x: (status_priority.get(x["status"], 3), -(x.get("rating") or 0)))
return results
def get_playback_info(self, item_id: str) -> Dict[str, Any]:
"""Fetch full details, web link, stream link, and download link for a Jellyfin item."""
data = self.request_jellyfin(f"/Users/{self.jf_user_id}/Items/{item_id}")
name = data.get("Name", "Unknown")
year = data.get("ProductionYear")
overview = data.get("Overview")
media_type = data.get("Type")
genres = data.get("Genres", [])
community_rating = data.get("CommunityRating")
runtime_ticks = data.get("RunTimeTicks", 0)
runtime_mins = int(runtime_ticks / (10_000_000 * 60)) if runtime_ticks else None
return {
"title": name,
"year": year,
"type": media_type,
"genres": genres,
"rating": community_rating,
"runtime_mins": runtime_mins,
"overview": overview,
"jellyfin_item_id": item_id,
"web_details_url": f"{JELLYFIN_URL}/web/index.html#!/details?id={item_id}&serverId={self.jf_server_id}",
"stream_url": f"{JELLYFIN_URL}/Videos/{item_id}/stream?static=true&api_key={self.jf_token}",
"download_url": f"{JELLYFIN_URL}/Items/{item_id}/Download?api_key={self.jf_token}",
}
def request_media(self, media_id: int, media_type: str = "movie", seasons: Optional[List[int]] = None) -> Dict[str, Any]:
"""Submit a request to Jellyseerr."""
payload: Dict[str, Any] = {
"mediaId": media_id,
"mediaType": media_type,
}
if media_type == "tv":
payload["seasons"] = seasons if seasons is not None else [1]
res = self.request_jellyseerr("/api/v1/request", method="POST", payload=payload)
return {
"success": True,
"request_id": res.get("id"),
"status": res.get("status"),
"media": res.get("media"),
"message": "Media request successfully submitted to Jellyseerr!"
}
def list_sessions(self) -> List[Dict[str, Any]]:
"""List active playback sessions and devices on Jellyfin."""
raw_sessions = self.request_jellyfin("/Sessions")
output = []
for s in raw_sessions:
np = s.get("NowPlayingItem")
output.append({
"session_id": s.get("Id"),
"device_name": s.get("DeviceName"),
"client": s.get("Client"),
"user_name": s.get("UserName"),
"supports_remote_control": s.get("SupportsRemoteControl", False),
"is_active": s.get("IsActive", False),
"now_playing": {
"name": np.get("Name"),
"type": np.get("Type"),
"id": np.get("Id")
} if np else None
})
return output
def remote_play(self, session_id: str, item_id: str) -> Dict[str, Any]:
"""Trigger remote playback on a Jellyfin client session."""
path = f"/Sessions/{session_id}/Play?playCommand=PlayNow&ItemIds={item_id}"
self.request_jellyfin(path, method="POST")
return {"success": True, "message": f"Playback triggered for item {item_id} on session {session_id}"}
def get_resume(self) -> List[Dict[str, Any]]:
"""Fetch items currently in progress for the user."""
data = self.request_jellyfin(f"/Users/{self.jf_user_id}/Items/Resume?Recursive=true")
items = data.get("Items", [])
output = []
for it in items:
ticks = it.get("UserData", {}).get("PlaybackPositionTicks", 0)
total_ticks = it.get("RunTimeTicks", 1) or 1
pct = round((ticks / total_ticks) * 100, 1)
mins = round(ticks / (10_000_000 * 60), 1)
total_mins = round(total_ticks / (10_000_000 * 60), 1)
iid = it.get("Id")
output.append({
"title": it.get("Name"),
"series": it.get("SeriesName"),
"type": it.get("Type"),
"item_id": iid,
"progress_percent": pct,
"position_mins": mins,
"total_mins": total_mins,
"web_details_url": f"{JELLYFIN_URL}/web/index.html#!/details?id={iid}&serverId={self.jf_server_id}",
"stream_url": f"{JELLYFIN_URL}/Videos/{iid}/stream?static=true&api_key={self.jf_token}"
})
return output
def get_latest(self, limit: int = 10) -> List[Dict[str, Any]]:
"""Fetch newly added items in the Jellyfin library."""
data = self.request_jellyfin(f"/Users/{self.jf_user_id}/Items/Latest?Limit={limit}")
output = []
for it in data:
iid = it.get("Id")
output.append({
"title": it.get("Name"),
"series": it.get("SeriesName"),
"type": it.get("Type"),
"year": it.get("ProductionYear"),
"item_id": iid,
"web_details_url": f"{JELLYFIN_URL}/web/index.html#!/details?id={iid}&serverId={self.jf_server_id}",
"stream_url": f"{JELLYFIN_URL}/Videos/{iid}/stream?static=true&api_key={self.jf_token}",
"download_url": f"{JELLYFIN_URL}/Items/{iid}/Download?api_key={self.jf_token}"
})
return output
# --- MCP SERVER STDIO IMPLEMENTATION ---
MCP_TOOLS = [
{
"name": "jelly_search",
"description": "Search for movies or series across Jellyseerr and Jellyfin. Returns availability status (AVAILABLE, PROCESSING, NOT_AVAILABLE) favoring existing library media with instant playback and download URLs.",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "The search term, title, or keywords"},
"media_type": {"type": "string", "enum": ["all", "movie", "tv"], "description": "Optional filter for media type", "default": "all"}
},
"required": ["query"]
}
},
{
"name": "jelly_discover",
"description": "Discover movies or TV shows based on genre, minimum rating, release year, or popularity. Automatically checks and highlights items already available to stream on Jellyfin.",
"inputSchema": {
"type": "object",
"properties": {
"genre": {"type": "string", "description": "Genre name (e.g. action, comedy, sci-fi, thriller, drama, animation, horror)"},
"min_rating": {"type": "number", "description": "Minimum TMDB score out of 10 (e.g. 7.0)"},
"year": {"type": "integer", "description": "Release year filter (e.g. 2024)"},
"sort_by": {"type": "string", "description": "Sort order (e.g. popularity.desc, vote_average.desc)", "default": "popularity.desc"},
"only_available": {"type": "boolean", "description": "If true, returns only items already downloaded on Jellyfin", "default": False},
"media_type": {"type": "string", "enum": ["movie", "tv"], "description": "Type of media", "default": "movie"}
}
}
},
{
"name": "jelly_get_playback",
"description": "Get web details URL, direct streaming URL, and offline direct download URL for an item in the Jellyfin library.",
"inputSchema": {
"type": "object",
"properties": {
"item_id": {"type": "string", "description": "The Jellyfin item ID"}
},
"required": ["item_id"]
}
},
{
"name": "jelly_request",
"description": "Submit a download request for a movie or TV series in Jellyseerr on behalf of user mop.",
"inputSchema": {
"type": "object",
"properties": {
"media_id": {"type": "integer", "description": "The TMDB ID of the movie or series"},
"media_type": {"type": "string", "enum": ["movie", "tv"], "description": "Type of media", "default": "movie"},
"seasons": {"type": "array", "items": {"type": "integer"}, "description": "For TV series: list of season numbers to request (e.g. [1, 2])"}
},
"required": ["media_id"]
}
},
{
"name": "jelly_list_sessions",
"description": "List all active playback devices and sessions connected to Jellyfin (e.g. Apple TV in Living Room, iPad, Chrome Web).",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "jelly_remote_play",
"description": "Send a command to a specific Jellyfin client session to immediately play an item.",
"inputSchema": {
"type": "object",
"properties": {
"session_id": {"type": "string", "description": "The Jellyfin session ID of the target device"},
"item_id": {"type": "string", "description": "The Jellyfin item ID to play"}
},
"required": ["session_id", "item_id"]
}
},
{
"name": "jelly_get_resume",
"description": "Get in-progress movies and TV series episodes that user mop is currently watching, with timestamps and progress percentage.",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{
"name": "jelly_get_latest",
"description": "Get the latest movies or series episodes newly added to the Jellyfin library.",
"inputSchema": {
"type": "object",
"properties": {
"limit": {"type": "integer", "description": "Maximum number of items to return", "default": 10}
}
}
}
]
def run_mcp_server():
client = JellyClient()
while True:
line = sys.stdin.readline()
if not line:
break
line = line.strip()
if not line:
continue
try:
req = json.loads(line)
except Exception as e:
sys.stderr.write(f"Invalid JSON received: {e}\n")
continue
msg_id = req.get("id")
method = req.get("method")
params = req.get("params", {})
if method == "initialize":
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "jelly-media-mcp", "version": "1.0.0"}
}
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
elif method == "notifications/initialized":
pass
elif method == "ping":
resp = {"jsonrpc": "2.0", "id": msg_id, "result": {}}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
elif method == "tools/list":
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {"tools": MCP_TOOLS}
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
elif method == "tools/call":
tool_name = params.get("name")
args = params.get("arguments", {})
try:
if tool_name == "jelly_search":
data = client.search_media(args.get("query", ""), args.get("media_type"))
elif tool_name == "jelly_discover":
data = client.discover_media(
genre=args.get("genre"),
min_rating=args.get("min_rating"),
year=args.get("year"),
sort_by=args.get("sort_by", "popularity.desc"),
only_available=args.get("only_available", False),
media_type=args.get("media_type", "movie")
)
elif tool_name == "jelly_get_playback":
data = client.get_playback_info(args.get("item_id", ""))
elif tool_name == "jelly_request":
data = client.request_media(
media_id=args.get("media_id"),
media_type=args.get("media_type", "movie"),
seasons=args.get("seasons")
)
elif tool_name == "jelly_list_sessions":
data = client.list_sessions()
elif tool_name == "jelly_remote_play":
data = client.remote_play(args.get("session_id", ""), args.get("item_id", ""))
elif tool_name == "jelly_get_resume":
data = client.get_resume()
elif tool_name == "jelly_get_latest":
data = client.get_latest(args.get("limit", 10))
else:
raise ValueError(f"Unknown tool: {tool_name}")
text_result = json.dumps(data, indent=2, ensure_ascii=False)
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"content": [{"type": "text", "text": text_result}]
}
}
except Exception as e:
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"result": {
"isError": True,
"content": [{"type": "text", "text": f"Error executing {tool_name}: {str(e)}"}]
}
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
else:
if msg_id is not None:
resp = {
"jsonrpc": "2.0",
"id": msg_id,
"error": {"code": -32601, "message": f"Method not found: {method}"}
}
sys.stdout.write(json.dumps(resp) + "\n")
sys.stdout.flush()
def run_cli():
import argparse
parser = argparse.ArgumentParser(description="Jellyfin & Jellyseerr Media Tool")
subparsers = parser.add_subparsers(dest="command")
# Search
sp_search = subparsers.add_parser("search", help="Search media")
sp_search.add_argument("query", help="Search query")
sp_search.add_argument("--type", choices=["all", "movie", "tv"], default="all")
# Discover
sp_disc = subparsers.add_parser("discover", help="Discover movies/shows")
sp_disc.add_argument("--genre", help="Genre (action, comedy, etc.)")
sp_disc.add_argument("--min-rating", type=float, help="Minimum TMDB rating")
sp_disc.add_argument("--year", type=int, help="Year")
sp_disc.add_argument("--sort-by", default="popularity.desc")
sp_disc.add_argument("--only-available", action="store_true")
sp_disc.add_argument("--type", choices=["movie", "tv"], default="movie")
# Playback
sp_play = subparsers.add_parser("playback", help="Get playback links for item ID")
sp_play.add_argument("item_id", help="Jellyfin Item ID")
# Request
sp_req = subparsers.add_parser("request", help="Request media")
sp_req.add_argument("media_id", type=int, help="TMDB ID")
sp_req.add_argument("--type", choices=["movie", "tv"], default="movie")
sp_req.add_argument("--seasons", type=int, nargs="*", help="Seasons for TV")
# Sessions
subparsers.add_parser("sessions", help="List active Jellyfin sessions")
# Remote Play
sp_rem = subparsers.add_parser("remote-play", help="Trigger remote play")
sp_rem.add_argument("session_id", help="Session ID")
sp_rem.add_argument("item_id", help="Item ID")
# Resume
subparsers.add_parser("resume", help="Get in-progress items")
# Latest
sp_latest = subparsers.add_parser("latest", help="Get latest items")
sp_latest.add_argument("--limit", type=int, default=10)
# Test
subparsers.add_parser("test", help="Test connectivity to Jellyfin & Jellyseerr")
args = parser.parse_args()
client = JellyClient()
if args.command == "search":
print(json.dumps(client.search_media(args.query, args.type), indent=2, ensure_ascii=False))
elif args.command == "discover":
print(json.dumps(client.discover_media(
genre=args.genre,
min_rating=args.min_rating,
year=args.year,
sort_by=args.sort_by,
only_available=args.only_available,
media_type=args.type
), indent=2, ensure_ascii=False))
elif args.command == "playback":
print(json.dumps(client.get_playback_info(args.item_id), indent=2, ensure_ascii=False))
elif args.command == "request":
print(json.dumps(client.request_media(args.media_id, args.type, args.seasons), indent=2, ensure_ascii=False))
elif args.command == "sessions":
print(json.dumps(client.list_sessions(), indent=2, ensure_ascii=False))
elif args.command == "remote-play":
print(json.dumps(client.remote_play(args.session_id, args.item_id), indent=2, ensure_ascii=False))
elif args.command == "resume":
print(json.dumps(client.get_resume(), indent=2, ensure_ascii=False))
elif args.command == "latest":
print(json.dumps(client.get_latest(args.limit), indent=2, ensure_ascii=False))
elif args.command == "test":
print("Testing Jellyfin Auth...")
jf_ok = client.auth_jellyfin()
print(f" Jellyfin: {"[OK]" if jf_ok else "[FAILED]"}")
print("Testing Jellyseerr Auth...")
js_ok = client.auth_jellyseerr()
print(f" Jellyseerr: {"[OK]" if js_ok else "[FAILED]"}")
else:
parser.print_help()
if __name__ == "__main__":
if len(sys.argv) == 1 or (len(sys.argv) == 2 and sys.argv[1] == "--stdio"):
run_mcp_server()
else:
run_cli()