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

+
+ + + + + + + + + {% for sa in similar_artists|slice:":10" %} + + + + + {% endfor %} + +
ArtistScore
{% if sa.local_url %}{{sa.name}}{% else %}{{sa.name}}{% endif %}{{sa.score}}
+
+ {% 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

+
+
+ + + + + + + + + {% for sr in object.similar_recordings|slice:":10" %} + + + + + + {% endfor %} + +
TrackArtistScore
{{sr.recording_name}}{{sr.artist_credit_name}}{{sr.score}}
+
+ {% endif %}

Last scrobbles