feat(skills): add jelly-media skill and MCP server for Jellyfin & Jellyseerr
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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?"*
|
||||
Executable
+786
@@ -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()
|
||||
Reference in New Issue
Block a user