mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 16:42:42 +01:00
Merge pull request #5058 from arsaboo/lb
Add initial version of the Listenbrainz plugin
This commit is contained in:
commit
35e8eb985f
5 changed files with 327 additions and 5 deletions
|
|
@ -204,12 +204,20 @@ def process_tracks(lib, tracks, log):
|
||||||
|
|
||||||
for num in range(0, total):
|
for num in range(0, total):
|
||||||
song = None
|
song = None
|
||||||
trackid = tracks[num]["mbid"].strip()
|
trackid = tracks[num]["mbid"].strip() if tracks[num]["mbid"] else None
|
||||||
artist = tracks[num]["artist"].get("name", "").strip()
|
artist = (
|
||||||
title = tracks[num]["name"].strip()
|
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 = ""
|
album = ""
|
||||||
if "album" in tracks[num]:
|
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)
|
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)
|
dbcore.query.MatchQuery("mb_trackid", trackid)
|
||||||
).get()
|
).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 not, try just artist/title
|
||||||
if song is None:
|
if song is None:
|
||||||
log.debug("no album match, trying by artist/title")
|
log.debug("no album match, trying by artist/title")
|
||||||
|
|
@ -244,7 +265,7 @@ def process_tracks(lib, tracks, log):
|
||||||
|
|
||||||
if song is not None:
|
if song is not None:
|
||||||
count = int(song.get("play_count", 0))
|
count = int(song.get("play_count", 0))
|
||||||
new_count = int(tracks[num]["playcount"])
|
new_count = int(tracks[num].get("playcount", 1))
|
||||||
log.debug(
|
log.debug(
|
||||||
"match: {0} - {1} ({2}) " "updating: play_count {3} => {4}",
|
"match: {0} - {1} ({2}) " "updating: play_count {3} => {4}",
|
||||||
song.artist,
|
song.artist,
|
||||||
|
|
|
||||||
266
beetsplug/listenbrainz.py
Normal file
266
beetsplug/listenbrainz.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -17,6 +17,8 @@ Major new features:
|
||||||
|
|
||||||
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/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.
|
* :doc:`plugins/spotify`: We now fetch track's ISRC, EAN, and UPC identifiers from Spotify when using the ``spotifysync`` command.
|
||||||
:bug:`4992`
|
:bug:`4992`
|
||||||
|
|
@ -156,6 +158,7 @@ New features:
|
||||||
|
|
||||||
Bug fixes:
|
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/spotify`: Improve handling of ConnectionError.
|
||||||
* :doc:`/plugins/deezer`: Improve Deezer plugin error handling and set requests timeout to 10 seconds.
|
* :doc:`/plugins/deezer`: Improve Deezer plugin error handling and set requests timeout to 10 seconds.
|
||||||
:bug:`4983`
|
:bug:`4983`
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,7 @@ following to your configuration::
|
||||||
lastgenre
|
lastgenre
|
||||||
lastimport
|
lastimport
|
||||||
limit
|
limit
|
||||||
|
listenbrainz
|
||||||
loadext
|
loadext
|
||||||
lyrics
|
lyrics
|
||||||
mbcollection
|
mbcollection
|
||||||
|
|
|
||||||
31
docs/plugins/listenbrainz.rst
Normal file
31
docs/plugins/listenbrainz.rst
Normal file
|
|
@ -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
|
||||||
Loading…
Reference in a new issue