"""Canonical contract for media server clients. Narrow on purpose. Protocol body declares ONLY the methods every registered client actually implements today — keeps the static contract honest. Server-specific extras (Plex's ``set_music_library_by_name``, Jellyfin's user picker, Navidrome's music folder filter, SoulSync's filesystem rescan) and methods that most-but-not-all servers implement (``search_tracks`` on Plex / 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. 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 four calls. This file just makes that implicit contract explicit + the conformance test pins it. """ from __future__ import annotations from typing import Any, List, Protocol, runtime_checkable @runtime_checkable class MediaServerClient(Protocol): """Structural contract every media server client must satisfy. ``runtime_checkable`` lets ``isinstance(client, MediaServerClient)`` work, but it ONLY checks method names — not signatures. The conformance test in ``tests/media_server/test_conformance.py`` does the deeper class-level check via REQUIRED_METHODS. """ # ------------------------------------------------------------------ # Connection / lifecycle — required, every server implements # ------------------------------------------------------------------ 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 implements # ------------------------------------------------------------------ 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.""" ... # --------------------------------------------------------------------------- # Required method set — pinned by the conformance test. Mirrors the # Protocol body exactly so static + runtime contracts can't drift. # --------------------------------------------------------------------------- REQUIRED_METHODS = { 'is_connected', 'ensure_connection', 'get_all_artists', 'get_all_album_ids', } # Methods that exist on SOME servers but NOT all, listed here for # discoverability. The conformance test does NOT enforce these. Callers # that need one reach the per-server client directly via # ``engine.client(name).`` rather than going through the engine, # 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', 'get_recently_added_albums', 'trigger_library_scan', 'is_library_scanning', 'get_library_stats', 'create_playlist', 'update_playlist', 'append_to_playlist', 'copy_playlist', 'get_all_playlists', 'get_playlist_by_name', 'get_play_history', 'get_track_play_counts', 'update_artist_genres', 'update_artist_poster', 'update_album_poster', 'update_artist_biography', 'update_track_metadata', )