[music] Resolve artist identity before splitting (fd86a11a)

This commit is contained in:
2026-08-08 20:35:45 -04:00
parent 5f88d97c69
commit 5e3b0055dc
7 changed files with 453 additions and 54 deletions

View File

@ -612,7 +612,7 @@ favorited media objects.
*** Description *** Description
As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction As an example https://comicbookroundup.com/comic-books/reviews/humanoids-publishing/the-history-of-science-fiction
** TODO [#A] Add an exception list of artists as a constant that are exempted from splitting :music:artists:metadata: ** DONE [#A] Add an exception list of artists as a constant that are exempted from splitting :music:artists:metadata:
:PROPERTIES: :PROPERTIES:
:ID: fd86a11a-73ec-470d-b5e3-2d90ba9137c8 :ID: fd86a11a-73ec-470d-b5e3-2d90ba9137c8
:END: :END:
@ -623,6 +623,22 @@ Certain artists like "Simon & Garfunkel" are actually one artist. While we don't
tracks into featured artists, we should have a "LITERAL_ARTIST_TITLES" constant that can have exceptions like tracks into featured artists, we should have a "LITERAL_ARTIST_TITLES" constant that can have exceptions like
this put into it and then we stop trying to pull the artist apart when we run into it. this put into it and then we stop trying to pull the artist apart when we run into it.
*** Notes
Instead of a hardcoded exception list, we resolve artist identity before
splitting on " & ":
- `resolve_artist_names()` in music/utils.py keeps an artist string literal
when a MusicBrainz artist id is provided (webhooks), when the full string
already exists as an Artist, or when it matches a single MusicBrainz
artist (e.g. "Simon & Garfunkel" is a Group). Splitting on " & " is now a
last resort for genuine collaborations.
- `Track.find_or_create()` and `Track.fix_metadata()` use the resolved name
instead of always splitting.
- `reconcile_split_artists` management command repairs tracks already split
by the old logic, using the raw artist data stored in scrobble logs, and
deletes orphaned fragment artists.
** TODO [#A] Update how board game scrobbles work :boardgames: ** TODO [#A] Update how board game scrobbles work :boardgames:
*** Description *** Description

View File

View File

@ -0,0 +1,173 @@
from unittest.mock import patch
import pytest
from django.contrib.auth import get_user_model
from django.core.management import call_command
from music.models import Artist, Track
from music.utils import resolve_artist_names
from scrobbles.models import Scrobble
@pytest.mark.django_db
def test_resolve_with_mbid_keeps_literal_name():
names = resolve_artist_names("Simon & Garfunkel", artist_mbid="grp-1")
assert names == ["Simon & Garfunkel"]
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={"name": "Simon & Garfunkel"})
def test_resolve_mb_exact_match_keeps_literal_name(mock_lookup):
names = resolve_artist_names("Simon & Garfunkel")
assert names == ["Simon & Garfunkel"]
mock_lookup.assert_called_once_with("Simon & Garfunkel")
@pytest.mark.django_db
@patch(
"music.utils.lookup_artist_from_mb", return_value={"name": "Simon and Garfunkel"}
)
def test_resolve_mb_inexact_match_falls_back_to_split(mock_lookup):
names = resolve_artist_names("Simon & Garfunkel")
assert names == ["Simon", "Garfunkel"]
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
def test_resolve_splits_collab_when_mb_has_no_single_artist(mock_lookup):
names = resolve_artist_names("Matt Sweeney & Bonnie Prince Billy")
assert names == ["Matt Sweeney", "Bonnie Prince Billy"]
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb")
def test_resolve_existing_db_artist_skips_mb_lookup(mock_lookup):
Artist.objects.create(name="Simon & Garfunkel")
names = resolve_artist_names("Simon & Garfunkel")
assert names == ["Simon & Garfunkel"]
mock_lookup.assert_not_called()
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
def test_resolve_strips_featured_names(mock_lookup):
names = resolve_artist_names("Ariana Grande feat. Zedd")
assert names == ["Ariana Grande"]
@pytest.mark.django_db
def test_find_or_create_trust_webhook_keeps_literal_artist():
track = Track.find_or_create(
title="Bridge over Troubled Water",
artist_name="Simon & Garfunkel",
album_name="Bridge over Troubled Water",
artist_mbid="grp-1",
trust_webhook_data=True,
)
assert list(track.artists.all().values_list("name", flat=True)) == [
"Simon & Garfunkel"
]
assert not Artist.objects.filter(name="Simon").exists()
assert not Artist.objects.filter(name="Garfunkel").exists()
@pytest.mark.django_db
@patch("music.models.resolve_track", return_value=(None, ""))
def test_find_or_create_reuses_literal_artist(mock_resolve):
artist = Artist.objects.create(name="Simon & Garfunkel")
track = Track.find_or_create(
title="Bridge over Troubled Water",
artist_name="Simon & Garfunkel",
)
assert list(track.artists.all().values_list("name", flat=True)) == [
"Simon & Garfunkel"
]
assert not Artist.objects.filter(name="Simon").exists()
assert not Artist.objects.filter(name="Garfunkel").exists()
@pytest.mark.django_db
@patch("music.models.get_track_metadata_with_artist", return_value=None)
@patch("music.models.lookup_artist_from_tadb", return_value=None)
def test_reconcile_command_merges_split_artists(mock_tadb, mock_track_meta):
user = get_user_model().objects.create(email="reconcile@example.com")
simon = Artist.objects.create(name="Simon")
garfunkel = Artist.objects.create(name="Garfunkel")
track = Track.objects.create(title="Bridge over Troubled Water", artist_fk=simon)
track.artists.add(simon, garfunkel)
Scrobble.objects.create(
track=track,
media_type="Track",
user=user,
log={
"raw_data": {
"artist": "Simon & Garfunkel",
"musicbrainz_artist_id": "grp-1",
}
},
)
call_command("reconcile_split_artists", "--commit")
track.refresh_from_db()
assert [a.name for a in track.artists.all()] == ["Simon & Garfunkel"]
assert track.artist_fk.name == "Simon & Garfunkel"
assert "artist-reconciled" in track.tags.names()
assert not Artist.objects.filter(name="Simon").exists()
assert not Artist.objects.filter(name="Garfunkel").exists()
@pytest.mark.django_db
def test_reconcile_command_dry_run_makes_no_changes():
user = get_user_model().objects.create(email="dry@example.com")
simon = Artist.objects.create(name="Simon")
garfunkel = Artist.objects.create(name="Garfunkel")
track = Track.objects.create(title="Bridge over Troubled Water")
track.artists.add(simon, garfunkel)
Scrobble.objects.create(
track=track,
media_type="Track",
user=user,
log={
"raw_data": {
"artist": "Simon & Garfunkel",
"musicbrainz_artist_id": "grp-1",
}
},
)
call_command("reconcile_split_artists")
track.refresh_from_db()
assert sorted(track.artists.all().values_list("name", flat=True)) == [
"Garfunkel",
"Simon",
]
assert Artist.objects.filter(name="Simon").exists()
assert Artist.objects.filter(name="Garfunkel").exists()
@pytest.mark.django_db
@patch("music.utils.lookup_artist_from_mb", return_value={})
def test_reconcile_command_leaves_genuine_collab_alone(mock_lookup):
user = get_user_model().objects.create(email="collab@example.com")
matt = Artist.objects.create(name="Matt Sweeney")
bonnie = Artist.objects.create(name="Bonnie Prince Billy")
track = Track.objects.create(title="My Morning Song")
track.artists.add(matt, bonnie)
Scrobble.objects.create(
track=track,
media_type="Track",
user=user,
log={"raw_data": {"artist": "Matt Sweeney & Bonnie Prince Billy"}},
)
call_command("reconcile_split_artists", "--commit")
track.refresh_from_db()
assert sorted(track.artists.all().values_list("name", flat=True)) == [
"Bonnie Prince Billy",
"Matt Sweeney",
]

View File

@ -0,0 +1,170 @@
import logging
import time
from django.core.management.base import BaseCommand
from django.db import transaction
from music.models import Album, Artist, Track
from music.utils import (
get_raw_artist_data,
normalize_name,
resolve_artist_names,
)
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = (
"Reconcile track artists against the raw artist data stored in "
"scrobble logs from Mopidy/Jellyfin webhooks. Repairs tracks whose "
"artists were wrongly split (e.g. 'Simon & Garfunkel' stored as two "
"artists) and deletes any artists left with no references."
)
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 scrobbles to process per batch (default: 100)",
)
def _resolve_names(
self, cache: dict, raw_artist: str, artist_mbid: str
) -> list[str]:
key = (normalize_name(raw_artist), artist_mbid)
if key not in cache:
cache[key] = resolve_artist_names(raw_artist, artist_mbid)
return cache[key]
def _resolve_artists(
self, cache: dict, expected_names: list[str], track
) -> list[Artist]:
artists = []
for name in expected_names:
if name not in cache:
cache[name] = Artist.objects.filter(name=name).first()
if not cache[name]:
cache[name] = Artist.find_or_create(name, track_name=track.title)
if cache[name]:
artists.append(cache[name])
return artists
def _artist_is_orphan(self, artist: Artist) -> bool:
if Track.objects.filter(artist_fk=artist).exists():
return False
if Track.objects.filter(artists=artist).exists():
return False
if Album.objects.filter(album_artist=artist).exists():
return False
if Album.objects.filter(artists=artist).exists():
return False
return True
def handle(self, *args, **options):
from scrobbles.models import Scrobble
commit = options["commit"]
batch_size = options["batch_size"]
if not commit:
self.stdout.write(
"Dry run — no changes will be saved. Use --commit to apply."
)
qs = (
Scrobble.objects.filter(media_type=Scrobble.MediaType.TRACK)
.exclude(log__isnull=True)
.exclude(log={})
.order_by("id")
)
scrobble_ids = list(qs.values_list("pk", flat=True))
self.stdout.write(f"Processing {len(scrobble_ids)} track scrobbles")
resolution_cache = {}
artist_cache = {}
reconciled = 0
orphaned_ids = set()
for batch_num, offset in enumerate(range(0, len(scrobble_ids), batch_size)):
batch_pks = scrobble_ids[offset : offset + batch_size]
with transaction.atomic():
batch = (
Scrobble.objects.filter(pk__in=batch_pks)
.select_related("track")
.prefetch_related("track__artists")
)
for scrobble in batch.iterator(chunk_size=batch_size):
track = scrobble.track
if not track:
continue
raw_data = scrobble.log.get("raw_data")
if not raw_data:
continue
raw_artist, _, raw_artist_mbid = get_raw_artist_data(raw_data)
if not raw_artist:
continue
expected_names = self._resolve_names(
resolution_cache, raw_artist, raw_artist_mbid or ""
)
stored_names = [
n for n in track.artists.all().values_list("name", flat=True)
]
if track.artist_fk and track.artist_fk.name not in stored_names:
stored_names.append(track.artist_fk.name)
expected = {normalize_name(n) for n in expected_names}
stored = {normalize_name(n) for n in stored_names}
if expected == stored:
continue
reconciled += 1
self.stdout.write(
f" Track '{track.title}' artists {stored_names} -> "
f"{expected_names} (raw: '{raw_artist}')"
)
if commit:
artists = self._resolve_artists(
artist_cache, expected_names, track
)
if not artists:
continue
old_artist_ids = set(
track.artists.all().values_list("id", flat=True)
)
track.artists.set(artists)
track.artist_fk = artists[0]
track.save(update_fields=["artist_fk"])
track.tags.add("artist-reconciled")
orphaned_ids.update(old_artist_ids - {a.id for a in artists})
self.stdout.write(
f" Batch {batch_num + 1}: {offset + len(batch_pks)}/{len(scrobble_ids)} processed, "
f"{reconciled} reconciled so far"
)
time.sleep(1)
deleted = 0
if commit and orphaned_ids:
for artist_id in sorted(orphaned_ids):
artist = Artist.objects.filter(id=artist_id).first()
if artist and self._artist_is_orphan(artist):
artist.delete()
deleted += 1
self.stdout.write(f" Deleted orphaned artist '{artist.name}'")
self.stdout.write(
f"\nResults (commit={commit}):\n"
f" Tracks reconciled: {reconciled}\n"
f" Orphaned artists deleted: {deleted}"
)

View File

@ -2,46 +2,16 @@ import csv
import logging import logging
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from music.utils import (
album_mismatch,
artist_mismatch,
get_artist_source,
get_raw_artist_data,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_source(raw_data):
if "Artist" in raw_data:
return "Jellyfin"
if "artist" in raw_data:
return "Mopidy"
return None
def _get_raw_values(raw_data, source):
if source == "Jellyfin":
return raw_data.get("Artist", ""), raw_data.get("Album", "")
return raw_data.get("artist", ""), raw_data.get("album", "")
def _normalize(name):
return name.strip().casefold()
def _artist_mismatch(raw_artist, track_artist_names):
if not raw_artist or not track_artist_names:
return False
track_names = [_normalize(n) for n in track_artist_names.split(" / ")]
raw = _normalize(raw_artist)
if raw in track_names:
return False
if raw == _normalize(track_artist_names):
return False
return True
def _album_mismatch(raw_album, track_album_name):
if not raw_album or not track_album_name:
return False
return _normalize(raw_album) != _normalize(track_album_name)
class Command(BaseCommand): class Command(BaseCommand):
help = ( help = (
"Outputs a CSV of track IDs where raw metadata from scrobble logs " "Outputs a CSV of track IDs where raw metadata from scrobble logs "
@ -80,26 +50,26 @@ class Command(BaseCommand):
if not raw_data: if not raw_data:
continue continue
source = _get_source(raw_data) source = get_artist_source(raw_data)
if not source: if not source:
continue continue
raw_artist, raw_album = _get_raw_values(raw_data, source) raw_artist, raw_album, _ = get_raw_artist_data(raw_data)
if not raw_artist and not raw_album: if not raw_artist and not raw_album:
continue continue
track_artist_names = " / ".join( track_artist_names = [
track.artists.all().values_list("name", flat=True) name for name in track.artists.all().values_list("name", flat=True)
) ]
track_album_name = track.album.name if track.album else "" track_album_name = track.album.name if track.album else ""
if _artist_mismatch(raw_artist, track_artist_names) or _album_mismatch( if artist_mismatch(raw_artist, track_artist_names) or album_mismatch(
raw_album, track_album_name raw_album, track_album_name
): ):
rows.append( rows.append(
{ {
"track_id": track.id, "track_id": track.id,
"track_artist_name": track_artist_names, "track_artist_name": " / ".join(track_artist_names),
"track_album_name": track_album_name, "track_album_name": track_album_name,
"raw_artist": raw_artist, "raw_artist": raw_artist,
"raw_album": raw_album, "raw_album": raw_album,
@ -121,7 +91,5 @@ class Command(BaseCommand):
writer.writerows(rows) writer.writerows(rows)
self.stdout.write( self.stdout.write(
self.style.SUCCESS( self.style.SUCCESS(f"Wrote {len(rows)} mismatched track(s) to {file_path}")
f"Wrote {len(rows)} mismatched track(s) to {file_path}"
)
) )

View File

@ -654,10 +654,10 @@ class Track(ScrobblableMixin):
further enrichment; otherwise the track is created and stamped with the further enrichment; otherwise the track is created and stamped with the
provided mbids, and async enrichment can pick it up later via provided mbids, and async enrichment can pick it up later via
:meth:`fix_metadata`.""" :meth:`fix_metadata`."""
from music.utils import parse_artist_names from music.utils import resolve_artist_names
if artist_names is None and artist_name: if artist_names is None and artist_name:
artist_names = parse_artist_names(artist_name) artist_names = resolve_artist_names(artist_name, artist_mbid or "")
if not artist_names: if not artist_names:
artist_names = [] artist_names = []
@ -757,8 +757,6 @@ class Track(ScrobblableMixin):
This is safe to call off the request path (e.g. from the async webhook This is safe to call off the request path (e.g. from the async webhook
enrichment task), where it can afford slower lookups and subtler enrichment task), where it can afford slower lookups and subtler
matching rules.""" matching rules."""
from music.utils import parse_artist_names
if "musicbrainz-enriched" in self.tags.names() and not force_update: if "musicbrainz-enriched" in self.tags.names() and not force_update:
logger.info( logger.info(
f"Track {self} already enriched, skipping", f"Track {self} already enriched, skipping",
@ -766,9 +764,7 @@ class Track(ScrobblableMixin):
) )
return True return True
artist_name_str = "" artist_name_str = str(self.artist) if self.artist else ""
if self.artist:
artist_name_str = " & ".join(parse_artist_names(str(self.artist)))
album_name = "" album_name = ""
if self.primary_album: if self.primary_album:
album_name = self.primary_album.name album_name = self.primary_album.name

View File

@ -3,6 +3,7 @@ import re
from django.db import IntegrityError, models, transaction from django.db import IntegrityError, models, transaction
from music.constants import VARIOUS_ARTIST_DICT from music.constants import VARIOUS_ARTIST_DICT
from music.musicbrainz import lookup_artist_from_mb
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -35,6 +36,31 @@ def parse_artist_names(name: str) -> list[str]:
return [name] return [name]
def resolve_artist_names(name: str, artist_mbid: str = "") -> list[str]:
"""Resolve an artist string into one or more artist names.
Prefers treating the full string as a single artist. An authoritative
MusicBrainz artist id (e.g. from a webhook) or an exact match against a
known single artist means we keep the name literal -- so acts like
"Simon & Garfunkel" are never pulled apart. Splitting on ' & ' (for
genuine collaborations) is only a last resort.
"""
name = clean_artist_name(name)
if artist_mbid:
return [name]
from music.models import Artist
if Artist.objects.filter(name=name).exists():
return [name]
result = lookup_artist_from_mb(name)
if result and result.get("name", "").strip().casefold() == name.casefold():
return [name]
return parse_artist_names(name)
def get_or_create_various_artists() -> "Artist": def get_or_create_various_artists() -> "Artist":
from music.models import Artist from music.models import Artist
@ -121,3 +147,53 @@ def condense_albums(commit: bool = False):
Track.objects.filter(scrobble__isnull=True).delete() Track.objects.filter(scrobble__isnull=True).delete()
return len(set(processed_ids)) return len(set(processed_ids))
def get_artist_source(raw_data: dict) -> str:
"""Return the provider that produced raw webhook data ('Jellyfin'/'Mopidy')."""
if "Artist" in raw_data:
return "Jellyfin"
if "artist" in raw_data:
return "Mopidy"
return ""
def get_raw_artist_data(raw_data: dict) -> tuple[str, str, str]:
"""Extract (artist_name, album_name, artist_mbid) from raw webhook data."""
source = get_artist_source(raw_data)
if source == "Jellyfin":
return (
raw_data.get("Artist", ""),
raw_data.get("Album", ""),
raw_data.get("Provider_musicbrainzartist", ""),
)
if source == "Mopidy":
return (
raw_data.get("artist", ""),
raw_data.get("album", ""),
raw_data.get("musicbrainz_artist_id", ""),
)
return "", "", ""
def normalize_name(name: str) -> str:
return name.strip().casefold()
def artist_mismatch(raw_artist: str, track_artist_names: list[str]) -> bool:
"""True when a raw artist string doesn't match the stored track artists."""
if not raw_artist or not track_artist_names:
return False
track_names = {normalize_name(n) for n in track_artist_names}
raw = normalize_name(raw_artist)
if raw in track_names:
return False
if raw == normalize_name(" / ".join(track_artist_names)):
return False
return True
def album_mismatch(raw_album: str, track_album_name: str) -> bool:
if not raw_album or not track_album_name:
return False
return normalize_name(raw_album) != normalize_name(track_album_name)