mirror of
https://github.com/beetbox/beets.git
synced 2025-12-06 08:39:17 +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):
|
||||
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,
|
||||
|
|
|
|||
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:
|
||||
|
||||
* :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`
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ following to your configuration::
|
|||
lastgenre
|
||||
lastimport
|
||||
limit
|
||||
listenbrainz
|
||||
loadext
|
||||
lyrics
|
||||
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