funkwhale/api/funkwhale_api/music/models.py

626 wiersze
20 KiB
Python
Czysty Zwykły widok Historia

import datetime
2018-06-10 08:55:16 +00:00
import os
import shutil
2018-06-10 08:55:16 +00:00
import tempfile
import uuid
2018-06-10 08:55:16 +00:00
import arrow
import markdown
from django.conf import settings
from django.core.files import File
2018-06-10 08:55:16 +00:00
from django.core.files.base import ContentFile
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
2017-12-16 13:32:52 +00:00
from django.urls import reverse
from django.utils import timezone
from taggit.managers import TaggableManager
from versatileimagefield.fields import VersatileImageField
2018-06-10 08:55:16 +00:00
from funkwhale_api import downloader, musicbrainz
from funkwhale_api.federation import utils as federation_utils
2018-06-10 08:55:16 +00:00
from . import importers, metadata, utils
class APIModelMixin(models.Model):
mbid = models.UUIDField(unique=True, db_index=True, null=True, blank=True)
2018-06-09 13:36:16 +00:00
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
api_includes = []
creation_date = models.DateTimeField(default=timezone.now)
import_hooks = []
class Meta:
abstract = True
2018-06-09 13:36:16 +00:00
ordering = ["-creation_date"]
@classmethod
def get_or_create_from_api(cls, mbid):
try:
return cls.objects.get(mbid=mbid), False
except cls.DoesNotExist:
return cls.create_from_api(id=mbid), True
def get_api_data(self):
2018-06-09 13:36:16 +00:00
return self.__class__.api.get(id=self.mbid, includes=self.api_includes)[
self.musicbrainz_model
]
@classmethod
def create_from_api(cls, **kwargs):
2018-06-09 13:36:16 +00:00
if kwargs.get("id"):
raw_data = cls.api.get(id=kwargs["id"], includes=cls.api_includes)[
cls.musicbrainz_model
]
else:
2018-06-09 13:36:16 +00:00
raw_data = cls.api.search(**kwargs)[
"{0}-list".format(cls.musicbrainz_model)
][0]
cleaned_data = cls.clean_musicbrainz_data(raw_data)
return importers.load(cls, cleaned_data, raw_data, cls.import_hooks)
@classmethod
def clean_musicbrainz_data(cls, data):
cleaned_data = {}
mapping = importers.Mapping(cls.musicbrainz_mapping)
for key, value in data.items():
try:
cleaned_key, cleaned_value = mapping.from_musicbrainz(key, value)
cleaned_data[cleaned_key] = cleaned_value
except KeyError as e:
pass
return cleaned_data
@property
def musicbrainz_url(self):
if self.mbid:
2018-06-09 13:36:16 +00:00
return "https://musicbrainz.org/{}/{}".format(
self.musicbrainz_model, self.mbid
)
2018-05-08 19:21:52 +00:00
class ArtistQuerySet(models.QuerySet):
def with_albums_count(self):
2018-06-09 13:36:16 +00:00
return self.annotate(_albums_count=models.Count("albums"))
2018-05-08 19:21:52 +00:00
def with_albums(self):
return self.prefetch_related(
2018-06-09 13:36:16 +00:00
models.Prefetch("albums", queryset=Album.objects.with_tracks_count())
)
2018-05-08 19:21:52 +00:00
class Artist(APIModelMixin):
name = models.CharField(max_length=255)
2018-06-09 13:36:16 +00:00
musicbrainz_model = "artist"
musicbrainz_mapping = {
2018-06-09 13:36:16 +00:00
"mbid": {"musicbrainz_field_name": "id"},
"name": {"musicbrainz_field_name": "name"},
}
api = musicbrainz.api.artists
2018-05-08 19:21:52 +00:00
objects = ArtistQuerySet.as_manager()
def __str__(self):
return self.name
@property
def tags(self):
t = []
for album in self.albums.all():
for tag in album.tags:
t.append(tag)
return set(t)
@classmethod
def get_or_create_from_name(cls, name, **kwargs):
2018-06-09 13:36:16 +00:00
kwargs.update({"name": name})
return cls.objects.get_or_create(name__iexact=name, defaults=kwargs)
def import_artist(v):
2018-06-09 13:36:16 +00:00
a = Artist.get_or_create_from_api(mbid=v[0]["artist"]["id"])[0]
return a
def parse_date(v):
if len(v) == 4:
return datetime.date(int(v), 1, 1)
d = arrow.get(v).date()
return d
def import_tracks(instance, cleaned_data, raw_data):
2018-06-09 13:36:16 +00:00
for track_data in raw_data["medium-list"][0]["track-list"]:
track_cleaned_data = Track.clean_musicbrainz_data(track_data["recording"])
track_cleaned_data["album"] = instance
track_cleaned_data["position"] = int(track_data["position"])
track = importers.load(
Track, track_cleaned_data, track_data, Track.import_hooks
)
2018-05-08 19:21:52 +00:00
class AlbumQuerySet(models.QuerySet):
def with_tracks_count(self):
2018-06-09 13:36:16 +00:00
return self.annotate(_tracks_count=models.Count("tracks"))
2018-05-08 19:21:52 +00:00
class Album(APIModelMixin):
title = models.CharField(max_length=255)
2018-06-09 13:36:16 +00:00
artist = models.ForeignKey(Artist, related_name="albums", on_delete=models.CASCADE)
release_date = models.DateField(null=True)
release_group_id = models.UUIDField(null=True, blank=True)
2018-06-09 13:36:16 +00:00
cover = VersatileImageField(
upload_to="albums/covers/%Y/%m/%d", null=True, blank=True
)
2018-06-09 13:36:16 +00:00
TYPE_CHOICES = (("album", "Album"),)
type = models.CharField(choices=TYPE_CHOICES, max_length=30, default="album")
2018-06-09 13:36:16 +00:00
api_includes = ["artist-credits", "recordings", "media", "release-groups"]
api = musicbrainz.api.releases
2018-06-09 13:36:16 +00:00
musicbrainz_model = "release"
musicbrainz_mapping = {
2018-06-09 13:36:16 +00:00
"mbid": {"musicbrainz_field_name": "id"},
"position": {
"musicbrainz_field_name": "release-list",
"converter": lambda v: int(v[0]["medium-list"][0]["position"]),
},
2018-06-09 13:36:16 +00:00
"release_group_id": {
"musicbrainz_field_name": "release-group",
"converter": lambda v: v["id"],
},
2018-06-09 13:36:16 +00:00
"title": {"musicbrainz_field_name": "title"},
"release_date": {"musicbrainz_field_name": "date", "converter": parse_date},
"type": {"musicbrainz_field_name": "type", "converter": lambda v: v.lower()},
"artist": {
"musicbrainz_field_name": "artist-credit",
"converter": import_artist,
},
}
2018-05-08 19:21:52 +00:00
objects = AlbumQuerySet.as_manager()
def get_image(self, data=None):
if data:
2018-06-09 13:36:16 +00:00
f = ContentFile(data["content"])
extensions = {"image/jpeg": "jpg", "image/png": "png", "image/gif": "gif"}
extension = extensions.get(data["mimetype"], "jpg")
self.cover.save("{}.{}".format(self.uuid, extension), f)
else:
2018-06-09 13:36:16 +00:00
image_data = musicbrainz.api.images.get_front(str(self.mbid))
f = ContentFile(image_data)
2018-06-09 13:36:16 +00:00
self.cover.save("{0}.jpg".format(self.mbid), f)
return self.cover.file
def __str__(self):
return self.title
@property
def tags(self):
t = []
for track in self.tracks.all():
for tag in track.tags.all():
t.append(tag)
return set(t)
@classmethod
def get_or_create_from_title(cls, title, **kwargs):
2018-06-09 13:36:16 +00:00
kwargs.update({"title": title})
return cls.objects.get_or_create(title__iexact=title, defaults=kwargs)
def import_tags(instance, cleaned_data, raw_data):
MINIMUM_COUNT = 2
tags_to_add = []
2018-06-09 13:36:16 +00:00
for tag_data in raw_data.get("tag-list", []):
try:
2018-06-09 13:36:16 +00:00
if int(tag_data["count"]) < MINIMUM_COUNT:
continue
except ValueError:
continue
2018-06-09 13:36:16 +00:00
tags_to_add.append(tag_data["name"])
instance.tags.add(*tags_to_add)
def import_album(v):
2018-06-09 13:36:16 +00:00
a = Album.get_or_create_from_api(mbid=v[0]["id"])[0]
return a
def link_recordings(instance, cleaned_data, raw_data):
2018-06-09 13:36:16 +00:00
tracks = [r["target"] for r in raw_data["recording-relation-list"]]
Track.objects.filter(mbid__in=tracks).update(work=instance)
def import_lyrics(instance, cleaned_data, raw_data):
try:
url = [
url_data
2018-06-09 13:36:16 +00:00
for url_data in raw_data["url-relation-list"]
if url_data["type"] == "lyrics"
][0]["target"]
except (IndexError, KeyError):
return
l, _ = Lyrics.objects.get_or_create(work=instance, url=url)
return l
class Work(APIModelMixin):
language = models.CharField(max_length=20)
nature = models.CharField(max_length=50)
title = models.CharField(max_length=255)
api = musicbrainz.api.works
2018-06-09 13:36:16 +00:00
api_includes = ["url-rels", "recording-rels"]
musicbrainz_model = "work"
musicbrainz_mapping = {
2018-06-09 13:36:16 +00:00
"mbid": {"musicbrainz_field_name": "id"},
"title": {"musicbrainz_field_name": "title"},
"language": {"musicbrainz_field_name": "language"},
"nature": {"musicbrainz_field_name": "type", "converter": lambda v: v.lower()},
}
2018-06-09 13:36:16 +00:00
import_hooks = [import_lyrics, link_recordings]
def fetch_lyrics(self):
l = self.lyrics.first()
if l:
return l
2018-06-09 13:36:16 +00:00
data = self.api.get(self.mbid, includes=["url-rels"])["work"]
l = import_lyrics(self, {}, data)
return l
class Lyrics(models.Model):
2018-06-09 13:36:16 +00:00
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
2017-12-15 23:36:06 +00:00
work = models.ForeignKey(
2018-06-09 13:36:16 +00:00
Work, related_name="lyrics", null=True, blank=True, on_delete=models.CASCADE
)
url = models.URLField(unique=True)
content = models.TextField(null=True, blank=True)
@property
def content_rendered(self):
return markdown.markdown(
self.content,
safe_mode=True,
enable_attributes=False,
2018-06-09 13:36:16 +00:00
extensions=["markdown.extensions.nl2br"],
)
2018-01-07 21:13:32 +00:00
class TrackQuerySet(models.QuerySet):
def for_nested_serialization(self):
2018-06-09 13:36:16 +00:00
return (
self.select_related()
.select_related("album__artist", "artist")
.prefetch_related("files")
)
2018-01-07 21:13:32 +00:00
def get_artist(release_list):
return Artist.get_or_create_from_api(
2018-06-09 13:36:16 +00:00
mbid=release_list[0]["artist-credits"][0]["artists"]["id"]
)[0]
class Track(APIModelMixin):
title = models.CharField(max_length=255)
2018-06-09 13:36:16 +00:00
artist = models.ForeignKey(Artist, related_name="tracks", on_delete=models.CASCADE)
position = models.PositiveIntegerField(null=True, blank=True)
2017-12-15 23:36:06 +00:00
album = models.ForeignKey(
2018-06-09 13:36:16 +00:00
Album, related_name="tracks", null=True, blank=True, on_delete=models.CASCADE
)
2017-12-15 23:36:06 +00:00
work = models.ForeignKey(
2018-06-09 13:36:16 +00:00
Work, related_name="tracks", null=True, blank=True, on_delete=models.CASCADE
)
2018-06-09 13:36:16 +00:00
musicbrainz_model = "recording"
api = musicbrainz.api.recordings
2018-06-09 13:36:16 +00:00
api_includes = ["artist-credits", "releases", "media", "tags", "work-rels"]
musicbrainz_mapping = {
2018-06-09 13:36:16 +00:00
"mbid": {"musicbrainz_field_name": "id"},
"title": {"musicbrainz_field_name": "title"},
"artist": {
# we use the artist from the release to avoid #237
2018-06-09 13:36:16 +00:00
"musicbrainz_field_name": "release-list",
"converter": get_artist,
},
2018-06-09 13:36:16 +00:00
"album": {"musicbrainz_field_name": "release-list", "converter": import_album},
}
2018-06-09 13:36:16 +00:00
import_hooks = [import_tags]
2018-01-07 21:13:32 +00:00
objects = TrackQuerySet.as_manager()
tags = TaggableManager(blank=True)
class Meta:
2018-06-09 13:36:16 +00:00
ordering = ["album", "position"]
def __str__(self):
return self.title
def save(self, **kwargs):
try:
self.artist
except Artist.DoesNotExist:
self.artist = self.album.artist
super().save(**kwargs)
def get_work(self):
if self.work:
return self.work
2018-06-09 13:36:16 +00:00
data = self.api.get(self.mbid, includes=["work-rels"])
try:
2018-06-09 13:36:16 +00:00
work_data = data["recording"]["work-relation-list"][0]["work"]
except (IndexError, KeyError):
return
2018-06-09 13:36:16 +00:00
work, _ = Work.get_or_create_from_api(mbid=work_data["id"])
return work
def get_lyrics_url(self):
2018-06-09 13:36:16 +00:00
return reverse("api:v1:tracks-lyrics", kwargs={"pk": self.pk})
@property
def full_name(self):
try:
2018-06-09 13:36:16 +00:00
return "{} - {} - {}".format(self.artist.name, self.album.title, self.title)
except AttributeError:
2018-06-09 13:36:16 +00:00
return "{} - {}".format(self.artist.name, self.title)
def get_activity_url(self):
if self.mbid:
2018-06-09 13:36:16 +00:00
return "https://musicbrainz.org/recording/{}".format(self.mbid)
return settings.FUNKWHALE_URL + "/tracks/{}".format(self.pk)
@classmethod
def get_or_create_from_title(cls, title, **kwargs):
2018-06-09 13:36:16 +00:00
kwargs.update({"title": title})
return cls.objects.get_or_create(title__iexact=title, defaults=kwargs)
@classmethod
def get_or_create_from_release(cls, release_mbid, mbid):
release_mbid = str(release_mbid)
mbid = str(mbid)
try:
return cls.objects.get(mbid=mbid), False
except cls.DoesNotExist:
pass
album = Album.get_or_create_from_api(release_mbid)[0]
data = musicbrainz.client.api.releases.get(
2018-06-09 13:36:16 +00:00
str(album.mbid), includes=Album.api_includes
)
tracks = [t for m in data["release"]["medium-list"] for t in m["track-list"]]
track_data = None
for track in tracks:
2018-06-09 13:36:16 +00:00
if track["recording"]["id"] == mbid:
track_data = track
break
if not track_data:
2018-06-09 13:36:16 +00:00
raise ValueError("No track found matching this ID")
return cls.objects.update_or_create(
mbid=mbid,
defaults={
2018-06-09 13:36:16 +00:00
"position": int(track["position"]),
"title": track["recording"]["title"],
"album": album,
"artist": album.artist,
},
)
2018-06-09 13:36:16 +00:00
class TrackFile(models.Model):
2018-06-09 13:36:16 +00:00
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
track = models.ForeignKey(Track, related_name="files", on_delete=models.CASCADE)
audio_file = models.FileField(upload_to="tracks/%Y/%m/%d", max_length=255)
source = models.URLField(null=True, blank=True, max_length=500)
creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField(auto_now=True)
accessed_date = models.DateTimeField(null=True, blank=True)
duration = models.IntegerField(null=True, blank=True)
size = models.IntegerField(null=True, blank=True)
bitrate = models.IntegerField(null=True, blank=True)
acoustid_track_id = models.UUIDField(null=True, blank=True)
mimetype = models.CharField(null=True, blank=True, max_length=200)
library_track = models.OneToOneField(
2018-06-09 13:36:16 +00:00
"federation.LibraryTrack",
related_name="local_track_file",
on_delete=models.CASCADE,
null=True,
blank=True,
)
def download_file(self):
# import the track file, since there is not any
# we create a tmp dir for the download
tmp_dir = tempfile.mkdtemp()
2018-06-09 13:36:16 +00:00
data = downloader.download(self.source, target_directory=tmp_dir)
self.duration = data.get("duration", None)
self.audio_file.save(
2018-06-09 13:36:16 +00:00
os.path.basename(data["audio_file_path"]),
File(open(data["audio_file_path"], "rb")),
)
shutil.rmtree(tmp_dir)
return self.audio_file
def get_federation_url(self):
2018-06-09 13:36:16 +00:00
return federation_utils.full_url("/federation/music/file/{}".format(self.uuid))
@property
def path(self):
2018-06-09 13:36:16 +00:00
return reverse("api:v1:trackfiles-serve", kwargs={"pk": self.pk})
@property
def filename(self):
2018-06-09 13:36:16 +00:00
return "{}.{}".format(self.track.full_name, self.extension)
@property
def extension(self):
if not self.audio_file:
return
2018-06-09 13:36:16 +00:00
return os.path.splitext(self.audio_file.name)[-1].replace(".", "", 1)
def get_file_size(self):
if self.audio_file:
return self.audio_file.size
2018-06-09 13:36:16 +00:00
if self.source.startswith("file://"):
return os.path.getsize(self.source.replace("file://", "", 1))
if self.library_track and self.library_track.audio_file:
return self.library_track.audio_file.size
def get_audio_file(self):
if self.audio_file:
return self.audio_file.open()
2018-06-09 13:36:16 +00:00
if self.source.startswith("file://"):
return open(self.source.replace("file://", "", 1), "rb")
if self.library_track and self.library_track.audio_file:
return self.library_track.audio_file.open()
def set_audio_data(self):
audio_file = self.get_audio_file()
if audio_file:
with audio_file as f:
audio_data = utils.get_audio_file_data(f)
if not audio_data:
return
2018-06-09 13:36:16 +00:00
self.duration = int(audio_data["length"])
self.bitrate = audio_data["bitrate"]
self.size = self.get_file_size()
else:
lt = self.library_track
if lt:
2018-06-09 13:36:16 +00:00
self.duration = lt.get_metadata("length")
self.size = lt.get_metadata("size")
self.bitrate = lt.get_metadata("bitrate")
def save(self, **kwargs):
if not self.mimetype and self.audio_file:
self.mimetype = utils.guess_mimetype(self.audio_file)
return super().save(**kwargs)
def get_metadata(self):
audio_file = self.get_audio_file()
if not audio_file:
return
return metadata.Metadata(audio_file)
IMPORT_STATUS_CHOICES = (
2018-06-09 13:36:16 +00:00
("pending", "Pending"),
("finished", "Finished"),
("errored", "Errored"),
("skipped", "Skipped"),
)
class ImportBatch(models.Model):
2018-06-09 13:36:16 +00:00
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
IMPORT_BATCH_SOURCES = [
2018-06-09 13:36:16 +00:00
("api", "api"),
("shell", "shell"),
("federation", "federation"),
]
source = models.CharField(
2018-06-09 13:36:16 +00:00
max_length=30, default="api", choices=IMPORT_BATCH_SOURCES
)
creation_date = models.DateTimeField(default=timezone.now)
2017-12-15 23:36:06 +00:00
submitted_by = models.ForeignKey(
2018-06-09 13:36:16 +00:00
"users.User",
related_name="imports",
null=True,
blank=True,
2018-06-09 13:36:16 +00:00
on_delete=models.CASCADE,
)
status = models.CharField(
2018-06-09 13:36:16 +00:00
choices=IMPORT_STATUS_CHOICES, default="pending", max_length=30
)
import_request = models.ForeignKey(
2018-06-09 13:36:16 +00:00
"requests.ImportRequest",
related_name="import_batches",
null=True,
blank=True,
2018-06-09 13:36:16 +00:00
on_delete=models.CASCADE,
)
class Meta:
2018-06-09 13:36:16 +00:00
ordering = ["-creation_date"]
def __str__(self):
return str(self.pk)
def update_status(self):
old_status = self.status
self.status = utils.compute_status(self.jobs.all())
if self.status == old_status:
return
2018-06-09 13:36:16 +00:00
self.save(update_fields=["status"])
if self.status != old_status and self.status == "finished":
from . import tasks
2018-06-09 13:36:16 +00:00
tasks.import_batch_notify_followers.delay(import_batch_id=self.pk)
def get_federation_url(self):
return federation_utils.full_url(
2018-06-09 13:36:16 +00:00
"/federation/music/import/batch/{}".format(self.uuid)
)
class ImportJob(models.Model):
2018-06-09 13:36:16 +00:00
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
2017-12-15 23:36:06 +00:00
batch = models.ForeignKey(
2018-06-09 13:36:16 +00:00
ImportBatch, related_name="jobs", on_delete=models.CASCADE
)
track_file = models.ForeignKey(
2018-06-09 13:36:16 +00:00
TrackFile, related_name="jobs", null=True, blank=True, on_delete=models.CASCADE
)
2017-12-27 22:32:02 +00:00
source = models.CharField(max_length=500)
mbid = models.UUIDField(editable=False, null=True, blank=True)
status = models.CharField(
2018-06-09 13:36:16 +00:00
choices=IMPORT_STATUS_CHOICES, default="pending", max_length=30
)
audio_file = models.FileField(
2018-06-09 13:36:16 +00:00
upload_to="imports/%Y/%m/%d", max_length=255, null=True, blank=True
)
library_track = models.ForeignKey(
2018-06-09 13:36:16 +00:00
"federation.LibraryTrack",
related_name="import_jobs",
on_delete=models.SET_NULL,
null=True,
2018-06-09 13:36:16 +00:00
blank=True,
)
class Meta:
2018-06-09 13:36:16 +00:00
ordering = ("id",)
2018-02-20 23:03:37 +00:00
@receiver(post_save, sender=ImportJob)
def update_batch_status(sender, instance, **kwargs):
instance.batch.update_status()
@receiver(post_save, sender=ImportBatch)
def update_request_status(sender, instance, created, **kwargs):
2018-06-09 13:36:16 +00:00
update_fields = kwargs.get("update_fields", []) or []
if not instance.import_request:
return
2018-06-09 13:36:16 +00:00
if not created and not "status" in update_fields:
return
r_status = instance.import_request.status
status = instance.status
2018-06-09 13:36:16 +00:00
if status == "pending" and r_status == "pending":
# let's mark the request as accepted since we started an import
2018-06-09 13:36:16 +00:00
instance.import_request.status = "accepted"
return instance.import_request.save(update_fields=["status"])
2018-06-09 13:36:16 +00:00
if status == "finished" and r_status == "accepted":
# let's mark the request as imported since the import is over
2018-06-09 13:36:16 +00:00
instance.import_request.status = "imported"
return instance.import_request.save(update_fields=["status"])