models.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. import requests
  2. import logging
  3. from tempfile import NamedTemporaryFile
  4. from typing import Dict, Optional
  5. from urllib.request import urlopen
  6. from uuid import uuid4
  7. import musicbrainzngs
  8. from django.conf import settings
  9. from django.core.files.base import ContentFile, File
  10. from django.db import models
  11. from django.urls import reverse
  12. from django.utils.translation import gettext_lazy as _
  13. from django_extensions.db.models import TimeStampedModel
  14. from scrobbles.mixins import ScrobblableMixin
  15. from music.theaudiodb import lookup_artist_from_tadb, lookup_album_from_tadb
  16. logger = logging.getLogger(__name__)
  17. BNULL = {"blank": True, "null": True}
  18. class Artist(TimeStampedModel):
  19. uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
  20. name = models.CharField(max_length=255)
  21. biography = models.TextField(**BNULL)
  22. theaudiodb_genre = models.CharField(max_length=255, **BNULL)
  23. theaudiodb_mood = models.CharField(max_length=255, **BNULL)
  24. musicbrainz_id = models.CharField(max_length=255, **BNULL)
  25. thumbnail = models.ImageField(upload_to="artist/", **BNULL)
  26. class Meta:
  27. unique_together = [["name", "musicbrainz_id"]]
  28. def __str__(self):
  29. return self.name
  30. @property
  31. def mb_link(self):
  32. return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
  33. def get_absolute_url(self):
  34. return reverse("music:artist_detail", kwargs={"slug": self.uuid})
  35. def scrobbles(self):
  36. from scrobbles.models import Scrobble
  37. return Scrobble.objects.filter(
  38. track__in=self.track_set.all()
  39. ).order_by("-timestamp")
  40. @property
  41. def tracks(self):
  42. return (
  43. self.track_set.all()
  44. .annotate(scrobble_count=models.Count("scrobble"))
  45. .order_by("-scrobble_count")
  46. )
  47. def charts(self):
  48. from scrobbles.models import ChartRecord
  49. return ChartRecord.objects.filter(track__artist=self).order_by("-year")
  50. def fix_metadata(self):
  51. tadb_info = lookup_artist_from_tadb(self.name)
  52. if not tadb_info:
  53. logger.warn(f"No response from TADB for artist {self.name}")
  54. return
  55. self.biography = tadb_info["biography"]
  56. self.theaudiodb_genre = tadb_info["genre"]
  57. self.theaudiodb_mood = tadb_info["mood"]
  58. r = requests.get(tadb_info.get("thumb_url", ""))
  59. if r.status_code == 200:
  60. fname = f"{self.name}_{self.uuid}.jpg"
  61. self.thumbnail.save(fname, ContentFile(r.content), save=True)
  62. @property
  63. def rym_link(self):
  64. artist_slug = self.name.lower().replace(" ", "-")
  65. return f"https://rateyourmusic.com/artist/{artist_slug}/"
  66. @property
  67. def bandcamp_search_link(self):
  68. artist = self.name.lower()
  69. return f"https://bandcamp.com/search?q={artist}&item_type=b"
  70. class Album(TimeStampedModel):
  71. uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
  72. name = models.CharField(max_length=255)
  73. artists = models.ManyToManyField(Artist)
  74. year = models.IntegerField(**BNULL)
  75. musicbrainz_id = models.CharField(max_length=255, unique=True, **BNULL)
  76. musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
  77. musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
  78. cover_image = models.ImageField(upload_to="albums/", **BNULL)
  79. theaudiodb_id = models.CharField(max_length=255, unique=True, **BNULL)
  80. theaudiodb_description = models.TextField(**BNULL)
  81. theaudiodb_year_released = models.IntegerField(**BNULL)
  82. theaudiodb_score = models.FloatField(**BNULL)
  83. theaudiodb_score_votes = models.IntegerField(**BNULL)
  84. theaudiodb_genre = models.CharField(max_length=255, **BNULL)
  85. theaudiodb_style = models.CharField(max_length=255, **BNULL)
  86. theaudiodb_mood = models.CharField(max_length=255, **BNULL)
  87. theaudiodb_speed = models.CharField(max_length=255, **BNULL)
  88. theaudiodb_theme = models.CharField(max_length=255, **BNULL)
  89. allmusic_id = models.CharField(max_length=255, **BNULL)
  90. rateyourmusic_id = models.CharField(max_length=255, **BNULL)
  91. wikipedia_slug = models.CharField(max_length=255, **BNULL)
  92. discogs_id = models.CharField(max_length=255, **BNULL)
  93. wikidata_id = models.CharField(max_length=255, **BNULL)
  94. def __str__(self):
  95. return self.name
  96. def get_absolute_url(self):
  97. return reverse("music:album_detail", kwargs={"slug": self.uuid})
  98. def scrobbles(self):
  99. from scrobbles.models import Scrobble
  100. return Scrobble.objects.filter(
  101. track__in=self.track_set.all()
  102. ).order_by("-timestamp")
  103. @property
  104. def tracks(self):
  105. return (
  106. self.track_set.all()
  107. .annotate(scrobble_count=models.Count("scrobble"))
  108. .order_by("-scrobble_count")
  109. )
  110. @property
  111. def primary_artist(self):
  112. return self.artists.first()
  113. def scrape_theaudiodb(self) -> None:
  114. artist = "Various Artists"
  115. if self.primary_artist:
  116. artist = self.primary_artist.name
  117. album_data = lookup_album_from_tadb(self.name, artist)
  118. if not album_data.get("theaudiodb_id"):
  119. logger.info(f"No data for {self} found in TheAudioDB")
  120. return
  121. Album.objects.filter(pk=self.pk).update(**album_data)
  122. def fix_metadata(self):
  123. if (
  124. not self.musicbrainz_albumartist_id
  125. or not self.year
  126. or not self.musicbrainz_releasegroup_id
  127. ):
  128. musicbrainzngs.set_useragent("vrobbler", "0.3.0")
  129. mb_data = musicbrainzngs.get_release_by_id(
  130. self.musicbrainz_id, includes=["artists", "release-groups"]
  131. )
  132. if not self.musicbrainz_releasegroup_id:
  133. self.musicbrainz_releasegroup_id = mb_data["release"][
  134. "release-group"
  135. ]["id"]
  136. if not self.musicbrainz_albumartist_id:
  137. self.musicbrainz_albumartist_id = mb_data["release"][
  138. "artist-credit"
  139. ][0]["artist"]["id"]
  140. if not self.year:
  141. try:
  142. self.year = mb_data["release"]["date"][0:4]
  143. except KeyError:
  144. pass
  145. except IndexError:
  146. pass
  147. self.save(
  148. update_fields=[
  149. "musicbrainz_albumartist_id",
  150. "musicbrainz_releasegroup_id",
  151. "year",
  152. ]
  153. )
  154. new_artist = Artist.objects.filter(
  155. musicbrainz_id=self.musicbrainz_albumartist_id
  156. ).first()
  157. if self.musicbrainz_albumartist_id and new_artist:
  158. self.artists.add(new_artist)
  159. if not new_artist:
  160. for t in self.track_set.all():
  161. self.artists.add(t.artist)
  162. if (
  163. not self.cover_image
  164. or self.cover_image == "default-image-replace-me"
  165. ):
  166. self.fetch_artwork()
  167. self.scrape_theaudiodb()
  168. def fetch_artwork(self, force=False):
  169. if not self.cover_image and not force:
  170. if self.musicbrainz_id:
  171. try:
  172. img_data = musicbrainzngs.get_image_front(
  173. self.musicbrainz_id
  174. )
  175. name = f"{self.name}_{self.uuid}.jpg"
  176. self.cover_image = ContentFile(img_data, name=name)
  177. logger.info(f"Setting image to {name}")
  178. except musicbrainzngs.ResponseError:
  179. logger.warning(
  180. f"No cover art found for {self.name} by release"
  181. )
  182. if (
  183. not self.cover_image
  184. or self.cover_image == "default-image-replace-me"
  185. ) and self.musicbrainz_releasegroup_id:
  186. try:
  187. img_data = musicbrainzngs.get_release_group_image_front(
  188. self.musicbrainz_releasegroup_id
  189. )
  190. name = f"{self.name}_{self.uuid}.jpg"
  191. self.cover_image = ContentFile(img_data, name=name)
  192. logger.info(f"Setting image to {name}")
  193. except musicbrainzngs.ResponseError:
  194. logger.warning(
  195. f"No cover art found for {self.name} by release group"
  196. )
  197. if not self.cover_image:
  198. logger.debug(
  199. f"No cover art found for release or release group for {self.name}, setting to default"
  200. )
  201. self.save()
  202. @property
  203. def mb_link(self) -> str:
  204. return f"https://musicbrainz.org/release/{self.musicbrainz_id}"
  205. @property
  206. def allmusic_link(self) -> str:
  207. if self.allmusic_id:
  208. return f"https://www.allmusic.com/artist/{self.allmusic_id}"
  209. return ""
  210. @property
  211. def wikipedia_link(self):
  212. if self.wikipedia_slug:
  213. return f"https://www.wikipedia.org/en/{self.wikipedia_slug}"
  214. return ""
  215. @property
  216. def tadb_link(self):
  217. if self.theaudiodb_id:
  218. return f"https://www.theaudiodb.com/album/{self.theaudiodb_id}"
  219. return ""
  220. @property
  221. def rym_link(self):
  222. artist_slug = self.primary_artist.name.lower().replace(" ", "-")
  223. album_slug = self.name.lower().replace(" ", "-")
  224. return f"https://rateyourmusic.com/release/album/{artist_slug}/{album_slug}/"
  225. @property
  226. def bandcamp_search_link(self):
  227. artist = self.primary_artist.name.lower()
  228. album = self.name.lower()
  229. return f"https://bandcamp.com/search?q={album} {artist}&item_type=a"
  230. class Track(ScrobblableMixin):
  231. COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 90)
  232. class Opinion(models.IntegerChoices):
  233. DOWN = -1, "Thumbs down"
  234. NEUTRAL = 0, "No opinion"
  235. UP = 1, "Thumbs up"
  236. artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
  237. album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
  238. musicbrainz_id = models.CharField(max_length=255, **BNULL)
  239. class Meta:
  240. unique_together = [["album", "musicbrainz_id"]]
  241. def __str__(self):
  242. return f"{self.title} by {self.artist}"
  243. def get_absolute_url(self):
  244. return reverse("music:track_detail", kwargs={"slug": self.uuid})
  245. @property
  246. def subtitle(self):
  247. return self.artist
  248. @property
  249. def mb_link(self):
  250. return f"https://musicbrainz.org/recording/{self.musicbrainz_id}"
  251. @property
  252. def info_link(self):
  253. return self.mb_link
  254. @classmethod
  255. def find_or_create(
  256. cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict
  257. ) -> Optional["Track"]:
  258. """Given a data dict from Jellyfin, does the heavy lifting of looking up
  259. the video and, if need, TV Series, creating both if they don't yet
  260. exist.
  261. """
  262. if not artist_dict.get("name") or not artist_dict.get(
  263. "musicbrainz_id"
  264. ):
  265. logger.warning(
  266. f"No artist or artist musicbrainz ID found in message from source, not scrobbling"
  267. )
  268. return
  269. artist, artist_created = Artist.objects.get_or_create(**artist_dict)
  270. album, album_created = Album.objects.get_or_create(**album_dict)
  271. album.fix_metadata()
  272. if not album.cover_image:
  273. album.fetch_artwork()
  274. track_dict["album_id"] = getattr(album, "id", None)
  275. track_dict["artist_id"] = artist.id
  276. track, created = cls.objects.get_or_create(**track_dict)
  277. return track