Initial commit that merge both the front end and the API in the same repository
This commit is contained in:
commit
76f98b74dd
.dockerignore.editorconfig.env.dev.gitattributes.gitignore.gitlab-ci.ymlCONTRIBUTORS.txtLICENSEREADME.rst
api
.coveragerc.pylintrcDockerfile
compose
config
demo
docker-compose.ymldocker
funkwhale_api
__init__.py
common
contrib
downloader
favorites
history
music
__init__.pyadmin.pyimporters.pylyrics.pymetadata.py
migrations
0001_initial.py0002_auto_20151215_1645.py0003_auto_20151222_2233.py0004_track_tags.py0005_deduplicate.py0006_unique_mbid.py0007_track_position.py0008_auto_20160529_1456.py0009_auto_20160920_1614.py0010_auto_20160920_1742.py0011_rename_files.py0012_auto_20161122_1905.py__init__.py
models.pyserializers.pytests
__init__.pycover.pydata.py
utils.pyviews.pymocking
test.oggtest_api.pytest_lyrics.pytest_metadata.pytest_music.pytest_works.pymusicbrainz
|
@ -0,0 +1,69 @@
|
|||
### OSX ###
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
### SublimeText ###
|
||||
# cache files for sublime text
|
||||
*.tmlanguage.cache
|
||||
*.tmPreferences.cache
|
||||
*.stTheme.cache
|
||||
|
||||
# workspace files are user-specific
|
||||
*.sublime-workspace
|
||||
|
||||
# project files should be checked into the repository, unless a significant
|
||||
# proportion of contributors will probably not be using SublimeText
|
||||
# *.sublime-project
|
||||
|
||||
# sftp configuration file
|
||||
sftp-config.json
|
||||
|
||||
# Basics
|
||||
*.py[cod]
|
||||
__pycache__
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
api/pip-log.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
.coverage
|
||||
.tox
|
||||
nosetests.xml
|
||||
htmlcov
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Pycharm
|
||||
.idea
|
||||
|
||||
# Vim
|
||||
|
||||
*~
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# npm
|
||||
front/node_modules/
|
||||
|
||||
# Compass
|
||||
.sass-cache
|
||||
|
||||
# virtual environments
|
||||
.env
|
||||
|
||||
# User-uploaded media
|
||||
api/funkwhale_api/media/
|
||||
|
||||
# Hitch directory
|
||||
api/tests/.hitch
|
||||
|
||||
# MailHog binary
|
||||
mailhog
|
||||
|
||||
*.sqlite3
|
||||
api/music
|
||||
api/media
|
|
@ -0,0 +1,29 @@
|
|||
# http://editorconfig.org
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.{py,rst,ini}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.py]
|
||||
line_length=120
|
||||
known_first_party=funkwhale_api
|
||||
multi_line_output=3
|
||||
default_section=THIRDPARTY
|
||||
|
||||
[*.{html,js,vue,css,scss,json,yml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
|
@ -0,0 +1,3 @@
|
|||
BACKEND_URL=http://localhost:6001
|
||||
YOUTUBE_API_KEY=
|
||||
API_AUTHENTICATION_REQUIRED=False
|
|
@ -0,0 +1 @@
|
|||
* text=auto
|
|
@ -0,0 +1,84 @@
|
|||
### OSX ###
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
|
||||
### SublimeText ###
|
||||
# cache files for sublime text
|
||||
*.tmlanguage.cache
|
||||
*.tmPreferences.cache
|
||||
*.stTheme.cache
|
||||
|
||||
# workspace files are user-specific
|
||||
*.sublime-workspace
|
||||
|
||||
# project files should be checked into the repository, unless a significant
|
||||
# proportion of contributors will probably not be using SublimeText
|
||||
# *.sublime-project
|
||||
|
||||
# sftp configuration file
|
||||
sftp-config.json
|
||||
|
||||
# Basics
|
||||
*.py[cod]
|
||||
__pycache__
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
pip-log.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
.coverage
|
||||
.tox
|
||||
nosetests.xml
|
||||
htmlcov
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Pycharm
|
||||
.idea
|
||||
|
||||
# Vim
|
||||
|
||||
*~
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# npm
|
||||
front/node_modules/
|
||||
|
||||
# Compass
|
||||
.sass-cache
|
||||
|
||||
# virtual environments
|
||||
.env
|
||||
|
||||
# User-uploaded media
|
||||
api/funkwhale_api/media/
|
||||
|
||||
# Hitch directory
|
||||
tests/.hitch
|
||||
|
||||
# MailHog binary
|
||||
mailhog
|
||||
|
||||
*.sqlite3
|
||||
|
||||
# Api
|
||||
api/music
|
||||
api/media
|
||||
api/staticfiles
|
||||
api/static
|
||||
|
||||
|
||||
# Front
|
||||
front/node_modules/
|
||||
front/dist/
|
||||
front/npm-debug.log*
|
||||
front/yarn-debug.log*
|
||||
front/yarn-error.log*
|
||||
front/test/unit/coverage
|
||||
front/test/e2e/reports
|
||||
front/selenium-debug.log
|
|
@ -0,0 +1,22 @@
|
|||
image: docker:latest
|
||||
|
||||
# When using dind, it's wise to use the overlayfs driver for
|
||||
# improved performance.
|
||||
# variables:
|
||||
# DOCKER_DRIVER: overlay
|
||||
#
|
||||
# services:
|
||||
# - docker:dind
|
||||
#
|
||||
#
|
||||
# # build:
|
||||
# # stage: build
|
||||
# # script:
|
||||
# # - docker login -u="$DOCKER_USERNAME" -p="$DOCKER_PASSWORD"
|
||||
# # - docker build -t funkwhale/front .
|
||||
# # - docker push
|
||||
# #
|
||||
# # tags:
|
||||
# # - dind
|
||||
# # only:
|
||||
# # - master
|
|
@ -0,0 +1 @@
|
|||
Eliot Berriot
|
|
@ -0,0 +1,27 @@
|
|||
Copyright (c) 2015, Eliot Berriot
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice, this
|
||||
list of conditions and the following disclaimer in the documentation and/or
|
||||
other materials provided with the distribution.
|
||||
|
||||
* Neither the name of funkwhale_api nor the names of its
|
||||
contributors may be used to endorse or promote products derived from this
|
||||
software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
|
||||
IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
||||
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
|
||||
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
|
||||
OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
|
||||
OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
|
||||
OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@ -0,0 +1,50 @@
|
|||
Funkwhale
|
||||
=============
|
||||
|
||||
A self-hosted tribute to Grooveshark.com.
|
||||
|
||||
LICENSE: BSD
|
||||
|
||||
Setting up a development environment (docker)
|
||||
----------------------------------------------
|
||||
|
||||
First of all, pull the repository.
|
||||
|
||||
Then, pull and build all the containers::
|
||||
|
||||
docker-compose -f dev.yml build
|
||||
docker-compose -f dev.yml pull
|
||||
|
||||
|
||||
API setup
|
||||
^^^^^^^^^^
|
||||
|
||||
You'll have apply database migrations::
|
||||
|
||||
docker-compose -f dev.yml run celeryworker python manage.py migrate
|
||||
|
||||
And to create an admin user::
|
||||
|
||||
docker-compose -f dev.yml run celeryworker python manage.py createsuperuser
|
||||
|
||||
|
||||
Launch all services
|
||||
^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Then you can run everything with::
|
||||
|
||||
docker-compose up
|
||||
|
||||
The API server will be accessible at http://localhost:6001, and the front-end at http://localhost:8080.
|
||||
|
||||
Running API tests
|
||||
------------------
|
||||
|
||||
Everything is managed using docker and docker-compose, just run::
|
||||
|
||||
./api/runtests
|
||||
|
||||
This bash script invoke `python manage.py test` in a docker container under the hood, so you can use
|
||||
traditional django test arguments and options, such as::
|
||||
|
||||
./api/runtests funkwhale_api.music # run a specific app test
|
|
@ -0,0 +1,5 @@
|
|||
[run]
|
||||
include = funkwhale_api/*
|
||||
omit = *migrations*, *tests*
|
||||
plugins =
|
||||
django_coverage_plugin
|
|
@ -0,0 +1,11 @@
|
|||
[MASTER]
|
||||
load-plugins=pylint_common, pylint_django, pylint_celery
|
||||
|
||||
[FORMAT]
|
||||
max-line-length=120
|
||||
|
||||
[MESSAGES CONTROL]
|
||||
disable=missing-docstring,invalid-name
|
||||
|
||||
[DESIGN]
|
||||
max-parents=13
|
|
@ -0,0 +1,21 @@
|
|||
FROM python:3.5
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
# Requirements have to be pulled and installed here, otherwise caching won't work
|
||||
|
||||
COPY ./requirements.apt /requirements.apt
|
||||
RUN apt-get update -qq && grep "^[^#;]" requirements.apt | xargs apt-get install -y
|
||||
|
||||
|
||||
COPY ./requirements /requirements
|
||||
RUN pip install -r /requirements/production.txt
|
||||
|
||||
COPY . /app
|
||||
|
||||
# Since youtube-dl code is updated fairly often, we split it here
|
||||
RUN pip install --upgrade youtube-dl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT ["./compose/django/entrypoint.sh"]
|
|
@ -0,0 +1,18 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
# This entrypoint is used to play nicely with the current cookiecutter configuration.
|
||||
# Since docker-compose relies heavily on environment variables itself for configuration, we'd have to define multiple
|
||||
# environment variables just to support cookiecutter out of the box. That makes no sense, so this little entrypoint
|
||||
# does all this for us.
|
||||
export REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# the official postgres image uses 'postgres' as default user if not set explictly.
|
||||
if [ -z "$POSTGRES_ENV_POSTGRES_USER" ]; then
|
||||
export POSTGRES_ENV_POSTGRES_USER=postgres
|
||||
fi
|
||||
|
||||
export DATABASE_URL=postgres://$POSTGRES_ENV_POSTGRES_USER:$POSTGRES_ENV_POSTGRES_PASSWORD@postgres:5432/$POSTGRES_ENV_POSTGRES_USER
|
||||
|
||||
export CELERY_BROKER_URL=$REDIS_URL
|
||||
|
||||
exec "$@"
|
|
@ -0,0 +1,3 @@
|
|||
#!/bin/sh
|
||||
python /app/manage.py collectstatic --noinput
|
||||
/usr/local/bin/gunicorn config.wsgi -w 4 -b 0.0.0.0:5000 --chdir=/app
|
|
@ -0,0 +1,2 @@
|
|||
FROM nginx:latest
|
||||
ADD nginx.conf /etc/nginx/nginx.conf
|
|
@ -0,0 +1,53 @@
|
|||
user nginx;
|
||||
worker_processes 1;
|
||||
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
sendfile on;
|
||||
#tcp_nopush on;
|
||||
|
||||
keepalive_timeout 65;
|
||||
|
||||
#gzip on;
|
||||
|
||||
upstream app {
|
||||
server django:12081;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
charset utf-8;
|
||||
|
||||
root /staticfiles;
|
||||
location / {
|
||||
# checks for static file, if not found proxy to app
|
||||
try_files $uri @proxy_to_app;
|
||||
}
|
||||
|
||||
location @proxy_to_app {
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_redirect off;
|
||||
|
||||
proxy_pass http://app;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
from rest_framework import routers
|
||||
from django.conf.urls import include, url
|
||||
from funkwhale_api.music import views
|
||||
from funkwhale_api.playlists import views as playlists_views
|
||||
|
||||
router = routers.SimpleRouter()
|
||||
router.register(r'tags', views.TagViewSet, 'tags')
|
||||
router.register(r'tracks', views.TrackViewSet, 'tracks')
|
||||
router.register(r'artists', views.ArtistViewSet, 'artists')
|
||||
router.register(r'albums', views.AlbumViewSet, 'albums')
|
||||
router.register(r'import-batches', views.ImportBatchViewSet, 'import-batches')
|
||||
router.register(r'submit', views.SubmitViewSet, 'submit')
|
||||
router.register(r'playlists', playlists_views.PlaylistViewSet, 'playlists')
|
||||
router.register(r'playlist-tracks', playlists_views.PlaylistTrackViewSet, 'playlist-tracks')
|
||||
urlpatterns = router.urls
|
||||
|
||||
urlpatterns += [
|
||||
url(r'^providers/', include('funkwhale_api.providers.urls', namespace='providers')),
|
||||
url(r'^favorites/', include('funkwhale_api.favorites.urls', namespace='favorites')),
|
||||
url(r'^search$', views.Search.as_view(), name='search'),
|
||||
url(r'^radios/', include('funkwhale_api.radios.urls', namespace='radios')),
|
||||
url(r'^history/', include('funkwhale_api.history.urls', namespace='history')),
|
||||
url(r'^users/', include('funkwhale_api.users.api_urls', namespace='users')),
|
||||
url(r'^token/', 'rest_framework_jwt.views.obtain_jwt_token'),
|
||||
url(r'^token/refresh/', 'rest_framework_jwt.views.refresh_jwt_token'),
|
||||
]
|
|
@ -0,0 +1 @@
|
|||
# -*- coding: utf-8 -*-
|
|
@ -0,0 +1,307 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Django settings for funkwhale_api project.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/dev/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/dev/ref/settings/
|
||||
"""
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import os
|
||||
import environ
|
||||
|
||||
ROOT_DIR = environ.Path(__file__) - 3 # (/a/b/myfile.py - 3 = /)
|
||||
APPS_DIR = ROOT_DIR.path('funkwhale_api')
|
||||
|
||||
env = environ.Env()
|
||||
|
||||
try:
|
||||
env.read_env(ROOT_DIR.file('.env'))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
# APP CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
DJANGO_APPS = (
|
||||
# Default Django apps:
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.sites',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
|
||||
# Useful template tags:
|
||||
# 'django.contrib.humanize',
|
||||
|
||||
# Admin
|
||||
'django.contrib.admin',
|
||||
)
|
||||
THIRD_PARTY_APPS = (
|
||||
# 'crispy_forms', # Form layouts
|
||||
'allauth', # registration
|
||||
'allauth.account', # registration
|
||||
'allauth.socialaccount', # registration
|
||||
'corsheaders',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'djcelery',
|
||||
'taggit',
|
||||
'cachalot',
|
||||
'rest_auth',
|
||||
'rest_auth.registration',
|
||||
'mptt',
|
||||
)
|
||||
|
||||
# Apps specific for this project go here.
|
||||
LOCAL_APPS = (
|
||||
'funkwhale_api.users', # custom users app
|
||||
# Your stuff: custom apps go here
|
||||
'funkwhale_api.music',
|
||||
'funkwhale_api.favorites',
|
||||
'funkwhale_api.radios',
|
||||
'funkwhale_api.history',
|
||||
'funkwhale_api.playlists',
|
||||
'funkwhale_api.providers.audiofile',
|
||||
)
|
||||
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
||||
# MIDDLEWARE CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
MIDDLEWARE_CLASSES = (
|
||||
# Make sure djangosecure.middleware.SecurityMiddleware is listed first
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'funkwhale_api.users.middleware.AnonymousSessionMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
)
|
||||
|
||||
# MIGRATIONS CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
MIGRATION_MODULES = {
|
||||
'sites': 'funkwhale_api.contrib.sites.migrations'
|
||||
}
|
||||
|
||||
# DEBUG
|
||||
# ------------------------------------------------------------------------------
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#debug
|
||||
DEBUG = env.bool("DJANGO_DEBUG", False)
|
||||
|
||||
# FIXTURE CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-FIXTURE_DIRS
|
||||
FIXTURE_DIRS = (
|
||||
str(APPS_DIR.path('fixtures')),
|
||||
)
|
||||
|
||||
# EMAIL CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND', default='django.core.mail.backends.smtp.EmailBackend')
|
||||
|
||||
# MANAGER CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#admins
|
||||
ADMINS = (
|
||||
("""Eliot Berriot""", 'contact@eliotberriot.om'),
|
||||
)
|
||||
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#managers
|
||||
MANAGERS = ADMINS
|
||||
|
||||
# DATABASE CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#databases
|
||||
DATABASES = {
|
||||
# Raises ImproperlyConfigured exception if DATABASE_URL not in os.environ
|
||||
'default': env.db("DATABASE_URL", default="postgresql://postgres@postgres/postgres"),
|
||||
}
|
||||
DATABASES['default']['ATOMIC_REQUESTS'] = True
|
||||
#
|
||||
# DATABASES = {
|
||||
# 'default': {
|
||||
# 'ENGINE': 'django.db.backends.sqlite3',
|
||||
# 'NAME': 'db.sqlite3',
|
||||
# }
|
||||
# }
|
||||
# GENERAL CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
# Local time zone for this installation. Choices can be found here:
|
||||
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
|
||||
# although not all choices may be available on all operating systems.
|
||||
# In a Windows environment this must be set to your system time zone.
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#language-code
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#site-id
|
||||
SITE_ID = 1
|
||||
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#use-i18n
|
||||
USE_I18N = True
|
||||
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#use-l10n
|
||||
USE_L10N = True
|
||||
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#use-tz
|
||||
USE_TZ = True
|
||||
|
||||
# TEMPLATE CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#templates
|
||||
TEMPLATES = [
|
||||
{
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-dirs
|
||||
'DIRS': [
|
||||
str(APPS_DIR.path('templates')),
|
||||
],
|
||||
'OPTIONS': {
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-debug
|
||||
'debug': DEBUG,
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-loaders
|
||||
# https://docs.djangoproject.com/en/dev/ref/templates/api/#loader-types
|
||||
'loaders': [
|
||||
'django.template.loaders.filesystem.Loader',
|
||||
'django.template.loaders.app_directories.Loader',
|
||||
],
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.template.context_processors.i18n',
|
||||
'django.template.context_processors.media',
|
||||
'django.template.context_processors.static',
|
||||
'django.template.context_processors.tz',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
# Your stuff: custom template context processors go here
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
# See: http://django-crispy-forms.readthedocs.org/en/latest/install.html#template-packs
|
||||
CRISPY_TEMPLATE_PACK = 'bootstrap3'
|
||||
|
||||
# STATIC FILE CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#static-root
|
||||
STATIC_ROOT = str(ROOT_DIR('staticfiles'))
|
||||
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#static-url
|
||||
STATIC_URL = env("STATIC_URL", default='/static/')
|
||||
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS
|
||||
STATICFILES_DIRS = (
|
||||
str(APPS_DIR.path('static')),
|
||||
)
|
||||
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders
|
||||
STATICFILES_FINDERS = (
|
||||
'django.contrib.staticfiles.finders.FileSystemFinder',
|
||||
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
|
||||
)
|
||||
|
||||
# MEDIA CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#media-root
|
||||
MEDIA_ROOT = str(APPS_DIR('media'))
|
||||
|
||||
USE_SAMPLE_TRACK = env.bool("USE_SAMPLE_TRACK", False)
|
||||
|
||||
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#media-url
|
||||
MEDIA_URL = '/media/'
|
||||
|
||||
# URL Configuration
|
||||
# ------------------------------------------------------------------------------
|
||||
ROOT_URLCONF = 'config.urls'
|
||||
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
|
||||
WSGI_APPLICATION = 'config.wsgi.application'
|
||||
|
||||
# AUTHENTICATION CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
AUTHENTICATION_BACKENDS = (
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
'allauth.account.auth_backends.AuthenticationBackend',
|
||||
)
|
||||
|
||||
# Some really nice defaults
|
||||
ACCOUNT_AUTHENTICATION_METHOD = 'username'
|
||||
ACCOUNT_EMAIL_REQUIRED = True
|
||||
ACCOUNT_EMAIL_VERIFICATION = 'mandatory'
|
||||
|
||||
# Custom user app defaults
|
||||
# Select the correct user model
|
||||
AUTH_USER_MODEL = 'users.User'
|
||||
LOGIN_REDIRECT_URL = 'users:redirect'
|
||||
LOGIN_URL = 'account_login'
|
||||
|
||||
# SLUGLIFIER
|
||||
AUTOSLUG_SLUGIFY_FUNCTION = 'slugify.slugify'
|
||||
|
||||
########## CELERY
|
||||
INSTALLED_APPS += ('funkwhale_api.taskapp.celery.CeleryConfig',)
|
||||
# if you are not using the django database broker (e.g. rabbitmq, redis, memcached), you can remove the next line.
|
||||
INSTALLED_APPS += ('kombu.transport.django',)
|
||||
BROKER_URL = env("CELERY_BROKER_URL", default='django://')
|
||||
########## END CELERY
|
||||
|
||||
|
||||
# Location of root django.contrib.admin URL, use {% url 'admin:index' %}
|
||||
ADMIN_URL = r'^admin/'
|
||||
SESSION_SAVE_EVERY_REQUEST = True
|
||||
# Your common stuff: Below this line define 3rd party library settings
|
||||
CELERY_DEFAULT_RATE_LIMIT = 1
|
||||
CELERYD_TASK_TIME_LIMIT = 300
|
||||
import datetime
|
||||
JWT_AUTH = {
|
||||
'JWT_ALLOW_REFRESH': True,
|
||||
'JWT_EXPIRATION_DELTA': datetime.timedelta(days=7),
|
||||
'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=30),
|
||||
'JWT_AUTH_HEADER_PREFIX': 'JWT',
|
||||
}
|
||||
|
||||
ACCOUNT_ADAPTER = 'funkwhale_api.users.adapters.FunkwhaleAccountAdapter'
|
||||
CORS_ORIGIN_ALLOW_ALL = True
|
||||
# CORS_ORIGIN_WHITELIST = (
|
||||
# 'localhost',
|
||||
# 'funkwhale.localhost',
|
||||
# )
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
API_AUTHENTICATION_REQUIRED = env.bool("API_AUTHENTICATION_REQUIRED", True)
|
||||
REGISTRATION_MODE = env('REGISTRATION_MODE', default='disabled')
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_PERMISSION_CLASSES': (
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
),
|
||||
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
|
||||
'PAGE_SIZE': 25,
|
||||
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': (
|
||||
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
|
||||
'rest_framework.authentication.SessionAuthentication',
|
||||
'rest_framework.authentication.BasicAuthentication',
|
||||
),
|
||||
'DEFAULT_FILTER_BACKENDS': (
|
||||
'rest_framework.filters.OrderingFilter',
|
||||
)
|
||||
}
|
||||
|
||||
FUNKWHALE_PROVIDERS = {
|
||||
'youtube': {
|
||||
'api_key': env('YOUTUBE_API_KEY', default='REPLACE_ME')
|
||||
}
|
||||
}
|
||||
ATOMIC_REQUESTS = False
|
|
@ -0,0 +1,85 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
Local settings
|
||||
|
||||
- Run in Debug mode
|
||||
- Use console backend for emails
|
||||
- Add Django Debug Toolbar
|
||||
- Add django-extensions as app
|
||||
'''
|
||||
|
||||
from .common import * # noqa
|
||||
|
||||
# DEBUG
|
||||
# ------------------------------------------------------------------------------
|
||||
DEBUG = env.bool('DJANGO_DEBUG', default=True)
|
||||
TEMPLATES[0]['OPTIONS']['debug'] = DEBUG
|
||||
|
||||
# SECRET CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
|
||||
# Note: This key only used for development and testing.
|
||||
SECRET_KEY = env("DJANGO_SECRET_KEY", default='mc$&b=5j#6^bv7tld1gyjp2&+^-qrdy=0sw@r5sua*1zp4fmxc')
|
||||
|
||||
# Mail settings
|
||||
# ------------------------------------------------------------------------------
|
||||
EMAIL_HOST = 'localhost'
|
||||
EMAIL_PORT = 1025
|
||||
EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND',
|
||||
default='django.core.mail.backends.console.EmailBackend')
|
||||
|
||||
# CACHING
|
||||
# ------------------------------------------------------------------------------
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
'LOCATION': ''
|
||||
}
|
||||
}
|
||||
|
||||
# django-debug-toolbar
|
||||
# ------------------------------------------------------------------------------
|
||||
MIDDLEWARE_CLASSES += ('debug_toolbar.middleware.DebugToolbarMiddleware',)
|
||||
|
||||
# INTERNAL_IPS = ('127.0.0.1', '10.0.2.2',)
|
||||
|
||||
DEBUG_TOOLBAR_CONFIG = {
|
||||
'DISABLE_PANELS': [
|
||||
'debug_toolbar.panels.redirects.RedirectsPanel',
|
||||
],
|
||||
'SHOW_TEMPLATE_CONTEXT': True,
|
||||
'SHOW_TOOLBAR_CALLBACK': lambda request: True,
|
||||
}
|
||||
|
||||
# django-extensions
|
||||
# ------------------------------------------------------------------------------
|
||||
# INSTALLED_APPS += ('django_extensions', )
|
||||
INSTALLED_APPS += ('debug_toolbar', )
|
||||
|
||||
# TESTING
|
||||
# ------------------------------------------------------------------------------
|
||||
TEST_RUNNER = 'django.test.runner.DiscoverRunner'
|
||||
|
||||
########## CELERY
|
||||
# In development, all tasks will be executed locally by blocking until the task returns
|
||||
CELERY_ALWAYS_EAGER = False
|
||||
########## END CELERY
|
||||
|
||||
# Your local stuff: Below this line define 3rd party library settings
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'handlers': {
|
||||
'console':{
|
||||
'level':'DEBUG',
|
||||
'class':'logging.StreamHandler',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'django.request': {
|
||||
'handlers':['console'],
|
||||
'propagate': True,
|
||||
'level':'DEBUG',
|
||||
}
|
||||
},
|
||||
}
|
|
@ -0,0 +1,167 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
'''
|
||||
Production Configurations
|
||||
|
||||
- Use djangosecure
|
||||
- Use Amazon's S3 for storing static files and uploaded media
|
||||
- Use mailgun to send emails
|
||||
- Use Redis on Heroku
|
||||
|
||||
|
||||
'''
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
from django.utils import six
|
||||
|
||||
|
||||
from .common import * # noqa
|
||||
|
||||
# SECRET CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
|
||||
# Raises ImproperlyConfigured exception if DJANGO_SECRET_KEY not in os.environ
|
||||
SECRET_KEY = env("DJANGO_SECRET_KEY")
|
||||
|
||||
# This ensures that Django will be able to detect a secure connection
|
||||
# properly on Heroku.
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
|
||||
# django-secure
|
||||
# ------------------------------------------------------------------------------
|
||||
# INSTALLED_APPS += ("djangosecure", )
|
||||
#
|
||||
# SECURITY_MIDDLEWARE = (
|
||||
# 'djangosecure.middleware.SecurityMiddleware',
|
||||
# )
|
||||
#
|
||||
#
|
||||
# # Make sure djangosecure.middleware.SecurityMiddleware is listed first
|
||||
# MIDDLEWARE_CLASSES = SECURITY_MIDDLEWARE + MIDDLEWARE_CLASSES
|
||||
#
|
||||
# # set this to 60 seconds and then to 518400 when you can prove it works
|
||||
# SECURE_HSTS_SECONDS = 60
|
||||
# SECURE_HSTS_INCLUDE_SUBDOMAINS = env.bool(
|
||||
# "DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS", default=True)
|
||||
# SECURE_FRAME_DENY = env.bool("DJANGO_SECURE_FRAME_DENY", default=True)
|
||||
# SECURE_CONTENT_TYPE_NOSNIFF = env.bool(
|
||||
# "DJANGO_SECURE_CONTENT_TYPE_NOSNIFF", default=True)
|
||||
# SECURE_BROWSER_XSS_FILTER = True
|
||||
# SESSION_COOKIE_SECURE = False
|
||||
# SESSION_COOKIE_HTTPONLY = True
|
||||
# SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=True)
|
||||
|
||||
# SITE CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
# Hosts/domain names that are valid for this site
|
||||
# See https://docs.djangoproject.com/en/1.6/ref/settings/#allowed-hosts
|
||||
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS', default=['funkwhale.io'])
|
||||
# END SITE CONFIGURATION
|
||||
|
||||
INSTALLED_APPS += ("gunicorn", )
|
||||
|
||||
# STORAGE CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
# Uploaded Media Files
|
||||
# ------------------------
|
||||
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage'
|
||||
|
||||
# URL that handles the media served from MEDIA_ROOT, used for managing
|
||||
# stored files.
|
||||
MEDIA_URL = '/media/'
|
||||
|
||||
# Static Assets
|
||||
# ------------------------
|
||||
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage'
|
||||
|
||||
|
||||
# EMAIL
|
||||
# ------------------------------------------------------------------------------
|
||||
DEFAULT_FROM_EMAIL = env('DJANGO_DEFAULT_FROM_EMAIL',
|
||||
default='funkwhale_api <noreply@funkwhale.io>')
|
||||
|
||||
EMAIL_SUBJECT_PREFIX = env("DJANGO_EMAIL_SUBJECT_PREFIX", default='[funkwhale_api] ')
|
||||
SERVER_EMAIL = env('DJANGO_SERVER_EMAIL', default=DEFAULT_FROM_EMAIL)
|
||||
|
||||
|
||||
# TEMPLATE CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
# See:
|
||||
# https://docs.djangoproject.com/en/dev/ref/templates/api/#django.template.loaders.cached.Loader
|
||||
TEMPLATES[0]['OPTIONS']['loaders'] = [
|
||||
('django.template.loaders.cached.Loader', [
|
||||
'django.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader', ]),
|
||||
]
|
||||
|
||||
# DATABASE CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
# Raises ImproperlyConfigured exception if DATABASE_URL not in os.environ
|
||||
DATABASES['default'] = env.db("DATABASE_URL")
|
||||
|
||||
# CACHING
|
||||
# ------------------------------------------------------------------------------
|
||||
# Heroku URL does not pass the DB number, so we parse it in
|
||||
CACHES = {
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": "{0}/{1}".format(env.cache_url('REDIS_URL', default="redis://127.0.0.1:6379"), 0),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
"IGNORE_EXCEPTIONS": True, # mimics memcache behavior.
|
||||
# http://niwinz.github.io/django-redis/latest/#_memcached_exceptions_behavior
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# LOGGING CONFIGURATION
|
||||
# ------------------------------------------------------------------------------
|
||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#logging
|
||||
# A sample logging configuration. The only tangible logging
|
||||
# performed by this configuration is to send an email to
|
||||
# the site admins on every HTTP 500 error when DEBUG=False.
|
||||
# See http://docs.djangoproject.com/en/dev/topics/logging for
|
||||
# more details on how to customize your logging configuration.
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'filters': {
|
||||
'require_debug_false': {
|
||||
'()': 'django.utils.log.RequireDebugFalse'
|
||||
}
|
||||
},
|
||||
'formatters': {
|
||||
'verbose': {
|
||||
'format': '%(levelname)s %(asctime)s %(module)s '
|
||||
'%(process)d %(thread)d %(message)s'
|
||||
},
|
||||
},
|
||||
'handlers': {
|
||||
'mail_admins': {
|
||||
'level': 'ERROR',
|
||||
'filters': ['require_debug_false'],
|
||||
'class': 'django.utils.log.AdminEmailHandler'
|
||||
},
|
||||
'console': {
|
||||
'level': 'DEBUG',
|
||||
'class': 'logging.StreamHandler',
|
||||
'formatter': 'verbose',
|
||||
},
|
||||
},
|
||||
'loggers': {
|
||||
'django.request': {
|
||||
'handlers': ['mail_admins'],
|
||||
'level': 'ERROR',
|
||||
'propagate': True
|
||||
},
|
||||
'django.security.DisallowedHost': {
|
||||
'level': 'ERROR',
|
||||
'handlers': ['console', 'mail_admins'],
|
||||
'propagate': True
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Custom Admin URL, use {% url 'admin:index' %}
|
||||
ADMIN_URL = env('DJANGO_ADMIN_URL')
|
||||
|
||||
# Your production stuff: Below this line define 3rd party library settings
|
|
@ -0,0 +1,34 @@
|
|||
from .common import * # noqa
|
||||
SECRET_KEY = env("DJANGO_SECRET_KEY", default='test')
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': ':memory:',
|
||||
}
|
||||
}
|
||||
|
||||
# Mail settings
|
||||
# ------------------------------------------------------------------------------
|
||||
EMAIL_HOST = 'localhost'
|
||||
EMAIL_PORT = 1025
|
||||
EMAIL_BACKEND = env('DJANGO_EMAIL_BACKEND',
|
||||
default='django.core.mail.backends.console.EmailBackend')
|
||||
|
||||
# CACHING
|
||||
# ------------------------------------------------------------------------------
|
||||
CACHES = {
|
||||
'default': {
|
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
'LOCATION': ''
|
||||
}
|
||||
}
|
||||
# TESTING
|
||||
# ------------------------------------------------------------------------------
|
||||
TEST_RUNNER = 'django.test.runner.DiscoverRunner'
|
||||
|
||||
########## CELERY
|
||||
# In development, all tasks will be executed locally by blocking until the task returns
|
||||
CELERY_ALWAYS_EAGER = True
|
||||
########## END CELERY
|
||||
|
||||
# Your local stuff: Below this line define 3rd party library settings
|
|
@ -0,0 +1,33 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls import include, url
|
||||
from django.conf.urls.static import static
|
||||
from django.contrib import admin
|
||||
from django.views.generic import TemplateView
|
||||
from django.views import defaults as default_views
|
||||
|
||||
urlpatterns = [
|
||||
# Django Admin, use {% url 'admin:index' %}
|
||||
url(settings.ADMIN_URL, include(admin.site.urls)),
|
||||
|
||||
url(r'^api/', include("config.api_urls", namespace="api")),
|
||||
url(r'^api/auth/', include('rest_auth.urls')),
|
||||
url(r'^api/auth/registration/', include('funkwhale_api.users.rest_auth_urls')),
|
||||
url(r'^accounts/', include('allauth.urls')),
|
||||
|
||||
# Your stuff: custom urls includes go here
|
||||
|
||||
|
||||
]
|
||||
|
||||
if settings.DEBUG:
|
||||
# This allows the error pages to be debugged during development, just visit
|
||||
# these url in browser to see how these error pages look like.
|
||||
urlpatterns += [
|
||||
url(r'^400/$', default_views.bad_request),
|
||||
url(r'^403/$', default_views.permission_denied),
|
||||
url(r'^404/$', default_views.page_not_found),
|
||||
url(r'^500/$', default_views.server_error),
|
||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
|
@ -0,0 +1,41 @@
|
|||
"""
|
||||
WSGI config for funkwhale_api project.
|
||||
|
||||
This module contains the WSGI application used by Django's development server
|
||||
and any production WSGI deployments. It should expose a module-level variable
|
||||
named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover
|
||||
this application via the ``WSGI_APPLICATION`` setting.
|
||||
|
||||
Usually you will have the standard Django WSGI application here, but it also
|
||||
might make sense to replace the whole Django WSGI application with a custom one
|
||||
that later delegates to the Django one. For example, you could introduce WSGI
|
||||
middleware here, or combine a Django application with an application of another
|
||||
framework.
|
||||
|
||||
"""
|
||||
import os
|
||||
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
from whitenoise.django import DjangoWhiteNoise
|
||||
|
||||
|
||||
# We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks
|
||||
# if running multiple sites in the same mod_wsgi process. To fix this, use
|
||||
# mod_wsgi daemon mode with each site in its own daemon process, or use
|
||||
# os.environ["DJANGO_SETTINGS_MODULE"] = "config.settings.production"
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
|
||||
|
||||
# This application object is used by any WSGI server configured to use this
|
||||
# file. This includes Django's development server, if the WSGI_APPLICATION
|
||||
# setting points here.
|
||||
application = get_wsgi_application()
|
||||
|
||||
# Use Whitenoise to serve static files
|
||||
# See: https://whitenoise.readthedocs.org/
|
||||
application = DjangoWhiteNoise(application)
|
||||
|
||||
|
||||
# Apply WSGI middleware here.
|
||||
# from helloworld.wsgi import HelloWorldApplication
|
||||
# application = HelloWorldApplication(application)
|
|
@ -0,0 +1,6 @@
|
|||
from funkwhale_api.users.models import User
|
||||
|
||||
|
||||
u = User.objects.create(email='demo@demo.com', username='demo', is_staff=True)
|
||||
u.set_password('demo')
|
||||
u.save()
|
|
@ -0,0 +1,13 @@
|
|||
#! /bin/bash
|
||||
|
||||
echo "Loading demo data..."
|
||||
|
||||
python manage.py migrate --noinput
|
||||
|
||||
echo "Creating demo user..."
|
||||
|
||||
cat demo/demo-user.py | python manage.py shell --plain
|
||||
|
||||
echo "Importing demo tracks..."
|
||||
|
||||
python manage.py import_files "/music/**/*.ogg" --recursive --noinput
|
|
@ -0,0 +1,42 @@
|
|||
version: '2'
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:9.5
|
||||
|
||||
api:
|
||||
build: .
|
||||
links:
|
||||
- postgres
|
||||
- redis
|
||||
command: ./compose/django/gunicorn.sh
|
||||
env_file: .env
|
||||
volumes:
|
||||
- ./media:/app/funkwhale_api/media
|
||||
- ./staticfiles:/app/staticfiles
|
||||
- ./music:/music
|
||||
ports:
|
||||
- "127.0.0.1:6001:5000"
|
||||
|
||||
redis:
|
||||
image: redis:3.0
|
||||
|
||||
celeryworker:
|
||||
build: .
|
||||
env_file: .env
|
||||
links:
|
||||
- postgres
|
||||
- redis
|
||||
command: celery -A funkwhale_api.taskapp worker -l INFO
|
||||
volumes:
|
||||
- ./media:/app/funkwhale_api/media
|
||||
- ./music:/music
|
||||
environment:
|
||||
- C_FORCE_ROOT=True
|
||||
|
||||
celerybeat:
|
||||
build: .
|
||||
env_file: .env
|
||||
links:
|
||||
- postgres
|
||||
- redis
|
||||
command: celery -A funkwhale_api.taskapp beat -l INFO
|
|
@ -0,0 +1,10 @@
|
|||
FROM python:3.5
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
# Requirements have to be pulled and installed here, otherwise caching won't work
|
||||
COPY ./requirements.apt /requirements.apt
|
||||
COPY ./install_os_dependencies.sh /install_os_dependencies.sh
|
||||
RUN bash install_os_dependencies.sh install
|
||||
COPY ./requirements /requirements
|
||||
RUN pip install -r /requirements/base.txt
|
|
@ -0,0 +1,12 @@
|
|||
FROM python:3.5
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
# Requirements have to be pulled and installed here, otherwise caching won't work
|
||||
COPY ./requirements.apt /requirements.apt
|
||||
COPY ./install_os_dependencies.sh /install_os_dependencies.sh
|
||||
RUN bash install_os_dependencies.sh install
|
||||
COPY ./requirements /requirements
|
||||
RUN pip install -r /requirements/local.txt
|
||||
|
||||
WORKDIR /app
|
|
@ -0,0 +1,13 @@
|
|||
FROM funkwhale/apibase
|
||||
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
# Requirements have to be pulled and installed here, otherwise caching won't work
|
||||
COPY ./requirements.apt /requirements.apt
|
||||
COPY ./install_os_dependencies.sh /install_os_dependencies.sh
|
||||
RUN bash install_os_dependencies.sh install
|
||||
COPY ./requirements /requirements
|
||||
RUN pip install -r /requirements/local.txt
|
||||
RUN pip install -r /requirements/test.txt
|
||||
|
||||
WORKDIR /app
|
|
@ -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('.')])
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
# -*- coding: utf-8 -*-
|
|
@ -0,0 +1 @@
|
|||
# -*- coding: utf-8 -*-
|
|
@ -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()),
|
||||
],
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -0,0 +1 @@
|
|||
# -*- coding: utf-8 -*-
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
from .downloader import download
|
|
@ -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
|
|
@ -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']))
|
|
@ -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')]),
|
||||
),
|
||||
]
|
|
@ -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
|
|
@ -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')
|
|
@ -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)
|
|
@ -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
|
|
@ -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)
|
|
@ -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']
|
|
@ -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',),
|
||||
},
|
||||
),
|
||||
]
|
|
@ -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)
|
|
@ -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)
|
|
@ -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)
|
|
@ -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
|
|
@ -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
|
|
@ -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']
|
|
@ -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,
|
||||
}
|
|
@ -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
|
|
@ -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)
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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),
|
||||
]
|
|
@ -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'),
|
||||
),
|
||||
]
|
|
@ -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
|
|
@ -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
|
@ -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': 'U‐Fig'},
|
||||
'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': 'She’s 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.
|
@ -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)
|
|
@ -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)
|
|
@ -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'))
|
|
@ -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)
|
|
@ -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!')
|
|
@ -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
|
|
@ -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])
|
|
@ -0,0 +1 @@
|
|||
from .client import api
|
|
@ -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()
|
|
@ -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"
|
||||
},
|
||||
]
|
||||
}
|
|
@ -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')))
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue