diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py index 76dbfd1ade0779965b09f7575107e9b4bebaf7f4..e841b639489c754a92d426da18c38445c3e1d7e8 100644 --- a/api/funkwhale_api/federation/models.py +++ b/api/funkwhale_api/federation/models.py @@ -163,3 +163,10 @@ class LibraryTrack(models.Model): title = models.CharField(max_length=500) metadata = JSONField( default={}, max_length=10000, encoder=DjangoJSONEncoder) + + @property + def mbid(self): + try: + return self.metadata['recording']['musicbrainz_id'] + except KeyError: + pass diff --git a/api/funkwhale_api/music/forms.py b/api/funkwhale_api/music/forms.py index 04e4bfe057c0723b161265c63000a2494c687a9d..e68ab73cc2b95030d6dfb284a10b8becbefc9a9e 100644 --- a/api/funkwhale_api/music/forms.py +++ b/api/funkwhale_api/music/forms.py @@ -19,5 +19,5 @@ class TranscodeForm(forms.Form): choices=BITRATE_CHOICES, required=False) track_file = forms.ModelChoiceField( - queryset=models.TrackFile.objects.all() + queryset=models.TrackFile.objects.exclude(audio_file__isnull=True) ) diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 42795dbea87b8114125dea4e8153eabd5c2600e5..b5f69eb1db866225fca4cc147e71a08901194dd8 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -3,8 +3,9 @@ from rest_framework import serializers from taggit.models import Tag from funkwhale_api.activity import serializers as activity_serializers -from funkwhale_api.federation.serializers import AP_CONTEXT from funkwhale_api.federation import utils as federation_utils +from funkwhale_api.federation.models import LibraryTrack +from funkwhale_api.federation.serializers import AP_CONTEXT from . import models @@ -153,3 +154,25 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer): def get_type(self, obj): return 'Audio' + + +class SubmitFederationTracksSerializer(serializers.Serializer): + library_tracks = serializers.PrimaryKeyRelatedField( + many=True, + queryset=LibraryTrack.objects.filter(local_track_file__isnull=True), + ) + + @transaction.atomic + def save(self, **kwargs): + batch = models.ImportBatch.objects.create( + source='federation', + **kwargs + ) + for lt in self.validated_data['library_tracks']: + models.ImportJob.objects.create( + batch=batch, + library_track=lt, + mbid=lt.mbid, + source=lt.url, + ) + return batch diff --git a/api/funkwhale_api/music/views.py b/api/funkwhale_api/music/views.py index 98048b41d7edc41ac28749172feda426aaa03708..d5247fbf60c8ad2419c5a467c6d4a4ef9d145431 100644 --- a/api/funkwhale_api/music/views.py +++ b/api/funkwhale_api/music/views.py @@ -1,6 +1,7 @@ import ffmpeg import os import json +import logging import subprocess import unicodedata import urllib @@ -40,6 +41,8 @@ from . import serializers from . import tasks from . import utils +logger = logging.getLogger(__name__) + class SearchMixin(object): search_fields = [] @@ -223,6 +226,8 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet): headers={ 'Content-Type': 'application/activity+json' }) + logger.debug( + 'Proxying media request to %s', library_track.audio_url) response = StreamingHttpResponse(remote_response.iter_content()) else: response = Response() @@ -249,6 +254,8 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet): 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'] } @@ -392,6 +399,22 @@ class SubmitViewSet(viewsets.ViewSet): data, request, batch=None, import_request=import_request) return Response(import_data) + @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) + @transaction.atomic def _import_album(self, data, request, batch=None, import_request=None): # we import the whole album here to prevent race conditions that occurs diff --git a/api/tests/music/test_views.py b/api/tests/music/test_views.py index 468ea77e38f47848c1cf7090307d8cff8f5ffd86..f18d18c8615bd838f404a1a6024d93c639d017c8 100644 --- a/api/tests/music/test_views.py +++ b/api/tests/music/test_views.py @@ -1,6 +1,8 @@ import io import pytest +from django.urls import reverse + from funkwhale_api.music import views from funkwhale_api.federation import actors @@ -83,3 +85,21 @@ def test_can_proxy_remote_track( assert response.status_code == 200 assert list(response.streaming_content) == [b't', b'e', b's', b't'] assert response['Content-Type'] == track_file.library_track.audio_mimetype + + +def test_can_create_import_from_federation_tracks( + factories, superuser_api_client, mocker): + lts = factories['federation.LibraryTrack'].create_batch(size=5) + mocker.patch('funkwhale_api.music.tasks.import_job_run') + + payload = { + 'library_tracks': [l.pk for l in lts] + } + url = reverse('api:v1:submit-federation') + response = superuser_api_client.post(url, payload) + + assert response.status_code == 201 + batch = superuser_api_client.user.imports.latest('id') + assert batch.jobs.count() == 5 + for i, job in enumerate(batch.jobs.all()): + assert job.library_track == lts[i] diff --git a/front/src/components/federation/LibraryTrackTable.vue b/front/src/components/federation/LibraryTrackTable.vue new file mode 100644 index 0000000000000000000000000000000000000000..dc6eb9d210be0971b0b276b32a3675107ddda64a --- /dev/null +++ b/front/src/components/federation/LibraryTrackTable.vue @@ -0,0 +1,143 @@ +<template> + <div> + <div class="ui inline form"> + <input type="text" v-model="search" placeholder="Search by title, artist, domain..." /> + </div> + <table v-if="result" class="ui compact very basic single line unstackable table"> + <thead> + <tr> + <th colspan="1"> + <div class="ui checkbox"> + <input + type="checkbox" + @change="toggleCheckAll" + :checked="result.results.length === checked.length"><label> </label> + </div> + </th> + <th>Title</th> + <th>Artist</th> + <th>Album</th> + <th>Published date</th> + </tr> + </thead> + <tbody> + <tr v-for="track in result.results"> + <td class="collapsing"> + <div v-if="!track.local_track_file" class="ui checkbox"> + <input + type="checkbox" + @change="toggleCheck(track.id)" + :checked="checked.indexOf(track.id) > -1"><label> </label> + </div> + <div v-else class="ui label"> + In library + </div> + </td> + <td> + {{ track.title }} + </td> + <td> + {{ track.artist_name }} + </td> + <td> + {{ track.album_title }} + </td> + <td> + <human-date :date="track.published_date"></human-date> + </td> + </tr> + </tbody> + <tfoot class="full-width"> + <tr> + <th colspan="5"> + <button + @click="launchImport" + :disabled="checked.length === 0 || isImporting" + :class="['ui', 'green', {loading: isImporting}, 'button']">Import {{ checked.length }} tracks + </button> + </th> + </tr> + </tfoot> + </table> + </div> +</template> + +<script> +import axios from 'axios' +import _ from 'lodash' + +export default { + props: ['filters'], + data () { + return { + isLoading: false, + result: null, + page: 1, + paginateBy: 50, + search: '', + checked: {}, + isImporting: false + } + }, + created () { + this.fetchData() + }, + methods: { + fetchData () { + let params = _.merge({ + 'page': this.page, + 'paginate_by': this.paginateBy, + 'q': this.search + }, this.filters) + let self = this + self.isLoading = true + self.checked = [] + axios.get('/federation/library-tracks/', {params: params}).then((response) => { + self.result = response.data + self.isLoading = false + }, error => { + self.isLoading = false + self.errors = error.backendErrors + }) + }, + launchImport () { + let self = this + self.isImporting = true + let payload = { + library_tracks: this.checked + } + axios.post('/submit/federation/', payload).then((response) => { + console.log('Triggered import', response.data) + self.isImporting = false + self.fetchData() + }, error => { + self.isImporting = false + self.errors = error.backendErrors + }) + }, + toggleCheckAll () { + if (this.checked.length === this.result.results.length) { + // we uncheck + this.checked = [] + } else { + this.checked = this.result.results.map(t => { return t.id }) + } + }, + toggleCheck (id) { + if (this.checked.indexOf(id) > -1) { + // we uncheck + this.checked.splice(this.checked.indexOf(id), 1) + } else { + this.checked.push(id) + } + } + }, + watch: { + search (newValue) { + if (newValue.length > 0) { + this.fetchData() + } + } + } +} +</script> diff --git a/front/src/views/federation/LibraryDetail.vue b/front/src/views/federation/LibraryDetail.vue index d33fcc212829e411d7d51e9650437cc3ca4ddade..6d9ab2eeb87fbf567c4d787cc67a46c8323d90c4 100644 --- a/front/src/views/federation/LibraryDetail.vue +++ b/front/src/views/federation/LibraryDetail.vue @@ -82,6 +82,16 @@ <td> <human-date v-if="object.fetched_date" :date="object.fetched_date"></human-date> <template v-else>Never</template> + <button + @click="scan" + v-if="!scanTrigerred" + :class="['ui', 'basic', {loading: isScanLoading}, 'button']"> + <i class="sync icon"></i> Trigger scan + </button> + <button v-else class="ui success button"> + <i class="check icon"></i> Scan triggered! + </button> + </td> <td></td> </tr> @@ -91,6 +101,7 @@ </div> <div class="ui vertical stripe segment"> <h2>Tracks available in this library</h2> + <library-track-table :filters="{library: id}"></library-track-table> <div class="ui stackable doubling three column grid"> </div> </div> @@ -102,13 +113,19 @@ import axios from 'axios' import logger from '@/logging' +import LibraryTrackTable from '@/components/federation/LibraryTrackTable' + export default { props: ['id'], - components: {}, + components: { + LibraryTrackTable + }, data () { return { isLoading: true, - object: null + isScanLoading: false, + object: null, + scanTrigerred: false } }, created () { @@ -125,6 +142,18 @@ export default { self.isLoading = false }) }, + scan (until) { + var self = this + this.isScanLoading = true + let data = {} + let url = 'federation/libraries/' + this.id + '/scan/' + logger.default.debug('Triggering scan for library "' + this.id + '"') + axios.post(url, data).then((response) => { + self.scanTrigerred = true + logger.default.debug('Scan triggered with id', response.data) + self.isScanLoading = false + }) + }, update (attr) { let newValue = this.object[attr] let params = {}