Initial commit: Javis project
This commit is contained in:
commit
435c645f36
164 files changed
+35279
No files matched your search
@@ -0,0 +1,3 @@
|
||||
"""emby-mcp: MCP server for an Emby media server."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,4 @@
|
||||
from .server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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 []
|
||||
@@ -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]
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user