Files
musicseerr/backend/repositories/plex_repository.py
T
Harvey 0f25ebc26d Plex Integration + Music Source Integration Improvements (#37)
* plex integration

* The big one - Full Music Source page rework + Playlist importing + Full Plex Integration + Discovery Options + More Like This/Surprise Me/Instant Mix + More...

* Music source track page - Play all / shuffle fixes

* lint

* format

* fix type checks

* format
2026-04-13 23:39:01 +01:00

865 lines
30 KiB
Python

from __future__ import annotations
import logging
from collections.abc import AsyncIterator
from typing import Any
from urllib.parse import quote
import httpx
from core.exceptions import ExternalServiceError, PlexApiError, PlexAuthError
from infrastructure.cache.cache_keys import PLEX_PREFIX
from infrastructure.cache.memory_cache import CacheInterface
from infrastructure.degradation import try_get_degradation_context
from infrastructure.integration_result import IntegrationResult
from infrastructure.resilience.retry import CircuitBreaker, with_retry
from repositories.plex_models import (
PlexAlbum,
PlexArtist,
PlexHistoryEntry,
PlexLibrarySection,
PlexOAuthPin,
PlexPlaylist,
PlexSession,
PlexTrack,
StreamProxyResult,
parse_album,
parse_artist,
parse_library_sections,
parse_plex_history,
parse_plex_response,
parse_plex_sessions,
parse_playlist,
parse_track,
)
logger = logging.getLogger(__name__)
_SOURCE = "plex"
_PLEX_TV_BASE = "https://plex.tv/api/v2"
_PROXY_FORWARD_HEADERS = {"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"}
_STREAM_CHUNK_SIZE = 64 * 1024
_DEFAULT_TTL_LIST = 300
_DEFAULT_TTL_SEARCH = 120
_DEFAULT_TTL_GENRES = 3600
_DEFAULT_TTL_DETAIL = 300
_plex_circuit_breaker = CircuitBreaker(
failure_threshold=5,
success_threshold=2,
timeout=60.0,
name="plex",
)
def _record_degradation(msg: str) -> None:
ctx = try_get_degradation_context()
if ctx is not None:
ctx.record(IntegrationResult.error(source=_SOURCE, msg=msg))
class PlexRepository:
def __init__(
self,
http_client: httpx.AsyncClient,
cache: CacheInterface,
) -> None:
self._client = http_client
self._cache = cache
self._url: str = ""
self._token: str = ""
self._client_id: str = ""
self._configured: bool = False
self._ttl_list: int = _DEFAULT_TTL_LIST
self._ttl_search: int = _DEFAULT_TTL_SEARCH
self._ttl_genres: int = _DEFAULT_TTL_GENRES
self._ttl_detail: int = _DEFAULT_TTL_DETAIL
self._ttl_stats: int = 600
def configure(self, url: str, token: str, client_id: str = "") -> None:
self._url = url.rstrip("/") if url else ""
self._token = token
self._client_id = client_id
self._configured = bool(self._url and self._token)
def is_configured(self) -> bool:
return self._configured
@property
def stats_ttl(self) -> int:
return self._ttl_stats
def configure_cache_ttls(
self,
*,
list_ttl: int | None = None,
search_ttl: int | None = None,
genres_ttl: int | None = None,
detail_ttl: int | None = None,
stats_ttl: int | None = None,
) -> None:
if list_ttl is not None:
self._ttl_list = list_ttl
if search_ttl is not None:
self._ttl_search = search_ttl
if genres_ttl is not None:
self._ttl_genres = genres_ttl
if detail_ttl is not None:
self._ttl_detail = detail_ttl
if stats_ttl is not None:
self._ttl_stats = stats_ttl
@staticmethod
def reset_circuit_breaker() -> None:
_plex_circuit_breaker.reset()
def _build_headers(self) -> dict[str, str]:
headers: dict[str, str] = {
"X-Plex-Token": self._token,
"X-Plex-Product": "MusicSeerr",
"X-Plex-Version": "1.0",
"Accept": "application/json",
}
if self._client_id:
headers["X-Plex-Client-Identifier"] = self._client_id
return headers
@with_retry(
max_attempts=3,
base_delay=1.0,
max_delay=5.0,
circuit_breaker=_plex_circuit_breaker,
retriable_exceptions=(httpx.HTTPError, ExternalServiceError),
non_breaking_exceptions=(PlexApiError,),
)
async def _request(
self,
endpoint: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
if not self._configured:
raise ExternalServiceError("Plex not configured")
url = f"{self._url}{endpoint}"
try:
response = await self._client.get(
url,
params=params,
headers=self._build_headers(),
timeout=15.0,
)
except httpx.TimeoutException as exc:
raise ExternalServiceError(f"Plex request timed out: {exc}")
except httpx.HTTPError as exc:
raise ExternalServiceError(f"Plex request failed: {exc}")
if response.status_code in (401, 403):
raise PlexAuthError(
f"Plex authentication failed ({response.status_code})"
)
if response.status_code != 200:
raise PlexApiError(
f"Plex request failed ({response.status_code})",
)
try:
data: dict[str, Any] = response.json()
except Exception as exc:
raise PlexApiError(f"Plex returned invalid JSON for {endpoint}") from exc
return parse_plex_response(data)
async def ping(self) -> bool:
try:
if not self._configured:
return False
url = f"{self._url}/"
response = await self._client.get(
url,
headers=self._build_headers(),
timeout=10.0,
)
return response.status_code == 200
except Exception: # noqa: BLE001
logger.debug("Plex ping failed", exc_info=True)
_record_degradation("Plex ping failed")
return False
async def get_libraries(self) -> list[PlexLibrarySection]:
cache_key = f"{PLEX_PREFIX}libraries"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request("/library/sections")
sections = parse_library_sections(container)
await self._cache.set(cache_key, sections, self._ttl_list)
return sections
async def get_music_libraries(self) -> list[PlexLibrarySection]:
sections = await self.get_libraries()
return [s for s in sections if s.type == "artist"]
async def get_artists(
self,
section_id: str,
size: int = 100,
offset: int = 0,
search: str = "",
) -> list[PlexArtist]:
cache_key = f"{PLEX_PREFIX}artists:{section_id}:{size}:{offset}:{search}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
params: dict[str, Any] = {
"type": 8,
"X-Plex-Container-Start": offset,
"X-Plex-Container-Size": size,
}
if search:
params["title"] = search
container = await self._request(
f"/library/sections/{section_id}/all",
params=params,
)
raw = container.get("Metadata", [])
artists = [parse_artist(a) for a in raw]
await self._cache.set(cache_key, artists, self._ttl_list)
return artists
async def get_albums(
self,
section_id: str,
size: int = 50,
offset: int = 0,
sort: str = "titleSort:asc",
genre: str | None = None,
mood: str | None = None,
decade: str | None = None,
) -> tuple[list[PlexAlbum], int]:
cache_key = f"{PLEX_PREFIX}albums:{section_id}:{size}:{offset}:{sort}:{genre or ''}:{mood or ''}:{decade or ''}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
params: dict[str, Any] = {
"type": 9,
"X-Plex-Container-Start": offset,
"X-Plex-Container-Size": size,
"sort": sort,
}
if genre:
params["genre"] = genre
if mood:
params["mood"] = mood
if decade:
stripped = decade.rstrip("s")
try:
start = int(stripped)
params["year"] = ",".join(str(y) for y in range(start, start + 10))
except ValueError:
pass
container = await self._request(
f"/library/sections/{section_id}/all",
params=params,
)
raw = container.get("Metadata", [])
total = container.get("totalSize", len(raw))
albums = [parse_album(a) for a in raw]
result = (albums, total)
await self._cache.set(cache_key, result, self._ttl_list)
return result
async def get_track_count(self, section_id: str) -> int:
cache_key = f"{PLEX_PREFIX}track_count:{section_id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(
f"/library/sections/{section_id}/all",
params={
"type": 10,
"X-Plex-Container-Start": 0,
"X-Plex-Container-Size": 0,
},
)
total = container.get("totalSize", 0)
await self._cache.set(cache_key, total, self._ttl_list)
return total
async def get_artist_count(self, section_id: str) -> int:
cache_key = f"{PLEX_PREFIX}artist_count:{section_id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(
f"/library/sections/{section_id}/all",
params={
"type": 8,
"X-Plex-Container-Start": 0,
"X-Plex-Container-Size": 0,
},
)
total = container.get("totalSize", 0)
await self._cache.set(cache_key, total, self._ttl_list)
return total
async def get_tracks(
self,
section_id: str,
size: int = 100,
offset: int = 0,
sort: str = "titleSort:asc",
search: str = "",
genre: str = "",
) -> tuple[list[PlexTrack], int]:
cache_key = f"{PLEX_PREFIX}tracks:{section_id}:{size}:{offset}:{sort}:{search}:{genre}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
params: dict[str, Any] = {
"type": 10,
"sort": sort,
"X-Plex-Container-Start": offset,
"X-Plex-Container-Size": size,
}
if search:
params["title"] = search
if genre:
params["genre"] = genre
container = await self._request(
f"/library/sections/{section_id}/all",
params=params,
)
raw = container.get("Metadata", [])
tracks = [parse_track(t) for t in raw]
total = container.get("totalSize", len(tracks))
result = (tracks, total)
await self._cache.set(cache_key, result, self._ttl_list)
return result
async def get_album_tracks(self, rating_key: str) -> list[PlexTrack]:
cache_key = f"{PLEX_PREFIX}album_tracks:{rating_key}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(f"/library/metadata/{rating_key}/children")
raw = container.get("Metadata", [])
tracks = [parse_track(t) for t in raw]
await self._cache.set(cache_key, tracks, self._ttl_detail)
return tracks
async def get_album_metadata(self, rating_key: str) -> PlexAlbum:
cache_key = f"{PLEX_PREFIX}album:{rating_key}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(f"/library/metadata/{rating_key}")
raw = container.get("Metadata", [])
if not raw:
raise PlexApiError(f"Album {rating_key} not found")
album = parse_album(raw[0])
await self._cache.set(cache_key, album, self._ttl_detail)
return album
async def get_recently_added(
self,
section_id: str,
limit: int = 20,
) -> list[PlexAlbum]:
cache_key = f"{PLEX_PREFIX}recent:{section_id}:{limit}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(
f"/library/sections/{section_id}/recentlyAdded",
params={
"type": 9,
"X-Plex-Container-Size": limit,
},
)
raw = container.get("Metadata", [])
albums = [parse_album(a) for a in raw]
await self._cache.set(cache_key, albums, self._ttl_list)
return albums
async def get_recently_viewed(
self,
section_id: str,
limit: int = 20,
) -> list[PlexAlbum]:
cache_key = f"{PLEX_PREFIX}recent_viewed:{section_id}:{limit}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(
f"/library/sections/{section_id}/recentlyViewed",
params={
"type": 9,
"X-Plex-Container-Size": limit,
},
)
raw = container.get("Metadata", [])
albums = [parse_album(a) for a in raw]
await self._cache.set(cache_key, albums, self._ttl_list)
return albums
async def get_playlists(self) -> list[PlexPlaylist]:
cache_key = f"{PLEX_PREFIX}playlists"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(
"/playlists",
params={"playlistType": "audio"},
)
raw = container.get("Metadata", [])
playlists = [parse_playlist(p) for p in raw]
await self._cache.set(cache_key, playlists, self._ttl_list)
return playlists
async def get_playlist_items(self, rating_key: str) -> list[PlexTrack]:
cache_key = f"{PLEX_PREFIX}playlist:{rating_key}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(f"/playlists/{rating_key}/items")
raw = container.get("Metadata", [])
tracks = [parse_track(t) for t in raw]
await self._cache.set(cache_key, tracks, self._ttl_detail)
return tracks
async def search(
self,
query: str,
section_id: str | None = None,
limit: int = 20,
) -> dict[str, list[Any]]:
cache_key = f"{PLEX_PREFIX}search:{query}:{section_id or ''}:{limit}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
params: dict[str, Any] = {"query": query, "limit": limit}
if section_id:
params["sectionId"] = section_id
container = await self._request("/hubs/search", params=params)
albums: list[PlexAlbum] = []
tracks: list[PlexTrack] = []
artists: list[PlexArtist] = []
for hub in container.get("Hub", []):
hub_type = hub.get("type", "")
for item in hub.get("Metadata", []):
if hub_type == "album":
albums.append(parse_album(item))
elif hub_type == "track":
tracks.append(parse_track(item))
elif hub_type == "artist":
artists.append(parse_artist(item))
result: dict[str, list[Any]] = {
"albums": albums,
"tracks": tracks,
"artists": artists,
}
await self._cache.set(cache_key, result, self._ttl_search)
return result
async def get_genres(self, section_id: str) -> list[str]:
cache_key = f"{PLEX_PREFIX}genres:{section_id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(
f"/library/sections/{section_id}/genre",
)
raw = container.get("Directory", [])
genres = [g.get("title", "") for g in raw if g.get("title")]
await self._cache.set(cache_key, genres, self._ttl_genres)
return genres
async def get_moods(self, section_id: str) -> list[str]:
cache_key = f"{PLEX_PREFIX}moods:{section_id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
container = await self._request(
f"/library/sections/{section_id}/mood",
)
raw = container.get("Directory", [])
moods = [m.get("title", "") for m in raw if m.get("title")]
await self._cache.set(cache_key, moods, self._ttl_genres)
return moods
async def get_hubs(
self, section_id: str, count: int = 10
) -> list[dict[str, Any]]:
cache_key = f"{PLEX_PREFIX}hubs:{section_id}:{count}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
container = await self._request(
f"/hubs/sections/{section_id}",
params={"count": count},
)
hubs = container.get("Hub", [])
await self._cache.set(cache_key, hubs, ttl_seconds=1800)
return hubs
except Exception: # noqa: BLE001
logger.warning("get_hubs failed for section %s", section_id, exc_info=True)
_record_degradation("Plex get_hubs failed")
return []
async def scrobble(self, rating_key: str) -> bool:
try:
if not self._configured:
return False
url = f"{self._url}/:/scrobble"
response = await self._client.get(
url,
params={
"key": rating_key,
"identifier": "com.plexapp.plugins.library",
},
headers=self._build_headers(),
timeout=10.0,
)
return response.status_code == 200
except Exception: # noqa: BLE001
logger.warning("Plex scrobble failed for %s", rating_key, exc_info=True)
_record_degradation("Plex scrobble failed")
return False
async def now_playing(self, rating_key: str, state: str = "playing") -> bool:
try:
if not self._configured:
return False
url = f"{self._url}/:/timeline"
response = await self._client.get(
url,
params={
"ratingKey": rating_key,
"state": state,
"key": f"/library/metadata/{rating_key}",
},
headers=self._build_headers(),
timeout=10.0,
)
return response.status_code == 200
except Exception: # noqa: BLE001
logger.warning("Plex now-playing report failed for %s", rating_key, exc_info=True)
_record_degradation("Plex now-playing report failed")
return False
def build_stream_url(self, track: PlexTrack) -> str:
if not self._configured:
raise ValueError("Plex is not configured")
if not track.Media or not track.Media[0].Part:
raise ValueError(f"Track {track.ratingKey} has no streamable media")
part_key = track.Media[0].Part[0].key
return f"{self._url}{part_key}"
async def proxy_head_stream(self, part_key: str) -> StreamProxyResult:
if not self._configured:
raise ExternalServiceError("Plex not configured")
if not part_key.startswith("/"):
part_key = f"/{part_key}"
if ".." in part_key.split("/"):
raise ValueError(f"Invalid Plex part key: {part_key}")
if not part_key.startswith("/library/parts/"):
raise ValueError(f"Invalid Plex part key: {part_key}")
url = f"{self._url}{part_key}"
async with httpx.AsyncClient(
timeout=httpx.Timeout(connect=10, read=10, write=10, pool=10)
) as client:
try:
resp = await client.head(url, headers=self._build_headers())
except httpx.HTTPError:
raise ExternalServiceError("Failed to reach Plex for stream HEAD")
headers: dict[str, str] = {}
for h in _PROXY_FORWARD_HEADERS:
v = resp.headers.get(h)
if v:
headers[h] = v
return StreamProxyResult(
status_code=resp.status_code,
headers=headers,
media_type=headers.get("Content-Type", "audio/mpeg"),
)
async def proxy_get_stream(
self, part_key: str, range_header: str | None = None
) -> StreamProxyResult:
if not self._configured:
raise ExternalServiceError("Plex not configured")
if not part_key.startswith("/"):
part_key = f"/{part_key}"
if ".." in part_key.split("/"):
raise ValueError(f"Invalid Plex part key: {part_key}")
if not part_key.startswith("/library/parts/"):
raise ValueError(f"Invalid Plex part key: {part_key}")
url = f"{self._url}{part_key}"
upstream_headers = dict(self._build_headers())
if range_header:
upstream_headers["Range"] = range_header
client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=10, read=120, write=30, pool=10)
)
upstream_resp = None
try:
upstream_resp = await client.send(
client.build_request("GET", url, headers=upstream_headers),
stream=True,
)
if upstream_resp.status_code == 416:
raise ExternalServiceError("416 Range not satisfiable")
if upstream_resp.status_code >= 400:
logger.error(
"Plex upstream returned %d for %s",
upstream_resp.status_code, part_key,
)
raise ExternalServiceError("Plex returned an error")
resp_headers: dict[str, str] = {}
for header_name in _PROXY_FORWARD_HEADERS:
value = upstream_resp.headers.get(header_name)
if value:
resp_headers[header_name] = value
status_code = 206 if upstream_resp.status_code == 206 else 200
async def _stream_body() -> AsyncIterator[bytes]:
try:
async for chunk in upstream_resp.aiter_bytes(
chunk_size=_STREAM_CHUNK_SIZE
):
yield chunk
finally:
await upstream_resp.aclose()
await client.aclose()
return StreamProxyResult(
status_code=status_code,
headers=resp_headers,
media_type=resp_headers.get("Content-Type", "audio/mpeg"),
body_chunks=_stream_body(),
)
except httpx.HTTPError as exc:
if upstream_resp:
await upstream_resp.aclose()
await client.aclose()
raise ExternalServiceError(f"Plex streaming failed: {exc}") from exc
except Exception:
if upstream_resp:
await upstream_resp.aclose()
await client.aclose()
raise
async def proxy_thumb(self, rating_key: str, size: int = 500) -> tuple[bytes, str]:
if not self._configured:
raise ExternalServiceError("Plex not configured")
url = f"{self._url}/library/metadata/{rating_key}/thumb"
try:
response = await self._client.get(
url,
params={"width": size, "height": size},
headers=self._build_headers(),
timeout=15.0,
)
except httpx.TimeoutException:
raise ExternalServiceError("Plex thumb request timed out")
except httpx.HTTPError:
raise ExternalServiceError("Plex thumb request failed")
if response.status_code != 200:
raise ExternalServiceError(
f"Plex thumb request failed ({response.status_code})"
)
content_type = response.headers.get("content-type", "image/jpeg")
return response.content, content_type
async def proxy_playlist_composite(self, rating_key: str, size: int = 500) -> tuple[bytes, str]:
if not self._configured:
raise ExternalServiceError("Plex not configured")
playlists = await self.get_playlists()
playlist = next((p for p in playlists if p.ratingKey == rating_key), None)
composite_path = playlist.composite if playlist and playlist.composite else f"/playlists/{rating_key}/composite"
url = f"{self._url}{composite_path}"
headers = self._build_headers()
headers["Accept"] = "image/*"
try:
response = await self._client.get(
url,
params={"width": size, "height": size},
headers=headers,
timeout=15.0,
)
except httpx.TimeoutException:
raise ExternalServiceError("Plex playlist composite request timed out")
except httpx.HTTPError:
raise ExternalServiceError("Plex playlist composite request failed")
if response.status_code != 200:
raise ExternalServiceError(
f"Plex playlist composite request failed ({response.status_code})"
)
content_type = response.headers.get("content-type", "image/jpeg")
return response.content, content_type
async def validate_connection(self) -> tuple[bool, str]:
if not self._configured:
return False, "Plex URL or token not configured"
try:
url = f"{self._url}/"
response = await self._client.get(
url,
headers=self._build_headers(),
timeout=10.0,
)
if response.status_code in (401, 403):
return False, "Authentication failed - check your Plex token"
if response.status_code != 200:
return False, f"Plex returned status {response.status_code}"
data = response.json()
container = data.get("MediaContainer", {})
friendly_name = container.get("friendlyName", "Unknown")
version = container.get("version", "unknown")
return True, f"Connected to {friendly_name} (v{version})"
except httpx.TimeoutException:
return False, "Connection timed out - check URL"
except httpx.HTTPError as exc:
msg = str(exc)
if "connect" in msg.lower() or "refused" in msg.lower():
return False, "Could not connect - check URL and ensure server is running"
return False, f"Connection failed: {msg}"
except Exception as exc: # noqa: BLE001
return False, f"Connection failed: {exc}"
async def create_oauth_pin(self, client_id: str) -> PlexOAuthPin:
async with httpx.AsyncClient(timeout=httpx.Timeout(15.0)) as client:
response = await client.post(
f"{_PLEX_TV_BASE}/pins",
headers={
"X-Plex-Product": "MusicSeerr",
"X-Plex-Client-Identifier": client_id,
"Accept": "application/json",
},
data={"strong": "true"},
)
if response.status_code != 201:
raise PlexApiError(f"Failed to create OAuth pin ({response.status_code})")
data = response.json()
return PlexOAuthPin(
id=data.get("id", 0),
code=data.get("code", ""),
)
async def poll_oauth_pin(self, pin_id: int, client_id: str) -> str | None:
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
response = await client.get(
f"{_PLEX_TV_BASE}/pins/{pin_id}",
headers={
"X-Plex-Client-Identifier": client_id,
"Accept": "application/json",
},
)
if response.status_code != 200:
return None
data = response.json()
token = data.get("authToken")
return token if token else None
async def get_sessions(self) -> list[PlexSession]:
if not self._configured:
return []
cache_key = f"{PLEX_PREFIX}sessions"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
data = await self._request("/status/sessions")
sessions = parse_plex_sessions(data)
await self._cache.set(cache_key, sessions, 2)
return sessions
except (PlexAuthError, PlexApiError):
logger.warning("Plex sessions unavailable (may require admin token)")
_record_degradation("Plex sessions auth/api failure")
return []
except Exception: # noqa: BLE001
logger.warning("Failed to fetch Plex sessions", exc_info=True)
_record_degradation("Plex sessions fetch failed")
return []
async def get_listening_history(
self,
limit: int = 50,
offset: int = 0,
) -> tuple[list[PlexHistoryEntry], int]:
if not self._configured:
return [], 0
cache_key = f"{PLEX_PREFIX}history:{limit}:{offset}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
try:
data = await self._request(
"/status/sessions/history/all",
params={
"X-Plex-Container-Start": offset,
"X-Plex-Container-Size": limit,
"sort": "viewedAt:desc",
},
)
entries, total = parse_plex_history(data)
result = (entries, total)
await self._cache.set(cache_key, result, 300)
return result
except (PlexAuthError, PlexApiError):
logger.warning("Plex history unavailable (may require admin token)")
_record_degradation("Plex history auth/api failure")
return [], 0
except Exception: # noqa: BLE001
logger.warning("Failed to fetch Plex listening history", exc_info=True)
_record_degradation("Plex history fetch failed")
return [], 0
async def clear_cache(self) -> None:
await self._cache.clear_prefix(PLEX_PREFIX)