From 8c30c74dc85dbafc7954fcd15bf4650226e85a04 Mon Sep 17 00:00:00 2001 From: Matthias Meulien Date: Sat, 23 Mar 2024 15:38:17 +0100 Subject: [PATCH 01/15] Support import of ListenBrainz playlists --- README.rst | 2 +- mopidy_listenbrainz/__init__.py | 5 + mopidy_listenbrainz/backend.py | 26 ++++ mopidy_listenbrainz/ext.conf | 1 + mopidy_listenbrainz/frontend.py | 73 ++++++++++++ mopidy_listenbrainz/listenbrainz.py | 177 +++++++++++++++++++++++++++- mopidy_listenbrainz/playlists.py | 84 +++++++++++++ 7 files changed, 361 insertions(+), 7 deletions(-) create mode 100644 mopidy_listenbrainz/backend.py create mode 100644 mopidy_listenbrainz/playlists.py diff --git a/README.rst b/README.rst index 86da220..6ea2967 100644 --- a/README.rst +++ b/README.rst @@ -45,7 +45,7 @@ The following configuration values are available: Defaults to enabled. - ``listenbrainz/token``: Your `Listenbrainz user token `_ - ``listenbrainz/url``: The URL of the API of the Listenbrainz instance to record listens to (default: api.listenbrainz.org) - +- ``listenbrainz/import_playlists``: Whether to import Listenbrainz playlists (default: ``false``) Project resources ================= diff --git a/mopidy_listenbrainz/__init__.py b/mopidy_listenbrainz/__init__.py index a44b026..385d55d 100644 --- a/mopidy_listenbrainz/__init__.py +++ b/mopidy_listenbrainz/__init__.py @@ -19,9 +19,14 @@ def get_config_schema(self): schema = super().get_config_schema() schema["token"] = config.Secret() schema["url"] = config.String() + schema["import_playlists"] = config.Boolean() return schema def setup(self, registry): from .frontend import ListenbrainzFrontend registry.add("frontend", ListenbrainzFrontend) + + from .backend import ListenbrainzBackend + + registry.add("backend", ListenbrainzBackend) diff --git a/mopidy_listenbrainz/backend.py b/mopidy_listenbrainz/backend.py new file mode 100644 index 0000000..4629955 --- /dev/null +++ b/mopidy_listenbrainz/backend.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, ClassVar + +import pykka + +from mopidy import backend +from mopidy.types import UriScheme + +from . import playlists + +if TYPE_CHECKING: + from mopidy.audio import AudioProxy + from mopidy.ext import Config + + +class ListenbrainzBackend(pykka.ThreadingActor, backend.Backend): + uri_schemes: ClassVar[list[UriScheme]] = [UriScheme("listenbrainz")] + + def __init__( + self, + config: Config, # noqa: ARG002 + audio: AudioProxy, # noqa: ARG002 + ) -> None: + super().__init__() + self.playlists = playlists.ListenbrainzPlaylistsProvider(self) diff --git a/mopidy_listenbrainz/ext.conf b/mopidy_listenbrainz/ext.conf index 1c15765..b1dfd77 100644 --- a/mopidy_listenbrainz/ext.conf +++ b/mopidy_listenbrainz/ext.conf @@ -2,3 +2,4 @@ enabled = true token = url = api.listenbrainz.org +import_playlists = false diff --git a/mopidy_listenbrainz/frontend.py b/mopidy_listenbrainz/frontend.py index a3db66d..6eba0e8 100644 --- a/mopidy_listenbrainz/frontend.py +++ b/mopidy_listenbrainz/frontend.py @@ -3,6 +3,7 @@ import pykka from mopidy.core import CoreListener +from mopidy.models import Playlist from .listenbrainz import Listenbrainz @@ -15,14 +16,86 @@ class ListenbrainzFrontend(pykka.ThreadingActor, CoreListener): def __init__(self, config, core): super().__init__() self.config = config + self.library = core.library + self.playlists = core.playlists def on_start(self): self.lb = Listenbrainz( self.config["listenbrainz"]["token"], self.config["listenbrainz"]["url"], + self.config["proxy"], ) logger.debug("Listenbrainz token valid!") + if self.config["listenbrainz"].get("import_playlists", False): + self.import_playlists() + + def import_playlists(self) -> None: + logger.info("Import of ListenBrainz playlists") + + import_count = 0 + playlist_datas = self.lb.list_playlists_created_for_user() + logger.debug(f"Found {len(playlist_datas)} playlists to import") + + for playlist_data in playlist_datas: + source = playlist_data.get("playlist_id", "") + tracks = [] + for track_mbid in playlist_data.get("track_mbids", []): + query = self.library.search( + {"musicbrainz_trackid": [track_mbid]}, uris=["local:"] + ) + # search only in local database since other backends + # can be quite long to answer + results = query.get() + + found_tracks = [t for r in results for t in r.tracks] + if len(found_tracks) == 0: + logger.debug( + f"Library has no track with MBID {track_mbid!r}" + ) + continue + elif len(found_tracks) > 1: + logger.debug( + f"Library has multiple tracks with MBID {track_mbid!r}" + ) + + tracks.append(found_tracks[0]) + + if len(tracks) == 0: + logger.warning( + f"Skipping import of playlist with no tracks for {source!r}" + ) + continue + + playlist_name = playlist_data.get("name", "") + query = self.playlists.create( + playlist_name, uri_scheme="listenbrainz" + ) + playlist = query.get() + if playlist is None: + logger.warning(f"Failed to create playlist for {source!r}") + continue + + complete_playlist = Playlist( + uri=playlist.uri, + name=playlist_name, + tracks=tracks, + last_modified=playlist_data.get("last_modified"), + ) + query = self.playlists.save(complete_playlist) + playlist = query.get() + if playlist is None: + logger.warning(f"Failed to save playlist for {source!r}") + else: + import_count += 1 + logger.debug( + f"Playlist {playlist.uri!r} imported from {source!r}" + ) + + logger.info( + f"Successfull import of ListenBrainz playlists: {import_count}" + ) + def track_playback_started(self, tl_track): track = tl_track.track artists = ", ".join(sorted([a.name for a in track.artists])) diff --git a/mopidy_listenbrainz/listenbrainz.py b/mopidy_listenbrainz/listenbrainz.py index b157125..a917201 100644 --- a/mopidy_listenbrainz/listenbrainz.py +++ b/mopidy_listenbrainz/listenbrainz.py @@ -1,27 +1,82 @@ +import datetime +import logging import time -from typing import Any, Dict +from typing import Any, Dict, List, Optional +from urllib.parse import urlparse + +import pkg_resources +from mopidy import httpclient import requests from . import __version__ +logger = logging.getLogger(__name__) + +# Listenbrainz API +LIST_PLAYLIST_CREATED_FOR_ENDPOINT = "/1/user/{user}/playlists/createdfor" +PLAYLIST_ENDPOINT = "/1/playlist/{playlist_id}" SUBMIT_LISTEN_ENDPOINT = "/1/submit-listens" VALIDATE_TOKEN_ENDPOINT = "/1/validate-token" +# Musicbrainz resources +MUSICBRAINZ_PLAYLIST_EXTENSION_URL = "https://musicbrainz.org/doc/jspf#playlist" + + +def playlist_identifier_to_id(playlist_identifier: str) -> Optional[str]: + playlist_path = urlparse(playlist_identifier).path + path_prefix = "/playlist/" + return ( + playlist_path[len(path_prefix) :] + if playlist_path.startswith(path_prefix) + else None + ) + + +def track_identifier_to_mbid(track_identifier: str) -> Optional[str]: + track_path = urlparse(track_identifier).path + path_prefix = "/recording/" + return ( + track_path[len(path_prefix) :] + if track_path.startswith(path_prefix) + else None + ) + + +def get_requests_session(proxy_config, user_agent): + proxy = httpclient.format_proxy(proxy_config) + full_user_agent = httpclient.format_user_agent(user_agent) + + session = requests.Session() + session.proxies.update({"http": proxy, "https": proxy}) + session.headers.update({"user-agent": full_user_agent}) + + return session + class Listenbrainz(object): token: str url: str - def __init__(self, token: str, url: str) -> None: + user_name: Optional[str] + + def __init__(self, token: str, url: str, proxy_config: Any) -> None: self.token = token self.url = url + self.user_name = None # initialized during token validation + + dist = pkg_resources.get_distribution("Mopidy-Listenbrainz") + self.session = get_requests_session( + proxy_config=proxy_config, + user_agent=f"{dist.project_name}/{dist.version}", + ) + if not self.validate_token(): raise RuntimeError(f"Token {token} is not valid") def validate_token(self) -> bool: - response = requests.get( + response = self.session.get( url=f"https://{self.url}{VALIDATE_TOKEN_ENDPOINT}", headers={ "Authorization": f"Token {self.token}", @@ -31,7 +86,9 @@ def validate_token(self) -> bool: if response.status_code != 200: return False - return response.json()["valid"] + parsed_response = response.json() + self.user_name = parsed_response.get("user_name") + return parsed_response.get("valid") def submit_listen( self, @@ -64,7 +121,7 @@ def submit_listen( payload = [listen] - response = requests.post( + response = self.session.post( # hardcode https? url=f"https://{self.url}{SUBMIT_LISTEN_ENDPOINT}", json={ @@ -74,4 +131,112 @@ def submit_listen( headers={ "Authorization": f"Token {self.token}", }, - ) \ No newline at end of file + ) + + def list_playlists_created_for_user(self) -> List[Dict[str, Any]]: + if self.user_name is None: + logger.warning("No playlist created for unknown user!") + return [] + + path = LIST_PLAYLIST_CREATED_FOR_ENDPOINT.format(user=self.user_name) + response = self.session.get( + url=f"https://{self.url}{path}", + headers={ + "Authorization": f"Token {self.token}", + }, + ) + parsed_response = response.json() + playlists: List[Dict[str, Any]] = [] + found_playlists: List[str] = [] + for dto in parsed_response.get("playlists", []): + playlist_dto = dto.get("playlist", {}) + playlist_identifier = playlist_dto.get("identifier") + + if playlist_identifier is None: + logger.debug(f"Skipping playlist without identifier") + continue + + if playlist_identifier in found_playlists: + logger.warning(f"Duplicated playlist {playlist_identifier!r}") + continue + + found_playlists.append(playlist_identifier) + + playlist_data = self._collect_playlist_data(playlist_identifier) + if playlist_data is None: + logger.warning( + f"Failed to build playlist {playlist_identifier!r}" + ) + continue + + playlists.append(playlist_data) + + return playlists + + def _collect_playlist_data( + self, playlist_identifier: str + ) -> Optional[Dict[str, Any]]: + """Collect playlist data from a ListenBrainz playlist identifier. + + The ListenBrainz playlist identifier is a URL whose last path + segment identifies the playlist. A playlist DTO is fetched + from that identifier using ListenBrainz API. + + If the ListenBrainz playlist identifier doesn't match our + expectations (see ``playlist_identifier_to_id()``), or the DTO + hasn't the expected fields (``name``, ``date``), None is + returned. + + MusicBrainz track identifiers are extracted from the tracks + identifiers found in the DTO ``tracks`` field. + + """ + playlist_id = playlist_identifier_to_id(playlist_identifier) + if playlist_id is None: + logger.warning( + f"Failed to extract playlist id from {playlist_identifier}" + ) + return None + + path = PLAYLIST_ENDPOINT.format(playlist_id=playlist_id) + response = self.session.get( + url=f"https://{self.url}{path}", + headers={ + "Authorization": f"Token {self.token}", + }, + ) + parsed_response = response.json() + dto = parsed_response.get("playlist", {}) + name = dto.get("title") + if name is None: + logger.debug(f"Unable to read a name from playlist {playlist_id!r}") + return None + try: + creation_date = datetime.datetime.fromisoformat(dto.get("date")) + except (ValueError, TypeError): + logger.warning(f"Failed to parse date for playlist {playlist_id!r}") + return None + track_mbids = [] + for track_dto in dto.get("track", []): # not tracks! + track_identifier = track_dto.get("identifier", "") + track_mbid = track_identifier_to_mbid(track_identifier) + if track_mbid is None: + logger.debug( + f"Failed to identify MBID from {track_identifier!r}" + ) + continue + + track_mbids.append(track_mbid) + + if len(track_mbids) == 0: + logger.debug( + f"No MBID found for tracks of playlist {playlist_id!r}" + ) + return None + + return { + "playlist_id": playlist_id, + "name": name, + "track_mbids": track_mbids, + "last_modified": int(creation_date.timestamp()), + } diff --git a/mopidy_listenbrainz/playlists.py b/mopidy_listenbrainz/playlists.py new file mode 100644 index 0000000..8eb7c8a --- /dev/null +++ b/mopidy_listenbrainz/playlists.py @@ -0,0 +1,84 @@ +import datetime +import logging +from typing import cast, List +from uuid import uuid4 + +from mopidy.backend import Backend, PlaylistsProvider +from mopidy.models import Playlist, Ref +from mopidy.types import Uri, UriScheme + + +logger = logging.getLogger(__name__) + + +class ListenbrainzPlaylistsProvider(PlaylistsProvider): + """Provider for ListenBrainz playlists. + + Note that this provider doesn't serialize the playlists. They're + expected to be created by the frontend on each extension setup. + + This provider handles URIs with scheme ``listenbrainz:playlist``. + + """ + + uri_prefix: UriScheme + playlists: List[Playlist] + + def __init__(self, backend: Backend) -> None: + super().__init__(backend) + + assert len(backend.uri_schemes) == 1 + self.uri_prefix = cast(UriScheme, backend.uri_schemes[0] + ":playlist") + self.playlists = [] + + def as_list(self) -> List[Ref]: + return [Ref.playlist(uri=p.uri, name=p.name) for p in self.playlists] + + def create(self, name: str) -> Playlist | None: + uri = f"{self.uri_prefix}:{uuid4()}" + playlist = Playlist(uri=uri) + self.playlists.append(playlist) + return playlist + + def delete(self, uri: Uri) -> bool: + return False + + def get_items(self, uri: Uri) -> List[Ref] | None: + if not uri.startswith(self.uri_prefix): + return None + + found = [p for p in self.playlists if p.uri == uri] + if len(found) == 0: + return None + + return [Ref.playlist(uri=p.uri, name=p.name) for p in found] + + def lookup(self, uri: Uri) -> Playlist | None: + if not uri.startswith(self.uri_prefix): + return None + + found = [p for p in self.playlists if p.uri == uri] + if len(found) == 0: + return None + + return found[0] + + def refresh(self) -> None: + pass + + def save(self, playlist: Playlist) -> Playlist | None: + uri = playlist.uri + + if not uri.startswith(self.uri_prefix): + return None + + found = [p for p in self.playlists if p.uri == uri] + if len(found) == 0: + return None + + if len(found[0].tracks) > 0: + return None # Playlists are expected to be saved + # once, by the frontend + + found[0] = playlist + return found[0] From b487711402c289ac350421c201fcc376555d65bf Mon Sep 17 00:00:00 2001 From: Matthias Meulien Date: Sat, 23 Mar 2024 21:47:27 +0100 Subject: [PATCH 02/15] Support Mopidy 3.4.x --- mopidy_listenbrainz/backend.py | 7 +++++-- mopidy_listenbrainz/frontend.py | 2 ++ mopidy_listenbrainz/playlists.py | 8 ++++++-- 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/mopidy_listenbrainz/backend.py b/mopidy_listenbrainz/backend.py index 4629955..ee5170e 100644 --- a/mopidy_listenbrainz/backend.py +++ b/mopidy_listenbrainz/backend.py @@ -1,11 +1,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, ClassVar, NewType import pykka from mopidy import backend -from mopidy.types import UriScheme +try: + from mopidy.types import UriScheme +except ModuleNotFoundError: + UriScheme = NewType("UriScheme", str) from . import playlists diff --git a/mopidy_listenbrainz/frontend.py b/mopidy_listenbrainz/frontend.py index 6eba0e8..7f8f93b 100644 --- a/mopidy_listenbrainz/frontend.py +++ b/mopidy_listenbrainz/frontend.py @@ -76,6 +76,8 @@ def import_playlists(self) -> None: logger.warning(f"Failed to create playlist for {source!r}") continue + logger.debug(f"Playlist {playlist.uri!r} created from {source!r}") + complete_playlist = Playlist( uri=playlist.uri, name=playlist_name, diff --git a/mopidy_listenbrainz/playlists.py b/mopidy_listenbrainz/playlists.py index 8eb7c8a..91e5d9a 100644 --- a/mopidy_listenbrainz/playlists.py +++ b/mopidy_listenbrainz/playlists.py @@ -1,11 +1,15 @@ import datetime import logging -from typing import cast, List +from typing import cast, List, NewType from uuid import uuid4 from mopidy.backend import Backend, PlaylistsProvider from mopidy.models import Playlist, Ref -from mopidy.types import Uri, UriScheme +try: + from mopidy.types import Uri, UriScheme +except ModuleNotFoundError: + Uri = NewType("Uri", str) + UriScheme = NewType("UriScheme", str) logger = logging.getLogger(__name__) From 5e830dbe9a80f22c27733d85e87123dc2abe1d74 Mon Sep 17 00:00:00 2001 From: Matthias Meulien Date: Sat, 23 Mar 2024 22:57:39 +0100 Subject: [PATCH 03/15] Fix missing playlist name --- mopidy_listenbrainz/frontend.py | 2 +- mopidy_listenbrainz/playlists.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy_listenbrainz/frontend.py b/mopidy_listenbrainz/frontend.py index 7f8f93b..bd15ff4 100644 --- a/mopidy_listenbrainz/frontend.py +++ b/mopidy_listenbrainz/frontend.py @@ -91,7 +91,7 @@ def import_playlists(self) -> None: else: import_count += 1 logger.debug( - f"Playlist {playlist.uri!r} imported from {source!r}" + f"Tracks saved for playlist {playlist.uri!r}: {len(playlist.tracks)!r}" ) logger.info( diff --git a/mopidy_listenbrainz/playlists.py b/mopidy_listenbrainz/playlists.py index 91e5d9a..28b042f 100644 --- a/mopidy_listenbrainz/playlists.py +++ b/mopidy_listenbrainz/playlists.py @@ -40,7 +40,7 @@ def as_list(self) -> List[Ref]: def create(self, name: str) -> Playlist | None: uri = f"{self.uri_prefix}:{uuid4()}" - playlist = Playlist(uri=uri) + playlist = Playlist(uri=uri, name=name) self.playlists.append(playlist) return playlist From 8b08535db76ea0976a34239047ea5879432e2283 Mon Sep 17 00:00:00 2001 From: Matthias Meulien Date: Sat, 23 Mar 2024 23:48:06 +0100 Subject: [PATCH 04/15] Fix missing playlist tracks with Mopidy 3.4.x --- mopidy_listenbrainz/playlists.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy_listenbrainz/playlists.py b/mopidy_listenbrainz/playlists.py index 28b042f..5d59172 100644 --- a/mopidy_listenbrainz/playlists.py +++ b/mopidy_listenbrainz/playlists.py @@ -84,5 +84,6 @@ def save(self, playlist: Playlist) -> Playlist | None: return None # Playlists are expected to be saved # once, by the frontend - found[0] = playlist - return found[0] + pos = self.playlists.index(found[0]) + self.playlists[pos] = playlist + return self.playlists[pos] From 294de175b745f749639b4d9f13dc756f06bd66b8 Mon Sep 17 00:00:00 2001 From: Matthias Meulien Date: Wed, 27 Mar 2024 23:39:11 +0100 Subject: [PATCH 05/15] Periodic update of playlists --- README.rst | 1 + mopidy_listenbrainz/backend.py | 1 + mopidy_listenbrainz/frontend.py | 69 +++++++++++++++++++++++------ mopidy_listenbrainz/listenbrainz.py | 24 ++++++---- mopidy_listenbrainz/playlists.py | 21 ++++++--- 5 files changed, 88 insertions(+), 28 deletions(-) diff --git a/README.rst b/README.rst index 6ea2967..e757abf 100644 --- a/README.rst +++ b/README.rst @@ -46,6 +46,7 @@ The following configuration values are available: - ``listenbrainz/token``: Your `Listenbrainz user token `_ - ``listenbrainz/url``: The URL of the API of the Listenbrainz instance to record listens to (default: api.listenbrainz.org) - ``listenbrainz/import_playlists``: Whether to import Listenbrainz playlists (default: ``false``) +- ``listenbrainz/periodic_playlists_update``: Enable periodic import of Listenbrainz playlists (default: ``true``) Project resources ================= diff --git a/mopidy_listenbrainz/backend.py b/mopidy_listenbrainz/backend.py index ee5170e..98a1655 100644 --- a/mopidy_listenbrainz/backend.py +++ b/mopidy_listenbrainz/backend.py @@ -5,6 +5,7 @@ import pykka from mopidy import backend + try: from mopidy.types import UriScheme except ModuleNotFoundError: diff --git a/mopidy_listenbrainz/frontend.py b/mopidy_listenbrainz/frontend.py index bd15ff4..be83dd8 100644 --- a/mopidy_listenbrainz/frontend.py +++ b/mopidy_listenbrainz/frontend.py @@ -1,5 +1,7 @@ import logging import time +from datetime import datetime, timedelta +from threading import Timer import pykka from mopidy.core import CoreListener @@ -18,6 +20,7 @@ def __init__(self, config, core): self.config = config self.library = core.library self.playlists = core.playlists + self.playlists_update_timer = None def on_start(self): self.lb = Listenbrainz( @@ -30,6 +33,10 @@ def on_start(self): if self.config["listenbrainz"].get("import_playlists", False): self.import_playlists() + def on_stop(self): + if self.playlists_update_timer: + self.playlists_update_timer.cancel() + def import_playlists(self) -> None: logger.info("Import of ListenBrainz playlists") @@ -37,10 +44,19 @@ def import_playlists(self) -> None: playlist_datas = self.lb.list_playlists_created_for_user() logger.debug(f"Found {len(playlist_datas)} playlists to import") + existing_playlists = self.playlists.as_list().get() + filtered_existing_playlists = dict( + [ + (ref.uri, ref) + for ref in existing_playlists + if ref.uri.startswith("listenbrainz") + ] + ) + for playlist_data in playlist_datas: - source = playlist_data.get("playlist_id", "") + source = playlist_data.playlist_id tracks = [] - for track_mbid in playlist_data.get("track_mbids", []): + for track_mbid in playlist_data.track_mbids: query = self.library.search( {"musicbrainz_trackid": [track_mbid]}, uris=["local:"] ) @@ -67,22 +83,30 @@ def import_playlists(self) -> None: ) continue - playlist_name = playlist_data.get("name", "") - query = self.playlists.create( - playlist_name, uri_scheme="listenbrainz" - ) - playlist = query.get() - if playlist is None: - logger.warning(f"Failed to create playlist for {source!r}") - continue + playlist_uri = f"listenbrainz:playlist:{playlist_data.playlist_id}" + if playlist_uri in filtered_existing_playlists: + logger.debug(f"Will update playlist {playlist_uri}") + playlist = filtered_existing_playlists.pop(playlist_uri) + else: + query = self.playlists.create( + playlist_uri, uri_scheme="listenbrainz" + ) + # Hack, hack: The backend uses first parameter as URI, + # not name... + playlist = query.get() + if playlist is None: + logger.warning(f"Failed to create playlist for {source!r}") + continue - logger.debug(f"Playlist {playlist.uri!r} created from {source!r}") + logger.debug( + f"Playlist {playlist.uri!r} created from {source!r}" + ) complete_playlist = Playlist( uri=playlist.uri, - name=playlist_name, + name=playlist_data.name, tracks=tracks, - last_modified=playlist_data.get("last_modified"), + last_modified=playlist_data.last_modified, ) query = self.playlists.save(complete_playlist) playlist = query.get() @@ -94,9 +118,28 @@ def import_playlists(self) -> None: f"Tracks saved for playlist {playlist.uri!r}: {len(playlist.tracks)!r}" ) + for playlist in filtered_existing_playlists.values(): + self.playlists.delete(playlist.uri) + logger.info( f"Successfull import of ListenBrainz playlists: {import_count}" ) + self._schedule_playlists_import() + + def _schedule_playlists_import(self): + if self.config["listenbrainz"].get("periodic_playlists_update", True): + now = datetime.now() + days_until_next_monday = 7 - now.weekday() + timer_interval = ( + timedelta(days=days_until_next_monday).total_seconds() + ) + logger.debug( + f"Playlist update scheduled in {timer_interval} seconds" + ) + self.playlists_update_timer = Timer( + timer_interval, self.import_playlists + ) + self.playlists_update_timer.start() def track_playback_started(self, tl_track): track = tl_track.track diff --git a/mopidy_listenbrainz/listenbrainz.py b/mopidy_listenbrainz/listenbrainz.py index a917201..28a51bc 100644 --- a/mopidy_listenbrainz/listenbrainz.py +++ b/mopidy_listenbrainz/listenbrainz.py @@ -1,6 +1,7 @@ import datetime import logging import time +from dataclasses import dataclass from typing import Any, Dict, List, Optional from urllib.parse import urlparse @@ -23,6 +24,14 @@ MUSICBRAINZ_PLAYLIST_EXTENSION_URL = "https://musicbrainz.org/doc/jspf#playlist" +@dataclass +class PlaylistData: + playlist_id: str + name: str + track_mbids: List[str] + last_modified: int + + def playlist_identifier_to_id(playlist_identifier: str) -> Optional[str]: playlist_path = urlparse(playlist_identifier).path path_prefix = "/playlist/" @@ -133,7 +142,7 @@ def submit_listen( }, ) - def list_playlists_created_for_user(self) -> List[Dict[str, Any]]: + def list_playlists_created_for_user(self) -> List[PlaylistData]: if self.user_name is None: logger.warning("No playlist created for unknown user!") return [] @@ -146,7 +155,7 @@ def list_playlists_created_for_user(self) -> List[Dict[str, Any]]: }, ) parsed_response = response.json() - playlists: List[Dict[str, Any]] = [] + playlists: List[PlaylistData] = [] found_playlists: List[str] = [] for dto in parsed_response.get("playlists", []): playlist_dto = dto.get("playlist", {}) @@ -175,7 +184,7 @@ def list_playlists_created_for_user(self) -> List[Dict[str, Any]]: def _collect_playlist_data( self, playlist_identifier: str - ) -> Optional[Dict[str, Any]]: + ) -> Optional[PlaylistData]: """Collect playlist data from a ListenBrainz playlist identifier. The ListenBrainz playlist identifier is a URL whose last path @@ -234,9 +243,6 @@ def _collect_playlist_data( ) return None - return { - "playlist_id": playlist_id, - "name": name, - "track_mbids": track_mbids, - "last_modified": int(creation_date.timestamp()), - } + return PlaylistData( + playlist_id, name, track_mbids, int(creation_date.timestamp()) + ) diff --git a/mopidy_listenbrainz/playlists.py b/mopidy_listenbrainz/playlists.py index 5d59172..d4b48fa 100644 --- a/mopidy_listenbrainz/playlists.py +++ b/mopidy_listenbrainz/playlists.py @@ -5,6 +5,7 @@ from mopidy.backend import Backend, PlaylistsProvider from mopidy.models import Playlist, Ref + try: from mopidy.types import Uri, UriScheme except ModuleNotFoundError: @@ -39,13 +40,25 @@ def as_list(self) -> List[Ref]: return [Ref.playlist(uri=p.uri, name=p.name) for p in self.playlists] def create(self, name: str) -> Playlist | None: - uri = f"{self.uri_prefix}:{uuid4()}" + uri = name + if not uri.startswith(self.uri_prefix): + return None + playlist = Playlist(uri=uri, name=name) self.playlists.append(playlist) return playlist def delete(self, uri: Uri) -> bool: - return False + if not uri.startswith(self.uri_prefix): + return False + + found = [p for p in self.playlists if p.uri == uri] + if len(found) == 0: + return False + + pos = self.playlists.index(found[0]) + del self.playlists[pos] + return True def get_items(self, uri: Uri) -> List[Ref] | None: if not uri.startswith(self.uri_prefix): @@ -80,10 +93,6 @@ def save(self, playlist: Playlist) -> Playlist | None: if len(found) == 0: return None - if len(found[0].tracks) > 0: - return None # Playlists are expected to be saved - # once, by the frontend - pos = self.playlists.index(found[0]) self.playlists[pos] = playlist return self.playlists[pos] From 96deeeb6964711e354d6eb028cdcc0a5f4075176 Mon Sep 17 00:00:00 2001 From: Matthias Meulien Date: Fri, 29 Mar 2024 07:14:33 +0100 Subject: [PATCH 06/15] Use a URI dedicated to recommendation playlists --- mopidy_listenbrainz/frontend.py | 83 ++++++++++++++++------------- mopidy_listenbrainz/listenbrainz.py | 4 ++ mopidy_listenbrainz/playlists.py | 8 +++ 3 files changed, 57 insertions(+), 38 deletions(-) diff --git a/mopidy_listenbrainz/frontend.py b/mopidy_listenbrainz/frontend.py index be83dd8..3642d74 100644 --- a/mopidy_listenbrainz/frontend.py +++ b/mopidy_listenbrainz/frontend.py @@ -2,12 +2,13 @@ import time from datetime import datetime, timedelta from threading import Timer +from typing import List, Optional import pykka from mopidy.core import CoreListener -from mopidy.models import Playlist +from mopidy.models import Playlist, Track -from .listenbrainz import Listenbrainz +from .listenbrainz import Listenbrainz, PlaylistData logger = logging.getLogger(__name__) @@ -45,51 +46,38 @@ def import_playlists(self) -> None: logger.debug(f"Found {len(playlist_datas)} playlists to import") existing_playlists = self.playlists.as_list().get() + recommendation_playlist_uri_prefix = ( + "listenbrainz:playlist:recommendation" + ) filtered_existing_playlists = dict( [ (ref.uri, ref) for ref in existing_playlists - if ref.uri.startswith("listenbrainz") + if ref.uri.startswith(recommendation_playlist_uri_prefix) ] ) for playlist_data in playlist_datas: source = playlist_data.playlist_id - tracks = [] - for track_mbid in playlist_data.track_mbids: - query = self.library.search( - {"musicbrainz_trackid": [track_mbid]}, uris=["local:"] - ) - # search only in local database since other backends - # can be quite long to answer - results = query.get() - - found_tracks = [t for r in results for t in r.tracks] - if len(found_tracks) == 0: - logger.debug( - f"Library has no track with MBID {track_mbid!r}" - ) - continue - elif len(found_tracks) > 1: - logger.debug( - f"Library has multiple tracks with MBID {track_mbid!r}" - ) - - tracks.append(found_tracks[0]) + playlist_uri = f"{recommendation_playlist_uri_prefix}:{playlist_data.playlist_id}" + tracks = self._collect_playlist_tracks(playlist_data) if len(tracks) == 0: - logger.warning( - f"Skipping import of playlist with no tracks for {source!r}" + logger.debug( + f"Skipping import of playlist with no known track for {source!r}" ) continue - playlist_uri = f"listenbrainz:playlist:{playlist_data.playlist_id}" if playlist_uri in filtered_existing_playlists: - logger.debug(f"Will update playlist {playlist_uri}") - playlist = filtered_existing_playlists.pop(playlist_uri) + filtered_existing_playlists.pop(playlist_uri) + # must pop since filtered_existing_playlists will + # finally be deleted + + logger.debug(f"Already known playlist {playlist_uri}") + # maybe there're new tracks in Mopidy's database... else: query = self.playlists.create( - playlist_uri, uri_scheme="listenbrainz" + name=playlist_uri, uri_scheme="listenbrainz" ) # Hack, hack: The backend uses first parameter as URI, # not name... @@ -98,12 +86,10 @@ def import_playlists(self) -> None: logger.warning(f"Failed to create playlist for {source!r}") continue - logger.debug( - f"Playlist {playlist.uri!r} created from {source!r}" - ) + logger.debug(f"Playlist {playlist.uri!r} created") complete_playlist = Playlist( - uri=playlist.uri, + uri=playlist_uri, name=playlist_data.name, tracks=tracks, last_modified=playlist_data.last_modified, @@ -115,10 +101,11 @@ def import_playlists(self) -> None: else: import_count += 1 logger.debug( - f"Tracks saved for playlist {playlist.uri!r}: {len(playlist.tracks)!r}" + f"Playlist saved with {len(playlist.tracks)} tracks {playlist.uri!r}" ) for playlist in filtered_existing_playlists.values(): + logger.debug(f"Deletion of obsolete playlist {playlist.uri!r}") self.playlists.delete(playlist.uri) logger.info( @@ -126,13 +113,33 @@ def import_playlists(self) -> None: ) self._schedule_playlists_import() + def _collect_playlist_tracks( + self, playlist_data: PlaylistData + ) -> List[Track]: + tracks: List[Track] = [] + for track_mbid in playlist_data.track_mbids: + query = self.library.search( + {"musicbrainz_trackid": [track_mbid]}, uris=["local:"] + ) + # search only in local database since other backends can + # be quite long to answer, should we offer choice through + # config? + results = query.get() + + found_tracks = [t for r in results for t in r.tracks] + if len(found_tracks) == 0: + continue + + tracks.append(found_tracks[0]) + return tracks + def _schedule_playlists_import(self): if self.config["listenbrainz"].get("periodic_playlists_update", True): now = datetime.now() days_until_next_monday = 7 - now.weekday() - timer_interval = ( - timedelta(days=days_until_next_monday).total_seconds() - ) + timer_interval = timedelta( + days=days_until_next_monday + ).total_seconds() logger.debug( f"Playlist update scheduled in {timer_interval} seconds" ) diff --git a/mopidy_listenbrainz/listenbrainz.py b/mopidy_listenbrainz/listenbrainz.py index 28a51bc..67b558d 100644 --- a/mopidy_listenbrainz/listenbrainz.py +++ b/mopidy_listenbrainz/listenbrainz.py @@ -143,6 +143,10 @@ def submit_listen( ) def list_playlists_created_for_user(self) -> List[PlaylistData]: + """List all playlist data from the "created for" endpoint. + + The "created for" endpoint list recommendation playlists; It + is defined in ``LIST_PLAYLIST_CREATED_FOR_ENDPOINT``.""" if self.user_name is None: logger.warning("No playlist created for unknown user!") return [] diff --git a/mopidy_listenbrainz/playlists.py b/mopidy_listenbrainz/playlists.py index d4b48fa..802d439 100644 --- a/mopidy_listenbrainz/playlists.py +++ b/mopidy_listenbrainz/playlists.py @@ -93,6 +93,14 @@ def save(self, playlist: Playlist) -> Playlist | None: if len(found) == 0: return None + if uri.startswith(self.uri_prefix + ":recommendation"): + if not (len(playlist.tracks) > len(found[0].tracks)): + # return unchanged playlist for recommendations whose + # track list isn't increasing, really save iff first + # save after creation or new tracks being available in + # Mopidy's database + return found[0] + pos = self.playlists.index(found[0]) self.playlists[pos] = playlist return self.playlists[pos] From 2b5c756844f75c4ba844ff015c2d66e6751707f8 Mon Sep 17 00:00:00 2001 From: Matthias Meulien Date: Mon, 13 May 2024 07:37:46 +0200 Subject: [PATCH 07/15] Apply suggestions from code review Co-authored-by: Max Carr --- mopidy_listenbrainz/frontend.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mopidy_listenbrainz/frontend.py b/mopidy_listenbrainz/frontend.py index 3642d74..98010d0 100644 --- a/mopidy_listenbrainz/frontend.py +++ b/mopidy_listenbrainz/frontend.py @@ -39,7 +39,7 @@ def on_stop(self): self.playlists_update_timer.cancel() def import_playlists(self) -> None: - logger.info("Import of ListenBrainz playlists") + logger.info("Importing ListenBrainz playlists") import_count = 0 playlist_datas = self.lb.list_playlists_created_for_user() @@ -109,7 +109,7 @@ def import_playlists(self) -> None: self.playlists.delete(playlist.uri) logger.info( - f"Successfull import of ListenBrainz playlists: {import_count}" + f"Successfully imported ListenBrainz playlists: {import_count}" ) self._schedule_playlists_import() From e1b41340bc0ea032fc7bdb62fe226a3824abd815 Mon Sep 17 00:00:00 2001 From: Matthias Meulien Date: Mon, 13 May 2024 22:18:12 +0200 Subject: [PATCH 08/15] Other changes related to code review --- mopidy_listenbrainz/backend.py | 4 ++-- mopidy_listenbrainz/ext.conf | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/mopidy_listenbrainz/backend.py b/mopidy_listenbrainz/backend.py index 98a1655..cb72f7a 100644 --- a/mopidy_listenbrainz/backend.py +++ b/mopidy_listenbrainz/backend.py @@ -11,7 +11,7 @@ except ModuleNotFoundError: UriScheme = NewType("UriScheme", str) -from . import playlists +from .playlists import ListenbrainzPlaylistsProvider if TYPE_CHECKING: from mopidy.audio import AudioProxy @@ -27,4 +27,4 @@ def __init__( audio: AudioProxy, # noqa: ARG002 ) -> None: super().__init__() - self.playlists = playlists.ListenbrainzPlaylistsProvider(self) + self.playlists = ListenbrainzPlaylistsProvider(self) diff --git a/mopidy_listenbrainz/ext.conf b/mopidy_listenbrainz/ext.conf index b1dfd77..fc42747 100644 --- a/mopidy_listenbrainz/ext.conf +++ b/mopidy_listenbrainz/ext.conf @@ -3,3 +3,4 @@ enabled = true token = url = api.listenbrainz.org import_playlists = false +periodic_playlists_update = true From 2d01d4553eebc0830dbda08228bd25b0e1cc98ab Mon Sep 17 00:00:00 2001 From: Matthias Meulien Date: Mon, 13 May 2024 22:26:32 +0200 Subject: [PATCH 09/15] Remove useless configuration --- README.rst | 1 - mopidy_listenbrainz/ext.conf | 1 - mopidy_listenbrainz/frontend.py | 25 ++++++++++++------------- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/README.rst b/README.rst index e757abf..6ea2967 100644 --- a/README.rst +++ b/README.rst @@ -46,7 +46,6 @@ The following configuration values are available: - ``listenbrainz/token``: Your `Listenbrainz user token `_ - ``listenbrainz/url``: The URL of the API of the Listenbrainz instance to record listens to (default: api.listenbrainz.org) - ``listenbrainz/import_playlists``: Whether to import Listenbrainz playlists (default: ``false``) -- ``listenbrainz/periodic_playlists_update``: Enable periodic import of Listenbrainz playlists (default: ``true``) Project resources ================= diff --git a/mopidy_listenbrainz/ext.conf b/mopidy_listenbrainz/ext.conf index fc42747..b1dfd77 100644 --- a/mopidy_listenbrainz/ext.conf +++ b/mopidy_listenbrainz/ext.conf @@ -3,4 +3,3 @@ enabled = true token = url = api.listenbrainz.org import_playlists = false -periodic_playlists_update = true diff --git a/mopidy_listenbrainz/frontend.py b/mopidy_listenbrainz/frontend.py index 98010d0..7ed85e4 100644 --- a/mopidy_listenbrainz/frontend.py +++ b/mopidy_listenbrainz/frontend.py @@ -134,19 +134,18 @@ def _collect_playlist_tracks( return tracks def _schedule_playlists_import(self): - if self.config["listenbrainz"].get("periodic_playlists_update", True): - now = datetime.now() - days_until_next_monday = 7 - now.weekday() - timer_interval = timedelta( - days=days_until_next_monday - ).total_seconds() - logger.debug( - f"Playlist update scheduled in {timer_interval} seconds" - ) - self.playlists_update_timer = Timer( - timer_interval, self.import_playlists - ) - self.playlists_update_timer.start() + now = datetime.now() + days_until_next_monday = 7 - now.weekday() + timer_interval = timedelta( + days=days_until_next_monday + ).total_seconds() + logger.debug( + f"Playlist update scheduled in {timer_interval} seconds" + ) + self.playlists_update_timer = Timer( + timer_interval, self.import_playlists + ) + self.playlists_update_timer.start() def track_playback_started(self, tl_track): track = tl_track.track From 458457ca1b3ae855b2ede9b4db9234a7e5f00ac5 Mon Sep 17 00:00:00 2001 From: Matthias Meulien Date: Mon, 13 May 2024 23:33:31 +0200 Subject: [PATCH 10/15] Support track search in any backend --- README.rst | 1 + mopidy_listenbrainz/__init__.py | 1 + mopidy_listenbrainz/ext.conf | 1 + mopidy_listenbrainz/frontend.py | 10 +++++++++- 4 files changed, 12 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 6ea2967..9494ba9 100644 --- a/README.rst +++ b/README.rst @@ -46,6 +46,7 @@ The following configuration values are available: - ``listenbrainz/token``: Your `Listenbrainz user token `_ - ``listenbrainz/url``: The URL of the API of the Listenbrainz instance to record listens to (default: api.listenbrainz.org) - ``listenbrainz/import_playlists``: Whether to import Listenbrainz playlists (default: ``false``) +- ``listenbrainz/search_schemes``: If non empty, the search for tracks in Mopidy's library is limited to results with the given schemes. Leave empty (the default) to search among all backends, but note that it may affect performance; It's recommended to customize the value according to your favorite backends (``file:`` for Mopidy-File, ``local:`` for Mopidy-Local, etc.). Project resources ================= diff --git a/mopidy_listenbrainz/__init__.py b/mopidy_listenbrainz/__init__.py index 385d55d..ac0ff1b 100644 --- a/mopidy_listenbrainz/__init__.py +++ b/mopidy_listenbrainz/__init__.py @@ -20,6 +20,7 @@ def get_config_schema(self): schema["token"] = config.Secret() schema["url"] = config.String() schema["import_playlists"] = config.Boolean() + schema["search_schemes"] = config.List(optional=True) return schema def setup(self, registry): diff --git a/mopidy_listenbrainz/ext.conf b/mopidy_listenbrainz/ext.conf index b1dfd77..05c53f0 100644 --- a/mopidy_listenbrainz/ext.conf +++ b/mopidy_listenbrainz/ext.conf @@ -3,3 +3,4 @@ enabled = true token = url = api.listenbrainz.org import_playlists = false +search_schemes = diff --git a/mopidy_listenbrainz/frontend.py b/mopidy_listenbrainz/frontend.py index 7ed85e4..a6b7b19 100644 --- a/mopidy_listenbrainz/frontend.py +++ b/mopidy_listenbrainz/frontend.py @@ -32,6 +32,12 @@ def on_start(self): logger.debug("Listenbrainz token valid!") if self.config["listenbrainz"].get("import_playlists", False): + search_schemes = self.config["listenbrainz"].get("search_schemes", []) + if len(search_schemes) > 0: + logger.debug(f"Will limit track searches to URIs: {search_schemes}") + else: + logger.debug("Will search tracks among all backends") + self.import_playlists() def on_stop(self): @@ -117,9 +123,11 @@ def _collect_playlist_tracks( self, playlist_data: PlaylistData ) -> List[Track]: tracks: List[Track] = [] + search_schemes = self.config["listenbrainz"].get("search_schemes", []) + for track_mbid in playlist_data.track_mbids: query = self.library.search( - {"musicbrainz_trackid": [track_mbid]}, uris=["local:"] + {"musicbrainz_trackid": [track_mbid]}, uris=search_schemes ) # search only in local database since other backends can # be quite long to answer, should we offer choice through From e16a34e81b7f9a1bfbcec0456b46fa87fe87d727 Mon Sep 17 00:00:00 2001 From: Matthias Meulien Date: Mon, 13 May 2024 23:34:55 +0200 Subject: [PATCH 11/15] Fix format --- mopidy_listenbrainz/frontend.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/mopidy_listenbrainz/frontend.py b/mopidy_listenbrainz/frontend.py index a6b7b19..9084add 100644 --- a/mopidy_listenbrainz/frontend.py +++ b/mopidy_listenbrainz/frontend.py @@ -32,9 +32,13 @@ def on_start(self): logger.debug("Listenbrainz token valid!") if self.config["listenbrainz"].get("import_playlists", False): - search_schemes = self.config["listenbrainz"].get("search_schemes", []) + search_schemes = self.config["listenbrainz"].get( + "search_schemes", [] + ) if len(search_schemes) > 0: - logger.debug(f"Will limit track searches to URIs: {search_schemes}") + logger.debug( + f"Will limit track searches to URIs: {search_schemes}" + ) else: logger.debug("Will search tracks among all backends") @@ -144,12 +148,8 @@ def _collect_playlist_tracks( def _schedule_playlists_import(self): now = datetime.now() days_until_next_monday = 7 - now.weekday() - timer_interval = timedelta( - days=days_until_next_monday - ).total_seconds() - logger.debug( - f"Playlist update scheduled in {timer_interval} seconds" - ) + timer_interval = timedelta(days=days_until_next_monday).total_seconds() + logger.debug(f"Playlist update scheduled in {timer_interval} seconds") self.playlists_update_timer = Timer( timer_interval, self.import_playlists ) From 930d1751f72558a2244f124bf7059af167382f27 Mon Sep 17 00:00:00 2001 From: Matthias Meulien Date: Mon, 13 May 2024 23:40:52 +0200 Subject: [PATCH 12/15] Remove obsolete comment --- mopidy_listenbrainz/frontend.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/mopidy_listenbrainz/frontend.py b/mopidy_listenbrainz/frontend.py index 9084add..3adc6c9 100644 --- a/mopidy_listenbrainz/frontend.py +++ b/mopidy_listenbrainz/frontend.py @@ -133,9 +133,6 @@ def _collect_playlist_tracks( query = self.library.search( {"musicbrainz_trackid": [track_mbid]}, uris=search_schemes ) - # search only in local database since other backends can - # be quite long to answer, should we offer choice through - # config? results = query.get() found_tracks = [t for r in results for t in r.tracks] From e2f28322c42ae183a24427f530a685221e832d6b Mon Sep 17 00:00:00 2001 From: Matthias Meulien Date: Tue, 14 May 2024 22:46:19 +0200 Subject: [PATCH 13/15] Change default value of search_schemes --- README.rst | 2 +- mopidy_listenbrainz/ext.conf | 2 +- mopidy_listenbrainz/frontend.py | 7 +++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.rst b/README.rst index 9494ba9..104d545 100644 --- a/README.rst +++ b/README.rst @@ -46,7 +46,7 @@ The following configuration values are available: - ``listenbrainz/token``: Your `Listenbrainz user token `_ - ``listenbrainz/url``: The URL of the API of the Listenbrainz instance to record listens to (default: api.listenbrainz.org) - ``listenbrainz/import_playlists``: Whether to import Listenbrainz playlists (default: ``false``) -- ``listenbrainz/search_schemes``: If non empty, the search for tracks in Mopidy's library is limited to results with the given schemes. Leave empty (the default) to search among all backends, but note that it may affect performance; It's recommended to customize the value according to your favorite backends (``file:`` for Mopidy-File, ``local:`` for Mopidy-Local, etc.). +- ``listenbrainz/search_schemes``: If non empty, the search for tracks in Mopidy's library is limited to results with the given schemes. The default value is ``"local:"`` to search tracks in Mopidy-Local library. It's recommended to customize the value according to your favorite backend but beware that not all backends support the required track search by ``musicbrainz_trackid`` (Mopidy-File, Mopidy-InternetArchive, Mopidy-Podcast, Mopidy-Somafm, Mopidy-Stream don't support such searches). Project resources ================= diff --git a/mopidy_listenbrainz/ext.conf b/mopidy_listenbrainz/ext.conf index 05c53f0..84f21c5 100644 --- a/mopidy_listenbrainz/ext.conf +++ b/mopidy_listenbrainz/ext.conf @@ -3,4 +3,4 @@ enabled = true token = url = api.listenbrainz.org import_playlists = false -search_schemes = +search_schemes = "local:" diff --git a/mopidy_listenbrainz/frontend.py b/mopidy_listenbrainz/frontend.py index 3adc6c9..a7def96 100644 --- a/mopidy_listenbrainz/frontend.py +++ b/mopidy_listenbrainz/frontend.py @@ -33,14 +33,17 @@ def on_start(self): if self.config["listenbrainz"].get("import_playlists", False): search_schemes = self.config["listenbrainz"].get( - "search_schemes", [] + "search_schemes", "local:" ) if len(search_schemes) > 0: logger.debug( f"Will limit track searches to URIs: {search_schemes}" ) else: - logger.debug("Will search tracks among all backends") + msg = "Track searches among all backends aren't stable! " \ + "Better configure `search_schemes' to match your " \ + "favorite backend" + logger.warn(msg) self.import_playlists() From a8412cab78a5eb416edfeefc9fd6354745ea0cb5 Mon Sep 17 00:00:00 2001 From: Matthias Meulien Date: Thu, 16 May 2024 07:26:30 +0200 Subject: [PATCH 14/15] Apply suggestions from code review Co-authored-by: Max Carr --- mopidy_listenbrainz/ext.conf | 2 +- mopidy_listenbrainz/frontend.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/mopidy_listenbrainz/ext.conf b/mopidy_listenbrainz/ext.conf index 84f21c5..088394a 100644 --- a/mopidy_listenbrainz/ext.conf +++ b/mopidy_listenbrainz/ext.conf @@ -3,4 +3,4 @@ enabled = true token = url = api.listenbrainz.org import_playlists = false -search_schemes = "local:" +search_schemes = local: diff --git a/mopidy_listenbrainz/frontend.py b/mopidy_listenbrainz/frontend.py index a7def96..10d6ff8 100644 --- a/mopidy_listenbrainz/frontend.py +++ b/mopidy_listenbrainz/frontend.py @@ -33,7 +33,7 @@ def on_start(self): if self.config["listenbrainz"].get("import_playlists", False): search_schemes = self.config["listenbrainz"].get( - "search_schemes", "local:" + "search_schemes", ["local:"] ) if len(search_schemes) > 0: logger.debug( @@ -130,7 +130,7 @@ def _collect_playlist_tracks( self, playlist_data: PlaylistData ) -> List[Track]: tracks: List[Track] = [] - search_schemes = self.config["listenbrainz"].get("search_schemes", []) + search_schemes = self.config["listenbrainz"].get("search_schemes", ["local:"]) for track_mbid in playlist_data.track_mbids: query = self.library.search( From 21ea159cf6882de3a06073bf09f661e8ace6a4af Mon Sep 17 00:00:00 2001 From: Matthias Meulien Date: Thu, 16 May 2024 07:31:35 +0200 Subject: [PATCH 15/15] Update to credits section --- README.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/README.rst b/README.rst index 104d545..1c4a503 100644 --- a/README.rst +++ b/README.rst @@ -61,4 +61,5 @@ Credits - Fork of `Mopidy-Scrobbler `__ by `Stein Magnus Jodal `__ - Current maintainer: `suaviloquence `__ +- Playlist support by `Matthias Meulien `__ - `Contributors `_