[music] Add listenbrainz support
This commit is contained in:
90
vrobbler/apps/music/listenbrainz.py
Normal file
90
vrobbler/apps/music/listenbrainz.py
Normal file
@ -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 []
|
||||
@ -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}"
|
||||
)
|
||||
@ -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}"
|
||||
)
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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),
|
||||
),
|
||||
]
|
||||
@ -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"]]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -29,6 +29,14 @@
|
||||
{% if artist.bandcamp_link %}<a href="{{artist.bandcamp_link}}"><img src="{% static "images/bandcamp-logo.png" %}" width=35></a>{% endif %}
|
||||
{% if artist.allmusic_link %}<a href="{{artist.allmusic_link}}"><img src="{% static "images/allmusic-logo.png" %}" width=35></a>{% endif %}
|
||||
</p>
|
||||
<p>
|
||||
{% if artist.theaudiodb_genre %}
|
||||
<a href="{% url "music:artist_list" %}?genre={{artist.theaudiodb_genre|urlencode}}">{{artist.theaudiodb_genre}}</a> ({{genre_count}}){% if artist.theaudiodb_mood %} · {% endif %}
|
||||
{% endif %}
|
||||
{% if artist.theaudiodb_mood %}
|
||||
<a href="{% url "music:artist_list" %}?mood={{artist.theaudiodb_mood|urlencode}}">{{artist.theaudiodb_mood}}</a> ({{mood_count}})
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
@ -37,6 +45,27 @@
|
||||
{% include "scrobbles/_chart_links.html" %}
|
||||
{% endif %}
|
||||
<div class="col-md">
|
||||
{% if similar_artists %}
|
||||
<h3>Similar artists</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Artist</th>
|
||||
<th scope="col">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for sa in similar_artists|slice:":10" %}
|
||||
<tr>
|
||||
<td>{% if sa.local_url %}<a href="{{sa.local_url}}">{{sa.name}}</a>{% else %}{{sa.name}}{% endif %}</td>
|
||||
<td>{{sa.score}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
<h3>Top tracks</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
|
||||
@ -14,6 +14,29 @@
|
||||
{% include "scrobbles/_chart_links.html" %}
|
||||
{% endif %}
|
||||
<div class="col-md">
|
||||
{% if object.similar_recordings %}
|
||||
<h3>Similar recordings</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Track</th>
|
||||
<th scope="col">Artist</th>
|
||||
<th scope="col">Score</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for sr in object.similar_recordings|slice:":10" %}
|
||||
<tr>
|
||||
<td><a href="https://musicbrainz.org/recording/{{sr.recording_mbid}}">{{sr.recording_name}}</a></td>
|
||||
<td>{{sr.artist_credit_name}}</td>
|
||||
<td>{{sr.score}}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
<h3>Last scrobbles</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-sm">
|
||||
|
||||
Reference in New Issue
Block a user