Merge branch 'release/0.3.1'

This commit is contained in:
Eliot Berriot 2017-12-27 23:36:47 +01:00
commit 08e28aa694
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
42 changed files with 953 additions and 137 deletions

View File

@ -1,8 +1,19 @@
Changelog Changelog
========= =========
0.3.2 (Unreleased)
------------------
0.2.7 (Unreleased)
0.3.1
------------------
- Revamped all import logic, everything is more tested and consistend
- Can now use Acoustid in file imports to automatically grab metadata from musicbrainz
- Brand new file import wizard
0.2.7
------------------ ------------------
- Shortcuts: can now use the ``f`` shortcut to toggle the currently playing track - Shortcuts: can now use the ``f`` shortcut to toggle the currently playing track

View File

@ -6,8 +6,8 @@ ENV PYTHONUNBUFFERED 1
COPY ./requirements.apt /requirements.apt COPY ./requirements.apt /requirements.apt
RUN apt-get update -qq && grep "^[^#;]" requirements.apt | xargs apt-get install -y RUN apt-get update -qq && grep "^[^#;]" requirements.apt | xargs apt-get install -y
RUN curl -L https://github.com/acoustid/chromaprint/releases/download/v1.4.2/chromaprint-fpcalc-1.4.2-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin --strip 1
RUN fcalc yolofkjdssdhf
COPY ./requirements/base.txt /requirements/base.txt COPY ./requirements/base.txt /requirements/base.txt
RUN pip install -r /requirements/base.txt RUN pip install -r /requirements/base.txt
COPY ./requirements/production.txt /requirements/production.txt COPY ./requirements/production.txt /requirements/production.txt

View File

@ -15,6 +15,7 @@ router.register(r'trackfiles', views.TrackFileViewSet, 'trackfiles')
router.register(r'artists', views.ArtistViewSet, 'artists') router.register(r'artists', views.ArtistViewSet, 'artists')
router.register(r'albums', views.AlbumViewSet, 'albums') router.register(r'albums', views.AlbumViewSet, 'albums')
router.register(r'import-batches', views.ImportBatchViewSet, 'import-batches') router.register(r'import-batches', views.ImportBatchViewSet, 'import-batches')
router.register(r'import-jobs', views.ImportJobViewSet, 'import-jobs')
router.register(r'submit', views.SubmitViewSet, 'submit') router.register(r'submit', views.SubmitViewSet, 'submit')
router.register(r'playlists', playlists_views.PlaylistViewSet, 'playlists') router.register(r'playlists', playlists_views.PlaylistViewSet, 'playlists')
router.register( router.register(

View File

@ -47,7 +47,6 @@ THIRD_PARTY_APPS = (
'corsheaders', 'corsheaders',
'rest_framework', 'rest_framework',
'rest_framework.authtoken', 'rest_framework.authtoken',
'djcelery',
'taggit', 'taggit',
'cachalot', 'cachalot',
'rest_auth', 'rest_auth',
@ -68,6 +67,7 @@ LOCAL_APPS = (
'funkwhale_api.playlists', 'funkwhale_api.playlists',
'funkwhale_api.providers.audiofile', 'funkwhale_api.providers.audiofile',
'funkwhale_api.providers.youtube', 'funkwhale_api.providers.youtube',
'funkwhale_api.providers.acoustid',
) )
# See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
@ -266,14 +266,14 @@ CACHES["default"]["OPTIONS"] = {
########## CELERY ########## CELERY
INSTALLED_APPS += ('funkwhale_api.taskapp.celery.CeleryConfig',) INSTALLED_APPS += ('funkwhale_api.taskapp.celery.CeleryConfig',)
BROKER_URL = env( CELERY_BROKER_URL = env(
"CELERY_BROKER_URL", default=env('CACHE_URL', default=CACHE_DEFAULT)) "CELERY_BROKER_URL", default=env('CACHE_URL', default=CACHE_DEFAULT))
########## END CELERY ########## END CELERY
# Location of root django.contrib.admin URL, use {% url 'admin:index' %} # Location of root django.contrib.admin URL, use {% url 'admin:index' %}
ADMIN_URL = r'^admin/' ADMIN_URL = r'^admin/'
# Your common stuff: Below this line define 3rd party library settings # Your common stuff: Below this line define 3rd party library settings
CELERY_DEFAULT_RATE_LIMIT = 1 CELERY_TASK_DEFAULT_RATE_LIMIT = 1
CELERYD_TASK_TIME_LIMIT = 300 CELERY_TASK_TIME_LIMIT = 300
import datetime import datetime
JWT_AUTH = { JWT_AUTH = {
'JWT_ALLOW_REFRESH': True, 'JWT_ALLOW_REFRESH': True,

View File

@ -54,7 +54,7 @@ TEST_RUNNER = 'django.test.runner.DiscoverRunner'
########## CELERY ########## CELERY
# In development, all tasks will be executed locally by blocking until the task returns # In development, all tasks will be executed locally by blocking until the task returns
CELERY_ALWAYS_EAGER = False CELERY_TASK_ALWAYS_EAGER = False
########## END CELERY ########## END CELERY
# Your local stuff: Below this line define 3rd party library settings # Your local stuff: Below this line define 3rd party library settings

View File

@ -23,7 +23,7 @@ CACHES = {
} }
} }
BROKER_URL = 'memory://' CELERY_BROKER_URL = 'memory://'
# TESTING # TESTING
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@ -31,7 +31,7 @@ TEST_RUNNER = 'django.test.runner.DiscoverRunner'
########## CELERY ########## CELERY
# In development, all tasks will be executed locally by blocking until the task returns # In development, all tasks will be executed locally by blocking until the task returns
CELERY_ALWAYS_EAGER = True CELERY_TASK_ALWAYS_EAGER = True
########## END CELERY ########## END CELERY
# Your local stuff: Below this line define 3rd party library settings # Your local stuff: Below this line define 3rd party library settings

View File

@ -7,6 +7,7 @@ ENV PYTHONDONTWRITEBYTECODE 1
COPY ./requirements.apt /requirements.apt COPY ./requirements.apt /requirements.apt
COPY ./install_os_dependencies.sh /install_os_dependencies.sh COPY ./install_os_dependencies.sh /install_os_dependencies.sh
RUN bash install_os_dependencies.sh install RUN bash install_os_dependencies.sh install
RUN curl -L https://github.com/acoustid/chromaprint/releases/download/v1.4.2/chromaprint-fpcalc-1.4.2-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin --strip 1
RUN mkdir /requirements RUN mkdir /requirements
COPY ./requirements/base.txt /requirements/base.txt COPY ./requirements/base.txt /requirements/base.txt

View File

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
__version__ = '0.3' __version__ = '0.3.1'
__version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')]) __version_info__ = tuple([int(num) if num.isdigit() else num for num in __version__.replace('-', '.', 1).split('.')])

View File

@ -1,6 +1,6 @@
from django.conf import settings from django.conf import settings
from rest_framework.permissions import BasePermission from rest_framework.permissions import BasePermission, DjangoModelPermissions
class ConditionalAuthentication(BasePermission): class ConditionalAuthentication(BasePermission):
@ -9,3 +9,14 @@ class ConditionalAuthentication(BasePermission):
if settings.API_AUTHENTICATION_REQUIRED: if settings.API_AUTHENTICATION_REQUIRED:
return request.user and request.user.is_authenticated return request.user and request.user.is_authenticated
return True return True
class HasModelPermission(DjangoModelPermissions):
"""
Same as DjangoModelPermissions, but we pin the model:
class MyModelPermission(HasModelPermission):
model = User
"""
def get_required_permissions(self, method, model_cls):
return super().get_required_permissions(method, self.model)

View File

@ -72,6 +72,14 @@ class ImportJobFactory(factory.django.DjangoModelFactory):
model = 'music.ImportJob' model = 'music.ImportJob'
@registry.register(name='music.FileImportJob')
class FileImportJobFactory(ImportJobFactory):
source = 'file://'
mbid = None
audio_file = factory.django.FileField(
from_path=os.path.join(SAMPLES_PATH, 'test.ogg'))
@registry.register @registry.register
class WorkFactory(factory.django.DjangoModelFactory): class WorkFactory(factory.django.DjangoModelFactory):
mbid = factory.Faker('uuid4') mbid = factory.Faker('uuid4')

View File

@ -0,0 +1,18 @@
# Generated by Django 2.0 on 2017-12-26 16:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0015_bind_track_file_to_import_job'),
]
operations = [
migrations.AddField(
model_name='trackfile',
name='acoustid_track_id',
field=models.UUIDField(blank=True, null=True),
),
]

View File

@ -0,0 +1,28 @@
# Generated by Django 2.0 on 2017-12-27 17:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0016_trackfile_acoustid_track_id'),
]
operations = [
migrations.AddField(
model_name='importbatch',
name='source',
field=models.CharField(choices=[('api', 'api'), ('shell', 'shell')], default='api', max_length=30),
),
migrations.AddField(
model_name='importjob',
name='audio_file',
field=models.FileField(blank=True, max_length=255, null=True, upload_to='imports/%Y/%m/%d'),
),
migrations.AlterField(
model_name='importjob',
name='mbid',
field=models.UUIDField(blank=True, editable=False, null=True),
),
]

View File

@ -15,11 +15,9 @@ from django.utils import timezone
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from versatileimagefield.fields import VersatileImageField from versatileimagefield.fields import VersatileImageField
from funkwhale_api.taskapp import celery
from funkwhale_api import downloader from funkwhale_api import downloader
from funkwhale_api import musicbrainz from funkwhale_api import musicbrainz
from . import importers from . import importers
from . import lyrics as lyrics_utils
class APIModelMixin(models.Model): class APIModelMixin(models.Model):
@ -255,14 +253,6 @@ class Lyrics(models.Model):
url = models.URLField(unique=True) url = models.URLField(unique=True)
content = models.TextField(null=True, blank=True) content = models.TextField(null=True, blank=True)
@celery.app.task(name='Lyrics.fetch_content', filter=celery.task_method)
def fetch_content(self):
html = lyrics_utils._get_html(self.url)
content = lyrics_utils.extract_content(html)
cleaned_content = lyrics_utils.clean_content(content)
self.content = cleaned_content
self.save()
@property @property
def content_rendered(self): def content_rendered(self):
return markdown.markdown( return markdown.markdown(
@ -362,6 +352,7 @@ class TrackFile(models.Model):
audio_file = models.FileField(upload_to='tracks/%Y/%m/%d', max_length=255) audio_file = models.FileField(upload_to='tracks/%Y/%m/%d', max_length=255)
source = models.URLField(null=True, blank=True) source = models.URLField(null=True, blank=True)
duration = models.IntegerField(null=True, blank=True) duration = models.IntegerField(null=True, blank=True)
acoustid_track_id = models.UUIDField(null=True, blank=True)
def download_file(self): def download_file(self):
# import the track file, since there is not any # import the track file, since there is not any
@ -393,9 +384,17 @@ class TrackFile(models.Model):
class ImportBatch(models.Model): class ImportBatch(models.Model):
IMPORT_BATCH_SOURCES = [
('api', 'api'),
('shell', 'shell')
]
source = models.CharField(
max_length=30, default='api', choices=IMPORT_BATCH_SOURCES)
creation_date = models.DateTimeField(default=timezone.now) creation_date = models.DateTimeField(default=timezone.now)
submitted_by = models.ForeignKey( submitted_by = models.ForeignKey(
'users.User', related_name='imports', on_delete=models.CASCADE) 'users.User',
related_name='imports',
on_delete=models.CASCADE)
class Meta: class Meta:
ordering = ['-creation_date'] ordering = ['-creation_date']
@ -406,8 +405,11 @@ class ImportBatch(models.Model):
@property @property
def status(self): def status(self):
pending = any([job.status == 'pending' for job in self.jobs.all()]) pending = any([job.status == 'pending' for job in self.jobs.all()])
errored = any([job.status == 'errored' for job in self.jobs.all()])
if pending: if pending:
return 'pending' return 'pending'
if errored:
return 'errored'
return 'finished' return 'finished'
class ImportJob(models.Model): class ImportJob(models.Model):
@ -419,36 +421,17 @@ class ImportJob(models.Model):
null=True, null=True,
blank=True, blank=True,
on_delete=models.CASCADE) on_delete=models.CASCADE)
source = models.URLField() source = models.CharField(max_length=500)
mbid = models.UUIDField(editable=False) mbid = models.UUIDField(editable=False, null=True, blank=True)
STATUS_CHOICES = ( STATUS_CHOICES = (
('pending', 'Pending'), ('pending', 'Pending'),
('finished', 'finished'), ('finished', 'Finished'),
('errored', 'Errored'),
('skipped', 'Skipped'),
) )
status = models.CharField(choices=STATUS_CHOICES, default='pending', max_length=30) status = models.CharField(choices=STATUS_CHOICES, default='pending', max_length=30)
audio_file = models.FileField(
upload_to='imports/%Y/%m/%d', max_length=255, null=True, blank=True)
class Meta: class Meta:
ordering = ('id', ) ordering = ('id', )
@celery.app.task(name='ImportJob.run', filter=celery.task_method)
def run(self, replace=False):
try:
track, created = Track.get_or_create_from_api(mbid=self.mbid)
track_file = None
if replace:
track_file = track.files.first()
elif track.files.count() > 0:
return
track_file = track_file or TrackFile(
track=track, source=self.source)
track_file.download_file()
track_file.save()
self.status = 'finished'
self.track_file = track_file
self.save()
return track.pk
except Exception as exc:
if not settings.DEBUG:
raise ImportJob.run.retry(args=[self], exc=exc, countdown=30, max_retries=3)
raise

View File

@ -113,7 +113,8 @@ class ImportJobSerializer(serializers.ModelSerializer):
track_file = TrackFileSerializer(read_only=True) track_file = TrackFileSerializer(read_only=True)
class Meta: class Meta:
model = models.ImportJob model = models.ImportJob
fields = ('id', 'mbid', 'source', 'status', 'track_file') fields = ('id', 'mbid', 'batch', 'source', 'status', 'track_file', 'audio_file')
read_only_fields = ('status', 'track_file')
class ImportBatchSerializer(serializers.ModelSerializer): class ImportBatchSerializer(serializers.ModelSerializer):
@ -121,3 +122,4 @@ class ImportBatchSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.ImportBatch model = models.ImportBatch
fields = ('id', 'jobs', 'status', 'creation_date') fields = ('id', 'jobs', 'status', 'creation_date')
read_only_fields = ('creation_date',)

View File

@ -0,0 +1,102 @@
from django.core.files.base import ContentFile
from funkwhale_api.taskapp import celery
from funkwhale_api.providers.acoustid import get_acoustid_client
from funkwhale_api.providers.audiofile.tasks import import_track_data_from_path
from django.conf import settings
from . import models
from . import lyrics as lyrics_utils
@celery.app.task(name='acoustid.set_on_track_file')
@celery.require_instance(models.TrackFile, 'track_file')
def set_acoustid_on_track_file(track_file):
client = get_acoustid_client()
result = client.get_best_match(track_file.audio_file.path)
def update(id):
track_file.acoustid_track_id = id
track_file.save(update_fields=['acoustid_track_id'])
return id
if result:
return update(result['id'])
def _do_import(import_job, replace):
from_file = bool(import_job.audio_file)
mbid = import_job.mbid
acoustid_track_id = None
duration = None
track = None
if not mbid and from_file:
# we try to deduce mbid from acoustid
client = get_acoustid_client()
match = client.get_best_match(import_job.audio_file.path)
if not match:
raise ValueError('Cannot get match')
duration = match['recordings'][0]['duration']
mbid = match['recordings'][0]['id']
acoustid_track_id = match['id']
if mbid:
track, _ = models.Track.get_or_create_from_api(mbid=mbid)
else:
track = import_track_data_from_path(import_job.audio_file.path)
track_file = None
if replace:
track_file = track.files.first()
elif track.files.count() > 0:
if import_job.audio_file:
import_job.audio_file.delete()
import_job.status = 'skipped'
import_job.save()
return
track_file = track_file or models.TrackFile(
track=track, source=import_job.source)
track_file.acoustid_track_id = acoustid_track_id
if from_file:
track_file.audio_file = ContentFile(import_job.audio_file.read())
track_file.audio_file.name = import_job.audio_file.name
track_file.duration = duration
else:
track_file.download_file()
track_file.save()
import_job.status = 'finished'
import_job.track_file = track_file
if import_job.audio_file:
# it's imported on the track, we don't need it anymore
import_job.audio_file.delete()
import_job.save()
return track.pk
@celery.app.task(name='ImportJob.run', bind=True)
@celery.require_instance(models.ImportJob, 'import_job')
def import_job_run(self, import_job, replace=False):
def mark_errored():
import_job.status = 'errored'
import_job.save()
try:
return _do_import(import_job, replace)
except Exception as exc:
if not settings.DEBUG:
try:
self.retry(exc=exc, countdown=30, max_retries=3)
except:
mark_errored()
raise
mark_errored()
raise
@celery.app.task(name='Lyrics.fetch_content')
@celery.require_instance(models.Lyrics, 'lyrics')
def fetch_content(lyrics):
html = lyrics_utils._get_html(lyrics.url)
content = lyrics_utils.extract_content(html)
cleaned_content = lyrics_utils.clean_content(content)
lyrics.content = cleaned_content
lyrics.save(update_fields=['content'])

View File

@ -6,7 +6,7 @@ from django.urls import reverse
from django.db import models, transaction from django.db import models, transaction
from django.db.models.functions import Length from django.db.models.functions import Length
from django.conf import settings from django.conf import settings
from rest_framework import viewsets, views from rest_framework import viewsets, views, mixins
from rest_framework.decorators import detail_route, list_route from rest_framework.decorators import detail_route, list_route
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import permissions from rest_framework import permissions
@ -15,13 +15,15 @@ from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from funkwhale_api.musicbrainz import api from funkwhale_api.musicbrainz import api
from funkwhale_api.common.permissions import ConditionalAuthentication from funkwhale_api.common.permissions import (
ConditionalAuthentication, HasModelPermission)
from taggit.models import Tag from taggit.models import Tag
from . import models from . import models
from . import serializers from . import serializers
from . import importers from . import importers
from . import filters from . import filters
from . import tasks
from . import utils from . import utils
@ -70,16 +72,45 @@ class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
ordering_fields = ('creation_date',) ordering_fields = ('creation_date',)
class ImportBatchViewSet(viewsets.ReadOnlyModelViewSet): class ImportBatchViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet):
queryset = ( queryset = (
models.ImportBatch.objects.all() models.ImportBatch.objects.all()
.prefetch_related('jobs__track_file') .prefetch_related('jobs__track_file')
.order_by('-creation_date')) .order_by('-creation_date'))
serializer_class = serializers.ImportBatchSerializer serializer_class = serializers.ImportBatchSerializer
permission_classes = (permissions.DjangoModelPermissions, )
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(submitted_by=self.request.user) return super().get_queryset().filter(submitted_by=self.request.user)
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)
tasks.import_job_run.delay(import_job_id=serializer.instance.pk)
class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet): class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
""" """
@ -129,7 +160,8 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
lyrics = work.fetch_lyrics() lyrics = work.fetch_lyrics()
try: try:
if not lyrics.content: if not lyrics.content:
lyrics.fetch_content() tasks.fetch_content(lyrics_id=lyrics.pk)
lyrics.refresh_from_db()
except AttributeError: except AttributeError:
return Response({'error': 'unavailable lyrics'}, status=404) return Response({'error': 'unavailable lyrics'}, status=404)
serializer = serializers.LyricsSerializer(lyrics) serializer = serializers.LyricsSerializer(lyrics)
@ -244,7 +276,7 @@ class SubmitViewSet(viewsets.ViewSet):
pass pass
batch = models.ImportBatch.objects.create(submitted_by=request.user) 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']) job = models.ImportJob.objects.create(mbid=request.POST['mbid'], batch=batch, source=request.POST['import_url'])
job.run.delay() tasks.import_job_run.delay(import_job_id=job.pk)
serializer = serializers.ImportBatchSerializer(batch) serializer = serializers.ImportBatchSerializer(batch)
return Response(serializer.data) return Response(serializer.data)
@ -272,7 +304,7 @@ class SubmitViewSet(viewsets.ViewSet):
models.TrackFile.objects.get(track__mbid=row['mbid']) models.TrackFile.objects.get(track__mbid=row['mbid'])
except models.TrackFile.DoesNotExist: except models.TrackFile.DoesNotExist:
job = models.ImportJob.objects.create(mbid=row['mbid'], batch=batch, source=row['source']) job = models.ImportJob.objects.create(mbid=row['mbid'], batch=batch, source=row['source'])
job.run.delay() tasks.import_job_run.delay(import_job_id=job.pk)
serializer = serializers.ImportBatchSerializer(batch) serializer = serializers.ImportBatchSerializer(batch)
return serializer.data, batch return serializer.data, batch

View File

@ -0,0 +1,27 @@
import acoustid
from dynamic_preferences.registries import global_preferences_registry
class Client(object):
def __init__(self, api_key):
self.api_key = api_key
def match(self, file_path):
return acoustid.match(self.api_key, file_path, parse=False)
def get_best_match(self, file_path):
results = self.match(file_path=file_path)
MIN_SCORE_FOR_MATCH = 0.8
try:
rows = results['results']
except KeyError:
return
for row in rows:
if row['score'] >= MIN_SCORE_FOR_MATCH:
return row
def get_acoustid_client():
manager = global_preferences_registry.manager()
return Client(api_key=manager['providers_acoustid__api_key'])

View File

@ -0,0 +1,13 @@
from dynamic_preferences.types import StringPreference, Section
from dynamic_preferences.registries import global_preferences_registry
acoustid = Section('providers_acoustid')
@global_preferences_registry.register
class APIKey(StringPreference):
section = acoustid
name = 'api_key'
default = ''
verbose_name = 'Acoustid API key'
help_text = 'The API key used to query AcoustID. Get one at https://acoustid.org/new-application.'

View File

@ -1,6 +1,10 @@
import glob import glob
import os
from django.core.files import File
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from funkwhale_api.providers.audiofile import tasks from funkwhale_api.music import tasks
from funkwhale_api.users.models import User
class Command(BaseCommand): class Command(BaseCommand):
@ -15,6 +19,11 @@ class Command(BaseCommand):
default=False, default=False,
help='Will match the pattern recursively (including subdirectories)', help='Will match the pattern recursively (including subdirectories)',
) )
parser.add_argument(
'--username',
dest='username',
help='The username of the user you want to be bound to the import',
)
parser.add_argument( parser.add_argument(
'--async', '--async',
action='store_true', action='store_true',
@ -46,6 +55,20 @@ class Command(BaseCommand):
if not matching: if not matching:
raise CommandError('No file matching pattern, aborting') raise CommandError('No file matching pattern, aborting')
user = None
if options['username']:
try:
user = User.objects.get(username=options['username'])
except User.DoesNotExist:
raise CommandError('Invalid username')
else:
# we bind the import to the first registered superuser
try:
user = User.objects.filter(is_superuser=True).order_by('pk').first()
assert user is not None
except AssertionError:
raise CommandError(
'No superuser available, please provide a --username')
if options['interactive']: if options['interactive']:
message = ( message = (
'Are you sure you want to do this?\n\n' 'Are you sure you want to do this?\n\n'
@ -54,18 +77,35 @@ class Command(BaseCommand):
if input(''.join(message)) != 'yes': if input(''.join(message)) != 'yes':
raise CommandError("Import cancelled.") raise CommandError("Import cancelled.")
message = 'Importing {}...' batch = self.do_import(matching, user=user, options=options)
if options['async']:
message = 'Launching import for {}...'
for path in matching:
self.stdout.write(message.format(path))
try:
tasks.from_path(path)
except Exception as e:
self.stdout.write('Error: {}'.format(e))
message = 'Successfully imported {} tracks' message = 'Successfully imported {} tracks'
if options['async']: if options['async']:
message = 'Successfully launched import for {} tracks' message = 'Successfully launched import for {} tracks'
self.stdout.write(message.format(len(matching))) self.stdout.write(message.format(len(matching)))
self.stdout.write(
"For details, please refer to import batch #".format(batch.pk))
def do_import(self, matching, user, options):
message = 'Importing {}...'
if options['async']:
message = 'Launching import for {}...'
# we create an import batch binded to the user
batch = user.imports.create(source='shell')
async = options['async']
handler = tasks.import_job_run.delay if async else tasks.import_job_run
for path in matching:
job = batch.jobs.create(
source='file://' + path,
)
name = os.path.basename(path)
with open(path, 'rb') as f:
job.audio_file.save(name, File(f))
job.save()
try:
handler(import_job_id=job.pk)
except Exception as e:
self.stdout.write('Error: {}'.format(e))
return batch

View File

@ -1,20 +1,20 @@
import acoustid
import os import os
import datetime import datetime
from django.core.files import File from django.core.files import File
from funkwhale_api.taskapp import celery from funkwhale_api.taskapp import celery
from funkwhale_api.providers.acoustid import get_acoustid_client
from funkwhale_api.music import models, metadata from funkwhale_api.music import models, metadata
@celery.app.task(name='audiofile.from_path') def import_track_data_from_path(path):
def from_path(path):
data = metadata.Metadata(path) data = metadata.Metadata(path)
artist = models.Artist.objects.get_or_create( artist = models.Artist.objects.get_or_create(
name__iexact=data.get('artist'), name__iexact=data.get('artist'),
defaults={ defaults={
'name': data.get('artist'), 'name': data.get('artist'),
'mbid': data.get('musicbrainz_artistid', None), 'mbid': data.get('musicbrainz_artistid', None),
}, },
)[0] )[0]
@ -39,11 +39,33 @@ def from_path(path):
'mbid': data.get('musicbrainz_recordingid', None), 'mbid': data.get('musicbrainz_recordingid', None),
}, },
)[0] )[0]
return track
def import_metadata_with_musicbrainz(path):
pass
@celery.app.task(name='audiofile.from_path')
def from_path(path):
acoustid_track_id = None
try:
client = get_acoustid_client()
result = client.get_best_match(path)
acoustid_track_id = result['id']
except acoustid.WebServiceError:
track = import_track_data_from_path(path)
except (TypeError, KeyError):
track = import_metadata_without_musicbrainz(path)
else:
track, created = models.Track.get_or_create_from_api(
mbid=result['recordings'][0]['id']
)
if track.files.count() > 0: if track.files.count() > 0:
raise ValueError('File already exists for track {}'.format(track.pk)) raise ValueError('File already exists for track {}'.format(track.pk))
track_file = models.TrackFile(track=track) track_file = models.TrackFile(
track=track, acoustid_track_id=acoustid_track_id)
track_file.audio_file.save( track_file.audio_file.save(
os.path.basename(path), os.path.basename(path),
File(open(path, 'rb')) File(open(path, 'rb'))

View File

@ -1,10 +1,12 @@
from __future__ import absolute_import from __future__ import absolute_import
import os import os
import functools
from celery import Celery from celery import Celery
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings from django.conf import settings
from celery.contrib.methods import task_method
if not settings.configured: if not settings.configured:
# set the default Django settings module for the 'celery' program. # set the default Django settings module for the 'celery' program.
@ -21,12 +23,20 @@ class CeleryConfig(AppConfig):
def ready(self): def ready(self):
# Using a string here means the worker will not have to # Using a string here means the worker will not have to
# pickle the object when using Windows. # pickle the object when using Windows.
app.config_from_object('django.conf:settings') app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks(lambda: settings.INSTALLED_APPS, force=True) app.autodiscover_tasks(lambda: settings.INSTALLED_APPS, force=True)
def require_instance(model_or_qs, parameter_name):
def decorator(function):
@app.task(bind=True) @functools.wraps(function)
def debug_task(self): def inner(*args, **kwargs):
print('Request: {0!r}'.format(self.request)) # pragma: no cover pk = kwargs.pop('_'.join([parameter_name, 'id']))
try:
instance = model_or_qs.get(pk=pk)
except AttributeError:
instance = model_or_qs.objects.get(pk=pk)
kwargs[parameter_name] = instance
return function(*args, **kwargs)
return inner
return decorator

View File

@ -79,4 +79,3 @@ case "$1" in
help) usage_message;; help) usage_message;;
*) wrong_command $1;; *) wrong_command $1;;
esac esac

View File

@ -7,3 +7,4 @@ libpq-dev
postgresql-client postgresql-client
libav-tools libav-tools
python3-dev python3-dev
curl

View File

@ -24,7 +24,7 @@ django-redis>=4.5,<4.6
redis>=2.10,<2.11 redis>=2.10,<2.11
celery>=3.1,<3.2 celery>=4.1,<4.2
# Your custom requirements go here # Your custom requirements go here
@ -33,7 +33,6 @@ musicbrainzngs==0.6
youtube_dl>=2017.12.14 youtube_dl>=2017.12.14
djangorestframework>=3.7,<3.8 djangorestframework>=3.7,<3.8
djangorestframework-jwt>=1.11,<1.12 djangorestframework-jwt>=1.11,<1.12
django-celery>=3.2,<3.3
django-mptt>=0.9,<0.10 django-mptt>=0.9,<0.10
google-api-python-client>=1.6,<1.7 google-api-python-client>=1.6,<1.7
arrow>=0.12,<0.13 arrow>=0.12,<0.13
@ -57,3 +56,4 @@ git+https://github.com/EliotBerriot/PyMemoize.git@django
git+https://github.com/EliotBerriot/django-cachalot.git@django-2 git+https://github.com/EliotBerriot/django-cachalot.git@django-2
django-dynamic-preferences>=1.5,<1.6 django-dynamic-preferences>=1.5,<1.6
pyacoustid>=1.1.5,<1.2

View File

@ -1,6 +1,10 @@
import tempfile import tempfile
import shutil import shutil
import pytest import pytest
from django.core.cache import cache as django_cache
from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.taskapp import celery
@pytest.fixture(scope="session", autouse=True) @pytest.fixture(scope="session", autouse=True)
@ -11,12 +15,23 @@ def factories_autodiscover():
factories.registry.autodiscover(app_names) factories.registry.autodiscover(app_names)
@pytest.fixture(autouse=True)
def cache():
yield django_cache
django_cache.clear()
@pytest.fixture @pytest.fixture
def factories(db): def factories(db):
from funkwhale_api import factories from funkwhale_api import factories
yield factories.registry yield factories.registry
@pytest.fixture
def preferences(db):
yield global_preferences_registry.manager()
@pytest.fixture @pytest.fixture
def tmpdir(): def tmpdir():
d = tempfile.mkdtemp() d = tempfile.mkdtemp()

View File

@ -1,4 +1,5 @@
import json import json
import os
import pytest import pytest
from django.urls import reverse from django.urls import reverse
@ -8,6 +9,8 @@ from funkwhale_api.music import serializers
from . import data as api_data from . import data as api_data
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
def test_can_submit_youtube_url_for_track_import(mocker, superuser_client): def test_can_submit_youtube_url_for_track_import(mocker, superuser_client):
mocker.patch( mocker.patch(
@ -34,11 +37,11 @@ def test_can_submit_youtube_url_for_track_import(mocker, superuser_client):
assert track.album.title == 'Marsupial Madness' assert track.album.title == 'Marsupial Madness'
def test_import_creates_an_import_with_correct_data(superuser_client, settings): def test_import_creates_an_import_with_correct_data(mocker, superuser_client):
mocker.patch('funkwhale_api.music.tasks.import_job_run')
mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed' mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
video_id = 'tPEE9ZwTmy0' video_id = 'tPEE9ZwTmy0'
url = reverse('api:v1:submit-single') url = reverse('api:v1:submit-single')
settings.CELERY_ALWAYS_EAGER = False
response = superuser_client.post( response = superuser_client.post(
url, url,
{'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id), {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id),
@ -54,7 +57,8 @@ def test_import_creates_an_import_with_correct_data(superuser_client, settings):
assert job.source == 'https://www.youtube.com/watch?v={0}'.format(video_id) assert job.source == 'https://www.youtube.com/watch?v={0}'.format(video_id)
def test_can_import_whole_album(mocker, superuser_client, settings): def test_can_import_whole_album(mocker, superuser_client):
mocker.patch('funkwhale_api.music.tasks.import_job_run')
mocker.patch( mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get', 'funkwhale_api.musicbrainz.api.artists.get',
return_value=api_data.artists['get']['soad']) return_value=api_data.artists['get']['soad'])
@ -82,7 +86,6 @@ def test_can_import_whole_album(mocker, superuser_client, settings):
] ]
} }
url = reverse('api:v1:submit-album') url = reverse('api:v1:submit-album')
settings.CELERY_ALWAYS_EAGER = False
response = superuser_client.post( response = superuser_client.post(
url, json.dumps(payload), content_type="application/json") url, json.dumps(payload), content_type="application/json")
@ -109,7 +112,8 @@ def test_can_import_whole_album(mocker, superuser_client, settings):
assert job.source == row['source'] assert job.source == row['source']
def test_can_import_whole_artist(mocker, superuser_client, settings): def test_can_import_whole_artist(mocker, superuser_client):
mocker.patch('funkwhale_api.music.tasks.import_job_run')
mocker.patch( mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get', 'funkwhale_api.musicbrainz.api.artists.get',
return_value=api_data.artists['get']['soad']) return_value=api_data.artists['get']['soad'])
@ -142,7 +146,6 @@ def test_can_import_whole_artist(mocker, superuser_client, settings):
] ]
} }
url = reverse('api:v1:submit-artist') url = reverse('api:v1:submit-artist')
settings.CELERY_ALWAYS_EAGER = False
response = superuser_client.post( response = superuser_client.post(
url, json.dumps(payload), content_type="application/json") url, json.dumps(payload), content_type="application/json")
@ -189,6 +192,48 @@ def test_user_can_query_api_for_his_own_batches(client, factories):
assert results['results'][0]['jobs'][0]['mbid'] == job.mbid assert results['results'][0]['jobs'][0]['mbid'] == job.mbid
def test_user_can_create_an_empty_batch(client, factories):
user = factories['users.SuperUser']()
url = reverse('api:v1:import-batches-list')
client.login(username=user.username, password='test')
response = client.post(url)
assert response.status_code == 201
batch = user.imports.latest('id')
assert batch.submitted_by == user
assert batch.source == 'api'
def test_user_can_create_import_job_with_file(client, factories, mocker):
path = os.path.join(DATA_DIR, 'test.ogg')
m = mocker.patch('funkwhale_api.music.tasks.import_job_run.delay')
user = factories['users.SuperUser']()
batch = factories['music.ImportBatch'](submitted_by=user)
url = reverse('api:v1:import-jobs-list')
client.login(username=user.username, password='test')
with open(path, 'rb') as f:
content = f.read()
f.seek(0)
response = client.post(url, {
'batch': batch.pk,
'audio_file': f,
'source': 'file://'
}, format='multipart')
assert response.status_code == 201
job = batch.jobs.latest('id')
assert job.status == 'pending'
assert job.source.startswith('file://')
assert 'test.ogg' in job.source
assert job.audio_file.read() == content
m.assert_called_once_with(import_job_id=job.pk)
def test_can_search_artist(factories, client): def test_can_search_artist(factories, client):
artist1 = factories['music.Artist']() artist1 = factories['music.Artist']()
artist2 = factories['music.Artist']() artist2 = factories['music.Artist']()

View File

@ -4,6 +4,7 @@ from django.urls import reverse
from funkwhale_api.music import models from funkwhale_api.music import models
from funkwhale_api.musicbrainz import api from funkwhale_api.musicbrainz import api
from funkwhale_api.music import serializers from funkwhale_api.music import serializers
from funkwhale_api.music import tasks
from funkwhale_api.music import lyrics as lyrics_utils from funkwhale_api.music import lyrics as lyrics_utils
from .mocking import lyricswiki from .mocking import lyricswiki
@ -18,7 +19,8 @@ def test_works_import_lyrics_if_any(mocker, factories):
lyrics = factories['music.Lyrics']( lyrics = factories['music.Lyrics'](
url='http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!') url='http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!')
lyrics.fetch_content() tasks.fetch_content(lyrics_id=lyrics.pk)
lyrics.refresh_from_db()
self.assertIn( self.assertIn(
'Grab a brush and put on a little makeup', 'Grab a brush and put on a little makeup',
lyrics.content, lyrics.content,

View File

@ -2,6 +2,7 @@ import pytest
from funkwhale_api.music import models from funkwhale_api.music import models
from funkwhale_api.music import importers from funkwhale_api.music import importers
from funkwhale_api.music import tasks
def test_can_store_release_group_id_on_album(factories): def test_can_store_release_group_id_on_album(factories):
@ -44,6 +45,6 @@ def test_import_job_is_bound_to_track_file(factories, mocker):
job = factories['music.ImportJob'](mbid=track.mbid) job = factories['music.ImportJob'](mbid=track.mbid)
mocker.patch('funkwhale_api.music.models.TrackFile.download_file') mocker.patch('funkwhale_api.music.models.TrackFile.download_file')
job.run() tasks.import_job_run(import_job_id=job.pk)
job.refresh_from_db() job.refresh_from_db()
assert job.track_file.track == track assert job.track_file.track == track

View File

@ -0,0 +1,153 @@
import os
import pytest
from funkwhale_api.providers.acoustid import get_acoustid_client
from funkwhale_api.music import tasks
from . import data as api_data
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
def test_set_acoustid_on_track_file(factories, mocker):
track_file = factories['music.TrackFile'](acoustid_track_id=None)
id = 'e475bf79-c1ce-4441-bed7-1e33f226c0a2'
payload = {
'results': [
{'id': id,
'recordings': [
{'artists': [
{'id': '9c6bddde-6228-4d9f-ad0d-03f6fcb19e13',
'name': 'Binärpilot'}],
'duration': 268,
'id': 'f269d497-1cc0-4ae4-a0c4-157ec7d73fcb',
'title': 'Bend'}],
'score': 0.860825}],
'status': 'ok'
}
m = mocker.patch('acoustid.match', return_value=payload)
r = tasks.set_acoustid_on_track_file(track_file_id=track_file.pk)
track_file.refresh_from_db()
assert str(track_file.acoustid_track_id) == id
assert r == id
m.assert_called_once_with('', track_file.audio_file.path, parse=False)
def test_set_acoustid_on_track_file_required_high_score(factories, mocker):
track_file = factories['music.TrackFile'](acoustid_track_id=None)
id = 'e475bf79-c1ce-4441-bed7-1e33f226c0a2'
payload = {
'results': [{'score': 0.79}],
'status': 'ok'
}
m = mocker.patch('acoustid.match', return_value=payload)
r = tasks.set_acoustid_on_track_file(track_file_id=track_file.pk)
track_file.refresh_from_db()
assert track_file.acoustid_track_id is None
def test_import_job_can_run_with_file_and_acoustid(factories, mocker):
path = os.path.join(DATA_DIR, 'test.ogg')
mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
acoustid_payload = {
'results': [
{'id': 'e475bf79-c1ce-4441-bed7-1e33f226c0a2',
'recordings': [
{
'duration': 268,
'id': mbid}],
'score': 0.860825}],
'status': 'ok'
}
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
return_value=api_data.artists['get']['adhesive_wombat'])
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
return_value=api_data.albums['get']['marsupial'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.search',
return_value=api_data.tracks['search']['8bitadventures'])
mocker.patch('acoustid.match', return_value=acoustid_payload)
job = factories['music.FileImportJob'](audio_file__path=path)
f = job.audio_file
tasks.import_job_run(import_job_id=job.pk)
job.refresh_from_db()
track_file = job.track_file
with open(path, 'rb') as f:
assert track_file.audio_file.read() == f.read()
assert track_file.duration == 268
# audio file is deleted from import job once persisted to audio file
assert not job.audio_file
assert job.status == 'finished'
assert job.source == 'file://'
def test_import_job_can_be_skipped(factories, mocker):
path = os.path.join(DATA_DIR, 'test.ogg')
mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
track_file = factories['music.TrackFile'](track__mbid=mbid)
acoustid_payload = {
'results': [
{'id': 'e475bf79-c1ce-4441-bed7-1e33f226c0a2',
'recordings': [
{
'duration': 268,
'id': mbid}],
'score': 0.860825}],
'status': 'ok'
}
mocker.patch(
'funkwhale_api.musicbrainz.api.artists.get',
return_value=api_data.artists['get']['adhesive_wombat'])
mocker.patch(
'funkwhale_api.musicbrainz.api.releases.get',
return_value=api_data.albums['get']['marsupial'])
mocker.patch(
'funkwhale_api.musicbrainz.api.recordings.search',
return_value=api_data.tracks['search']['8bitadventures'])
mocker.patch('acoustid.match', return_value=acoustid_payload)
job = factories['music.FileImportJob'](audio_file__path=path)
f = job.audio_file
tasks.import_job_run(import_job_id=job.pk)
job.refresh_from_db()
assert job.track_file is None
# audio file is deleted from import job once persisted to audio file
assert not job.audio_file
assert job.status == 'skipped'
def test_import_job_can_be_errored(factories, mocker):
path = os.path.join(DATA_DIR, 'test.ogg')
mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
track_file = factories['music.TrackFile'](track__mbid=mbid)
acoustid_payload = {
'results': [
{'id': 'e475bf79-c1ce-4441-bed7-1e33f226c0a2',
'recordings': [
{
'duration': 268,
'id': mbid}],
'score': 0.860825}],
'status': 'ok'
}
class MyException(Exception):
pass
mocker.patch('acoustid.match', side_effect=MyException())
job = factories['music.FileImportJob'](
audio_file__path=path, track_file=None)
with pytest.raises(MyException):
tasks.import_job_run(import_job_id=job.pk)
job.refresh_from_db()
assert job.track_file is None
assert job.status == 'errored'

View File

@ -0,0 +1,34 @@
from funkwhale_api.providers.acoustid import get_acoustid_client
def test_client_is_configured_with_correct_api_key(preferences):
api_key = 'hello world'
preferences['providers_acoustid__api_key'] = api_key
client = get_acoustid_client()
assert client.api_key == api_key
def test_client_returns_raw_results(db, mocker, preferences):
api_key = 'test'
preferences['providers_acoustid__api_key'] = api_key
payload = {
'results': [
{'id': 'e475bf79-c1ce-4441-bed7-1e33f226c0a2',
'recordings': [
{'artists': [
{'id': '9c6bddde-6228-4d9f-ad0d-03f6fcb19e13',
'name': 'Binärpilot'}],
'duration': 268,
'id': 'f269d497-1cc0-4ae4-a0c4-157ec7d73fcb',
'title': 'Bend'}],
'score': 0.860825}],
'status': 'ok'
}
m = mocker.patch('acoustid.match', return_value=payload)
client = get_acoustid_client()
response = client.match('/tmp/noopfile.mp3')
assert response == payload
m.assert_called_once_with('test', '/tmp/noopfile.mp3', parse=False)

View File

@ -1,39 +0,0 @@
import os
import datetime
from funkwhale_api.providers.audiofile import tasks
DATA_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
'files'
)
def test_can_import_single_audio_file(db, mocker):
metadata = {
'artist': ['Test artist'],
'album': ['Test album'],
'title': ['Test track'],
'TRACKNUMBER': ['4'],
'date': ['2012-08-15'],
'musicbrainz_albumid': ['a766da8b-8336-47aa-a3ee-371cc41ccc75'],
'musicbrainz_trackid': ['bd21ac48-46d8-4e78-925f-d9cc2a294656'],
'musicbrainz_artistid': ['013c8e5b-d72a-4cd3-8dee-6c64d6125823'],
}
m1 = mocker.patch('mutagen.File', return_value=metadata)
m2 = mocker.patch(
'funkwhale_api.music.metadata.Metadata.get_file_type',
return_value='OggVorbis',
)
track_file = tasks.from_path(os.path.join(DATA_DIR, 'dummy_file.ogg'))
track = track_file.track
assert track.title == metadata['title'][0]
assert track.mbid == metadata['musicbrainz_trackid'][0]
assert track.position == 4
assert track.album.title == metadata['album'][0]
assert track.album.mbid == metadata['musicbrainz_albumid'][0]
assert track.album.release_date == datetime.date(2012, 8, 15)
assert track.artist.name == metadata['artist'][0]
assert track.artist.mbid == metadata['musicbrainz_artistid'][0]

View File

@ -0,0 +1,77 @@
import pytest
import acoustid
import datetime
import os
from django.core.management import call_command
from django.core.management.base import CommandError
from funkwhale_api.providers.audiofile import tasks
DATA_DIR = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
'files'
)
def test_can_create_track_from_file_metadata(db, mocker):
mocker.patch('acoustid.match', side_effect=acoustid.WebServiceError('test'))
metadata = {
'artist': ['Test artist'],
'album': ['Test album'],
'title': ['Test track'],
'TRACKNUMBER': ['4'],
'date': ['2012-08-15'],
'musicbrainz_albumid': ['a766da8b-8336-47aa-a3ee-371cc41ccc75'],
'musicbrainz_trackid': ['bd21ac48-46d8-4e78-925f-d9cc2a294656'],
'musicbrainz_artistid': ['013c8e5b-d72a-4cd3-8dee-6c64d6125823'],
}
m1 = mocker.patch('mutagen.File', return_value=metadata)
m2 = mocker.patch(
'funkwhale_api.music.metadata.Metadata.get_file_type',
return_value='OggVorbis',
)
track = tasks.import_track_data_from_path(
os.path.join(DATA_DIR, 'dummy_file.ogg'))
assert track.title == metadata['title'][0]
assert track.mbid == metadata['musicbrainz_trackid'][0]
assert track.position == 4
assert track.album.title == metadata['album'][0]
assert track.album.mbid == metadata['musicbrainz_albumid'][0]
assert track.album.release_date == datetime.date(2012, 8, 15)
assert track.artist.name == metadata['artist'][0]
assert track.artist.mbid == metadata['musicbrainz_artistid'][0]
def test_management_command_requires_a_valid_username(factories, mocker):
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
user = factories['users.User'](username='me')
mocker.patch('funkwhale_api.providers.audiofile.management.commands.import_files.Command.do_import') # NOQA
with pytest.raises(CommandError):
call_command('import_files', path, username='not_me', interactive=False)
call_command('import_files', path, username='me', interactive=False)
def test_import_files_creates_a_batch_and_job(factories, mocker):
m = mocker.patch('funkwhale_api.music.tasks.import_job_run.delay')
user = factories['users.User'](username='me')
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
call_command(
'import_files',
path,
username='me',
async=True,
interactive=False)
batch = user.imports.latest('id')
assert batch.source == 'shell'
assert batch.jobs.count() == 1
job = batch.jobs.first()
assert job.status == 'pending'
with open(path, 'rb') as f:
assert job.audio_file.read() == f.read()
assert job.source == 'file://' + path
m.assert_called_once_with(import_job_id=job.pk)

33
api/tests/test_tasks.py Normal file
View File

@ -0,0 +1,33 @@
import pytest
from funkwhale_api.taskapp import celery
class Dummy:
@staticmethod
def noop(instance):
pass
def test_require_instance_decorator(factories, mocker):
user = factories['users.User']()
@celery.require_instance(user.__class__, 'user')
def t(user):
Dummy.noop(user)
m = mocker.patch.object(Dummy, 'noop')
t(user_id=user.pk)
m.assert_called_once_with(user)
def test_require_instance_decorator_accepts_qs(factories, mocker):
user = factories['users.User'](is_active=False)
qs = user.__class__.objects.filter(is_active=True)
@celery.require_instance(qs, 'user')
def t(user):
pass
with pytest.raises(user.__class__.DoesNotExist):
t(user_id=user.pk)

View File

@ -47,6 +47,8 @@ server {
rewrite ^(.+)$ /index.html last; rewrite ^(.+)$ /index.html last;
} }
location /api/ { location /api/ {
# this is needed if you have file import via upload enabled
client_max_body_size 30M;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

View File

@ -30,7 +30,7 @@ services:
links: links:
- postgres - postgres
- redis - redis
command: python manage.py celery worker command: celery -A funkwhale_api.taskapp worker -l debug
environment: environment:
- "DJANGO_ALLOWED_HOSTS=localhost" - "DJANGO_ALLOWED_HOSTS=localhost"
- "DJANGO_SETTINGS_MODULE=config.settings.local" - "DJANGO_SETTINGS_MODULE=config.settings.local"

View File

@ -30,7 +30,7 @@ http {
server { server {
listen 6001; listen 6001;
charset utf-8; charset utf-8;
client_max_body_size 20M;
location /_protected/media { location /_protected/media {
internal; internal;
alias /protected/media; alias /protected/media;

View File

@ -23,6 +23,7 @@
"vue-lazyload": "^1.1.4", "vue-lazyload": "^1.1.4",
"vue-resource": "^1.3.4", "vue-resource": "^1.3.4",
"vue-router": "^2.3.1", "vue-router": "^2.3.1",
"vue-upload-component": "^2.7.4",
"vuedraggable": "^2.14.1", "vuedraggable": "^2.14.1",
"vuex": "^3.0.1", "vuex": "^3.0.1",
"vuex-persistedstate": "^2.4.2" "vuex-persistedstate": "^2.4.2"

View File

@ -8,6 +8,7 @@
['ui', ['ui',
{'active': batch.status === 'pending'}, {'active': batch.status === 'pending'},
{'warning': batch.status === 'pending'}, {'warning': batch.status === 'pending'},
{'error': batch.status === 'errored'},
{'success': batch.status === 'finished'}, {'success': batch.status === 'finished'},
'progress']"> 'progress']">
<div class="bar" :style="progressBarStyle"> <div class="bar" :style="progressBarStyle">
@ -37,7 +38,7 @@
</td> </td>
<td> <td>
<span <span
:class="['ui', {'yellow': job.status === 'pending'}, {'green': job.status === 'finished'}, 'label']">{{ job.status }}</span> :class="['ui', {'yellow': job.status === 'pending'}, {'red': job.status === 'errored'}, {'green': job.status === 'finished'}, 'label']">{{ job.status }}</span>
</td> </td>
<td> <td>
<router-link v-if="job.track_file" :to="{name: 'library.tracks.detail', params: {id: job.track_file.track }}">{{ job.track_file.track }}</router-link> <router-link v-if="job.track_file" :to="{name: 'library.tracks.detail', params: {id: job.track_file.track }}">{{ job.track_file.track }}</router-link>
@ -89,7 +90,7 @@ export default {
computed: { computed: {
progress () { progress () {
return this.batch.jobs.filter(j => { return this.batch.jobs.filter(j => {
return j.status === 'finished' return j.status !== 'pending'
}).length * 100 / this.batch.jobs.length }).length * 100 / this.batch.jobs.length
}, },
progressBarStyle () { progressBarStyle () {

View File

@ -32,7 +32,7 @@
<td>{{ result.jobs.length }}</td> <td>{{ result.jobs.length }}</td>
<td> <td>
<span <span
:class="['ui', {'yellow': result.status === 'pending'}, {'green': result.status === 'finished'}, 'label']">{{ result.status }}</span> :class="['ui', {'yellow': result.status === 'pending'}, {'red': result.status === 'errored'}, {'green': result.status === 'finished'}, 'label']">{{ result.status }}</span>
</td> </td>
</tr> </tr>
</tbody> </tbody>

View File

@ -0,0 +1,140 @@
<template>
<div>
<div v-if="batch" class="ui two buttons">
<file-upload-widget
class="ui icon button"
:post-action="uploadUrl"
:multiple="true"
:size="1024 * 1024 * 30"
:data="uploadData"
:drop="true"
extensions="ogg,mp3"
accept="audio/*"
v-model="files"
name="audio_file"
:thread="3"
@input-filter="inputFilter"
@input-file="inputFile"
ref="upload">
<i class="upload icon"></i>
Select files to upload...
</file-upload-widget>
<button class="ui icon teal button" v-if="!$refs.upload || !$refs.upload.active" @click.prevent="$refs.upload.active = true">
<i class="play icon" aria-hidden="true"></i>
Start Upload
</button>
<button type="button" class="ui icon yellow button" v-else @click.prevent="$refs.upload.active = false">
<i class="pause icon" aria-hidden="true"></i>
Stop Upload
</button>
</div>
<div class="ui hidden divider"></div>
<p>
Once all your files are uploaded, simply head over <router-link :to="{name: 'library.import.batches.detail', params: {id: batch.id }}">import detail page</router-link> to check the import status.
</p>
<table class="ui single line table">
<thead>
<tr>
<th>File name</th>
<th>Size</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="(file, index) in files" :key="file.id">
<td>{{ file.name }}</td>
<td>{{ file.size }}</td>
<td>
<span v-if="file.error" class="ui red label">
{{ file.error }}
</span>
<span v-else-if="file.success" class="ui green label">Success</span>
<span v-else-if="file.active" class="ui yellow label">Uploading...</span>
<template v-else>
<span class="ui label">Pending</span>
<button class="ui tiny basic red icon button" @click.prevent="$refs.upload.remove(file)"><i class="delete icon"></i></button>
</template>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script>
import Vue from 'vue'
import logger from '@/logging'
import FileUploadWidget from './FileUploadWidget'
import config from '@/config'
export default {
components: {
FileUploadWidget
},
data () {
return {
files: [],
uploadUrl: config.API_URL + 'import-jobs/',
batch: null
}
},
mounted: function () {
this.createBatch()
},
methods: {
inputFilter (newFile, oldFile, prevent) {
if (newFile && !oldFile) {
let extension = newFile.name.split('.').pop()
if (['ogg', 'mp3'].indexOf(extension) < 0) {
prevent()
}
}
},
inputFile (newFile, oldFile) {
if (newFile && !oldFile) {
// add
console.log('add', newFile)
if (!this.batch) {
this.createBatch()
}
}
if (newFile && oldFile) {
// update
console.log('update', newFile)
}
if (!newFile && oldFile) {
// remove
console.log('remove', oldFile)
}
},
createBatch () {
let self = this
let url = config.API_URL + 'import-batches/'
let resource = Vue.resource(url)
resource.save({}, {}).then((response) => {
self.batch = response.data
}, (response) => {
logger.default.error('error while launching creating batch')
})
}
},
computed: {
batchId: function () {
if (this.batch) {
return this.batch.id
}
return null
},
uploadData: function () {
return {
'batch': this.batchId,
'source': 'file://'
}
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -0,0 +1,34 @@
<script>
import FileUpload from 'vue-upload-component'
export default {
extends: FileUpload,
methods: {
uploadHtml5 (file) {
let form = new window.FormData()
let value
for (let key in file.data) {
value = file.data[key]
if (value && typeof value === 'object' && typeof value.toString !== 'function') {
if (value instanceof File) {
form.append(key, value, value.name)
} else {
form.append(key, JSON.stringify(value))
}
} else if (value !== null && value !== undefined) {
form.append(key, value)
}
}
form.append(this.name, file.file, file.file.filename || file.name)
let xhr = new XMLHttpRequest()
xhr.open('POST', file.postAction)
xhr.setRequestHeader('Authorization', this.$store.getters['auth/header'])
return this.uploadXhr(xhr, file, form)
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -39,8 +39,8 @@
</div> </div>
</div> </div>
<div class="field"> <div class="field">
<div class="ui disabled radio checkbox"> <div class="ui radio checkbox">
<input type="radio" id="upload" value="upload" v-model="currentSource" disabled> <input type="radio" id="upload" value="upload" v-model="currentSource">
<label for="upload">File upload</label> <label for="upload">File upload</label>
</div> </div>
</div> </div>
@ -84,8 +84,14 @@
</div> </div>
</div> </div>
<div v-if="currentStep === 2"> <div v-if="currentStep === 2">
<file-upload
ref="import"
v-if="currentSource == 'upload'"
></file-upload>
<component <component
ref="import" ref="import"
v-if="currentSource == 'external'"
:metadata="metadata" :metadata="metadata"
:is="importComponent" :is="importComponent"
:backends="backends" :backends="backends"
@ -119,6 +125,7 @@ import MetadataSearch from '@/components/metadata/Search'
import ReleaseCard from '@/components/metadata/ReleaseCard' import ReleaseCard from '@/components/metadata/ReleaseCard'
import ArtistCard from '@/components/metadata/ArtistCard' import ArtistCard from '@/components/metadata/ArtistCard'
import ReleaseImport from './ReleaseImport' import ReleaseImport from './ReleaseImport'
import FileUpload from './FileUpload'
import ArtistImport from './ArtistImport' import ArtistImport from './ArtistImport'
import router from '@/router' import router from '@/router'
@ -130,7 +137,8 @@ export default {
ArtistCard, ArtistCard,
ReleaseCard, ReleaseCard,
ArtistImport, ArtistImport,
ReleaseImport ReleaseImport,
FileUpload
}, },
props: { props: {
mbType: {type: String, required: false}, mbType: {type: String, required: false},
@ -142,7 +150,7 @@ export default {
currentType: this.mbType || 'artist', currentType: this.mbType || 'artist',
currentId: this.mbId, currentId: this.mbId,
currentStep: 0, currentStep: 0,
currentSource: this.source || 'external', currentSource: '',
metadata: {}, metadata: {},
isImporting: false, isImporting: false,
importData: { importData: {