[music] Add albums to tracks and utility to condense tracks

This commit is contained in:
2025-07-20 21:18:46 -04:00
parent 69401d11c8
commit e8e989bb63
6 changed files with 170 additions and 56 deletions

View File

@ -0,0 +1,20 @@
# Generated by Django 4.2.19 on 2025-07-20 20:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("music", "0026_album_alt_names"),
]
operations = [
migrations.AddField(
model_name="track",
name="albums",
field=models.ManyToManyField(
blank=True, null=True, related_name="tracks", to="music.album"
),
),
]

View File

@ -14,7 +14,12 @@ from imagekit.models import ImageSpecField
from imagekit.processors import ResizeToFit from imagekit.processors import ResizeToFit
from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic
from music.bandcamp import get_bandcamp_slug from music.bandcamp import get_bandcamp_slug
from music.musicbrainz import lookup_album_dict_from_mb, lookup_album_from_mb, lookup_track_from_mb, lookup_artist_from_mb from music.musicbrainz import (
lookup_album_dict_from_mb,
lookup_album_from_mb,
lookup_track_from_mb,
lookup_artist_from_mb,
)
from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
from music.utils import clean_artist_name from music.utils import clean_artist_name
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
@ -22,6 +27,7 @@ from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BNULL = {"blank": True, "null": True} BNULL = {"blank": True, "null": True}
class Artist(TimeStampedModel): class Artist(TimeStampedModel):
"""Represents a music artist. """Represents a music artist.
@ -170,7 +176,9 @@ class Artist(TimeStampedModel):
return f"https://bandcamp.com/search?q={artist}&item_type=b" return f"https://bandcamp.com/search?q={artist}&item_type=b"
@classmethod @classmethod
def find_or_create(cls, name: str = "", musicbrainz_id: str = "") -> "Artist": def find_or_create(
cls, name: str = "", musicbrainz_id: str = ""
) -> "Artist":
keys = {} keys = {}
if name: if name:
name = clean_artist_name(name) name = clean_artist_name(name)
@ -547,12 +555,8 @@ class Album(TimeStampedModel):
class Track(ScrobblableMixin): class Track(ScrobblableMixin):
COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100) COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
class Opinion(models.IntegerChoices):
DOWN = -1, "Thumbs down"
NEUTRAL = 0, "No opinion"
UP = 1, "Thumbs up"
artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING) artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
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)
@ -562,6 +566,10 @@ class Track(ScrobblableMixin):
def __str__(self): def __str__(self):
return f"{self.title} by {self.artist}" return f"{self.title} by {self.artist}"
@property
def primary_album(self):
return self.albums.order_by("year").first()
def get_absolute_url(self): def get_absolute_url(self):
return reverse("music:track_detail", kwargs={"slug": self.uuid}) return reverse("music:track_detail", kwargs={"slug": self.uuid})
@ -590,17 +598,16 @@ class Track(ScrobblableMixin):
url = self.album.cover_image_medium.url url = self.album.cover_image_medium.url
return url return url
@classmethod @classmethod
def find_or_create( def find_or_create(
cls, cls,
title: str = "", title: str = "",
artist_name: str = "", artist_name: str = "",
musicbrainz_id: str = "", musicbrainz_id: str = "",
album_name: str = "", album_name: str = "",
run_time_seconds: int = 900, run_time_seconds: int = 900,
enrich: bool = False, enrich: bool = False,
commit: bool = True commit: bool = True,
) -> "Track": ) -> "Track":
"""Given a name, try to find the track by the artist from Musicbrainz. """Given a name, try to find the track by the artist from Musicbrainz.
@ -613,13 +620,24 @@ class Track(ScrobblableMixin):
track = cls.objects.filter(musicbrainz_id=musicbrainz_id).first() track = cls.objects.filter(musicbrainz_id=musicbrainz_id).first()
artist = track.artist artist = track.artist
if not track and not (title and album_name): if not track and not (title and album_name):
raise Exception("Cannot find track with musicbrainz_id and no track title or artist name provided.") raise Exception(
"Cannot find track with musicbrainz_id and no track title or artist name provided."
)
else: else:
artist = Artist.find_or_create(artist_name) artist = Artist.find_or_create(artist_name)
track, created = cls.objects.get_or_create(title=title, artist=artist) track, created = cls.objects.get_or_create(
title=title, artist=artist
)
if not created: if not created:
logger.info("Found exact match for track by name and artist", extra={"title": title, "artist_name": artist_name, "track_id": track.id}) logger.info(
"Found exact match for track by name and artist",
extra={
"title": title,
"artist_name": artist_name,
"track_id": track.id,
},
)
if track.album and album_name != track.album.name: if track.album and album_name != track.album.name:
# TODO found track, but it's on a different album ... associations? # TODO found track, but it's on a different album ... associations?
@ -638,14 +656,16 @@ class Track(ScrobblableMixin):
found_title: bool = track_dict.get("name", False) found_title: bool = track_dict.get("name", False)
mismatched_title: bool = title != track_dict.get("name", "") mismatched_title: bool = title != track_dict.get("name", "")
if found_title and mismatched_title: if found_title and mismatched_title:
logger.warning("Source track title and found title do not match", extra={"title": title, "track_dict": track_dict}) logger.warning(
"Source track title and found title do not match",
extra={"title": title, "track_dict": track_dict},
)
if not run_time_seconds: if not run_time_seconds:
run_time_seconds = int( run_time_seconds = int(
int(track_dict.get("length", 900000)) / 1000 int(track_dict.get("length", 900000)) / 1000
) )
track.album = album track.album = album
track.artist = artist track.artist = artist
track.run_time_seconds = run_time_seconds track.run_time_seconds = run_time_seconds

View File

@ -1,10 +1,12 @@
import logging import logging
import re import re
from django.db import IntegrityError, models, transaction
from music.constants import VARIOUS_ARTIST_DICT from music.constants import VARIOUS_ARTIST_DICT
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def clean_artist_name(name: str) -> str: def clean_artist_name(name: str) -> str:
"""Remove featured names from artist string.""" """Remove featured names from artist string."""
if "feat." in name.lower(): if "feat." in name.lower():
@ -16,6 +18,7 @@ def clean_artist_name(name: str) -> str:
return name return 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
@ -24,3 +27,85 @@ def get_or_create_various_artists() -> "Artist":
artist = Artist.objects.create(**VARIOUS_ARTIST_DICT) artist = Artist.objects.create(**VARIOUS_ARTIST_DICT)
logger.info("Created Various Artists placeholder") logger.info("Created Various Artists placeholder")
return artist return artist
def deduplicate_tracks(commit=False) -> int:
from music.models import Track
duplicates = (
Track.objects.values("artist", "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"])
duplicate_tracks = Track.objects.filter(query)
for b in duplicate_tracks:
tracks = Track.objects.filter(artist=b.artist, title=b.title)
first = tracks.first()
for other in tracks.exclude(id=first.id):
print("Moving scrobbles for", other.id, " to ", first.id)
if commit:
with transaction.atomic():
other.scrobble_set.update(track=first)
print("deleting ", other.id, " - ", other)
try:
other.delete()
except IntegrityError as e:
print(
"could not delete ",
other.id,
f": IntegrityError {e}",
)
return len(duplicate_tracks)
def condense_albums(commit: bool = False):
from music.models import Track
from scrobbles.models import Scrobble
processed_ids = []
for track in Track.objects.all():
albums_to_add = []
duplicates = (
Track.objects.filter(title=track.title, artist=track.artist)
.exclude(id=track.id)
.exclude(id__in=processed_ids)
)
if commit and track.album:
albums_to_add.append(track.album)
for dup_track in duplicates:
logger.info(f"Adding {dup_track.album} to {track} albums")
if commit and dup_track.album:
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
)
if duplicates.count() > 1:
logger.info(f"Track appears more than once, condensing: {track}")
albums_to_add.extend([d.album for d in duplicates])
# Find all scrobbles
duplicate_ids = duplicates.values_list("id", flat=True)
scrobbles = Scrobble.objects.filter(track_id__in=duplicate_ids)
logger.info(
f"Found {scrobbles.count()} scrobbles to merge onto {track}"
)
if commit:
scrobbles.update(track=track)
track.albums.add(*list(set(albums_to_add)))
processed_ids.extend(duplicate_ids)
if commit:
Track.objects.filter(scrobble__isnull=True).delete()
return len(set(processed_ids))

View File

@ -0,0 +1,19 @@
from django.core.management.base import BaseCommand
from vrobbler.apps.music.utils import condense_albums
class Command(BaseCommand):
def add_arguments(self, parser):
parser.add_argument(
"--commit",
action="store_true",
help="Commit changes",
)
def handle(self, *args, **options):
commit = False
if options["commit"]:
commit = True
print(f"Condensing albums")
album_count = condense_albums(commit)
print(f"Condensed {album_count} albums")

View File

@ -1,5 +1,5 @@
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from vrobbler.apps.scrobbles.utils import deduplicate_tracks from vrobbler.apps.music.utils import deduplicate_tracks
class Command(BaseCommand): class Command(BaseCommand):
@ -14,5 +14,7 @@ class Command(BaseCommand):
commit = False commit = False
if options["commit"]: if options["commit"]:
commit = True commit = True
else:
print("No changes will be saved, use --commit to save")
dups = deduplicate_tracks(commit=commit) dups = deduplicate_tracks(commit=commit)
print(f"Deduplicated {dups} music tracks") print(f"Deduplicated {dups} music tracks")

View File

@ -2,17 +2,17 @@ import hashlib
import logging import logging
import re import re
from datetime import datetime, timedelta, tzinfo from datetime import datetime, timedelta, tzinfo
from sqlite3 import IntegrityError
from urllib.parse import urlparse from urllib.parse import urlparse
import pytz import pytz
from django.apps import apps from django.apps import apps
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.db import models, transaction from django.db import models
from django.utils import timezone from django.utils import timezone
from profiles.models import UserProfile from profiles.models import UserProfile
from profiles.utils import now_user_timezone from profiles.utils import now_user_timezone
from scrobbles.constants import LONG_PLAY_MEDIA from scrobbles.constants import LONG_PLAY_MEDIA
from scrobbles.notifications import NtfyNotification
from scrobbles.tasks import ( from scrobbles.tasks import (
process_koreader_import, process_koreader_import,
process_lastfm_import, process_lastfm_import,
@ -20,8 +20,6 @@ from scrobbles.tasks import (
) )
from webdav.client import get_webdav_client from webdav.client import get_webdav_client
from vrobbler.apps.scrobbles.notifications import NtfyNotification
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
User = get_user_model() User = get_user_model()
@ -275,36 +273,6 @@ def get_file_md5_hash(file_path: str) -> str:
return file_hash.hexdigest() return file_hash.hexdigest()
def deduplicate_tracks(commit=False) -> int:
from music.models import Track
# TODO This whole thing should iterate over users
dups = []
for t in Track.objects.all():
if Track.objects.filter(title=t.title, artist=t.artist).exists():
dups.append(t)
for b in dups:
tracks = Track.objects.filter(artist=b.artist, title=b.title)
first = tracks.first()
for other in tracks.exclude(id=first.id):
print("moving scrobbles for ", other.id, " to ", first.id)
if commit:
with transaction.atomic():
other.scrobble_set.update(track=first)
print("deleting ", other.id, " - ", other)
try:
other.delete()
except IntegrityError as e:
print(
"could not delete ",
other.id,
f": IntegrityError {e}",
)
return len(dups)
def send_stop_notifications_for_in_progress_scrobbles() -> int: def send_stop_notifications_for_in_progress_scrobbles() -> int:
"""Get all inprogress scrobbles and check if they're passed their media obj length. """Get all inprogress scrobbles and check if they're passed their media obj length.