[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_genre = models.CharField(max_length=255, **BNULL)
|
||||||
theaudiodb_mood = models.CharField(max_length=255, **BNULL)
|
theaudiodb_mood = models.CharField(max_length=255, **BNULL)
|
||||||
musicbrainz_id = 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)
|
allmusic_id = models.CharField(max_length=100, **BNULL)
|
||||||
bandcamp_id = models.CharField(max_length=100, **BNULL)
|
bandcamp_id = models.CharField(max_length=100, **BNULL)
|
||||||
thumbnail = models.ImageField(upload_to="artist/", **BNULL)
|
thumbnail = models.ImageField(upload_to="artist/", **BNULL)
|
||||||
@ -587,6 +588,7 @@ class Track(ScrobblableMixin):
|
|||||||
albums = models.ManyToManyField(Album, related_name="tracks")
|
albums = models.ManyToManyField(Album, related_name="tracks")
|
||||||
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
|
album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
|
||||||
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
musicbrainz_id = models.CharField(max_length=255, **BNULL)
|
||||||
|
similar_recordings = models.JSONField(**BNULL)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
unique_together = [["album", "musicbrainz_id"]]
|
unique_together = [["album", "musicbrainz_id"]]
|
||||||
|
|||||||
@ -19,16 +19,28 @@ class ArtistListView(generic.ListView):
|
|||||||
paginate_by = 100
|
paginate_by = 100
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
return (
|
qs = (
|
||||||
super()
|
super()
|
||||||
.get_queryset()
|
.get_queryset()
|
||||||
.annotate(scrobble_count=Count("track__scrobble"))
|
.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):
|
def get_context_data(self, *, object_list=None, **kwargs):
|
||||||
context_data = super().get_context_data(object_list=object_list, **kwargs)
|
context_data = super().get_context_data(object_list=object_list, **kwargs)
|
||||||
context_data["view"] = self.request.GET.get("view")
|
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
|
return context_data
|
||||||
|
|
||||||
|
|
||||||
@ -68,6 +80,29 @@ class ArtistDetailView(generic.DetailView):
|
|||||||
.order_by("-timestamp")[:100]
|
.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
|
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.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 %}
|
{% if artist.allmusic_link %}<a href="{{artist.allmusic_link}}"><img src="{% static "images/allmusic-logo.png" %}" width=35></a>{% endif %}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@ -37,6 +45,27 @@
|
|||||||
{% include "scrobbles/_chart_links.html" %}
|
{% include "scrobbles/_chart_links.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="col-md">
|
<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>
|
<h3>Top tracks</h3>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped table-sm">
|
<table class="table table-striped table-sm">
|
||||||
|
|||||||
@ -14,6 +14,29 @@
|
|||||||
{% include "scrobbles/_chart_links.html" %}
|
{% include "scrobbles/_chart_links.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="col-md">
|
<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>
|
<h3>Last scrobbles</h3>
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-striped table-sm">
|
<table class="table table-striped table-sm">
|
||||||
|
|||||||
Reference in New Issue
Block a user