`core/media_server/` package with the Protocol contract that every media server client (Plex, Jellyfin, Navidrome, SoulSync standalone) satisfies, plus the registry that holds them. Required methods conservatively limited to the four every server truly implements today: is_connected, ensure_connection, get_all_artists, get_all_album_ids. Other generic methods (search_tracks, trigger_library_scan, get_recently_added_albums, etc.) are listed as OPTIONAL — present on most servers but not all (SoulSync has no library-scan API since it walks the filesystem directly; Jellyfin uses a different search shape). Phase B's engine adapters route around the gaps with per-server fallback instead of forcing every client to declare a no-op stub. Same registry shape as the download plugin registry — single source of truth for which servers exist + name resolution. Adding a 5th server (Subsonic, Emby, etc.) becomes one register call plus the new client class. 5 conformance tests pin every server class implements every required method. Plan doc at docs/media-server-engine-refactor-plan.md. Pure additive — no consumer routes through the contract or registry yet. Suite still green (1921 passed).
127 lines
4.7 KiB
Python
127 lines
4.7 KiB
Python
"""Media server plugin registry.
|
|
|
|
Single source of truth for which servers exist, what their canonical
|
|
names are, and which client class implements each. Replaces the
|
|
historic web_server.py pattern of holding 4 separate client globals
|
|
+ 33 hand-maintained ``if active_server == 'plex' / 'jellyfin' / ...``
|
|
dispatch sites.
|
|
|
|
Adding a new server (e.g. Subsonic, Emby) becomes one ``register``
|
|
call here + the new client class. Web server dispatch stays put.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
from typing import Callable, Dict, Iterator, List, Optional, Tuple
|
|
|
|
from utils.logging_config import get_logger
|
|
|
|
from core.media_server.contract import MediaServerClient
|
|
|
|
# Eager imports for the same import-order reason the download plugin
|
|
# registry uses them (some integration tests inject mock modules into
|
|
# sys.modules at collection time; lazy import would bind to the mock).
|
|
from core.jellyfin_client import JellyfinClient
|
|
from core.navidrome_client import NavidromeClient
|
|
from core.plex_client import PlexClient
|
|
from core.soulsync_client import SoulSyncClient
|
|
|
|
logger = get_logger("media_server.registry")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ServerSpec:
|
|
"""Static descriptor for a media server. ``factory`` is the
|
|
zero-arg callable that builds the client (each server has its
|
|
own setup chain — Plex pulls token from config, Jellyfin reads
|
|
user_id, etc.)."""
|
|
|
|
name: str
|
|
factory: Callable[[], MediaServerClient]
|
|
display_name: str
|
|
aliases: Tuple[str, ...] = field(default_factory=tuple)
|
|
|
|
|
|
class MediaServerRegistry:
|
|
"""Holds the live client instances + name → instance lookup.
|
|
|
|
Two-phase construction (mirrors the download plugin registry):
|
|
1. Specs registered cheaply (just stores callable refs).
|
|
2. ``initialize()`` calls each factory once. Failures captured
|
|
in ``init_failures`` so one broken server doesn't take down
|
|
the orchestrator.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._specs: Dict[str, ServerSpec] = {}
|
|
self._instances: Dict[str, Optional[MediaServerClient]] = {}
|
|
self._init_failures: List[str] = []
|
|
|
|
def register(self, spec: ServerSpec) -> None:
|
|
if spec.name in self._specs:
|
|
raise ValueError(f"Server already registered: {spec.name}")
|
|
self._specs[spec.name] = spec
|
|
|
|
def initialize(self) -> None:
|
|
for spec in self._specs.values():
|
|
try:
|
|
instance = spec.factory()
|
|
self._instances[spec.name] = instance
|
|
except Exception as exc:
|
|
logger.error("%s media server client failed to initialize: %s", spec.display_name, exc)
|
|
self._init_failures.append(spec.display_name)
|
|
self._instances[spec.name] = None
|
|
|
|
@property
|
|
def init_failures(self) -> List[str]:
|
|
return list(self._init_failures)
|
|
|
|
def get(self, name: str) -> Optional[MediaServerClient]:
|
|
if not name:
|
|
return None
|
|
if name in self._instances:
|
|
return self._instances[name]
|
|
for spec in self._specs.values():
|
|
if name in spec.aliases:
|
|
return self._instances.get(spec.name)
|
|
return None
|
|
|
|
def get_spec(self, name: str) -> Optional[ServerSpec]:
|
|
if name in self._specs:
|
|
return self._specs[name]
|
|
for spec in self._specs.values():
|
|
if name in spec.aliases:
|
|
return spec
|
|
return None
|
|
|
|
def display_name(self, name: str) -> str:
|
|
spec = self.get_spec(name)
|
|
return spec.display_name if spec else name
|
|
|
|
def names(self) -> List[str]:
|
|
return list(self._specs.keys())
|
|
|
|
def all_clients(self) -> Iterator[Tuple[str, MediaServerClient]]:
|
|
"""Yield (name, client) for every successfully-initialized
|
|
server. Used by cross-server operations."""
|
|
for name, instance in self._instances.items():
|
|
if instance is not None:
|
|
yield name, instance
|
|
|
|
|
|
def build_default_registry() -> MediaServerRegistry:
|
|
"""Construct the registry with SoulSync's four built-in media
|
|
servers. Called once during MediaServerEngine construction.
|
|
|
|
Adding a server (e.g. Subsonic, Emby) = one ``register`` call
|
|
here + the new client class. No dispatch-site changes required.
|
|
"""
|
|
registry = MediaServerRegistry()
|
|
|
|
registry.register(ServerSpec(name='plex', factory=PlexClient, display_name='Plex'))
|
|
registry.register(ServerSpec(name='jellyfin', factory=JellyfinClient, display_name='Jellyfin'))
|
|
registry.register(ServerSpec(name='navidrome', factory=NavidromeClient, display_name='Navidrome'))
|
|
registry.register(ServerSpec(name='soulsync', factory=SoulSyncClient, display_name='SoulSync Library'))
|
|
|
|
return registry
|