/
/
/
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 async def setup(self) -> None:
64 """
65 Validate the librespot binary and install the stored playback credential.
66
67 :raises LoginFailed: When no playback credential is configured, which requires
68 the user to re-run the setup flow.
69 """
70 # a missing binary is a platform problem, not an auth problem: let the
71 # RuntimeError surface as a plain setup failure
72 self._librespot_bin = await get_librespot_binary()
73 credentials = self.provider.get_setup_value(CONF_LIBRESPOT_CREDENTIALS)
74 if not credentials:
75 # Spotify's login5 refuses credentials minted with any client id other than the one
76 # librespot presents, so installs predating the dedicated playback credential (and
77 # anything cached from before) cannot stream and must authorize playback again.
78 raise LoginFailed(
79 "Spotify playback authorization required",
80 translation_key="playback_auth_required",
81 translation_owner="provider.spotify",
82 )
83 await asyncio.to_thread(
84 self._write_librespot_credentials, self.provider.cache_dir, str(credentials)
85 )
86
87 async def stream_spotify_uri(
88 self,
89 spotify_uri: str,
90 seek_position: int = 0,
91 *,
92 streamdetails: StreamDetails | None = None,
93 continuation: bool = False,
94 ) -> AsyncGenerator[bytes]:
95 """
96 Yield the Ogg Vorbis audio for one Spotify URI.
97
98 :param spotify_uri: Canonical Spotify URI (``spotify:track:<id>`` or
99 ``spotify:episode:<id>``).
100 :param seek_position: Position in seconds to start from.
101 :param streamdetails: Unused: every item is fetched on its own.
102 :param continuation: Unused: every item is fetched on its own.
103 """
104 # librespot's --single-track parser wants its own spotify://type:id form
105 librespot_uri = spotify_uri.replace("spotify:", "spotify://", 1)
106 self.logger.log(VERBOSE_LOG_LEVEL, "Start streaming %s using librespot", spotify_uri)
107 if not self._librespot_bin:
108 raise AudioError("Spotify playback could not be set up")
109
110 args = [
111 self._librespot_bin,
112 "--cache",
113 self.provider.cache_dir,
114 "--disable-audio-cache",
115 "--passthrough",
116 "--bitrate",
117 "320",
118 "--backend",
119 "pipe",
120 "--single-track",
121 librespot_uri,
122 "--disable-discovery",
123 "--dither",
124 "none",
125 ]
126 if seek_position:
127 args += ["--start-position", str(int(seek_position))]
128
129 async with AsyncProcess(
130 args,
131 stdout=True,
132 stderr=True,
133 name="librespot",
134 ) as librespot_proc:
135 log_history: deque[str] = deque(maxlen=10)
136 logger = self.logger
137 provider = self.provider
138
139 async def log_librespot_output() -> None:
140 """Log librespot's output, and end the process when it reports a fatal error."""
141 async for line in librespot_proc.iter_stderr():
142 log_history.append(line)
143 if "ERROR" in line or "WARNING" in line:
144 logger.warning("[librespot] %s", line)
145 if "INVALID_CREDENTIALS" in line and provider.available:
146 # Spotify refused the stored playback credential: surface this as an
147 # auth failure so the provider asks for re-authorization instead of
148 # reporting every track as unplayable. The availability check keeps
149 # concurrent/queued streams from each scheduling their own unload.
150 provider.unload_with_error(
151 LoginFailed(
152 "Spotify playback authorization required",
153 translation_key="playback_auth_required",
154 translation_owner="provider.spotify",
155 )
156 )
157 if "unable to" in line.lower() or "skipping" in line.lower():
158 # if librespot reports a fatal error (e.g. unable to load
159 # or read audio), terminate the process to avoid hanging
160 # indefinitely as it won't produce any audio output.
161 # NOTE: we terminate the underlying process directly instead
162 # of calling close() because this task IS the stderr reader
163 # and close() would try to await itself.
164 if librespot_proc.proc and librespot_proc.proc.returncode is None:
165 librespot_proc.proc.terminate()
166 return
167 else:
168 logger.log(VERBOSE_LOG_LEVEL, "[librespot] %s", line)
169
170 librespot_proc.attach_stderr_reader(asyncio.create_task(log_librespot_output()))
171 # yield from librespot's stdout
172 async for chunk in librespot_proc.iter_chunked():
173 yield chunk
174
175 if librespot_proc.returncode != 0:
176 raise AudioError(
177 f"Spotify stopped playing this track unexpectedly "
178 f"(exit code {librespot_proc.returncode})"
179 )
180
181 async def get_diagnostics(self) -> dict[str, SerializableType]:
182 """Return diagnostic details about the backend."""
183 return {"librespot_available": self._librespot_bin is not None}
184
185 @staticmethod
186 def _write_librespot_credentials(cache_dir: str, credentials: str) -> None:
187 """Write the stored credential to librespot's cache, replacing any stale one."""
188 Path(cache_dir).mkdir(parents=True, exist_ok=True)
189 credentials_file = os.path.join(cache_dir, CREDENTIALS_FILE)
190 with open(credentials_file, "w", encoding="utf-8") as fileobj:
191 fileobj.write(credentials)
192