From 91c33762565e0500f7c047694701a45c885696dc Mon Sep 17 00:00:00 2001 From: Colin Powell Date: Mon, 8 Jun 2026 11:17:36 -0400 Subject: [PATCH] [music] Add mgmt command to see mismatched metadata --- PROJECT.org | 38 ++++-- .../commands/report_mismatched_metadata.py | 127 ++++++++++++++++++ 2 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 vrobbler/apps/music/management/commands/report_mismatched_metadata.py diff --git a/PROJECT.org b/PROJECT.org index f820b65..3e10e67 100644 --- a/PROJECT.org +++ b/PROJECT.org @@ -88,7 +88,7 @@ fetching and simple saving. *** Metadata sources **** Scraper -* Backlog [1/16] :vrobbler:project:personal: +* Backlog [2/16] :vrobbler:project:personal: ** TODO [#C] Create small utility to clean up tracks scrobbled with wonky playback times :vrobbler:personal:bug:music:scrobbles: :PROPERTIES: :ID: 702462cf-d54b-48c6-8a7c-78b8de751deb @@ -500,15 +500,6 @@ needed import celery task. This is how the WebDAV celery task currently works. This would also be an opporunity to clean up the code around WebDAV imports and make them more re-usable for other import services. -** TODO [#A] Generate a report of tracks with mistmatched metadata :music:tracks:metadata: -:PROPERTIES: -:ID: 684b8cd2-a3c1-4995-ba9e-7abdb02c37f2 -:END: - -*** Description - -We should have a management command that outputs a CSV file of track IDs where the log["raw_data"] artist -does not match the actual track artist. Specific cases are ** TODO [#A] Add an exception list of artists as a constant that are exempted from splitting :music:artists:metadata: :PROPERTIES: @@ -521,6 +512,7 @@ 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 this put into it and then we stop trying to pull the artist apart when we run into it. + ** TODO [#A] Before enriching anything, trust the POST data :feature:scrobbles:metadata: :PROPERTIES: :ID: db6b05f8-09f4-49f5-9838-fbacc9fe9cd0 @@ -542,6 +534,31 @@ async with the POST data stored in the log["raw_data"] and used by the celery en to go try to enrich the media instance. Should this enrichment fail, tag the scrobble as "enrichment-failed" log a warning and move on. + +** DONE [#A] Generate a report of tracks with mistmatched metadata :music:tracks:metadata: +:PROPERTIES: +:ID: 684b8cd2-a3c1-4995-ba9e-7abdb02c37f2 +:END: + +*** Description + +We should have a management command that outputs a CSV file of track IDs where +the log["raw_data"]["Artist"] (for Jellyfin) or log["raw_data"]["artist"] +(mopidy) value does not match the Track.artists names. + +And we should see the same thing for albums (log["raw_data"]["Album"] or +log["raw_data]["album"]). + +It should output the fields "track_id", "track_artist_name", "track_album_name", +"raw_artist", "raw_album", "source" + +Where source is either Jellyfin or Mopidy based on the keys. + +Put the file /tmp/metadata-report.csv by default and overwrite exsiting reports. + +The command should also accept a file-path to overide this default. + + ** DONE [#A] Date parsing failing in eBird imports :birds:ebird:importers: :PROPERTIES: :ID: 72e0254f-39d8-4843-9857-623e0362d77e @@ -565,6 +582,7 @@ We should also add a "error_log" to the importers so that errors tha occur are surfaced, even when 0 successful files were processed. And we should make sure all importers do this as well. + * Version 48.0 [2/2] ** DONE [#B] Show team or player images on sport detail and scrobble detail :sports:templates: :PROPERTIES: diff --git a/vrobbler/apps/music/management/commands/report_mismatched_metadata.py b/vrobbler/apps/music/management/commands/report_mismatched_metadata.py new file mode 100644 index 0000000..8338560 --- /dev/null +++ b/vrobbler/apps/music/management/commands/report_mismatched_metadata.py @@ -0,0 +1,127 @@ +import csv +import logging + +from django.core.management.base import BaseCommand + +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): + help = ( + "Outputs a CSV of track IDs where raw metadata from scrobble logs " + "does not match the track's stored artists or album" + ) + + def add_arguments(self, parser): + parser.add_argument( + "--file-path", + type=str, + default="/tmp/metadata-report.csv", + help="Output CSV file path (default: /tmp/metadata-report.csv)", + ) + + def handle(self, *args, **options): + from scrobbles.models import Scrobble + + file_path = options["file_path"] + + qs = ( + Scrobble.objects.filter(media_type=Scrobble.MediaType.TRACK) + .exclude(log__isnull=True) + .exclude(log={}) + .select_related("track__album") + .prefetch_related("track__artists") + .iterator() + ) + + rows = [] + for scrobble in qs: + track = scrobble.track + if not track: + continue + + raw_data = scrobble.log.get("raw_data") + if not raw_data: + continue + + source = _get_source(raw_data) + if not source: + continue + + raw_artist, raw_album = _get_raw_values(raw_data, source) + if not raw_artist and not raw_album: + continue + + track_artist_names = " / ".join( + track.artists.all().values_list("name", flat=True) + ) + track_album_name = track.album.name if track.album else "" + + if _artist_mismatch(raw_artist, track_artist_names) or _album_mismatch( + raw_album, track_album_name + ): + rows.append( + { + "track_id": track.id, + "track_artist_name": track_artist_names, + "track_album_name": track_album_name, + "raw_artist": raw_artist, + "raw_album": raw_album, + "source": source, + } + ) + + fieldnames = [ + "track_id", + "track_artist_name", + "track_album_name", + "raw_artist", + "raw_album", + "source", + ] + with open(file_path, "w", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + + self.stdout.write( + self.style.SUCCESS( + f"Wrote {len(rows)} mismatched track(s) to {file_path}" + ) + )