517 lines
18 KiB
Python
517 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
|
|
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),
|
|
)
|
|
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
|