Initial commit: Javis project

This commit is contained in:
Javis committed 2026-09-21 21:44:00 +02:00
commit 435c645f36
164 files changed
+35279

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
"""emby-mcp: MCP server for an Emby media server."""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from .server import main
if __name__ == "__main__":
main()
+134
View File
@@ -0,0 +1,134 @@
"""Async client for the Emby API."""
from __future__ import annotations
import logging
from typing import Any
import httpx
from .config import Settings
log = logging.getLogger(__name__)
class EmbyClient:
"""Thin async wrapper around Emby's REST API."""
def __init__(self, settings: Settings) -> None:
self.settings = settings
self._http = httpx.AsyncClient(
base_url=settings.base_url,
headers={
"X-Emby-Token": settings.api_key,
"Accept": "application/json",
},
timeout=settings.timeout,
)
async def aclose(self) -> None:
await self._http.aclose()
# ---- helpers ----------------------------------------------------------
async def _get(self, path: str, params: dict[str, Any] | None = None) -> Any:
r = await self._http.get(path, params=params)
r.raise_for_status()
return r.json()
async def _post(
self, path: str, json: dict[str, Any] | None = None, params: dict[str, Any] | None = None
) -> Any:
r = await self._http.post(path, json=json, params=params)
r.raise_for_status()
if r.content:
try:
return r.json()
except ValueError:
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_order: str = "Ascending",
limit: int | None = None,
) -> dict[str, Any]:
"""List items from the media library."""
params: dict[str, Any] = {
"IncludeRelated": str(include_related).lower(),
"Recursive": str(recursive).lower(),
"SortBy": sort_by,
"SortOrder": sort_order,
}
if parent_id:
params["ParentId"] = parent_id
if limit:
params["Limit"] = limit
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}")
# ---- API: Users -------------------------------------------------------
async def list_users(self) -> dict[str, Any]:
"""List all users on the server."""
return await self._get("/Users")
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}")
# ---- API: Sessions ----------------------------------------------------
async def list_sessions(self) -> list[dict[str, Any]]:
"""List active sessions (connected devices/apps)."""
return await self._get("/Sessions")
async def get_system_info(self) -> dict[str, Any]:
"""Get server system info."""
return await self._get("/System/Info")
# ---- API: Playback ----------------------------------------------------
async def play_item(
self, item_id: str, user_id: str | None = None, autoplay: bool = True
) -> 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)
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")
# ---- 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 []
+40
View File
@@ -0,0 +1,40 @@
"""Configuration loaded from environment variables / .env."""
from __future__ import annotations
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Settings for the emby-mcp server.
Values are read from environment variables (prefix ``EMBY_``) or a
local ``.env`` file.
"""
model_config = SettingsConfigDict(
env_prefix="EMBY_",
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
host: str = Field(..., description="IP or hostname of the Emby server.")
api_key: str = Field(..., description="Emby API key (from dashboard).")
scheme: str = Field("https", description="http or https.")
port: int | None = Field(
None,
description="Optional TCP port.",
)
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}"
def load_settings() -> Settings:
return Settings() # type: ignore[call-arg]
+438
View File
@@ -0,0 +1,438 @@
"""FastMCP server exposing an Emby media server."""
from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any
from mcp.server.fastmcp import FastMCP, Context
from pydantic import BaseModel, Field
from .client import EmbyClient
from .config import load_settings
log = logging.getLogger("emby_mcp")
# ---- models --------------------------------------------------------------
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")
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}
class UserSummary(BaseModel):
id: str
name: str
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}
# ---- lifespan ------------------------------------------------------------
# Shared client for all operations (tools and resources)
_shared_client: EmbyClient | None = None
@dataclass
class AppContext:
emby: EmbyClient
@asynccontextmanager
async def lifespan(_server: FastMCP) -> AsyncIterator[AppContext]:
global _shared_client
settings = load_settings()
client = EmbyClient(settings)
_shared_client = client
try:
yield AppContext(emby=client)
finally:
_shared_client = None
await client.aclose()
# ---- MCP server ----------------------------------------------------------
mcp = FastMCP(
"emby-mcp",
instructions=(
"Tools for inspecting and controlling an Emby media server.\n\n"
"AVAILABLE TOOLS SUMMARY:\n"
" Library: list_items, get_item, search_items\n"
" Users: list_users, get_user\n"
" Sessions:list_sessions, system_info\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"
" 'Who is connected?' → list_sessions()\n"
),
lifespan=lifespan,
)
# ---- helpers -------------------------------------------------------------
def _emby(ctx: Context) -> EmbyClient:
app: AppContext = ctx.request_context.lifespan_context # type: ignore[assignment]
return app.emby
def _item_to_summary(item: dict[str, Any]) -> ItemSummary:
"""Convert an Emby item dict to a summary."""
return ItemSummary(
id=str(item.get("Id", "")),
name=item.get("Name"),
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,
)
# ---- tools ---------------------------------------------------------------
@mcp.tool()
async def ping(ctx: Context) -> dict[str, Any]:
"""Verify connectivity to the Emby server and return system info."""
emby = _emby(ctx)
try:
info = await emby.get_system_info()
return {
"ok": True,
"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()
async def list_items(
ctx: Context,
parent_id: str | None = None,
limit: int = 50,
sort_by: str | None = "Name",
) -> 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').
"""
emby = _emby(ctx)
raw = await emby.list_items(parent_id=parent_id, limit=limit, sort_by=sort_by)
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.
Args:
query: Text to search for in item names.
limit: Maximum number of results (default 20).
"""
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)
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]
@mcp.tool()
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).
"""
emby = _emby(ctx)
return await emby.get_item(item_id)
@mcp.tool()
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 []
return [
UserSummary(
id=str(u.get("Id", "")),
name=u.get("Name"),
is_admin=u.get("IsAdmin", False),
has_authorization=u.get("HasAuthorization", True),
)
for u in users if isinstance(u, dict)
]
@mcp.tool()
async def get_user(ctx: Context, user_id: str) -> dict[str, Any]:
"""Get detailed information about a specific user.
Args:
user_id: The Emby user ID.
"""
emby = _emby(ctx)
return await emby.get_user(user_id)
@mcp.tool()
async def list_sessions(ctx: Context) -> list[SessionInfo]:
"""List all active sessions (connected devices/apps)."""
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)
]
@mcp.tool()
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.
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.
"""
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}
@mcp.tool()
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.
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}
@mcp.tool()
async def play_item(
ctx: Context,
item_id: str,
user_id: str | None = None,
autoplay: bool = True,
) -> dict[str, Any]:
"""Start playback of a media item.
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.
"""
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}
@mcp.tool()
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()
# ---- resources -----------------------------------------------------------
@mcp.resource("emby://library")
async def resource_library() -> str:
"""Snapshot of the entire media library as JSON."""
import json
emby = _shared_client
if emby is None:
raise RuntimeError("Emby client not initialized (server may be shutting down)")
# Get all items without limit for a full snapshot
raw = await emby.list_items(limit=1000)
items = raw.get("Items", []) if isinstance(raw, dict) else []
summaries = [_item_to_summary(i).model_dump() for i in items if isinstance(i, dict)]
return json.dumps(summaries, indent=2, default=str)
@mcp.resource("emby://users")
async def resource_users() -> str:
"""Snapshot of all users as JSON."""
import json
emby = _shared_client
if emby is None:
raise RuntimeError("Emby client not initialized (server may be shutting down)")
raw = await emby.list_users()
users = raw if isinstance(raw, list) else []
result = [
{
"id": str(u.get("Id", "")),
"name": u.get("Name"),
"is_admin": u.get("IsAdmin", False),
}
for u in users if isinstance(u, dict)
]
return json.dumps(result, indent=2)
@mcp.resource("emby://sessions")
async def resource_sessions() -> str:
"""Snapshot of current sessions as JSON."""
import json
emby = _shared_client
if emby is None:
raise RuntimeError("Emby client not initialized (server may be shutting down)")
raw = await emby.list_sessions()
result = [
{
"user_name": s.get("UserName"),
"client_name": s.get("ClientName"),
"device_name": s.get("DeviceName"),
"app_id": s.get("AppId"),
}
for s in raw if isinstance(s, dict)
]
return json.dumps(result, indent=2)
# ---- entry point ---------------------------------------------------------
def main() -> None:
import logging as _logging
_logging.basicConfig(
level=_logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)
mcp.run() # stdio transport for local MCP hosts
if __name__ == "__main__":
main()