diff --git a/beetsplug/lastimport.py b/beetsplug/lastimport.py index 12a98764ad..f59205b991 100644 --- a/beetsplug/lastimport.py +++ b/beetsplug/lastimport.py @@ -204,12 +204,20 @@ def process_tracks(lib, tracks, log): for num in range(0, total): song = None - trackid = tracks[num]["mbid"].strip() - artist = tracks[num]["artist"].get("name", "").strip() - title = tracks[num]["name"].strip() + trackid = tracks[num]["mbid"].strip() if tracks[num]["mbid"] else None + artist = ( + tracks[num]["artist"].get("name", "").strip() + if tracks[num]["artist"].get("name", "") + else None + ) + title = tracks[num]["name"].strip() if tracks[num]["name"] else None album = "" if "album" in tracks[num]: - album = tracks[num]["album"].get("name", "").strip() + album = ( + tracks[num]["album"].get("name", "").strip() + if tracks[num]["album"] + else None + ) log.debug("query: {0} - {1} ({2})", artist, title, album) @@ -219,6 +227,19 @@ def process_tracks(lib, tracks, log): dbcore.query.MatchQuery("mb_trackid", trackid) ).get() + # If not, try just album/title + if song is None: + log.debug( + "no album match, trying by album/title: {0} - {1}", album, title + ) + query = dbcore.AndQuery( + [ + dbcore.query.SubstringQuery("album", album), + dbcore.query.SubstringQuery("title", title), + ] + ) + song = lib.items(query).get() + # If not, try just artist/title if song is None: log.debug("no album match, trying by artist/title") @@ -244,7 +265,7 @@ def process_tracks(lib, tracks, log): if song is not None: count = int(song.get("play_count", 0)) - new_count = int(tracks[num]["playcount"]) + new_count = int(tracks[num].get("playcount", 1)) log.debug( "match: {0} - {1} ({2}) " "updating: play_count {3} => {4}", song.artist, diff --git a/beetsplug/listenbrainz.py b/beetsplug/listenbrainz.py new file mode 100644 index 0000000000..4855481f86 --- /dev/null +++ b/beetsplug/listenbrainz.py @@ -0,0 +1,266 @@ +"""Adds Listenbrainz support to Beets.""" + +import datetime + +import musicbrainzngs +import requests + +from beets import config, ui +from beets.plugins import BeetsPlugin +from beetsplug.lastimport import process_tracks + + +class ListenBrainzPlugin(BeetsPlugin): + """A Beets plugin for interacting with ListenBrainz.""" + + data_source = "ListenBrainz" + ROOT = "http://api.listenbrainz.org/1/" + + def __init__(self): + """Initialize the plugin.""" + super().__init__() + self.token = self.config["token"].get() + self.username = self.config["username"].get() + self.AUTH_HEADER = {"Authorization": f"Token {self.token}"} + config["listenbrainz"]["token"].redact = True + + def commands(self): + """Add beet UI commands to interact with ListenBrainz.""" + lbupdate_cmd = ui.Subcommand( + "lbimport", help=f"Import {self.data_source} history" + ) + + def func(lib, opts, args): + self._lbupdate(lib, self._log) + + lbupdate_cmd.func = func + return [lbupdate_cmd] + + def _lbupdate(self, lib, log): + """Obtain view count from Listenbrainz.""" + found_total = 0 + unknown_total = 0 + ls = self.get_listens() + tracks = self.get_tracks_from_listens(ls) + log.info(f"Found {len(ls)} listens") + if tracks: + found, unknown = process_tracks(lib, tracks, log) + found_total += found + unknown_total += unknown + log.info("... done!") + log.info("{0} unknown play-counts", unknown_total) + log.info("{0} play-counts imported", found_total) + + def _make_request(self, url, params=None): + """Makes a request to the ListenBrainz API.""" + try: + response = requests.get( + url=url, + headers=self.AUTH_HEADER, + timeout=10, + params=params, + ) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + self._log.debug(f"Invalid Search Error: {e}") + return None + + def get_listens(self, min_ts=None, max_ts=None, count=None): + """Gets the listen history of a given user. + + Args: + username: User to get listen history of. + min_ts: History before this timestamp will not be returned. + DO NOT USE WITH max_ts. + max_ts: History after this timestamp will not be returned. + DO NOT USE WITH min_ts. + count: How many listens to return. If not specified, + uses a default from the server. + + Returns: + A list of listen info dictionaries if there's an OK status. + + Raises: + An HTTPError if there's a failure. + A ValueError if the JSON in the response is invalid. + An IndexError if the JSON is not structured as expected. + """ + url = f"{self.ROOT}/user/{self.username}/listens" + params = { + k: v + for k, v in { + "min_ts": min_ts, + "max_ts": max_ts, + "count": count, + }.items() + if v is not None + } + response = self._make_request(url, params) + + if response is not None: + return response["payload"]["listens"] + else: + return None + + def get_tracks_from_listens(self, listens): + """Returns a list of tracks from a list of listens.""" + tracks = [] + for track in listens: + if track["track_metadata"].get("release_name") is None: + continue + mbid_mapping = track["track_metadata"].get("mbid_mapping", {}) + # print(json.dumps(track, indent=4, sort_keys=True)) + if mbid_mapping.get("recording_mbid") is None: + # search for the track using title and release + mbid = self.get_mb_recording_id(track) + tracks.append( + { + "album": { + "name": track["track_metadata"].get("release_name") + }, + "name": track["track_metadata"].get("track_name"), + "artist": { + "name": track["track_metadata"].get("artist_name") + }, + "mbid": mbid, + "release_mbid": mbid_mapping.get("release_mbid"), + "listened_at": track.get("listened_at"), + } + ) + return tracks + + def get_mb_recording_id(self, track): + """Returns the MusicBrainz recording ID for a track.""" + resp = musicbrainzngs.search_recordings( + query=track["track_metadata"].get("track_name"), + release=track["track_metadata"].get("release_name"), + strict=True, + ) + if resp.get("recording-count") == "1": + return resp.get("recording-list")[0].get("id") + else: + return None + + def get_playlists_createdfor(self, username): + """Returns a list of playlists created by a user.""" + url = f"{self.ROOT}/user/{username}/playlists/createdfor" + return self._make_request(url) + + def get_listenbrainz_playlists(self): + """Returns a list of playlists created by ListenBrainz.""" + import re + + resp = self.get_playlists_createdfor(self.username) + playlists = resp.get("playlists") + listenbrainz_playlists = [] + + for playlist in playlists: + playlist_info = playlist.get("playlist") + if playlist_info.get("creator") == "listenbrainz": + title = playlist_info.get("title") + match = re.search( + r"(Missed Recordings of \d{4}|Discoveries of \d{4})", title + ) + if "Exploration" in title: + playlist_type = "Exploration" + elif "Jams" in title: + playlist_type = "Jams" + elif match: + playlist_type = match.group(1) + else: + playlist_type = None + if "week of " in title: + date_str = title.split("week of ")[1].split(" ")[0] + date = datetime.datetime.strptime( + date_str, "%Y-%m-%d" + ).date() + else: + date = None + identifier = playlist_info.get("identifier") + id = identifier.split("/")[-1] + if playlist_type in ["Jams", "Exploration"]: + listenbrainz_playlists.append( + { + "type": playlist_type, + "date": date, + "identifier": id, + "title": title, + } + ) + return listenbrainz_playlists + + def get_playlist(self, identifier): + """Returns a playlist.""" + url = f"{self.ROOT}/playlist/{identifier}" + return self._make_request(url) + + def get_tracks_from_playlist(self, playlist): + """This function returns a list of tracks in the playlist.""" + tracks = [] + for track in playlist.get("playlist").get("track"): + tracks.append( + { + "artist": track.get("creator"), + "identifier": track.get("identifier").split("/")[-1], + "title": track.get("title"), + } + ) + return self.get_track_info(tracks) + + def get_track_info(self, tracks): + """Returns a list of track info.""" + track_info = [] + for track in tracks: + identifier = track.get("identifier") + resp = musicbrainzngs.get_recording_by_id( + identifier, includes=["releases", "artist-credits"] + ) + recording = resp.get("recording") + title = recording.get("title") + artist_credit = recording.get("artist-credit", []) + if artist_credit: + artist = artist_credit[0].get("artist", {}).get("name") + else: + artist = None + releases = recording.get("release-list", []) + if releases: + album = releases[0].get("title") + date = releases[0].get("date") + year = date.split("-")[0] if date else None + else: + album = None + year = None + track_info.append( + { + "identifier": identifier, + "title": title, + "artist": artist, + "album": album, + "year": year, + } + ) + return track_info + + def get_weekly_playlist(self, index): + """Returns a list of weekly playlists based on the index.""" + playlists = self.get_listenbrainz_playlists() + playlist = self.get_playlist(playlists[index].get("identifier")) + self._log.info(f"Getting {playlist.get('playlist').get('title')}") + return self.get_tracks_from_playlist(playlist) + + def get_weekly_exploration(self): + """Returns a list of weekly exploration.""" + return self.get_weekly_playlist(0) + + def get_weekly_jams(self): + """Returns a list of weekly jams.""" + return self.get_weekly_playlist(1) + + def get_last_weekly_exploration(self): + """Returns a list of weekly exploration.""" + return self.get_weekly_playlist(3) + + def get_last_weekly_jams(self): + """Returns a list of weekly jams.""" + return self.get_weekly_playlist(3) diff --git a/docs/changelog.rst b/docs/changelog.rst index b3b63f1c2d..de629dfc23 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -17,6 +17,8 @@ Major new features: New features: +* :doc:`/plugins/listenbrainz`: Add initial support for importing history and playlists from `ListenBrainz` + :bug:`1719` * :doc:`plugins/mbsubmit`: add new prompt choices helping further to submit unmatched tracks to MusicBrainz faster. * :doc:`plugins/spotify`: We now fetch track's ISRC, EAN, and UPC identifiers from Spotify when using the ``spotifysync`` command. :bug:`4992` @@ -156,6 +158,7 @@ New features: Bug fixes: +* :doc:`/plugins/lastimport`: Improve error handling in the `process_tracks` function and enable it to be used with other plugins. * :doc:`/plugins/spotify`: Improve handling of ConnectionError. * :doc:`/plugins/deezer`: Improve Deezer plugin error handling and set requests timeout to 10 seconds. :bug:`4983` diff --git a/docs/plugins/index.rst b/docs/plugins/index.rst index 98d322442c..0da487b032 100644 --- a/docs/plugins/index.rst +++ b/docs/plugins/index.rst @@ -101,6 +101,7 @@ following to your configuration:: lastgenre lastimport limit + listenbrainz loadext lyrics mbcollection diff --git a/docs/plugins/listenbrainz.rst b/docs/plugins/listenbrainz.rst new file mode 100644 index 0000000000..1be15ae670 --- /dev/null +++ b/docs/plugins/listenbrainz.rst @@ -0,0 +1,31 @@ +.. _listenbrainz: + +ListenBrainz Plugin +=================== + +The ListenBrainz plugin for beets allows you to interact with the ListenBrainz service. + +Installation +------------ + +To enable the ListenBrainz plugin, add the following to your beets configuration file (`config.yaml`): + +.. code-block:: yaml + + plugins: + - listenbrainz + +You can then configure the plugin by providing your Listenbrainz token (see intructions `here`_`)and username:: + + listenbrainz: + token: TOKEN + username: LISTENBRAINZ_USERNAME + + +Usage +----- + +Once the plugin is enabled, you can import the listening history using the `lbimport` command in beets. + + +.. _here: https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#get-the-user-token \ No newline at end of file