MS Cin/JohnBaumb honesty pass — drop dead wrappers, sync contract to reality
Pre-review audit found premature abstraction + lying docstrings. Cut what isn't used, made the rest match what's actually shipped. (1) Engine: dropped 7 cross-server dispatch wrappers that had ZERO production callers (ensure_connection / get_all_artists / get_all_album_ids / search_tracks / trigger_library_scan / is_library_scanning / get_library_stats / get_recently_added_albums). Every consumer reaches the active client directly via sync_service._get_active_media_client() or engine.client(name). Engine surface shrinks to client(name) / active_client() / active_server / is_connected() (the one wrapper that has callers — 4 dashboard status sites) / configured_clients() / reload_config(). ~150 lines deleted, 5 dead-method tests removed. (2) Contract Protocol body trimmed to match REQUIRED_METHODS exactly (is_connected, ensure_connection, get_all_artists, get_all_album_ids). The other 5 methods that were declared in the Protocol "required" section weren't actually required — Plex doesn't implement get_recently_added_albums, Jellyfin doesn't implement search_tracks, SoulSync doesn't implement most of them. Static contract now matches runtime conformance test. Optional methods moved to a KNOWN_PER_SERVER_METHODS data-only listing with audited per-server coverage notes — discoverability without false promises. (3) Engine module docstring + __init__.py docstring no longer overclaim "33+ chains collapsed" — only 4 uniform-shape chains were collapsed; ~18 server-specific chains stay explicit per the "lift what's truly shared" standard. Phrasing now matches reality. (4) types.py docstring claimed TrackInfo.from_jellyfin_dict and TrackInfo.from_navidrome_dict exist as classmethods. They don't — only from_plex_track / from_plex_playlist do. Jellyfin and Navidrome construct TrackInfo inline at their call sites today. Docstring now honest about that + flags the lift as a clean followup. (5) Engine line 95 comment "backward-compat for source-specific reaches" was misleading — there is no legacy alternative being preserved; engine.client(name) IS the canonical access pattern. Section header rewritten. Tests: 2121 pass (was 2126; -5 dead-method pin tests).
This commit is contained in:
parent
99f527ecd1
commit
6489244bcc
5 changed files with 114 additions and 314 deletions
|
|
@ -1,17 +1,23 @@
|
||||||
"""Media server engine — central dispatch for the per-server clients
|
"""Media server engine — central registry-backed access to the
|
||||||
(Plex, Jellyfin, Navidrome, SoulSync standalone).
|
per-server clients (Plex, Jellyfin, Navidrome, SoulSync standalone).
|
||||||
|
|
||||||
Companion to the download engine refactor — same architectural
|
Companion to the download engine refactor — same architectural
|
||||||
shape applied to the read-side of the library. The orchestrator
|
shape applied to the read-side of the library. Pre-refactor
|
||||||
historically had 33+ ``if active_server == 'plex' / 'jellyfin' /
|
web_server.py held four separate per-server globals
|
||||||
...`` dispatch sites in web_server.py. This package replaces those
|
(``plex_client`` / ``jellyfin_client`` / ``navidrome_client`` /
|
||||||
with a single ``MediaServerEngine.method()`` call per operation.
|
``soulsync_library_client``) that every dispatch site reached
|
||||||
|
individually. This package replaces those globals with a single
|
||||||
|
engine that owns the client instances + a generic
|
||||||
|
``engine.client(name)`` accessor.
|
||||||
|
|
||||||
Per-server clients keep their protocol-specific work (Plex's
|
The 18-or-so ``if active_server == 'plex' / 'jellyfin' / ...``
|
||||||
PlexAPI SDK, Jellyfin's REST endpoints, Navidrome's OpenSubsonic
|
chains in web_server.py that do server-specific work (Plex raw
|
||||||
API, SoulSync's filesystem walk). The engine just routes by
|
playlist API vs Jellyfin / Navidrome client methods returning
|
||||||
``active_server`` config + provides a uniform shape for the calls
|
different shapes) stay explicit at the call site per the "lift
|
||||||
``web_server.py`` makes generically.
|
what's truly shared" standard — but they reach the per-server
|
||||||
|
client through ``engine.client(name)`` rather than the legacy
|
||||||
|
globals. The four uniform-shape ``is_connected`` chains were the
|
||||||
|
only ones genuinely shared and are now ``engine.is_connected()``.
|
||||||
|
|
||||||
See ``docs/media-server-engine-refactor-plan.md`` for the full
|
See ``docs/media-server-engine-refactor-plan.md`` for the full
|
||||||
phased plan.
|
phased plan.
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,25 @@
|
||||||
"""Canonical contract for media server clients.
|
"""Canonical contract for media server clients.
|
||||||
|
|
||||||
Narrow on purpose. Only declares the methods the orchestrator
|
Narrow on purpose. Protocol body declares ONLY the methods every
|
||||||
dispatches generically across all servers. Server-specific extras
|
registered client actually implements today — keeps the static
|
||||||
(Plex's `set_music_library_by_name`, Jellyfin's user picker,
|
contract honest. Server-specific extras (Plex's
|
||||||
Navidrome's music folder filter, SoulSync's filesystem rescan)
|
``set_music_library_by_name``, Jellyfin's user picker, Navidrome's
|
||||||
stay on the underlying client and are accessed through the
|
music folder filter, SoulSync's filesystem rescan) and methods that
|
||||||
registry's typed accessor — same pattern as the download
|
most-but-not-all servers implement (``search_tracks`` on Plex /
|
||||||
plugin contract.
|
Navidrome but not Jellyfin; ``get_recently_added_albums`` on
|
||||||
|
Jellyfin / Navidrome / SoulSync but not Plex) stay off the Protocol
|
||||||
|
and are reached through ``engine.client(name)`` directly.
|
||||||
|
|
||||||
Every required method must be implemented by every registered
|
The contract is a Protocol (structural typing) rather than an ABC —
|
||||||
client. Optional methods have default no-op implementations so
|
existing PlexClient / JellyfinClient / NavidromeClient /
|
||||||
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
|
SoulSyncClient grew the same shape independently because every
|
||||||
caller needed the same calls. This file just makes the implicit
|
caller needed the same four calls. This file just makes that
|
||||||
contract explicit.
|
implicit contract explicit + the conformance test pins it.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
from typing import Any, List, Protocol, runtime_checkable
|
||||||
|
|
||||||
|
|
||||||
@runtime_checkable
|
@runtime_checkable
|
||||||
|
|
@ -31,14 +27,13 @@ class MediaServerClient(Protocol):
|
||||||
"""Structural contract every media server client must satisfy.
|
"""Structural contract every media server client must satisfy.
|
||||||
|
|
||||||
``runtime_checkable`` lets ``isinstance(client, MediaServerClient)``
|
``runtime_checkable`` lets ``isinstance(client, MediaServerClient)``
|
||||||
work for the conformance test, but it ONLY checks method names —
|
work, but it ONLY checks method names — not signatures. The
|
||||||
not signatures. The conformance test in
|
conformance test in ``tests/media_server/test_conformance.py``
|
||||||
``tests/media_server/test_conformance.py`` does the deeper
|
does the deeper class-level check via REQUIRED_METHODS.
|
||||||
signature check.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Connection / lifecycle
|
# Connection / lifecycle — required, every server implements
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
|
|
@ -53,7 +48,7 @@ class MediaServerClient(Protocol):
|
||||||
...
|
...
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Library reads (required — every server must support these)
|
# Library reads — required, every server implements
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def get_all_artists(self) -> List[Any]:
|
def get_all_artists(self) -> List[Any]:
|
||||||
|
|
@ -68,51 +63,12 @@ class MediaServerClient(Protocol):
|
||||||
format is server-native — caller doesn't introspect."""
|
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
|
# Required method set — pinned by the conformance test. Mirrors the
|
||||||
# check structural conformance without the proxy weight of dataclasses.
|
# Protocol body exactly so static + runtime contracts can't drift.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# 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 = {
|
REQUIRED_METHODS = {
|
||||||
'is_connected',
|
'is_connected',
|
||||||
'ensure_connection',
|
'ensure_connection',
|
||||||
|
|
@ -120,29 +76,35 @@ REQUIRED_METHODS = {
|
||||||
'get_all_album_ids',
|
'get_all_album_ids',
|
||||||
}
|
}
|
||||||
|
|
||||||
# Methods declared on the protocol but NOT enforced — the
|
# Methods that exist on SOME servers but NOT all, listed here for
|
||||||
# conformance test does NOT fail if a client lacks one. Engine
|
# discoverability. The conformance test does NOT enforce these. Callers
|
||||||
# adapters route around the gaps. Listed here so future contributors
|
# that need one reach the per-server client directly via
|
||||||
# know what the engine expects to find when present.
|
# ``engine.client(name).<method>`` rather than going through the engine,
|
||||||
OPTIONAL_METHODS = {
|
# since the engine has no uniform safe-default that fits every method.
|
||||||
|
#
|
||||||
|
# Coverage today (audited 2026-05):
|
||||||
|
# search_tracks: Plex ✓, Navidrome ✓, Jellyfin ✗, SoulSync ✗
|
||||||
|
# get_recently_added_albums: Jellyfin ✓, Navidrome ✓, SoulSync ✓, Plex ✗ (uses recentlyAdded() on music library)
|
||||||
|
# trigger_library_scan / is_library_scanning: Plex ✓, Jellyfin ✓, Navidrome ✓, SoulSync ✗ (filesystem walks in-process)
|
||||||
|
# get_library_stats: Plex ✓, Jellyfin ✓, Navidrome ✓, SoulSync ✗
|
||||||
|
# create_playlist / update_playlist / get_all_playlists / etc: Plex ✓, Jellyfin ✓, Navidrome ✓, SoulSync ✗
|
||||||
|
# update_artist_*, update_album_poster, update_track_metadata: Plex ✓, Jellyfin partial, Navidrome stubs, SoulSync ✗
|
||||||
|
KNOWN_PER_SERVER_METHODS = (
|
||||||
'search_tracks',
|
'search_tracks',
|
||||||
'get_recently_added_albums',
|
'get_recently_added_albums',
|
||||||
'trigger_library_scan',
|
'trigger_library_scan',
|
||||||
'is_library_scanning',
|
'is_library_scanning',
|
||||||
'get_library_stats',
|
'get_library_stats',
|
||||||
# Playlist sync (Plex / Jellyfin / Navidrome implement; SoulSync no-op)
|
|
||||||
'create_playlist',
|
'create_playlist',
|
||||||
'update_playlist',
|
'update_playlist',
|
||||||
'copy_playlist',
|
'copy_playlist',
|
||||||
'get_all_playlists',
|
'get_all_playlists',
|
||||||
'get_playlist_by_name',
|
'get_playlist_by_name',
|
||||||
# Analytics
|
|
||||||
'get_play_history',
|
'get_play_history',
|
||||||
'get_track_play_counts',
|
'get_track_play_counts',
|
||||||
# Metadata writeback (Plex full support; Jellyfin partial; Navidrome stubs; SoulSync N/A)
|
|
||||||
'update_artist_genres',
|
'update_artist_genres',
|
||||||
'update_artist_poster',
|
'update_artist_poster',
|
||||||
'update_album_poster',
|
'update_album_poster',
|
||||||
'update_artist_biography',
|
'update_artist_biography',
|
||||||
'update_track_metadata',
|
'update_track_metadata',
|
||||||
}
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,40 @@
|
||||||
"""MediaServerEngine — central dispatch for media server operations.
|
"""MediaServerEngine — central registry-backed access to media server clients.
|
||||||
|
|
||||||
Replaces the *uniform-shape* dispatch chains in web_server.py
|
Honest scope: the engine OWNS the per-server client instances and
|
||||||
(``is_connected``, ``get_all_artists``, etc. — anything where every
|
exposes a small set of generic accessors so callers don't need
|
||||||
server returns the same shape and the only branching was on
|
per-server attribute reaches. Most actual cross-server dispatch in
|
||||||
``active_server == X``). Each such operation is now one
|
web_server.py (playlist add / remove / replace, per-server metadata
|
||||||
``engine.method()`` call that:
|
sync, deep scan with server-specific cache strategies) is genuinely
|
||||||
|
different per server and stays explicit in the call site — the
|
||||||
|
engine just provides the canonical client lookup so those sites
|
||||||
|
reach via ``engine.client(name)`` instead of separate globals.
|
||||||
|
|
||||||
1. Reads the ``server.active`` config to find the current target.
|
Surface:
|
||||||
2. Looks up the registered client.
|
- ``client(name)`` / ``active_client()`` — name → client lookup
|
||||||
3. Calls the corresponding method (with safe per-server fallbacks
|
- ``active_server`` — config-driven active server name
|
||||||
for methods that don't exist on every client — e.g. SoulSync
|
- ``is_connected()`` — only cross-server dispatch with real callers
|
||||||
has no library-scan API).
|
today (dashboard status indicators); kept as the canonical example
|
||||||
|
- ``configured_clients()`` — replaces the legacy per-server
|
||||||
|
``if X and X.is_connected()`` chains in web_server.py
|
||||||
|
- ``reload_config(name=None)`` — generic dispatch instead of
|
||||||
|
per-client reload calls
|
||||||
|
|
||||||
Server-specific dispatch sites (Plex's raw playlist API, Jellyfin /
|
Per-method engine wrappers for ``get_all_artists`` / ``search_tracks``
|
||||||
Navidrome client methods returning different shapes) stay explicit
|
/ ``trigger_library_scan`` / etc. were on an earlier draft but had no
|
||||||
in web_server.py per the "lift what's truly shared" standard. They
|
production callers — every consumer reaches the active client directly
|
||||||
reach individual clients via ``engine.client(name)`` rather than
|
through ``sync_service._get_active_media_client()`` or
|
||||||
the per-server globals — same generic-accessor pattern as the
|
``engine.client(name)`` and calls the per-server method itself. Cut
|
||||||
download orchestrator.
|
per the "no premature abstraction" standard.
|
||||||
|
|
||||||
Engine itself is constructed once during web_server.py init and
|
Engine is constructed once during web_server.py init and held as a
|
||||||
held as a process-wide singleton via
|
process-wide singleton via ``set_media_server_engine`` /
|
||||||
``set_media_server_engine`` / ``get_media_server_engine``, mirroring
|
``get_media_server_engine``, mirroring the metadata + download
|
||||||
the metadata + download engine factory shape.
|
engine factory shape.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from utils.logging_config import get_logger
|
from utils.logging_config import get_logger
|
||||||
|
|
||||||
|
|
@ -92,7 +99,8 @@ class MediaServerEngine:
|
||||||
self._resolve_active = active_server_resolver
|
self._resolve_active = active_server_resolver
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Direct client access (backward-compat for source-specific reaches)
|
# Client lookup — generic accessors that replace per-server
|
||||||
|
# attribute reaches in callers.
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def client(self, name: str) -> Optional[MediaServerClient]:
|
def client(self, name: str) -> Optional[MediaServerClient]:
|
||||||
|
|
@ -111,13 +119,12 @@ class MediaServerEngine:
|
||||||
"""The client for the currently-active server."""
|
"""The client for the currently-active server."""
|
||||||
return self.registry.get(self.active_server)
|
return self.registry.get(self.active_server)
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Cross-server dispatch — required methods (always present)
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def is_connected(self) -> bool:
|
def is_connected(self) -> bool:
|
||||||
"""Active server's connection state. False if no active
|
"""Active server's connection state. False if no active
|
||||||
client (registered but failed to initialize)."""
|
client (registered but failed to initialize). The dashboard
|
||||||
|
status indicators + endpoint guards rely on this — the only
|
||||||
|
cross-server dispatch wrapper kept on the engine because it
|
||||||
|
actually has callers."""
|
||||||
client = self.active_client()
|
client = self.active_client()
|
||||||
if client is None:
|
if client is None:
|
||||||
return False
|
return False
|
||||||
|
|
@ -127,124 +134,6 @@ class MediaServerEngine:
|
||||||
logger.warning("%s is_connected raised: %s", self.active_server, exc)
|
logger.warning("%s is_connected raised: %s", self.active_server, exc)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def ensure_connection(self) -> bool:
|
|
||||||
"""Re-auth or reconnect the active server. Returns True if
|
|
||||||
usable after the call."""
|
|
||||||
client = self.active_client()
|
|
||||||
if client is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
return client.ensure_connection()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("%s ensure_connection raised: %s", self.active_server, exc)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_all_artists(self) -> List[Any]:
|
|
||||||
"""Active server's full artist list. Empty list if not
|
|
||||||
connected or call fails."""
|
|
||||||
client = self.active_client()
|
|
||||||
if client is None:
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
return client.get_all_artists()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("%s get_all_artists raised: %s", self.active_server, exc)
|
|
||||||
return []
|
|
||||||
|
|
||||||
def get_all_album_ids(self) -> set:
|
|
||||||
"""Active server's album-ID set. Empty set if not connected
|
|
||||||
or call fails."""
|
|
||||||
client = self.active_client()
|
|
||||||
if client is None:
|
|
||||||
return set()
|
|
||||||
try:
|
|
||||||
return client.get_all_album_ids()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("%s get_all_album_ids raised: %s", self.active_server, exc)
|
|
||||||
return set()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Optional methods — engine routes if the client implements them,
|
|
||||||
# returns a safe default otherwise (mirrors the legacy web_server.py
|
|
||||||
# branches that special-cased SoulSync / Navidrome).
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def search_tracks(self, title: str, artist: str, limit: int = 15) -> List[Any]:
|
|
||||||
"""Search the active server's library. Returns empty list
|
|
||||||
for servers that don't implement search_tracks (SoulSync
|
|
||||||
standalone reads filesystem; no live search API)."""
|
|
||||||
client = self.active_client()
|
|
||||||
if client is None or not hasattr(client, 'search_tracks'):
|
|
||||||
return []
|
|
||||||
try:
|
|
||||||
return client.search_tracks(title, artist, limit)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("%s search_tracks raised: %s", self.active_server, exc)
|
|
||||||
return []
|
|
||||||
|
|
||||||
def trigger_library_scan(self) -> bool:
|
|
||||||
"""Trigger a server-side library scan. No-op (returns True)
|
|
||||||
for SoulSync standalone — filesystem walks happen in-process."""
|
|
||||||
client = self.active_client()
|
|
||||||
if client is None:
|
|
||||||
return False
|
|
||||||
if not hasattr(client, 'trigger_library_scan'):
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
return client.trigger_library_scan()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("%s trigger_library_scan raised: %s", self.active_server, exc)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def is_library_scanning(self) -> bool:
|
|
||||||
"""True if the active server is currently scanning. Always
|
|
||||||
False for SoulSync standalone."""
|
|
||||||
client = self.active_client()
|
|
||||||
if client is None or not hasattr(client, 'is_library_scanning'):
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
return client.is_library_scanning()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("%s is_library_scanning raised: %s", self.active_server, exc)
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_library_stats(self) -> Dict[str, int]:
|
|
||||||
"""Counts of artists / albums / tracks. Default empty dict
|
|
||||||
if the server doesn't implement (SoulSync standalone)."""
|
|
||||||
client = self.active_client()
|
|
||||||
if client is None or not hasattr(client, 'get_library_stats'):
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
return client.get_library_stats()
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning("%s get_library_stats raised: %s", self.active_server, exc)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def get_recently_added_albums(self, max_results: int = 400) -> List[Any]:
|
|
||||||
"""Recently-added albums view. Plex uses a different name;
|
|
||||||
engine routes to whichever method the active server has."""
|
|
||||||
client = self.active_client()
|
|
||||||
if client is None:
|
|
||||||
return []
|
|
||||||
# Plex uses recentlyAdded() on the music library object, not
|
|
||||||
# a top-level method. SoulSync, Jellyfin, Navidrome all
|
|
||||||
# expose get_recently_added_albums directly.
|
|
||||||
if hasattr(client, 'get_recently_added_albums'):
|
|
||||||
try:
|
|
||||||
return client.get_recently_added_albums(max_results)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.warning(
|
|
||||||
"%s get_recently_added_albums raised: %s",
|
|
||||||
self.active_server, exc,
|
|
||||||
)
|
|
||||||
return []
|
|
||||||
return []
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Generic accessors — replace per-server attribute reaches in
|
|
||||||
# callers (Cin's standard from the download refactor).
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def configured_clients(self) -> Dict[str, MediaServerClient]:
|
def configured_clients(self) -> Dict[str, MediaServerClient]:
|
||||||
"""Return ``{name: client}`` for every server that's both
|
"""Return ``{name: client}`` for every server that's both
|
||||||
registered AND reports ``is_connected() == True``. Replaces
|
registered AND reports ``is_connected() == True``. Replaces
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,21 @@
|
||||||
"""Shared dataclasses for the media-server contract.
|
"""Shared dataclasses for the media-server contract.
|
||||||
|
|
||||||
Plex / Jellyfin / Navidrome / SoulSync clients all surfaced near-
|
Plex / Jellyfin / Navidrome clients all surfaced near-identical
|
||||||
identical ``XTrackInfo`` and ``XPlaylistInfo`` shapes (id, title,
|
``XTrackInfo`` and ``XPlaylistInfo`` shapes (id, title, artist,
|
||||||
artist, album, duration, track_number, year, optional rating)
|
album, duration, track_number, year, optional rating) because every
|
||||||
because every consumer needed the same shape downstream. The
|
consumer needed the same shape downstream. The per-server class
|
||||||
per-server class names were a copy-paste artifact, not a real
|
names were a copy-paste artifact, not a real contract difference.
|
||||||
contract difference.
|
|
||||||
|
|
||||||
This module owns the canonical types. Per-server constructors live
|
This module owns the canonical types. Plex's existing classmethod
|
||||||
on the unified class as classmethods (``TrackInfo.from_plex_track``,
|
constructors (``TrackInfo.from_plex_track``,
|
||||||
``TrackInfo.from_jellyfin_dict``, ``TrackInfo.from_navidrome_dict``)
|
``PlaylistInfo.from_plex_playlist``) live here. Jellyfin and
|
||||||
— same pattern Cin used for ``Album.from_spotify_dict`` etc. on the
|
Navidrome currently construct ``TrackInfo`` inline at their call
|
||||||
metadata engine. Heavy server SDK types (``PlexTrack``, dict
|
sites — lifting those into matching ``from_jellyfin_dict`` /
|
||||||
shapes) imported under TYPE_CHECKING so this module stays
|
``from_navidrome_dict`` classmethods is a clean followup but isn't
|
||||||
import-light.
|
needed for the dataclass unification this module ships.
|
||||||
|
|
||||||
|
Heavy server SDK types (``PlexTrack``) imported under TYPE_CHECKING
|
||||||
|
so this module stays import-light.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -107,20 +107,9 @@ def test_is_connected_routes_to_active_client(make_engine):
|
||||||
assert engine.is_connected() is True # follows plex
|
assert engine.is_connected() is True # follows plex
|
||||||
|
|
||||||
|
|
||||||
def test_get_all_album_ids_returns_active_clients_set(make_engine):
|
def test_engine_is_connected_returns_false_when_active_client_failed_to_init():
|
||||||
plex = _FakeClient('plex')
|
|
||||||
plex._album_ids = {'p-1', 'p-2'}
|
|
||||||
jelly = _FakeClient('jellyfin')
|
|
||||||
jelly._album_ids = {'j-1'}
|
|
||||||
engine = make_engine({'plex': plex, 'jellyfin': jelly}, active='plex')
|
|
||||||
assert engine.get_all_album_ids() == {'p-1', 'p-2'}
|
|
||||||
engine = make_engine({'plex': plex, 'jellyfin': jelly}, active='jellyfin')
|
|
||||||
assert engine.get_all_album_ids() == {'j-1'}
|
|
||||||
|
|
||||||
|
|
||||||
def test_engine_returns_safe_defaults_when_active_client_failed_to_init(make_engine):
|
|
||||||
"""When the active client failed to initialize (registry stored
|
"""When the active client failed to initialize (registry stored
|
||||||
None), the engine returns safe defaults instead of raising."""
|
None), the engine returns False instead of raising."""
|
||||||
registry = MediaServerRegistry()
|
registry = MediaServerRegistry()
|
||||||
registry.register(ServerSpec(
|
registry.register(ServerSpec(
|
||||||
name='broken',
|
name='broken',
|
||||||
|
|
@ -131,65 +120,18 @@ def test_engine_returns_safe_defaults_when_active_client_failed_to_init(make_eng
|
||||||
engine = MediaServerEngine(registry=registry, active_server_resolver=lambda: 'broken')
|
engine = MediaServerEngine(registry=registry, active_server_resolver=lambda: 'broken')
|
||||||
|
|
||||||
assert engine.is_connected() is False
|
assert engine.is_connected() is False
|
||||||
assert engine.get_all_artists() == []
|
assert engine.active_client() is None
|
||||||
assert engine.get_all_album_ids() == set()
|
|
||||||
assert engine.search_tracks('t', 'a') == []
|
|
||||||
assert engine.trigger_library_scan() is False
|
|
||||||
assert engine.is_library_scanning() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_engine_swallows_per_method_exceptions(make_engine):
|
def test_is_connected_swallows_exception_from_client(make_engine):
|
||||||
"""A method that raises must NOT propagate to the dispatch
|
"""If the client's is_connected raises, engine returns False
|
||||||
site — engine returns the safe default instead, mirroring the
|
instead of propagating — dashboard status indicators stay
|
||||||
legacy web_server.py defensive try/except chains."""
|
responsive even if a server is misbehaving."""
|
||||||
plex = _FakeClient('plex')
|
plex = _FakeClient('plex')
|
||||||
plex.is_connected = MagicMock(side_effect=RuntimeError("boom"))
|
plex.is_connected = MagicMock(side_effect=RuntimeError("boom"))
|
||||||
plex.get_all_album_ids = MagicMock(side_effect=RuntimeError("boom"))
|
|
||||||
engine = make_engine({'plex': plex}, active='plex')
|
engine = make_engine({'plex': plex}, active='plex')
|
||||||
|
|
||||||
assert engine.is_connected() is False
|
assert engine.is_connected() is False
|
||||||
assert engine.get_all_album_ids() == set()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Optional-method dispatch (engine returns safe default when missing)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class _MinimalClient:
|
|
||||||
"""Stand-in for SoulSync standalone — only the required methods,
|
|
||||||
NO optional methods. Used to assert engine routes around missing
|
|
||||||
optional methods with safe defaults."""
|
|
||||||
|
|
||||||
def is_connected(self): return True
|
|
||||||
def ensure_connection(self): return True
|
|
||||||
def get_all_artists(self): return []
|
|
||||||
def get_all_album_ids(self): return set()
|
|
||||||
|
|
||||||
|
|
||||||
def test_search_tracks_returns_empty_when_client_lacks_method(make_engine):
|
|
||||||
"""SoulSync standalone has no search_tracks — engine returns
|
|
||||||
[] instead of raising AttributeError."""
|
|
||||||
engine = make_engine({'soulsync': _MinimalClient()}, active='soulsync')
|
|
||||||
assert engine.search_tracks('t', 'a') == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_trigger_library_scan_returns_true_when_client_lacks_method(make_engine):
|
|
||||||
"""SoulSync has no trigger_library_scan (filesystem walks
|
|
||||||
happen in-process). Engine no-ops with True so callers don't
|
|
||||||
treat it as a failure."""
|
|
||||||
engine = make_engine({'soulsync': _MinimalClient()}, active='soulsync')
|
|
||||||
assert engine.trigger_library_scan() is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_is_library_scanning_returns_false_when_client_lacks_method(make_engine):
|
|
||||||
engine = make_engine({'soulsync': _MinimalClient()}, active='soulsync')
|
|
||||||
assert engine.is_library_scanning() is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_library_stats_returns_empty_dict_when_client_lacks_method(make_engine):
|
|
||||||
engine = make_engine({'soulsync': _MinimalClient()}, active='soulsync')
|
|
||||||
assert engine.get_library_stats() == {}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -306,7 +248,7 @@ def test_engine_with_empty_clients_dict_is_safe_to_use():
|
||||||
full engine init raises — preserves the resilience the per-server
|
full engine init raises — preserves the resilience the per-server
|
||||||
globals had pre-refactor (each one had its own try/except so engine
|
globals had pre-refactor (each one had its own try/except so engine
|
||||||
failure didn't take down dispatch sites). Pin the contract: empty
|
failure didn't take down dispatch sites). Pin the contract: empty
|
||||||
engine still answers safely on every method instead of raising."""
|
engine still answers safely on every accessor instead of raising."""
|
||||||
registry = MediaServerRegistry()
|
registry = MediaServerRegistry()
|
||||||
registry.register(ServerSpec(
|
registry.register(ServerSpec(
|
||||||
name='plex', factory=lambda: _FakeClient('plex'), display_name='Plex',
|
name='plex', factory=lambda: _FakeClient('plex'), display_name='Plex',
|
||||||
|
|
@ -320,11 +262,10 @@ def test_engine_with_empty_clients_dict_is_safe_to_use():
|
||||||
# client(name) returns None for every server — engine doesn't crash.
|
# client(name) returns None for every server — engine doesn't crash.
|
||||||
assert engine.client('plex') is None
|
assert engine.client('plex') is None
|
||||||
assert engine.client('jellyfin') is None
|
assert engine.client('jellyfin') is None
|
||||||
# Cross-server methods return safe defaults instead of raising.
|
# Active-client lookup + is_connected gracefully handle the empty
|
||||||
|
# case without raising.
|
||||||
|
assert engine.active_client() is None
|
||||||
assert engine.is_connected() is False
|
assert engine.is_connected() is False
|
||||||
assert engine.get_all_artists() == []
|
|
||||||
assert engine.get_all_album_ids() == set()
|
|
||||||
assert engine.search_tracks('t', 'a') == []
|
|
||||||
# configured_clients() returns empty dict cleanly.
|
# configured_clients() returns empty dict cleanly.
|
||||||
assert engine.configured_clients() == {}
|
assert engine.configured_clients() == {}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue