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)
|
||||
metadata = JSONField(
|
||||
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)
|
||||
|
||||
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 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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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>
|
||||
<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 = {}
|
||||
|
|
Loading…
Reference in New Issue