[music] Tracks can have multiple artists
All checks were successful
build & deploy / test (push) Successful in 1m54s
build & deploy / build-and-deploy (push) Successful in 33s

This commit is contained in:
2026-05-28 23:32:09 -04:00
parent 17aed1191d
commit 22956c7c7f
15 changed files with 231 additions and 84 deletions

View File

@ -93,7 +93,7 @@ fetching and simple saving.
:LOGBOOK:
CLOCK: [2025-07-09 Wed 09:55]--[2025-07-09 Wed 10:15] => 0:20
:END:
* Backlog [30/47] :vrobbler:project:personal:
* Backlog [32/47] :vrobbler:project:personal:
** TODO [#C] Add sentiment parsing for Scrobbles with notes :vrobbler:project:scrobbles:sentiment:
:PROPERTIES:
:ID: 37781d6a-f3b0-48b2-bf98-33c2c791cf85
@ -475,7 +475,7 @@ to GMT to save it in the database.
It would be nice to not duplicate comments that exist on a task when it's first scrobbled.
** TODO [#A] Fix various artist album problem with Superwolves (track with multiple artists) :vrobbler:project:music:bug:artists:
** DONE [#A] Fix various artist album problem with Superwolves (track with multiple artists) :vrobbler:project:music:bug:artists:
:PROPERTIES:
:ID: 590bc038-745f-710b-8272-4d8a3d2efa01
:END:
@ -496,10 +496,11 @@ or at least make it optional, and then require a M2M between Track and Artist.
Then this one would be a Track by both `Matt Sweeney` and `Bonnie "Prince"
Billy`
** TODO [#A] Move imported eBird CSV files to processed/ directory on WebDAV :webdav:ebird:importers:
** DONE [#A] Move imported eBird CSV files to processed/ directory on WebDAV :webdav:ebird:importers:
:PROPERTIES:
:ID: 445e1253-d353-4b55-b1d8-39d0a0dcdd34
:END:
- File: ~vrobbler/apps/scrobbles/importers/webdav.py~ (line 439)
- Same pattern as the GPX importer: after importing a =.csv= file from
WebDAV, move it to =var/ebird/processed/= with a timestamp appended.

View File

@ -143,9 +143,10 @@ def test_mopidy_track_webhook_creates_track_and_scrobble(
album = Album.objects.create(name="Sublime", album_artist=artist)
track = Track.objects.create(
title="Same in the End",
artist=artist,
artist_fk=artist,
base_run_time_seconds=60,
)
track.artists.add(artist)
track.albums.add(album)
mock_artist_fc.return_value = artist
@ -181,9 +182,10 @@ def test_jellyfin_track_webhook_creates_track_and_scrobble(
album = Album.objects.create(name="Emotion", album_artist=artist)
track = Track.objects.create(
title="Emotion",
artist=artist,
artist_fk=artist,
base_run_time_seconds=60,
)
track.artists.add(artist)
track.albums.add(album)
mock_artist_fc.return_value = artist
@ -225,9 +227,10 @@ def test_mopidy_track_webhook_stores_raw_data(
album = Album.objects.create(name="Sublime", album_artist=artist)
track = Track.objects.create(
title="Same in the End",
artist=artist,
artist_fk=artist,
base_run_time_seconds=60,
)
track.artists.add(artist)
track.albums.add(album)
mock_artist_fc.return_value = artist
@ -265,10 +268,11 @@ def test_mopidy_track_webhook_stores_album_id(
album = Album.objects.create(name="Sublime", album_artist=artist)
track = Track.objects.create(
title="Same in the End",
artist=artist,
artist_fk=artist,
album=album,
base_run_time_seconds=60,
)
track.artists.add(artist)
mock_artist_fc.return_value = artist
mock_track_fc.return_value = track
@ -303,9 +307,10 @@ def test_jellyfin_track_webhook_stores_raw_data(
album = Album.objects.create(name="Emotion", album_artist=artist)
track = Track.objects.create(
title="Emotion",
artist=artist,
artist_fk=artist,
base_run_time_seconds=60,
)
track.artists.add(artist)
track.albums.add(album)
mock_artist_fc.return_value = artist
@ -347,11 +352,11 @@ def test_jellyfin_track_webhook_stores_album_id(
album = Album.objects.create(name="Emotion", album_artist=artist)
track = Track.objects.create(
title="Emotion",
artist=artist,
artist_fk=artist,
album=album,
base_run_time_seconds=60,
)
track.albums.add(album)
track.artists.add(artist)
mock_artist_fc.return_value = artist
mock_track_fc.return_value = track

View File

@ -104,8 +104,8 @@ def build_charts(
media_config = {
"artist": {
"filter": Q(track__isnull=False) & Q(track__artist__isnull=False),
"values": "track__artist",
"filter": Q(track__isnull=False) & Q(track__artists__isnull=False),
"values": "track__artists",
"annotate": Count("id", distinct=True),
},
"album": {

View File

@ -1,7 +1,5 @@
from django.contrib import admin
from music.models import Artist, Album, Track
from music.models import Album, Artist, Track
from scrobbles.admin import ScrobbleInline
@ -54,12 +52,12 @@ class TrackAdmin(admin.ModelAdmin):
"artist",
"musicbrainz_id",
)
raw_id_fields = ("artist", "albums", "album")
list_filter = ("album", "artist")
raw_id_fields = ("artist_fk", "artists", "albums", "album")
search_fields = ("title",)
ordering = ("-created",)
filter_horizontal = [
"albums",
"artists",
]
inlines = [
ScrobbleInline,

View File

@ -149,7 +149,7 @@ def live_charts(
def artist_scrobble_count(artist_id: int, filter: str = "today") -> int:
return Scrobble.objects.filter(track__artist=artist_id).count()
return Scrobble.objects.filter(track__artists=artist_id).count()
def live_tv_charts(
@ -158,7 +158,7 @@ def live_tv_charts(
limit: int = 15,
) -> QuerySet:
from django.db.models import OuterRef, Subquery
from videos.models import Video, Series
from videos.models import Series, Video
now = timezone.now()
tzinfo = now.tzinfo
@ -215,7 +215,7 @@ def live_youtube_channel_charts(
limit: int = 15,
) -> QuerySet:
from django.db.models import OuterRef, Subquery
from videos.models import Video, Channel
from videos.models import Channel, Video
now = timezone.now()
tzinfo = now.tzinfo

View File

@ -1,3 +1,5 @@
from taggit.serializers import TaggitSerializer, TagListSerializerField
from music.models import Album, Artist, Track
from rest_framework import serializers
@ -14,7 +16,25 @@ class AlbumSerializer(serializers.HyperlinkedModelSerializer):
fields = "__all__"
class TrackSerializer(serializers.HyperlinkedModelSerializer):
class TrackSerializer(TaggitSerializer, serializers.HyperlinkedModelSerializer):
tags = TagListSerializerField()
genre = TagListSerializerField()
class Meta:
model = Track
fields = "__all__"
fields = [
"url",
"id",
"uuid",
"title",
"artist_fk",
"artists",
"album",
"albums",
"musicbrainz_id",
"genre",
"tags",
"base_run_time_seconds",
"created",
"modified",
]

View File

@ -0,0 +1,32 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0031_alter_track_genre"),
]
operations = [
migrations.RenameField(
model_name="track",
old_name="artist",
new_name="artist_fk",
),
migrations.AlterField(
model_name="track",
name="artist_fk",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=models.DO_NOTHING,
related_name="+",
to="music.artist",
),
),
migrations.AddField(
model_name="track",
name="artists",
field=models.ManyToManyField(to="music.artist"),
),
]

View File

@ -0,0 +1,27 @@
from django.db import migrations
def backfill_artist_fk_to_artists(apps, schema_editor):
Track = apps.get_model("music", "Track")
TrackArtist = Track.artists.through
TrackArtist.objects.bulk_create(
[
TrackArtist(track_id=r["id"], artist_id=r["artist_fk_id"])
for r in Track.objects.filter(artist_fk__isnull=False).values("id", "artist_fk_id")
],
ignore_conflicts=True,
)
class Migration(migrations.Migration):
dependencies = [
("music", "0032_track_artist_fk_and_artists"),
]
operations = [
migrations.RunPython(
backfill_artist_fk_to_artists,
reverse_code=migrations.RunPython.noop,
),
]

View File

@ -124,7 +124,7 @@ class Artist(TimeStampedModel):
def charts(self):
from scrobbles.models import ChartRecord
return ChartRecord.objects.filter(track__artist=self).order_by("-year")
return ChartRecord.objects.filter(track__artists=self).order_by("-year")
def scrape_allmusic(self, force=False) -> None:
if not self.allmusic_id or force:
@ -422,7 +422,8 @@ class Album(TimeStampedModel):
self.artists.add(new_artist)
if not new_artist:
for t in self.track_set.all():
self.artists.add(t.artist)
for a in t.artists.all():
self.artists.add(a)
if not self.cover_image or self.cover_image == "default-image-replace-me":
self.fetch_artwork()
self.fix_album_artist()
@ -573,7 +574,10 @@ class Album(TimeStampedModel):
class Track(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
artist_fk = models.ForeignKey(
Artist, on_delete=models.DO_NOTHING, **BNULL, related_name="+"
)
artists = models.ManyToManyField(Artist)
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)
@ -581,6 +585,10 @@ class Track(ScrobblableMixin):
class Meta:
unique_together = [["album", "musicbrainz_id"]]
@property
def artist(self):
return self.artists.first() or self.artist_fk
def __str__(self):
return f"{self.title} by {self.artist}"
@ -615,8 +623,9 @@ class Track(ScrobblableMixin):
@property
def primary_image_url(self) -> str:
url = ""
if self.artist.thumbnail:
url = self.artist.thumbnail_medium.url
primary_artist = self.artist
if primary_artist and primary_artist.thumbnail:
url = primary_artist.thumbnail_medium.url
if self.primary_album and self.primary_album.cover_image:
url = self.primary_album.cover_image_medium.url
return url
@ -625,6 +634,7 @@ class Track(ScrobblableMixin):
def find_or_create(
cls,
title: str = "",
artist_names: list[str] | None = None,
artist_name: str = "",
album_name: str = "",
run_time_seconds: int | None = None,
@ -638,25 +648,40 @@ class Track(ScrobblableMixin):
name
Optionally, we can update any found artists with overwrite."""
from music.utils import parse_artist_names
if artist_names is None and artist_name:
artist_names = parse_artist_names(artist_name)
if not artist_names:
artist_names = []
album = None
if album_name:
logger.info(f"Looking up album for: {album_name}")
album = Album.find_or_create(name=album_name, artist_name=artist_name)
artist = album.album_artist
else:
artist = Artist.find_or_create(artist_name, track_name=title)
if not artist:
artist = Artist.find_or_create(artist_name)
first_name = artist_names[0] if artist_names else ""
album = Album.find_or_create(name=album_name, artist_name=first_name)
artist_objs = []
for name in artist_names:
artist = Artist.find_or_create(name, track_name=title)
if artist:
artist_objs.append(artist)
track = None
if artist_objs:
track = cls.objects.filter(title=title, artists__in=artist_objs).first()
lookup_keys = {"title": title, "artist": artist}
if run_time_seconds:
lookup_keys["base_run_time_seconds"] = run_time_seconds
logger.info(f"Looking up track using: {lookup_keys}")
track = cls.objects.filter(**lookup_keys).first()
if not track:
track = cls.objects.filter(title=title, artist=artist).first()
if not track:
track, _ = cls.objects.get_or_create(title=title, artist=artist)
track = cls.objects.filter(title=title).first()
if not track:
track = cls(title=title)
track.save()
track.refresh_from_db()
if artist_objs:
track.artists.add(*artist_objs)
if album:
track.albums.add(album)
@ -666,17 +691,18 @@ class Track(ScrobblableMixin):
track.base_run_time_seconds = run_time_seconds
track.musicbrainz_id = mbid
else:
artist_name_str = " & ".join(artist_names) if artist_names else ""
logger.info(
f"Enriching track {track}",
extra={
"title": title,
"artist_name": artist_name,
"artist_name": artist_name_str,
"track_id": track.id,
},
)
try:
mbid, length = get_recording_mbid_exact(
title, artist_name, album_name
title, artist_name_str, album_name
)
except Exception:
print("No musicbrainz result found, cannot enrich")
@ -688,6 +714,4 @@ class Track(ScrobblableMixin):
return track
def fix_metadata(self, force_update=False):
...
def fix_metadata(self, force_update=False): ...

View File

@ -1,5 +1,5 @@
from datetime import datetime
import logging
from datetime import datetime
import musicbrainzngs
from dateutil.parser import parse

View File

@ -15,12 +15,26 @@ def clean_artist_name(name: str) -> str:
name = re.split(" w. ", name, flags=re.IGNORECASE)[0].strip()
if " featuring " in name.lower():
name = re.split(" featuring ", name, flags=re.IGNORECASE)[0].strip()
# if " & " in name.lower() and "of the wand" not in name.lower():
# name = re.split("&", name, flags=re.IGNORECASE)[0].strip()
return name
def parse_artist_names(name: str) -> list[str]:
"""Split a combined artist string (e.g. 'A & B') into individual names.
First strips feat./featuring/w. prefixes, then splits on ' & ' to
support collaboration-style credits like 'Matt Sweeney & Bonnie Prince Billy'.
"""
name = clean_artist_name(name)
if " & " in name.lower():
return [
part.strip()
for part in re.split(r"\s+&\s+", name, flags=re.IGNORECASE)
if part.strip()
]
return [name]
def get_or_create_various_artists() -> "Artist":
from music.models import Artist
@ -35,19 +49,19 @@ def deduplicate_tracks(commit=False) -> int:
from music.models import Track
duplicates = (
Track.objects.values("artist", "title")
Track.objects.values("title")
.annotate(dup_count=models.Count("id"))
.filter(dup_count__gt=1)
)
query = models.Q()
for dup in duplicates:
query |= models.Q(artist=dup["artist"], title=dup["title"])
query |= models.Q(title=dup["title"])
duplicate_tracks = Track.objects.filter(query)
for b in duplicate_tracks:
tracks = Track.objects.filter(artist=b.artist, title=b.title)
tracks = Track.objects.filter(title=b.title)
first = tracks.first()
for other in tracks.exclude(id=first.id):
print("Moving scrobbles for", other.id, " to ", first.id)
@ -74,7 +88,7 @@ def condense_albums(commit: bool = False):
for track in Track.objects.all():
albums_to_add = []
duplicates = (
Track.objects.filter(title=track.title, artist=track.artist)
Track.objects.filter(title=track.title)
.exclude(id=track.id)
.exclude(id__in=processed_ids)
)
@ -88,7 +102,7 @@ def condense_albums(commit: bool = False):
track.albums.add(dup_track.album)
# Find out if this track appears more than once
duplicates = Track.objects.filter(title=track.title, artist=track.artist)
duplicates = Track.objects.filter(title=track.title)
if duplicates.count() > 1:
logger.info(f"Track appears more than once, condensing: {track}")

View File

@ -1,10 +1,9 @@
from charts.models import ChartRecord
from django.db.models import Count
from django.views import generic
from music.models import Album, Artist, Track
from charts.models import ChartRecord
from scrobbles.stats import get_scrobble_count_qs
from scrobbles.views import ScrobbleableListView, ScrobbleableDetailView
from scrobbles.views import ScrobbleableDetailView, ScrobbleableListView
class TrackListView(ScrobbleableListView):
@ -64,7 +63,7 @@ class ArtistDetailView(generic.DetailView):
from scrobbles.models import Scrobble
context_data["recent_scrobbles"] = (
Scrobble.objects.filter(track__artist=artist)
Scrobble.objects.filter(track__artists=artist)
.select_related("track", "track__album")
.order_by("-timestamp")[:100]
)

View File

@ -1,8 +1,8 @@
import csv
import tempfile
from scrobbles.models import Scrobble
from django.db.models import Q
from scrobbles.models import Scrobble
def export_scrobbles(start_date=None, end_date=None, format="AS"):

View File

@ -564,7 +564,7 @@ class ScrobbleQuerySet(models.QuerySet):
return self.select_related("user").prefetch_related(
"video",
"track",
"track__artist",
"track__artist_fk",
"podcast_episode",
"podcast_episode__podcast",
"sport_event",

View File

@ -226,7 +226,7 @@ class RecentScrobbleList(ListView):
# Get user's home scrobble limit (default 20)
home_limit = 20
if hasattr(user, 'profile') and user.profile.home_scrobble_limit:
if hasattr(user, "profile") and user.profile.home_scrobble_limit:
home_limit = user.profile.home_scrobble_limit
today = timezone.localtime(timezone.now())
@ -612,9 +612,7 @@ class KoReaderImportCreateView(LoginRequiredMixin, JsonableResponseMixin, Create
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
class ScaleCSVImportCreateView(
LoginRequiredMixin, JsonableResponseMixin, CreateView
):
class ScaleCSVImportCreateView(LoginRequiredMixin, JsonableResponseMixin, CreateView):
model = ScaleCSVImport
fields = ["csv_file"]
template_name = "scrobbles/upload_form.html"
@ -623,17 +621,13 @@ class ScaleCSVImportCreateView(
def form_valid(self, form):
self.object = form.save(commit=False)
self.object.user = self.request.user
self.object.original_filename = (
form.cleaned_data["csv_file"].name
)
self.object.original_filename = form.cleaned_data["csv_file"].name
self.object.save()
self.object.process()
return HttpResponseRedirect(self.request.META.get("HTTP_REFERER"))
class TrailGPXImportCreateView(
LoginRequiredMixin, JsonableResponseMixin, CreateView
):
class TrailGPXImportCreateView(LoginRequiredMixin, JsonableResponseMixin, CreateView):
model = TrailGPXImport
fields = ["gpx_file"]
template_name = "scrobbles/upload_form.html"
@ -852,7 +846,9 @@ def scrobble_start(request, uuid):
book=media_obj,
user_id=user_id,
)
.filter(Q(long_play_complete=False) | Q(long_play_complete__isnull=True))
.filter(
Q(long_play_complete=False) | Q(long_play_complete__isnull=True)
)
.filter(log__page_end__isnull=False)
.order_by("-timestamp")
.first()
@ -1328,6 +1324,7 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
def _day_color(self, month_index, day_num, total_days):
import colorsys
hue = (month_index - 1) * 30 / 360
lightness = 0.80 + (day_num / total_days) * 0.15
r, g, b = colorsys.hls_to_rgb(hue, lightness, 0.5)
@ -1355,7 +1352,9 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
if media_type_filter and media_type_filter in self.CALENDAR_MEDIA_TYPES:
active_types = [media_type_filter]
else:
active_types = [t for t in self.CALENDAR_MEDIA_TYPES if t not in self.DEFAULT_EXCLUDE]
active_types = [
t for t in self.CALENDAR_MEDIA_TYPES if t not in self.DEFAULT_EXCLUDE
]
scrobbles = (
Scrobble.objects.filter(
@ -1364,12 +1363,23 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
timestamp__date__lte=month_end,
media_type__in=active_types,
)
.select_related("task", "birding_location", "food", "trail", "video_game", "book", "mood", "video", "board_game")
.select_related(
"task",
"birding_location",
"food",
"trail",
"video_game",
"book",
"mood",
"video",
"board_game",
)
.order_by("timestamp")
)
from django.db.models import Count, Q
from django.db.models.functions import TruncDate
total_by_day = dict(
Scrobble.objects.filter(
user=self.request.user,
@ -1377,7 +1387,11 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
timestamp__date__lte=month_end,
)
.exclude(Q(media_type="GeoLocation") & Q(geo_location__title__isnull=True))
.annotate(local_date=TruncDate("timestamp", tzinfo=timezone.get_current_timezone()))
.annotate(
local_date=TruncDate(
"timestamp", tzinfo=timezone.get_current_timezone()
)
)
.values("local_date")
.annotate(count=Count("id"))
.values_list("local_date", "count")
@ -1403,17 +1417,27 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
{
"uuid": scrobble.uuid,
"emoji": self.MEDIA_EMOJI.get(scrobble.media_type, "📌"),
"title": str(scrobble.media_obj) if scrobble.media_obj else scrobble.media_type,
"title": (
str(scrobble.media_obj)
if scrobble.media_obj
else scrobble.media_type
),
"media_type": scrobble.media_type,
}
)
calendar_days.append({
"day": day_num,
"scrobbles": day_scrobbles,
"total_count": total_by_day.get(datetime(year, month, day_num).date(), 0),
"is_today": year == today.year and month == today.month and day_num == today.day,
"color": self._day_color(month, day_num, total_days),
})
calendar_days.append(
{
"day": day_num,
"scrobbles": day_scrobbles,
"total_count": total_by_day.get(
datetime(year, month, day_num).date(), 0
),
"is_today": year == today.year
and month == today.month
and day_num == today.day,
"color": self._day_color(month, day_num, total_days),
}
)
ctx.update(
{
@ -1430,7 +1454,10 @@ class ScrobbleCalendarView(LoginRequiredMixin, TemplateView):
"day_names": ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
"month_color": month_color,
"active_filter": media_type_filter or "",
"media_types": [{"name": mt, "emoji": self.MEDIA_EMOJI.get(mt, "📌")} for mt in self.CALENDAR_MEDIA_TYPES],
"media_types": [
{"name": mt, "emoji": self.MEDIA_EMOJI.get(mt, "📌")}
for mt in self.CALENDAR_MEDIA_TYPES
],
}
)
return ctx
@ -1441,7 +1468,7 @@ class ScrobbleSearchView(LoginRequiredMixin, TemplateView):
MEDIA_FIELDS = {
"Video": ["video__title", "video__overview"],
"Track": ["track__title", "track__artist__name", "track__album__name"],
"Track": ["track__title", "track__artists__name", "track__album__name"],
"PodcastEpisode": ["podcast_episode__title", None],
"Book": ["book__title", "book__summary"],
"Paper": ["paper__title", None],