You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
SoulSync/core/media_server/registry.py

143 lines
5.5 KiB

"""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
that every dispatch site reached individually.
Server-specific dispatch chains in web_server.py (playlist add /
remove / replace, per-server metadata sync, etc.) still hand-branch
on ``active_server == X`` because the work each server does at those
sites is genuinely different — but they reach the per-server CLIENT
through ``engine.client(name)`` (which goes through this registry)
instead of separate globals.
Adding a new server (Subsonic / Emby) = one ``register`` call here +
the new client class.
"""
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
def set_instance(self, name: str, instance: Optional[MediaServerClient]) -> None:
"""Stash a pre-built client instance into the registry without
running the spec's factory. Used by callers (web_server.py at
boot) that already constructed the per-server clients and want
the engine to wrap those exact instances rather than build new
ones. Replaces the old pattern of reaching into ``_instances``
directly from the engine."""
self._instances[name] = instance
@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