Initial commit that merge both the front end and the API in the same repository
This commit is contained in:
commit
76f98b74dd
285 changed files with 51318 additions and 0 deletions
0
api/funkwhale_api/providers/__init__.py
Normal file
0
api/funkwhale_api/providers/__init__.py
Normal file
4
api/funkwhale_api/providers/audiofile/__init__.py
Normal file
4
api/funkwhale_api/providers/audiofile/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""
|
||||
This module is responsible from importing existing audiofiles from the
|
||||
filesystem into funkwhale.
|
||||
"""
|
||||
60
api/funkwhale_api/providers/audiofile/importer.py
Normal file
60
api/funkwhale_api/providers/audiofile/importer.py
Normal 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
|
||||
|
|
@ -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)))
|
||||
0
api/funkwhale_api/providers/audiofile/tests/__init__.py
Normal file
0
api/funkwhale_api/providers/audiofile/tests/__init__.py
Normal 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])
|
||||
8
api/funkwhale_api/providers/urls.py
Normal file
8
api/funkwhale_api/providers/urls.py
Normal 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')),
|
||||
]
|
||||
0
api/funkwhale_api/providers/youtube/__init__.py
Normal file
0
api/funkwhale_api/providers/youtube/__init__.py
Normal file
58
api/funkwhale_api/providers/youtube/client.py
Normal file
58
api/funkwhale_api/providers/youtube/client.py
Normal 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()
|
||||
0
api/funkwhale_api/providers/youtube/tests/__init__.py
Normal file
0
api/funkwhale_api/providers/youtube/tests/__init__.py
Normal file
162
api/funkwhale_api/providers/youtube/tests/data.py
Normal file
162
api/funkwhale_api/providers/youtube/tests/data.py
Normal 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"
|
||||
}
|
||||
74
api/funkwhale_api/providers/youtube/tests/test_youtube.py
Normal file
74
api/funkwhale_api/providers/youtube/tests/test_youtube.py
Normal 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')))
|
||||
8
api/funkwhale_api/providers/youtube/urls.py
Normal file
8
api/funkwhale_api/providers/youtube/urls.py
Normal 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'),
|
||||
]
|
||||
21
api/funkwhale_api/providers/youtube/views.py
Normal file
21
api/funkwhale_api/providers/youtube/views.py
Normal 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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue