`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).
148 lines
5.7 KiB
Python
148 lines
5.7 KiB
Python
"""Canonical contract for media server clients.
|
|
|
|
Narrow on purpose. Only declares the methods the orchestrator
|
|
dispatches generically across all servers. Server-specific extras
|
|
(Plex's `set_music_library_by_name`, Jellyfin's user picker,
|
|
Navidrome's music folder filter, SoulSync's filesystem rescan)
|
|
stay on the underlying client and are accessed through the
|
|
registry's typed accessor — same pattern as the download
|
|
plugin contract.
|
|
|
|
Every required method must be implemented by every registered
|
|
client. Optional methods have default no-op implementations so
|
|
servers without that capability (e.g. Navidrome's metadata
|
|
writeback stubs, SoulSync's playlist sync N/A) don't have to
|
|
declare a no-op explicitly.
|
|
|
|
The contract is a Protocol (structural typing) rather than an
|
|
ABC — existing PlexClient / JellyfinClient / NavidromeClient /
|
|
SoulSyncClient grew the same shape independently because every
|
|
caller needed the same calls. This file just makes the implicit
|
|
contract explicit.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
|
|
|
|
|
@runtime_checkable
|
|
class MediaServerClient(Protocol):
|
|
"""Structural contract every media server client must satisfy.
|
|
|
|
``runtime_checkable`` lets ``isinstance(client, MediaServerClient)``
|
|
work for the conformance test, but it ONLY checks method names —
|
|
not signatures. The conformance test in
|
|
``tests/media_server/test_conformance.py`` does the deeper
|
|
signature check.
|
|
"""
|
|
|
|
# ------------------------------------------------------------------
|
|
# Connection / lifecycle
|
|
# ------------------------------------------------------------------
|
|
|
|
def is_connected(self) -> bool:
|
|
"""Cheap probe — does the client have a live connection /
|
|
token / session right now? Used by the dashboard status
|
|
indicators + endpoint guards."""
|
|
...
|
|
|
|
def ensure_connection(self) -> bool:
|
|
"""Re-auth or reconnect if needed. May make a network call.
|
|
Returns True if connection is usable after the call."""
|
|
...
|
|
|
|
# ------------------------------------------------------------------
|
|
# Library reads (required — every server must support these)
|
|
# ------------------------------------------------------------------
|
|
|
|
def get_all_artists(self) -> List[Any]:
|
|
"""Return every artist the server knows about. Each item is
|
|
a server-specific wrapper object (PlexArtist, JellyfinArtist,
|
|
NavidromeArtist, SoulSyncArtist) — caller treats them
|
|
opaquely."""
|
|
...
|
|
|
|
def get_all_album_ids(self) -> set:
|
|
"""Return the set of every album ID in the library. ID
|
|
format is server-native — caller doesn't introspect."""
|
|
...
|
|
|
|
def search_tracks(self, title: str, artist: str, limit: int = 15) -> List[Any]:
|
|
"""Search the server's library for tracks matching the title
|
|
+ artist. Used by playlist sync, download matching, and the
|
|
general search UI. Each item is a server-specific TrackInfo
|
|
wrapper."""
|
|
...
|
|
|
|
def get_recently_added_albums(self, max_results: int = 400) -> List[Any]:
|
|
"""Recently-added view — used by the Discover page +
|
|
watchlist scanner. Sorted by add timestamp descending."""
|
|
...
|
|
|
|
# ------------------------------------------------------------------
|
|
# Library writes — scan triggers
|
|
# ------------------------------------------------------------------
|
|
|
|
def trigger_library_scan(self) -> bool:
|
|
"""Ask the server to scan its music library. Some servers
|
|
(SoulSync standalone) walk the filesystem themselves; some
|
|
(Plex / Jellyfin / Navidrome) hit a server-side scan API."""
|
|
...
|
|
|
|
def is_library_scanning(self) -> bool:
|
|
"""True if a scan is currently running. Polled by the
|
|
scan-progress UI."""
|
|
...
|
|
|
|
def get_library_stats(self) -> Dict[str, int]:
|
|
"""Counts of artists / albums / tracks. Used by the
|
|
dashboard system-stats card."""
|
|
...
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Required + optional method names — used by the conformance tests to
|
|
# check structural conformance without the proxy weight of dataclasses.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Conservative requirement set — only methods every one of the four
|
|
# servers actually implements today. Audited by the conformance test.
|
|
# Other methods (search_tracks, trigger_library_scan, etc.) exist on
|
|
# most servers but not all (e.g. SoulSync has no library scan API
|
|
# because it walks the filesystem directly). Phase B's engine
|
|
# adapters handle those with per-server fallback rather than forcing
|
|
# every client to declare a no-op stub.
|
|
REQUIRED_METHODS = {
|
|
'is_connected',
|
|
'ensure_connection',
|
|
'get_all_artists',
|
|
'get_all_album_ids',
|
|
}
|
|
|
|
# Methods declared on the protocol but NOT enforced — the
|
|
# conformance test does NOT fail if a client lacks one. Engine
|
|
# adapters route around the gaps. Listed here so future contributors
|
|
# know what the engine expects to find when present.
|
|
OPTIONAL_METHODS = {
|
|
'search_tracks',
|
|
'get_recently_added_albums',
|
|
'trigger_library_scan',
|
|
'is_library_scanning',
|
|
'get_library_stats',
|
|
# Playlist sync (Plex / Jellyfin / Navidrome implement; SoulSync no-op)
|
|
'create_playlist',
|
|
'update_playlist',
|
|
'copy_playlist',
|
|
'get_all_playlists',
|
|
'get_playlist_by_name',
|
|
# Analytics
|
|
'get_play_history',
|
|
'get_track_play_counts',
|
|
# Metadata writeback (Plex full support; Jellyfin partial; Navidrome stubs; SoulSync N/A)
|
|
'update_artist_genres',
|
|
'update_artist_poster',
|
|
'update_album_poster',
|
|
'update_artist_biography',
|
|
'update_track_metadata',
|
|
}
|