Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement Spotify Connect Auth #1562

Merged
merged 1 commit into from
Aug 13, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 117 additions & 82 deletions music_assistant/server/providers/spotify/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import json
import os
import platform
import shutil
import time
from json.decoder import JSONDecodeError
from tempfile import gettempdir
Expand Down Expand Up @@ -42,7 +43,6 @@
Track,
)
from music_assistant.common.models.streamdetails import StreamDetails
from music_assistant.constants import CONF_PASSWORD, CONF_USERNAME

# pylint: disable=no-name-in-module
from music_assistant.server.helpers.app_vars import app_var
Expand All @@ -62,9 +62,11 @@
from music_assistant.server.models import ProviderInstanceType

CONF_CLIENT_ID = "client_id"
CONF_ACTION_AUTH = "auth"

CACHE_DIR = gettempdir()
LIKED_SONGS_FAKE_PLAYLIST_ID_PREFIX = "liked_songs"
SETUP_STORAGE_PATH = "spotify-setup"
SUPPORTED_FEATURES = (
ProviderFeature.LIBRARY_ARTISTS,
ProviderFeature.LIBRARY_ALBUMS,
Expand Down Expand Up @@ -106,18 +108,70 @@ async def get_config_entries(
values: the (intermediate) raw values for config entries sent with the action.
"""
# ruff: noqa: ARG001
data_dir = os.path.join(mass.storage_path, instance_id or SETUP_STORAGE_PATH)
data_dir_exists = await asyncio.to_thread(os.path.isdir, data_dir)

if action == CONF_ACTION_AUTH:
# authenticate with spotify using spotify connect
# NOTE: we like to switch to Spotify PKCE auth (and even have a branch
# where this is implemented), but librespot is not yet supporting this
# once Librespot supports using a Bearer token, we can switch to PKCE
# and keep the oauth flow in MA itself.
if not data_dir_exists:
await asyncio.to_thread(os.makedirs, data_dir)
# use librespot to perform auth using spotify connect
librespot_bin = await get_librespot_binary()
args = [
librespot_bin,
"-c",
data_dir,
"-a",
"-n",
"MUSIC_ASSISTANT",
]
async with asyncio.timeout(300):
_returncode, output = await check_output(*args)
if _returncode == 0 and output.decode().strip() != "authorized":
raise LoginFailed("Authentication failed")

elif not instance_id or not data_dir_exists:
# authentication required
return (
ConfigEntry(
key="warn",
type=ConfigEntryType.ALERT,
label="Spotify needs to be authenticated with your account.\n\n"
"Click the authenticate button below to start the authentication process.\n\n"
"Then open the Spotify app on your device and select MUSIC_ASSISTANT "
"as playback device to authenticate.",
required=True,
),
ConfigEntry(
key=CONF_ACTION_AUTH,
type=ConfigEntryType.ACTION,
label="Authenticate Spotify",
required=True,
),
ConfigEntry(
key=CONF_CLIENT_ID,
type=ConfigEntryType.SECURE_STRING,
label=CONF_CLIENT_ID,
hidden=True,
required=False,
),
)

# return the default config entries
return (
ConfigEntry(
key=CONF_USERNAME,
type=ConfigEntryType.STRING,
label="Username",
required=True,
key="label_authenticated",
type=ConfigEntryType.LABEL,
label="Authenticated to Spotify",
),
ConfigEntry(
key=CONF_PASSWORD,
type=ConfigEntryType.SECURE_STRING,
label="Password",
required=True,
key="label_whitespace",
type=ConfigEntryType.LABEL,
label=" ",
),
ConfigEntry(
key=CONF_CLIENT_ID,
Expand All @@ -143,15 +197,24 @@ class SpotifyProvider(MusicProvider):

async def handle_async_init(self) -> None:
"""Handle async initialization of the provider."""
self._cache_dir = CACHE_DIR
self._librespot_bin = await get_librespot_binary()
self._ap_workaround = False
self._cache_dir = os.path.join(CACHE_DIR, self.instance_id)
data_dir = os.path.join(self.mass.storage_path, self.instance_id)
setup_data_dir = os.path.join(self.mass.storage_path, SETUP_STORAGE_PATH)
data_dir_exists = await asyncio.to_thread(os.path.isdir, data_dir)
setup_dir_exists = await asyncio.to_thread(os.path.isdir, setup_data_dir)
if not data_dir_exists and setup_dir_exists:
# complete setup: move setup data to data dir
await asyncio.to_thread(os.rename, setup_data_dir, data_dir)
elif not data_dir_exists:
raise LoginFailed("Spotify is not authenticated")
self._data_dir = data_dir
if self.config.get_value(CONF_CLIENT_ID):
# loosen the throttler a bit when a custom client id is used
self.throttler.rate_limit = 45
self.throttler.period = 30
# try login which will raise if it fails
await self.login()
await self.get_token()

@property
def supported_features(self) -> tuple[ProviderFeature, ...]:
Expand Down Expand Up @@ -441,12 +504,11 @@ async def get_audio_stream(
) -> AsyncGenerator[bytes, None]:
"""Return the audio stream for the provider item."""
# make sure that the token is still valid by just requesting it
await self.login()
librespot = await self.get_librespot_binary()
await self.get_token()
args = [
librespot,
self._librespot_bin,
"-c",
self._cache_dir,
self._data_dir,
"--pass-through",
"-b",
"320",
Expand Down Expand Up @@ -642,19 +704,16 @@ def _parse_playlist(self, playlist_obj):
playlist.cache_checksum = str(playlist_obj["snapshot_id"])
return playlist

async def login(self) -> dict:
async def get_token(self) -> dict:
"""Log-in Spotify and return tokeninfo."""
# return existing token if we have one in memory
if (
self._auth_token
and await asyncio.to_thread(os.path.isdir, self._cache_dir)
and (self._auth_token["expiresAt"] > int(time.time()) + 600)
and await asyncio.to_thread(os.path.isdir, self._data_dir)
and (self._auth_token["expiresAt"] > int(time.time()))
):
return self._auth_token
tokeninfo, userinfo = None, self._sp_user
if not self.config.get_value(CONF_USERNAME) or not self.config.get_value(CONF_PASSWORD):
msg = "Invalid login credentials"
raise LoginFailed(msg)
# retrieve token with librespot
retries = 0
while retries < 5:
Expand All @@ -678,7 +737,7 @@ async def login(self) -> dict:
self._auth_token = tokeninfo
self._sp_user = userinfo
self.mass.metadata.set_default_preferred_language(userinfo["country"])
self.logger.info("Successfully logged in to Spotify as %s", userinfo["id"])
self.logger.debug("Auth token refreshed")
self._auth_token = tokeninfo
return tokeninfo
if tokeninfo and not userinfo:
Expand All @@ -687,36 +746,14 @@ async def login(self) -> dict:
"probably just a temporary error"
)
raise LoginFailed(msg)
if self.config.get_value(CONF_USERNAME).isnumeric():
# a spotify free/basic account can be recognized when
# the username consists of numbers only - check that here
# an integer can be parsed of the username, this is a free account
msg = "Only Spotify Premium accounts are supported"
raise LoginFailed(msg)
msg = f"Login failed for user {self.config.get_value(CONF_USERNAME)}"
await asyncio.to_thread(shutil.rmtree, self._data_dir)
msg = "Retrieving token failed, note that only Spotify Premium accounts are supported"
raise LoginFailed(msg)

async def _get_token(self):
"""Get spotify auth token with librespot bin."""
time_start = time.time()
# authorize with username and password (NOTE: this can also be Spotify Connect)
args = [
await self.get_librespot_binary(),
"-O",
"-c",
self._cache_dir,
"-a",
"-u",
self.config.get_value(CONF_USERNAME),
"-p",
self.config.get_value(CONF_PASSWORD),
]
if self._ap_workaround:
args += ["--ap-port", "12345"]
_returncode, output = await check_output(*args)
if _returncode == 0 and output.decode().strip() != "authorized":
raise LoginFailed(f"Login failed for username {self.config.get_value(CONF_USERNAME)}")
# get token with (authorized) librespot
# get token with (pre-authorized) librespot
scopes = [
"user-read-playback-state",
"user-read-currently-playing",
Expand All @@ -735,15 +772,15 @@ async def _get_token(self):
]
scope = ",".join(scopes)
args = [
await self.get_librespot_binary(),
self._librespot_bin,
"-O",
"-t",
"--client-id",
self.config.get_value(CONF_CLIENT_ID) or app_var(2),
"--scope",
scope,
"-c",
self._cache_dir,
self._data_dir,
]
if self._ap_workaround:
args += ["--ap-port", "12345"]
Expand All @@ -765,7 +802,7 @@ async def _get_token(self):
# transform token info to spotipy compatible format
if result and "accessToken" in result:
tokeninfo = result
tokeninfo["expiresAt"] = tokeninfo["expiresIn"] + int(time.time())
tokeninfo["expiresAt"] = tokeninfo["expiresIn"] + int(time.time() - 120)
return tokeninfo
return None

Expand Down Expand Up @@ -794,7 +831,7 @@ async def _get_data(self, endpoint, **kwargs) -> dict[str, Any]:
kwargs["country"] = "from_token"
tokeninfo = kwargs.pop("tokeninfo", None)
if tokeninfo is None:
tokeninfo = await self.login()
tokeninfo = await self.get_token()
headers = {"Authorization": f'Bearer {tokeninfo["accessToken"]}'}
locale = self.mass.metadata.locale.replace("_", "-")
language = locale.split("-")[0]
Expand Down Expand Up @@ -824,7 +861,7 @@ async def _get_data(self, endpoint, **kwargs) -> dict[str, Any]:
async def _delete_data(self, endpoint, data=None, **kwargs) -> None:
"""Delete data from api."""
url = f"https://api.spotify.com/v1/{endpoint}"
token = await self.login()
token = await self.get_token()
headers = {"Authorization": f'Bearer {token["accessToken"]}'}
async with self.mass.http_session.delete(
url, headers=headers, params=kwargs, json=data, ssl=False
Expand All @@ -844,7 +881,7 @@ async def _delete_data(self, endpoint, data=None, **kwargs) -> None:
async def _put_data(self, endpoint, data=None, **kwargs) -> None:
"""Put data on api."""
url = f"https://api.spotify.com/v1/{endpoint}"
token = await self.login()
token = await self.get_token()
headers = {"Authorization": f'Bearer {token["accessToken"]}'}
async with self.mass.http_session.put(
url, headers=headers, params=kwargs, json=data, ssl=False
Expand All @@ -864,7 +901,7 @@ async def _put_data(self, endpoint, data=None, **kwargs) -> None:
async def _post_data(self, endpoint, data=None, **kwargs) -> dict[str, Any]:
"""Post data on api."""
url = f"https://api.spotify.com/v1/{endpoint}"
token = await self.login()
token = await self.get_token()
headers = {"Authorization": f'Bearer {token["accessToken"]}'}
async with self.mass.http_session.post(
url, headers=headers, params=kwargs, json=data, ssl=False
Expand All @@ -881,33 +918,6 @@ async def _post_data(self, endpoint, data=None, **kwargs) -> dict[str, Any]:
response.raise_for_status()
return await response.json(loads=json_loads)

async def get_librespot_binary(self):
"""Find the correct librespot binary belonging to the platform."""
# ruff: noqa: SIM102
if self._librespot_bin is not None:
return self._librespot_bin

async def check_librespot(librespot_path: str) -> str | None:
try:
returncode, output = await check_output(librespot_path, "--check")
if returncode == 0 and b"ok spotty" in output and b"using librespot" in output:
self._librespot_bin = librespot_path
return librespot_path
except OSError:
return None

base_path = os.path.join(os.path.dirname(__file__), "bin")
system = platform.system().lower()
architecture = platform.machine().lower()

if bridge_binary := await check_librespot(
os.path.join(base_path, f"librespot-{system}-{architecture}")
):
return bridge_binary

msg = f"Unable to locate Librespot for {system}/{architecture}"
raise RuntimeError(msg)

def _fix_create_playlist_api_bug(self, playlist_obj: dict[str, Any]) -> None:
"""Fix spotify API bug where incorrect owner id is returned from Create Playlist."""
if playlist_obj["owner"]["id"] != self._sp_user["id"]:
Expand All @@ -917,3 +927,28 @@ def _fix_create_playlist_api_bug(self, playlist_obj: dict[str, Any]) -> None:
self.logger.warning(
"FIXME: Spotify have fixed their Create Playlist API, this fix can be removed."
)


async def get_librespot_binary():
"""Find the correct librespot binary belonging to the platform."""
# ruff: noqa: SIM102

async def check_librespot(librespot_path: str) -> str | None:
try:
returncode, output = await check_output(librespot_path, "--check")
if returncode == 0 and b"ok spotty" in output and b"using librespot" in output:
return librespot_path
except OSError:
return None

base_path = os.path.join(os.path.dirname(__file__), "bin")
system = platform.system().lower()
architecture = platform.machine().lower()

if bridge_binary := await check_librespot(
os.path.join(base_path, f"librespot-{system}-{architecture}")
):
return bridge_binary

msg = f"Unable to locate Librespot for {system}/{architecture}"
raise RuntimeError(msg)