|
@@ -1,13 +1,11 @@
|
|
|
import logging
|
|
|
-from tempfile import NamedTemporaryFile
|
|
|
from typing import Dict, Optional
|
|
|
-from urllib.request import urlopen
|
|
|
from uuid import uuid4
|
|
|
|
|
|
import musicbrainzngs
|
|
|
import requests
|
|
|
from django.conf import settings
|
|
|
-from django.core.files.base import ContentFile, File
|
|
|
+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 _
|
|
@@ -16,6 +14,7 @@ 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_track_from_mb
|
|
|
from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
|
|
|
from scrobbles.mixins import ScrobblableConstants, ScrobblableMixin
|
|
|
|
|
@@ -24,6 +23,16 @@ 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)
|
|
@@ -46,6 +55,7 @@ class Artist(TimeStampedModel):
|
|
|
format="JPEG",
|
|
|
options={"quality": 75},
|
|
|
)
|
|
|
+ alt_names = models.TextField(**BNULL)
|
|
|
|
|
|
class Meta:
|
|
|
unique_together = [["name", "musicbrainz_id"]]
|
|
@@ -62,8 +72,10 @@ class Artist(TimeStampedModel):
|
|
|
return ""
|
|
|
|
|
|
@property
|
|
|
- def mb_link(self):
|
|
|
- return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
|
|
|
+ def mb_link(self) -> str:
|
|
|
+ if self.musicbrainz_id:
|
|
|
+ return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
|
|
|
+ return ""
|
|
|
|
|
|
@property
|
|
|
def allmusic_link(self):
|
|
@@ -104,7 +116,9 @@ class Artist(TimeStampedModel):
|
|
|
if not self.allmusic_id or force:
|
|
|
slug = get_allmusic_slug(self.name)
|
|
|
if not slug:
|
|
|
- logger.info(f"No allmsuic link for {self}")
|
|
|
+ logger.info(
|
|
|
+ "No allmusic link found", extra={"track_id": self.id}
|
|
|
+ )
|
|
|
return
|
|
|
self.allmusic_id = slug
|
|
|
self.save(update_fields=["allmusic_id"])
|
|
@@ -113,7 +127,9 @@ class Artist(TimeStampedModel):
|
|
|
if not self.bandcamp_id or force:
|
|
|
slug = get_bandcamp_slug(self.name)
|
|
|
if not slug:
|
|
|
- logger.info(f"No bandcamp link for {self}")
|
|
|
+ logger.info(
|
|
|
+ "No bandcamp link found", extra={"track_id": self.id}
|
|
|
+ )
|
|
|
return
|
|
|
self.bandcamp_id = slug
|
|
|
self.save(update_fields=["bandcamp_id"])
|
|
@@ -153,6 +169,61 @@ class Artist(TimeStampedModel):
|
|
|
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":
|
|
|
+ from music.musicbrainz import lookup_artist_from_mb
|
|
|
+ from music.utils import clean_artist_name
|
|
|
+
|
|
|
+ if not name:
|
|
|
+ raise Exception("Must have name to lookup artist")
|
|
|
+
|
|
|
+ artist = None
|
|
|
+ name = clean_artist_name(name)
|
|
|
+
|
|
|
+ # Check for name/mbid combo, just mbid and then just name
|
|
|
+ if musicbrainz_id:
|
|
|
+ artist = cls.objects.filter(
|
|
|
+ name=name, musicbrainz_id=musicbrainz_id
|
|
|
+ ).first()
|
|
|
+ if not artist:
|
|
|
+ artist = cls.objects.filter(musicbrainz_id=musicbrainz_id).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)
|
|
@@ -196,9 +267,10 @@ class Album(TimeStampedModel):
|
|
|
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):
|
|
|
- return self.name
|
|
|
+ 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})
|
|
@@ -402,6 +474,69 @@ class Album(TimeStampedModel):
|
|
|
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)
|
|
@@ -425,8 +560,8 @@ class Track(ScrobblableMixin):
|
|
|
return reverse("music:track_detail", kwargs={"slug": self.uuid})
|
|
|
|
|
|
@property
|
|
|
- def subtitle(self):
|
|
|
- return self.artist
|
|
|
+ def subtitle(self) -> str:
|
|
|
+ return str(self.artist)
|
|
|
|
|
|
@property
|
|
|
def strings(self) -> ScrobblableConstants:
|
|
@@ -451,31 +586,85 @@ class Track(ScrobblableMixin):
|
|
|
|
|
|
@classmethod
|
|
|
def find_or_create(
|
|
|
- cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict
|
|
|
- ) -> Optional["Track"]:
|
|
|
- """Given a data dict from Jellyfin, does the heavy lifting of looking up
|
|
|
- the video and, if need, TV Series, creating both if they don't yet
|
|
|
- exist.
|
|
|
-
|
|
|
- """
|
|
|
- if not artist_dict.get("name") or not artist_dict.get(
|
|
|
- "musicbrainz_id"
|
|
|
- ):
|
|
|
- logger.warning(
|
|
|
- f"No artist or artist musicbrainz ID found in message from source, not scrobbling"
|
|
|
- )
|
|
|
- return
|
|
|
-
|
|
|
- artist, artist_created = Artist.objects.get_or_create(**artist_dict)
|
|
|
- album, album_created = Album.objects.get_or_create(**album_dict)
|
|
|
+ cls,
|
|
|
+ title: str = "",
|
|
|
+ musicbrainz_id: str = "",
|
|
|
+ album_name: str = "",
|
|
|
+ artist_name: str = "",
|
|
|
+ enrich: bool = True,
|
|
|
+ run_time_seconds: Optional[int] = None,
|
|
|
+ ) -> "Track":
|
|
|
+ # TODO we can use Q to build queries here based on whether we have mbid and album name
|
|
|
+ track = None
|
|
|
+ # Full look up with MB ID
|
|
|
+ if album_name:
|
|
|
+ track = cls.objects.filter(
|
|
|
+ musicbrainz_id=musicbrainz_id,
|
|
|
+ title=title,
|
|
|
+ artist__name=artist_name,
|
|
|
+ album__name=album_name,
|
|
|
+ ).first()
|
|
|
+ # Full look up without album
|
|
|
+ if not track:
|
|
|
+ track = cls.objects.filter(
|
|
|
+ musicbrainz_id=musicbrainz_id,
|
|
|
+ title=title,
|
|
|
+ artist__name=artist_name,
|
|
|
+ ).first()
|
|
|
|
|
|
- album.fix_metadata()
|
|
|
- if not album.cover_image:
|
|
|
- album.fetch_artwork()
|
|
|
+ # Full look up without MB ID
|
|
|
+ if not track:
|
|
|
+ track = cls.objects.filter(
|
|
|
+ title=title,
|
|
|
+ artist__name=artist_name,
|
|
|
+ album__name=album_name,
|
|
|
+ ).first()
|
|
|
+ # Base look up without MB ID or album
|
|
|
+ if not track:
|
|
|
+ track = cls.objects.filter(
|
|
|
+ title=title,
|
|
|
+ artist__name=artist_name,
|
|
|
+ ).first()
|
|
|
|
|
|
- track_dict["album_id"] = getattr(album, "id", None)
|
|
|
- track_dict["artist_id"] = artist.id
|
|
|
+ if not track and enrich:
|
|
|
+ track_dict = lookup_track_from_mb(title, artist_name, album_name)
|
|
|
+ musicbrainz_id = musicbrainz_id or track_dict.get("id", "")
|
|
|
+ # TODO This only works some of the time
|
|
|
+ # try:
|
|
|
+ # album_name = album_name or track_dict.get("release-list")[
|
|
|
+ # 0
|
|
|
+ # ].get("title", "")
|
|
|
+ # except IndexError:
|
|
|
+ # pass
|
|
|
+ if not run_time_seconds:
|
|
|
+ run_time_seconds = int(
|
|
|
+ int(track_dict.get("length", 900000)) / 1000
|
|
|
+ )
|
|
|
+ if title != track_dict.get("name", "") and track_dict.get(
|
|
|
+ "name", False
|
|
|
+ ):
|
|
|
|
|
|
- track, created = cls.objects.get_or_create(**track_dict)
|
|
|
+ title = track_dict.get("name", "")
|
|
|
+
|
|
|
+ if musicbrainz_id:
|
|
|
+ track = cls.objects.filter(
|
|
|
+ musicbrainz_id=musicbrainz_id
|
|
|
+ ).first()
|
|
|
+ if not track:
|
|
|
+ artist = Artist.find_or_create(name=artist_name)
|
|
|
+ album = None
|
|
|
+ if album_name:
|
|
|
+ album = Album.find_or_create(
|
|
|
+ name=album_name, artist_name=artist_name
|
|
|
+ )
|
|
|
+ track = cls.objects.create(
|
|
|
+ title=title,
|
|
|
+ album=album,
|
|
|
+ musicbrainz_id=musicbrainz_id,
|
|
|
+ artist=artist,
|
|
|
+ run_time_seconds=run_time_seconds,
|
|
|
+ )
|
|
|
+ # TODO maybe do this in a separate process?
|
|
|
+ track.fix_metadata()
|
|
|
|
|
|
return track
|