v1.0
This commit is contained in:
1 parent
0246cbb6ba
commit
0ca4b4bf7a
89 files changed
+7042
-2480
No files matched your search
+28
-11
@@ -4,20 +4,23 @@ MCP (Model Context Protocol) server that exposes an **Emby** media server to LLM
|
||||
|
||||
## Features
|
||||
|
||||
Tools:
|
||||
Tools (all read-only except `play_item`; failures are returned as MCP tool errors):
|
||||
- `ping` — verify connectivity / return Emby system info
|
||||
- `list_items` — browse the media library with optional filtering
|
||||
- `search_items` — search for items by name or metadata
|
||||
- `get_item` — get detailed information about a specific item
|
||||
- `list_items(parent_id?, item_type?, limit, sort_by)` — browse the media library
|
||||
- `search_items(query, item_type?, limit)` — server-side search over the whole library
|
||||
- `get_item(item_id)` — details incl. overview, genres, people, media sources
|
||||
- `list_users` — list all users on the server
|
||||
- `get_user` — get user details
|
||||
- `list_sessions` — see who's currently connected / watching
|
||||
- `list_sessions` — connected clients, incl. `supports_remote_control` and what is playing
|
||||
- `system_info` — get server system information
|
||||
- `playback_url` — get a direct stream URL for an item
|
||||
- `playback_info` — get detailed playback/transcoding options
|
||||
- `play_item` — send remote control commands to start playback
|
||||
- `playback_url(item_id)` — direct stream URL (returned **without** credentials)
|
||||
- `playback_info` — media sources, codecs, direct-play / transcoding support
|
||||
- `play_item(item_id, device?, session_id?, play_command)` — play on a remote-controllable client
|
||||
- `list_devices` — list registered devices
|
||||
|
||||
IDs are URL-encoded, so a crafted ID such as `123/Delete` or `../System/Restart`
|
||||
cannot reach other Emby endpoints.
|
||||
|
||||
Resources:
|
||||
- `emby://library` — snapshot of the entire media library as JSON
|
||||
- `emby://users` — snapshot of all users as JSON
|
||||
@@ -57,6 +60,13 @@ EMBY_SCHEME=https
|
||||
EMBY_TIMEOUT=10.0
|
||||
```
|
||||
|
||||
`EMBY_HOST` may also be `host:port` or a full base URL (`http://10.0.0.5:8096`).
|
||||
`EMBY_SCHEME` defaults to `http` (Emby's own default on port 8096); set `https`
|
||||
only for a TLS endpoint such as a reverse proxy. `EMBY_PORT` is ignored when the
|
||||
host already contains a port. The `.env` file is read from the **current working
|
||||
directory**, so when Javis launches the server, put the settings into the `env`
|
||||
block of `mcp_servers.json` instead.
|
||||
|
||||
Get your API key from the Emby web dashboard: **Dashboard → Users → Manage Users → [your user] → Settings → API Keys**.
|
||||
|
||||
## Run
|
||||
@@ -79,10 +89,10 @@ Add to your `mcp_servers.json`:
|
||||
|
||||
```json
|
||||
"emby": {
|
||||
"command": "d:\\Javis\\emby-mcp\\.venv\\Scripts\\python.exe",
|
||||
"command": "F:\\Javis\\emby-mcp\\.venv\\Scripts\\python.exe",
|
||||
"args": ["-m", "emby_mcp"],
|
||||
"env": {
|
||||
"EMBY_HOST": "emby.spaeth-bayern.de",
|
||||
"EMBY_HOST": "http://10.0.0.5:8096",
|
||||
"EMBY_API_KEY": "your-api-key-here"
|
||||
},
|
||||
"transport": "stdio",
|
||||
@@ -107,7 +117,14 @@ list_items(item_type="Movie")
|
||||
### Search for content
|
||||
> "Find all episodes of Stranger Things"
|
||||
```python
|
||||
search_items("Stranger Things", limit=50)
|
||||
search_items("Stranger Things", item_type="Episode", limit=50)
|
||||
```
|
||||
|
||||
### Play on a TV
|
||||
> "Play Stranger Things on the living-room TV"
|
||||
```python
|
||||
search_items("Stranger Things") # -> item id
|
||||
play_item(item_id="...", device="TV") # needs an open, remote-controllable Emby app
|
||||
```
|
||||
|
||||
### Check active sessions
|
||||
|
||||
@@ -20,7 +20,8 @@ classifiers = [
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
dependencies = [
|
||||
"mcp[cli]>=1.2",
|
||||
# >=1.13: ToolAnnotations / transport security; 2.x removed mcp.server.fastmcp
|
||||
"mcp[cli]>=1.13,<2",
|
||||
"httpx>=0.27",
|
||||
"pydantic>=2.7",
|
||||
"pydantic-settings>=2.3",
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -11,6 +12,19 @@ from .config import Settings
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Fields Emby only returns on request.
|
||||
_LIST_FIELDS = "ProductionYear,ParentId,PremiereDate"
|
||||
_DETAIL_FIELDS = (
|
||||
"Overview,Genres,ProductionYear,PremiereDate,People,Studios,Taglines,"
|
||||
"ProviderIds,ParentId,MediaSources,Path,CommunityRating,OfficialRating"
|
||||
)
|
||||
|
||||
|
||||
def _seg(value: Any) -> str:
|
||||
"""One URL path segment: IDs come from the LLM and must not add path parts
|
||||
(e.g. ``123/Delete`` or ``../System/Restart``)."""
|
||||
return quote(str(value), safe="")
|
||||
|
||||
|
||||
class EmbyClient:
|
||||
"""Thin async wrapper around Emby's REST API."""
|
||||
@@ -48,59 +62,65 @@ class EmbyClient:
|
||||
return None
|
||||
return None
|
||||
|
||||
async def _delete(self, path: str) -> Any:
|
||||
r = await self._http.delete(path)
|
||||
r.raise_for_status()
|
||||
if r.content:
|
||||
try:
|
||||
return r.json()
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
# ---- API: Items (media library) ---------------------------------------
|
||||
|
||||
async def list_items(
|
||||
self,
|
||||
parent_id: str | None = None,
|
||||
include_related: bool = False,
|
||||
recursive: bool = True,
|
||||
sort_by: str | None = "Name",
|
||||
sort_by: str | None = "SortName",
|
||||
sort_order: str = "Ascending",
|
||||
limit: int | None = None,
|
||||
search_term: str | None = None,
|
||||
item_types: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""List items from the media library."""
|
||||
"""List or search items from the media library (server-side filtering)."""
|
||||
params: dict[str, Any] = {
|
||||
"IncludeRelated": str(include_related).lower(),
|
||||
"Recursive": str(recursive).lower(),
|
||||
"SortBy": sort_by,
|
||||
"SortOrder": sort_order,
|
||||
"Fields": _LIST_FIELDS,
|
||||
}
|
||||
if sort_by:
|
||||
params["SortBy"] = sort_by
|
||||
if parent_id:
|
||||
params["ParentId"] = parent_id
|
||||
if limit:
|
||||
params["Limit"] = limit
|
||||
if search_term:
|
||||
params["SearchTerm"] = search_term
|
||||
if item_types:
|
||||
params["IncludeItemTypes"] = item_types
|
||||
return await self._get("/Items", params=params)
|
||||
|
||||
async def get_item(self, item_id: str) -> dict[str, Any]:
|
||||
"""Get details for a specific item."""
|
||||
return await self._get(f"/Items/{item_id}")
|
||||
"""Get details for a specific item (``GET /Items/{id}`` does not exist in Emby)."""
|
||||
raw = await self._get(
|
||||
"/Items", params={"Ids": item_id, "Recursive": "true", "Fields": _DETAIL_FIELDS}
|
||||
)
|
||||
items = raw.get("Items") if isinstance(raw, dict) else None
|
||||
if not items:
|
||||
raise LookupError(f"No Emby item with id {item_id!r}. Use search_items to find it.")
|
||||
return items[0]
|
||||
|
||||
# ---- API: Users -------------------------------------------------------
|
||||
|
||||
async def list_users(self) -> dict[str, Any]:
|
||||
async def list_users(self) -> list[dict[str, Any]]:
|
||||
"""List all users on the server."""
|
||||
return await self._get("/Users")
|
||||
raw = await self._get("/Users/Query")
|
||||
if isinstance(raw, dict):
|
||||
return [u for u in raw.get("Items", []) if isinstance(u, dict)]
|
||||
return raw if isinstance(raw, list) else []
|
||||
|
||||
async def get_user(self, user_id: str) -> dict[str, Any]:
|
||||
"""Get details for a specific user."""
|
||||
return await self._get(f"/Users/{user_id}")
|
||||
return await self._get(f"/Users/{_seg(user_id)}")
|
||||
|
||||
# ---- API: Sessions ----------------------------------------------------
|
||||
|
||||
async def list_sessions(self) -> list[dict[str, Any]]:
|
||||
"""List active sessions (connected devices/apps)."""
|
||||
return await self._get("/Sessions")
|
||||
raw = await self._get("/Sessions")
|
||||
return raw if isinstance(raw, list) else []
|
||||
|
||||
async def get_system_info(self) -> dict[str, Any]:
|
||||
"""Get server system info."""
|
||||
@@ -108,27 +128,27 @@ class EmbyClient:
|
||||
|
||||
# ---- API: Playback ----------------------------------------------------
|
||||
|
||||
async def play_item(
|
||||
self, item_id: str, user_id: str | None = None, autoplay: bool = True
|
||||
async def play_on_session(
|
||||
self, session_id: str, item_ids: list[str], play_command: str = "PlayNow"
|
||||
) -> Any:
|
||||
"""Start playback of an item."""
|
||||
params: dict[str, Any] = {"UserId": user_id or "null", "Autostart": autoplay}
|
||||
return await self._post(f"/Items/{item_id}/Play", params=params)
|
||||
"""Remote-control a client session to play items."""
|
||||
return await self._post(
|
||||
f"/Sessions/{_seg(session_id)}/Playing",
|
||||
params={"ItemIds": ",".join(item_ids), "PlayCommand": play_command},
|
||||
)
|
||||
|
||||
async def get_playback_info(self, item_id: str, user_id: str | None = None) -> dict[str, Any]:
|
||||
"""Get playback info for an item (transcoding options, etc.)."""
|
||||
params = {}
|
||||
if user_id:
|
||||
params["UserId"] = user_id
|
||||
return await self._get(f"/Items/{item_id}/PlaybackInfo", params=params)
|
||||
|
||||
async def get_playback_url(self, item_id: str) -> dict[str, Any]:
|
||||
"""Get a stream URL for direct playback."""
|
||||
return await self._get(f"/Items/{item_id}/StreamUrl")
|
||||
return await self._get(f"/Items/{_seg(item_id)}/PlaybackInfo", params=params)
|
||||
|
||||
# ---- API: Device Info -------------------------------------------------
|
||||
|
||||
async def list_devices(self) -> list[dict[str, Any]]:
|
||||
"""List registered devices."""
|
||||
r = await self._get("/Devices")
|
||||
return r if isinstance(r, list) else []
|
||||
"""List registered devices (Emby wraps them in ``{"Items": [...]}``)."""
|
||||
raw = await self._get("/Devices")
|
||||
if isinstance(raw, dict):
|
||||
raw = raw.get("Items", [])
|
||||
return [d for d in raw if isinstance(d, dict)] if isinstance(raw, list) else []
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
@@ -20,20 +22,27 @@ class Settings(BaseSettings):
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
host: str = Field(..., description="IP or hostname of the Emby server.")
|
||||
host: str = Field(
|
||||
...,
|
||||
description="Emby host, 'host:port', or a full base URL like 'http://10.0.0.5:8096'.",
|
||||
)
|
||||
api_key: str = Field(..., description="Emby API key (from dashboard).")
|
||||
scheme: str = Field("https", description="http or https.")
|
||||
# Emby itself serves plain HTTP (8096) by default; HTTPS needs extra setup.
|
||||
scheme: str = Field("http", description="http or https.")
|
||||
port: int | None = Field(
|
||||
None,
|
||||
description="Optional TCP port.",
|
||||
description="Optional TCP port (ignored when host already contains one).",
|
||||
)
|
||||
timeout: float = Field(10.0, description="HTTP timeout in seconds.")
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
if self.port is None:
|
||||
return f"{self.scheme}://{self.host}"
|
||||
return f"{self.scheme}://{self.host}:{self.port}"
|
||||
host = self.host.strip().rstrip("/")
|
||||
if "://" in host:
|
||||
return host
|
||||
if self.port is None or re.search(r":\d+$", host):
|
||||
return f"{self.scheme}://{host}"
|
||||
return f"{self.scheme}://{host}:{self.port}"
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
|
||||
+168
-149
@@ -7,15 +7,24 @@ from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from mcp.server.fastmcp import FastMCP, Context
|
||||
from pydantic import BaseModel, Field
|
||||
from mcp.types import ToolAnnotations
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .client import EmbyClient
|
||||
from .config import load_settings
|
||||
|
||||
log = logging.getLogger("emby_mcp")
|
||||
|
||||
_READ = ToolAnnotations(readOnlyHint=True, openWorldHint=False)
|
||||
_PLAY = ToolAnnotations(readOnlyHint=False, destructiveHint=False, idempotentHint=False,
|
||||
openWorldHint=False)
|
||||
# Media types worth searching (without a filter Emby also returns People, Studios, ...).
|
||||
_MEDIA_TYPES = "Movie,Series,Season,Episode,Audio,MusicAlbum,MusicArtist,MusicVideo,Video,BoxSet"
|
||||
_TICKS_PER_MINUTE = 600_000_000
|
||||
|
||||
|
||||
# ---- models --------------------------------------------------------------
|
||||
|
||||
@@ -24,16 +33,13 @@ class ItemSummary(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
type: str | None = None
|
||||
parent_id: str | None = Field(default=None, alias="ParentId")
|
||||
series_name: str | None = Field(default=None, alias="SeriesName")
|
||||
season_number: int | None = Field(default=None, alias="SeasonNumber")
|
||||
episode_number: int | None = Field(default=None, alias="EpisodeNumber")
|
||||
parent_id: str | None = None
|
||||
series_name: str | None = None
|
||||
season_number: int | None = None
|
||||
episode_number: int | None = None
|
||||
year: int | None = None
|
||||
runtime_minutes: int | None = Field(default=None, alias="RunTimeNumeric")
|
||||
size_mb: float | None = Field(default=None, alias="Size")
|
||||
image_url: str | None = Field(default=None, alias="ImageTags")
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
runtime_minutes: int | None = None
|
||||
size_mb: float | None = None
|
||||
|
||||
|
||||
class UserSummary(BaseModel):
|
||||
@@ -42,28 +48,17 @@ class UserSummary(BaseModel):
|
||||
is_admin: bool = False
|
||||
has_authorization: bool = True
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class SessionInfo(BaseModel):
|
||||
user_id: str | None = Field(default=None, alias="UserId")
|
||||
user_name: str | None = Field(default=None, alias="UserName")
|
||||
client_name: str | None = Field(default=None, alias="ClientName")
|
||||
device_name: str | None = Field(default=None, alias="DeviceName")
|
||||
app_id: str | None = Field(default=None, alias="AppId")
|
||||
device_type: str | None = Field(default=None, alias="DeviceType")
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class PlaybackInfo(BaseModel):
|
||||
playable_url: str | None = None
|
||||
transcoding_url: str | None = None
|
||||
can_transcode: bool = False
|
||||
audio_codec: str | None = None
|
||||
video_codec: str | None = None
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
id: str | None = None
|
||||
user_id: str | None = None
|
||||
user_name: str | None = None
|
||||
client_name: str | None = None
|
||||
device_name: str | None = None
|
||||
app_id: str | None = None
|
||||
device_type: str | None = None
|
||||
supports_remote_control: bool = False
|
||||
now_playing: str | None = None
|
||||
|
||||
|
||||
# ---- lifespan ------------------------------------------------------------
|
||||
@@ -99,11 +94,12 @@ mcp = FastMCP(
|
||||
"AVAILABLE TOOLS SUMMARY:\n"
|
||||
" Library: list_items, get_item, search_items\n"
|
||||
" Users: list_users, get_user\n"
|
||||
" Sessions:list_sessions, system_info\n"
|
||||
" Sessions:list_sessions, system_info, list_devices\n"
|
||||
" Playback:play_item, playback_url, playback_info\n\n"
|
||||
"COMMON WORKFLOWS:\n"
|
||||
" 'What movies do I have?' → list_items(type='Movie')\n"
|
||||
" 'Play Stranger Things' → search_items('Stranger Things') → play_item(id)\n"
|
||||
" 'What movies do I have?' → list_items(item_type='Movie')\n"
|
||||
" 'Play Stranger Things on the TV' → search_items('Stranger Things') → "
|
||||
"play_item(item_id, device='TV')\n"
|
||||
" 'Who is connected?' → list_sessions()\n"
|
||||
),
|
||||
lifespan=lifespan,
|
||||
@@ -120,23 +116,40 @@ def _emby(ctx: Context) -> EmbyClient:
|
||||
|
||||
def _item_to_summary(item: dict[str, Any]) -> ItemSummary:
|
||||
"""Convert an Emby item dict to a summary."""
|
||||
ticks = item.get("RunTimeTicks")
|
||||
size = item.get("Size")
|
||||
return ItemSummary(
|
||||
id=str(item.get("Id", "")),
|
||||
name=item.get("Name"),
|
||||
name=str(item.get("Name") or ""),
|
||||
type=item.get("Type"),
|
||||
parent_id=item.get("ParentId"),
|
||||
series_name=item.get("SeriesName"),
|
||||
season_number=item.get("SeasonNumber"),
|
||||
episode_number=item.get("EpisodeNumber"),
|
||||
year=item.get("ProductionYear") or item.get("Year"),
|
||||
runtime_minutes=item.get("RunTimeNumeric"),
|
||||
size_mb=item.get("Size") / (1024 * 1024) if item.get("Size") else None,
|
||||
season_number=item.get("ParentIndexNumber") if item.get("Type") == "Episode" else None,
|
||||
episode_number=item.get("IndexNumber") if item.get("Type") == "Episode" else None,
|
||||
year=item.get("ProductionYear"),
|
||||
runtime_minutes=round(ticks / _TICKS_PER_MINUTE) if isinstance(ticks, (int, float)) else None,
|
||||
size_mb=round(size / (1024 * 1024), 1) if isinstance(size, (int, float)) else None,
|
||||
)
|
||||
|
||||
|
||||
def _session_to_info(s: dict[str, Any]) -> SessionInfo:
|
||||
playing = s.get("NowPlayingItem") or {}
|
||||
return SessionInfo(
|
||||
id=s.get("Id"),
|
||||
user_id=s.get("UserId"),
|
||||
user_name=s.get("UserName"),
|
||||
client_name=s.get("Client") or s.get("ClientName"),
|
||||
device_name=s.get("DeviceName"),
|
||||
app_id=s.get("AppId"),
|
||||
device_type=s.get("DeviceType"),
|
||||
supports_remote_control=bool(s.get("SupportsRemoteControl")),
|
||||
now_playing=playing.get("Name") if isinstance(playing, dict) else None,
|
||||
)
|
||||
|
||||
|
||||
# ---- tools ---------------------------------------------------------------
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(annotations=_READ)
|
||||
async def ping(ctx: Context) -> dict[str, Any]:
|
||||
"""Verify connectivity to the Emby server and return system info."""
|
||||
emby = _emby(ctx)
|
||||
@@ -147,89 +160,87 @@ async def ping(ctx: Context) -> dict[str, Any]:
|
||||
"server_name": info.get("ServerName"),
|
||||
"version": info.get("Version"),
|
||||
"id": info.get("Id"),
|
||||
"date_created": info.get("dateCreated"),
|
||||
}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc)}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(annotations=_READ)
|
||||
async def list_items(
|
||||
ctx: Context,
|
||||
parent_id: str | None = None,
|
||||
item_type: str | None = None,
|
||||
limit: int = 50,
|
||||
sort_by: str | None = "Name",
|
||||
sort_by: str | None = "SortName",
|
||||
) -> list[ItemSummary]:
|
||||
"""List items from the Emby media library.
|
||||
|
||||
Args:
|
||||
parent_id: Parent folder ID to browse within (optional).
|
||||
limit: Maximum number of items to return (default 50).
|
||||
sort_by: Sort field name, e.g. 'Name', 'DateCreated', 'Rating' (default 'Name').
|
||||
item_type: Emby item type(s), comma-separated, e.g. 'Movie', 'Series',
|
||||
'Episode', 'MusicAlbum', 'Audio' (optional).
|
||||
limit: Maximum number of items to return (1-200, default 50).
|
||||
sort_by: Sort field, e.g. 'SortName', 'DateCreated', 'CommunityRating',
|
||||
'ProductionYear' (default 'SortName').
|
||||
"""
|
||||
emby = _emby(ctx)
|
||||
raw = await emby.list_items(parent_id=parent_id, limit=limit, sort_by=sort_by)
|
||||
raw = await emby.list_items(parent_id=parent_id, limit=max(1, min(limit, 200)),
|
||||
sort_by=sort_by, item_types=item_type)
|
||||
items = raw.get("Items", []) if isinstance(raw, dict) else []
|
||||
|
||||
return [_item_to_summary(i) for i in items if isinstance(i, dict)]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def search_items(ctx: Context, query: str, limit: int = 20) -> list[ItemSummary]:
|
||||
"""Search for media items by name.
|
||||
|
||||
Note: Currently performs a client-side filter on item names. For full-text search,
|
||||
use the Emby web interface or provide specific item types and parent folders.
|
||||
@mcp.tool(annotations=_READ)
|
||||
async def search_items(
|
||||
ctx: Context, query: str, item_type: str | None = None, limit: int = 20
|
||||
) -> list[ItemSummary]:
|
||||
"""Search the whole media library by name (server-side search).
|
||||
|
||||
Args:
|
||||
query: Text to search for in item names.
|
||||
limit: Maximum number of results (default 20).
|
||||
query: Text to search for (title, series, album, artist ...).
|
||||
item_type: Optional Emby item type(s), comma-separated, e.g. 'Movie' or
|
||||
'Series,Episode'. Defaults to all playable media types.
|
||||
limit: Maximum number of results (1-100, default 20).
|
||||
"""
|
||||
if not query.strip():
|
||||
raise ValueError("query must not be empty")
|
||||
emby = _emby(ctx)
|
||||
# Fetch a larger batch to filter on client side (search not natively supported by client)
|
||||
raw = await emby.list_items(limit=limit * 3)
|
||||
raw = await emby.list_items(search_term=query.strip(), item_types=item_type or _MEDIA_TYPES,
|
||||
limit=max(1, min(limit, 100)), sort_by=None)
|
||||
items = raw.get("Items", []) if isinstance(raw, dict) else []
|
||||
|
||||
# Simple client-side filter on Name field
|
||||
query_lower = query.lower()
|
||||
filtered = [
|
||||
i for i in items
|
||||
if isinstance(i, dict) and query_lower in (i.get("Name") or "").lower()
|
||||
][:limit]
|
||||
|
||||
return [_item_to_summary(i) for i in filtered]
|
||||
return [_item_to_summary(i) for i in items if isinstance(i, dict)]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(annotations=_READ)
|
||||
async def get_item(ctx: Context, item_id: str) -> dict[str, Any]:
|
||||
"""Get detailed information about a specific media item.
|
||||
|
||||
Args:
|
||||
item_id: The Emby item ID (UUID format).
|
||||
item_id: The Emby item ID (from search_items / list_items).
|
||||
"""
|
||||
emby = _emby(ctx)
|
||||
return await emby.get_item(item_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(annotations=_READ)
|
||||
async def list_users(ctx: Context) -> list[UserSummary]:
|
||||
"""List all users on the Emby server."""
|
||||
emby = _emby(ctx)
|
||||
raw = await emby.list_users()
|
||||
users = raw if isinstance(raw, list) else []
|
||||
users = await emby.list_users()
|
||||
|
||||
return [
|
||||
UserSummary(
|
||||
id=str(u.get("Id", "")),
|
||||
name=u.get("Name"),
|
||||
is_admin=u.get("IsAdmin", False),
|
||||
name=str(u.get("Name") or ""),
|
||||
is_admin=bool((u.get("Policy") or {}).get("IsAdministrator", u.get("IsAdmin", False))),
|
||||
has_authorization=u.get("HasAuthorization", True),
|
||||
)
|
||||
for u in users if isinstance(u, dict)
|
||||
for u in users
|
||||
]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(annotations=_READ)
|
||||
async def get_user(ctx: Context, user_id: str) -> dict[str, Any]:
|
||||
"""Get detailed information about a specific user.
|
||||
|
||||
@@ -240,124 +251,132 @@ async def get_user(ctx: Context, user_id: str) -> dict[str, Any]:
|
||||
return await emby.get_user(user_id)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(annotations=_READ)
|
||||
async def list_sessions(ctx: Context) -> list[SessionInfo]:
|
||||
"""List all active sessions (connected devices/apps)."""
|
||||
"""List active sessions (connected devices/apps).
|
||||
|
||||
Sessions with `supports_remote_control` can be targeted by `play_item`.
|
||||
"""
|
||||
emby = _emby(ctx)
|
||||
raw = await emby.list_sessions()
|
||||
|
||||
return [
|
||||
SessionInfo(
|
||||
user_id=s.get("UserId"),
|
||||
user_name=s.get("UserName"),
|
||||
client_name=s.get("ClientName"),
|
||||
device_name=s.get("DeviceName"),
|
||||
app_id=s.get("AppId"),
|
||||
device_type=s.get("DeviceType"),
|
||||
)
|
||||
for s in raw if isinstance(s, dict)
|
||||
]
|
||||
return [_session_to_info(s) for s in await emby.list_sessions() if isinstance(s, dict)]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(annotations=_READ)
|
||||
async def system_info(ctx: Context) -> dict[str, Any]:
|
||||
"""Get Emby server system information."""
|
||||
emby = _emby(ctx)
|
||||
return await emby.get_system_info()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def playback_url(
|
||||
ctx: Context, item_id: str, user_id: str | None = None, format: str = "mp4"
|
||||
) -> dict[str, Any]:
|
||||
"""Get a direct stream URL for playing media.
|
||||
@mcp.tool(annotations=_READ)
|
||||
async def playback_url(ctx: Context, item_id: str) -> dict[str, Any]:
|
||||
"""Get the direct (static) stream URL of a media item.
|
||||
|
||||
The URL is returned WITHOUT credentials: a player must authenticate with
|
||||
an `X-Emby-Token` header (or `api_key` parameter) of its own.
|
||||
|
||||
Args:
|
||||
item_id: The Emby item ID to play.
|
||||
user_id: Optional user ID for access control.
|
||||
format: Output format (default mp4).
|
||||
|
||||
Note: This returns a temporary URL that may expire. Use playback_info first
|
||||
if you need transcoding options.
|
||||
item_id: The Emby item ID.
|
||||
"""
|
||||
emby = _emby(ctx)
|
||||
try:
|
||||
result = await emby.get_playback_url(item_id)
|
||||
return {"url": result, "item_id": item_id}
|
||||
except Exception as exc:
|
||||
return {"error": str(exc), "item_id": item_id}
|
||||
item = await emby.get_item(item_id)
|
||||
kind = "Audio" if item.get("MediaType") == "Audio" else "Videos"
|
||||
url = f"{emby.settings.base_url}/{kind}/{quote(str(item.get('Id', item_id)), safe='')}/stream?static=true"
|
||||
return {"item_id": item_id, "name": item.get("Name"), "url": url,
|
||||
"auth": "requires X-Emby-Token header or api_key query parameter"}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(annotations=_READ)
|
||||
async def playback_info(
|
||||
ctx: Context, item_id: str, user_id: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""Get detailed playback information for an item.
|
||||
|
||||
Returns transcoding options, compatible codecs, and stream URLs
|
||||
for direct playback or transcoding.
|
||||
Returns the media sources with container, codecs and whether direct play
|
||||
or transcoding is supported.
|
||||
|
||||
Args:
|
||||
item_id: The Emby item ID.
|
||||
user_id: Optional user ID for access control.
|
||||
"""
|
||||
emby = _emby(ctx)
|
||||
try:
|
||||
info = await emby.get_playback_info(item_id, user_id)
|
||||
|
||||
# Extract relevant playback information
|
||||
result: dict[str, Any] = {
|
||||
"item_id": item_id,
|
||||
"can_transcode": info.get("CanTranscode", False),
|
||||
"media_sources": [],
|
||||
}
|
||||
|
||||
media_sources = info.get("MediaSources", [])
|
||||
for source in media_sources:
|
||||
if isinstance(source, dict):
|
||||
result["media_sources"].append({
|
||||
"id": source.get("Id"),
|
||||
"protocol": source.get("Protocol"),
|
||||
"container": source.get("Container"),
|
||||
"video_codec": source.get("VideoCodec"),
|
||||
"audio_codec": source.get("AudioCodec"),
|
||||
"direct_path": source.get("DirectPath"),
|
||||
})
|
||||
|
||||
return result
|
||||
except Exception as exc:
|
||||
return {"error": str(exc), "item_id": item_id}
|
||||
info = await emby.get_playback_info(item_id, user_id)
|
||||
sources = [s for s in info.get("MediaSources", []) if isinstance(s, dict)]
|
||||
return {
|
||||
"item_id": item_id,
|
||||
"can_transcode": any(s.get("SupportsTranscoding") for s in sources),
|
||||
"media_sources": [
|
||||
{
|
||||
"id": s.get("Id"),
|
||||
"protocol": s.get("Protocol"),
|
||||
"container": s.get("Container"),
|
||||
"supports_direct_play": s.get("SupportsDirectPlay"),
|
||||
"supports_transcoding": s.get("SupportsTranscoding"),
|
||||
"streams": [
|
||||
{"type": m.get("Type"), "codec": m.get("Codec"), "language": m.get("Language")}
|
||||
for m in s.get("MediaStreams", []) if isinstance(m, dict)
|
||||
],
|
||||
}
|
||||
for s in sources
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(annotations=_PLAY)
|
||||
async def play_item(
|
||||
ctx: Context,
|
||||
item_id: str,
|
||||
user_id: str | None = None,
|
||||
autoplay: bool = True,
|
||||
device: str | None = None,
|
||||
session_id: str | None = None,
|
||||
play_command: str = "PlayNow",
|
||||
) -> dict[str, Any]:
|
||||
"""Start playback of a media item.
|
||||
"""Play a media item on a connected Emby client (remote control).
|
||||
|
||||
The target is a session that supports remote control: pass `session_id`
|
||||
(from list_sessions) or a `device` name substring (e.g. 'TV'). If omitted
|
||||
and exactly one controllable session exists, that one is used.
|
||||
|
||||
Args:
|
||||
item_id: The Emby item ID to play.
|
||||
user_id: Optional user ID for access control.
|
||||
autoplay: Whether to start playback immediately (default True).
|
||||
|
||||
Note: This sends a remote control command to connected sessions.
|
||||
device: Device/client name substring to pick the session.
|
||||
session_id: Exact session ID (overrides device).
|
||||
play_command: 'PlayNow' (default), 'PlayNext' or 'PlayLast'.
|
||||
"""
|
||||
if play_command not in ("PlayNow", "PlayNext", "PlayLast"):
|
||||
raise ValueError("play_command must be PlayNow, PlayNext or PlayLast")
|
||||
emby = _emby(ctx)
|
||||
try:
|
||||
result = await emby.play_item(item_id, user_id, autoplay)
|
||||
return {"ok": True, "item_id": item_id, "result": result}
|
||||
except Exception as exc:
|
||||
return {"ok": False, "error": str(exc), "item_id": item_id}
|
||||
sessions = [s for s in await emby.list_sessions() if isinstance(s, dict)]
|
||||
controllable = [s for s in sessions if s.get("SupportsRemoteControl")]
|
||||
if session_id:
|
||||
targets = [s for s in controllable if s.get("Id") == session_id]
|
||||
elif device:
|
||||
d = device.strip().lower()
|
||||
targets = [s for s in controllable
|
||||
if d in str(s.get("DeviceName", "")).lower() or d in str(s.get("Client", "")).lower()]
|
||||
else:
|
||||
targets = controllable
|
||||
if len(targets) != 1:
|
||||
names = [f"{s.get('DeviceName')} ({s.get('Client')}, id={s.get('Id')})" for s in controllable]
|
||||
reason = "No matching" if not targets else "More than one matching"
|
||||
raise ValueError(
|
||||
f"{reason} remote-controllable session. Pass device or session_id. "
|
||||
f"Controllable sessions: {names or 'none (open an Emby app first)'}"
|
||||
)
|
||||
target = targets[0]
|
||||
await emby.play_on_session(str(target["Id"]), [item_id], play_command)
|
||||
return {"ok": True, "item_id": item_id, "session_id": target.get("Id"),
|
||||
"device": target.get("DeviceName"), "command": play_command}
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
@mcp.tool(annotations=_READ)
|
||||
async def list_devices(ctx: Context) -> list[dict[str, Any]]:
|
||||
"""List registered devices connected to the Emby server."""
|
||||
emby = _emby(ctx)
|
||||
return await emby.list_devices()
|
||||
return [
|
||||
{"id": d.get("Id"), "name": d.get("Name"), "app": d.get("AppName"),
|
||||
"last_user": d.get("LastUserName"), "last_active": d.get("DateLastActivity")}
|
||||
for d in await emby.list_devices()
|
||||
]
|
||||
|
||||
|
||||
# ---- resources -----------------------------------------------------------
|
||||
|
||||
Reference in new issue
Block a user