/
/
1"""Librespot playback backend for the Spotify music provider."""
2
3from __future__ import annotations
4
5import asyncio
6import os
7from collections import deque
8from pathlib import Path
9from typing import TYPE_CHECKING
10
11from music_assistant_models.enums import ContentType
12from music_assistant_models.errors import AudioError, LoginFailed
13from music_assistant_models.media_items import AudioFormat
14
15from music_assistant.constants import VERBOSE_LOG_LEVEL
16from music_assistant.helpers.process import AsyncProcess
17from music_assistant.providers.spotify.constants import (
18 CONF_LIBRESPOT_CREDENTIALS,
19 CREDENTIALS_FILE,
20)
21from music_assistant.providers.spotify.helpers import get_librespot_binary
22
23from .base import SpotifyPlaybackBackend
24
25if TYPE_CHECKING:
26 from collections.abc import AsyncGenerator
27
28 from music_assistant_models.enums import MediaType
29 from music_assistant_models.streamdetails import StreamDetails
30
31 from music_assistant.helpers.json import SerializableType
32
33
34class LibrespotBackend(SpotifyPlaybackBackend):
35 """
36 Fetches Spotify audio through the bundled librespot fork.
37
38 One short-lived ``librespot --single-track`` process per item, yielding the
39 original Ogg Vorbis stream (passthrough, no decode).
40 """
41
42 _librespot_bin: str | None = None
43
44 def source_audio_format(self, media_type: MediaType) -> AudioFormat:
45 """
46 Return the format of the Spotify source.
47
48 librespot hands over Spotify's own file untouched, so this describes the
49 delivered bytes as well. It fetches the highest quality the account is
50 entitled to, which for a Premium account is 320 kbps Ogg Vorbis.
51
52 :param media_type: Unused: librespot fetches every item the same way.
53 """
54 return AudioFormat(
55 content_type=ContentType.OGG,
56 codec_type=ContentType.VORBIS,
57 sample_rate=44100,
58 bit_depth=16,
59 channels=2,
60 bit_rate=320,
61 )
62
63 @property
64 def max_concurrent_streams(self) -> int:
65 """Spotify accounts tolerate two concurrent sessions (main + librespot)."""
66 return 2
67
68 async def setup(self) -> None:
69 """
70 Validate the librespot binary and install the stored playback credential.
71
72 :raises LoginFailed: When no playback credential is configured, which requires
73 the user to re-run the setup flow.
74 """
75 # a missing binary is a platform problem, not an auth problem: let the
76 # RuntimeError surface as a plain setup failure
77 self._librespot_bin = await get_librespot_binary()
78 credentials = self.provider.get_setup_value(CONF_LIBRESPOT_CREDENTIALS)
79 if not credentials:
80 # Spotify's login5 refuses credentials minted with any client id other than the one
81 # librespot presents, so installs predating the dedicated playback credential (and
82 # anything cached from before) cannot stream and must authorize playback again.
83 raise LoginFailed(
84 "Spotify playback authorization required",
85 translation_key="playback_auth_required",
86 translation_owner="provider.spotify",
87 )
88 await asyncio.to_thread(
89 self._write_librespot_credentials, self.provider.cache_dir, str(credentials)
90 )
91
92 async def stream_spotify_uri(
93 self,
94 spotify_uri: str,
95 seek_position: int = 0,
96 *,
97 streamdetails: StreamDetails | None = None,
98 ) -> AsyncGenerator[bytes]:
99 """
100 Yield the Ogg Vorbis audio for one Spotify URI.
101
102 :param spotify_uri: Canonical Spotify URI (``spotify:track:<id>`` or
103 ``spotify:episode:<id>``).
104 :param seek_position: Position in seconds to start from.
105 :param streamdetails: Unused: every item is fetched on its own.
106 """
107 # librespot's --single-track parser wants its own spotify://type:id form
108 librespot_uri = spotify_uri.replace("spotify:", "spotify://", 1)
109 self.logger.log(VERBOSE_LOG_LEVEL, "Start streaming %s using librespot", spotify_uri)
110 if not self._librespot_bin:
111 raise AudioError("Librespot binary not available")
112
113 args = [
114 self._librespot_bin,
115 "--cache",
116 self.provider.cache_dir,
117 "--disable-audio-cache",
118 "--passthrough",
119 "--bitrate",
120 "320",
121 "--backend",
122 "pipe",
123 "--single-track",
124 librespot_uri,
125 "--disable-discovery",
126 "--dither",
127 "none",
128 ]
129 if seek_position:
130 args += ["--start-position", str(int(seek_position))]
131
132 async with AsyncProcess(
133 args,
134 stdout=True,
135 stderr=True,
136 name="librespot",
137 ) as librespot_proc:
138 log_history: deque[str] = deque(maxlen=10)
139 logger = self.logger
140 provider = self.provider
141
142 async def log_librespot_output() -> None:
143 """Log librespot output if verbose logging is enabled."""
144 async for line in librespot_proc.iter_stderr():
145 log_history.append(line)
146 if "ERROR" in line or "WARNING" in line:
147 logger.warning("[librespot] %s", line)
148 if "INVALID_CREDENTIALS" in line and provider.available:
149 # Spotify refused the stored playback credential: surface this as an
150 # auth failure so the provider asks for re-authorization instead of
151 # reporting every track as unplayable. The availability check keeps
152 # concurrent/queued streams from each scheduling their own unload.
153 provider.unload_with_error(
154 LoginFailed(
155 "Spotify playback authorization required",
156 translation_key="playback_auth_required",
157 translation_owner="provider.spotify",
158 )
159 )
160 if "unable to" in line.lower() or "skipping" in line.lower():
161 # if librespot reports a fatal error (e.g. unable to load
162 # or read audio), terminate the process to avoid hanging
163 # indefinitely as it won't produce any audio output.
164 # NOTE: we terminate the underlying process directly instead
165 # of calling close() because this task IS the stderr reader
166 # and close() would try to await itself.
167 if librespot_proc.proc and librespot_proc.proc.returncode is None:
168 librespot_proc.proc.terminate()
169 return
170 else:
171 logger.log(VERBOSE_LOG_LEVEL, "[librespot] %s", line)
172
173 librespot_proc.attach_stderr_reader(asyncio.create_task(log_librespot_output()))
174 # yield from librespot's stdout
175 async for chunk in librespot_proc.iter_chunked():
176 yield chunk
177
178 if librespot_proc.returncode != 0:
179 raise AudioError(
180 f"Librespot exited with code {librespot_proc.returncode} for {spotify_uri}"
181 )
182
183 async def get_diagnostics(self) -> dict[str, SerializableType]:
184 """Return diagnostic details about the backend."""
185 return {"librespot_available": self._librespot_bin is not None}
186
187 @staticmethod
188 def _write_librespot_credentials(cache_dir: str, credentials: str) -> None:
189 """Write the stored credential to librespot's cache, replacing any stale one."""
190 Path(cache_dir).mkdir(parents=True, exist_ok=True)
191 credentials_file = os.path.join(cache_dir, CREDENTIALS_FILE)
192 with open(credentials_file, "w", encoding="utf-8") as fileobj:
193 fileobj.write(credentials)
194