Skip to content
Snippets Groups Projects
views.py 16.4 KiB
Newer Older
from django.conf import settings
Eliot Berriot's avatar
Eliot Berriot committed
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.db.models import Count
Eliot Berriot's avatar
Eliot Berriot committed
from django.db.models.functions import Length
Eliot Berriot's avatar
Eliot Berriot committed
from musicbrainzngs import ResponseError
from rest_framework import mixins
from rest_framework import settings as rest_settings
from rest_framework import views, viewsets
from rest_framework.decorators import detail_route, list_route
from rest_framework.response import Response
Eliot Berriot's avatar
Eliot Berriot committed
from taggit.models import Tag
from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.common.permissions import ConditionalAuthentication
from funkwhale_api.federation.authentication import SignatureAuthentication
from funkwhale_api.federation.models import LibraryTrack
from funkwhale_api.musicbrainz import api
from funkwhale_api.requests.models import ImportRequest
Eliot Berriot's avatar
Eliot Berriot committed
from funkwhale_api.users.permissions import HasUserPermission
Eliot Berriot's avatar
Eliot Berriot committed
from . import filters, importers, models
from . import permissions as music_permissions
Eliot Berriot's avatar
Eliot Berriot committed
from . import serializers, tasks, utils
logger = logging.getLogger(__name__)

class TagViewSetMixin(object):
    def get_queryset(self):
        queryset = super().get_queryset()
Eliot Berriot's avatar
Eliot Berriot committed
        tag = self.request.query_params.get("tag")
        if tag:
            queryset = queryset.filter(tags__pk=tag)
        return queryset

class ArtistViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = models.Artist.objects.with_albums()
    serializer_class = serializers.ArtistWithAlbumsSerializer
    permission_classes = [ConditionalAuthentication]
    filter_class = filters.ArtistFilter
Eliot Berriot's avatar
Eliot Berriot committed
    ordering_fields = ("id", "name", "creation_date")
class AlbumViewSet(viewsets.ReadOnlyModelViewSet):
Eliot Berriot's avatar
Eliot Berriot committed
        .order_by("artist", "release_date")
        .select_related()
        .prefetch_related("tracks__artist", "tracks__files")
    )
    serializer_class = serializers.AlbumSerializer
    permission_classes = [ConditionalAuthentication]
Eliot Berriot's avatar
Eliot Berriot committed
    ordering_fields = ("creation_date", "release_date", "title")
    filter_class = filters.AlbumFilter
Eliot Berriot's avatar
Eliot Berriot committed
class ImportBatchViewSet(
Eliot Berriot's avatar
Eliot Berriot committed
    mixins.CreateModelMixin,
    mixins.ListModelMixin,
    mixins.RetrieveModelMixin,
    viewsets.GenericViewSet,
):
Eliot Berriot's avatar
Eliot Berriot committed
        models.ImportBatch.objects.select_related()
        .order_by("-creation_date")
        .annotate(job_count=Count("jobs"))
    serializer_class = serializers.ImportBatchSerializer
    permission_classes = (HasUserPermission,)
Eliot Berriot's avatar
Eliot Berriot committed
    required_permissions = ["library", "upload"]
    permission_operator = "or"
    filter_class = filters.ImportBatchFilter
Eliot Berriot's avatar
Eliot Berriot committed
    def perform_create(self, serializer):
        serializer.save(submitted_by=self.request.user)

    def get_queryset(self):
        qs = super().get_queryset()
        # if user do not have library permission, we limit to their
        # own jobs
Eliot Berriot's avatar
Eliot Berriot committed
        if not self.request.user.has_permissions("library"):
            qs = qs.filter(submitted_by=self.request.user)
        return qs

Eliot Berriot's avatar
Eliot Berriot committed

class ImportJobViewSet(
Eliot Berriot's avatar
Eliot Berriot committed
    mixins.CreateModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet
):
    queryset = models.ImportJob.objects.all().select_related()
Eliot Berriot's avatar
Eliot Berriot committed
    serializer_class = serializers.ImportJobSerializer
    permission_classes = (HasUserPermission,)
Eliot Berriot's avatar
Eliot Berriot committed
    required_permissions = ["library", "upload"]
    permission_operator = "or"
    filter_class = filters.ImportJobFilter
    def get_queryset(self):
        qs = super().get_queryset()
        # if user do not have library permission, we limit to their
        # own jobs
Eliot Berriot's avatar
Eliot Berriot committed
        if not self.request.user.has_permissions("library"):
            qs = qs.filter(batch__submitted_by=self.request.user)
        return qs

Eliot Berriot's avatar
Eliot Berriot committed
    @list_route(methods=["get"])
    def stats(self, request, *args, **kwargs):
Eliot Berriot's avatar
Eliot Berriot committed
        if not request.user.has_permissions("library"):
        qs = models.ImportJob.objects.all()
        filterset = filters.ImportJobFilter(request.GET, queryset=qs)
        qs = filterset.qs
Eliot Berriot's avatar
Eliot Berriot committed
        qs = qs.values("status").order_by("status")
        qs = qs.annotate(status_count=Count("status"))
Eliot Berriot's avatar
Eliot Berriot committed
            data[row["status"]] = row["status_count"]

        for s, _ in models.IMPORT_STATUS_CHOICES:
            data.setdefault(s, 0)

Eliot Berriot's avatar
Eliot Berriot committed
        data["count"] = sum([v for v in data.values()])
Eliot Berriot's avatar
Eliot Berriot committed
    @list_route(methods=["post"])
    def run(self, request, *args, **kwargs):
        serializer = serializers.ImportJobRunSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        payload = serializer.save()

        return Response(payload)

Eliot Berriot's avatar
Eliot Berriot committed
    def perform_create(self, serializer):
Eliot Berriot's avatar
Eliot Berriot committed
        source = "file://" + serializer.validated_data["audio_file"].name
Eliot Berriot's avatar
Eliot Berriot committed
        serializer.save(source=source)
        funkwhale_utils.on_commit(
Eliot Berriot's avatar
Eliot Berriot committed
            tasks.import_job_run.delay, import_job_id=serializer.instance.pk
class TrackViewSet(TagViewSetMixin, viewsets.ReadOnlyModelViewSet):
    """
    A simple ViewSet for viewing and editing accounts.
    """
Eliot Berriot's avatar
Eliot Berriot committed

    queryset = models.Track.objects.all().for_nested_serialization()
    serializer_class = serializers.TrackSerializer
    permission_classes = [ConditionalAuthentication]
    filter_class = filters.TrackFilter
    ordering_fields = (
Eliot Berriot's avatar
Eliot Berriot committed
        "creation_date",
        "title",
        "album__title",
        "album__release_date",
        "position",
        "artist__name",

    def get_queryset(self):
        queryset = super().get_queryset()
Eliot Berriot's avatar
Eliot Berriot committed
        filter_favorites = self.request.GET.get("favorites", None)
Eliot Berriot's avatar
Eliot Berriot committed
        if user.is_authenticated and filter_favorites == "true":
            queryset = queryset.filter(track_favorites__user=user)

        return queryset

Eliot Berriot's avatar
Eliot Berriot committed
    @detail_route(methods=["get"])
    @transaction.non_atomic_requests
    def lyrics(self, request, *args, **kwargs):
        try:
Eliot Berriot's avatar
Eliot Berriot committed
            track = models.Track.objects.get(pk=kwargs["pk"])
        except models.Track.DoesNotExist:
            return Response(status=404)

        work = track.work
        if not work:
            work = track.get_work()

        if not work:
Eliot Berriot's avatar
Eliot Berriot committed
            return Response({"error": "unavailable work "}, status=404)

        lyrics = work.fetch_lyrics()
        try:
            if not lyrics.content:
                tasks.fetch_content(lyrics_id=lyrics.pk)
                lyrics.refresh_from_db()
Eliot Berriot's avatar
Eliot Berriot committed
            return Response({"error": "unavailable lyrics"}, status=404)
        serializer = serializers.LyricsSerializer(lyrics)
        return Response(serializer.data)


    serve_path = settings.MUSIC_DIRECTORY_SERVE_PATH
    prefix = settings.MUSIC_DIRECTORY_PATH
Eliot Berriot's avatar
Eliot Berriot committed
    if t == "nginx":
        # we have to use the internal locations
        try:
            path = audio_file.url
        except AttributeError:
            # a path was given
            if not serve_path or not prefix:
                raise ValueError(
Eliot Berriot's avatar
Eliot Berriot committed
                    "You need to specify MUSIC_DIRECTORY_SERVE_PATH and "
                    "MUSIC_DIRECTORY_PATH to serve in-place imported files"
Eliot Berriot's avatar
Eliot Berriot committed
            path = "/music" + audio_file.replace(prefix, "", 1)
        return (settings.PROTECT_FILES_PATH + path).encode("utf-8")
    if t == "apache2":
        try:
            path = audio_file.path
        except AttributeError:
            # a path was given
            if not serve_path or not prefix:
                raise ValueError(
Eliot Berriot's avatar
Eliot Berriot committed
                    "You need to specify MUSIC_DIRECTORY_SERVE_PATH and "
                    "MUSIC_DIRECTORY_PATH to serve in-place imported files"
            path = audio_file.replace(prefix, serve_path, 1)
Eliot Berriot's avatar
Eliot Berriot committed
        return path.encode("utf-8")
def handle_serve(track_file):
    f = track_file
    # we update the accessed_date
    f.accessed_date = timezone.now()
Eliot Berriot's avatar
Eliot Berriot committed
    f.save(update_fields=["accessed_date"])

    mt = f.mimetype
    audio_file = f.audio_file
    try:
        library_track = f.library_track
    except ObjectDoesNotExist:
        library_track = None
    if library_track and not audio_file:
        if not library_track.audio_file:
            # we need to populate from cache
            with transaction.atomic():
                # why the transaction/select_for_update?
                # this is because browsers may send multiple requests
                # in a short time range, for partial content,
                # thus resulting in multiple downloads from the remote
                qs = LibraryTrack.objects.select_for_update()
                library_track = qs.get(pk=library_track.pk)
                library_track.download_audio()
            track_file.library_track = library_track
            track_file.set_audio_data()
Eliot Berriot's avatar
Eliot Berriot committed
            track_file.save(update_fields=["bitrate", "duration", "size"])
        audio_file = library_track.audio_file
        file_path = get_file_path(audio_file)
        mt = library_track.audio_mimetype
    elif audio_file:
        file_path = get_file_path(audio_file)
Eliot Berriot's avatar
Eliot Berriot committed
    elif f.source and f.source.startswith("file://"):
        file_path = get_file_path(f.source.replace("file://", "", 1))
    if mt:
        response = Response(content_type=mt)
    else:
        response = Response()
Eliot Berriot's avatar
Eliot Berriot committed
    mapping = {"nginx": "X-Accel-Redirect", "apache2": "X-Sendfile"}
    file_header = mapping[settings.REVERSE_PROXY_TYPE]
    response[file_header] = file_path
Eliot Berriot's avatar
Eliot Berriot committed
    filename = "filename*=UTF-8''{}".format(urllib.parse.quote(filename))
    response["Content-Disposition"] = "attachment; {}".format(filename)
    if mt:
        response["Content-Type"] = mt

    return response


class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = (
        models.TrackFile.objects.all()
Eliot Berriot's avatar
Eliot Berriot committed
        .select_related("track__artist", "track__album")
        .order_by("-id")
    serializer_class = serializers.TrackFileSerializer
Eliot Berriot's avatar
Eliot Berriot committed
    authentication_classes = (
        rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES
        + [SignatureAuthentication]
    )
    permission_classes = [music_permissions.Listen]
Eliot Berriot's avatar
Eliot Berriot committed
    @detail_route(methods=["get"])
    def serve(self, request, *args, **kwargs):
        queryset = models.TrackFile.objects.select_related(
Eliot Berriot's avatar
Eliot Berriot committed
            "library_track", "track__album__artist", "track__artist"
Eliot Berriot's avatar
Eliot Berriot committed
            return handle_serve(queryset.get(pk=kwargs["pk"]))
        except models.TrackFile.DoesNotExist:
            return Response(status=404)


class TagViewSet(viewsets.ReadOnlyModelViewSet):
Eliot Berriot's avatar
Eliot Berriot committed
    queryset = Tag.objects.all().order_by("name")
    serializer_class = serializers.TagSerializer
    permission_classes = [ConditionalAuthentication]


class Search(views.APIView):
    max_results = 3
    permission_classes = [ConditionalAuthentication]
    def get(self, request, *args, **kwargs):
Eliot Berriot's avatar
Eliot Berriot committed
        query = request.GET["query"]
            # 'tags': serializers.TagSerializer(self.get_tags(query), many=True).data,
Eliot Berriot's avatar
Eliot Berriot committed
            "artists": serializers.ArtistWithAlbumsSerializer(
                self.get_artists(query), many=True
            ).data,
            "tracks": serializers.TrackSerializer(
                self.get_tracks(query), many=True
            ).data,
            "albums": serializers.AlbumSerializer(
                self.get_albums(query), many=True
            ).data,
        }
        return Response(results, status=200)

    def get_tracks(self, query):
Eliot Berriot's avatar
Eliot Berriot committed
            "mbid",
            "title__unaccent",
            "album__title__unaccent",
            "artist__name__unaccent",
        ]
        query_obj = utils.get_query(query, search_fields)
        return (
            models.Track.objects.all()
Eliot Berriot's avatar
Eliot Berriot committed
            .filter(query_obj)
            .select_related("artist", "album__artist")
            .prefetch_related("files")
        )[: self.max_results]
Eliot Berriot's avatar
Eliot Berriot committed
        search_fields = ["mbid", "title__unaccent", "artist__name__unaccent"]
        query_obj = utils.get_query(query, search_fields)
        return (
            models.Album.objects.all()
Eliot Berriot's avatar
Eliot Berriot committed
            .filter(query_obj)
            .select_related()
            .prefetch_related("tracks__files")
        )[: self.max_results]
Eliot Berriot's avatar
Eliot Berriot committed
        search_fields = ["mbid", "name__unaccent"]
        query_obj = utils.get_query(query, search_fields)
Eliot Berriot's avatar
Eliot Berriot committed
        return (models.Artist.objects.all().filter(query_obj).with_albums())[
            : self.max_results
        ]
Eliot Berriot's avatar
Eliot Berriot committed
        search_fields = ["slug", "name__unaccent"]
        query_obj = utils.get_query(query, search_fields)

        # We want the shortest tag first
Eliot Berriot's avatar
Eliot Berriot committed
        qs = (
            Tag.objects.all()
            .annotate(slug_length=Length("slug"))
            .order_by("slug_length")
        )
Eliot Berriot's avatar
Eliot Berriot committed
        return qs.filter(query_obj)[: self.max_results]


class SubmitViewSet(viewsets.ViewSet):
    queryset = models.ImportBatch.objects.none()
    permission_classes = (HasUserPermission,)
Eliot Berriot's avatar
Eliot Berriot committed
    required_permissions = ["library"]
Eliot Berriot's avatar
Eliot Berriot committed
    @list_route(methods=["post"])
    @transaction.non_atomic_requests
    def single(self, request, *args, **kwargs):
        try:
Eliot Berriot's avatar
Eliot Berriot committed
            models.Track.objects.get(mbid=request.POST["mbid"])
            return Response({})
        except models.Track.DoesNotExist:
            pass
        batch = models.ImportBatch.objects.create(submitted_by=request.user)
Eliot Berriot's avatar
Eliot Berriot committed
        job = models.ImportJob.objects.create(
            mbid=request.POST["mbid"], batch=batch, source=request.POST["import_url"]
        )
        tasks.import_job_run.delay(import_job_id=job.pk)
        serializer = serializers.ImportBatchSerializer(batch)
        return Response(serializer.data, status=201)
    def get_import_request(self, data):
        try:
Eliot Berriot's avatar
Eliot Berriot committed
            raw = data["importRequest"]
        except KeyError:
            return

        pk = int(raw)
        try:
            return ImportRequest.objects.get(pk=pk)
        except ImportRequest.DoesNotExist:
            pass

Eliot Berriot's avatar
Eliot Berriot committed
    @list_route(methods=["post"])
    @transaction.non_atomic_requests
    def album(self, request, *args, **kwargs):
Eliot Berriot's avatar
Eliot Berriot committed
        data = json.loads(request.body.decode("utf-8"))
        import_request = self.get_import_request(data)
        import_data, batch = self._import_album(
Eliot Berriot's avatar
Eliot Berriot committed
            data, request, batch=None, import_request=import_request
        )
    def _import_album(self, data, request, batch=None, import_request=None):
        # we import the whole album here to prevent race conditions that occurs
        # when using get_or_create_from_api in tasks
Eliot Berriot's avatar
Eliot Berriot committed
        album_data = api.releases.get(
            id=data["releaseId"], includes=models.Album.api_includes
        )["release"]
        cleaned_data = models.Album.clean_musicbrainz_data(album_data)
Eliot Berriot's avatar
Eliot Berriot committed
        album = importers.load(
            models.Album, cleaned_data, album_data, import_hooks=[models.import_tracks]
        )
        try:
            album.get_image()
        except ResponseError:
            pass
        if not batch:
            batch = models.ImportBatch.objects.create(
Eliot Berriot's avatar
Eliot Berriot committed
                submitted_by=request.user, import_request=import_request
            )
        for row in data["tracks"]:
Eliot Berriot's avatar
Eliot Berriot committed
                models.TrackFile.objects.get(track__mbid=row["mbid"])
Eliot Berriot's avatar
Eliot Berriot committed
                job = models.ImportJob.objects.create(
                    mbid=row["mbid"], batch=batch, source=row["source"]
                )
                funkwhale_utils.on_commit(
Eliot Berriot's avatar
Eliot Berriot committed
                    tasks.import_job_run.delay, import_job_id=job.pk
        serializer = serializers.ImportBatchSerializer(batch)
        return serializer.data, batch

Eliot Berriot's avatar
Eliot Berriot committed
    @list_route(methods=["post"])
    @transaction.non_atomic_requests
    def artist(self, request, *args, **kwargs):
Eliot Berriot's avatar
Eliot Berriot committed
        data = json.loads(request.body.decode("utf-8"))
        import_request = self.get_import_request(data)
Eliot Berriot's avatar
Eliot Berriot committed
        artist_data = api.artists.get(id=data["artistId"])["artist"]
        cleaned_data = models.Artist.clean_musicbrainz_data(artist_data)
        importers.load(models.Artist, cleaned_data, artist_data, import_hooks=[])
Eliot Berriot's avatar
Eliot Berriot committed
        for row in data["albums"]:
            row_data, batch = self._import_album(
Eliot Berriot's avatar
Eliot Berriot committed
                row, request, batch=batch, import_request=import_request
            )
            import_data.append(row_data)

        return Response(import_data[0])