Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add initial version of the Listenbrainz plugin #5058

Merged
merged 73 commits into from
Mar 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
d4cb8ff
Create listenbrainz.py
arsaboo Dec 19, 2023
2c25076
Update listenbrainz.py
arsaboo Dec 19, 2023
619fb17
Update listenbrainz.py
arsaboo Dec 19, 2023
7d6c123
Update listenbrainz.py
arsaboo Dec 19, 2023
969ff61
Update listenbrainz.py
arsaboo Dec 19, 2023
0d56ec7
Update listenbrainz.py
arsaboo Dec 19, 2023
b12a59e
Update listenbrainz.py
arsaboo Dec 19, 2023
4afd992
Update listenbrainz.py
arsaboo Dec 19, 2023
444fd2e
Update listenbrainz.py
arsaboo Dec 19, 2023
a2428f4
Update listenbrainz.py
arsaboo Dec 19, 2023
658d1d7
Update listenbrainz.py
arsaboo Dec 19, 2023
cb58f32
Update listenbrainz.py
arsaboo Dec 19, 2023
ed98370
Update listenbrainz.py
arsaboo Dec 19, 2023
549827a
Update listenbrainz.py
arsaboo Dec 19, 2023
393ff0c
Update listenbrainz.py
arsaboo Dec 19, 2023
eeb4c4b
Update listenbrainz.py
arsaboo Dec 19, 2023
69a0ce6
Update listenbrainz.py
arsaboo Dec 19, 2023
2be00a4
Update listenbrainz.py
arsaboo Dec 19, 2023
04dc0f6
Update listenbrainz.py
arsaboo Dec 19, 2023
702570f
Update listenbrainz.py
arsaboo Dec 19, 2023
7cae5eb
Update listenbrainz.py
arsaboo Dec 19, 2023
c99cd85
Update listenbrainz.py
arsaboo Dec 19, 2023
03888fa
Update listenbrainz.py
arsaboo Dec 19, 2023
240faaa
Update listenbrainz.py
arsaboo Dec 19, 2023
6a94276
Update listenbrainz.py
arsaboo Dec 19, 2023
cd4e44e
Update listenbrainz.py
arsaboo Dec 19, 2023
e884d78
Update listenbrainz.py
arsaboo Dec 19, 2023
efcb549
Update listenbrainz.py
arsaboo Dec 19, 2023
cac7f7d
Update listenbrainz.py
arsaboo Dec 19, 2023
92bb858
Update listenbrainz.py
arsaboo Dec 19, 2023
f3d8655
Update listenbrainz.py
arsaboo Dec 19, 2023
3000664
Update listenbrainz.py
arsaboo Dec 19, 2023
1406395
Update listenbrainz.py
arsaboo Dec 19, 2023
9e7b709
Update listenbrainz.py
arsaboo Dec 19, 2023
af55c1e
Update listenbrainz.py
arsaboo Dec 19, 2023
a35d764
Update listenbrainz.py
arsaboo Dec 19, 2023
f376c21
Update listenbrainz.py
arsaboo Dec 19, 2023
a0b41e8
Update listenbrainz.py
arsaboo Dec 19, 2023
b39779d
Update listenbrainz.py
arsaboo Dec 19, 2023
d17d146
Update listenbrainz.py
arsaboo Dec 19, 2023
f5735f6
Update listenbrainz.py
arsaboo Dec 19, 2023
84da424
Update listenbrainz.py
arsaboo Dec 19, 2023
4a46769
Update listenbrainz.py
arsaboo Dec 19, 2023
aa117bb
Update listenbrainz.py
arsaboo Dec 20, 2023
fc9e68e
Update listenbrainz.py
arsaboo Dec 20, 2023
3ead377
Update listenbrainz.py
arsaboo Dec 20, 2023
6d44c6a
Update listenbrainz.py
arsaboo Dec 20, 2023
272c7c3
Allow handling of None
arsaboo Dec 20, 2023
d7823a0
Update lastimport.py
arsaboo Dec 20, 2023
c437a55
Update lastimport.py
arsaboo Dec 20, 2023
135faac
Update lastimport.py
arsaboo Dec 20, 2023
5e4cb20
Update lastimport.py
arsaboo Dec 20, 2023
ec3711f
Update lastimport.py
arsaboo Dec 20, 2023
d83a07d
Update listenbrainz.py
arsaboo Dec 20, 2023
c445e5e
Update listenbrainz.py
arsaboo Dec 20, 2023
75deae5
Update lastimport.py
arsaboo Dec 20, 2023
b010fb5
Update listenbrainz.py
arsaboo Dec 20, 2023
7f4da6e
Update changelog.rst
arsaboo Dec 20, 2023
4f66897
Update listenbrainz.py
arsaboo Dec 20, 2023
e8dc2cb
Sort imports
arsaboo Dec 21, 2023
4541644
Updated docs
arsaboo Dec 21, 2023
0ed6556
Update listenbrainz.py
arsaboo Dec 21, 2023
d5a2379
Add listenbrainz to index
arsaboo Dec 21, 2023
2eb8000
Update docstrings
arsaboo Dec 21, 2023
7440ca5
Error handling
arsaboo Dec 22, 2023
71a6a4f
Formatting
arsaboo Dec 22, 2023
537b57d
Make sure only Jams and Exploration playlists are added.
arsaboo Dec 22, 2023
47584f2
Formatting
arsaboo Dec 22, 2023
a885918
Add lastimport changelog
arsaboo Dec 23, 2023
9b8dbe8
Add logging
arsaboo Dec 23, 2023
6bfe266
Merge remote-tracking branch 'upstream/master' into lb
arsaboo Dec 23, 2023
7838e70
Revert "Merge remote-tracking branch 'upstream/master' into lb"
arsaboo Dec 23, 2023
ce023a3
Revert unwanted commits
arsaboo Dec 25, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading