Initial commit that merge both the front end and the API in the same repository

This commit is contained in:
Eliot Berriot 2017-06-23 23:00:42 +02:00
commit 76f98b74dd
285 changed files with 51318 additions and 0 deletions

View file

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

View file

View file

@ -0,0 +1,11 @@
from django.conf import settings
from rest_framework.permissions import BasePermission
class ConditionalAuthentication(BasePermission):
def has_permission(self, request, view):
if settings.API_AUTHENTICATION_REQUIRED:
return request.user and request.user.is_authenticated()
return True

View file

@ -0,0 +1,19 @@
import os
import shutil
def rename_file(instance, field_name, new_name, allow_missing_file=False):
field = getattr(instance, field_name)
current_name, extension = os.path.splitext(field.name)
new_name_with_extension = '{}{}'.format(new_name, extension)
try:
shutil.move(field.path, new_name_with_extension)
except FileNotFoundError:
if not allow_missing_file:
raise
print('Skipped missing file', field.path)
initial_path = os.path.dirname(field.name)
field.name = os.path.join(initial_path, new_name_with_extension)
instance.save()
return new_name_with_extension

View file

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View file

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View file

@ -0,0 +1,31 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
import django.contrib.sites.models
class Migration(migrations.Migration):
dependencies = [
]
operations = [
migrations.CreateModel(
name='Site',
fields=[
('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)),
('domain', models.CharField(verbose_name='domain name', max_length=100, validators=[django.contrib.sites.models._simple_domain_name_validator])),
('name', models.CharField(verbose_name='display name', max_length=50)),
],
options={
'verbose_name_plural': 'sites',
'verbose_name': 'site',
'db_table': 'django_site',
'ordering': ('domain',),
},
managers=[
(b'objects', django.contrib.sites.models.SiteManager()),
],
),
]

View file

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.conf import settings
from django.db import migrations
def update_site_forward(apps, schema_editor):
"""Set site domain and name."""
Site = apps.get_model("sites", "Site")
Site.objects.update_or_create(
id=settings.SITE_ID,
defaults={
"domain": "funkwhale.io",
"name": "funkwhale_api"
}
)
def update_site_backward(apps, schema_editor):
"""Revert site domain and name to default."""
Site = apps.get_model("sites", "Site")
Site.objects.update_or_create(
id=settings.SITE_ID,
defaults={
"domain": "example.com",
"name": "example.com"
}
)
class Migration(migrations.Migration):
dependencies = [
('sites', '0001_initial'),
]
operations = [
migrations.RunPython(update_site_forward, update_site_backward),
]

View file

@ -0,0 +1 @@
# -*- coding: utf-8 -*-

View file

@ -0,0 +1,2 @@
from .downloader import download

View file

@ -0,0 +1,27 @@
import os
import requests
import json
from urllib.parse import quote_plus
import youtube_dl
from django.conf import settings
import glob
def download(
url,
target_directory=settings.MEDIA_ROOT,
name="%(id)s.%(ext)s",
bitrate=192):
target_path = os.path.join(target_directory, name)
ydl_opts = {
'quiet': True,
'outtmpl': target_path,
'postprocessors': [{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'vorbis',
}],
}
_downloader = youtube_dl.YoutubeDL(ydl_opts)
info = _downloader.extract_info(url)
info['audio_file_path'] = target_path % {'id': info['id'], 'ext': 'ogg'}
return info

View file

@ -0,0 +1,14 @@
import os
from test_plus.test import TestCase
from .. import downloader
from funkwhale_api.utils.tests import TMPDirTestCaseMixin
class TestDownloader(TMPDirTestCaseMixin, TestCase):
def test_can_download_audio_from_youtube_url_to_vorbis(self):
data = downloader.download('https://www.youtube.com/watch?v=tPEE9ZwTmy0', target_directory=self.download_dir)
self.assertEqual(
data['audio_file_path'],
os.path.join(self.download_dir, 'tPEE9ZwTmy0.ogg'))
self.assertTrue(os.path.exists(data['audio_file_path']))

View file

View file

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.utils.timezone
from django.conf import settings
class Migration(migrations.Migration):
dependencies = [
('music', '0003_auto_20151222_2233'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TrackFavorite',
fields=[
('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('track', models.ForeignKey(related_name='track_favorites', to='music.Track')),
('user', models.ForeignKey(related_name='track_favorites', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('-creation_date',),
},
),
migrations.AlterUniqueTogether(
name='trackfavorite',
unique_together=set([('track', 'user')]),
),
]

View file

@ -0,0 +1,18 @@
from django.db import models
from django.utils import timezone
from funkwhale_api.music.models import Track
class TrackFavorite(models.Model):
creation_date = models.DateTimeField(default=timezone.now)
user = models.ForeignKey('users.User', related_name='track_favorites')
track = models.ForeignKey(Track, related_name='track_favorites')
class Meta:
unique_together = ('track', 'user')
ordering = ('-creation_date',)
@classmethod
def add(cls, track, user):
favorite, created = cls.objects.get_or_create(user=user, track=track)
return favorite

View file

@ -0,0 +1,12 @@
from rest_framework import serializers
from funkwhale_api.music.serializers import TrackSerializerNested
from . import models
class UserTrackFavoriteSerializer(serializers.ModelSerializer):
# track = TrackSerializerNested(read_only=True)
class Meta:
model = models.TrackFavorite
fields = ('id', 'track', 'creation_date')

View file

@ -0,0 +1,113 @@
import json
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from funkwhale_api.music.models import Track, Artist
from funkwhale_api.favorites.models import TrackFavorite
from funkwhale_api.users.models import User
class TestFavorites(TestCase):
def setUp(self):
super().setUp()
self.artist = Artist.objects.create(name='test')
self.track = Track.objects.create(title='test', artist=self.artist)
self.user = User.objects.create_user(username='test', email='test@test.com', password='test')
def test_user_can_add_favorite(self):
TrackFavorite.add(self.track, self.user)
favorite = TrackFavorite.objects.latest('id')
self.assertEqual(favorite.track, self.track)
self.assertEqual(favorite.user, self.user)
def test_user_can_get_his_favorites(self):
favorite = TrackFavorite.add(self.track, self.user)
url = reverse('api:favorites:tracks-list')
self.client.login(username=self.user.username, password='test')
response = self.client.get(url)
expected = [
{
'track': self.track.pk,
'id': favorite.id,
'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'),
}
]
parsed_json = json.loads(response.content.decode('utf-8'))
self.assertEqual(expected, parsed_json['results'])
def test_user_can_add_favorite_via_api(self):
url = reverse('api:favorites:tracks-list')
self.client.login(username=self.user.username, password='test')
response = self.client.post(url, {'track': self.track.pk})
favorite = TrackFavorite.objects.latest('id')
expected = {
'track': self.track.pk,
'id': favorite.id,
'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'),
}
parsed_json = json.loads(response.content.decode('utf-8'))
self.assertEqual(expected, parsed_json)
self.assertEqual(favorite.track, self.track)
self.assertEqual(favorite.user, self.user)
def test_user_can_remove_favorite_via_api(self):
favorite = TrackFavorite.add(self.track, self.user)
url = reverse('api:favorites:tracks-detail', kwargs={'pk': favorite.pk})
self.client.login(username=self.user.username, password='test')
response = self.client.delete(url, {'track': self.track.pk})
self.assertEqual(response.status_code, 204)
self.assertEqual(TrackFavorite.objects.count(), 0)
def test_user_can_remove_favorite_via_api_using_track_id(self):
favorite = TrackFavorite.add(self.track, self.user)
url = reverse('api:favorites:tracks-remove')
self.client.login(username=self.user.username, password='test')
response = self.client.delete(
url, json.dumps({'track': self.track.pk}),
content_type='application/json'
)
self.assertEqual(response.status_code, 204)
self.assertEqual(TrackFavorite.objects.count(), 0)
from funkwhale_api.users.models import User
def test_can_restrict_api_views_to_authenticated_users(self):
urls = [
('api:favorites:tracks-list', 'get'),
]
for route_name, method in urls:
url = self.reverse(route_name)
with self.settings(API_AUTHENTICATION_REQUIRED=True):
response = getattr(self.client, method)(url)
self.assertEqual(response.status_code, 401)
self.client.login(username=self.user.username, password='test')
for route_name, method in urls:
url = self.reverse(route_name)
with self.settings(API_AUTHENTICATION_REQUIRED=False):
response = getattr(self.client, method)(url)
self.assertEqual(response.status_code, 200)
def test_can_filter_tracks_by_favorites(self):
favorite = TrackFavorite.add(self.track, self.user)
url = reverse('api:tracks-list')
self.client.login(username=self.user.username, password='test')
response = self.client.get(url, data={'favorites': True})
parsed_json = json.loads(response.content.decode('utf-8'))
self.assertEqual(parsed_json['count'], 1)
self.assertEqual(parsed_json['results'][0]['id'], self.track.id)

View file

@ -0,0 +1,8 @@
from django.conf.urls import include, url
from . import views
from rest_framework import routers
router = routers.SimpleRouter()
router.register(r'tracks', views.TrackFavoriteViewSet, 'tracks')
urlpatterns = router.urls

View file

@ -0,0 +1,54 @@
from rest_framework import generics, mixins, viewsets
from rest_framework import status
from rest_framework.response import Response
from rest_framework import pagination
from rest_framework.decorators import list_route
from funkwhale_api.music.models import Track
from funkwhale_api.common.permissions import ConditionalAuthentication
from . import models
from . import serializers
class CustomLimitPagination(pagination.PageNumberPagination):
page_size = 100
page_size_query_param = 'page_size'
max_page_size = 100
class TrackFavoriteViewSet(mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet):
serializer_class = serializers.UserTrackFavoriteSerializer
queryset = (models.TrackFavorite.objects.all())
permission_classes = [ConditionalAuthentication]
pagination_class = CustomLimitPagination
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
instance = self.perform_create(serializer)
serializer = self.get_serializer(instance=instance)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def get_queryset(self):
return self.queryset.filter(user=self.request.user)
def perform_create(self, serializer):
track = Track.objects.get(pk=serializer.data['track'])
favorite = models.TrackFavorite.add(track=track, user=self.request.user)
return favorite
@list_route(methods=['delete'])
def remove(self, request, *args, **kwargs):
try:
pk = int(request.data['track'])
favorite = request.user.track_favorites.get(track__pk=pk)
except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist):
return Response({}, status=400)
favorite.delete()
return Response([], status=status.HTTP_204_NO_CONTENT)

View file

View file

@ -0,0 +1,8 @@
from django.contrib import admin
from . import models
@admin.register(models.Listening)
class ListeningAdmin(admin.ModelAdmin):
list_display = ['track', 'end_date', 'user', 'session_key']
search_fields = ['track__name', 'user__username']

View file

@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('music', '0008_auto_20160529_1456'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Listening',
fields=[
('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)),
('end_date', models.DateTimeField(null=True, blank=True, default=django.utils.timezone.now)),
('session_key', models.CharField(null=True, blank=True, max_length=100)),
('track', models.ForeignKey(related_name='listenings', to='music.Track')),
('user', models.ForeignKey(blank=True, null=True, related_name='listenings', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('-end_date',),
},
),
]

View file

@ -0,0 +1,21 @@
from django.utils import timezone
from django.db import models
from django.core.exceptions import ValidationError
from funkwhale_api.music.models import Track
class Listening(models.Model):
end_date = models.DateTimeField(default=timezone.now, null=True, blank=True)
track = models.ForeignKey(Track, related_name="listenings")
user = models.ForeignKey('users.User', related_name="listenings", null=True, blank=True)
session_key = models.CharField(max_length=100, null=True, blank=True)
class Meta:
ordering = ('-end_date',)
def save(self, **kwargs):
if not self.user and not self.session_key:
raise ValidationError('Cannot have both session_key and user empty for listening')
super().save(**kwargs)

View file

@ -0,0 +1,20 @@
from rest_framework import serializers
from funkwhale_api.music.serializers import TrackSerializerNested
from . import models
class ListeningSerializer(serializers.ModelSerializer):
class Meta:
model = models.Listening
fields = ('id', 'user', 'session_key', 'track', 'end_date')
def create(self, validated_data):
if self.context.get('user'):
validated_data['user'] = self.context.get('user')
else:
validated_data['session_key'] = self.context['session_key']
return super().create(validated_data)

View file

@ -0,0 +1,49 @@
import random
import json
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from django.core.exceptions import ValidationError
from django.utils import timezone
from model_mommy import mommy
from funkwhale_api.users.models import User
from funkwhale_api.history import models
class TestHistory(TestCase):
def setUp(self):
super().setUp()
self.user = User.objects.create_user(username='test', email='test@test.com', password='test')
def test_can_create_listening(self):
track = mommy.make('music.Track')
now = timezone.now()
l = models.Listening.objects.create(user=self.user, track=track)
def test_anonymous_user_can_create_listening_via_api(self):
track = mommy.make('music.Track')
url = self.reverse('api:history:listenings-list')
response = self.client.post(url, {
'track': track.pk,
})
listening = models.Listening.objects.latest('id')
self.assertEqual(listening.track, track)
self.assertIsNotNone(listening.session_key)
def test_logged_in_user_can_create_listening_via_api(self):
track = mommy.make('music.Track')
self.client.login(username=self.user.username, password='test')
url = self.reverse('api:history:listenings-list')
response = self.client.post(url, {
'track': track.pk,
})
listening = models.Listening.objects.latest('id')
self.assertEqual(listening.track, track)
self.assertEqual(listening.user, self.user)

View file

@ -0,0 +1,8 @@
from django.conf.urls import include, url
from . import views
from rest_framework import routers
router = routers.SimpleRouter()
router.register(r'listenings', views.ListeningViewSet, 'listenings')
urlpatterns = router.urls

View file

@ -0,0 +1,36 @@
from rest_framework import generics, mixins, viewsets
from rest_framework import status
from rest_framework.response import Response
from rest_framework.decorators import detail_route
from funkwhale_api.music.serializers import TrackSerializerNested
from funkwhale_api.common.permissions import ConditionalAuthentication
from . import models
from . import serializers
class ListeningViewSet(mixins.CreateModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet):
serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all()
permission_classes = [ConditionalAuthentication]
def create(self, request, *args, **kwargs):
return super().create(request, *args, **kwargs)
def get_queryset(self):
queryset = super().get_queryset()
if self.request.user.is_authenticated():
return queryset.filter(user=self.request.user)
else:
return queryset.filter(session_key=self.request.session.session_key)
def get_serializer_context(self):
context = super().get_serializer_context()
if self.request.user.is_authenticated():
context['user'] = self.request.user
else:
context['session_key'] = self.request.session.session_key
return context

View file

View file

@ -0,0 +1,47 @@
from django.contrib import admin
from . import models
@admin.register(models.Artist)
class ArtistAdmin(admin.ModelAdmin):
list_display = ['name', 'mbid', 'creation_date']
search_fields = ['name', 'mbid']
@admin.register(models.Album)
class AlbumAdmin(admin.ModelAdmin):
list_display = ['title', 'artist', 'mbid', 'release_date', 'creation_date']
search_fields = ['title', 'artist__name', 'mbid']
list_select_related = True
@admin.register(models.Track)
class TrackAdmin(admin.ModelAdmin):
list_display = ['title', 'artist', 'album', 'mbid']
search_fields = ['title', 'artist__name', 'album__title', 'mbid']
list_select_related = True
@admin.register(models.ImportBatch)
class ImportBatchAdmin(admin.ModelAdmin):
list_display = ['creation_date', 'status']
@admin.register(models.ImportJob)
class ImportJobAdmin(admin.ModelAdmin):
list_display = ['source', 'batch', 'status', 'mbid']
list_select_related = True
search_fields = ['source', 'batch__pk', 'mbid']
list_filter = ['status']
@admin.register(models.Work)
class WorkAdmin(admin.ModelAdmin):
list_display = ['title', 'mbid', 'language', 'nature']
list_select_related = True
search_fields = ['title']
list_filter = ['language', 'nature']
@admin.register(models.Lyrics)
class LyricsAdmin(admin.ModelAdmin):
list_display = ['url', 'id', 'url']
list_select_related = True
search_fields = ['url', 'work__title']
list_filter = ['work__language']

View file

@ -0,0 +1,42 @@
def load(model, *args, **kwargs):
importer = registry[model.__name__](model=model)
return importer.load(*args, **kwargs)
class Importer(object):
def __init__(self, model):
self.model = model
def load(self, cleaned_data, raw_data, import_hooks):
mbid = cleaned_data.pop('mbid')
m = self.model.objects.update_or_create(mbid=mbid, defaults=cleaned_data)[0]
for hook in import_hooks:
hook(m, cleaned_data, raw_data)
return m
class Mapping(object):
"""Cast musicbrainz data to funkwhale data and vice-versa"""
def __init__(self, musicbrainz_mapping):
self.musicbrainz_mapping = musicbrainz_mapping
self._from_musicbrainz = {}
self._to_musicbrainz = {}
for field_name, conf in self.musicbrainz_mapping.items():
self._from_musicbrainz[conf['musicbrainz_field_name']] = {
'field_name': field_name,
'converter': conf.get('converter', lambda v: v)
}
self._to_musicbrainz[field_name] = {
'field_name': conf['musicbrainz_field_name'],
'converter': conf.get('converter', lambda v: v)
}
def from_musicbrainz(self, key, value):
return self._from_musicbrainz[key]['field_name'], self._from_musicbrainz[key]['converter'](value)
registry = {
'Artist': Importer,
'Track': Importer,
'Album': Importer,
'Work': Importer,
}

View file

@ -0,0 +1,31 @@
import urllib.request
import html.parser
from bs4 import BeautifulSoup
def _get_html(url):
with urllib.request.urlopen(url) as response:
html = response.read()
return html.decode('utf-8')
def extract_content(html):
soup = BeautifulSoup(html, "html.parser")
return soup.find_all("div", class_='lyricbox')[0].contents
def clean_content(contents):
final_content = ""
for e in contents:
if e == '\n':
continue
if e.name == 'script':
continue
if e.name == 'br':
final_content += "\n"
continue
try:
final_content += e.text
except AttributeError:
final_content += str(e)
return final_content

View file

@ -0,0 +1,34 @@
import mutagen
NODEFAULT = object()
class Metadata(object):
ALIASES = {
'release': 'musicbrainz_albumid',
'artist': 'musicbrainz_artistid',
'recording': 'musicbrainz_trackid',
}
def __init__(self, path):
self._file = mutagen.File(path)
def get(self, key, default=NODEFAULT, single=True):
try:
v = self._file[key]
except KeyError:
if default == NODEFAULT:
raise
return default
# Some tags are returned as lists of string
if single:
return v[0]
return v
def __getattr__(self, key):
try:
alias = self.ALIASES[key]
except KeyError:
raise ValueError('Invalid alias {}'.format(key))
return self.get(alias, single=True)

View file

@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Album',
fields=[
('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')),
('mbid', models.UUIDField(editable=False, blank=True, null=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('title', models.CharField(max_length=255)),
('release_date', models.DateField()),
('type', models.CharField(default='album', choices=[('album', 'Album')], max_length=30)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Artist',
fields=[
('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')),
('mbid', models.UUIDField(editable=False, blank=True, null=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('name', models.CharField(max_length=255)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='ImportBatch',
fields=[
('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('submitted_by', models.ForeignKey(related_name='imports', to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='ImportJob',
fields=[
('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')),
('source', models.URLField()),
('mbid', models.UUIDField(editable=False)),
('status', models.CharField(default='pending', choices=[('pending', 'Pending'), ('finished', 'finished')], max_length=30)),
('batch', models.ForeignKey(related_name='jobs', to='music.ImportBatch')),
],
),
migrations.CreateModel(
name='Track',
fields=[
('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')),
('mbid', models.UUIDField(editable=False, blank=True, null=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('title', models.CharField(max_length=255)),
('album', models.ForeignKey(related_name='tracks', blank=True, null=True, to='music.Album')),
('artist', models.ForeignKey(related_name='tracks', to='music.Artist')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='TrackFile',
fields=[
('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')),
('audio_file', models.FileField(upload_to='tracks')),
('source', models.URLField(blank=True, null=True)),
('duration', models.IntegerField(blank=True, null=True)),
('track', models.ForeignKey(related_name='files', to='music.Track')),
],
),
migrations.AddField(
model_name='album',
name='artist',
field=models.ForeignKey(related_name='albums', to='music.Artist'),
),
]

View file

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='album',
options={'ordering': ['-creation_date']},
),
migrations.AlterModelOptions(
name='artist',
options={'ordering': ['-creation_date']},
),
migrations.AlterModelOptions(
name='importbatch',
options={'ordering': ['-creation_date']},
),
migrations.AlterModelOptions(
name='track',
options={'ordering': ['-creation_date']},
),
migrations.AddField(
model_name='album',
name='cover',
field=models.ImageField(upload_to='albums/covers/%Y/%m/%d', null=True, blank=True),
),
migrations.AlterField(
model_name='trackfile',
name='audio_file',
field=models.FileField(upload_to='tracks/%Y/%m/%d'),
),
]

View file

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0002_auto_20151215_1645'),
]
operations = [
migrations.AlterField(
model_name='album',
name='release_date',
field=models.DateField(null=True),
),
]

View file

@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('taggit', '0002_auto_20150616_2121'),
('music', '0003_auto_20151222_2233'),
]
operations = [
migrations.AddField(
model_name='track',
name='tags',
field=taggit.managers.TaggableManager(verbose_name='Tags', help_text='A comma-separated list of tags.', through='taggit.TaggedItem', to='taggit.Tag'),
),
]

View file

@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
def get_duplicates(model):
return [i['mbid'] for i in model.objects.values('mbid').annotate(idcount=models.Count('mbid')).order_by('-idcount') if i['idcount'] > 1]
def deduplicate(apps, schema_editor):
Artist = apps.get_model("music", "Artist")
Album = apps.get_model("music", "Album")
Track = apps.get_model("music", "Track")
for mbid in get_duplicates(Artist):
ref = Artist.objects.filter(mbid=mbid).order_by('pk').first()
duplicates = Artist.objects.filter(mbid=mbid).exclude(pk=ref.pk)
Album.objects.filter(artist__in=duplicates).update(artist=ref)
Track.objects.filter(artist__in=duplicates).update(artist=ref)
duplicates.delete()
for mbid in get_duplicates(Album):
ref = Album.objects.filter(mbid=mbid).order_by('pk').first()
duplicates = Album.objects.filter(mbid=mbid).exclude(pk=ref.pk)
Track.objects.filter(album__in=duplicates).update(album=ref)
duplicates.delete()
def rewind(*args, **kwargs):
pass
class Migration(migrations.Migration):
dependencies = [
('music', '0004_track_tags'),
]
operations = [
migrations.RunPython(deduplicate, rewind),
]

View file

@ -0,0 +1,28 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0005_deduplicate'),
]
operations = [
migrations.AlterField(
model_name='album',
name='mbid',
field=models.UUIDField(null=True, editable=False, unique=True, blank=True, db_index=True),
),
migrations.AlterField(
model_name='artist',
name='mbid',
field=models.UUIDField(null=True, editable=False, unique=True, blank=True, db_index=True),
),
migrations.AlterField(
model_name='track',
name='mbid',
field=models.UUIDField(null=True, editable=False, unique=True, blank=True, db_index=True),
),
]

View file

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0006_unique_mbid'),
]
operations = [
migrations.AddField(
model_name='track',
name='position',
field=models.PositiveIntegerField(blank=True, null=True),
),
]

View file

@ -0,0 +1,29 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0007_track_position'),
]
operations = [
migrations.AlterField(
model_name='album',
name='mbid',
field=models.UUIDField(null=True, db_index=True, unique=True, blank=True),
),
migrations.AlterField(
model_name='artist',
name='mbid',
field=models.UUIDField(null=True, db_index=True, unique=True, blank=True),
),
migrations.AlterField(
model_name='track',
name='mbid',
field=models.UUIDField(null=True, db_index=True, unique=True, blank=True),
),
]

View file

@ -0,0 +1,49 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import django.utils.timezone
import versatileimagefield.fields
class Migration(migrations.Migration):
dependencies = [
('music', '0008_auto_20160529_1456'),
]
operations = [
migrations.CreateModel(
name='Lyrics',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, verbose_name='ID', serialize=False)),
('url', models.URLField()),
('content', models.TextField(null=True, blank=True)),
],
),
migrations.CreateModel(
name='Work',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, verbose_name='ID', serialize=False)),
('mbid', models.UUIDField(unique=True, null=True, db_index=True, blank=True)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('language', models.CharField(max_length=20)),
('nature', models.CharField(max_length=50)),
('title', models.CharField(max_length=255)),
],
options={
'ordering': ['-creation_date'],
'abstract': False,
},
),
migrations.AddField(
model_name='lyrics',
name='work',
field=models.ForeignKey(related_name='lyrics', to='music.Work', blank=True, null=True),
),
migrations.AddField(
model_name='track',
name='work',
field=models.ForeignKey(related_name='tracks', to='music.Work', blank=True, null=True),
),
]

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import versatileimagefield.fields
class Migration(migrations.Migration):
dependencies = [
('music', '0009_auto_20160920_1614'),
]
operations = [
migrations.AlterField(
model_name='lyrics',
name='url',
field=models.URLField(unique=True),
),
]

View file

@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import os
from django.db import migrations, models
from funkwhale_api.common.utils import rename_file
def rename_files(apps, schema_editor):
"""
This migration script is utterly broken and made me redownload all my audio files.
So next time -> Write some actual tests before running a migration script
on thousand of tracks...
"""
return
# TrackFile = apps.get_model("music", "TrackFile")
# qs = TrackFile.objects.select_related(
# 'track__album__artist', 'track__artist')
# total = len(qs)
#
#
# for i, tf in enumerate(qs):
# try:
# new_name = '{} - {} - {}'.format(
# tf.track.artist.name,
# tf.track.album.title,
# tf.track.title,
# )
# except AttributeError:
# new_name = '{} - {}'.format(
# tf.track.artist.name,
# tf.track.title,
# )
# rename_file(
# instance=tf,
# field_name='audio_file',
# allow_missing_file=True,
# new_name=new_name)
# print('Renamed file {}/{} (new name: {})'.format(
# i + 1, total, tf.audio_file.name
# ))
def rewind(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('music', '0010_auto_20160920_1742'),
]
operations = [
migrations.AlterField(
model_name='trackfile',
name='audio_file',
field=models.FileField(upload_to='tracks/%Y/%m/%d', max_length=255),
),
migrations.RunPython(rename_files, rewind),
]

View file

@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
import versatileimagefield.fields
class Migration(migrations.Migration):
dependencies = [
('music', '0011_rename_files'),
]
operations = [
migrations.AlterField(
model_name='album',
name='cover',
field=versatileimagefield.fields.VersatileImageField(null=True, blank=True, upload_to='albums/covers/%Y/%m/%d'),
),
]

View file

@ -0,0 +1,408 @@
import os
import io
import arrow
import datetime
import tempfile
import shutil
import markdown
from django.conf import settings
from django.db import models
from django.contrib.staticfiles.templatetags.staticfiles import static
from django.core.files.base import ContentFile
from django.core.files import File
from django.core.urlresolvers import reverse
from django.utils import timezone
from taggit.managers import TaggableManager
from versatileimagefield.fields import VersatileImageField
from funkwhale_api.taskapp import celery
from funkwhale_api import downloader
from funkwhale_api import musicbrainz
from . import importers
from . import lyrics as lyrics_utils
class APIModelMixin(models.Model):
mbid = models.UUIDField(unique=True, db_index=True, null=True, blank=True)
api_includes = []
creation_date = models.DateTimeField(default=timezone.now)
import_hooks = []
class Meta:
abstract = True
ordering = ['-creation_date']
@classmethod
def get_or_create_from_api(cls, mbid):
try:
return cls.objects.get(mbid=mbid), False
except cls.DoesNotExist:
return cls.create_from_api(id=mbid), True
def get_api_data(self):
return self.__class__.api.get(id=self.mbid, includes=self.api_includes)[self.musicbrainz_model]
@classmethod
def create_from_api(cls, **kwargs):
if kwargs.get('id'):
raw_data = cls.api.get(id=kwargs['id'], includes=cls.api_includes)[cls.musicbrainz_model]
else:
raw_data = cls.api.search(**kwargs)['{0}-list'.format(cls.musicbrainz_model)][0]
cleaned_data = cls.clean_musicbrainz_data(raw_data)
return importers.load(cls, cleaned_data, raw_data, cls.import_hooks)
@classmethod
def clean_musicbrainz_data(cls, data):
cleaned_data = {}
mapping = importers.Mapping(cls.musicbrainz_mapping)
for key, value in data.items():
try:
cleaned_key, cleaned_value = mapping.from_musicbrainz(key, value)
cleaned_data[cleaned_key] = cleaned_value
except KeyError as e:
pass
return cleaned_data
class Artist(APIModelMixin):
name = models.CharField(max_length=255)
musicbrainz_model = 'artist'
musicbrainz_mapping = {
'mbid': {
'musicbrainz_field_name': 'id'
},
'name': {
'musicbrainz_field_name': 'name'
}
}
api = musicbrainz.api.artists
def __str__(self):
return self.name
@property
def tags(self):
t = []
for album in self.albums.all():
for tag in album.tags:
t.append(tag)
return set(t)
def import_artist(v):
a = Artist.get_or_create_from_api(mbid=v[0]['artist']['id'])[0]
return a
def parse_date(v):
if len(v) == 4:
return datetime.date(int(v), 1, 1)
d = arrow.get(v).date()
return d
def import_tracks(instance, cleaned_data, raw_data):
for track_data in raw_data['medium-list'][0]['track-list']:
track_cleaned_data = Track.clean_musicbrainz_data(track_data['recording'])
track_cleaned_data['album'] = instance
track_cleaned_data['position'] = int(track_data['position'])
track = importers.load(Track, track_cleaned_data, track_data, Track.import_hooks)
class Album(APIModelMixin):
title = models.CharField(max_length=255)
artist = models.ForeignKey(Artist, related_name='albums')
release_date = models.DateField(null=True)
cover = VersatileImageField(upload_to='albums/covers/%Y/%m/%d', null=True, blank=True)
TYPE_CHOICES = (
('album', 'Album'),
)
type = models.CharField(choices=TYPE_CHOICES, max_length=30, default='album')
api_includes = ['artist-credits', 'recordings', 'media']
api = musicbrainz.api.releases
musicbrainz_model = 'release'
musicbrainz_mapping = {
'mbid': {
'musicbrainz_field_name': 'id',
},
'position': {
'musicbrainz_field_name': 'release-list',
'converter': lambda v: int(v[0]['medium-list'][0]['position']),
},
'title': {
'musicbrainz_field_name': 'title',
},
'release_date': {
'musicbrainz_field_name': 'date',
'converter': parse_date,
},
'type': {
'musicbrainz_field_name': 'type',
'converter': lambda v: v.lower(),
},
'artist': {
'musicbrainz_field_name': 'artist-credit',
'converter': import_artist,
}
}
def get_image(self):
image_data = musicbrainz.api.images.get_front(str(self.mbid))
f = ContentFile(image_data)
self.cover.save('{0}.jpg'.format(self.mbid), f)
return self.cover.file
def __str__(self):
return self.title
@property
def tags(self):
t = []
for track in self.tracks.all():
for tag in track.tags.all():
t.append(tag)
return set(t)
def import_tags(instance, cleaned_data, raw_data):
MINIMUM_COUNT = 2
tags_to_add = []
for tag_data in raw_data.get('tag-list', []):
try:
if int(tag_data['count']) < MINIMUM_COUNT:
continue
except ValueError:
continue
tags_to_add.append(tag_data['name'])
instance.tags.add(*tags_to_add)
def import_album(v):
a = Album.get_or_create_from_api(mbid=v[0]['id'])[0]
return a
def link_recordings(instance, cleaned_data, raw_data):
tracks = [
r['target']
for r in raw_data['recording-relation-list']
]
Track.objects.filter(mbid__in=tracks).update(work=instance)
def import_lyrics(instance, cleaned_data, raw_data):
try:
url = [
url_data
for url_data in raw_data['url-relation-list']
if url_data['type'] == 'lyrics'
][0]['target']
except (IndexError, KeyError):
return
l, _ = Lyrics.objects.get_or_create(work=instance, url=url)
return l
class Work(APIModelMixin):
language = models.CharField(max_length=20)
nature = models.CharField(max_length=50)
title = models.CharField(max_length=255)
api = musicbrainz.api.works
api_includes = ['url-rels', 'recording-rels']
musicbrainz_model = 'work'
musicbrainz_mapping = {
'mbid': {
'musicbrainz_field_name': 'id'
},
'title': {
'musicbrainz_field_name': 'title'
},
'language': {
'musicbrainz_field_name': 'language',
},
'nature': {
'musicbrainz_field_name': 'type',
'converter': lambda v: v.lower(),
},
}
import_hooks = [
import_lyrics,
link_recordings
]
def fetch_lyrics(self):
l = self.lyrics.first()
if l:
return l
data = self.api.get(self.mbid, includes=['url-rels'])['work']
l = import_lyrics(self, {}, data)
return l
class Lyrics(models.Model):
work = models.ForeignKey(Work, related_name='lyrics', null=True, blank=True)
url = models.URLField(unique=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
def content_rendered(self):
return markdown.markdown(
self.content,
safe_mode=True,
enable_attributes=False,
extensions=['markdown.extensions.nl2br'])
class Track(APIModelMixin):
title = models.CharField(max_length=255)
artist = models.ForeignKey(Artist, related_name='tracks')
position = models.PositiveIntegerField(null=True, blank=True)
album = models.ForeignKey(Album, related_name='tracks', null=True, blank=True)
work = models.ForeignKey(Work, related_name='tracks', null=True, blank=True)
musicbrainz_model = 'recording'
api = musicbrainz.api.recordings
api_includes = ['artist-credits', 'releases', 'media', 'tags', 'work-rels']
musicbrainz_mapping = {
'mbid': {
'musicbrainz_field_name': 'id'
},
'title': {
'musicbrainz_field_name': 'title'
},
'artist': {
'musicbrainz_field_name': 'artist-credit',
'converter': lambda v: Artist.get_or_create_from_api(mbid=v[0]['artist']['id'])[0],
},
'album': {
'musicbrainz_field_name': 'release-list',
'converter': import_album,
},
}
import_hooks = [
import_tags
]
tags = TaggableManager()
def __str__(self):
return self.title
def save(self, **kwargs):
try:
self.artist
except Artist.DoesNotExist:
self.artist = self.album.artist
super().save(**kwargs)
def get_work(self):
if self.work:
return self.work
data = self.api.get(self.mbid, includes=['work-rels'])
try:
work_data = data['recording']['work-relation-list'][0]['work']
except (IndexError, KeyError):
return
work, _ = Work.get_or_create_from_api(mbid=work_data['id'])
return work
def get_lyrics_url(self):
return reverse('api:tracks-lyrics', kwargs={'pk': self.pk})
@property
def full_name(self):
try:
return '{} - {} - {}'.format(
self.artist.name,
self.album.title,
self.title,
)
except AttributeError:
return '{} - {}'.format(
self.artist.name,
self.title,
)
class TrackFile(models.Model):
track = models.ForeignKey(Track, related_name='files')
audio_file = models.FileField(upload_to='tracks/%Y/%m/%d', max_length=255)
source = models.URLField(null=True, blank=True)
duration = models.IntegerField(null=True, blank=True)
def download_file(self):
# import the track file, since there is not any
# we create a tmp dir for the download
tmp_dir = tempfile.mkdtemp()
data = downloader.download(
self.source,
target_directory=tmp_dir)
self.duration = data.get('duration', None)
self.audio_file.save(
os.path.basename(data['audio_file_path']),
File(open(data['audio_file_path'], 'rb'))
)
shutil.rmtree(tmp_dir)
return self.audio_file
@property
def path(self):
if settings.USE_SAMPLE_TRACK:
return static('music/sample1.ogg')
return self.audio_file.url
class ImportBatch(models.Model):
creation_date = models.DateTimeField(default=timezone.now)
submitted_by = models.ForeignKey('users.User', related_name='imports')
class Meta:
ordering = ['-creation_date']
def __str__(self):
return str(self.pk)
@property
def status(self):
pending = any([job.status == 'pending' for job in self.jobs.all()])
if pending:
return 'pending'
return 'finished'
class ImportJob(models.Model):
batch = models.ForeignKey(ImportBatch, related_name='jobs')
source = models.URLField()
mbid = models.UUIDField(editable=False)
STATUS_CHOICES = (
('pending', 'Pending'),
('finished', 'finished'),
)
status = models.CharField(choices=STATUS_CHOICES, default='pending', max_length=30)
@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.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

@ -0,0 +1,96 @@
from rest_framework import serializers
from taggit.models import Tag
from . import models
class TagSerializer(serializers.ModelSerializer):
class Meta:
model = Tag
fields = ('id', 'name', 'slug')
class SimpleArtistSerializer(serializers.ModelSerializer):
class Meta:
model = models.Artist
fields = ('id', 'mbid', 'name')
class ArtistSerializer(serializers.ModelSerializer):
tags = TagSerializer(many=True, read_only=True)
class Meta:
model = models.Artist
fields = ('id', 'mbid', 'name', 'tags')
class ImportJobSerializer(serializers.ModelSerializer):
class Meta:
model = models.ImportJob
fields = ('id', 'mbid', 'source', 'status')
class ImportBatchSerializer(serializers.ModelSerializer):
jobs = ImportJobSerializer(many=True, read_only=True)
class Meta:
model = models.ImportBatch
fields = ('id', 'jobs', 'status', 'creation_date')
class TrackFileSerializer(serializers.ModelSerializer):
class Meta:
model = models.TrackFile
fields = ('id', 'path', 'duration', 'source')
class SimpleAlbumSerializer(serializers.ModelSerializer):
class Meta:
model = models.Album
fields = ('id', 'mbid', 'title', 'release_date', 'cover')
class AlbumSerializer(serializers.ModelSerializer):
tags = TagSerializer(many=True, read_only=True)
class Meta:
model = models.Album
fields = ('id', 'mbid', 'title', 'cover', 'release_date', 'tags')
class LyricsMixin(serializers.ModelSerializer):
lyrics = serializers.SerializerMethodField()
def get_lyrics(self, obj):
return obj.get_lyrics_url()
class TrackSerializer(LyricsMixin):
files = TrackFileSerializer(many=True, read_only=True)
tags = TagSerializer(many=True, read_only=True)
class Meta:
model = models.Track
fields = ('id', 'mbid', 'title', 'artist', 'files', 'tags', 'lyrics')
class TrackSerializerNested(LyricsMixin):
artist = ArtistSerializer()
files = TrackFileSerializer(many=True, read_only=True)
album = SimpleAlbumSerializer(read_only=True)
tags = TagSerializer(many=True, read_only=True)
class Meta:
model = models.Track
fields = ('id', 'mbid', 'title', 'artist', 'files', 'album', 'tags', 'lyrics')
class AlbumSerializerNested(serializers.ModelSerializer):
tracks = TrackSerializer(many=True, read_only=True)
artist = SimpleArtistSerializer()
tags = TagSerializer(many=True, read_only=True)
class Meta:
model = models.Album
fields = ('id', 'mbid', 'title', 'cover', 'artist', 'release_date', 'tracks', 'tags')
class ArtistSerializerNested(serializers.ModelSerializer):
albums = AlbumSerializerNested(many=True, read_only=True)
tags = TagSerializer(many=True, read_only=True)
class Meta:
model = models.Artist
fields = ('id', 'mbid', 'name', 'albums', 'tags')
class LyricsSerializer(serializers.ModelSerializer):
class Meta:
model = models.Lyrics
fields = ('id', 'work', 'content', 'content_rendered')

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,502 @@
artists = {'search': {}, 'get': {}}
artists['search']['adhesive_wombat'] = {
'artist-list': [
{
'type': 'Person',
'ext:score': '100',
'id': '62c3befb-6366-4585-b256-809472333801',
'disambiguation': 'George Shaw',
'gender': 'male',
'area': {'sort-name': 'Raleigh', 'id': '3f8828b9-ba93-4604-9b92-1f616fa1abd1', 'name': 'Raleigh'},
'sort-name': 'Wombat, Adhesive',
'life-span': {'ended': 'false'},
'name': 'Adhesive Wombat'
},
{
'country': 'SE',
'type': 'Group',
'ext:score': '42',
'id': '61b34e69-7573-4208-bc89-7061bca5a8fc',
'area': {'sort-name': 'Sweden', 'id': '23d10872-f5ae-3f0c-bf55-332788a16ecb', 'name': 'Sweden'},
'sort-name': 'Adhesive',
'life-span': {'end': '2002-07-12', 'begin': '1994', 'ended': 'true'},
'name': 'Adhesive',
'begin-area': {
'sort-name': 'Katrineholm',
'id': '02390d96-b5a3-4282-a38f-e64a95d08b7f',
'name': 'Katrineholm'
},
},
]
}
artists['get']['adhesive_wombat'] = {'artist': artists['search']['adhesive_wombat']['artist-list'][0]}
artists['get']['soad'] = {
'artist': {
'country': 'US',
'isni-list': ['0000000121055332'],
'type': 'Group',
'area': {
'iso-3166-1-code-list': ['US'],
'sort-name': 'United States',
'id': '489ce91b-6658-3307-9877-795b68554c98',
'name': 'United States'
},
'begin-area': {
'sort-name': 'Glendale',
'id': '6db2e45d-d7f3-43da-ac0b-7ba5ca627373',
'name': 'Glendale'
},
'id': 'cc0b7089-c08d-4c10-b6b0-873582c17fd6',
'life-span': {'begin': '1994'},
'sort-name': 'System of a Down',
'name': 'System of a Down'
}
}
albums = {'search': {}, 'get': {}, 'get_with_includes': {}}
albums['search']['hypnotize'] = {
'release-list': [
{
"artist-credit": [
{
"artist": {
"alias-list": [
{
"alias": "SoaD",
"sort-name": "SoaD",
"type": "Search hint"
},
{
"alias": "S.O.A.D.",
"sort-name": "S.O.A.D.",
"type": "Search hint"
},
{
"alias": "System Of Down",
"sort-name": "System Of Down",
"type": "Search hint"
}
],
"id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6",
"name": "System of a Down",
"sort-name": "System of a Down"
}
}
],
"artist-credit-phrase": "System of a Down",
"barcode": "",
"country": "US",
"date": "2005",
"ext:score": "100",
"id": "47ae093f-1607-49a3-be11-a15d335ccc94",
"label-info-list": [
{
"catalog-number": "8-2796-93871-2",
"label": {
"id": "f5be9cfe-e1af-405c-a074-caeaed6797c0",
"name": "American Recordings"
}
},
{
"catalog-number": "D162990",
"label": {
"id": "9a7d39a4-a887-40f3-a645-a9a136d1f13f",
"name": "BMG Direct Marketing, Inc."
}
}
],
"medium-count": 1,
"medium-list": [
{
"disc-count": 1,
"disc-list": [],
"format": "CD",
"track-count": 12,
"track-list": []
}
],
"medium-track-count": 12,
"packaging": "Digipak",
"release-event-list": [
{
"area": {
"id": "489ce91b-6658-3307-9877-795b68554c98",
"iso-3166-1-code-list": [
"US"
],
"name": "United States",
"sort-name": "United States"
},
"date": "2005"
}
],
"release-group": {
"id": "72035143-d6ec-308b-8ee5-070b8703902a",
"primary-type": "Album",
"type": "Album"
},
"status": "Official",
"text-representation": {
"language": "eng",
"script": "Latn"
},
"title": "Hypnotize"
},
{
"artist-credit": [
{
"artist": {
"alias-list": [
{
"alias": "SoaD",
"sort-name": "SoaD",
"type": "Search hint"
},
{
"alias": "S.O.A.D.",
"sort-name": "S.O.A.D.",
"type": "Search hint"
},
{
"alias": "System Of Down",
"sort-name": "System Of Down",
"type": "Search hint"
}
],
"id": "cc0b7089-c08d-4c10-b6b0-873582c17fd6",
"name": "System of a Down",
"sort-name": "System of a Down"
}
}
],
"artist-credit-phrase": "System of a Down",
"asin": "B000C6NRY8",
"barcode": "827969387115",
"country": "US",
"date": "2005-12-20",
"ext:score": "100",
"id": "8a4034a9-7834-3b7e-a6f0-d0791e3731fb",
"medium-count": 1,
"medium-list": [
{
"disc-count": 0,
"disc-list": [],
"format": "Vinyl",
"track-count": 12,
"track-list": []
}
],
"medium-track-count": 12,
"release-event-list": [
{
"area": {
"id": "489ce91b-6658-3307-9877-795b68554c98",
"iso-3166-1-code-list": [
"US"
],
"name": "United States",
"sort-name": "United States"
},
"date": "2005-12-20"
}
],
"release-group": {
"id": "72035143-d6ec-308b-8ee5-070b8703902a",
"primary-type": "Album",
"type": "Album"
},
"status": "Official",
"text-representation": {
"language": "eng",
"script": "Latn"
},
"title": "Hypnotize"
},
]
}
albums['get']['hypnotize'] = {'release': albums['search']['hypnotize']['release-list'][0]}
albums['get_with_includes']['hypnotize'] = {
'release': {
'artist-credit': [
{'artist': {'id': 'cc0b7089-c08d-4c10-b6b0-873582c17fd6',
'name': 'System of a Down',
'sort-name': 'System of a Down'}}],
'artist-credit-phrase': 'System of a Down',
'barcode': '',
'country': 'US',
'cover-art-archive': {'artwork': 'true',
'back': 'false',
'count': '1',
'front': 'true'},
'date': '2005',
'id': '47ae093f-1607-49a3-be11-a15d335ccc94',
'medium-count': 1,
'medium-list': [{'format': 'CD',
'position': '1',
'track-count': 12,
'track-list': [{'id': '59f5cf9a-75b2-3aa3-abda-6807a87107b3',
'length': '186000',
'number': '1',
'position': '1',
'recording': {'id': '76d03fc5-758c-48d0-a354-a67de086cc68',
'length': '186000',
'title': 'Attack'},
'track_or_recording_length': '186000'},
{'id': '3aaa28c1-12b1-3c2a-b90a-82e09e355608',
'length': '239000',
'number': '2',
'position': '2',
'recording': {'id': '327543b0-9193-48c5-83c9-01c7b36c8c0a',
'length': '239000',
'title': 'Dreaming'},
'track_or_recording_length': '239000'},
{'id': 'a34fef19-e637-3436-b7eb-276ff2814d6f',
'length': '147000',
'number': '3',
'position': '3',
'recording': {'id': '6e27866c-07a1-425d-bb4f-9d9e728db344',
'length': '147000',
'title': 'Kill Rock n Roll'},
'track_or_recording_length': '147000'},
{'id': '72a4e5c0-c150-3ba1-9ceb-3ab82648af25',
'length': '189000',
'number': '4',
'position': '4',
'recording': {'id': '7ff8a67d-c8e2-4b3a-a045-7ad3561d0605',
'length': '189000',
'title': 'Hypnotize'},
'track_or_recording_length': '189000'},
{'id': 'a748fa6e-b3b7-3b22-89fb-a038ec92ac32',
'length': '178000',
'number': '5',
'position': '5',
'recording': {'id': '19b6eb6a-0e76-4ef7-b63f-959339dbd5d2',
'length': '178000',
'title': 'Stealing Society'},
'track_or_recording_length': '178000'},
{'id': '5c5a8d4e-e21a-317e-a719-6e2dbdefa5d2',
'length': '216000',
'number': '6',
'position': '6',
'recording': {'id': 'c3c2afe1-ee9a-47cb-b3c6-ff8100bc19d5',
'length': '216000',
'title': 'Tentative'},
'track_or_recording_length': '216000'},
{'id': '265718ba-787f-3193-947b-3b6fa69ffe96',
'length': '175000',
'number': '7',
'position': '7',
'recording': {'id': '96f804e1-f600-4faa-95a6-ce597e7db120',
'length': '175000',
'title': 'UFig'},
'title': 'U-Fig',
'track_or_recording_length': '175000'},
{'id': 'cdcf8572-3060-31ca-a72c-1ded81ca1f7a',
'length': '328000',
'number': '8',
'position': '8',
'recording': {'id': '26ba38f0-b26b-48b7-8e77-226b22a55f79',
'length': '328000',
'title': 'Holy Mountains'},
'track_or_recording_length': '328000'},
{'id': 'f9f00cb0-5635-3217-a2a0-bd61917eb0df',
'length': '171000',
'number': '9',
'position': '9',
'recording': {'id': '039f3379-3a69-4e75-a882-df1c4e1608aa',
'length': '171000',
'title': 'Vicinity of Obscenity'},
'track_or_recording_length': '171000'},
{'id': 'cdd45914-6741-353e-bbb5-d281048ff24f',
'length': '164000',
'number': '10',
'position': '10',
'recording': {'id': 'c24d541a-a9a8-4a22-84c6-5e6419459cf8',
'length': '164000',
'title': 'Shes Like Heroin'},
'track_or_recording_length': '164000'},
{'id': 'cfcf12ac-6831-3dd6-a2eb-9d0bfeee3f6d',
'length': '167000',
'number': '11',
'position': '11',
'recording': {'id': '0aff4799-849f-4f83-84f4-22cabbba2378',
'length': '167000',
'title': 'Lonely Day'},
'track_or_recording_length': '167000'},
{'id': '7e38bb38-ff62-3e41-a670-b7d77f578a1f',
'length': '220000',
'number': '12',
'position': '12',
'recording': {'id': 'e1b4d90f-2f44-4fe6-a826-362d4e3d9b88',
'length': '220000',
'title': 'Soldier Side'},
'track_or_recording_length': '220000'}]}],
'packaging': 'Digipak',
'quality': 'normal',
'release-event-count': 1,
'release-event-list': [{'area': {'id': '489ce91b-6658-3307-9877-795b68554c98',
'iso-3166-1-code-list': ['US'],
'name': 'United States',
'sort-name': 'United States'},
'date': '2005'}],
'status': 'Official',
'text-representation': {'language': 'eng', 'script': 'Latn'},
'title': 'Hypnotize'}}
albums['get']['marsupial'] = {
'release': {
"artist-credit": [
{
"artist": {
"disambiguation": "George Shaw",
"id": "62c3befb-6366-4585-b256-809472333801",
"name": "Adhesive Wombat",
"sort-name": "Wombat, Adhesive"
}
}
],
"artist-credit-phrase": "Adhesive Wombat",
"country": "XW",
"cover-art-archive": {
"artwork": "true",
"back": "false",
"count": "1",
"front": "true"
},
"date": "2013-06-05",
"id": "a50d2a81-2a50-484d-9cb4-b9f6833f583e",
"packaging": "None",
"quality": "normal",
"release-event-count": 1,
"release-event-list": [
{
"area": {
"id": "525d4e18-3d00-31b9-a58b-a146a916de8f",
"iso-3166-1-code-list": [
"XW"
],
"name": "[Worldwide]",
"sort-name": "[Worldwide]"
},
"date": "2013-06-05"
}
],
"status": "Official",
"text-representation": {
"language": "eng",
"script": "Latn"
},
"title": "Marsupial Madness"
}
}
tracks = {'search': {}, 'get': {}}
tracks['search']['8bitadventures'] = {
'recording-list': [
{
"artist-credit": [
{
"artist": {
"disambiguation": "George Shaw",
"id": "62c3befb-6366-4585-b256-809472333801",
"name": "Adhesive Wombat",
"sort-name": "Wombat, Adhesive"
}
}
],
"artist-credit-phrase": "Adhesive Wombat",
"ext:score": "100",
"id": "9968a9d6-8d92-4051-8f76-674e157b6eed",
"length": "271000",
"release-list": [
{
"country": "XW",
"date": "2013-06-05",
"id": "a50d2a81-2a50-484d-9cb4-b9f6833f583e",
"medium-list": [
{
"format": "Digital Media",
"position": "1",
"track-count": 11,
"track-list": [
{
"id": "64d43604-c1ee-4f45-a02c-030672d2fe27",
"length": "271000",
"number": "1",
"title": "8-Bit Adventure",
"track_or_recording_length": "271000"
}
]
}
],
"medium-track-count": 11,
"release-event-list": [
{
"area": {
"id": "525d4e18-3d00-31b9-a58b-a146a916de8f",
"iso-3166-1-code-list": [
"XW"
],
"name": "[Worldwide]",
"sort-name": "[Worldwide]"
},
"date": "2013-06-05"
}
],
"release-group": {
"id": "447b4979-2178-405c-bfe6-46bf0b09e6c7",
"primary-type": "Album",
"type": "Album"
},
"status": "Official",
"title": "Marsupial Madness"
}
],
"title": "8-Bit Adventure",
"tag-list": [
{
"count": "2",
"name": "techno"
},
{
"count": "2",
"name": "good-music"
},
],
},
]
}
tracks['get']['8bitadventures'] = {'recording': tracks['search']['8bitadventures']['recording-list'][0]}
tracks['get']['chop_suey'] = {
'recording': {
'id': '46c7368a-013a-47b6-97cc-e55e7ab25213',
'length': '210240',
'title': 'Chop Suey!',
'work-relation-list': [{'target': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
'type': 'performance',
'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0',
'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
'language': 'eng',
'title': 'Chop Suey!'}}]}}
works = {'search': {}, 'get': {}}
works['get']['chop_suey'] = {'work': {'id': 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5',
'language': 'eng',
'recording-relation-list': [{'direction': 'backward',
'recording': {'disambiguation': 'edit',
'id': '07ca77cf-f513-4e9c-b190-d7e24bbad448',
'length': '170893',
'title': 'Chop Suey!'},
'target': '07ca77cf-f513-4e9c-b190-d7e24bbad448',
'type': 'performance',
'type-id': 'a3005666-a872-32c3-ad06-98af558e99b0'},
],
'title': 'Chop Suey!',
'type': 'Song',
'url-relation-list': [{'direction': 'backward',
'target': 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!',
'type': 'lyrics',
'type-id': 'e38e65aa-75e0-42ba-ace0-072aeb91a538'}]}}

File diff suppressed because one or more lines are too long

Binary file not shown.

View file

@ -0,0 +1,216 @@
import json
import unittest
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from funkwhale_api.music import models
from funkwhale_api.utils.tests import TMPDirTestCaseMixin
from funkwhale_api.musicbrainz import api
from funkwhale_api.music import serializers
from funkwhale_api.users.models import User
from . import data as api_data
class TestAPI(TMPDirTestCaseMixin, TestCase):
@unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['adhesive_wombat'])
@unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get']['marsupial'])
@unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get', return_value=api_data.tracks['get']['8bitadventures'])
@unittest.mock.patch('funkwhale_api.music.models.TrackFile.download_file', return_value=None)
def test_can_submit_youtube_url_for_track_import(self, *mocks):
mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
video_id = 'tPEE9ZwTmy0'
url = reverse('api:submit-single')
user = User.objects.create_superuser(username='test', email='test@test.com', password='test')
self.client.login(username=user.username, password='test')
response = self.client.post(url, {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id), 'mbid': mbid})
track = models.Track.objects.get(mbid=mbid)
self.assertEqual(track.artist.name, 'Adhesive Wombat')
self.assertEqual(track.album.title, 'Marsupial Madness')
# self.assertIn(video_id, track.files.first().audio_file.name)
def test_import_creates_an_import_with_correct_data(self):
user = User.objects.create_superuser(username='test', email='test@test.com', password='test')
mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
video_id = 'tPEE9ZwTmy0'
url = reverse('api:submit-single')
self.client.login(username=user.username, password='test')
with self.settings(CELERY_ALWAYS_EAGER=False):
response = self.client.post(url, {'import_url': 'https://www.youtube.com/watch?v={0}'.format(video_id), 'mbid': mbid})
batch = models.ImportBatch.objects.latest('id')
self.assertEqual(batch.jobs.count(), 1)
self.assertEqual(batch.submitted_by, user)
self.assertEqual(batch.status, 'pending')
job = batch.jobs.first()
self.assertEqual(str(job.mbid), mbid)
self.assertEqual(job.status, 'pending')
self.assertEqual(job.source, 'https://www.youtube.com/watch?v={0}'.format(video_id))
@unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['soad'])
@unittest.mock.patch('funkwhale_api.musicbrainz.api.images.get_front', return_value=b'')
@unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get_with_includes']['hypnotize'])
def test_can_import_whole_album(self, *mocks):
user = User.objects.create_superuser(username='test', email='test@test.com', password='test')
payload = {
'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
'tracks': [
{
'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed',
'source': 'https://www.youtube.com/watch?v=1111111111',
},
{
'mbid': '2968a9d6-8d92-4051-8f76-674e157b6eed',
'source': 'https://www.youtube.com/watch?v=2222222222',
},
{
'mbid': '3968a9d6-8d92-4051-8f76-674e157b6eed',
'source': 'https://www.youtube.com/watch?v=3333333333',
},
]
}
url = reverse('api:submit-album')
self.client.login(username=user.username, password='test')
with self.settings(CELERY_ALWAYS_EAGER=False):
response = self.client.post(url, json.dumps(payload), content_type="application/json")
batch = models.ImportBatch.objects.latest('id')
self.assertEqual(batch.jobs.count(), 3)
self.assertEqual(batch.submitted_by, user)
self.assertEqual(batch.status, 'pending')
album = models.Album.objects.latest('id')
self.assertEqual(str(album.mbid), '47ae093f-1607-49a3-be11-a15d335ccc94')
medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
self.assertEqual(int(medium_data['track-count']), album.tracks.all().count())
for track in medium_data['track-list']:
instance = models.Track.objects.get(mbid=track['recording']['id'])
self.assertEqual(instance.title, track['recording']['title'])
self.assertEqual(instance.position, int(track['position']))
self.assertEqual(instance.title, track['recording']['title'])
for row in payload['tracks']:
job = models.ImportJob.objects.get(mbid=row['mbid'])
self.assertEqual(str(job.mbid), row['mbid'])
self.assertEqual(job.status, 'pending')
self.assertEqual(job.source, row['source'])
@unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['soad'])
@unittest.mock.patch('funkwhale_api.musicbrainz.api.images.get_front', return_value=b'')
@unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get_with_includes']['hypnotize'])
def test_can_import_whole_artist(self, *mocks):
user = User.objects.create_superuser(username='test', email='test@test.com', password='test')
payload = {
'artistId': 'mbid',
'albums': [
{
'releaseId': '47ae093f-1607-49a3-be11-a15d335ccc94',
'tracks': [
{
'mbid': '1968a9d6-8d92-4051-8f76-674e157b6eed',
'source': 'https://www.youtube.com/watch?v=1111111111',
},
{
'mbid': '2968a9d6-8d92-4051-8f76-674e157b6eed',
'source': 'https://www.youtube.com/watch?v=2222222222',
},
{
'mbid': '3968a9d6-8d92-4051-8f76-674e157b6eed',
'source': 'https://www.youtube.com/watch?v=3333333333',
},
]
}
]
}
url = reverse('api:submit-artist')
self.client.login(username=user.username, password='test')
with self.settings(CELERY_ALWAYS_EAGER=False):
response = self.client.post(url, json.dumps(payload), content_type="application/json")
batch = models.ImportBatch.objects.latest('id')
self.assertEqual(batch.jobs.count(), 3)
self.assertEqual(batch.submitted_by, user)
self.assertEqual(batch.status, 'pending')
album = models.Album.objects.latest('id')
self.assertEqual(str(album.mbid), '47ae093f-1607-49a3-be11-a15d335ccc94')
medium_data = api_data.albums['get_with_includes']['hypnotize']['release']['medium-list'][0]
self.assertEqual(int(medium_data['track-count']), album.tracks.all().count())
for track in medium_data['track-list']:
instance = models.Track.objects.get(mbid=track['recording']['id'])
self.assertEqual(instance.title, track['recording']['title'])
self.assertEqual(instance.position, int(track['position']))
self.assertEqual(instance.title, track['recording']['title'])
for row in payload['albums'][0]['tracks']:
job = models.ImportJob.objects.get(mbid=row['mbid'])
self.assertEqual(str(job.mbid), row['mbid'])
self.assertEqual(job.status, 'pending')
self.assertEqual(job.source, row['source'])
def test_user_can_query_api_for_his_own_batches(self):
user1 = User.objects.create_superuser(username='test1', email='test1@test.com', password='test')
user2 = User.objects.create_superuser(username='test2', email='test2@test.com', password='test')
mbid = '9968a9d6-8d92-4051-8f76-674e157b6eed'
source = 'https://www.youtube.com/watch?v=tPEE9ZwTmy0'
batch = models.ImportBatch.objects.create(submitted_by=user1)
job = models.ImportJob.objects.create(batch=batch, mbid=mbid, source=source)
url = reverse('api:import-batches-list')
self.client.login(username=user2.username, password='test')
response2 = self.client.get(url)
self.assertJSONEqual(response2.content.decode('utf-8'), '{"count":0,"next":null,"previous":null,"results":[]}')
self.client.logout()
self.client.login(username=user1.username, password='test')
response1 = self.client.get(url)
self.assertIn(mbid, response1.content.decode('utf-8'))
def test_can_search_artist(self):
artist1 = models.Artist.objects.create(name='Test1')
artist2 = models.Artist.objects.create(name='Test2')
query = 'test1'
expected = '[{0}]'.format(json.dumps(serializers.ArtistSerializerNested(artist1).data))
url = self.reverse('api:artists-search')
response = self.client.get(url + '?query={0}'.format(query))
self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8')))
def test_can_search_tracks(self):
artist1 = models.Artist.objects.create(name='Test1')
artist2 = models.Artist.objects.create(name='Test2')
track1 = models.Track.objects.create(artist=artist1, title="test_track1")
track2 = models.Track.objects.create(artist=artist2, title="test_track2")
query = 'test track 1'
expected = '[{0}]'.format(json.dumps(serializers.TrackSerializerNested(track1).data))
url = self.reverse('api:tracks-search')
response = self.client.get(url + '?query={0}'.format(query))
self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8')))
def test_can_restrict_api_views_to_authenticated_users(self):
urls = [
('api:tags-list', 'get'),
('api:tracks-list', 'get'),
('api:artists-list', 'get'),
('api:albums-list', 'get'),
]
for route_name, method in urls:
url = self.reverse(route_name)
with self.settings(API_AUTHENTICATION_REQUIRED=True):
response = getattr(self.client, method)(url)
self.assertEqual(response.status_code, 401)
user = User.objects.create_superuser(username='test', email='test@test.com', password='test')
self.client.login(username=user.username, password='test')
for route_name, method in urls:
url = self.reverse(route_name)
with self.settings(API_AUTHENTICATION_REQUIRED=False):
response = getattr(self.client, method)(url)
self.assertEqual(response.status_code, 200)

View file

@ -0,0 +1,75 @@
import json
import unittest
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from model_mommy import mommy
from funkwhale_api.music import models
from funkwhale_api.musicbrainz import api
from funkwhale_api.music import serializers
from funkwhale_api.users.models import User
from .mocking import lyricswiki
from . import data as api_data
from funkwhale_api.music import lyrics as lyrics_utils
class TestLyrics(TestCase):
@unittest.mock.patch('funkwhale_api.music.lyrics._get_html',
return_value=lyricswiki.content)
def test_works_import_lyrics_if_any(self, *mocks):
lyrics = mommy.make(
models.Lyrics,
url='http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!')
lyrics.fetch_content()
self.assertIn(
'Grab a brush and put on a little makeup',
lyrics.content,
)
def test_clean_content(self):
c = """<div class="lyricbox">Hello<br /><script>alert('hello');</script>Is it me you're looking for?<br /></div>"""
d = lyrics_utils.extract_content(c)
d = lyrics_utils.clean_content(d)
expected = """Hello
Is it me you're looking for?
"""
self.assertEqual(d, expected)
def test_markdown_rendering(self):
content = """Hello
Is it me you're looking for?"""
l = mommy.make(models.Lyrics, content=content)
expected = "<p>Hello<br />Is it me you're looking for?</p>"
self.assertHTMLEqual(expected, l.content_rendered)
@unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
return_value=api_data.works['get']['chop_suey'])
@unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get',
return_value=api_data.tracks['get']['chop_suey'])
@unittest.mock.patch('funkwhale_api.music.lyrics._get_html',
return_value=lyricswiki.content)
def test_works_import_lyrics_if_any(self, *mocks):
track = mommy.make(
models.Track,
work=None,
mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
url = reverse('api:tracks-lyrics', kwargs={'pk': track.pk})
user = User.objects.create_user(
username='test', email='test@test.com', password='test')
self.client.login(username=user.username, password='test')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
track.refresh_from_db()
lyrics = models.Lyrics.objects.latest('id')
work = models.Work.objects.latest('id')
self.assertEqual(track.work, work)
self.assertEqual(lyrics.work, work)

View file

@ -0,0 +1,27 @@
import unittest
import os
from test_plus.test import TestCase
from funkwhale_api.music import metadata
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
class TestMetadata(TestCase):
def test_can_get_metadata_from_file(self, *mocks):
path = os.path.join(DATA_DIR, 'test.ogg')
data = metadata.Metadata(path)
self.assertEqual(
data.get('musicbrainz_albumid'),
'a766da8b-8336-47aa-a3ee-371cc41ccc75')
self.assertEqual(
data.get('musicbrainz_trackid'),
'bd21ac48-46d8-4e78-925f-d9cc2a294656')
self.assertEqual(
data.get('musicbrainz_artistid'),
'013c8e5b-d72a-4cd3-8dee-6c64d6125823')
self.assertEqual(data.release, data.get('musicbrainz_albumid'))
self.assertEqual(data.artist, data.get('musicbrainz_artistid'))
self.assertEqual(data.recording, data.get('musicbrainz_trackid'))

View file

@ -0,0 +1,115 @@
from test_plus.test import TestCase
import unittest.mock
from funkwhale_api.music import models
import datetime
from model_mommy import mommy
from . import data as api_data
from .cover import binary_data
def prettyprint(d):
import json
print(json.dumps(d, sort_keys=True, indent=4))
class TestMusic(TestCase):
@unittest.mock.patch('musicbrainzngs.search_artists', return_value=api_data.artists['search']['adhesive_wombat'])
def test_can_create_artist_from_api(self, *mocks):
artist = models.Artist.create_from_api(query="Adhesive wombat")
data = models.Artist.api.search(query='Adhesive wombat')['artist-list'][0]
self.assertEqual(int(data['ext:score']), 100)
self.assertEqual(data['id'], '62c3befb-6366-4585-b256-809472333801')
self.assertEqual(artist.mbid, data['id'])
self.assertEqual(artist.name, 'Adhesive Wombat')
@unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.search', return_value=api_data.albums['search']['hypnotize'])
@unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['soad'])
def test_can_create_album_from_api(self, *mocks):
album = models.Album.create_from_api(query="Hypnotize", artist='system of a down', type='album')
data = models.Album.api.search(query='Hypnotize', artist='system of a down', type='album')['release-list'][0]
self.assertEqual(album.mbid, data['id'])
self.assertEqual(album.title, 'Hypnotize')
with self.assertRaises(ValueError):
self.assertFalse(album.cover.path is None)
self.assertEqual(album.release_date, datetime.date(2005, 1, 1))
self.assertEqual(album.artist.name, 'System of a Down')
self.assertEqual(album.artist.mbid, data['artist-credit'][0]['artist']['id'])
@unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['adhesive_wombat'])
@unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get']['marsupial'])
@unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.search', return_value=api_data.tracks['search']['8bitadventures'])
def test_can_create_track_from_api(self, *mocks):
track = models.Track.create_from_api(query="8-bit adventure")
data = models.Track.api.search(query='8-bit adventure')['recording-list'][0]
self.assertEqual(int(data['ext:score']), 100)
self.assertEqual(data['id'], '9968a9d6-8d92-4051-8f76-674e157b6eed')
self.assertEqual(track.mbid, data['id'])
self.assertTrue(track.artist.pk is not None)
self.assertEqual(str(track.artist.mbid), '62c3befb-6366-4585-b256-809472333801')
self.assertEqual(track.artist.name, 'Adhesive Wombat')
self.assertEqual(str(track.album.mbid), 'a50d2a81-2a50-484d-9cb4-b9f6833f583e')
self.assertEqual(track.album.title, 'Marsupial Madness')
@unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['adhesive_wombat'])
@unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get']['marsupial'])
@unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get', return_value=api_data.tracks['get']['8bitadventures'])
def test_can_create_track_from_api_with_corresponding_tags(self, *mocks):
track = models.Track.create_from_api(id='9968a9d6-8d92-4051-8f76-674e157b6eed')
expected_tags = ['techno', 'good-music']
track_tags = [tag.slug for tag in track.tags.all()]
for tag in expected_tags:
self.assertIn(tag, track_tags)
@unittest.mock.patch('funkwhale_api.musicbrainz.api.artists.get', return_value=api_data.artists['get']['adhesive_wombat'])
@unittest.mock.patch('funkwhale_api.musicbrainz.api.releases.get', return_value=api_data.albums['get']['marsupial'])
@unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.search', return_value=api_data.tracks['search']['8bitadventures'])
def test_can_get_or_create_track_from_api(self, *mocks):
track = models.Track.create_from_api(query="8-bit adventure")
data = models.Track.api.search(query='8-bit adventure')['recording-list'][0]
self.assertEqual(int(data['ext:score']), 100)
self.assertEqual(data['id'], '9968a9d6-8d92-4051-8f76-674e157b6eed')
self.assertEqual(track.mbid, data['id'])
self.assertTrue(track.artist.pk is not None)
self.assertEqual(str(track.artist.mbid), '62c3befb-6366-4585-b256-809472333801')
self.assertEqual(track.artist.name, 'Adhesive Wombat')
track2, created = models.Track.get_or_create_from_api(mbid=data['id'])
self.assertFalse(created)
self.assertEqual(track, track2)
def test_album_tags_deduced_from_tracks_tags(self):
tag = mommy.make('taggit.Tag')
album = mommy.make('music.Album')
tracks = mommy.make('music.Track', album=album, _quantity=5)
for track in tracks:
track.tags.add(tag)
album = models.Album.objects.prefetch_related('tracks__tags').get(pk=album.pk)
with self.assertNumQueries(0):
self.assertIn(tag, album.tags)
def test_artist_tags_deduced_from_album_tags(self):
tag = mommy.make('taggit.Tag')
artist = mommy.make('music.Artist')
album = mommy.make('music.Album', artist=artist)
tracks = mommy.make('music.Track', album=album, _quantity=5)
for track in tracks:
track.tags.add(tag)
artist = models.Artist.objects.prefetch_related('albums__tracks__tags').get(pk=artist.pk)
with self.assertNumQueries(0):
self.assertIn(tag, artist.tags)
@unittest.mock.patch('funkwhale_api.musicbrainz.api.images.get_front', return_value=binary_data)
def test_can_download_image_file_for_album(self, *mocks):
# client._api.get_image_front('55ea4f82-b42b-423e-a0e5-290ccdf443ed')
album = mommy.make('music.Album', mbid='55ea4f82-b42b-423e-a0e5-290ccdf443ed')
album.get_image()
album.save()
self.assertEqual(album.cover.file.read(), binary_data)

View file

@ -0,0 +1,66 @@
import json
import unittest
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from model_mommy import mommy
from funkwhale_api.music import models
from funkwhale_api.musicbrainz import api
from funkwhale_api.music import serializers
from funkwhale_api.users.models import User
from . import data as api_data
class TestWorks(TestCase):
@unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
return_value=api_data.works['get']['chop_suey'])
def test_can_import_work(self, *mocks):
recording = mommy.make(
models.Track, mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
work = models.Work.create_from_api(id=mbid)
self.assertEqual(work.title, 'Chop Suey!')
self.assertEqual(work.nature, 'song')
self.assertEqual(work.language, 'eng')
self.assertEqual(work.mbid, mbid)
# a imported work should also be linked to corresponding recordings
recording.refresh_from_db()
self.assertEqual(recording.work, work)
@unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
return_value=api_data.works['get']['chop_suey'])
@unittest.mock.patch('funkwhale_api.musicbrainz.api.recordings.get',
return_value=api_data.tracks['get']['chop_suey'])
def test_can_get_work_from_recording(self, *mocks):
recording = mommy.make(
models.Track,
work=None,
mbid='07ca77cf-f513-4e9c-b190-d7e24bbad448')
mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
self.assertEqual(recording.work, None)
work = recording.get_work()
self.assertEqual(work.title, 'Chop Suey!')
self.assertEqual(work.nature, 'song')
self.assertEqual(work.language, 'eng')
self.assertEqual(work.mbid, mbid)
recording.refresh_from_db()
self.assertEqual(recording.work, work)
@unittest.mock.patch('funkwhale_api.musicbrainz.api.works.get',
return_value=api_data.works['get']['chop_suey'])
def test_works_import_lyrics_if_any(self, *mocks):
mbid = 'e2ecabc4-1b9d-30b2-8f30-3596ec423dc5'
work = models.Work.create_from_api(id=mbid)
lyrics = models.Lyrics.objects.latest('id')
self.assertEqual(lyrics.work, work)
self.assertEqual(
lyrics.url, 'http://lyrics.wikia.com/System_Of_A_Down:Chop_Suey!')

View file

@ -0,0 +1,37 @@
import re
from django.db.models import Q
def normalize_query(query_string,
findterms=re.compile(r'"([^"]+)"|(\S+)').findall,
normspace=re.compile(r'\s{2,}').sub):
''' Splits the query string in invidual keywords, getting rid of unecessary spaces
and grouping quoted words together.
Example:
>>> normalize_query(' some random words "with quotes " and spaces')
['some', 'random', 'words', 'with quotes', 'and', 'spaces']
'''
return [normspace(' ', (t[0] or t[1]).strip()) for t in findterms(query_string)]
def get_query(query_string, search_fields):
''' Returns a query, that is a combination of Q objects. That combination
aims to search keywords within a model by testing the given search fields.
'''
query = None # Query to search for every search term
terms = normalize_query(query_string)
for term in terms:
or_query = None # Query to search for a given term in each field
for field_name in search_fields:
q = Q(**{"%s__icontains" % field_name: term})
if or_query is None:
or_query = q
else:
or_query = or_query | q
if query is None:
query = or_query
else:
query = query & or_query
return query

View file

@ -0,0 +1,254 @@
import os
import json
from django.core.urlresolvers import reverse
from django.db import models, transaction
from django.db.models.functions import Length
from rest_framework import viewsets, views
from rest_framework.decorators import detail_route, list_route
from rest_framework.response import Response
from rest_framework import permissions
from musicbrainzngs import ResponseError
from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator
from funkwhale_api.musicbrainz import api
from funkwhale_api.common.permissions import ConditionalAuthentication
from taggit.models import Tag
from . import models
from . import serializers
from . import importers
from . import utils
class SearchMixin(object):
search_fields = []
@list_route(methods=['get'])
def search(self, request, *args, **kwargs):
query = utils.get_query(request.GET['query'], self.search_fields)
queryset = self.get_queryset().filter(query)
serializer = self.serializer_class(queryset, many=True)
return Response(serializer.data)
class TagViewSetMixin(object):
def get_queryset(self):
queryset = super().get_queryset()
tag = self.request.query_params.get('tag')
if tag:
queryset = queryset.filter(tags__pk=tag)
return queryset
class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
queryset = (
models.Artist.objects.all()
.order_by('name')
.prefetch_related(
'albums__tracks__files',
'albums__tracks__tags'))
serializer_class = serializers.ArtistSerializerNested
permission_classes = [ConditionalAuthentication]
search_fields = ['name']
ordering_fields = ('creation_date',)
class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
queryset = (
models.Album.objects.all()
.order_by('-creation_date')
.select_related()
.prefetch_related('tracks__tags',
'tracks__files'))
serializer_class = serializers.AlbumSerializerNested
permission_classes = [ConditionalAuthentication]
search_fields = ['title']
ordering_fields = ('creation_date',)
class ImportBatchViewSet(viewsets.ReadOnlyModelViewSet):
queryset = models.ImportBatch.objects.all().order_by('-creation_date')
serializer_class = serializers.ImportBatchSerializer
def get_queryset(self):
return super().get_queryset().filter(submitted_by=self.request.user)
class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
"""
A simple ViewSet for viewing and editing accounts.
"""
queryset = (models.Track.objects.all()
.select_related()
.select_related('album__artist')
.prefetch_related(
'tags',
'files',
'artist__albums__tracks__tags'))
serializer_class = serializers.TrackSerializerNested
permission_classes = [ConditionalAuthentication]
search_fields = ['title', 'artist__name']
ordering_fields = ('creation_date',)
def get_queryset(self):
queryset = super().get_queryset()
filter_favorites = self.request.GET.get('favorites', None)
user = self.request.user
if user.is_authenticated() and filter_favorites == 'true':
queryset = queryset.filter(track_favorites__user=user)
return queryset
@detail_route(methods=['get'])
@transaction.non_atomic_requests
def lyrics(self, request, *args, **kwargs):
try:
track = models.Track.objects.get(pk=kwargs['pk'])
except models.Track.DoesNotExist:
return Response(status=404)
work = track.work
if not work:
work = track.get_work()
if not work:
return Response({'error': 'unavailable work '}, status=404)
lyrics = work.fetch_lyrics()
try:
if not lyrics.content:
lyrics.fetch_content()
except AttributeError:
return Response({'error': 'unavailable lyrics'}, status=404)
serializer = serializers.LyricsSerializer(lyrics)
return Response(serializer.data)
class TagViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Tag.objects.all()
serializer_class = serializers.TagSerializer
permission_classes = [ConditionalAuthentication]
class Search(views.APIView):
max_results = 3
def get(self, request, *args, **kwargs):
query = request.GET['query']
results = {
'tags': serializers.TagSerializer(self.get_tags(query), many=True).data,
'artists': serializers.ArtistSerializerNested(self.get_artists(query), many=True).data,
'tracks': serializers.TrackSerializerNested(self.get_tracks(query), many=True).data,
'albums': serializers.AlbumSerializerNested(self.get_albums(query), many=True).data,
}
return Response(results, status=200)
def get_tracks(self, query):
search_fields = ['mbid', 'title', 'album__title', 'artist__name']
query_obj = utils.get_query(query, search_fields)
return (
models.Track.objects.all()
.filter(query_obj)
.select_related('album__artist')
.prefetch_related(
'tags',
'artist__albums__tracks__tags',
'files')
)[:self.max_results]
def get_albums(self, query):
search_fields = ['mbid', 'title', 'artist__name']
query_obj = utils.get_query(query, search_fields)
return (
models.Album.objects.all()
.filter(query_obj)
.select_related()
.prefetch_related(
'tracks__tags',
'tracks__files',
)
)[:self.max_results]
def get_artists(self, query):
search_fields = ['mbid', 'name']
query_obj = utils.get_query(query, search_fields)
return (
models.Artist.objects.all()
.filter(query_obj)
.select_related()
.prefetch_related(
'albums__tracks__tags',
'albums__tracks__files',
)
)[:self.max_results]
def get_tags(self, query):
search_fields = ['slug', 'name']
query_obj = utils.get_query(query, search_fields)
# We want the shortest tag first
qs = Tag.objects.all().annotate(slug_length=Length('slug')).order_by('slug_length')
return qs.filter(query_obj)[:self.max_results]
class SubmitViewSet(viewsets.ViewSet):
queryset = models.ImportBatch.objects.none()
permission_classes = (permissions.DjangoModelPermissions, )
@list_route(methods=['post'])
@transaction.non_atomic_requests
def single(self, request, *args, **kwargs):
try:
models.Track.objects.get(mbid=request.POST['mbid'])
return Response({})
except models.Track.DoesNotExist:
pass
batch = models.ImportBatch.objects.create(submitted_by=request.user)
job = models.ImportJob.objects.create(mbid=request.POST['mbid'], batch=batch, source=request.POST['import_url'])
job.run.delay()
serializer = serializers.ImportBatchSerializer(batch)
return Response(serializer.data)
@list_route(methods=['post'])
@transaction.non_atomic_requests
def album(self, request, *args, **kwargs):
data = json.loads(request.body.decode('utf-8'))
import_data, batch = self._import_album(data, request, batch=None)
return Response(import_data)
def _import_album(self, data, request, batch=None):
# we import the whole album here to prevent race conditions that occurs
# when using get_or_create_from_api in tasks
album_data = api.releases.get(id=data['releaseId'], includes=models.Album.api_includes)['release']
cleaned_data = models.Album.clean_musicbrainz_data(album_data)
album = importers.load(models.Album, cleaned_data, album_data, import_hooks=[models.import_tracks])
try:
album.get_image()
except ResponseError:
pass
if not batch:
batch = models.ImportBatch.objects.create(submitted_by=request.user)
for row in data['tracks']:
try:
models.TrackFile.objects.get(track__mbid=row['mbid'])
except models.TrackFile.DoesNotExist:
job = models.ImportJob.objects.create(mbid=row['mbid'], batch=batch, source=row['source'])
job.run.delay()
serializer = serializers.ImportBatchSerializer(batch)
return serializer.data, batch
@list_route(methods=['post'])
@transaction.non_atomic_requests
def artist(self, request, *args, **kwargs):
data = json.loads(request.body.decode('utf-8'))
artist_data = api.artists.get(id=data['artistId'])['artist']
cleaned_data = models.Artist.clean_musicbrainz_data(artist_data)
artist = importers.load(models.Artist, cleaned_data, artist_data, import_hooks=[])
import_data = []
batch = None
for row in data['albums']:
row_data, batch = self._import_album(row, request, batch=batch)
import_data.append(row_data)
return Response(import_data[0])

View file

@ -0,0 +1 @@
from .client import api

View file

@ -0,0 +1,46 @@
import musicbrainzngs
from django.conf import settings
from funkwhale_api import __version__
_api = musicbrainzngs
_api.set_useragent('funkwhale', str(__version__), 'contact@eliotberriot.com')
def clean_artist_search(query, **kwargs):
cleaned_kwargs = {}
if kwargs.get('name'):
cleaned_kwargs['artist'] = kwargs.get('name')
return _api.search_artists(query, **cleaned_kwargs)
class API(object):
_api = _api
class artists(object):
search = clean_artist_search
get = _api.get_artist_by_id
class images(object):
get_front = _api.get_image_front
class recordings(object):
search = _api.search_recordings
get = _api.get_recording_by_id
class works(object):
search = _api.search_works
get = _api.get_work_by_id
class releases(object):
search = _api.search_releases
get = _api.get_release_by_id
browse = _api.browse_releases
# get_image_front = _api.get_image_front
class release_groups(object):
search = _api.search_release_groups
get = _api.get_release_group_by_id
browse = _api.browse_release_groups
# get_image_front = _api.get_image_front
api = API()

View file

@ -0,0 +1,478 @@
artists = {'search': {}, 'get': {}}
artists['search']['lost fingers'] = {
'artist-count': 696,
'artist-list': [
{
'country': 'CA',
'sort-name': 'Lost Fingers, The',
'id': 'ac16bbc0-aded-4477-a3c3-1d81693d58c9',
'type': 'Group',
'life-span': {
'ended': 'false',
'begin': '2008'
},
'area': {
'sort-name': 'Canada',
'id': '71bbafaa-e825-3e15-8ca9-017dcad1748b',
'name': 'Canada'
},
'ext:score': '100',
'name': 'The Lost Fingers'
},
]
}
artists['get']['lost fingers'] = {
"artist": {
"life-span": {
"begin": "2008"
},
"type": "Group",
"id": "ac16bbc0-aded-4477-a3c3-1d81693d58c9",
"release-group-count": 8,
"name": "The Lost Fingers",
"release-group-list": [
{
"title": "Gypsy Kameleon",
"first-release-date": "2010",
"type": "Album",
"id": "03d3f1d4-e2b0-40d3-8314-05f1896e93a0",
"primary-type": "Album"
},
{
"title": "Gitan Kameleon",
"first-release-date": "2011-11-11",
"type": "Album",
"id": "243c0cd2-2492-4f5d-bf37-c7c76bed05b7",
"primary-type": "Album"
},
{
"title": "Pump Up the Jam \u2013 Do Not Cover, Pt. 3",
"first-release-date": "2014-03-17",
"type": "Single",
"id": "4429befd-ff45-48eb-a8f4-cdf7bf007f3f",
"primary-type": "Single"
},
{
"title": "La Marquise",
"first-release-date": "2012-03-27",
"type": "Album",
"id": "4dab4b96-0a6b-4507-a31e-2189e3e7bad1",
"primary-type": "Album"
},
{
"title": "Christmas Caravan",
"first-release-date": "2016-11-11",
"type": "Album",
"id": "ca0a506d-6ba9-47c3-a712-de5ce9ae6b1f",
"primary-type": "Album"
},
{
"title": "Rendez-vous rose",
"first-release-date": "2009-06-16",
"type": "Album",
"id": "d002f1a8-5890-4188-be58-1caadbbd767f",
"primary-type": "Album"
},
{
"title": "Wonders of the World",
"first-release-date": "2014-05-06",
"type": "Album",
"id": "eeb644c2-5000-42fb-b959-e5e9cc2901c5",
"primary-type": "Album"
},
{
"title": "Lost in the 80s",
"first-release-date": "2008-05-06",
"type": "Album",
"id": "f04ed607-11b7-3843-957e-503ecdd485d1",
"primary-type": "Album"
}
],
"area": {
"iso-3166-1-code-list": [
"CA"
],
"name": "Canada",
"id": "71bbafaa-e825-3e15-8ca9-017dcad1748b",
"sort-name": "Canada"
},
"sort-name": "Lost Fingers, The",
"country": "CA"
}
}
release_groups = {'browse': {}}
release_groups['browse']["lost fingers"] = {
"release-group-list": [
{
"first-release-date": "2010",
"type": "Album",
"primary-type": "Album",
"title": "Gypsy Kameleon",
"id": "03d3f1d4-e2b0-40d3-8314-05f1896e93a0"
},
{
"first-release-date": "2011-11-11",
"type": "Album",
"primary-type": "Album",
"title": "Gitan Kameleon",
"id": "243c0cd2-2492-4f5d-bf37-c7c76bed05b7"
},
{
"first-release-date": "2014-03-17",
"type": "Single",
"primary-type": "Single",
"title": "Pump Up the Jam \u2013 Do Not Cover, Pt. 3",
"id": "4429befd-ff45-48eb-a8f4-cdf7bf007f3f"
},
{
"first-release-date": "2012-03-27",
"type": "Album",
"primary-type": "Album",
"title": "La Marquise",
"id": "4dab4b96-0a6b-4507-a31e-2189e3e7bad1"
},
{
"first-release-date": "2016-11-11",
"type": "Album",
"primary-type": "Album",
"title": "Christmas Caravan",
"id": "ca0a506d-6ba9-47c3-a712-de5ce9ae6b1f"
},
{
"first-release-date": "2009-06-16",
"type": "Album",
"primary-type": "Album",
"title": "Rendez-vous rose",
"id": "d002f1a8-5890-4188-be58-1caadbbd767f"
},
{
"first-release-date": "2014-05-06",
"type": "Album",
"primary-type": "Album",
"title": "Wonders of the World",
"id": "eeb644c2-5000-42fb-b959-e5e9cc2901c5"
},
{
"first-release-date": "2008-05-06",
"type": "Album",
"primary-type": "Album",
"title": "Lost in the 80s",
"id": "f04ed607-11b7-3843-957e-503ecdd485d1"
}
],
"release-group-count": 8
}
recordings = {'search': {}, 'get': {}}
recordings['search']['brontide matador'] = {
"recording-count": 1044,
"recording-list": [
{
"ext:score": "100",
"length": "366280",
"release-list": [
{
"date": "2011-05-30",
"medium-track-count": 8,
"release-event-list": [
{
"area": {
"name": "United Kingdom",
"sort-name": "United Kingdom",
"id": "8a754a16-0027-3a29-b6d7-2b40ea0481ed",
"iso-3166-1-code-list": ["GB"]
},
"date": "2011-05-30"
}
],
"country": "GB",
"title": "Sans Souci",
"status": "Official",
"id": "fde538c8-ffef-47c6-9b5a-bd28f4070e5c",
"release-group": {
"type": "Album",
"id": "113ab958-cfb8-4782-99af-639d4d9eae8d",
"primary-type": "Album"
},
"medium-list": [
{
"format": "CD",
"track-list": [
{
"track_or_recording_length": "366280",
"id": "fe506782-a5cb-3d89-9b3e-86287be05768",
"length": "366280",
"title": "Matador", "number": "1"
}
],
"position": "1",
"track-count": 8
}
]
},
]
}
]
}
releases = {'search': {}, 'get': {}, 'browse': {}}
releases['search']['brontide matador'] = {
"release-count": 116, "release-list": [
{
"ext:score": "100",
"date": "2009-04-02",
"release-event-list": [
{
"area": {
"name": "[Worldwide]",
"sort-name": "[Worldwide]",
"id": "525d4e18-3d00-31b9-a58b-a146a916de8f",
"iso-3166-1-code-list": ["XW"]
},
"date": "2009-04-02"
}
],
"label-info-list": [
{
"label": {
"name": "Holy Roar",
"id": "6e940f35-961d-4ac3-bc2a-569fc211c2e3"
}
}
],
"medium-track-count": 3,
"packaging": "None",
"artist-credit": [
{
"artist": {
"name": "Brontide",
"sort-name": "Brontide",
"id": "2179fbd2-3c88-4b94-a778-eb3daf1e81a1"
}
}
],
"artist-credit-phrase": "Brontide",
"country": "XW",
"title": "Brontide EP",
"status": "Official",
"barcode": "",
"id": "59fbd4d1-6121-40e3-9b76-079694fe9702",
"release-group": {
"type": "EP",
"secondary-type-list": ["Demo"],
"id": "b9207129-2d03-4a68-8a53-3c46fe7d2810",
"primary-type": "EP"
},
"medium-list": [
{
"disc-list": [],
"format": "Digital Media",
"disc-count": 0,
"track-count": 3,
"track-list": []
}
],
"medium-count": 1,
"text-representation": {
"script": "Latn",
"language": "eng"
}
},
]
}
releases['browse']['Lost in the 80s'] = {
"release-count": 3,
"release-list": [
{
"quality": "normal",
"status": "Official",
"text-representation": {
"script": "Latn",
"language": "eng"
},
"title": "Lost in the 80s",
"date": "2008-05-06",
"release-event-count": 1,
"id": "34e27fa0-aad4-4cc5-83a3-0f97089154dc",
"barcode": "622406580223",
"medium-count": 1,
"release-event-list": [
{
"area": {
"iso-3166-1-code-list": [
"CA"
],
"id": "71bbafaa-e825-3e15-8ca9-017dcad1748b",
"name": "Canada",
"sort-name": "Canada"
},
"date": "2008-05-06"
}
],
"country": "CA",
"cover-art-archive": {
"back": "false",
"artwork": "false",
"front": "false",
"count": "0"
},
"medium-list": [
{
"position": "1",
"track-count": 12,
"format": "CD",
"track-list": [
{
"id": "1662bdf8-31d6-3f6e-846b-fe88c087b109",
"length": "228000",
"recording": {
"id": "2e0dbf37-65af-4408-8def-7b0b3cb8426b",
"length": "228000",
"title": "Pump Up the Jam"
},
"track_or_recording_length": "228000",
"position": "1",
"number": "1"
},
{
"id": "01a8cf99-2170-3d3f-96ef-5e4ef7a015a4",
"length": "231000",
"recording": {
"id": "57017e2e-625d-4e7b-a445-47cdb0224dd2",
"length": "231000",
"title": "You Give Love a Bad Name"
},
"track_or_recording_length": "231000",
"position": "2",
"number": "2"
},
{
"id": "375a7ce7-5a41-3fbf-9809-96d491401034",
"length": "189000",
"recording": {
"id": "a948672b-b42d-44a5-89b0-7e9ab6a7e11d",
"length": "189000",
"title": "You Shook Me All Night Long"
},
"track_or_recording_length": "189000",
"position": "3",
"number": "3"
},
{
"id": "ed7d823e-76da-31be-82a8-770288e27d32",
"length": "253000",
"recording": {
"id": "6e097e31-f37b-4fae-8ad0-ada57f3091a7",
"length": "253000",
"title": "Incognito"
},
"track_or_recording_length": "253000",
"position": "4",
"number": "4"
},
{
"id": "76ac8c77-6a99-34d9-ae4d-be8f056d50e0",
"length": "221000",
"recording": {
"id": "faa922e6-e834-44ee-8125-79e640a690e3",
"length": "221000",
"title": "Touch Me"
},
"track_or_recording_length": "221000",
"position": "5",
"number": "5"
},
{
"id": "d0a87409-2be6-3ab7-8526-4313e7134be1",
"length": "228000",
"recording": {
"id": "02da8148-60d8-4c79-ab31-8d90d233d711",
"length": "228000",
"title": "Part-Time Lover"
},
"track_or_recording_length": "228000",
"position": "6",
"number": "6"
},
{
"id": "02c5384b-5ca9-38e9-8b7c-c08dce608deb",
"length": "248000",
"recording": {
"id": "40085704-d6ab-44f6-a4d8-b27c9ca25b31",
"length": "248000",
"title": "Fresh"
},
"track_or_recording_length": "248000",
"position": "7",
"number": "7"
},
{
"id": "ab389542-53d5-346a-b168-1d915ecf0ef6",
"length": "257000",
"recording": {
"id": "77edd338-eeaf-4157-9e2a-5cc3bcee8abd",
"length": "257000",
"title": "Billie Jean"
},
"track_or_recording_length": "257000",
"position": "8",
"number": "8"
},
{
"id": "6d9e722b-7408-350e-bb7c-2de1e329ae84",
"length": "293000",
"recording": {
"id": "040aaffa-7206-40ff-9930-469413fe2420",
"length": "293000",
"title": "Careless Whisper"
},
"track_or_recording_length": "293000",
"position": "9",
"number": "9"
},
{
"id": "63b4e67c-7536-3cd0-8c47-0310c1e40866",
"length": "211000",
"recording": {
"id": "054942f0-4c0f-4e92-a606-d590976b1cff",
"length": "211000",
"title": "Tainted Love"
},
"track_or_recording_length": "211000",
"position": "10",
"number": "10"
},
{
"id": "a07f4ca3-dbf0-3337-a247-afcd0509334a",
"length": "245000",
"recording": {
"id": "8023b5ad-649a-4c67-b7a2-e12358606f6e",
"length": "245000",
"title": "Straight Up"
},
"track_or_recording_length": "245000",
"position": "11",
"number": "11"
},
{
"id": "73d47f16-b18d-36ff-b0bb-1fa1fd32ebf7",
"length": "322000",
"recording": {
"id": "95a8c8a1-fcb6-4cbb-a853-be86d816b357",
"length": "322000",
"title": "Black Velvet"
},
"track_or_recording_length": "322000",
"position": "12",
"number": "12"
}
]
}
],
"asin": "B0017M8YTO"
},
]
}

View file

@ -0,0 +1,87 @@
import json
import unittest
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from funkwhale_api.musicbrainz import api
from . import data as api_data
class TestAPI(TestCase):
@unittest.mock.patch(
'funkwhale_api.musicbrainz.api.recordings.search',
return_value=api_data.recordings['search']['brontide matador'])
def test_can_search_recording_in_musicbrainz_api(self, *mocks):
query = 'brontide matador'
url = reverse('api:providers:musicbrainz:search-recordings')
expected = api_data.recordings['search']['brontide matador']
response = self.client.get(url, data={'query': query})
self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
@unittest.mock.patch(
'funkwhale_api.musicbrainz.api.releases.search',
return_value=api_data.releases['search']['brontide matador'])
def test_can_search_release_in_musicbrainz_api(self, *mocks):
query = 'brontide matador'
url = reverse('api:providers:musicbrainz:search-releases')
expected = api_data.releases['search']['brontide matador']
response = self.client.get(url, data={'query': query})
self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
@unittest.mock.patch(
'funkwhale_api.musicbrainz.api.artists.search',
return_value=api_data.artists['search']['lost fingers'])
def test_can_search_artists_in_musicbrainz_api(self, *mocks):
query = 'lost fingers'
url = reverse('api:providers:musicbrainz:search-artists')
expected = api_data.artists['search']['lost fingers']
response = self.client.get(url, data={'query': query})
self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
@unittest.mock.patch(
'funkwhale_api.musicbrainz.api.artists.get',
return_value=api_data.artists['get']['lost fingers'])
def test_can_get_artist_in_musicbrainz_api(self, *mocks):
uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9'
url = reverse('api:providers:musicbrainz:artist-detail', kwargs={
'uuid': uuid,
})
response = self.client.get(url)
expected = api_data.artists['get']['lost fingers']
self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
@unittest.mock.patch(
'funkwhale_api.musicbrainz.api.release_groups.browse',
return_value=api_data.release_groups['browse']['lost fingers'])
def test_can_broswe_release_group_using_musicbrainz_api(self, *mocks):
uuid = 'ac16bbc0-aded-4477-a3c3-1d81693d58c9'
url = reverse(
'api:providers:musicbrainz:release-group-browse',
kwargs={
'artist_uuid': uuid,
}
)
response = self.client.get(url)
expected = api_data.release_groups['browse']['lost fingers']
self.assertEqual(expected, json.loads(response.content.decode('utf-8')))
@unittest.mock.patch(
'funkwhale_api.musicbrainz.api.releases.browse',
return_value=api_data.releases['browse']['Lost in the 80s'])
def test_can_broswe_releases_using_musicbrainz_api(self, *mocks):
uuid = 'f04ed607-11b7-3843-957e-503ecdd485d1'
url = reverse(
'api:providers:musicbrainz:release-browse',
kwargs={
'release_group_uuid': uuid,
}
)
response = self.client.get(url)
expected = api_data.releases['browse']['Lost in the 80s']
self.assertEqual(expected, json.loads(response.content.decode('utf-8')))

View file

@ -0,0 +1,24 @@
from django.conf.urls import include, url
from rest_framework import routers
from . import views
router = routers.SimpleRouter()
router.register(r'search', views.SearchViewSet, 'search')
urlpatterns = [
url('releases/(?P<uuid>[0-9a-z-]+)/$',
views.ReleaseDetail.as_view(),
name='release-detail'),
url('artists/(?P<uuid>[0-9a-z-]+)/$',
views.ArtistDetail.as_view(),
name='artist-detail'),
url('release-groups/browse/(?P<artist_uuid>[0-9a-z-]+)/$',
views.ReleaseGroupBrowse.as_view(),
name='release-group-browse'),
url('releases/browse/(?P<release_group_uuid>[0-9a-z-]+)/$',
views.ReleaseBrowse.as_view(),
name='release-browse'),
# url('release-groups/(?P<uuid>[0-9a-z-]+)/$',
# views.ReleaseGroupDetail.as_view(),
# name='release-group-detail'),
] + router.urls

View file

@ -0,0 +1,70 @@
from rest_framework import viewsets
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.decorators import list_route
from funkwhale_api.common.permissions import ConditionalAuthentication
from .client import api
class ReleaseDetail(APIView):
permission_classes = [ConditionalAuthentication]
def get(self, request, *args, **kwargs):
result = api.releases.get(
id=kwargs['uuid'], includes=['artists', 'recordings'])
return Response(result)
class ArtistDetail(APIView):
permission_classes = [ConditionalAuthentication]
def get(self, request, *args, **kwargs):
result = api.artists.get(
id=kwargs['uuid'],
includes=['release-groups'])
# import json; print(json.dumps(result, indent=4))
return Response(result)
class ReleaseGroupBrowse(APIView):
permission_classes = [ConditionalAuthentication]
def get(self, request, *args, **kwargs):
result = api.release_groups.browse(
artist=kwargs['artist_uuid'])
return Response(result)
class ReleaseBrowse(APIView):
permission_classes = [ConditionalAuthentication]
def get(self, request, *args, **kwargs):
result = api.releases.browse(
release_group=kwargs['release_group_uuid'],
includes=['recordings'])
return Response(result)
class SearchViewSet(viewsets.ViewSet):
permission_classes = [ConditionalAuthentication]
@list_route(methods=['get'])
def recordings(self, request, *args, **kwargs):
query = request.GET['query']
results = api.recordings.search(query, artist=query)
return Response(results)
@list_route(methods=['get'])
def releases(self, request, *args, **kwargs):
query = request.GET['query']
results = api.releases.search(query, artist=query)
return Response(results)
@list_route(methods=['get'])
def artists(self, request, *args, **kwargs):
query = request.GET['query']
results = api.artists.search(query)
return Response(results)

View file

View file

@ -0,0 +1,17 @@
from django.contrib import admin
from . import models
@admin.register(models.Playlist)
class PlaylistAdmin(admin.ModelAdmin):
list_display = ['name', 'user', 'is_public', 'creation_date']
search_fields = ['name', ]
list_select_related = True
@admin.register(models.PlaylistTrack)
class PlaylistTrackAdmin(admin.ModelAdmin):
list_display = ['playlist', 'track', 'position', ]
search_fields = ['track__name', 'playlist__name']
list_select_related = True

View file

@ -0,0 +1,44 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
import django.utils.timezone
import mptt.fields
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('music', '0012_auto_20161122_1905'),
]
operations = [
migrations.CreateModel(
name='Playlist',
fields=[
('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)),
('name', models.CharField(max_length=50)),
('is_public', models.BooleanField(default=False)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('user', models.ForeignKey(to=settings.AUTH_USER_MODEL, related_name='playlists')),
],
),
migrations.CreateModel(
name='PlaylistTrack',
fields=[
('id', models.AutoField(auto_created=True, verbose_name='ID', primary_key=True, serialize=False)),
('lft', models.PositiveIntegerField(db_index=True, editable=False)),
('rght', models.PositiveIntegerField(db_index=True, editable=False)),
('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
('position', models.PositiveIntegerField(db_index=True, editable=False)),
('playlist', models.ForeignKey(to='playlists.Playlist', related_name='playlist_tracks')),
('previous', mptt.fields.TreeOneToOneField(null=True, to='playlists.PlaylistTrack', related_name='next', blank=True)),
('track', models.ForeignKey(to='music.Track', related_name='playlist_tracks')),
],
options={
'ordering': ('-playlist', 'position'),
},
),
]

View file

@ -0,0 +1,33 @@
from django.db import models
from django.utils import timezone
from mptt.models import MPTTModel, TreeOneToOneField
class Playlist(models.Model):
name = models.CharField(max_length=50)
is_public = models.BooleanField(default=False)
user = models.ForeignKey('users.User', related_name="playlists")
creation_date = models.DateTimeField(default=timezone.now)
def __str__(self):
return self.name
def add_track(self, track, previous=None):
plt = PlaylistTrack(previous=previous, track=track, playlist=self)
plt.save()
return plt
class PlaylistTrack(MPTTModel):
track = models.ForeignKey('music.Track', related_name='playlist_tracks')
previous = TreeOneToOneField('self', blank=True, null=True, related_name='next')
playlist = models.ForeignKey(Playlist, related_name='playlist_tracks')
class MPTTMeta:
level_attr = 'position'
parent_attr = 'previous'
class Meta:
ordering = ('-playlist', 'position')

View file

@ -0,0 +1,30 @@
from rest_framework import serializers
from taggit.models import Tag
from funkwhale_api.music.serializers import TrackSerializerNested
from . import models
class PlaylistTrackSerializer(serializers.ModelSerializer):
track = TrackSerializerNested()
class Meta:
model = models.PlaylistTrack
fields = ('id', 'track', 'playlist', 'position')
class PlaylistTrackCreateSerializer(serializers.ModelSerializer):
class Meta:
model = models.PlaylistTrack
fields = ('id', 'track', 'playlist', 'position')
class PlaylistSerializer(serializers.ModelSerializer):
playlist_tracks = PlaylistTrackSerializer(many=True, read_only=True)
class Meta:
model = models.Playlist
fields = ('id', 'name', 'is_public', 'creation_date', 'playlist_tracks')
read_only_fields = ['id', 'playlist_tracks', 'creation_date']

View file

@ -0,0 +1,65 @@
import json
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from django.core.exceptions import ValidationError
from django.utils import timezone
from model_mommy import mommy
from funkwhale_api.users.models import User
from funkwhale_api.playlists import models
from funkwhale_api.playlists.serializers import PlaylistSerializer
class TestPlayLists(TestCase):
def setUp(self):
super().setUp()
self.user = User.objects.create_user(username='test', email='test@test.com', password='test')
def test_can_create_playlist(self):
tracks = list(mommy.make('music.Track', _quantity=5))
playlist = models.Playlist.objects.create(user=self.user, name="test")
previous = None
for i in range(len(tracks)):
previous = playlist.add_track(tracks[i], previous=previous)
playlist_tracks = list(playlist.playlist_tracks.all())
previous = None
for idx, track in enumerate(tracks):
plt = playlist_tracks[idx]
self.assertEqual(plt.position, idx)
self.assertEqual(plt.track, track)
if previous:
self.assertEqual(playlist_tracks[idx + 1], previous)
self.assertEqual(plt.playlist, playlist)
def test_can_create_playlist_via_api(self):
self.client.login(username=self.user.username, password='test')
url = reverse('api:playlists-list')
data = {
'name': 'test',
}
response = self.client.post(url, data)
playlist = self.user.playlists.latest('id')
self.assertEqual(playlist.name, 'test')
def test_can_add_playlist_track_via_api(self):
tracks = list(mommy.make('music.Track', _quantity=5))
playlist = models.Playlist.objects.create(user=self.user, name="test")
self.client.login(username=self.user.username, password='test')
url = reverse('api:playlist-tracks-list')
data = {
'playlist': playlist.pk,
'track': tracks[0].pk
}
response = self.client.post(url, data)
plts = self.user.playlists.latest('id').playlist_tracks.all()
self.assertEqual(plts.first().track, tracks[0])

View file

@ -0,0 +1,58 @@
from rest_framework import generics, mixins, viewsets
from rest_framework import status
from rest_framework.response import Response
from funkwhale_api.music.models import Track
from funkwhale_api.common.permissions import ConditionalAuthentication
from . import models
from . import serializers
class PlaylistViewSet(
mixins.RetrieveModelMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet):
serializer_class = serializers.PlaylistSerializer
queryset = (models.Playlist.objects.all())
permission_classes = [ConditionalAuthentication]
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
instance = self.perform_create(serializer)
serializer = self.get_serializer(instance=instance)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def get_queryset(self):
return self.queryset.filter(user=self.request.user)
def perform_create(self, serializer):
return serializer.save(user=self.request.user)
class PlaylistTrackViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet):
serializer_class = serializers.PlaylistTrackSerializer
queryset = (models.PlaylistTrack.objects.all())
permission_classes = [ConditionalAuthentication]
def create(self, request, *args, **kwargs):
serializer = serializers.PlaylistTrackCreateSerializer(
data=request.data)
serializer.is_valid(raise_exception=True)
instance = self.perform_create(serializer)
serializer = self.get_serializer(instance=instance)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def get_queryset(self):
return self.queryset.filter(playlist__user=self.request.user)

View file

View file

@ -0,0 +1,4 @@
"""
This module is responsible from importing existing audiofiles from the
filesystem into funkwhale.
"""

View file

@ -0,0 +1,60 @@
import os
import datetime
from django.core.files import File
from funkwhale_api.taskapp import celery
from funkwhale_api.music import models, metadata
@celery.app.task(name='audiofile.from_path')
def from_path(path):
data = metadata.Metadata(path)
artist = models.Artist.objects.get_or_create(
name__iexact=data.get('artist'),
defaults={'name': data.get('artist')},
)[0]
release_date = None
try:
year, month, day = data.get('date', None).split('-')
release_date = datetime.date(
int(year), int(month), int(day)
)
except (ValueError, TypeError):
pass
album = models.Album.objects.get_or_create(
title__iexact=data.get('album'),
artist=artist,
defaults={
'title': data.get('album'),
'release_date': release_date,
},
)[0]
position = None
try:
position = int(data.get('tracknumber', None))
except ValueError:
pass
track = models.Track.objects.get_or_create(
title__iexact=data.get('title'),
album=album,
defaults={
'title': data.get('title'),
'position': position,
},
)[0]
if track.files.count() > 0:
raise ValueError('File already exists for track {}'.format(track.pk))
track_file = models.TrackFile(track=track)
track_file.audio_file.save(
os.path.basename(path),
File(open(path, 'rb'))
)
track_file.save()
return track_file

View file

@ -0,0 +1,61 @@
import glob
from django.core.management.base import BaseCommand, CommandError
from funkwhale_api.providers.audiofile import importer
class Command(BaseCommand):
help = 'Import audio files mathinc given glob pattern'
def add_arguments(self, parser):
parser.add_argument('path', type=str)
parser.add_argument(
'--recursive',
action='store_true',
dest='recursive',
default=False,
help='Will match the pattern recursively (including subdirectories)',
)
parser.add_argument(
'--async',
action='store_true',
dest='async',
default=False,
help='Will launch celery tasks for each file to import instead of doing it synchronously and block the CLI',
)
parser.add_argument(
'--noinput', '--no-input', action='store_false', dest='interactive',
help="Do NOT prompt the user for input of any kind.",
)
def handle(self, *args, **options):
# self.stdout.write(self.style.SUCCESS('Successfully closed poll "%s"' % poll_id))
matching = glob.glob(options['path'], recursive=options['recursive'])
self.stdout.write('This will import {} files matching this pattern: {}'.format(
len(matching), options['path']))
if not matching:
raise CommandError('No file matching pattern, aborting')
if options['interactive']:
message = (
'Are you sure you want to do this?\n\n'
"Type 'yes' to continue, or 'no' to cancel: "
)
if input(''.join(message)) != 'yes':
raise CommandError("Import cancelled.")
message = 'Importing {}...'
if options['async']:
message = 'Launching import for {}...'
for path in matching:
self.stdout.write(message.format(path))
try:
importer.from_path(path)
except Exception as e:
self.stdout.write('Error: {}'.format(e))
message = 'Successfully imported {} tracks'
if options['async']:
message = 'Successfully launched import for {} tracks'
self.stdout.write(message.format(len(matching)))

View file

@ -0,0 +1,34 @@
import os
import datetime
import unittest
from test_plus.test import TestCase
from funkwhale_api.providers.audiofile import importer
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
class TestAudioFile(TestCase):
def test_can_import_single_audio_file(self, *mocks):
metadata = {
'artist': ['Test artist'],
'album': ['Test album'],
'title': ['Test track'],
'tracknumber': ['4'],
'date': ['2012-08-15']
}
with unittest.mock.patch('mutagen.File', return_value=metadata):
track_file = importer.from_path(
os.path.join(DATA_DIR, 'dummy_file.ogg'))
self.assertEqual(
track_file.track.title, metadata['title'][0])
self.assertEqual(
track_file.track.position, 4)
self.assertEqual(
track_file.track.album.title, metadata['album'][0])
self.assertEqual(
track_file.track.album.release_date, datetime.date(2012, 8, 15))
self.assertEqual(
track_file.track.artist.name, metadata['artist'][0])

View file

@ -0,0 +1,8 @@
from django.conf.urls import include, url
from funkwhale_api.music import views
urlpatterns = [
url(r'^youtube/', include('funkwhale_api.providers.youtube.urls', namespace='youtube')),
url(r'^musicbrainz/', include('funkwhale_api.musicbrainz.urls', namespace='musicbrainz')),
]

View file

@ -0,0 +1,58 @@
import threading
from apiclient.discovery import build
from apiclient.errors import HttpError
from oauth2client.tools import argparser
from django.conf import settings
# Set DEVELOPER_KEY to the API key value from the APIs & auth > Registered apps
# tab of
# https://cloud.google.com/console
# Please ensure that you have enabled the YouTube Data API for your project.
DEVELOPER_KEY = settings.FUNKWHALE_PROVIDERS['youtube']['api_key']
YOUTUBE_API_SERVICE_NAME = "youtube"
YOUTUBE_API_VERSION = "v3"
VIDEO_BASE_URL = 'https://www.youtube.com/watch?v={0}'
def _do_search(query):
youtube = build(YOUTUBE_API_SERVICE_NAME, YOUTUBE_API_VERSION,
developerKey=DEVELOPER_KEY)
return youtube.search().list(
q=query,
part="id,snippet",
maxResults=25
).execute()
class Client(object):
def search(self, query):
search_response = _do_search(query)
videos = []
for search_result in search_response.get("items", []):
if search_result["id"]["kind"] == "youtube#video":
search_result['full_url'] = VIDEO_BASE_URL.format(search_result["id"]['videoId'])
videos.append(search_result)
return videos
def search_multiple(self, queries):
results = {}
def search(key, query):
results[key] = self.search(query)
threads = [
threading.Thread(target=search, args=(key, query,))
for key, query in queries.items()
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
return results
client = Client()

View file

@ -0,0 +1,162 @@
search = {}
search['8 bit adventure'] = {
"pageInfo": {
"totalResults": 1000000,
"resultsPerPage": 25
},
"nextPageToken": "CBkQAA",
"etag": "\"gMxXHe-zinKdE9lTnzKu8vjcmDI/1L34zetsKWv-raAFiz0MuT0SsfQ\"",
"items": [
{
"id": {
"videoId": "0HxZn6CzOIo",
"kind": "youtube#video"
},
"etag": "\"gMxXHe-zinKdE9lTnzKu8vjcmDI/GxK-wHBWUYfrJsd1dijBPTufrVE\"",
"snippet": {
"liveBroadcastContent": "none",
"description": "Make sure to apply adhesive evenly before use. GET IT HERE: http://adhesivewombat.bandcamp.com/album/marsupial-madness Facebook: ...",
"channelId": "UCps63j3krzAG4OyXeEyuhFw",
"title": "AdhesiveWombat - 8 Bit Adventure",
"channelTitle": "AdhesiveWombat",
"publishedAt": "2012-08-22T18:41:03.000Z",
"thumbnails": {
"medium": {
"url": "https://i.ytimg.com/vi/0HxZn6CzOIo/mqdefault.jpg",
"height": 180,
"width": 320
},
"high": {
"url": "https://i.ytimg.com/vi/0HxZn6CzOIo/hqdefault.jpg",
"height": 360,
"width": 480
},
"default": {
"url": "https://i.ytimg.com/vi/0HxZn6CzOIo/default.jpg",
"height": 90,
"width": 120
}
}
},
"kind": "youtube#searchResult"
},
{
"id": {
"videoId": "n4A_F5SXmgo",
"kind": "youtube#video"
},
"etag": "\"gMxXHe-zinKdE9lTnzKu8vjcmDI/aRVESw24jlgiErDgJKxNrazKRDc\"",
"snippet": {
"liveBroadcastContent": "none",
"description": "Free Download: http://bit.ly/1fZ1pMJ I don't post 8 bit'ish music much but damn I must admit this is goood! Enjoy \u2665 \u25bbSpikedGrin: ...",
"channelId": "UCMOgdURr7d8pOVlc-alkfRg",
"title": "\u3010Electro\u3011AdhesiveWombat - 8 Bit Adventure (SpikedGrin Remix) [Free Download]",
"channelTitle": "xKito Music",
"publishedAt": "2013-10-27T13:16:48.000Z",
"thumbnails": {
"medium": {
"url": "https://i.ytimg.com/vi/n4A_F5SXmgo/mqdefault.jpg",
"height": 180,
"width": 320
},
"high": {
"url": "https://i.ytimg.com/vi/n4A_F5SXmgo/hqdefault.jpg",
"height": 360,
"width": 480
},
"default": {
"url": "https://i.ytimg.com/vi/n4A_F5SXmgo/default.jpg",
"height": 90,
"width": 120
}
}
},
"kind": "youtube#searchResult"
},
],
"regionCode": "FR",
"kind": "youtube#searchListResponse"
}
search['system of a down toxicity'] = {
"items": [
{
"id": {
"kind": "youtube#video",
"videoId": "BorYwGi2SJc"
},
"kind": "youtube#searchResult",
"snippet": {
"title": "System of a Down: Toxicity",
"channelTitle": "Vedres Csaba",
"channelId": "UCBXeuQORNwPv4m68fgGMtPQ",
"thumbnails": {
"default": {
"height": 90,
"width": 120,
"url": "https://i.ytimg.com/vi/BorYwGi2SJc/default.jpg"
},
"high": {
"height": 360,
"width": 480,
"url": "https://i.ytimg.com/vi/BorYwGi2SJc/hqdefault.jpg"
},
"medium": {
"height": 180,
"width": 320,
"url": "https://i.ytimg.com/vi/BorYwGi2SJc/mqdefault.jpg"
}
},
"publishedAt": "2007-12-17T12:39:54.000Z",
"description": "http://www.vedrescsaba.uw.hu The System of a Down song Toxicity arranged for a classical piano quintet, played by Vedres Csaba and the Kairosz quartet.",
"liveBroadcastContent": "none"
},
"etag": "\"gMxXHe-zinKdE9lTnzKu8vjcmDI/UwR8H6P6kbijNZmBNkYd2jAzDnI\""
},
{
"id": {
"kind": "youtube#video",
"videoId": "ENBv2i88g6Y"
},
"kind": "youtube#searchResult",
"snippet": {
"title": "System Of A Down - Question!",
"channelTitle": "systemofadownVEVO",
"channelId": "UCvtZDkeFxMkRTNqfqXtxxkw",
"thumbnails": {
"default": {
"height": 90,
"width": 120,
"url": "https://i.ytimg.com/vi/ENBv2i88g6Y/default.jpg"
},
"high": {
"height": 360,
"width": 480,
"url": "https://i.ytimg.com/vi/ENBv2i88g6Y/hqdefault.jpg"
},
"medium": {
"height": 180,
"width": 320,
"url": "https://i.ytimg.com/vi/ENBv2i88g6Y/mqdefault.jpg"
}
},
"publishedAt": "2009-10-03T04:49:03.000Z",
"description": "System of a Down's official music video for 'Question!'. Click to listen to System of a Down on Spotify: http://smarturl.it/SystemSpotify?IQid=SystemQu As featured ...",
"liveBroadcastContent": "none"
},
"etag": "\"gMxXHe-zinKdE9lTnzKu8vjcmDI/dB-M0N9mB4xE-k4yAF_4d8aU0I4\""
},
],
"etag": "\"gMxXHe-zinKdE9lTnzKu8vjcmDI/yhLQgSpeObNnybd5JqSzlGiJ8Ew\"",
"nextPageToken": "CBkQAA",
"pageInfo": {
"resultsPerPage": 25,
"totalResults": 26825
},
"kind": "youtube#searchListResponse",
"regionCode": "FR"
}

View file

@ -0,0 +1,74 @@
import json
from collections import OrderedDict
import unittest
from test_plus.test import TestCase
from django.core.urlresolvers import reverse
from funkwhale_api.providers.youtube.client import client
from . import data as api_data
class TestAPI(TestCase):
@unittest.mock.patch(
'funkwhale_api.providers.youtube.client._do_search',
return_value=api_data.search['8 bit adventure'])
def test_can_get_search_results_from_youtube(self, *mocks):
query = '8 bit adventure'
results = client.search(query)
self.assertEqual(results[0]['id']['videoId'], '0HxZn6CzOIo')
self.assertEqual(results[0]['snippet']['title'], 'AdhesiveWombat - 8 Bit Adventure')
self.assertEqual(results[0]['full_url'], 'https://www.youtube.com/watch?v=0HxZn6CzOIo')
@unittest.mock.patch(
'funkwhale_api.providers.youtube.client._do_search',
return_value=api_data.search['8 bit adventure'])
def test_can_get_search_results_from_funkwhale(self, *mocks):
query = '8 bit adventure'
expected = json.dumps(client.search(query))
url = self.reverse('api:providers:youtube:search')
response = self.client.get(url + '?query={0}'.format(query))
self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8')))
@unittest.mock.patch(
'funkwhale_api.providers.youtube.client._do_search',
side_effect=[
api_data.search['8 bit adventure'],
api_data.search['system of a down toxicity'],
]
)
def test_can_send_multiple_queries_at_once(self, *mocks):
queries = OrderedDict()
queries['1'] = {
'q': '8 bit adventure',
}
queries['2'] = {
'q': 'system of a down toxicity',
}
results = client.search_multiple(queries)
self.assertEqual(results['1'][0]['id']['videoId'], '0HxZn6CzOIo')
self.assertEqual(results['1'][0]['snippet']['title'], 'AdhesiveWombat - 8 Bit Adventure')
self.assertEqual(results['1'][0]['full_url'], 'https://www.youtube.com/watch?v=0HxZn6CzOIo')
self.assertEqual(results['2'][0]['id']['videoId'], 'BorYwGi2SJc')
self.assertEqual(results['2'][0]['snippet']['title'], 'System of a Down: Toxicity')
self.assertEqual(results['2'][0]['full_url'], 'https://www.youtube.com/watch?v=BorYwGi2SJc')
@unittest.mock.patch(
'funkwhale_api.providers.youtube.client._do_search',
return_value=api_data.search['8 bit adventure'],
)
def test_can_send_multiple_queries_at_once_from_funwkhale(self, *mocks):
queries = OrderedDict()
queries['1'] = {
'q': '8 bit adventure',
}
expected = json.dumps(client.search_multiple(queries))
url = self.reverse('api:providers:youtube:searchs')
response = self.client.post(
url, json.dumps(queries), content_type='application/json')
self.assertJSONEqual(expected, json.loads(response.content.decode('utf-8')))

View file

@ -0,0 +1,8 @@
from django.conf.urls import include, url
from .views import APISearch, APISearchs
urlpatterns = [
url(r'^search/$', APISearch.as_view(), name='search'),
url(r'^searchs/$', APISearchs.as_view(), name='searchs'),
]

View file

@ -0,0 +1,21 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from funkwhale_api.common.permissions import ConditionalAuthentication
from .client import client
class APISearch(APIView):
permission_classes = [ConditionalAuthentication]
def get(self, request, *args, **kwargs):
results = client.search(request.GET['query'])
return Response(results)
class APISearchs(APIView):
permission_classes = [ConditionalAuthentication]
def post(self, request, *args, **kwargs):
results = client.search_multiple(request.data)
return Response(results)

View file

@ -0,0 +1 @@
from .registries import registry

View file

@ -0,0 +1,42 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
from django.conf import settings
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('music', '0004_track_tags'),
]
operations = [
migrations.CreateModel(
name='RadioSession',
fields=[
('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)),
('radio_type', models.CharField(max_length=50)),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('user', models.ForeignKey(related_name='radio_sessions', blank=True, to=settings.AUTH_USER_MODEL, null=True)),
],
),
migrations.CreateModel(
name='RadioSessionTrack',
fields=[
('id', models.AutoField(verbose_name='ID', primary_key=True, serialize=False, auto_created=True)),
('position', models.IntegerField(default=1)),
('session', models.ForeignKey(to='radios.RadioSession', related_name='session_tracks')),
('track', models.ForeignKey(to='music.Track', related_name='radio_session_tracks')),
],
options={
'ordering': ('session', 'position'),
},
),
migrations.AlterUniqueTogether(
name='radiosessiontrack',
unique_together=set([('session', 'position')]),
),
]

View file

@ -0,0 +1,19 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('radios', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='radiosession',
name='session_key',
field=models.CharField(null=True, blank=True, max_length=100),
),
]

Some files were not shown because too many files have changed in this diff Show more