Can now import library tracks from front-end
This commit is contained in:
parent
2cef58e6c1
commit
3a31248a3d
|
@ -163,3 +163,10 @@ class LibraryTrack(models.Model):
|
||||||
title = models.CharField(max_length=500)
|
title = models.CharField(max_length=500)
|
||||||
metadata = JSONField(
|
metadata = JSONField(
|
||||||
default={}, max_length=10000, encoder=DjangoJSONEncoder)
|
default={}, max_length=10000, encoder=DjangoJSONEncoder)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mbid(self):
|
||||||
|
try:
|
||||||
|
return self.metadata['recording']['musicbrainz_id']
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
|
@ -19,5 +19,5 @@ class TranscodeForm(forms.Form):
|
||||||
choices=BITRATE_CHOICES, required=False)
|
choices=BITRATE_CHOICES, required=False)
|
||||||
|
|
||||||
track_file = forms.ModelChoiceField(
|
track_file = forms.ModelChoiceField(
|
||||||
queryset=models.TrackFile.objects.all()
|
queryset=models.TrackFile.objects.exclude(audio_file__isnull=True)
|
||||||
)
|
)
|
||||||
|
|
|
@ -3,8 +3,9 @@ from rest_framework import serializers
|
||||||
from taggit.models import Tag
|
from taggit.models import Tag
|
||||||
|
|
||||||
from funkwhale_api.activity import serializers as activity_serializers
|
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 import utils as federation_utils
|
||||||
|
from funkwhale_api.federation.models import LibraryTrack
|
||||||
|
from funkwhale_api.federation.serializers import AP_CONTEXT
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
@ -153,3 +154,25 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
|
||||||
|
|
||||||
def get_type(self, obj):
|
def get_type(self, obj):
|
||||||
return 'Audio'
|
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
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import ffmpeg
|
import ffmpeg
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
import unicodedata
|
import unicodedata
|
||||||
import urllib
|
import urllib
|
||||||
|
@ -40,6 +41,8 @@ from . import serializers
|
||||||
from . import tasks
|
from . import tasks
|
||||||
from . import utils
|
from . import utils
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SearchMixin(object):
|
class SearchMixin(object):
|
||||||
search_fields = []
|
search_fields = []
|
||||||
|
@ -223,6 +226,8 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
headers={
|
headers={
|
||||||
'Content-Type': 'application/activity+json'
|
'Content-Type': 'application/activity+json'
|
||||||
})
|
})
|
||||||
|
logger.debug(
|
||||||
|
'Proxying media request to %s', library_track.audio_url)
|
||||||
response = StreamingHttpResponse(remote_response.iter_content())
|
response = StreamingHttpResponse(remote_response.iter_content())
|
||||||
else:
|
else:
|
||||||
response = Response()
|
response = Response()
|
||||||
|
@ -249,6 +254,8 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
return Response(form.errors, status=400)
|
return Response(form.errors, status=400)
|
||||||
|
|
||||||
f = form.cleaned_data['track_file']
|
f = form.cleaned_data['track_file']
|
||||||
|
if not f.audio_file:
|
||||||
|
return Response(status=400)
|
||||||
output_kwargs = {
|
output_kwargs = {
|
||||||
'format': form.cleaned_data['to']
|
'format': form.cleaned_data['to']
|
||||||
}
|
}
|
||||||
|
@ -392,6 +399,22 @@ class SubmitViewSet(viewsets.ViewSet):
|
||||||
data, request, batch=None, import_request=import_request)
|
data, request, batch=None, import_request=import_request)
|
||||||
return Response(import_data)
|
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
|
@transaction.atomic
|
||||||
def _import_album(self, data, request, batch=None, import_request=None):
|
def _import_album(self, data, request, batch=None, import_request=None):
|
||||||
# we import the whole album here to prevent race conditions that occurs
|
# we import the whole album here to prevent race conditions that occurs
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import io
|
import io
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
from funkwhale_api.music import views
|
from funkwhale_api.music import views
|
||||||
from funkwhale_api.federation import actors
|
from funkwhale_api.federation import actors
|
||||||
|
|
||||||
|
@ -83,3 +85,21 @@ def test_can_proxy_remote_track(
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert list(response.streaming_content) == [b't', b'e', b's', b't']
|
assert list(response.streaming_content) == [b't', b'e', b's', b't']
|
||||||
assert response['Content-Type'] == track_file.library_track.audio_mimetype
|
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]
|
||||||
|
|
|
@ -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>
|
|
@ -82,6 +82,16 @@
|
||||||
<td>
|
<td>
|
||||||
<human-date v-if="object.fetched_date" :date="object.fetched_date"></human-date>
|
<human-date v-if="object.fetched_date" :date="object.fetched_date"></human-date>
|
||||||
<template v-else>Never</template>
|
<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></td>
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -91,6 +101,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="ui vertical stripe segment">
|
<div class="ui vertical stripe segment">
|
||||||
<h2>Tracks available in this library</h2>
|
<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 class="ui stackable doubling three column grid">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -102,13 +113,19 @@
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import logger from '@/logging'
|
import logger from '@/logging'
|
||||||
|
|
||||||
|
import LibraryTrackTable from '@/components/federation/LibraryTrackTable'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: ['id'],
|
props: ['id'],
|
||||||
components: {},
|
components: {
|
||||||
|
LibraryTrackTable
|
||||||
|
},
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
object: null
|
isScanLoading: false,
|
||||||
|
object: null,
|
||||||
|
scanTrigerred: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
created () {
|
created () {
|
||||||
|
@ -125,6 +142,18 @@ export default {
|
||||||
self.isLoading = false
|
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) {
|
update (attr) {
|
||||||
let newValue = this.object[attr]
|
let newValue = this.object[attr]
|
||||||
let params = {}
|
let params = {}
|
||||||
|
|
Loading…
Reference in New Issue