Skip to content

Commit

Permalink
Merge pull request #5058 from arsaboo/lb
Browse files Browse the repository at this point in the history
Add initial version of the Listenbrainz plugin
  • Loading branch information
Serene-Arc authored Mar 1, 2024
2 parents 5317d44 + ce023a3 commit 35e8eb9
Show file tree
Hide file tree
Showing 5 changed files with 327 additions and 5 deletions.
31 changes: 26 additions & 5 deletions beetsplug/lastimport.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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")
Expand All @@ -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,
Expand Down
266 changes: 266 additions & 0 deletions beetsplug/listenbrainz.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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`
Expand Down
1 change: 1 addition & 0 deletions docs/plugins/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ following to your configuration::
lastgenre
lastimport
limit
listenbrainz
loadext
lyrics
mbcollection
Expand Down
31 changes: 31 additions & 0 deletions docs/plugins/listenbrainz.rst
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 35e8eb9

Please sign in to comment.