Skip to content
Snippets Groups Projects
models.py 45.1 KiB
Newer Older
  • Learn to ignore specific revisions
  • Eliot Berriot's avatar
    Eliot Berriot committed
    import logging
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    import mimetypes
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    import os
    import tempfile
    
    import urllib.parse
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from django.contrib.contenttypes.fields import GenericRelation
    
    from django.contrib.postgres.fields import JSONField
    
    from django.contrib.postgres.search import SearchVectorField
    from django.contrib.postgres.indexes import GinIndex
    
    from django.core.exceptions import ObjectDoesNotExist
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from django.core.files.base import ContentFile
    
    from django.core.serializers.json import DjangoJSONEncoder
    
    from django.db import models, transaction
    
    from django.db.models.signals import post_save, pre_save
    
    from django.dispatch import receiver
    
    from django.urls import reverse
    
    from django.utils import timezone
    from versatileimagefield.fields import VersatileImageField
    
    
    from funkwhale_api import musicbrainz
    from funkwhale_api.common import fields
    
    from funkwhale_api.common import models as common_models
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from funkwhale_api.common import session
    
    from funkwhale_api.common import utils as common_utils
    from funkwhale_api.federation import models as federation_models
    
    from funkwhale_api.federation import utils as federation_utils
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from funkwhale_api.tags import models as tags_models
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    from . import importers, metadata, utils
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    logger = logging.getLogger(__name__)
    
    MAX_LENGTHS = {
        "ARTIST_NAME": 255,
        "ALBUM_TITLE": 255,
        "TRACK_TITLE": 255,
        "COPYRIGHT": 500,
    }
    
    
    ARTIST_CONTENT_CATEGORY_CHOICES = [
        ("music", "music"),
        ("podcast", "podcast"),
        ("other", "other"),
    ]
    
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        fid = models.URLField(unique=True, max_length=500, db_index=True, null=True)
    
        mbid = models.UUIDField(unique=True, db_index=True, null=True, blank=True)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        from_activity = models.ForeignKey(
    
            "federation.Activity", null=True, blank=True, on_delete=models.SET_NULL
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        )
    
        creation_date = models.DateTimeField(default=timezone.now, db_index=True)
    
        body_text = SearchVectorField(blank=True)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            ordering = ["-creation_date"]
    
            indexes = [
                GinIndex(fields=["body_text"]),
            ]
    
    
        @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):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            return self.__class__.api.get(id=self.mbid, includes=self.api_includes)[
                self.musicbrainz_model
            ]
    
    
        @classmethod
        def create_from_api(cls, **kwargs):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            if kwargs.get("id"):
                raw_data = cls.api.get(id=kwargs["id"], includes=cls.api_includes)[
                    cls.musicbrainz_model
                ]
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                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:
    
        @property
        def musicbrainz_url(self):
            if self.mbid:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                return "https://musicbrainz.org/{}/{}".format(
                    self.musicbrainz_model, self.mbid
                )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        def get_federation_id(self):
            if self.fid:
                return self.fid
    
            return federation_utils.full_url(
                reverse(
                    "federation:music:{}-detail".format(self.federation_namespace),
                    kwargs={"uuid": self.uuid},
                )
            )
    
        def save(self, **kwargs):
            if not self.pk and not self.fid:
                self.fid = self.get_federation_id()
    
            return super().save(**kwargs)
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        @property
        def is_local(self):
    
            return federation_utils.is_local(self.fid)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
    
        @property
        def domain_name(self):
            if not self.fid:
                return
    
            parsed = urllib.parse.urlparse(self.fid)
            return parsed.hostname
    
    
        def get_tags(self):
            return list(sorted(self.tagged_items.values_list("tag__name", flat=True)))
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    class License(models.Model):
        code = models.CharField(primary_key=True, max_length=100)
        url = models.URLField(max_length=500)
    
        # if true, license is a copyleft license, meaning that derivative
        # work must be shared under the same license
        copyleft = models.BooleanField()
        # if true, commercial use of the work is allowed
        commercial = models.BooleanField()
        # if true, attribution to the original author is required when reusing
        # the work
        attribution = models.BooleanField()
        # if true, derivative work are allowed
        derivative = models.BooleanField()
        # if true, redistribution of the wor is allowed
        redistribute = models.BooleanField()
    
        @property
        def conf(self):
            from . import licenses
    
            for row in licenses.LICENSES:
                if self.code == row["code"]:
                    return row
            logger.warning("%s do not match any registered license", self.code)
    
    
    
    class ArtistQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
    
        def with_albums_count(self):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            return self.annotate(_albums_count=models.Count("albums"))
    
        def with_albums(self):
            return self.prefetch_related(
    
                models.Prefetch(
                    "albums",
                    queryset=Album.objects.with_tracks_count().select_related(
                        "attachment_cover", "attributed_to"
                    ),
                )
    
        def annotate_playable_by_actor(self, actor):
            tracks = (
    
                Upload.objects.playable_by(actor)
                .filter(track__artist=models.OuterRef("id"))
    
                .order_by("id")
                .values("id")[:1]
            )
            subquery = models.Subquery(tracks)
            return self.annotate(is_playable_by_actor=subquery)
    
        def playable_by(self, actor, include=True):
    
            tracks = Track.objects.playable_by(actor)
            matches = self.filter(pk__in=tracks.values("artist_id")).values_list("pk")
    
                return self.filter(pk__in=matches)
    
                return self.exclude(pk__in=matches)
    
        name = models.CharField(max_length=MAX_LENGTHS["ARTIST_NAME"])
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        federation_namespace = "artists"
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        musicbrainz_model = "artist"
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            "mbid": {"musicbrainz_field_name": "id"},
            "name": {"musicbrainz_field_name": "name"},
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        # Music entities are attributed to actors, to validate that updates occur
        # from an authorized account. On top of that, we consider the instance actor
        # can update anything under it's own domain
        attributed_to = models.ForeignKey(
            "federation.Actor",
            null=True,
            blank=True,
            on_delete=models.SET_NULL,
            related_name="attributed_artists",
        )
    
        tagged_items = GenericRelation(tags_models.TaggedItem)
    
        fetches = GenericRelation(
            "federation.Fetch",
            content_type_field="object_content_type",
            object_id_field="object_id",
        )
    
        description = models.ForeignKey(
            "common.Content", null=True, blank=True, on_delete=models.SET_NULL
        )
    
        attachment_cover = models.ForeignKey(
            "common.Attachment",
            null=True,
            blank=True,
            on_delete=models.SET_NULL,
            related_name="covered_artist",
        )
    
        content_category = models.CharField(
            max_length=30,
            db_index=True,
            default="music",
            choices=ARTIST_CONTENT_CATEGORY_CHOICES,
            null=True,
        )
    
        objects = ArtistQuerySet.as_manager()
    
        def get_absolute_url(self):
            return "/library/artists/{}".format(self.pk)
    
        def get_moderation_url(self):
            return "/manage/library/artists/{}".format(self.pk)
    
    
        @classmethod
        def get_or_create_from_name(cls, name, **kwargs):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            kwargs.update({"name": name})
            return cls.objects.get_or_create(name__iexact=name, defaults=kwargs)
    
        @property
        def cover(self):
            return self.attachment_cover
    
    
        def get_channel(self):
            try:
                return self.channel
            except ObjectDoesNotExist:
                return None
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        a = Artist.get_or_create_from_api(mbid=v[0]["artist"]["id"])[0]
    
        return d
    
    
    def import_tracks(instance, cleaned_data, raw_data):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        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"])
    
            importers.load(Track, track_cleaned_data, track_data, Track.import_hooks)
    
    class AlbumQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
    
        def with_tracks_count(self):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            return self.annotate(_tracks_count=models.Count("tracks"))
    
        def annotate_playable_by_actor(self, actor):
            tracks = (
    
                Upload.objects.playable_by(actor)
                .filter(track__album=models.OuterRef("id"))
    
                .order_by("id")
                .values("id")[:1]
            )
            subquery = models.Subquery(tracks)
            return self.annotate(is_playable_by_actor=subquery)
    
        def playable_by(self, actor, include=True):
    
            tracks = Track.objects.playable_by(actor)
            matches = self.filter(pk__in=tracks.values("album_id")).values_list("pk")
    
                return self.filter(pk__in=matches)
    
                return self.exclude(pk__in=matches)
    
        def with_prefetched_tracks_and_playable_uploads(self, actor):
            tracks = Track.objects.with_playable_uploads(actor)
    
            return self.prefetch_related(models.Prefetch("tracks", queryset=tracks))
    
        title = models.CharField(max_length=MAX_LENGTHS["ALBUM_TITLE"])
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        artist = models.ForeignKey(Artist, related_name="albums", on_delete=models.CASCADE)
    
        release_date = models.DateField(null=True, blank=True, db_index=True)
    
        release_group_id = models.UUIDField(null=True, blank=True)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        # XXX: 1.0 clean this uneeded field in favor of attachment_cover
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        cover = VersatileImageField(
            upload_to="albums/covers/%Y/%m/%d", null=True, blank=True
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        attachment_cover = models.ForeignKey(
            "common.Attachment",
            null=True,
            blank=True,
            on_delete=models.SET_NULL,
            related_name="covered_album",
        )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        TYPE_CHOICES = (("album", "Album"),)
        type = models.CharField(choices=TYPE_CHOICES, max_length=30, default="album")
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        # Music entities are attributed to actors, to validate that updates occur
        # from an authorized account. On top of that, we consider the instance actor
        # can update anything under it's own domain
        attributed_to = models.ForeignKey(
            "federation.Actor",
            null=True,
            blank=True,
            on_delete=models.SET_NULL,
            related_name="attributed_albums",
        )
    
        tagged_items = GenericRelation(tags_models.TaggedItem)
    
        fetches = GenericRelation(
            "federation.Fetch",
            content_type_field="object_content_type",
            object_id_field="object_id",
        )
    
        description = models.ForeignKey(
            "common.Content", null=True, blank=True, on_delete=models.SET_NULL
        )
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        api_includes = ["artist-credits", "recordings", "media", "release-groups"]
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        federation_namespace = "albums"
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        musicbrainz_model = "release"
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            "mbid": {"musicbrainz_field_name": "id"},
            "position": {
                "musicbrainz_field_name": "release-list",
                "converter": lambda v: int(v[0]["medium-list"][0]["position"]),
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            "release_group_id": {
                "musicbrainz_field_name": "release-group",
                "converter": lambda v: v["id"],
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            "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,
    
        objects = AlbumQuerySet.as_manager()
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        @property
        def cover(self):
            return self.attachment_cover
    
        def get_absolute_url(self):
            return "/library/albums/{}".format(self.pk)
    
        def get_moderation_url(self):
            return "/manage/library/albums/{}".format(self.pk)
    
    
        @classmethod
        def get_or_create_from_title(cls, title, **kwargs):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            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 = []
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        for tag_data in raw_data.get("tag-list", []):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                if int(tag_data["count"]) < MINIMUM_COUNT:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            tags_to_add.append(tag_data["name"])
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
        tags_models.add_tags(instance, *tags_to_add)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        a = Album.get_or_create_from_api(mbid=v[0]["id"])[0]
    
    class TrackQuerySet(common_models.LocalFromFidQuerySet, models.QuerySet):
    
        def for_nested_serialization(self):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            return self.prefetch_related(
                "artist", "album__artist", "album__attachment_cover"
            )
    
    
        def annotate_playable_by_actor(self, actor):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                Upload.objects.playable_by(actor)
    
                .filter(track=models.OuterRef("id"))
                .order_by("id")
                .values("id")[:1]
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            )
    
            subquery = models.Subquery(files)
            return self.annotate(is_playable_by_actor=subquery)
    
        def playable_by(self, actor, include=True):
    
    
            if settings.MUSIC_USE_DENORMALIZATION:
                if actor is not None:
                    query = models.Q(actor=None) | models.Q(actor=actor)
                else:
                    query = models.Q(actor=None, internal=False)
                if not include:
                    query = ~query
                return self.filter(pk__in=TrackActor.objects.filter(query).values("track"))
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            files = Upload.objects.playable_by(actor, include)
    
            matches = self.filter(uploads__in=files).values_list("pk")
    
                return self.filter(pk__in=matches)
    
                return self.exclude(pk__in=matches)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
    
        def with_playable_uploads(self, actor):
    
            uploads = Upload.objects.playable_by(actor)
    
            return self.prefetch_related(
    
                models.Prefetch("uploads", queryset=uploads, to_attr="playable_uploads")
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            )
    
        def order_for_album(self):
            """
            Order by disc number then position
            """
            return self.order_by("disc_number", "position", "title")
    
    
    def get_artist(release_list):
        return Artist.get_or_create_from_api(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            mbid=release_list[0]["artist-credits"][0]["artists"]["id"]
        )[0]
    
        mbid = models.UUIDField(db_index=True, null=True, blank=True)
    
        title = models.CharField(max_length=MAX_LENGTHS["TRACK_TITLE"])
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        artist = models.ForeignKey(Artist, related_name="tracks", on_delete=models.CASCADE)
    
        disc_number = models.PositiveIntegerField(null=True, blank=True)
    
        position = models.PositiveIntegerField(null=True, blank=True)
    
        album = models.ForeignKey(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            Album, related_name="tracks", null=True, blank=True, on_delete=models.CASCADE
        )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        license = models.ForeignKey(
            License,
            null=True,
            blank=True,
            on_delete=models.DO_NOTHING,
            related_name="tracks",
        )
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        # Music entities are attributed to actors, to validate that updates occur
        # from an authorized account. On top of that, we consider the instance actor
        # can update anything under it's own domain
        attributed_to = models.ForeignKey(
            "federation.Actor",
            null=True,
            blank=True,
            on_delete=models.SET_NULL,
            related_name="attributed_tracks",
        )
    
        copyright = models.CharField(
            max_length=MAX_LENGTHS["COPYRIGHT"], null=True, blank=True
        )
    
        description = models.ForeignKey(
            "common.Content", null=True, blank=True, on_delete=models.SET_NULL
        )
    
        attachment_cover = models.ForeignKey(
            "common.Attachment",
            null=True,
            blank=True,
            on_delete=models.SET_NULL,
            related_name="covered_track",
        )
    
        downloads_count = models.PositiveIntegerField(default=0)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        federation_namespace = "tracks"
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        musicbrainz_model = "recording"
    
        api_includes = ["artist-credits", "releases", "media", "tags"]
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            "mbid": {"musicbrainz_field_name": "id"},
            "title": {"musicbrainz_field_name": "title"},
            "artist": {
    
                "musicbrainz_field_name": "artist-credit",
                "converter": lambda v: Artist.get_or_create_from_api(
                    mbid=v[0]["artist"]["id"]
                )[0],
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            "album": {"musicbrainz_field_name": "release-list", "converter": import_album},
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        import_hooks = [import_tags]
    
        objects = TrackQuerySet.as_manager()
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        tagged_items = GenericRelation(tags_models.TaggedItem)
    
        fetches = GenericRelation(
            "federation.Fetch",
            content_type_field="object_content_type",
            object_id_field="object_id",
        )
    
            ordering = ["album", "disc_number", "position"]
    
            indexes = [
                GinIndex(fields=["body_text"]),
            ]
    
        def get_absolute_url(self):
            return "/library/tracks/{}".format(self.pk)
    
        def get_moderation_url(self):
            return "/manage/library/tracks/{}".format(self.pk)
    
    
            except Artist.DoesNotExist:
    
                self.artist = self.album.artist
            super().save(**kwargs)
    
        @property
        def full_name(self):
            try:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                return "{} - {} - {}".format(self.artist.name, self.album.title, self.title)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                return "{} - {}".format(self.artist.name, self.title)
    
        @property
        def cover(self):
            return self.attachment_cover
    
    
        def get_activity_url(self):
            if self.mbid:
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                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):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            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(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                str(album.mbid), includes=Album.api_includes
            )
            tracks = [t for m in data["release"]["medium-list"] for t in m["track-list"]]
    
                if track["recording"]["id"] == str(mbid):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                raise ValueError("No track found matching this ID")
    
            track_artist_mbid = None
            for ac in track_data["recording"]["artist-credit"]:
                try:
                    ac_mbid = ac["artist"]["id"]
                except TypeError:
                    # it's probably a string, like "feat."
                    continue
    
                if ac_mbid == str(album.artist.mbid):
                    continue
    
                track_artist_mbid = ac_mbid
                break
            track_artist_mbid = track_artist_mbid or album.artist.mbid
            if track_artist_mbid == str(album.artist.mbid):
                track_artist = album.artist
            else:
                track_artist = Artist.get_or_create_from_api(track_artist_mbid)[0]
    
            return cls.objects.update_or_create(
                mbid=mbid,
                defaults={
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                    "position": int(track["position"]),
                    "title": track["recording"]["title"],
                    "album": album,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                },
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
    
        @property
        def listen_url(self):
    
            # Not using reverse because this is slow
            return "/api/v1/listen/{}/".format(self.uuid)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        @property
        def local_license(self):
            """
            Since license primary keys are strings, and we can get the data
            from our hardcoded licenses.LICENSES list, there is no need
            for extra SQL joins / queries.
            """
            from . import licenses
    
            return licenses.LICENSES_BY_ID.get(self.license_id)
    
    
    class UploadQuerySet(common_models.NullsLastQuerySet):
    
        def playable_by(self, actor, include=True):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            libraries = Library.objects.viewable_by(actor)
    
                return self.filter(library__in=libraries, import_status="finished")
            return self.exclude(library__in=libraries, import_status="finished")
    
    
        def local(self, include=True):
    
            query = models.Q(library__actor__domain_id=settings.FEDERATION_HOSTNAME)
            if not include:
                query = ~query
            return self.filter(query)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        def for_federation(self):
            return self.filter(import_status="finished", mimetype__startswith="audio/")
    
    
        def with_file(self):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            return self.exclude(audio_file=None).exclude(audio_file="")
    
    
    
    TRACK_FILE_IMPORT_STATUS_CHOICES = (
    
        ("pending", "Pending"),
        ("finished", "Finished"),
        ("errored", "Errored"),
        ("skipped", "Skipped"),
    )
    
    
    def get_file_path(instance, filename):
    
        if isinstance(instance, UploadVersion):
            return common_utils.ChunkedPath("transcoded")(instance, filename)
    
    
        if instance.library.actor.get_user():
    
            return common_utils.ChunkedPath("tracks")(instance, filename)
        else:
            # we cache remote tracks in a different directory
            return common_utils.ChunkedPath("federation_cache/tracks")(instance, filename)
    
    
    def get_import_reference():
        return str(uuid.uuid4())
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
    class Upload(models.Model):
    
        fid = models.URLField(unique=True, max_length=500, null=True, blank=True)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
    
        track = models.ForeignKey(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            Track, related_name="uploads", on_delete=models.CASCADE, null=True, blank=True
    
        )
        audio_file = models.FileField(upload_to=get_file_path, max_length=255)
        source = models.CharField(
            # URL validators are not flexible enough for our file:// and upload:// schemes
            null=True,
            blank=True,
            max_length=500,
        )
    
        creation_date = models.DateTimeField(default=timezone.now, db_index=True)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        modification_date = models.DateTimeField(default=timezone.now, null=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 = models.ForeignKey(
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            "library",
            null=True,
            blank=True,
            related_name="uploads",
            on_delete=models.CASCADE,
    
        # metadata from federation
        metadata = JSONField(
    
            default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder, blank=True
    
        )
        import_date = models.DateTimeField(null=True, blank=True)
        # optionnal metadata provided during import
        import_metadata = JSONField(
    
            default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder, blank=True
    
        )
        # status / error details for the import
        import_status = models.CharField(
            default="pending", choices=TRACK_FILE_IMPORT_STATUS_CHOICES, max_length=25
        )
        # a short reference provided by the client to group multiple files
        # in the same import
        import_reference = models.CharField(max_length=50, default=get_import_reference)
    
        # optionnal metadata about import results (error messages, etc.)
        import_details = JSONField(
    
            default=empty_dict, max_length=50000, encoder=DjangoJSONEncoder, blank=True
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        from_activity = models.ForeignKey(
    
            "federation.Activity", null=True, on_delete=models.SET_NULL, blank=True
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        )
    
        downloads_count = models.PositiveIntegerField(default=0)
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        objects = UploadQuerySet.as_manager()
    
        @property
        def is_local(self):
            return federation_utils.is_local(self.fid)
    
        @property
        def domain_name(self):
            if not self.fid:
                return
    
            parsed = urllib.parse.urlparse(self.fid)
            return parsed.hostname
    
    
        def download_audio_from_remote(self, actor):
    
            from funkwhale_api.federation import signing
    
    
            if actor:
                auth = signing.get_auth(actor.private_key, actor.private_key_id)
    
            else:
                auth = None
    
            remote_response = session.get_session().get(
                self.source,
                auth=auth,
                stream=True,
                timeout=20,
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                headers={"Content-Type": "application/octet-stream"},
    
            with remote_response as r:
                remote_response.raise_for_status()
                extension = utils.get_ext_from_type(self.mimetype)
                title = " - ".join(
                    [self.track.title, self.track.album.title, self.track.artist.name]
                )
                filename = "{}.{}".format(title, extension)
                tmp_file = tempfile.TemporaryFile()
                for chunk in r.iter_content(chunk_size=512):
                    tmp_file.write(chunk)
                self.audio_file.save(filename, tmp_file, save=False)
                self.save(update_fields=["audio_file"])
    
        def get_federation_id(self):
            if self.fid:
                return self.fid
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            return federation_utils.full_url(
                reverse("federation:music:uploads-detail", kwargs={"uuid": self.uuid})
            )
    
        @property
        def filename(self):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            return "{}.{}".format(self.track.full_name, self.extension)
    
            try:
                return utils.MIMETYPE_TO_EXTENSION[self.mimetype]
            except KeyError:
                pass
    
            if self.audio_file:
                return os.path.splitext(self.audio_file.name)[-1].replace(".", "", 1)
            if self.in_place_path:
                return os.path.splitext(self.in_place_path)[-1].replace(".", "", 1)
    
        def get_file_size(self):
            if self.audio_file:
                return self.audio_file.size
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            if self.source.startswith("file://"):
                return os.path.getsize(self.source.replace("file://", "", 1))
    
    
        def get_audio_file(self):
            if self.audio_file:
                return self.audio_file.open()
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            if self.source.startswith("file://"):
                return open(self.source.replace("file://", "", 1), "rb")
    
        def get_audio_data(self):
    
            if not audio_file:
                return
            audio_data = utils.get_audio_file_data(audio_file)
            if not audio_data:
                return
            return {
                "duration": int(audio_data["length"]),
                "bitrate": audio_data["bitrate"],
                "size": self.get_file_size(),
            }
    
        def get_audio_segment(self):
            input = self.get_audio_file()
            if not input:
                return
    
            input_format = utils.MIMETYPE_TO_EXTENSION[self.mimetype]
            audio = pydub.AudioSegment.from_file(input, format=input_format)
            return audio
    
    
        def save(self, **kwargs):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            if not self.mimetype:
                if self.audio_file:
                    self.mimetype = utils.guess_mimetype(self.audio_file)
                elif self.source and self.source.startswith("file://"):
                    self.mimetype = mimetypes.guess_type(self.source)[0]
    
            if not self.size and self.audio_file:
                self.size = self.audio_file.size
    
            if not self.pk and not self.fid and self.library.actor.get_user():
    
    Eliot Berriot's avatar
    Eliot Berriot committed
                self.fid = self.get_federation_id()
    
            return super().save(**kwargs)
    
        def get_metadata(self):
            audio_file = self.get_audio_file()
            if not audio_file:
                return
            return metadata.Metadata(audio_file)
    
    
        @property
        def listen_url(self):
    
    Eliot Berriot's avatar
    Eliot Berriot committed
            return self.track.listen_url + "?upload={}".format(self.uuid)
    
        def get_listen_url(self, to=None):
            url = self.listen_url
            if to:
                url += "&to={}".format(to)
            return url
    
    
        @property
        def listen_url_no_download(self):
            # Not using reverse because this is slow
            return self.listen_url + "&download=false"
    
    
        def get_transcoded_version(self, format, max_bitrate=None):
            if format:
                mimetype = utils.EXTENSION_TO_MIMETYPE[format]
            else:
                mimetype = self.mimetype or "audio/mpeg"
                format = utils.MIMETYPE_TO_EXTENSION[mimetype]
    
            existing_versions = self.versions.filter(mimetype=mimetype)
            if max_bitrate is not None:
                # we don't want to transcode if a 320kbps version is available
                # and we're requestiong 300kbps
                acceptable_max_bitrate = max_bitrate * 1.2
                acceptable_min_bitrate = max_bitrate * 0.8
                existing_versions = existing_versions.filter(
                    bitrate__gte=acceptable_min_bitrate, bitrate__lte=acceptable_max_bitrate
                ).order_by("-bitrate")
    
            if existing_versions:
                # we found an existing version, no need to transcode again
                return existing_versions[0]
    
    
            return self.create_transcoded_version(mimetype, format, bitrate=max_bitrate)
    
        def create_transcoded_version(self, mimetype, format, bitrate):
    
            # we create the version with an empty file, then
            # we'll write to it
            f = ContentFile(b"")
    
            bitrate = min(bitrate or 320000, self.bitrate or 320000)
            version = self.versions.create(mimetype=mimetype, bitrate=bitrate, size=0)
    
            # we keep the same name, but we update the extension
    
            new_name = os.path.splitext(os.path.basename(self.audio_file.name))[
                0
            ] + ".{}".format(format)
    
            version.audio_file.save(new_name, f)
    
            utils.transcode_audio(
                audio=self.get_audio_segment(),
    
                output=version.audio_file,
                output_format=utils.MIMETYPE_TO_EXTENSION[mimetype],
    
            )
            version.size = version.audio_file.size
    
            version.save(update_fields=["size"])
    
        @property
        def in_place_path(self):
            if not self.source or not self.source.startswith("file://"):
                return
            return self.source.lstrip("file://")
    
    
        @property
        def audio_file_path(self):
            if not self.audio_file:
                return None
            try:
                return self.audio_file.path
            except NotImplementedError:
                # external storage
                return self.audio_file.name
    
    
        def get_all_tagged_items(self):
            track_tags = self.track.tagged_items.all()
            album_tags = self.track.album.tagged_items.all()
            artist_tags = self.track.artist.tagged_items.all()
    
            items = (track_tags | album_tags | artist_tags).order_by("tag__name")
            return items
    
    
    MIMETYPE_CHOICES = [(mt, ext) for ext, mt in utils.AUDIO_EXTENSIONS_AND_MIMETYPE]
    
    
    
    class UploadVersion(models.Model):
    
        upload = models.ForeignKey(
            Upload, related_name="versions", on_delete=models.CASCADE
        )
    
        mimetype = models.CharField(max_length=50, choices=MIMETYPE_CHOICES)
        creation_date = models.DateTimeField(default=timezone.now)
        accessed_date = models.DateTimeField(null=True, blank=True)
        audio_file = models.FileField(upload_to=get_file_path, max_length=255)
        bitrate = models.PositiveIntegerField()
        size = models.IntegerField()
    
        class Meta:
    
            unique_together = ("upload", "mimetype", "bitrate")
    
    
        @property
        def filename(self):
    
            try:
                return (
                    self.upload.track.full_name
                    + "."
                    + utils.MIMETYPE_TO_EXTENSION[self.mimetype]
                )
            except KeyError:
                return self.upload.filename
    
        @property
        def audio_file_path(self):
            if not self.audio_file:
                return None
            try:
                return self.audio_file.path
            except NotImplementedError:
                # external storage
                return self.audio_file.name
    
    
    Eliot Berriot's avatar
    Eliot Berriot committed
        ("pending", "Pending"),