Files
musicseerr/backend/repositories/navidrome_repository.py
T

518 lines
18 KiB
Python

from __future__ import annotations
import hashlib
import logging
import secrets
from typing import Any
from urllib.parse import urlencode
import httpx
import msgspec
from core.exceptions import ExternalServiceError, NavidromeApiError, NavidromeAuthError, NavidromeSubsonicError
from infrastructure.cache.cache_keys import NAVIDROME_PREFIX
from infrastructure.cache.memory_cache import CacheInterface
from infrastructure.resilience.retry import with_retry, CircuitBreaker
from repositories.navidrome_models import (
StreamProxyResult as StreamProxyResult,
SubsonicAlbum,
SubsonicArtist,
SubsonicGenre,
SubsonicPlaylist,
SubsonicSearchResult,
SubsonicSong,
parse_album,
parse_artist,
parse_genre,
parse_song,
parse_subsonic_response,
)
from infrastructure.degradation import try_get_degradation_context
from infrastructure.integration_result import IntegrationResult
logger = logging.getLogger(__name__)
_SOURCE = "navidrome"
def _record_degradation(msg: str) -> None:
ctx = try_get_degradation_context()
if ctx is not None:
ctx.record(IntegrationResult.error(source=_SOURCE, msg=msg))
_navidrome_circuit_breaker = CircuitBreaker(
failure_threshold=5,
success_threshold=2,
timeout=60.0,
name="navidrome",
)
_DEFAULT_TTL_LIST = 300
_DEFAULT_TTL_SEARCH = 120
_DEFAULT_TTL_GENRES = 3600
_DEFAULT_TTL_DETAIL = 300
class NavidromeRepository:
def __init__(
self,
http_client: httpx.AsyncClient,
cache: CacheInterface,
) -> None:
self._client = http_client
self._cache = cache
self._url: str = ""
self._username: str = ""
self._password: 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
def configure(self, url: str, username: str, password: str) -> None:
self._url = url.rstrip("/") if url else ""
self._username = username
self._password = password
self._configured = bool(self._url and self._username and self._password)
def is_configured(self) -> bool:
return self._configured
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,
) -> 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
@staticmethod
def reset_circuit_breaker() -> None:
_navidrome_circuit_breaker.reset()
def _build_auth_params(self) -> dict[str, str]:
salt = secrets.token_hex(3)
token = hashlib.md5(
(self._password + salt).encode("utf-8")
).hexdigest()
return {
"u": self._username,
"t": token,
"s": salt,
"v": "1.16.1",
"c": "musicseerr",
"f": "json",
}
def build_stream_url(self, song_id: str) -> str:
"""Build a full stream URL for a song, including auth params."""
if not self._configured:
raise ValueError("Navidrome is not configured")
params = self._build_auth_params()
params["id"] = song_id
return f"{self._url}/rest/stream?{urlencode(params)}"
async def proxy_head_stream(self, song_id: str) -> StreamProxyResult:
"""HEAD proxy to Navidrome stream endpoint. Returns filtered headers."""
stream_url = self.build_stream_url(song_id)
async with httpx.AsyncClient(
timeout=httpx.Timeout(connect=10, read=10, write=10, pool=10)
) as client:
try:
resp = await client.head(stream_url)
except httpx.HTTPError:
raise ExternalServiceError("Failed to reach Navidrome")
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, song_id: str, range_header: str | None = None
) -> StreamProxyResult:
"""GET streaming proxy to Navidrome. Returns chunked body iterator with cleanup."""
stream_url = self.build_stream_url(song_id)
upstream_headers: dict[str, str] = {}
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", stream_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(
"Navidrome upstream returned %d for %s",
upstream_resp.status_code, song_id,
)
raise ExternalServiceError("Navidrome 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 Exception:
if upstream_resp:
await upstream_resp.aclose()
await client.aclose()
raise
@with_retry(
max_attempts=3,
base_delay=1.0,
max_delay=5.0,
circuit_breaker=_navidrome_circuit_breaker,
retriable_exceptions=(httpx.HTTPError, ExternalServiceError),
non_breaking_exceptions=(NavidromeSubsonicError,),
)
async def _request(
self,
endpoint: str,
params: dict[str, Any] | None = None,
) -> dict[str, Any]:
if not self._configured:
raise ExternalServiceError("Navidrome not configured")
merged = self._build_auth_params()
if params:
merged.update(params)
url = f"{self._url}{endpoint}"
try:
response = await self._client.get(url, params=merged, timeout=15.0)
except httpx.TimeoutException as exc:
raise ExternalServiceError(f"Navidrome request timed out: {exc}")
except httpx.HTTPError as exc:
raise ExternalServiceError(f"Navidrome request failed: {exc}")
if response.status_code in (401, 403):
raise NavidromeAuthError(
f"Navidrome authentication failed ({response.status_code})"
)
if response.status_code != 200:
raise NavidromeApiError(
f"Navidrome request failed ({response.status_code})",
)
try:
data: dict[str, Any] = response.json()
except Exception as exc:
raise NavidromeApiError(f"Navidrome returned invalid JSON for {endpoint}") from exc
return parse_subsonic_response(data)
async def ping(self) -> bool:
try:
await self._request("/rest/ping")
return True
except Exception: # noqa: BLE001
logger.debug("Navidrome ping failed", exc_info=True)
_record_degradation("Navidrome ping failed")
return False
async def get_album_list(
self,
type: str,
size: int = 20,
offset: int = 0,
genre: str | None = None,
) -> list[SubsonicAlbum]:
cache_key = f"{NAVIDROME_PREFIX}albums:{type}:{size}:{offset}:{genre or ''}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
params: dict[str, Any] = {"type": type, "size": size, "offset": offset}
if genre and type == "byGenre":
params["genre"] = genre
resp = await self._request(
"/rest/getAlbumList2",
params,
)
raw = resp.get("albumList2", {}).get("album", [])
albums = [parse_album(a) for a in raw]
await self._cache.set(cache_key, albums, self._ttl_list)
return albums
async def get_album(self, id: str) -> SubsonicAlbum:
cache_key = f"{NAVIDROME_PREFIX}album:{id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
resp = await self._request("/rest/getAlbum", {"id": id})
album = parse_album(resp.get("album", {}))
await self._cache.set(cache_key, album, self._ttl_detail)
return album
async def get_artists(self) -> list[SubsonicArtist]:
cache_key = "navidrome:artists"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
resp = await self._request("/rest/getArtists")
artists: list[SubsonicArtist] = []
for index in resp.get("artists", {}).get("index", []):
for a in index.get("artist", []):
artists.append(parse_artist(a))
await self._cache.set(cache_key, artists, self._ttl_list)
return artists
async def get_artist(self, id: str) -> SubsonicArtist:
cache_key = f"{NAVIDROME_PREFIX}artist:{id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
resp = await self._request("/rest/getArtist", {"id": id})
artist = parse_artist(resp.get("artist", {}))
await self._cache.set(cache_key, artist, self._ttl_detail)
return artist
async def get_song(self, id: str) -> SubsonicSong:
cache_key = f"{NAVIDROME_PREFIX}song:{id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
resp = await self._request("/rest/getSong", {"id": id})
song = parse_song(resp.get("song", {}))
await self._cache.set(cache_key, song, self._ttl_detail)
return song
async def search(
self,
query: str,
artist_count: int = 20,
album_count: int = 20,
song_count: int = 20,
) -> SubsonicSearchResult:
cache_key = f"{NAVIDROME_PREFIX}search:{query}:{artist_count}:{album_count}:{song_count}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
resp = await self._request(
"/rest/search3",
{
"query": query,
"artistCount": artist_count,
"albumCount": album_count,
"songCount": song_count,
},
)
sr = resp.get("searchResult3", {})
result = SubsonicSearchResult(
artist=[parse_artist(a) for a in sr.get("artist", [])],
album=[parse_album(a) for a in sr.get("album", [])],
song=[parse_song(s) for s in sr.get("song", [])],
)
await self._cache.set(cache_key, result, self._ttl_search)
return result
async def get_starred(self) -> SubsonicSearchResult:
cache_key = "navidrome:starred"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
resp = await self._request("/rest/getStarred2")
sr = resp.get("starred2", {})
result = SubsonicSearchResult(
artist=[parse_artist(a) for a in sr.get("artist", [])],
album=[parse_album(a) for a in sr.get("album", [])],
song=[parse_song(s) for s in sr.get("song", [])],
)
await self._cache.set(cache_key, result, self._ttl_list)
return result
async def get_genres(self) -> list[SubsonicGenre]:
cache_key = "navidrome:genres"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
resp = await self._request("/rest/getGenres")
raw = resp.get("genres", {}).get("genre", [])
genres = [parse_genre(g) for g in raw]
await self._cache.set(cache_key, genres, self._ttl_genres)
return genres
async def get_playlists(self) -> list[SubsonicPlaylist]:
cache_key = "navidrome:playlists"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
resp = await self._request("/rest/getPlaylists")
raw = resp.get("playlists", {}).get("playlist", [])
playlists: list[SubsonicPlaylist] = []
for p in raw:
playlists.append(
SubsonicPlaylist(
id=p.get("id", ""),
name=p.get("name", ""),
songCount=p.get("songCount", 0),
duration=p.get("duration", 0),
)
)
await self._cache.set(cache_key, playlists, self._ttl_list)
return playlists
async def get_playlist(self, id: str) -> SubsonicPlaylist:
cache_key = f"{NAVIDROME_PREFIX}playlist:{id}"
cached = await self._cache.get(cache_key)
if cached is not None:
return cached
resp = await self._request("/rest/getPlaylist", {"id": id})
raw = resp.get("playlist", {})
entries = raw.get("entry", [])
playlist = SubsonicPlaylist(
id=raw.get("id", ""),
name=raw.get("name", ""),
songCount=raw.get("songCount", 0),
duration=raw.get("duration", 0),
entry=[parse_song(e) for e in entries] if entries else None,
)
await self._cache.set(cache_key, playlist, self._ttl_detail)
return playlist
async def get_random_songs(
self,
size: int = 20,
genre: str | None = None,
) -> list[SubsonicSong]:
params: dict[str, Any] = {"size": size}
if genre:
params["genre"] = genre
resp = await self._request("/rest/getRandomSongs", params)
raw = resp.get("randomSongs", {}).get("song", [])
return [parse_song(s) for s in raw]
async def scrobble(
self,
id: str,
time_ms: int | None = None,
) -> bool:
params: dict[str, Any] = {"id": id}
if time_ms is not None:
params["time"] = time_ms
try:
await self._request("/rest/scrobble", params)
return True
except Exception: # noqa: BLE001
logger.warning("Navidrome scrobble failed", exc_info=True)
_record_degradation("Navidrome scrobble failed")
return False
async def now_playing(self, id: str) -> bool:
params: dict[str, Any] = {"id": id, "submission": "false"}
try:
await self._request("/rest/scrobble", params)
return True
except Exception: # noqa: BLE001
logger.warning("Navidrome now-playing report failed", exc_info=True)
_record_degradation("Navidrome now-playing report failed")
return False
async def validate_connection(self) -> tuple[bool, str]:
if not self._configured:
return False, "Navidrome URL, username, or password not configured"
try:
resp = await self._request("/rest/ping")
version = resp.get("version", "unknown")
return True, f"Connected to Navidrome (API v{version})"
except NavidromeAuthError as exc:
return False, f"Authentication failed: {exc.message}"
except ExternalServiceError as exc:
msg = str(exc)
if "timed out" in msg.lower():
return False, "Connection timed out - check URL"
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 clear_cache(self) -> None:
await self._cache.clear_prefix(NAVIDROME_PREFIX)
async def get_cover_art(self, cover_art_id: str, size: int = 500) -> tuple[bytes, str]:
if not self._configured:
raise ExternalServiceError("Navidrome not configured")
params = self._build_auth_params()
params["id"] = cover_art_id
params["size"] = str(size)
url = f"{self._url}/rest/getCoverArt"
try:
response = await self._client.get(url, params=params, timeout=15.0)
except httpx.TimeoutException:
raise ExternalServiceError("Navidrome cover art request timed out")
except httpx.HTTPError:
raise ExternalServiceError("Navidrome cover art request failed")
if response.status_code != 200:
raise ExternalServiceError(
f"Navidrome cover art failed ({response.status_code})"
)
content_type = response.headers.get("content-type", "image/jpeg")
return response.content, content_type
_PROXY_FORWARD_HEADERS = {"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges"}
_STREAM_CHUNK_SIZE = 64 * 1024