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

merge-requests/154/head
Eliot Berriot 2017-06-23 23:00:42 +02:00
commit 76f98b74dd
285 zmienionych plików z 51318 dodań i 0 usunięć

69
.dockerignore 100644
Wyświetl plik

@ -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

29
.editorconfig 100644
Wyświetl plik

@ -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

3
.env.dev 100644
Wyświetl plik

@ -0,0 +1,3 @@
BACKEND_URL=http://localhost:6001
YOUTUBE_API_KEY=
API_AUTHENTICATION_REQUIRED=False

1
.gitattributes vendored 100644
Wyświetl plik

@ -0,0 +1 @@
* text=auto

84
.gitignore vendored 100644
Wyświetl plik

@ -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

22
.gitlab-ci.yml 100644
Wyświetl plik

@ -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

1
CONTRIBUTORS.txt 100644
Wyświetl plik

@ -0,0 +1 @@
Eliot Berriot

27
LICENSE 100644
Wyświetl plik

@ -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.

50
README.rst 100644
Wyświetl plik

@ -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

5
api/.coveragerc 100644
Wyświetl plik

@ -0,0 +1,5 @@
[run]
include = funkwhale_api/*
omit = *migrations*, *tests*
plugins =
django_coverage_plugin

11
api/.pylintrc 100644
Wyświetl plik

@ -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

21
api/Dockerfile 100644
Wyświetl plik

@ -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"]

Wyświetl plik

@ -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 "$@"

Wyświetl plik

@ -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

Wyświetl plik

@ -0,0 +1,2 @@
FROM nginx:latest
ADD nginx.conf /etc/nginx/nginx.conf

Wyświetl plik

@ -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;
}
}
}

Wyświetl plik

Wyświetl plik

@ -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'),
]

Wyświetl plik

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

Wyświetl plik

@ -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

Wyświetl plik

@ -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',
}
},
}

Wyświetl plik

@ -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

Wyświetl plik

@ -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

33
api/config/urls.py 100644
Wyświetl plik

@ -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)

41
api/config/wsgi.py 100644
Wyświetl plik

@ -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)

Wyświetl plik

@ -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()

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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('.')])

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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()),
],
),
]

Wyświetl plik

@ -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),
]

Wyświetl plik

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

Wyświetl plik

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

Wyświetl plik

@ -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

Wyświetl plik

@ -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']))

Wyświetl plik

@ -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')]),
),
]

Wyświetl plik

@ -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

Wyświetl plik

@ -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')

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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']

Wyświetl plik

@ -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',),
},
),
]

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

@ -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']

Wyświetl plik

@ -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,
}

Wyświetl plik

@ -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

Wyświetl plik

@ -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)

Wyświetl plik

@ -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'),
),
]

Wyświetl plik

@ -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'),
),
]

Wyświetl plik

@ -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),
),
]

Wyświetl plik

@ -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'),
),
]

Wyświetl plik

@ -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),
]

Wyświetl plik

@ -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),
),
]

Wyświetl plik

@ -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),
),
]

Wyświetl plik

@ -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),
),
]

Wyświetl plik

@ -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),
),
]

Wyświetl plik

@ -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),
),
]

Wyświetl plik

@ -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),
]

Wyświetl plik

@ -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'),
),
]

Wyświetl plik

@ -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

Wyświetl plik

@ -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

Wyświetl plik

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

File diff suppressed because one or more lines are too long

Plik binarny nie jest wyświetlany.

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)

Wyświetl plik

@ -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'))

Wyświetl plik

@ -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)

Wyświetl plik

@ -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!')

Wyświetl plik

@ -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

Wyświetl plik

@ -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])

Wyświetl plik

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

Wyświetl plik

@ -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()

Wyświetl plik

@ -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"
},
]
}

Wyświetl plik

@ -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