Skip to content
Snippets Groups Projects
views.py 16.6 KiB
Newer Older
import unicodedata
import urllib
from django.contrib.auth.decorators import login_required
from django.core.exceptions import ObjectDoesNotExist
from django.conf import settings
from django.db import models, transaction
from django.db.models.functions import Length
from django.http import StreamingHttpResponse
from django.urls import reverse
from django.utils.decorators import method_decorator
Eliot Berriot's avatar
Eliot Berriot committed
from rest_framework import viewsets, views, mixins
from rest_framework.decorators import detail_route, list_route
from rest_framework.response import Response
from rest_framework import settings as rest_settings
from rest_framework import permissions
from musicbrainzngs import ResponseError

from funkwhale_api.common import utils as funkwhale_utils
Eliot Berriot's avatar
Eliot Berriot committed
from funkwhale_api.common.permissions import (
    ConditionalAuthentication, HasModelPermission)
from funkwhale_api.federation import actors
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
from . import filters
from . import importers
from . import permissions as music_permissions
logger = logging.getLogger(__name__)

class SearchMixin(object):
    search_fields = []

    @list_route(methods=['get'])
    def search(self, request, *args, **kwargs):
        query = utils.get_query(request.GET['query'], self.search_fields)
        queryset = self.get_queryset().filter(query)
        serializer = self.serializer_class(queryset, many=True)
        return Response(serializer.data)

class TagViewSetMixin(object):

    def get_queryset(self):
        queryset = super().get_queryset()
        tag = self.request.query_params.get('tag')
        if tag:
            queryset = queryset.filter(tags__pk=tag)
        return queryset

class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
    queryset = (
        models.Artist.objects.all()
                             .prefetch_related(
                                'albums__tracks__files',
                                'albums__tracks__tags'))
    serializer_class = serializers.ArtistSerializerNested
    permission_classes = [ConditionalAuthentication]
    search_fields = ['name__unaccent']
    filter_class = filters.ArtistFilter
    ordering_fields = ('id', 'name', 'creation_date')
class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
    queryset = (
        models.Album.objects.all()
                            .order_by('-creation_date')
                            .select_related()
                            .prefetch_related('tracks__tags',
                                              'tracks__files'))
    serializer_class = serializers.AlbumSerializerNested
    permission_classes = [ConditionalAuthentication]
    search_fields = ['title__unaccent']
    filter_class = filters.AlbumFilter
Eliot Berriot's avatar
Eliot Berriot committed
class ImportBatchViewSet(
        mixins.CreateModelMixin,
        mixins.ListModelMixin,
        mixins.RetrieveModelMixin,
        viewsets.GenericViewSet):
    queryset = (
        models.ImportBatch.objects.all()
                          .prefetch_related('jobs__track_file')
                          .order_by('-creation_date'))
    serializer_class = serializers.ImportBatchSerializer
Eliot Berriot's avatar
Eliot Berriot committed
    permission_classes = (permissions.DjangoModelPermissions, )

    def get_queryset(self):
        return super().get_queryset().filter(submitted_by=self.request.user)

Eliot Berriot's avatar
Eliot Berriot committed
    def perform_create(self, serializer):
        serializer.save(submitted_by=self.request.user)


class ImportJobPermission(HasModelPermission):
    # not a typo, perms on import job is proxied to import batch
    model = models.ImportBatch


class ImportJobViewSet(
        mixins.CreateModelMixin,
        viewsets.GenericViewSet):
    queryset = (models.ImportJob.objects.all())
    serializer_class = serializers.ImportJobSerializer
    permission_classes = (ImportJobPermission, )

    def get_queryset(self):
        return super().get_queryset().filter(batch__submitted_by=self.request.user)

    def perform_create(self, serializer):
        source = 'file://' + serializer.validated_data['audio_file'].name
        serializer.save(source=source)
        funkwhale_utils.on_commit(
            tasks.import_job_run.delay,
            import_job_id=serializer.instance.pk
        )
class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
    """
    A simple ViewSet for viewing and editing accounts.
    """
    queryset = (models.Track.objects.all().for_nested_serialization())
    serializer_class = serializers.TrackSerializerNested
    permission_classes = [ConditionalAuthentication]
    search_fields = ['title', 'artist__name']
    ordering_fields = (
        'creation_date',
        'title__unaccent',
        'album__title__unaccent',
        'artist__name__unaccent',

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

        return queryset

    @detail_route(methods=['get'])
    @transaction.non_atomic_requests
    def lyrics(self, request, *args, **kwargs):
        try:
            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:
            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()
        except AttributeError:
            return Response({'error': 'unavailable lyrics'}, status=404)
        serializer = serializers.LyricsSerializer(lyrics)
        return Response(serializer.data)


class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
    queryset = (models.TrackFile.objects.all().order_by('-id'))
    serializer_class = serializers.TrackFileSerializer
    authentication_classes = rest_settings.api_settings.DEFAULT_AUTHENTICATION_CLASSES + [
        SignatureAuthentication
    ]
    permission_classes = [music_permissions.Listen]

    @detail_route(methods=['get'])
    def serve(self, request, *args, **kwargs):
        queryset = models.TrackFile.objects.select_related(
            'library_track',
            'track__album__artist',
            'track__artist',
        )
        except models.TrackFile.DoesNotExist:
            return Response(status=404)

        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()
            audio_file = library_track.audio_file
            file_path = '{}{}'.format(
                settings.PROTECT_FILES_PATH,
                audio_file.url)
            mt = library_track.audio_mimetype
        elif audio_file:
            file_path = '{}{}'.format(
                settings.PROTECT_FILES_PATH,
                audio_file.url)
        elif f.source and f.source.startswith('file://'):
            file_path = '{}{}'.format(
                settings.PROTECT_FILES_PATH + '/music',
                f.serve_from_source_path)
        response = Response()
        filename = f.filename
        response['X-Accel-Redirect'] = file_path
        filename = "filename*=UTF-8''{}".format(
            urllib.parse.quote(filename))
        response["Content-Disposition"] = "attachment; {}".format(filename)
        if mt:
            response["Content-Type"] = mt

    @list_route(methods=['get'])
    def viewable(self, request, *args, **kwargs):
        return Response({}, status=200)

    @list_route(methods=['get'])
    def transcode(self, request, *args, **kwargs):
        form = forms.TranscodeForm(request.GET)
        if not form.is_valid():
            return Response(form.errors, status=400)

        f = form.cleaned_data['track_file']
        if not f.audio_file:
            return Response(status=400)
        output_kwargs = {
            'format': form.cleaned_data['to']
        }
        args = (ffmpeg
            .input(f.audio_file.path)
            .output('pipe:', **output_kwargs)
            .get_args()
        )
        # we use a generator here so the view return immediatly and send
        # file chunk to the browser, instead of blocking a few seconds
        def _transcode():
            p = subprocess.Popen(
                ['ffmpeg'] + args,
                stdout=subprocess.PIPE)
            for line in p.stdout:
                yield line

        response = StreamingHttpResponse(
            _transcode(), status=200,
            content_type=form.cleaned_data['to'])

        return response

class TagViewSet(viewsets.ReadOnlyModelViewSet):
    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):
        query = request.GET['query']
        results = {
            'tags': serializers.TagSerializer(self.get_tags(query), many=True).data,
            'artists': serializers.ArtistSerializerNested(self.get_artists(query), many=True).data,
            'tracks': serializers.TrackSerializerNested(self.get_tracks(query), many=True).data,
            'albums': serializers.AlbumSerializerNested(self.get_albums(query), many=True).data,
        }
        return Response(results, status=200)

    def get_tracks(self, query):
        search_fields = [
            'mbid',
            'title__unaccent',
            'album__title__unaccent',
            'artist__name__unaccent']
        query_obj = utils.get_query(query, search_fields)
        return (
            models.Track.objects.all()
                        .filter(query_obj)
                        .select_related('album__artist')
                        .prefetch_related(
                            'tags',
                            'artist__albums__tracks__tags',
                            'files')
        )[:self.max_results]


    def get_albums(self, query):
        search_fields = [
            'mbid',
            'title__unaccent',
            'artist__name__unaccent']
        query_obj = utils.get_query(query, search_fields)
        return (
            models.Album.objects.all()
                        .filter(query_obj)
                        .select_related()
                        .prefetch_related(
                            'tracks__tags',
                            'tracks__files',
                            )
        )[:self.max_results]


    def get_artists(self, query):
        search_fields = ['mbid', 'name__unaccent']
        query_obj = utils.get_query(query, search_fields)
        return (
            models.Artist.objects.all()
                         .filter(query_obj)
                         .select_related()
                         .prefetch_related(
                             'albums__tracks__tags',
                             'albums__tracks__files',
                             )

        )[:self.max_results]


    def get_tags(self, query):
        search_fields = ['slug', 'name__unaccent']
        query_obj = utils.get_query(query, search_fields)

        # We want the shortest tag first
        qs = Tag.objects.all().annotate(slug_length=Length('slug')).order_by('slug_length')

        return qs.filter(query_obj)[:self.max_results]


class SubmitViewSet(viewsets.ViewSet):
    queryset = models.ImportBatch.objects.none()
    permission_classes = (permissions.DjangoModelPermissions, )

    @list_route(methods=['post'])
    @transaction.non_atomic_requests
    def single(self, request, *args, **kwargs):
        try:
            models.Track.objects.get(mbid=request.POST['mbid'])
            return Response({})
        except models.Track.DoesNotExist:
            pass
        batch = models.ImportBatch.objects.create(submitted_by=request.user)
        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:
            raw = data['importRequest']
        except KeyError:
            return

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

    @list_route(methods=['post'])
    @transaction.non_atomic_requests
    def album(self, request, *args, **kwargs):
        data = json.loads(request.body.decode('utf-8'))
        import_request = self.get_import_request(data)
        import_data, batch = self._import_album(
            data, request, batch=None, import_request=import_request)
    @list_route(methods=['post'])
    @transaction.non_atomic_requests
    def federation(self, request, *args, **kwargs):
        serializer = serializers.SubmitFederationTracksSerializer(
            data=request.data)
        serializer.is_valid(raise_exception=True)
        batch = serializer.save(submitted_by=request.user)
        for job in batch.jobs.all():
            funkwhale_utils.on_commit(
                tasks.import_job_run.delay,
                import_job_id=job.pk,
                use_acoustid=False,
            )

        return Response({'id': batch.id}, status=201)

    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
        album_data = api.releases.get(id=data['releaseId'], includes=models.Album.api_includes)['release']
        cleaned_data = models.Album.clean_musicbrainz_data(album_data)
        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(
                submitted_by=request.user,
                import_request=import_request)
        for row in data['tracks']:
            try:
                models.TrackFile.objects.get(track__mbid=row['mbid'])
            except models.TrackFile.DoesNotExist:
                job = models.ImportJob.objects.create(mbid=row['mbid'], batch=batch, source=row['source'])
                funkwhale_utils.on_commit(
                    tasks.import_job_run.delay,
                    import_job_id=job.pk
                )

        serializer = serializers.ImportBatchSerializer(batch)
        return serializer.data, batch

    @list_route(methods=['post'])
    @transaction.non_atomic_requests
    def artist(self, request, *args, **kwargs):
        data = json.loads(request.body.decode('utf-8'))
        import_request = self.get_import_request(data)
        artist_data = api.artists.get(id=data['artistId'])['artist']
        cleaned_data = models.Artist.clean_musicbrainz_data(artist_data)
        artist = importers.load(models.Artist, cleaned_data, artist_data, import_hooks=[])

        import_data = []
        batch = None
        for row in data['albums']:
            row_data, batch = self._import_album(
                row, request, batch=batch, import_request=import_request)
            import_data.append(row_data)

        return Response(import_data[0])