From bca90c97aeb4df24d64c09123d3678a9ef77020a Mon Sep 17 00:00:00 2001
From: Colin Powell
Date: Sun, 31 May 2026 11:21:11 -0400
Subject: [PATCH] [music] Add listenbrainz support
---
vrobbler/apps/music/listenbrainz.py | 90 +++++++++++++
.../commands/lookup_similar_artists.py | 123 +++++++++++++++++
.../commands/lookup_similar_recordings.py | 125 ++++++++++++++++++
.../0035_track_similar_recordings.py | 18 +++
.../migrations/0036_artist_similar_artists.py | 18 +++
vrobbler/apps/music/models.py | 2 +
vrobbler/apps/music/views.py | 39 +++++-
vrobbler/templates/music/artist_detail.html | 29 ++++
vrobbler/templates/music/track_detail.html | 23 ++++
9 files changed, 465 insertions(+), 2 deletions(-)
create mode 100644 vrobbler/apps/music/listenbrainz.py
create mode 100644 vrobbler/apps/music/management/commands/lookup_similar_artists.py
create mode 100644 vrobbler/apps/music/management/commands/lookup_similar_recordings.py
create mode 100644 vrobbler/apps/music/migrations/0035_track_similar_recordings.py
create mode 100644 vrobbler/apps/music/migrations/0036_artist_similar_artists.py
diff --git a/vrobbler/apps/music/listenbrainz.py b/vrobbler/apps/music/listenbrainz.py
new file mode 100644
index 0000000..1b5831a
--- /dev/null
+++ b/vrobbler/apps/music/listenbrainz.py
@@ -0,0 +1,90 @@
+import logging
+
+import requests
+
+logger = logging.getLogger(__name__)
+
+LB_LABS_API = "https://labs.api.listenbrainz.org"
+STANDARD_ALGORITHM = "session_based_days_180_session_300_contribution_5_threshold_15_limit_50_skip_30"
+MLHD_ALGORITHM = "session_based_mlhd_session_300_contribution_5_threshold_15_limit_50_skip_30"
+SIMILAR_ARTISTS_ALGORITHM = "session_based_days_1825_session_300_contribution_3_threshold_10_limit_100_filter_True_skip_30"
+
+
+def get_similar_recordings(recording_mbid: str) -> list[dict]:
+ """Fetch similar recordings from ListenBrainz.
+
+ Tries the standard dataset first, falls back to MLHD dataset.
+ Returns [] on error or no data.
+ """
+ if not recording_mbid:
+ return []
+
+ try:
+ resp = requests.post(
+ f"{LB_LABS_API}/similar-recordings/json",
+ json=[
+ {
+ "recording_mbids": [recording_mbid],
+ "algorithm": STANDARD_ALGORITHM,
+ }
+ ],
+ timeout=10,
+ )
+ resp.raise_for_status()
+ data = resp.json()
+ if data:
+ return data
+
+ resp = requests.post(
+ f"{LB_LABS_API}/mlhd-similar-recordings/json",
+ json=[
+ {
+ "recording_mbids": [recording_mbid],
+ "algorithm": MLHD_ALGORITHM,
+ }
+ ],
+ timeout=10,
+ )
+ resp.raise_for_status()
+ return resp.json()
+ except requests.RequestException as e:
+ logger.warning(
+ "ListenBrainz similar recordings error",
+ extra={
+ "recording_mbid": recording_mbid,
+ "error": str(e),
+ },
+ )
+ return []
+
+
+def get_similar_artists(artist_mbid: str) -> list[dict]:
+ """Fetch similar artists from ListenBrainz.
+
+ Returns [] on error or no data.
+ """
+ if not artist_mbid:
+ return []
+
+ try:
+ resp = requests.post(
+ f"{LB_LABS_API}/similar-artists/json",
+ json=[
+ {
+ "artist_mbids": [artist_mbid],
+ "algorithm": SIMILAR_ARTISTS_ALGORITHM,
+ }
+ ],
+ timeout=10,
+ )
+ resp.raise_for_status()
+ return resp.json()
+ except requests.RequestException as e:
+ logger.warning(
+ "ListenBrainz similar artists error",
+ extra={
+ "artist_mbid": artist_mbid,
+ "error": str(e),
+ },
+ )
+ return []
diff --git a/vrobbler/apps/music/management/commands/lookup_similar_artists.py b/vrobbler/apps/music/management/commands/lookup_similar_artists.py
new file mode 100644
index 0000000..98964dc
--- /dev/null
+++ b/vrobbler/apps/music/management/commands/lookup_similar_artists.py
@@ -0,0 +1,123 @@
+import logging
+import time
+
+from django.core.management.base import BaseCommand
+
+from music.listenbrainz import get_similar_artists
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ help = "Fetch similar artists from ListenBrainz for artists with a musicbrainz_id"
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--commit",
+ action="store_true",
+ help="Commit changes to the database",
+ )
+ parser.add_argument(
+ "--batch-size",
+ type=int,
+ default=100,
+ help="Number of artists to process per batch (default: 100)",
+ )
+ parser.add_argument(
+ "--artist-id",
+ type=str,
+ default="",
+ help="Only process the artist with this musicbrainz_id",
+ )
+ parser.add_argument(
+ "--overwrite",
+ action="store_true",
+ help="Re-fetch similar artists even if already populated",
+ )
+
+ def handle(self, *args, **options):
+ from music.models import Artist
+
+ commit = options["commit"]
+ batch_size = options["batch_size"]
+ artist_id = options["artist_id"]
+ overwrite = options["overwrite"]
+
+ if not commit:
+ self.stdout.write(
+ "Dry run — no changes will be saved. Use --commit to apply."
+ )
+
+ qs = Artist.objects.exclude(musicbrainz_id__isnull=True).exclude(
+ musicbrainz_id=""
+ )
+
+ if artist_id:
+ qs = qs.filter(musicbrainz_id=artist_id)
+ self.stdout.write(f"Filtering to artist with musicbrainz_id: {artist_id}")
+ elif not overwrite:
+ qs = qs.filter(similar_artists__isnull=True)
+ else:
+ qs = qs.all()
+
+ total = qs.count()
+ if total == 0:
+ self.stdout.write("No artists to process.")
+ return
+
+ self.stdout.write(
+ f"Found {total} artists with musicbrainz_id"
+ + (" (overwrite mode)" if overwrite else "")
+ + (" (--artist-id filter)" if artist_id else "")
+ )
+
+ if not commit:
+ self.stdout.write(
+ "\nSkipping API lookups in dry-run mode. Use --commit to run against ListenBrainz."
+ )
+ return
+
+ found_count = 0
+ empty_count = 0
+ error_count = 0
+ artist_ids = list(qs.values_list("pk", flat=True))
+ i = 0
+
+ for batch_num, offset in enumerate(
+ range(0, len(artist_ids), batch_size)
+ ):
+ batch_pks = artist_ids[offset : offset + batch_size]
+ for artist in Artist.objects.filter(pk__in=batch_pks).iterator():
+ i += 1
+ self.stdout.write(
+ f" [{i}/{total}] Fetching similar artists for {artist}...",
+ ending="",
+ )
+ try:
+ similar = get_similar_artists(artist.musicbrainz_id)
+ if similar:
+ artist.similar_artists = similar
+ artist.save(update_fields=["similar_artists"])
+ self.stdout.write(f" {len(similar)} similar artists found")
+ found_count += 1
+ else:
+ artist.similar_artists = []
+ artist.save(update_fields=["similar_artists"])
+ self.stdout.write(" none found")
+ empty_count += 1
+ except Exception as e:
+ self.stdout.write(f" error: {e}")
+ error_count += 1
+
+ self.stdout.write(
+ f" Batch {batch_num + 1}: {offset + len(batch_pks)}/{total} processed, "
+ f"found: {found_count}, empty: {empty_count}, errors: {error_count}"
+ )
+ time.sleep(1)
+
+ self.stdout.write(
+ f"\nResults (commit={commit}):\n"
+ f" Similar artists found: {found_count}\n"
+ f" No similar artists: {empty_count}\n"
+ f" Errors: {error_count}"
+ )
diff --git a/vrobbler/apps/music/management/commands/lookup_similar_recordings.py b/vrobbler/apps/music/management/commands/lookup_similar_recordings.py
new file mode 100644
index 0000000..d42fb44
--- /dev/null
+++ b/vrobbler/apps/music/management/commands/lookup_similar_recordings.py
@@ -0,0 +1,125 @@
+import logging
+import time
+
+from django.core.management.base import BaseCommand
+
+from music.listenbrainz import get_similar_recordings
+
+logger = logging.getLogger(__name__)
+
+
+class Command(BaseCommand):
+ help = "Fetch similar recordings from ListenBrainz for tracks with a musicbrainz_id"
+
+ def add_arguments(self, parser):
+ parser.add_argument(
+ "--commit",
+ action="store_true",
+ help="Commit changes to the database",
+ )
+ parser.add_argument(
+ "--batch-size",
+ type=int,
+ default=100,
+ help="Number of tracks to process per batch (default: 100)",
+ )
+ parser.add_argument(
+ "--track-id",
+ type=str,
+ default="",
+ help="Only process the track with this musicbrainz_id",
+ )
+ parser.add_argument(
+ "--overwrite",
+ action="store_true",
+ help="Re-fetch similar recordings even if already populated",
+ )
+
+ def handle(self, *args, **options):
+ from music.models import Track
+
+ commit = options["commit"]
+ batch_size = options["batch_size"]
+ track_id = options["track_id"]
+ overwrite = options["overwrite"]
+
+ if not commit:
+ self.stdout.write(
+ "Dry run — no changes will be saved. Use --commit to apply."
+ )
+
+ qs = Track.objects.exclude(musicbrainz_id__isnull=True).exclude(
+ musicbrainz_id=""
+ )
+
+ if track_id:
+ qs = qs.filter(musicbrainz_id=track_id)
+ self.stdout.write(f"Filtering to track with musicbrainz_id: {track_id}")
+ elif not overwrite:
+ qs = qs.filter(similar_recordings__isnull=True)
+ else:
+ qs = qs.all()
+
+ total = qs.count()
+ if total == 0:
+ self.stdout.write("No tracks to process.")
+ return
+
+ self.stdout.write(
+ f"Found {total} tracks with musicbrainz_id"
+ + (" (overwrite mode)" if overwrite else "")
+ + (" (--track-id filter)" if track_id else "")
+ )
+
+ if not commit:
+ self.stdout.write(
+ "\nSkipping API lookups in dry-run mode. Use --commit to run against ListenBrainz."
+ )
+ return
+
+ found_count = 0
+ empty_count = 0
+ error_count = 0
+ track_ids = list(qs.values_list("pk", flat=True))
+ i = 0
+
+ for batch_num, offset in enumerate(
+ range(0, len(track_ids), batch_size)
+ ):
+ batch_pks = track_ids[offset : offset + batch_size]
+ for track in Track.objects.filter(pk__in=batch_pks).iterator():
+ i += 1
+ self.stdout.write(
+ f" [{i}/{total}] Fetching similar recordings for {track}...",
+ ending="",
+ )
+ try:
+ similar = get_similar_recordings(track.musicbrainz_id)
+ if similar:
+ track.similar_recordings = similar
+ track.save(update_fields=["similar_recordings"])
+ self.stdout.write(
+ f" {len(similar)} similar recordings found"
+ )
+ found_count += 1
+ else:
+ track.similar_recordings = []
+ track.save(update_fields=["similar_recordings"])
+ self.stdout.write(" none found")
+ empty_count += 1
+ except Exception as e:
+ self.stdout.write(f" error: {e}")
+ error_count += 1
+
+ self.stdout.write(
+ f" Batch {batch_num + 1}: {offset + len(batch_pks)}/{total} processed, "
+ f"found: {found_count}, empty: {empty_count}, errors: {error_count}"
+ )
+ time.sleep(1)
+
+ self.stdout.write(
+ f"\nResults (commit={commit}):\n"
+ f" Similar recordings found: {found_count}\n"
+ f" No similar recordings: {empty_count}\n"
+ f" Errors: {error_count}"
+ )
diff --git a/vrobbler/apps/music/migrations/0035_track_similar_recordings.py b/vrobbler/apps/music/migrations/0035_track_similar_recordings.py
new file mode 100644
index 0000000..0d90e7c
--- /dev/null
+++ b/vrobbler/apps/music/migrations/0035_track_similar_recordings.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.30 on 2026-05-29 23:52
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("music", "0034_backfill_album_artists"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="track",
+ name="similar_recordings",
+ field=models.JSONField(blank=True, null=True),
+ ),
+ ]
diff --git a/vrobbler/apps/music/migrations/0036_artist_similar_artists.py b/vrobbler/apps/music/migrations/0036_artist_similar_artists.py
new file mode 100644
index 0000000..ac766fa
--- /dev/null
+++ b/vrobbler/apps/music/migrations/0036_artist_similar_artists.py
@@ -0,0 +1,18 @@
+# Generated by Django 4.2.30 on 2026-05-30 00:00
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("music", "0035_track_similar_recordings"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="artist",
+ name="similar_artists",
+ field=models.JSONField(blank=True, null=True),
+ ),
+ ]
diff --git a/vrobbler/apps/music/models.py b/vrobbler/apps/music/models.py
index 4b92b19..c8ad474 100644
--- a/vrobbler/apps/music/models.py
+++ b/vrobbler/apps/music/models.py
@@ -56,6 +56,7 @@ class Artist(TimeStampedModel):
theaudiodb_genre = models.CharField(max_length=255, **BNULL)
theaudiodb_mood = models.CharField(max_length=255, **BNULL)
musicbrainz_id = models.CharField(max_length=255, **BNULL)
+ similar_artists = models.JSONField(**BNULL)
allmusic_id = models.CharField(max_length=100, **BNULL)
bandcamp_id = models.CharField(max_length=100, **BNULL)
thumbnail = models.ImageField(upload_to="artist/", **BNULL)
@@ -587,6 +588,7 @@ class Track(ScrobblableMixin):
albums = models.ManyToManyField(Album, related_name="tracks")
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
musicbrainz_id = models.CharField(max_length=255, **BNULL)
+ similar_recordings = models.JSONField(**BNULL)
class Meta:
unique_together = [["album", "musicbrainz_id"]]
diff --git a/vrobbler/apps/music/views.py b/vrobbler/apps/music/views.py
index 3908aff..eb3f25c 100644
--- a/vrobbler/apps/music/views.py
+++ b/vrobbler/apps/music/views.py
@@ -19,16 +19,28 @@ class ArtistListView(generic.ListView):
paginate_by = 100
def get_queryset(self):
- return (
+ qs = (
super()
.get_queryset()
.annotate(scrobble_count=Count("track__scrobble"))
- .order_by("-scrobble_count")
)
+ genre = self.request.GET.get("genre")
+ if genre:
+ qs = qs.filter(theaudiodb_genre=genre)
+ mood = self.request.GET.get("mood")
+ if mood:
+ qs = qs.filter(theaudiodb_mood=mood)
+ return qs.order_by("-scrobble_count")
def get_context_data(self, *, object_list=None, **kwargs):
context_data = super().get_context_data(object_list=object_list, **kwargs)
context_data["view"] = self.request.GET.get("view")
+ genre = self.request.GET.get("genre")
+ if genre:
+ context_data["active_filter"] = f"genre: {genre}"
+ mood = self.request.GET.get("mood")
+ if mood:
+ context_data["active_filter"] = f"mood: {mood}"
return context_data
@@ -68,6 +80,29 @@ class ArtistDetailView(generic.DetailView):
.order_by("-timestamp")[:100]
)
+ similar = artist.similar_artists or []
+ if similar:
+ mbids = [sa["artist_mbid"] for sa in similar if sa.get("artist_mbid")]
+ local_artists = {
+ a.musicbrainz_id: a
+ for a in Artist.objects.filter(musicbrainz_id__in=mbids)
+ }
+ for sa in similar:
+ local = local_artists.get(sa.get("artist_mbid"))
+ sa["local_url"] = local.get_absolute_url() if local else None
+ context_data["similar_artists"] = similar
+
+ if artist.theaudiodb_genre:
+ context_data["genre_count"] = (
+ Artist.objects.filter(theaudiodb_genre=artist.theaudiodb_genre)
+ .count()
+ )
+ if artist.theaudiodb_mood:
+ context_data["mood_count"] = (
+ Artist.objects.filter(theaudiodb_mood=artist.theaudiodb_mood)
+ .count()
+ )
+
return context_data
diff --git a/vrobbler/templates/music/artist_detail.html b/vrobbler/templates/music/artist_detail.html
index 487ee1c..58be41f 100644
--- a/vrobbler/templates/music/artist_detail.html
+++ b/vrobbler/templates/music/artist_detail.html
@@ -29,6 +29,14 @@
{% if artist.bandcamp_link %}
{% endif %}
{% if artist.allmusic_link %}
{% endif %}
+
+ {% if artist.theaudiodb_genre %}
+ {{artist.theaudiodb_genre}} ({{genre_count}}){% if artist.theaudiodb_mood %} · {% endif %}
+ {% endif %}
+ {% if artist.theaudiodb_mood %}
+ {{artist.theaudiodb_mood}} ({{mood_count}})
+ {% endif %}
+
@@ -37,6 +45,27 @@
{% include "scrobbles/_chart_links.html" %}
{% endif %}
+ {% if similar_artists %}
+
Similar artists
+
+
+
+
+ | Artist |
+ Score |
+
+
+
+ {% for sa in similar_artists|slice:":10" %}
+
+ | {% if sa.local_url %}{{sa.name}}{% else %}{{sa.name}}{% endif %} |
+ {{sa.score}} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
Top tracks
diff --git a/vrobbler/templates/music/track_detail.html b/vrobbler/templates/music/track_detail.html
index 2c55bd4..96be4f0 100644
--- a/vrobbler/templates/music/track_detail.html
+++ b/vrobbler/templates/music/track_detail.html
@@ -14,6 +14,29 @@
{% include "scrobbles/_chart_links.html" %}
{% endif %}
+ {% if object.similar_recordings %}
+
Similar recordings
+
+
+
+
+ | Track |
+ Artist |
+ Score |
+
+
+
+ {% for sr in object.similar_recordings|slice:":10" %}
+
+ | {{sr.recording_name}} |
+ {{sr.artist_credit_name}} |
+ {{sr.score}} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
Last scrobbles