models.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. import logging
  2. from tempfile import NamedTemporaryFile
  3. from typing import Dict, Optional
  4. from urllib.request import urlopen
  5. from uuid import uuid4
  6. import musicbrainzngs
  7. import requests
  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 imagekit.models import ImageSpecField
  15. from imagekit.processors import ResizeToFit
  16. from music.allmusic import get_allmusic_slug, scrape_data_from_allmusic
  17. from music.bandcamp import get_bandcamp_slug
  18. from music.theaudiodb import lookup_album_from_tadb, lookup_artist_from_tadb
  19. from scrobbles.mixins import ScrobblableMixin
  20. logger = logging.getLogger(__name__)
  21. BNULL = {"blank": True, "null": True}
  22. class Artist(TimeStampedModel):
  23. uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
  24. name = models.CharField(max_length=255)
  25. biography = models.TextField(**BNULL)
  26. theaudiodb_id = models.CharField(max_length=255, unique=True, **BNULL)
  27. theaudiodb_genre = models.CharField(max_length=255, **BNULL)
  28. theaudiodb_mood = models.CharField(max_length=255, **BNULL)
  29. musicbrainz_id = models.CharField(max_length=255, **BNULL)
  30. allmusic_id = models.CharField(max_length=100, **BNULL)
  31. bandcamp_id = models.CharField(max_length=100, **BNULL)
  32. thumbnail = models.ImageField(upload_to="artist/", **BNULL)
  33. thumbnail_small = ImageSpecField(
  34. source="thumbnail",
  35. processors=[ResizeToFit(100, 100)],
  36. format="JPEG",
  37. options={"quality": 60},
  38. )
  39. thumbnail_medium = ImageSpecField(
  40. source="thumbnail",
  41. processors=[ResizeToFit(300, 300)],
  42. format="JPEG",
  43. options={"quality": 75},
  44. )
  45. class Meta:
  46. unique_together = [["name", "musicbrainz_id"]]
  47. def __str__(self):
  48. return self.name
  49. @property
  50. def primary_image_url(self) -> str:
  51. if self.thumbnail:
  52. return self.thumbnail.url
  53. if self.album_set.first().cover_image:
  54. return self.album_set.first().cover_image.url
  55. return ""
  56. @property
  57. def mb_link(self):
  58. return f"https://musicbrainz.org/artist/{self.musicbrainz_id}"
  59. @property
  60. def allmusic_link(self):
  61. if self.allmusic_id:
  62. return f"https://www.allmusic.com/artist/{self.allmusic_id}"
  63. return ""
  64. @property
  65. def bandcamp_link(self):
  66. if self.bandcamp_id:
  67. return f"https://{self.bandcamp_id}.bandcamp.com/"
  68. return ""
  69. def get_absolute_url(self):
  70. return reverse("music:artist_detail", kwargs={"slug": self.uuid})
  71. def scrobbles(self):
  72. from scrobbles.models import Scrobble
  73. return Scrobble.objects.filter(
  74. track__in=self.track_set.all()
  75. ).order_by("-timestamp")
  76. @property
  77. def tracks(self):
  78. return (
  79. self.track_set.all()
  80. .annotate(scrobble_count=models.Count("scrobble"))
  81. .order_by("-scrobble_count")
  82. )
  83. def charts(self):
  84. from scrobbles.models import ChartRecord
  85. return ChartRecord.objects.filter(track__artist=self).order_by("-year")
  86. def scrape_allmusic(self, force=False) -> None:
  87. if not self.allmusic_id or force:
  88. slug = get_allmusic_slug(self.name)
  89. if not slug:
  90. logger.info(f"No allmsuic link for {self}")
  91. return
  92. self.allmusic_id = slug
  93. self.save(update_fields=["allmusic_id"])
  94. def scrape_bandcamp(self, force=False) -> None:
  95. if not self.bandcamp_id or force:
  96. slug = get_bandcamp_slug(self.name)
  97. if not slug:
  98. logger.info(f"No bandcamp link for {self}")
  99. return
  100. self.bandcamp_id = slug
  101. self.save(update_fields=["bandcamp_id"])
  102. def fix_metadata(self, force_update=False):
  103. tadb_info = {}
  104. if self.theaudiodb_id and force_update:
  105. tadb_info = lookup_artist_from_tadb(self.theaudiodb_id)
  106. if not self.theaudiodb_id or (force_update and not tadb_info):
  107. tadb_info = lookup_artist_from_tadb(self.name)
  108. if not tadb_info:
  109. logger.warn(
  110. f"No response from TADB for artist {self.name}, try force_update=True"
  111. )
  112. return
  113. self.biography = tadb_info["biography"]
  114. self.theaudiodb_genre = tadb_info["genre"]
  115. self.theaudiodb_mood = tadb_info["mood"]
  116. thumb_url = tadb_info.get("thumb_url", "")
  117. if thumb_url:
  118. r = requests.get(thumb_url)
  119. if r.status_code == 200:
  120. fname = f"{self.name}_{self.uuid}.jpg"
  121. self.thumbnail.save(fname, ContentFile(r.content), save=True)
  122. @property
  123. def rym_link(self):
  124. artist_slug = self.name.lower().replace(" ", "-")
  125. return f"https://rateyourmusic.com/artist/{artist_slug}/"
  126. @property
  127. def bandcamp_search_link(self):
  128. artist = self.name.lower()
  129. return f"https://bandcamp.com/search?q={artist}&item_type=b"
  130. class Album(TimeStampedModel):
  131. uuid = models.UUIDField(default=uuid4, editable=False, **BNULL)
  132. name = models.CharField(max_length=255)
  133. album_artist = models.ForeignKey(
  134. Artist, related_name="albums", on_delete=models.DO_NOTHING, **BNULL
  135. )
  136. artists = models.ManyToManyField(Artist)
  137. year = models.IntegerField(**BNULL)
  138. musicbrainz_id = models.CharField(max_length=255, unique=True, **BNULL)
  139. musicbrainz_releasegroup_id = models.CharField(max_length=255, **BNULL)
  140. musicbrainz_albumartist_id = models.CharField(max_length=255, **BNULL)
  141. cover_image = models.ImageField(upload_to="albums/", **BNULL)
  142. cover_image_small = ImageSpecField(
  143. source="cover_image",
  144. processors=[ResizeToFit(100, 100)],
  145. format="JPEG",
  146. options={"quality": 60},
  147. )
  148. cover_image_medium = ImageSpecField(
  149. source="cover_image",
  150. processors=[ResizeToFit(300, 300)],
  151. format="JPEG",
  152. options={"quality": 75},
  153. )
  154. theaudiodb_id = models.CharField(max_length=255, unique=True, **BNULL)
  155. theaudiodb_description = models.TextField(**BNULL)
  156. theaudiodb_year_released = models.IntegerField(**BNULL)
  157. theaudiodb_score = models.FloatField(**BNULL)
  158. theaudiodb_score_votes = models.IntegerField(**BNULL)
  159. theaudiodb_genre = models.CharField(max_length=255, **BNULL)
  160. theaudiodb_style = models.CharField(max_length=255, **BNULL)
  161. theaudiodb_mood = models.CharField(max_length=255, **BNULL)
  162. theaudiodb_speed = models.CharField(max_length=255, **BNULL)
  163. theaudiodb_theme = models.CharField(max_length=255, **BNULL)
  164. allmusic_id = models.CharField(max_length=255, **BNULL)
  165. allmusic_rating = models.IntegerField(**BNULL)
  166. allmusic_review = models.TextField(**BNULL)
  167. bandcamp_id = models.CharField(max_length=100, **BNULL)
  168. rateyourmusic_id = models.CharField(max_length=255, **BNULL)
  169. wikipedia_slug = models.CharField(max_length=255, **BNULL)
  170. discogs_id = models.CharField(max_length=255, **BNULL)
  171. wikidata_id = models.CharField(max_length=255, **BNULL)
  172. def __str__(self):
  173. return self.name
  174. def get_absolute_url(self):
  175. return reverse("music:album_detail", kwargs={"slug": self.uuid})
  176. def scrobbles(self):
  177. from scrobbles.models import Scrobble
  178. return Scrobble.objects.filter(
  179. track__in=self.track_set.all()
  180. ).order_by("-timestamp")
  181. @property
  182. def primary_image_url(self) -> str:
  183. if self.cover_image:
  184. return self.cover_image_medium.url
  185. return ""
  186. @property
  187. def tracks(self):
  188. return (
  189. self.track_set.all()
  190. .annotate(scrobble_count=models.Count("scrobble"))
  191. .order_by("-scrobble_count")
  192. )
  193. def fix_album_artist(self):
  194. from music.utils import get_or_create_various_artists
  195. multiple_artists = self.artists.count() > 1
  196. if multiple_artists:
  197. self.album_artist = get_or_create_various_artists()
  198. else:
  199. self.album_artist = self.artists.first()
  200. self.save(update_fields=["album_artist"])
  201. def scrape_allmusic(self, force=False) -> None:
  202. if not self.allmusic_id or force:
  203. slug = get_allmusic_slug(self.name, self.album_artist.name)
  204. if not slug:
  205. logger.info(
  206. f"No allmsuic link for {self} by {self.album_artist}"
  207. )
  208. return
  209. self.allmusic_id = slug
  210. self.save(update_fields=["allmusic_id"])
  211. allmusic_data = scrape_data_from_allmusic(self.allmusic_link)
  212. if not allmusic_data:
  213. logger.info(f"No allmsuic data for {self} by {self.album_artist}")
  214. return
  215. self.allmusic_review = allmusic_data["review"]
  216. self.allmusic_rating = allmusic_data["rating"]
  217. self.save(update_fields=["allmusic_review", "allmusic_rating"])
  218. def scrape_theaudiodb(self) -> None:
  219. artist = "Various Artists"
  220. if self.album_artist:
  221. artist = self.album_artist.name
  222. album_data = lookup_album_from_tadb(self.name, artist)
  223. if not album_data.get("theaudiodb_id"):
  224. logger.info(f"No data for {self} found in TheAudioDB")
  225. return
  226. Album.objects.filter(pk=self.pk).update(**album_data)
  227. def scrape_bandcamp(self, force=False) -> None:
  228. if not self.bandcamp_id or force:
  229. slug = get_bandcamp_slug(self.album_artist.name, self.name)
  230. if not slug:
  231. logger.info(f"No bandcamp link for {self}")
  232. return
  233. self.bandcamp_id = slug
  234. self.save(update_fields=["bandcamp_id"])
  235. def fix_metadata(self):
  236. if (
  237. not self.musicbrainz_albumartist_id
  238. or not self.year
  239. or not self.musicbrainz_releasegroup_id
  240. ):
  241. musicbrainzngs.set_useragent("vrobbler", "0.3.0")
  242. mb_data = musicbrainzngs.get_release_by_id(
  243. self.musicbrainz_id, includes=["artists", "release-groups"]
  244. )
  245. if not self.musicbrainz_releasegroup_id:
  246. self.musicbrainz_releasegroup_id = mb_data["release"][
  247. "release-group"
  248. ]["id"]
  249. if not self.musicbrainz_albumartist_id:
  250. self.musicbrainz_albumartist_id = mb_data["release"][
  251. "artist-credit"
  252. ][0]["artist"]["id"]
  253. if not self.year:
  254. try:
  255. self.year = mb_data["release"]["date"][0:4]
  256. except KeyError:
  257. pass
  258. except IndexError:
  259. pass
  260. self.save(
  261. update_fields=[
  262. "musicbrainz_albumartist_id",
  263. "musicbrainz_releasegroup_id",
  264. "year",
  265. ]
  266. )
  267. new_artist = Artist.objects.filter(
  268. musicbrainz_id=self.musicbrainz_albumartist_id
  269. ).first()
  270. if self.musicbrainz_albumartist_id and new_artist:
  271. self.artists.add(new_artist)
  272. if not new_artist:
  273. for t in self.track_set.all():
  274. self.artists.add(t.artist)
  275. if (
  276. not self.cover_image
  277. or self.cover_image == "default-image-replace-me"
  278. ):
  279. self.fetch_artwork()
  280. self.fix_album_artist()
  281. self.scrape_theaudiodb()
  282. self.scrape_allmusic()
  283. def fetch_artwork(self, force=False):
  284. if not self.cover_image and not force:
  285. if self.musicbrainz_id:
  286. try:
  287. img_data = musicbrainzngs.get_image_front(
  288. self.musicbrainz_id
  289. )
  290. name = f"{self.name}_{self.uuid}.jpg"
  291. self.cover_image = ContentFile(img_data, name=name)
  292. logger.info(f"Setting image to {name}")
  293. except musicbrainzngs.ResponseError:
  294. logger.warning(
  295. f"No cover art found for {self.name} by release"
  296. )
  297. if (
  298. not self.cover_image
  299. or self.cover_image == "default-image-replace-me"
  300. ) and self.musicbrainz_releasegroup_id:
  301. try:
  302. img_data = musicbrainzngs.get_release_group_image_front(
  303. self.musicbrainz_releasegroup_id
  304. )
  305. name = f"{self.name}_{self.uuid}.jpg"
  306. self.cover_image = ContentFile(img_data, name=name)
  307. logger.info(f"Setting image to {name}")
  308. except musicbrainzngs.ResponseError:
  309. logger.warning(
  310. f"No cover art found for {self.name} by release group"
  311. )
  312. if not self.cover_image:
  313. logger.debug(
  314. f"No cover art found for release or release group for {self.name}, setting to default"
  315. )
  316. self.save()
  317. @property
  318. def mb_link(self) -> str:
  319. return f"https://musicbrainz.org/release/{self.musicbrainz_id}"
  320. @property
  321. def allmusic_link(self) -> str:
  322. if self.allmusic_id:
  323. return f"https://www.allmusic.com/album/{self.allmusic_id}"
  324. return ""
  325. @property
  326. def wikipedia_link(self):
  327. if self.wikipedia_slug:
  328. return f"https://www.wikipedia.org/en/{self.wikipedia_slug}"
  329. return ""
  330. @property
  331. def tadb_link(self):
  332. if self.theaudiodb_id:
  333. return f"https://www.theaudiodb.com/album/{self.theaudiodb_id}"
  334. return ""
  335. @property
  336. def rym_link(self):
  337. artist_slug = self.album_artist.name.lower().replace(" ", "-")
  338. album_slug = self.name.lower().replace(" ", "-")
  339. return f"https://rateyourmusic.com/release/album/{artist_slug}/{album_slug}/"
  340. @property
  341. def bandcamp_link(self):
  342. if self.bandcamp_id and self.album_artist.bandcamp_id:
  343. return f"https://{self.album_artist.bandcamp_id}.bandcamp.com/album/{self.bandcamp_id}"
  344. return ""
  345. @property
  346. def bandcamp_search_link(self):
  347. artist = self.album_artist.name.lower()
  348. album = self.name.lower()
  349. return f"https://bandcamp.com/search?q={album} {artist}&item_type=a"
  350. class Track(ScrobblableMixin):
  351. COMPLETION_PERCENT = getattr(settings, "MUSIC_COMPLETION_PERCENT", 100)
  352. class Opinion(models.IntegerChoices):
  353. DOWN = -1, "Thumbs down"
  354. NEUTRAL = 0, "No opinion"
  355. UP = 1, "Thumbs up"
  356. artist = models.ForeignKey(Artist, on_delete=models.DO_NOTHING)
  357. album = models.ForeignKey(Album, on_delete=models.DO_NOTHING, **BNULL)
  358. musicbrainz_id = models.CharField(max_length=255, **BNULL)
  359. class Meta:
  360. unique_together = [["album", "musicbrainz_id"]]
  361. def __str__(self):
  362. return f"{self.title} by {self.artist}"
  363. def get_absolute_url(self):
  364. return reverse("music:track_detail", kwargs={"slug": self.uuid})
  365. @property
  366. def subtitle(self):
  367. return self.artist
  368. @property
  369. def verb(self) -> str:
  370. return "Listening"
  371. @property
  372. def mb_link(self):
  373. return f"https://musicbrainz.org/recording/{self.musicbrainz_id}"
  374. @property
  375. def info_link(self):
  376. return self.mb_link
  377. @property
  378. def primary_image_url(self) -> str:
  379. url = ""
  380. if self.artist.thumbnail:
  381. url = self.artist.thumbnail_medium.url
  382. if self.album and self.album.cover_image:
  383. url = self.album.cover_image_medium.url
  384. return url
  385. @classmethod
  386. def find_or_create(
  387. cls, artist_dict: Dict, album_dict: Dict, track_dict: Dict
  388. ) -> Optional["Track"]:
  389. """Given a data dict from Jellyfin, does the heavy lifting of looking up
  390. the video and, if need, TV Series, creating both if they don't yet
  391. exist.
  392. """
  393. if not artist_dict.get("name") or not artist_dict.get(
  394. "musicbrainz_id"
  395. ):
  396. logger.warning(
  397. f"No artist or artist musicbrainz ID found in message from source, not scrobbling"
  398. )
  399. return
  400. artist, artist_created = Artist.objects.get_or_create(**artist_dict)
  401. album, album_created = Album.objects.get_or_create(**album_dict)
  402. album.fix_metadata()
  403. if not album.cover_image:
  404. album.fetch_artwork()
  405. track_dict["album_id"] = getattr(album, "id", None)
  406. track_dict["artist_id"] = artist.id
  407. track, created = cls.objects.get_or_create(**track_dict)
  408. return track