123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676 |
- import logging
- from typing import Dict, Optional
- from uuid import uuid4
- import musicbrainzngs
- import requests
- from django.conf import settings
- from django.core.files.base import ContentFile
- from django.db import models
- from django.urls import reverse
- from django.utils.translation import gettext_lazy as _
- from django_extensions.db.models import TimeStampedModel
- from imagekit.models import ImageSpecField
- from imagekit.processors import ResizeToFit
- from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic
- 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.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
- from music.utils import clean_artist_name
- from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
- logger = logging.getLogger(__name__)
- BNULL = {"blank": True, "null": True}
- class Artist(TimeStampedModel):
- """Represents a music artist.
- # Lookup or create by title alone
- >>> Artist.find_or_create(name="Bon Iver")
- # Lookup or create by MB id alone
- >>> Artist.find_or_create(musicbrainz_id="0307edfc-437c-4b48-8700-80680e66a228")
- """
- uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
- name = models.CharField(max_length=255)
- biography = models.TextField(**BNULL)
- theaudiodb_id = models.CharField(max_length=255, unique=True, **BNULL)
- theaudiodb_genre = models.CharField(max_length=255, **BNULL)
- theaudiodb_mood = models.CharField(max_length=255, **BNULL)
- musicbrainz_id = models.CharField(max_length=255, **BNULL)
- allmusic_id = models.CharField(max_length=100, **BNULL)
- bandcamp_id = models.CharField(max_length=100, **BNULL)
- thumbnail = models.ImageField(upload_to="artist/", **BNULL)
- thumbnail_small = ImageSpecField(
- source="thumbnail",
- processors=[ResizeToFit(100, 100)],
- format="JPEG",
- options={"quality": 60},
- )
- thumbnail_medium = ImageSpecField(
- source="thumbnail",
- processors=[ResizeToFit(300, 300)],
- format="JPEG",
- options={"quality": 75},
- )
- alt_names = models.TextField(**BNULL)
- class Meta:
- unique_together = [["name", "musicbrainz_id"]]
- def __str__(self):
- return self.name
- @property
- def primary_image_url(self) -> str:
- if self.thumbnail:
- return self.thumbnail.url
- if self.album_set.first().cover_image:
- return self.album_set.first().cover_image.url
- return ""
- @property
- def mb_link(self) -> str:
- if self.musicbrainz_id:
- return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
- return ""
- @property
- def allmusic_link(self):
- if self.allmusic_id:
- return f"https://www.allmusic.com/artist/{self.allmusic_id}"
- return ""
- @property
- def bandcamp_link(self):
- if self.bandcamp_id:
- return f"https://{self.bandcamp_id}.bandcamp.com/"
- return ""
- def get_absolute_url(self):
- return reverse("music:artist_detail", kwargs={"slug": self.uuid})
- def scrobbles(self):
- from scrobbles.models import Scrobble
- return Scrobble.objects.filter(
- track__in=self.track_set.all()
- ).order_by("-timestamp")
- @property
- def tracks(self):
- return (
- self.track_set.all()
- .annotate(scrobble_count=models.Count("scrobble"))
- .order_by("-scrobble_count")
- )
- def charts(self):
- from scrobbles.models import ChartRecord
- return ChartRecord.objects.filter(track__artist=self).order_by("-year")
- def scrape_allmusic(self, force=False) -> None:
- if not self.allmusic_id or force:
- slug = get_allmusic_slug(self.name)
- if not slug:
- logger.info(
- "No allmusic link found", extra={"track_id": self.id}
- )
- return
- self.allmusic_id = slug
- self.save(update_fields=["allmusic_id"])
- def scrape_bandcamp(self, force=False) -> None:
- if not self.bandcamp_id or force:
- slug = get_bandcamp_slug(self.name)
- if not slug:
- logger.info(
- "No bandcamp link found", extra={"track_id": self.id}
- )
- return
- self.bandcamp_id = slug
- self.save(update_fields=["bandcamp_id"])
- def fix_metadata(self, force_update=False):
- tadb_info = {}
- if self.theaudiodb_id and force_update:
- tadb_info = lookup_artist_from_tadb(self.theaudiodb_id)
- if not self.theaudiodb_id or (force_update and not tadb_info):
- tadb_info = lookup_artist_from_tadb(self.name)
- if not tadb_info:
- logger.warn(
- f"No response from TADB for artist {self.name}, try force_update=True"
- )
- return
- self.biography = tadb_info["biography"]
- self.theaudiodb_genre = tadb_info["genre"]
- self.theaudiodb_mood = tadb_info["mood"]
- thumb_url = tadb_info.get("thumb_url", "")
- if thumb_url:
- r = requests.get(thumb_url)
- if r.status_code == 200:
- fname = f"{self.name}_{self.uuid}.jpg"
- self.thumbnail.save(fname, ContentFile(r.content), save=True)
- @property
- def rym_link(self):
- artist_slug = self.name.lower().replace(" ", "-").replace(",", "")
- return f"https://rateyourmusic.com/artist/{artist_slug}/"
- @property
- def bandcamp_search_link(self):
- artist = self.name.lower()
- return f"https://bandcamp.com/search?q={artist}&item_type=b"
- @classmethod
- def find_or_create(
- cls, name: str = "", musicbrainz_id: str = ""
- ) -> "Artist":
- keys = {}
- if name:
- name = clean_artist_name(name)
- keys["name"] = name
- if musicbrainz_id:
- keys["musicbrainz_id"] = musicbrainz_id
- if not keys:
- raise Exception("Must have name, mb_id or both to lookup artist")
- artist = cls.objects.filter(**keys).first()
- if not artist:
- artist = cls.objects.filter(
- models.Q(name=name) | models.Q(alt_names__icontains=name)
- ).first()
- # Does not exist, look it up from Musicbrainz
- if not artist:
- alt_name = None
- try:
- artist_dict = lookup_artist_from_mb(name)
- musicbrainz_id = musicbrainz_id or artist_dict.get("id", "")
- if name != artist_dict.get("name", ""):
- alt_name = name
- name = artist_dict.get("name", "")
- except ValueError:
- pass
- if musicbrainz_id:
- artist = cls.objects.filter(
- musicbrainz_id=musicbrainz_id
- ).first()
- if artist and alt_name:
- if not artist.alt_names:
- artist.alt_names = alt_name
- else:
- artist.alt_names += f"\\{alt_name}"
- artist.save(update_fields=["alt_names"])
- if not artist:
- artist = cls.objects.create(
- name=name, musicbrainz_id=musicbrainz_id, alt_names=alt_name
- )
- # TODO maybe this should be spun off into an async task?
- artist.fix_metadata()
- return artist
- class Album(TimeStampedModel):
- uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
- name = models.CharField(max_length=255)
- album_artist = models.ForeignKey(
- Artist, related_name="albums", on_delete=models.DO_NOTHING, **BNULL
- )
- artists = models.ManyToManyField(Artist)
- year = models.IntegerField(**BNULL)
- musicbrainz_id = models.CharField(max_length=255, unique=True, **BNULL)
- musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
- musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
- cover_image = models.ImageField(upload_to="albums/", **BNULL)
- cover_image_small = ImageSpecField(
- source="cover_image",
- processors=[ResizeToFit(100, 100)],
- format="JPEG",
- options={"quality": 60},
- )
- cover_image_medium = ImageSpecField(
- source="cover_image",
- processors=[ResizeToFit(300, 300)],
- format="JPEG",
- options={"quality": 75},
- )
- theaudiodb_id = models.CharField(max_length=255, unique=True, **BNULL)
- theaudiodb_description = models.TextField(**BNULL)
- theaudiodb_year_released = models.IntegerField(**BNULL)
- theaudiodb_score = models.FloatField(**BNULL)
- theaudiodb_score_votes = models.IntegerField(**BNULL)
- theaudiodb_genre = models.CharField(max_length=255, **BNULL)
- theaudiodb_style = models.CharField(max_length=255, **BNULL)
- theaudiodb_mood = models.CharField(max_length=255, **BNULL)
- theaudiodb_speed = models.CharField(max_length=255, **BNULL)
- theaudiodb_theme = models.CharField(max_length=255, **BNULL)
- allmusic_id = models.CharField(max_length=255, **BNULL)
- allmusic_rating = models.IntegerField(**BNULL)
- allmusic_review = models.TextField(**BNULL)
- bandcamp_id = models.CharField(max_length=100, **BNULL)
- rateyourmusic_id = models.CharField(max_length=255, **BNULL)
- wikipedia_slug = models.CharField(max_length=255, **BNULL)
- discogs_id = models.CharField(max_length=255, **BNULL)
- wikidata_id = models.CharField(max_length=255, **BNULL)
- alt_names = models.TextField(**BNULL)
- def __str__(self) -> str:
- return "{} by {}".format(self.name, self.album_artist)
- def get_absolute_url(self):
- return reverse("music:album_detail", kwargs={"slug": self.uuid})
- def scrobbles(self):
- from scrobbles.models import Scrobble
- return Scrobble.objects.filter(
- track__in=self.track_set.all()
- ).order_by("-timestamp")
- @property
- def primary_image_url(self) -> str:
- if self.cover_image:
- return self.cover_image_medium.url
- return ""
- @property
- def tracks(self):
- return (
- self.track_set.all()
- .annotate(scrobble_count=models.Count("scrobble"))
- .order_by("-scrobble_count")
- )
- def fix_album_artist(self):
- from music.utils import get_or_create_various_artists
- multiple_artists = self.artists.count() > 1
- if multiple_artists:
- self.album_artist = get_or_create_various_artists()
- else:
- self.album_artist = self.artists.first()
- self.save(update_fields=["album_artist"])
- def scrape_allmusic(self, force=False) -> None:
- if not self.name:
- logger.warning(
- "Album without a name cannot be scraped",
- extra={"album_id": self.id},
- )
- return
- if not self.allmusic_id or force:
- slug = get_allmusic_slug(self.album_artist.name, self.name)
- if not slug:
- logger.info(
- f"No allmsuic link for {self} by {self.album_artist}"
- )
- return
- self.allmusic_id = slug
- self.save(update_fields=["allmusic_id"])
- allmusic_data = None
- if self.allmusic_link:
- allmusic_data = scrape_data_from_allmusic(self.allmusic_link)
- if not allmusic_data:
- logger.info(f"No allmsuic data for {self} by {self.album_artist}")
- return
- self.allmusic_review = allmusic_data["review"]
- self.allmusic_rating = allmusic_data["rating"]
- self.save(update_fields=["allmusic_review", "allmusic_rating"])
- def scrape_theaudiodb(self) -> None:
- artist = "Various Artists"
- if self.album_artist:
- artist = self.album_artist.name
- album_data = lookup_album_from_tadb(self.name, artist)
- if not album_data.get("theaudiodb_id"):
- logger.info(f"No data for {self} found in TheAudioDB")
- return
- Album.objects.filter(pk=self.pk).update(**album_data)
- def scrape_bandcamp(self, force=False) -> None:
- if not self.bandcamp_id or force:
- slug = get_bandcamp_slug(self.album_artist.name, self.name)
- if not slug:
- logger.info(f"No bandcamp link for {self}")
- return
- self.bandcamp_id = slug
- self.save(update_fields=["bandcamp_id"])
- def fix_metadata(self):
- if (
- not self.musicbrainz_albumartist_id
- or not self.year
- or not self.musicbrainz_releasegroup_id
- ):
- musicbrainzngs.set_useragent("vrobbler", "0.3.0")
- mb_data = musicbrainzngs.get_release_by_id(
- self.musicbrainz_id, includes=["artists", "release-groups"]
- )
- if not self.musicbrainz_releasegroup_id:
- self.musicbrainz_releasegroup_id = mb_data["release"][
- "release-group"
- ]["id"]
- if not self.musicbrainz_albumartist_id:
- self.musicbrainz_albumartist_id = mb_data["release"][
- "artist-credit"
- ][0]["artist"]["id"]
- if not self.year:
- try:
- self.year = mb_data["release"]["date"][0:4]
- except KeyError:
- pass
- except IndexError:
- pass
- self.save(
- update_fields=[
- "musicbrainz_albumartist_id",
- "musicbrainz_releasegroup_id",
- "year",
- ]
- )
- new_artist = Artist.objects.filter(
- musicbrainz_id=self.musicbrainz_albumartist_id
- ).first()
- if self.musicbrainz_albumartist_id and new_artist:
- self.artists.add(new_artist)
- if not new_artist:
- for t in self.track_set.all():
- self.artists.add(t.artist)
- if (
- not self.cover_image
- or self.cover_image == "default-image-replace-me"
- ):
- self.fetch_artwork()
- self.fix_album_artist()
- self.scrape_theaudiodb()
- self.scrape_allmusic()
- def fetch_artwork(self, force=False):
- if not self.cover_image and not force:
- if self.musicbrainz_id:
- try:
- img_data = musicbrainzngs.get_image_front(
- self.musicbrainz_id
- )
- name = f"{self.name}_{self.uuid}.jpg"
- self.cover_image = ContentFile(img_data, name=name)
- logger.info(f"Setting image to {name}")
- except musicbrainzngs.ResponseError:
- logger.warning(
- f"No cover art found for {self.name} by release"
- )
- if (
- not self.cover_image
- or self.cover_image == "default-image-replace-me"
- ) and self.musicbrainz_releasegroup_id:
- try:
- img_data = musicbrainzngs.get_release_group_image_front(
- self.musicbrainz_releasegroup_id
- )
- name = f"{self.name}_{self.uuid}.jpg"
- self.cover_image = ContentFile(img_data, name=name)
- logger.info(f"Setting image to {name}")
- except musicbrainzngs.ResponseError:
- logger.warning(
- f"No cover art found for {self.name} by release group"
- )
- if not self.cover_image:
- logger.debug(
- f"No cover art found for release or release group for {self.name}, setting to default"
- )
- self.save()
- @property
- def mb_link(self) -> str:
- return f"https://musicbrainz.org/release/{self.musicbrainz_id}"
- @property
- def allmusic_link(self) -> str:
- if self.allmusic_id:
- return f"https://www.allmusic.com/album/{self.allmusic_id}"
- return ""
- @property
- def wikipedia_link(self):
- if self.wikipedia_slug:
- return f"https://www.wikipedia.org/en/{self.wikipedia_slug}"
- return ""
- @property
- def tadb_link(self):
- if self.theaudiodb_id:
- return f"https://www.theaudiodb.com/album/{self.theaudiodb_id}"
- return ""
- @property
- def rym_link(self):
- artist_slug = self.album_artist.name.lower().replace(" ", "-")
- album_slug = self.name.lower().replace(" ", "-")
- return f"https://rateyourmusic.com/release/album/{artist_slug}/{album_slug}/"
- @property
- def bandcamp_link(self):
- if self.bandcamp_id and self.album_artist.bandcamp_id:
- return f"https://{self.album_artist.bandcamp_id}.bandcamp.com/album/{self.bandcamp_id}"
- return ""
- @property
- def bandcamp_search_link(self):
- artist = self.album_artist.name.lower()
- album = self.name.lower()
- return f"https://bandcamp.com/search?q={album} {artist}&item_type=a"
- @classmethod
- def find_or_create(
- cls, name: str, artist_name: str, musicbrainz_id: str = ""
- ) -> "Album":
- if not name or not artist_name:
- raise Exception(
- "Must have at least name and artist name to lookup album"
- )
- album = None
- if musicbrainz_id:
- album = cls.objects.filter(
- musicbrainz_id=musicbrainz_id,
- name=name,
- album_artist__name=artist_name,
- ).first()
- if not album and musicbrainz_id:
- album = cls.objects.filter(
- musicbrainz_id=musicbrainz_id,
- ).first()
- if not album:
- album = cls.objects.filter(
- models.Q(name=name) | models.Q(alt_names__icontains=name),
- album_artist__name=artist_name,
- ).first()
- if not album:
- alt_name = None
- try:
- album_dict = lookup_album_dict_from_mb(
- name, artist_name=artist_name
- )
- musicbrainz_id = musicbrainz_id or album_dict.get("mb_id", "")
- found_name = album_dict.get("title", "")
- if found_name and name != found_name:
- alt_name = name
- name = found_name
- except ValueError:
- pass
- if musicbrainz_id:
- album = cls.objects.filter(
- musicbrainz_id=musicbrainz_id
- ).first()
- if album and alt_name:
- if not album.alt_names:
- album.alt_names = alt_name
- else:
- album.alt_names += f"\\{alt_name}"
- album.save(update_fields=["alt_names"])
- if not album:
- artist = Artist.find_or_create(name=artist_name)
- album = cls.objects.create(
- name=name,
- album_artist=artist,
- musicbrainz_id=musicbrainz_id,
- alt_names=alt_name,
- )
- # TODO maybe do this in a separate process?
- album.fix_metadata()
- return album
- class Track(ScrobblableMixin):
- COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
- 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)
- musicbrainz_id = models.CharField(max_length=255, **BNULL)
- class Meta:
- unique_together = [["album", "musicbrainz_id"]]
- def __str__(self):
- return f"{self.title} by {self.artist}"
- @property
- def primary_album(self):
- return self.albums.order_by("year").first()
- def get_absolute_url(self):
- return reverse("music:track_detail", kwargs={"slug": self.uuid})
- @property
- def subtitle(self) -> str:
- return str(self.artist)
- @property
- def strings(self) -> ScrobblableConstants:
- return ScrobblableConstants(verb="Listening", tags="notes")
- @property
- def mb_link(self):
- return f"https://musicbrainz.org/recording/{self.musicbrainz_id}"
- @property
- def info_link(self):
- return self.mb_link
- @property
- def primary_image_url(self) -> str:
- url = ""
- if self.artist.thumbnail:
- url = self.artist.thumbnail_medium.url
- if self.album and self.album.cover_image:
- url = self.album.cover_image_medium.url
- return url
- @classmethod
- def find_or_create(
- cls,
- title: str = "",
- artist_name: str = "",
- musicbrainz_id: str = "",
- album_name: str = "",
- run_time_seconds: int = 900,
- enrich: bool = False,
- commit: bool = True,
- ) -> "Track":
- """Given a name, try to find the track by the artist from Musicbrainz.
- As a basic conceit we trust the source for giving us the track and artist
- name
- Optionally, we can update any found artists with overwrite."""
- created = False
- if musicbrainz_id:
- track = cls.objects.filter(musicbrainz_id=musicbrainz_id).first()
- artist = track.artist
- 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."
- )
- else:
- artist = Artist.find_or_create(artist_name)
- track, created = cls.objects.get_or_create(
- title=title, artist=artist
- )
- 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,
- },
- )
- if track.album and album_name != track.album.name:
- # TODO found track, but it's on a different album ... associations?
- logger.info("Found track by artist, but album is different.")
- album = Album.find_or_create()
- if enrich:
- album = None
- if album_name:
- album = Album.find_or_create(album_name)
- if artist.musicbrainz_id:
- track_dict = lookup_track_from_mb(title, artist.musicbrainz_id)
- musicbrainz_id = musicbrainz_id or track_dict.get("id", "")
- found_title: bool = track_dict.get("name", False)
- mismatched_title: bool = title != track_dict.get("name", "")
- if found_title and mismatched_title:
- logger.warning(
- "Source track title and found title do not match",
- extra={"title": title, "track_dict": track_dict},
- )
- if not run_time_seconds:
- run_time_seconds = int(
- int(track_dict.get("length", 900000)) / 1000
- )
- track.album = album
- track.artist = artist
- track.run_time_seconds = run_time_seconds
- if commit:
- track.save()
- # TODO Also set cover art and tags
- return track
|