Blacked the code

This commit is contained in:
Eliot Berriot 2018-06-09 15:36:16 +02:00
parent b6fc0051fa
commit 62ca3bd736
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
279 changed files with 8861 additions and 9527 deletions

View File

@ -12,70 +12,70 @@ from dynamic_preferences.api.viewsets import GlobalPreferencesViewSet
from dynamic_preferences.users.viewsets import UserPreferencesViewSet from dynamic_preferences.users.viewsets import UserPreferencesViewSet
router = routers.SimpleRouter() router = routers.SimpleRouter()
router.register(r'settings', GlobalPreferencesViewSet, base_name='settings') router.register(r"settings", GlobalPreferencesViewSet, base_name="settings")
router.register(r'activity', activity_views.ActivityViewSet, 'activity') router.register(r"activity", activity_views.ActivityViewSet, "activity")
router.register(r'tags', views.TagViewSet, 'tags') router.register(r"tags", views.TagViewSet, "tags")
router.register(r'tracks', views.TrackViewSet, 'tracks') router.register(r"tracks", views.TrackViewSet, "tracks")
router.register(r'trackfiles', views.TrackFileViewSet, 'trackfiles') router.register(r"trackfiles", views.TrackFileViewSet, "trackfiles")
router.register(r'artists', views.ArtistViewSet, 'artists') router.register(r"artists", views.ArtistViewSet, "artists")
router.register(r'albums', views.AlbumViewSet, 'albums') router.register(r"albums", views.AlbumViewSet, "albums")
router.register(r'import-batches', views.ImportBatchViewSet, 'import-batches') router.register(r"import-batches", views.ImportBatchViewSet, "import-batches")
router.register(r'import-jobs', views.ImportJobViewSet, 'import-jobs') router.register(r"import-jobs", views.ImportJobViewSet, "import-jobs")
router.register(r'submit', views.SubmitViewSet, 'submit') router.register(r"submit", views.SubmitViewSet, "submit")
router.register(r'playlists', playlists_views.PlaylistViewSet, 'playlists') router.register(r"playlists", playlists_views.PlaylistViewSet, "playlists")
router.register( router.register(
r'playlist-tracks', r"playlist-tracks", playlists_views.PlaylistTrackViewSet, "playlist-tracks"
playlists_views.PlaylistTrackViewSet, )
'playlist-tracks')
v1_patterns = router.urls v1_patterns = router.urls
subsonic_router = routers.SimpleRouter(trailing_slash=False) subsonic_router = routers.SimpleRouter(trailing_slash=False)
subsonic_router.register(r'subsonic/rest', SubsonicViewSet, base_name='subsonic') subsonic_router.register(r"subsonic/rest", SubsonicViewSet, base_name="subsonic")
v1_patterns += [ v1_patterns += [
url(r'^instance/', url(
r"^instance/",
include(("funkwhale_api.instance.urls", "instance"), namespace="instance"),
),
url(
r"^manage/",
include(("funkwhale_api.manage.urls", "manage"), namespace="manage"),
),
url(
r"^federation/",
include( include(
('funkwhale_api.instance.urls', 'instance'), ("funkwhale_api.federation.api_urls", "federation"), namespace="federation"
namespace='instance')), ),
url(r'^manage/', ),
include( url(
('funkwhale_api.manage.urls', 'manage'), r"^providers/",
namespace='manage')), include(("funkwhale_api.providers.urls", "providers"), namespace="providers"),
url(r'^federation/', ),
include( url(
('funkwhale_api.federation.api_urls', 'federation'), r"^favorites/",
namespace='federation')), include(("funkwhale_api.favorites.urls", "favorites"), namespace="favorites"),
url(r'^providers/', ),
include( url(r"^search$", views.Search.as_view(), name="search"),
('funkwhale_api.providers.urls', 'providers'), url(
namespace='providers')), r"^radios/",
url(r'^favorites/', include(("funkwhale_api.radios.urls", "radios"), namespace="radios"),
include( ),
('funkwhale_api.favorites.urls', 'favorites'), url(
namespace='favorites')), r"^history/",
url(r'^search$', include(("funkwhale_api.history.urls", "history"), namespace="history"),
views.Search.as_view(), name='search'), ),
url(r'^radios/', url(
include( r"^users/",
('funkwhale_api.radios.urls', 'radios'), include(("funkwhale_api.users.api_urls", "users"), namespace="users"),
namespace='radios')), ),
url(r'^history/', url(
include( r"^requests/",
('funkwhale_api.history.urls', 'history'), include(("funkwhale_api.requests.api_urls", "requests"), namespace="requests"),
namespace='history')), ),
url(r'^users/', url(r"^token/$", jwt_views.obtain_jwt_token, name="token"),
include( url(r"^token/refresh/$", jwt_views.refresh_jwt_token, name="token_refresh"),
('funkwhale_api.users.api_urls', 'users'),
namespace='users')),
url(r'^requests/',
include(
('funkwhale_api.requests.api_urls', 'requests'),
namespace='requests')),
url(r'^token/$', jwt_views.obtain_jwt_token, name='token'),
url(r'^token/refresh/$', jwt_views.refresh_jwt_token, name='token_refresh'),
] ]
urlpatterns = [ urlpatterns = [
url(r'^v1/', include((v1_patterns, 'v1'), namespace='v1')) url(r"^v1/", include((v1_patterns, "v1"), namespace="v1"))
] + format_suffix_patterns(subsonic_router.urls, allowed=['view']) ] + format_suffix_patterns(subsonic_router.urls, allowed=["view"])

View File

@ -7,12 +7,13 @@ from funkwhale_api.common.auth import TokenAuthMiddleware
from funkwhale_api.instance import consumers from funkwhale_api.instance import consumers
application = ProtocolTypeRouter({ application = ProtocolTypeRouter(
# Empty for now (http->django views is added by default) {
"websocket": TokenAuthMiddleware( # Empty for now (http->django views is added by default)
URLRouter([ "websocket": TokenAuthMiddleware(
url("^api/v1/instance/activity$", URLRouter(
consumers.InstanceActivityConsumer), [url("^api/v1/instance/activity$", consumers.InstanceActivityConsumer)]
]) )
), )
}) }
)

View File

@ -18,123 +18,117 @@ from celery.schedules import crontab
from funkwhale_api import __version__ from funkwhale_api import __version__
ROOT_DIR = environ.Path(__file__) - 3 # (/a/b/myfile.py - 3 = /) ROOT_DIR = environ.Path(__file__) - 3 # (/a/b/myfile.py - 3 = /)
APPS_DIR = ROOT_DIR.path('funkwhale_api') APPS_DIR = ROOT_DIR.path("funkwhale_api")
env = environ.Env() env = environ.Env()
try: try:
env.read_env(ROOT_DIR.file('.env')) env.read_env(ROOT_DIR.file(".env"))
except FileNotFoundError: except FileNotFoundError:
pass pass
FUNKWHALE_HOSTNAME = None FUNKWHALE_HOSTNAME = None
FUNKWHALE_HOSTNAME_SUFFIX = env('FUNKWHALE_HOSTNAME_SUFFIX', default=None) FUNKWHALE_HOSTNAME_SUFFIX = env("FUNKWHALE_HOSTNAME_SUFFIX", default=None)
FUNKWHALE_HOSTNAME_PREFIX = env('FUNKWHALE_HOSTNAME_PREFIX', default=None) FUNKWHALE_HOSTNAME_PREFIX = env("FUNKWHALE_HOSTNAME_PREFIX", default=None)
if FUNKWHALE_HOSTNAME_PREFIX and FUNKWHALE_HOSTNAME_SUFFIX: if FUNKWHALE_HOSTNAME_PREFIX and FUNKWHALE_HOSTNAME_SUFFIX:
# We're in traefik case, in development # We're in traefik case, in development
FUNKWHALE_HOSTNAME = '{}.{}'.format( FUNKWHALE_HOSTNAME = "{}.{}".format(
FUNKWHALE_HOSTNAME_PREFIX, FUNKWHALE_HOSTNAME_SUFFIX) FUNKWHALE_HOSTNAME_PREFIX, FUNKWHALE_HOSTNAME_SUFFIX
FUNKWHALE_PROTOCOL = env('FUNKWHALE_PROTOCOL', default='https') )
FUNKWHALE_PROTOCOL = env("FUNKWHALE_PROTOCOL", default="https")
else: else:
try: try:
FUNKWHALE_HOSTNAME = env('FUNKWHALE_HOSTNAME') FUNKWHALE_HOSTNAME = env("FUNKWHALE_HOSTNAME")
FUNKWHALE_PROTOCOL = env('FUNKWHALE_PROTOCOL', default='https') FUNKWHALE_PROTOCOL = env("FUNKWHALE_PROTOCOL", default="https")
except Exception: except Exception:
FUNKWHALE_URL = env('FUNKWHALE_URL') FUNKWHALE_URL = env("FUNKWHALE_URL")
_parsed = urlsplit(FUNKWHALE_URL) _parsed = urlsplit(FUNKWHALE_URL)
FUNKWHALE_HOSTNAME = _parsed.netloc FUNKWHALE_HOSTNAME = _parsed.netloc
FUNKWHALE_PROTOCOL = _parsed.scheme FUNKWHALE_PROTOCOL = _parsed.scheme
FUNKWHALE_URL = '{}://{}'.format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME) FUNKWHALE_URL = "{}://{}".format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME)
# XXX: deprecated, see #186 # XXX: deprecated, see #186
FEDERATION_ENABLED = env.bool('FEDERATION_ENABLED', default=True) FEDERATION_ENABLED = env.bool("FEDERATION_ENABLED", default=True)
FEDERATION_HOSTNAME = env('FEDERATION_HOSTNAME', default=FUNKWHALE_HOSTNAME) FEDERATION_HOSTNAME = env("FEDERATION_HOSTNAME", default=FUNKWHALE_HOSTNAME)
# XXX: deprecated, see #186 # XXX: deprecated, see #186
FEDERATION_COLLECTION_PAGE_SIZE = env.int( FEDERATION_COLLECTION_PAGE_SIZE = env.int("FEDERATION_COLLECTION_PAGE_SIZE", default=50)
'FEDERATION_COLLECTION_PAGE_SIZE', default=50
)
# XXX: deprecated, see #186 # XXX: deprecated, see #186
FEDERATION_MUSIC_NEEDS_APPROVAL = env.bool( FEDERATION_MUSIC_NEEDS_APPROVAL = env.bool(
'FEDERATION_MUSIC_NEEDS_APPROVAL', default=True "FEDERATION_MUSIC_NEEDS_APPROVAL", default=True
) )
# XXX: deprecated, see #186 # XXX: deprecated, see #186
FEDERATION_ACTOR_FETCH_DELAY = env.int( FEDERATION_ACTOR_FETCH_DELAY = env.int("FEDERATION_ACTOR_FETCH_DELAY", default=60 * 12)
'FEDERATION_ACTOR_FETCH_DELAY', default=60 * 12) ALLOWED_HOSTS = env.list("DJANGO_ALLOWED_HOSTS")
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
# APP CONFIGURATION # APP CONFIGURATION
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
DJANGO_APPS = ( DJANGO_APPS = (
'channels', "channels",
# Default Django apps: # Default Django apps:
'django.contrib.auth', "django.contrib.auth",
'django.contrib.contenttypes', "django.contrib.contenttypes",
'django.contrib.sessions', "django.contrib.sessions",
'django.contrib.sites', "django.contrib.sites",
'django.contrib.messages', "django.contrib.messages",
'django.contrib.staticfiles', "django.contrib.staticfiles",
'django.contrib.postgres', "django.contrib.postgres",
# Useful template tags: # Useful template tags:
# 'django.contrib.humanize', # 'django.contrib.humanize',
# Admin # Admin
'django.contrib.admin', "django.contrib.admin",
) )
THIRD_PARTY_APPS = ( THIRD_PARTY_APPS = (
# 'crispy_forms', # Form layouts # 'crispy_forms', # Form layouts
'allauth', # registration "allauth", # registration
'allauth.account', # registration "allauth.account", # registration
'allauth.socialaccount', # registration "allauth.socialaccount", # registration
'corsheaders', "corsheaders",
'rest_framework', "rest_framework",
'rest_framework.authtoken', "rest_framework.authtoken",
'taggit', "taggit",
'rest_auth', "rest_auth",
'rest_auth.registration', "rest_auth.registration",
'dynamic_preferences', "dynamic_preferences",
'django_filters', "django_filters",
'cacheops', "cacheops",
'django_cleanup', "django_cleanup",
) )
# Sentry # Sentry
RAVEN_ENABLED = env.bool("RAVEN_ENABLED", default=False) RAVEN_ENABLED = env.bool("RAVEN_ENABLED", default=False)
RAVEN_DSN = env("RAVEN_DSN", default='') RAVEN_DSN = env("RAVEN_DSN", default="")
if RAVEN_ENABLED: if RAVEN_ENABLED:
RAVEN_CONFIG = { RAVEN_CONFIG = {
'dsn': RAVEN_DSN, "dsn": RAVEN_DSN,
# If you are using git, you can also automatically configure the # If you are using git, you can also automatically configure the
# release based on the git info. # release based on the git info.
'release': __version__, "release": __version__,
} }
THIRD_PARTY_APPS += ( THIRD_PARTY_APPS += ("raven.contrib.django.raven_compat",)
'raven.contrib.django.raven_compat',
)
# Apps specific for this project go here. # Apps specific for this project go here.
LOCAL_APPS = ( LOCAL_APPS = (
'funkwhale_api.common', "funkwhale_api.common",
'funkwhale_api.activity.apps.ActivityConfig', "funkwhale_api.activity.apps.ActivityConfig",
'funkwhale_api.users', # custom users app "funkwhale_api.users", # custom users app
# Your stuff: custom apps go here # Your stuff: custom apps go here
'funkwhale_api.instance', "funkwhale_api.instance",
'funkwhale_api.music', "funkwhale_api.music",
'funkwhale_api.requests', "funkwhale_api.requests",
'funkwhale_api.favorites', "funkwhale_api.favorites",
'funkwhale_api.federation', "funkwhale_api.federation",
'funkwhale_api.radios', "funkwhale_api.radios",
'funkwhale_api.history', "funkwhale_api.history",
'funkwhale_api.playlists', "funkwhale_api.playlists",
'funkwhale_api.providers.audiofile', "funkwhale_api.providers.audiofile",
'funkwhale_api.providers.youtube', "funkwhale_api.providers.youtube",
'funkwhale_api.providers.acoustid', "funkwhale_api.providers.acoustid",
'funkwhale_api.subsonic', "funkwhale_api.subsonic",
) )
# See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps # See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
@ -145,20 +139,18 @@ INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
MIDDLEWARE = ( MIDDLEWARE = (
# Make sure djangosecure.middleware.SecurityMiddleware is listed first # Make sure djangosecure.middleware.SecurityMiddleware is listed first
'django.contrib.sessions.middleware.SessionMiddleware', "django.contrib.sessions.middleware.SessionMiddleware",
'corsheaders.middleware.CorsMiddleware', "corsheaders.middleware.CorsMiddleware",
'django.middleware.common.CommonMiddleware', "django.middleware.common.CommonMiddleware",
'django.middleware.csrf.CsrfViewMiddleware', "django.middleware.csrf.CsrfViewMiddleware",
'django.contrib.auth.middleware.AuthenticationMiddleware', "django.contrib.auth.middleware.AuthenticationMiddleware",
'django.contrib.messages.middleware.MessageMiddleware', "django.contrib.messages.middleware.MessageMiddleware",
'django.middleware.clickjacking.XFrameOptionsMiddleware', "django.middleware.clickjacking.XFrameOptionsMiddleware",
) )
# MIGRATIONS CONFIGURATION # MIGRATIONS CONFIGURATION
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
MIGRATION_MODULES = { MIGRATION_MODULES = {"sites": "funkwhale_api.contrib.sites.migrations"}
'sites': 'funkwhale_api.contrib.sites.migrations'
}
# DEBUG # DEBUG
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@ -168,9 +160,7 @@ DEBUG = env.bool("DJANGO_DEBUG", False)
# FIXTURE CONFIGURATION # FIXTURE CONFIGURATION
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# See: https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-FIXTURE_DIRS # See: https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-FIXTURE_DIRS
FIXTURE_DIRS = ( FIXTURE_DIRS = (str(APPS_DIR.path("fixtures")),)
str(APPS_DIR.path('fixtures')),
)
# EMAIL CONFIGURATION # EMAIL CONFIGURATION
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
@ -178,16 +168,14 @@ FIXTURE_DIRS = (
# EMAIL # EMAIL
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
DEFAULT_FROM_EMAIL = env( DEFAULT_FROM_EMAIL = env(
'DEFAULT_FROM_EMAIL', "DEFAULT_FROM_EMAIL", default="Funkwhale <noreply@{}>".format(FUNKWHALE_HOSTNAME)
default='Funkwhale <noreply@{}>'.format(FUNKWHALE_HOSTNAME)) )
EMAIL_SUBJECT_PREFIX = env( EMAIL_SUBJECT_PREFIX = env("EMAIL_SUBJECT_PREFIX", default="[Funkwhale] ")
"EMAIL_SUBJECT_PREFIX", default='[Funkwhale] ') SERVER_EMAIL = env("SERVER_EMAIL", default=DEFAULT_FROM_EMAIL)
SERVER_EMAIL = env('SERVER_EMAIL', default=DEFAULT_FROM_EMAIL)
EMAIL_CONFIG = env.email_url( EMAIL_CONFIG = env.email_url("EMAIL_CONFIG", default="consolemail://")
'EMAIL_CONFIG', default='consolemail://')
vars().update(EMAIL_CONFIG) vars().update(EMAIL_CONFIG)
@ -196,9 +184,9 @@ vars().update(EMAIL_CONFIG)
# See: https://docs.djangoproject.com/en/dev/ref/settings/#databases # See: https://docs.djangoproject.com/en/dev/ref/settings/#databases
DATABASES = { DATABASES = {
# Raises ImproperlyConfigured exception if DATABASE_URL not in os.environ # Raises ImproperlyConfigured exception if DATABASE_URL not in os.environ
'default': env.db("DATABASE_URL"), "default": env.db("DATABASE_URL")
} }
DATABASES['default']['ATOMIC_REQUESTS'] = True DATABASES["default"]["ATOMIC_REQUESTS"] = True
# #
# DATABASES = { # DATABASES = {
# 'default': { # 'default': {
@ -212,10 +200,10 @@ DATABASES['default']['ATOMIC_REQUESTS'] = True
# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
# although not all choices may be available on all operating systems. # although not all choices may be available on all operating systems.
# In a Windows environment this must be set to your system time zone. # In a Windows environment this must be set to your system time zone.
TIME_ZONE = 'UTC' TIME_ZONE = "UTC"
# See: https://docs.djangoproject.com/en/dev/ref/settings/#language-code # See: https://docs.djangoproject.com/en/dev/ref/settings/#language-code
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = "en-us"
# See: https://docs.djangoproject.com/en/dev/ref/settings/#site-id # See: https://docs.djangoproject.com/en/dev/ref/settings/#site-id
SITE_ID = 1 SITE_ID = 1
@ -235,126 +223,120 @@ USE_TZ = True
TEMPLATES = [ TEMPLATES = [
{ {
# See: https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND # See: https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-TEMPLATES-BACKEND
'BACKEND': 'django.template.backends.django.DjangoTemplates', "BACKEND": "django.template.backends.django.DjangoTemplates",
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-dirs # See: https://docs.djangoproject.com/en/dev/ref/settings/#template-dirs
'DIRS': [ "DIRS": [str(APPS_DIR.path("templates"))],
str(APPS_DIR.path('templates')), "OPTIONS": {
],
'OPTIONS': {
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-debug # See: https://docs.djangoproject.com/en/dev/ref/settings/#template-debug
'debug': DEBUG, "debug": DEBUG,
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-loaders # See: https://docs.djangoproject.com/en/dev/ref/settings/#template-loaders
# https://docs.djangoproject.com/en/dev/ref/templates/api/#loader-types # https://docs.djangoproject.com/en/dev/ref/templates/api/#loader-types
'loaders': [ "loaders": [
'django.template.loaders.filesystem.Loader', "django.template.loaders.filesystem.Loader",
'django.template.loaders.app_directories.Loader', "django.template.loaders.app_directories.Loader",
], ],
# See: https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors # See: https://docs.djangoproject.com/en/dev/ref/settings/#template-context-processors
'context_processors': [ "context_processors": [
'django.template.context_processors.debug', "django.template.context_processors.debug",
'django.template.context_processors.request', "django.template.context_processors.request",
'django.contrib.auth.context_processors.auth', "django.contrib.auth.context_processors.auth",
'django.template.context_processors.i18n', "django.template.context_processors.i18n",
'django.template.context_processors.media', "django.template.context_processors.media",
'django.template.context_processors.static', "django.template.context_processors.static",
'django.template.context_processors.tz', "django.template.context_processors.tz",
'django.contrib.messages.context_processors.messages', "django.contrib.messages.context_processors.messages",
# Your stuff: custom template context processors go here # Your stuff: custom template context processors go here
], ],
}, },
}, }
] ]
# See: http://django-crispy-forms.readthedocs.org/en/latest/install.html#template-packs # See: http://django-crispy-forms.readthedocs.org/en/latest/install.html#template-packs
CRISPY_TEMPLATE_PACK = 'bootstrap3' CRISPY_TEMPLATE_PACK = "bootstrap3"
# STATIC FILE CONFIGURATION # STATIC FILE CONFIGURATION
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# See: https://docs.djangoproject.com/en/dev/ref/settings/#static-root # See: https://docs.djangoproject.com/en/dev/ref/settings/#static-root
STATIC_ROOT = env("STATIC_ROOT", default=str(ROOT_DIR('staticfiles'))) STATIC_ROOT = env("STATIC_ROOT", default=str(ROOT_DIR("staticfiles")))
# See: https://docs.djangoproject.com/en/dev/ref/settings/#static-url # See: https://docs.djangoproject.com/en/dev/ref/settings/#static-url
STATIC_URL = env("STATIC_URL", default='/staticfiles/') STATIC_URL = env("STATIC_URL", default="/staticfiles/")
DEFAULT_FILE_STORAGE = 'funkwhale_api.common.storage.ASCIIFileSystemStorage' DEFAULT_FILE_STORAGE = "funkwhale_api.common.storage.ASCIIFileSystemStorage"
# See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS # See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#std:setting-STATICFILES_DIRS
STATICFILES_DIRS = ( STATICFILES_DIRS = (str(APPS_DIR.path("static")),)
str(APPS_DIR.path('static')),
)
# See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders # See: https://docs.djangoproject.com/en/dev/ref/contrib/staticfiles/#staticfiles-finders
STATICFILES_FINDERS = ( STATICFILES_FINDERS = (
'django.contrib.staticfiles.finders.FileSystemFinder', "django.contrib.staticfiles.finders.FileSystemFinder",
'django.contrib.staticfiles.finders.AppDirectoriesFinder', "django.contrib.staticfiles.finders.AppDirectoriesFinder",
) )
# MEDIA CONFIGURATION # MEDIA CONFIGURATION
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# See: https://docs.djangoproject.com/en/dev/ref/settings/#media-root # See: https://docs.djangoproject.com/en/dev/ref/settings/#media-root
MEDIA_ROOT = env("MEDIA_ROOT", default=str(APPS_DIR('media'))) MEDIA_ROOT = env("MEDIA_ROOT", default=str(APPS_DIR("media")))
# See: https://docs.djangoproject.com/en/dev/ref/settings/#media-url # See: https://docs.djangoproject.com/en/dev/ref/settings/#media-url
MEDIA_URL = env("MEDIA_URL", default='/media/') MEDIA_URL = env("MEDIA_URL", default="/media/")
# URL Configuration # URL Configuration
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
ROOT_URLCONF = 'config.urls' ROOT_URLCONF = "config.urls"
# See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application # See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
WSGI_APPLICATION = 'config.wsgi.application' WSGI_APPLICATION = "config.wsgi.application"
ASGI_APPLICATION = "config.routing.application" ASGI_APPLICATION = "config.routing.application"
# This ensures that Django will be able to detect a secure connection # This ensures that Django will be able to detect a secure connection
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# AUTHENTICATION CONFIGURATION # AUTHENTICATION CONFIGURATION
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
AUTHENTICATION_BACKENDS = ( AUTHENTICATION_BACKENDS = (
'django.contrib.auth.backends.ModelBackend', "django.contrib.auth.backends.ModelBackend",
'allauth.account.auth_backends.AuthenticationBackend', "allauth.account.auth_backends.AuthenticationBackend",
) )
SESSION_COOKIE_HTTPONLY = False SESSION_COOKIE_HTTPONLY = False
# Some really nice defaults # Some really nice defaults
ACCOUNT_AUTHENTICATION_METHOD = 'username_email' ACCOUNT_AUTHENTICATION_METHOD = "username_email"
ACCOUNT_EMAIL_REQUIRED = True ACCOUNT_EMAIL_REQUIRED = True
ACCOUNT_EMAIL_VERIFICATION = 'mandatory' ACCOUNT_EMAIL_VERIFICATION = "mandatory"
# Custom user app defaults # Custom user app defaults
# Select the correct user model # Select the correct user model
AUTH_USER_MODEL = 'users.User' AUTH_USER_MODEL = "users.User"
LOGIN_REDIRECT_URL = 'users:redirect' LOGIN_REDIRECT_URL = "users:redirect"
LOGIN_URL = 'account_login' LOGIN_URL = "account_login"
# SLUGLIFIER # SLUGLIFIER
AUTOSLUG_SLUGIFY_FUNCTION = 'slugify.slugify' AUTOSLUG_SLUGIFY_FUNCTION = "slugify.slugify"
CACHE_DEFAULT = "redis://127.0.0.1:6379/0" CACHE_DEFAULT = "redis://127.0.0.1:6379/0"
CACHES = { CACHES = {"default": env.cache_url("CACHE_URL", default=CACHE_DEFAULT)}
"default": env.cache_url('CACHE_URL', default=CACHE_DEFAULT)
}
CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache" CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache"
from urllib.parse import urlparse from urllib.parse import urlparse
cache_url = urlparse(CACHES['default']['LOCATION'])
cache_url = urlparse(CACHES["default"]["LOCATION"])
CHANNEL_LAYERS = { CHANNEL_LAYERS = {
"default": { "default": {
"BACKEND": "channels_redis.core.RedisChannelLayer", "BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": { "CONFIG": {"hosts": [(cache_url.hostname, cache_url.port)]},
"hosts": [(cache_url.hostname, cache_url.port)], }
},
},
} }
CACHES["default"]["OPTIONS"] = { CACHES["default"]["OPTIONS"] = {
"CLIENT_CLASS": "django_redis.client.DefaultClient", "CLIENT_CLASS": "django_redis.client.DefaultClient",
"IGNORE_EXCEPTIONS": True, # mimics memcache behavior. "IGNORE_EXCEPTIONS": True, # mimics memcache behavior.
# http://niwinz.github.io/django-redis/latest/#_memcached_exceptions_behavior # http://niwinz.github.io/django-redis/latest/#_memcached_exceptions_behavior
} }
########## CELERY ########## CELERY
INSTALLED_APPS += ('funkwhale_api.taskapp.celery.CeleryConfig',) INSTALLED_APPS += ("funkwhale_api.taskapp.celery.CeleryConfig",)
CELERY_BROKER_URL = env( CELERY_BROKER_URL = env(
"CELERY_BROKER_URL", default=env('CACHE_URL', default=CACHE_DEFAULT)) "CELERY_BROKER_URL", default=env("CACHE_URL", default=CACHE_DEFAULT)
)
########## END CELERY ########## END CELERY
# Location of root django.contrib.admin URL, use {% url 'admin:index' %} # Location of root django.contrib.admin URL, use {% url 'admin:index' %}
@ -362,25 +344,24 @@ CELERY_BROKER_URL = env(
CELERY_TASK_DEFAULT_RATE_LIMIT = 1 CELERY_TASK_DEFAULT_RATE_LIMIT = 1
CELERY_TASK_TIME_LIMIT = 300 CELERY_TASK_TIME_LIMIT = 300
CELERYBEAT_SCHEDULE = { CELERYBEAT_SCHEDULE = {
'federation.clean_music_cache': { "federation.clean_music_cache": {
'task': 'funkwhale_api.federation.tasks.clean_music_cache', "task": "funkwhale_api.federation.tasks.clean_music_cache",
'schedule': crontab(hour='*/2'), "schedule": crontab(hour="*/2"),
'options': { "options": {"expires": 60 * 2},
'expires': 60 * 2,
},
} }
} }
import datetime import datetime
JWT_AUTH = { JWT_AUTH = {
'JWT_ALLOW_REFRESH': True, "JWT_ALLOW_REFRESH": True,
'JWT_EXPIRATION_DELTA': datetime.timedelta(days=7), "JWT_EXPIRATION_DELTA": datetime.timedelta(days=7),
'JWT_REFRESH_EXPIRATION_DELTA': datetime.timedelta(days=30), "JWT_REFRESH_EXPIRATION_DELTA": datetime.timedelta(days=30),
'JWT_AUTH_HEADER_PREFIX': 'JWT', "JWT_AUTH_HEADER_PREFIX": "JWT",
'JWT_GET_USER_SECRET_KEY': lambda user: user.secret_key "JWT_GET_USER_SECRET_KEY": lambda user: user.secret_key,
} }
OLD_PASSWORD_FIELD_ENABLED = True OLD_PASSWORD_FIELD_ENABLED = True
ACCOUNT_ADAPTER = 'funkwhale_api.users.adapters.FunkwhaleAccountAdapter' ACCOUNT_ADAPTER = "funkwhale_api.users.adapters.FunkwhaleAccountAdapter"
CORS_ORIGIN_ALLOW_ALL = True CORS_ORIGIN_ALLOW_ALL = True
# CORS_ORIGIN_WHITELIST = ( # CORS_ORIGIN_WHITELIST = (
# 'localhost', # 'localhost',
@ -389,41 +370,37 @@ CORS_ORIGIN_ALLOW_ALL = True
CORS_ALLOW_CREDENTIALS = True CORS_ALLOW_CREDENTIALS = True
REST_FRAMEWORK = { REST_FRAMEWORK = {
'DEFAULT_PERMISSION_CLASSES': ( "DEFAULT_PERMISSION_CLASSES": ("rest_framework.permissions.IsAuthenticated",),
'rest_framework.permissions.IsAuthenticated', "DEFAULT_PAGINATION_CLASS": "funkwhale_api.common.pagination.FunkwhalePagination",
"PAGE_SIZE": 25,
"DEFAULT_PARSER_CLASSES": (
"rest_framework.parsers.JSONParser",
"rest_framework.parsers.FormParser",
"rest_framework.parsers.MultiPartParser",
"funkwhale_api.federation.parsers.ActivityParser",
), ),
'DEFAULT_PAGINATION_CLASS': 'funkwhale_api.common.pagination.FunkwhalePagination', "DEFAULT_AUTHENTICATION_CLASSES": (
'PAGE_SIZE': 25, "funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS",
'DEFAULT_PARSER_CLASSES': ( "funkwhale_api.common.authentication.BearerTokenHeaderAuth",
'rest_framework.parsers.JSONParser', "rest_framework_jwt.authentication.JSONWebTokenAuthentication",
'rest_framework.parsers.FormParser', "rest_framework.authentication.SessionAuthentication",
'rest_framework.parsers.MultiPartParser', "rest_framework.authentication.BasicAuthentication",
'funkwhale_api.federation.parsers.ActivityParser',
), ),
'DEFAULT_AUTHENTICATION_CLASSES': ( "DEFAULT_FILTER_BACKENDS": (
'funkwhale_api.common.authentication.JSONWebTokenAuthenticationQS', "rest_framework.filters.OrderingFilter",
'funkwhale_api.common.authentication.BearerTokenHeaderAuth', "django_filters.rest_framework.DjangoFilterBackend",
'rest_framework_jwt.authentication.JSONWebTokenAuthentication',
'rest_framework.authentication.SessionAuthentication',
'rest_framework.authentication.BasicAuthentication',
), ),
'DEFAULT_FILTER_BACKENDS': ( "DEFAULT_RENDERER_CLASSES": ("rest_framework.renderers.JSONRenderer",),
'rest_framework.filters.OrderingFilter',
'django_filters.rest_framework.DjangoFilterBackend',
),
'DEFAULT_RENDERER_CLASSES': (
'rest_framework.renderers.JSONRenderer',
)
} }
BROWSABLE_API_ENABLED = env.bool('BROWSABLE_API_ENABLED', default=False) BROWSABLE_API_ENABLED = env.bool("BROWSABLE_API_ENABLED", default=False)
if BROWSABLE_API_ENABLED: if BROWSABLE_API_ENABLED:
REST_FRAMEWORK['DEFAULT_RENDERER_CLASSES'] += ( REST_FRAMEWORK["DEFAULT_RENDERER_CLASSES"] += (
'rest_framework.renderers.BrowsableAPIRenderer', "rest_framework.renderers.BrowsableAPIRenderer",
) )
REST_AUTH_SERIALIZERS = { REST_AUTH_SERIALIZERS = {
'PASSWORD_RESET_SERIALIZER': 'funkwhale_api.users.serializers.PasswordResetSerializer' # noqa "PASSWORD_RESET_SERIALIZER": "funkwhale_api.users.serializers.PasswordResetSerializer" # noqa
} }
REST_SESSION_LOGIN = False REST_SESSION_LOGIN = False
REST_USE_JWT = True REST_USE_JWT = True
@ -434,60 +411,55 @@ USE_X_FORWARDED_PORT = True
# Wether we should use Apache, Nginx (or other) headers when serving audio files # Wether we should use Apache, Nginx (or other) headers when serving audio files
# Default to Nginx # Default to Nginx
REVERSE_PROXY_TYPE = env('REVERSE_PROXY_TYPE', default='nginx') REVERSE_PROXY_TYPE = env("REVERSE_PROXY_TYPE", default="nginx")
assert REVERSE_PROXY_TYPE in ['apache2', 'nginx'], 'Unsupported REVERSE_PROXY_TYPE' assert REVERSE_PROXY_TYPE in ["apache2", "nginx"], "Unsupported REVERSE_PROXY_TYPE"
# Which path will be used to process the internal redirection # Which path will be used to process the internal redirection
# **DO NOT** put a slash at the end # **DO NOT** put a slash at the end
PROTECT_FILES_PATH = env('PROTECT_FILES_PATH', default='/_protected') PROTECT_FILES_PATH = env("PROTECT_FILES_PATH", default="/_protected")
# use this setting to tweak for how long you want to cache # use this setting to tweak for how long you want to cache
# musicbrainz results. (value is in seconds) # musicbrainz results. (value is in seconds)
MUSICBRAINZ_CACHE_DURATION = env.int( MUSICBRAINZ_CACHE_DURATION = env.int("MUSICBRAINZ_CACHE_DURATION", default=300)
'MUSICBRAINZ_CACHE_DURATION', CACHEOPS_REDIS = env("CACHE_URL", default=CACHE_DEFAULT)
default=300 CACHEOPS_ENABLED = env.bool("CACHEOPS_ENABLED", default=True)
)
CACHEOPS_REDIS = env('CACHE_URL', default=CACHE_DEFAULT)
CACHEOPS_ENABLED = env.bool('CACHEOPS_ENABLED', default=True)
CACHEOPS = { CACHEOPS = {
'music.artist': {'ops': 'all', 'timeout': 60 * 60}, "music.artist": {"ops": "all", "timeout": 60 * 60},
'music.album': {'ops': 'all', 'timeout': 60 * 60}, "music.album": {"ops": "all", "timeout": 60 * 60},
'music.track': {'ops': 'all', 'timeout': 60 * 60}, "music.track": {"ops": "all", "timeout": 60 * 60},
'music.trackfile': {'ops': 'all', 'timeout': 60 * 60}, "music.trackfile": {"ops": "all", "timeout": 60 * 60},
'taggit.tag': {'ops': 'all', 'timeout': 60 * 60}, "taggit.tag": {"ops": "all", "timeout": 60 * 60},
} }
# Custom Admin URL, use {% url 'admin:index' %} # Custom Admin URL, use {% url 'admin:index' %}
ADMIN_URL = env('DJANGO_ADMIN_URL', default='^api/admin/') ADMIN_URL = env("DJANGO_ADMIN_URL", default="^api/admin/")
CSRF_USE_SESSIONS = True CSRF_USE_SESSIONS = True
# Playlist settings # Playlist settings
# XXX: deprecated, see #186 # XXX: deprecated, see #186
PLAYLISTS_MAX_TRACKS = env.int('PLAYLISTS_MAX_TRACKS', default=250) PLAYLISTS_MAX_TRACKS = env.int("PLAYLISTS_MAX_TRACKS", default=250)
ACCOUNT_USERNAME_BLACKLIST = [ ACCOUNT_USERNAME_BLACKLIST = [
'funkwhale', "funkwhale",
'library', "library",
'test', "test",
'status', "status",
'root', "root",
'admin', "admin",
'owner', "owner",
'superuser', "superuser",
'staff', "staff",
'service', "service",
] + env.list('ACCOUNT_USERNAME_BLACKLIST', default=[]) ] + env.list("ACCOUNT_USERNAME_BLACKLIST", default=[])
EXTERNAL_REQUESTS_VERIFY_SSL = env.bool( EXTERNAL_REQUESTS_VERIFY_SSL = env.bool("EXTERNAL_REQUESTS_VERIFY_SSL", default=True)
'EXTERNAL_REQUESTS_VERIFY_SSL',
default=True
)
# XXX: deprecated, see #186 # XXX: deprecated, see #186
API_AUTHENTICATION_REQUIRED = env.bool("API_AUTHENTICATION_REQUIRED", True) API_AUTHENTICATION_REQUIRED = env.bool("API_AUTHENTICATION_REQUIRED", True)
MUSIC_DIRECTORY_PATH = env('MUSIC_DIRECTORY_PATH', default=None) MUSIC_DIRECTORY_PATH = env("MUSIC_DIRECTORY_PATH", default=None)
# on Docker setup, the music directory may not match the host path, # on Docker setup, the music directory may not match the host path,
# and we need to know it for it to serve stuff properly # and we need to know it for it to serve stuff properly
MUSIC_DIRECTORY_SERVE_PATH = env( MUSIC_DIRECTORY_SERVE_PATH = env(
'MUSIC_DIRECTORY_SERVE_PATH', default=MUSIC_DIRECTORY_PATH) "MUSIC_DIRECTORY_SERVE_PATH", default=MUSIC_DIRECTORY_PATH
)

View File

@ -1,53 +1,53 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
''' """
Local settings Local settings
- Run in Debug mode - Run in Debug mode
- Use console backend for emails - Use console backend for emails
- Add Django Debug Toolbar - Add Django Debug Toolbar
- Add django-extensions as app - Add django-extensions as app
''' """
from .common import * # noqa from .common import * # noqa
# DEBUG # DEBUG
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
DEBUG = env.bool('DJANGO_DEBUG', default=True) DEBUG = env.bool("DJANGO_DEBUG", default=True)
TEMPLATES[0]['OPTIONS']['debug'] = DEBUG TEMPLATES[0]["OPTIONS"]["debug"] = DEBUG
# SECRET CONFIGURATION # SECRET CONFIGURATION
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# See: https://docs.djangoproject.com/en/dev/ref/settings/#secret-key # See: https://docs.djangoproject.com/en/dev/ref/settings/#secret-key
# Note: This key only used for development and testing. # 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') SECRET_KEY = env(
"DJANGO_SECRET_KEY", default="mc$&b=5j#6^bv7tld1gyjp2&+^-qrdy=0sw@r5sua*1zp4fmxc"
)
# Mail settings # Mail settings
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
EMAIL_HOST = 'localhost' EMAIL_HOST = "localhost"
EMAIL_PORT = 1025 EMAIL_PORT = 1025
# django-debug-toolbar # django-debug-toolbar
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
MIDDLEWARE += ('debug_toolbar.middleware.DebugToolbarMiddleware',) MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",)
# INTERNAL_IPS = ('127.0.0.1', '10.0.2.2',) # INTERNAL_IPS = ('127.0.0.1', '10.0.2.2',)
DEBUG_TOOLBAR_CONFIG = { DEBUG_TOOLBAR_CONFIG = {
'DISABLE_PANELS': [ "DISABLE_PANELS": ["debug_toolbar.panels.redirects.RedirectsPanel"],
'debug_toolbar.panels.redirects.RedirectsPanel', "SHOW_TEMPLATE_CONTEXT": True,
], "SHOW_TOOLBAR_CALLBACK": lambda request: True,
'SHOW_TEMPLATE_CONTEXT': True,
'SHOW_TOOLBAR_CALLBACK': lambda request: True,
} }
# django-extensions # django-extensions
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# INSTALLED_APPS += ('django_extensions', ) # INSTALLED_APPS += ('django_extensions', )
INSTALLED_APPS += ('debug_toolbar', ) INSTALLED_APPS += ("debug_toolbar",)
# TESTING # TESTING
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
TEST_RUNNER = 'django.test.runner.DiscoverRunner' TEST_RUNNER = "django.test.runner.DiscoverRunner"
########## CELERY ########## CELERY
# In development, all tasks will be executed locally by blocking until the task returns # In development, all tasks will be executed locally by blocking until the task returns
@ -57,23 +57,15 @@ CELERY_TASK_ALWAYS_EAGER = False
# Your local stuff: Below this line define 3rd party library settings # Your local stuff: Below this line define 3rd party library settings
LOGGING = { LOGGING = {
'version': 1, "version": 1,
'handlers': { "handlers": {"console": {"level": "DEBUG", "class": "logging.StreamHandler"}},
'console':{ "loggers": {
'level':'DEBUG', "django.request": {
'class':'logging.StreamHandler', "handlers": ["console"],
}, "propagate": True,
}, "level": "DEBUG",
'loggers': {
'django.request': {
'handlers':['console'],
'propagate': True,
'level':'DEBUG',
},
'': {
'level': 'DEBUG',
'handlers': ['console'],
}, },
"": {"level": "DEBUG", "handlers": ["console"]},
}, },
} }
CSRF_TRUSTED_ORIGINS = [o for o in ALLOWED_HOSTS] CSRF_TRUSTED_ORIGINS = [o for o in ALLOWED_HOSTS]

View File

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
''' """
Production Configurations Production Configurations
- Use djangosecure - Use djangosecure
@ -8,7 +8,7 @@ Production Configurations
- Use Redis on Heroku - Use Redis on Heroku
''' """
from __future__ import absolute_import, unicode_literals from __future__ import absolute_import, unicode_literals
from django.utils import six from django.utils import six
@ -58,19 +58,24 @@ CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# Uploaded Media Files # Uploaded Media Files
# ------------------------ # ------------------------
DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' DEFAULT_FILE_STORAGE = "django.core.files.storage.FileSystemStorage"
# Static Assets # Static Assets
# ------------------------ # ------------------------
STATICFILES_STORAGE = 'django.contrib.staticfiles.storage.StaticFilesStorage' STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
# TEMPLATE CONFIGURATION # TEMPLATE CONFIGURATION
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# See: # See:
# https://docs.djangoproject.com/en/dev/ref/templates/api/#django.template.loaders.cached.Loader # https://docs.djangoproject.com/en/dev/ref/templates/api/#django.template.loaders.cached.Loader
TEMPLATES[0]['OPTIONS']['loaders'] = [ TEMPLATES[0]["OPTIONS"]["loaders"] = [
('django.template.loaders.cached.Loader', [ (
'django.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader', ]), "django.template.loaders.cached.Loader",
[
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
],
)
] ]
# CACHING # CACHING
@ -78,7 +83,6 @@ TEMPLATES[0]['OPTIONS']['loaders'] = [
# Heroku URL does not pass the DB number, so we parse it in # Heroku URL does not pass the DB number, so we parse it in
# LOGGING CONFIGURATION # LOGGING CONFIGURATION
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
# See: https://docs.djangoproject.com/en/dev/ref/settings/#logging # See: https://docs.djangoproject.com/en/dev/ref/settings/#logging
@ -88,43 +92,39 @@ TEMPLATES[0]['OPTIONS']['loaders'] = [
# See http://docs.djangoproject.com/en/dev/topics/logging for # See http://docs.djangoproject.com/en/dev/topics/logging for
# more details on how to customize your logging configuration. # more details on how to customize your logging configuration.
LOGGING = { LOGGING = {
'version': 1, "version": 1,
'disable_existing_loggers': False, "disable_existing_loggers": False,
'filters': { "filters": {"require_debug_false": {"()": "django.utils.log.RequireDebugFalse"}},
'require_debug_false': { "formatters": {
'()': 'django.utils.log.RequireDebugFalse' "verbose": {
"format": "%(levelname)s %(asctime)s %(module)s "
"%(process)d %(thread)d %(message)s"
} }
}, },
'formatters': { "handlers": {
'verbose': { "mail_admins": {
'format': '%(levelname)s %(asctime)s %(module)s ' "level": "ERROR",
'%(process)d %(thread)d %(message)s' "filters": ["require_debug_false"],
"class": "django.utils.log.AdminEmailHandler",
},
"console": {
"level": "DEBUG",
"class": "logging.StreamHandler",
"formatter": "verbose",
}, },
}, },
'handlers': { "loggers": {
'mail_admins': { "django.request": {
'level': 'ERROR', "handlers": ["mail_admins"],
'filters': ['require_debug_false'], "level": "ERROR",
'class': 'django.utils.log.AdminEmailHandler' "propagate": True,
}, },
'console': { "django.security.DisallowedHost": {
'level': 'DEBUG', "level": "ERROR",
'class': 'logging.StreamHandler', "handlers": ["console", "mail_admins"],
'formatter': 'verbose', "propagate": True,
}, },
}, },
'loggers': {
'django.request': {
'handlers': ['mail_admins'],
'level': 'ERROR',
'propagate': True
},
'django.security.DisallowedHost': {
'level': 'ERROR',
'handlers': ['console', 'mail_admins'],
'propagate': True
}
}
} }

View File

@ -11,32 +11,30 @@ from django.views import defaults as default_views
urlpatterns = [ urlpatterns = [
# Django Admin, use {% url 'admin:index' %} # Django Admin, use {% url 'admin:index' %}
url(settings.ADMIN_URL, admin.site.urls), url(settings.ADMIN_URL, admin.site.urls),
url(r"^api/", include(("config.api_urls", "api"), namespace="api")),
url(r'^api/', include(("config.api_urls", 'api'), namespace="api")), url(
url(r'^', include( r"^",
('funkwhale_api.federation.urls', 'federation'), include(
namespace="federation")), ("funkwhale_api.federation.urls", "federation"), namespace="federation"
url(r'^api/v1/auth/', include('rest_auth.urls')), ),
url(r'^api/v1/auth/registration/', include('funkwhale_api.users.rest_auth_urls')), ),
url(r'^accounts/', include('allauth.urls')), url(r"^api/v1/auth/", include("rest_auth.urls")),
url(r"^api/v1/auth/registration/", include("funkwhale_api.users.rest_auth_urls")),
url(r"^accounts/", include("allauth.urls")),
# Your stuff: custom urls includes go here # Your stuff: custom urls includes go here
] ]
if settings.DEBUG: if settings.DEBUG:
# This allows the error pages to be debugged during development, just visit # This allows the error pages to be debugged during development, just visit
# these url in browser to see how these error pages look like. # these url in browser to see how these error pages look like.
urlpatterns += [ urlpatterns += [
url(r'^400/$', default_views.bad_request), url(r"^400/$", default_views.bad_request),
url(r'^403/$', default_views.permission_denied), url(r"^403/$", default_views.permission_denied),
url(r'^404/$', default_views.page_not_found), url(r"^404/$", default_views.page_not_found),
url(r'^500/$', default_views.server_error), url(r"^500/$", default_views.server_error),
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
if 'debug_toolbar' in settings.INSTALLED_APPS: if "debug_toolbar" in settings.INSTALLED_APPS:
import debug_toolbar import debug_toolbar
urlpatterns += [
url(r'^__debug__/', include(debug_toolbar.urls)), urlpatterns += [url(r"^__debug__/", include(debug_toolbar.urls))]
]

View File

@ -1,7 +1,7 @@
from funkwhale_api.users.models import User from funkwhale_api.users.models import User
u = User.objects.create(email='demo@demo.com', username='demo', is_staff=True) u = User.objects.create(email="demo@demo.com", username="demo", is_staff=True)
u.set_password('demo') u.set_password("demo")
u.subsonic_api_token = 'demo' u.subsonic_api_token = "demo"
u.save() u.save()

View File

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

View File

@ -2,8 +2,9 @@ from django.apps import AppConfig, apps
from . import record from . import record
class ActivityConfig(AppConfig): class ActivityConfig(AppConfig):
name = 'funkwhale_api.activity' name = "funkwhale_api.activity"
def ready(self): def ready(self):
super(ActivityConfig, self).ready() super(ActivityConfig, self).ready()

View File

@ -2,37 +2,36 @@ import persisting_theory
class ActivityRegistry(persisting_theory.Registry): class ActivityRegistry(persisting_theory.Registry):
look_into = 'activities' look_into = "activities"
def _register_for_model(self, model, attr, value): def _register_for_model(self, model, attr, value):
key = model._meta.label key = model._meta.label
d = self.setdefault(key, {'consumers': []}) d = self.setdefault(key, {"consumers": []})
d[attr] = value d[attr] = value
def register_serializer(self, serializer_class): def register_serializer(self, serializer_class):
model = serializer_class.Meta.model model = serializer_class.Meta.model
self._register_for_model(model, 'serializer', serializer_class) self._register_for_model(model, "serializer", serializer_class)
return serializer_class return serializer_class
def register_consumer(self, label): def register_consumer(self, label):
def decorator(func): def decorator(func):
consumers = self[label]['consumers'] consumers = self[label]["consumers"]
if func not in consumers: if func not in consumers:
consumers.append(func) consumers.append(func)
return func return func
return decorator return decorator
registry = ActivityRegistry() registry = ActivityRegistry()
def send(obj): def send(obj):
conf = registry[obj.__class__._meta.label] conf = registry[obj.__class__._meta.label]
consumers = conf['consumers'] consumers = conf["consumers"]
if not consumers: if not consumers:
return return
serializer = conf['serializer'](obj) serializer = conf["serializer"](obj)
for consumer in consumers: for consumer in consumers:
consumer(data=serializer.data, obj=obj) consumer(data=serializer.data, obj=obj)

View File

@ -4,8 +4,8 @@ from funkwhale_api.activity import record
class ModelSerializer(serializers.ModelSerializer): class ModelSerializer(serializers.ModelSerializer):
id = serializers.CharField(source='get_activity_url') id = serializers.CharField(source="get_activity_url")
local_id = serializers.IntegerField(source='id') local_id = serializers.IntegerField(source="id")
# url = serializers.SerializerMethodField() # url = serializers.SerializerMethodField()
def get_url(self, obj): def get_url(self, obj):
@ -17,8 +17,7 @@ class AutoSerializer(serializers.Serializer):
A serializer that will automatically use registered activity serializers A serializer that will automatically use registered activity serializers
to serialize an henerogeneous list of objects (favorites, listenings, etc.) to serialize an henerogeneous list of objects (favorites, listenings, etc.)
""" """
def to_representation(self, instance): def to_representation(self, instance):
serializer = record.registry[instance._meta.label]['serializer']( serializer = record.registry[instance._meta.label]["serializer"](instance)
instance
)
return serializer.data return serializer.data

View File

@ -6,31 +6,25 @@ from funkwhale_api.history.models import Listening
def combined_recent(limit, **kwargs): def combined_recent(limit, **kwargs):
datetime_field = kwargs.pop('datetime_field', 'creation_date') datetime_field = kwargs.pop("datetime_field", "creation_date")
source_querysets = { source_querysets = {qs.model._meta.label: qs for qs in kwargs.pop("querysets")}
qs.model._meta.label: qs for qs in kwargs.pop('querysets')
}
querysets = { querysets = {
k: qs.annotate( k: qs.annotate(
__type=models.Value( __type=models.Value(qs.model._meta.label, output_field=models.CharField())
qs.model._meta.label, output_field=models.CharField() ).values("pk", datetime_field, "__type")
)
).values('pk', datetime_field, '__type')
for k, qs in source_querysets.items() for k, qs in source_querysets.items()
} }
_qs_list = list(querysets.values()) _qs_list = list(querysets.values())
union_qs = _qs_list[0].union(*_qs_list[1:]) union_qs = _qs_list[0].union(*_qs_list[1:])
records = [] records = []
for row in union_qs.order_by('-{}'.format(datetime_field))[:limit]: for row in union_qs.order_by("-{}".format(datetime_field))[:limit]:
records.append({ records.append(
'type': row['__type'], {"type": row["__type"], "when": row[datetime_field], "pk": row["pk"]}
'when': row[datetime_field], )
'pk': row['pk']
})
# Now we bulk-load each object type in turn # Now we bulk-load each object type in turn
to_load = {} to_load = {}
for record in records: for record in records:
to_load.setdefault(record['type'], []).append(record['pk']) to_load.setdefault(record["type"], []).append(record["pk"])
fetched = {} fetched = {}
for key, pks in to_load.items(): for key, pks in to_load.items():
@ -39,26 +33,19 @@ def combined_recent(limit, **kwargs):
# Annotate 'records' with loaded objects # Annotate 'records' with loaded objects
for record in records: for record in records:
record['object'] = fetched[(record['type'], record['pk'])] record["object"] = fetched[(record["type"], record["pk"])]
return records return records
def get_activity(user, limit=20): def get_activity(user, limit=20):
query = fields.privacy_level_query( query = fields.privacy_level_query(user, lookup_field="user__privacy_level")
user, lookup_field='user__privacy_level')
querysets = [ querysets = [
Listening.objects.filter(query).select_related( Listening.objects.filter(query).select_related(
'track', "track", "user", "track__artist", "track__album__artist"
'user',
'track__artist',
'track__album__artist',
), ),
TrackFavorite.objects.filter(query).select_related( TrackFavorite.objects.filter(query).select_related(
'track', "track", "user", "track__artist", "track__album__artist"
'user',
'track__artist',
'track__album__artist',
), ),
] ]
records = combined_recent(limit=limit, querysets=querysets) records = combined_recent(limit=limit, querysets=querysets)
return [r['object'] for r in records] return [r["object"] for r in records]

View File

@ -17,4 +17,4 @@ class ActivityViewSet(viewsets.GenericViewSet):
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
activity = utils.get_activity(user=request.user) activity = utils.get_activity(user=request.user)
serializer = self.serializer_class(activity, many=True) serializer = self.serializer_class(activity, many=True)
return Response({'results': serializer.data}, status=200) return Response({"results": serializer.data}, status=200)

View File

@ -16,20 +16,19 @@ class TokenHeaderAuth(BaseJSONWebTokenAuthentication):
def get_jwt_value(self, request): def get_jwt_value(self, request):
try: try:
qs = request.get('query_string', b'').decode('utf-8') qs = request.get("query_string", b"").decode("utf-8")
parsed = parse_qs(qs) parsed = parse_qs(qs)
token = parsed['token'][0] token = parsed["token"][0]
except KeyError: except KeyError:
raise exceptions.AuthenticationFailed('No token') raise exceptions.AuthenticationFailed("No token")
if not token: if not token:
raise exceptions.AuthenticationFailed('Empty token') raise exceptions.AuthenticationFailed("Empty token")
return token return token
class TokenAuthMiddleware: class TokenAuthMiddleware:
def __init__(self, inner): def __init__(self, inner):
# Store the ASGI application we were passed # Store the ASGI application we were passed
self.inner = inner self.inner = inner
@ -41,5 +40,5 @@ class TokenAuthMiddleware:
except (User.DoesNotExist, exceptions.AuthenticationFailed): except (User.DoesNotExist, exceptions.AuthenticationFailed):
user = AnonymousUser() user = AnonymousUser()
scope['user'] = user scope["user"] = user
return self.inner(scope) return self.inner(scope)

View File

@ -6,34 +6,34 @@ from rest_framework_jwt import authentication
from rest_framework_jwt.settings import api_settings from rest_framework_jwt.settings import api_settings
class JSONWebTokenAuthenticationQS( class JSONWebTokenAuthenticationQS(authentication.BaseJSONWebTokenAuthentication):
authentication.BaseJSONWebTokenAuthentication):
www_authenticate_realm = 'api' www_authenticate_realm = "api"
def get_jwt_value(self, request): def get_jwt_value(self, request):
token = request.query_params.get('jwt') token = request.query_params.get("jwt")
if 'jwt' in request.query_params and not token: if "jwt" in request.query_params and not token:
msg = _('Invalid Authorization header. No credentials provided.') msg = _("Invalid Authorization header. No credentials provided.")
raise exceptions.AuthenticationFailed(msg) raise exceptions.AuthenticationFailed(msg)
return token return token
def authenticate_header(self, request): def authenticate_header(self, request):
return '{0} realm="{1}"'.format( return '{0} realm="{1}"'.format(
api_settings.JWT_AUTH_HEADER_PREFIX, self.www_authenticate_realm) api_settings.JWT_AUTH_HEADER_PREFIX, self.www_authenticate_realm
)
class BearerTokenHeaderAuth( class BearerTokenHeaderAuth(authentication.BaseJSONWebTokenAuthentication):
authentication.BaseJSONWebTokenAuthentication):
""" """
For backward compatibility purpose, we used Authorization: JWT <token> For backward compatibility purpose, we used Authorization: JWT <token>
but Authorization: Bearer <token> is probably better. but Authorization: Bearer <token> is probably better.
""" """
www_authenticate_realm = 'api'
www_authenticate_realm = "api"
def get_jwt_value(self, request): def get_jwt_value(self, request):
auth = authentication.get_authorization_header(request).split() auth = authentication.get_authorization_header(request).split()
auth_header_prefix = 'bearer' auth_header_prefix = "bearer"
if not auth: if not auth:
if api_settings.JWT_AUTH_COOKIE: if api_settings.JWT_AUTH_COOKIE:
@ -44,14 +44,16 @@ class BearerTokenHeaderAuth(
return None return None
if len(auth) == 1: if len(auth) == 1:
msg = _('Invalid Authorization header. No credentials provided.') msg = _("Invalid Authorization header. No credentials provided.")
raise exceptions.AuthenticationFailed(msg) raise exceptions.AuthenticationFailed(msg)
elif len(auth) > 2: elif len(auth) > 2:
msg = _('Invalid Authorization header. Credentials string ' msg = _(
'should not contain spaces.') "Invalid Authorization header. Credentials string "
"should not contain spaces."
)
raise exceptions.AuthenticationFailed(msg) raise exceptions.AuthenticationFailed(msg)
return auth[1] return auth[1]
def authenticate_header(self, request): def authenticate_header(self, request):
return '{0} realm="{1}"'.format('Bearer', self.www_authenticate_realm) return '{0} realm="{1}"'.format("Bearer", self.www_authenticate_realm)

View File

@ -5,7 +5,7 @@ from funkwhale_api.common import channels
class JsonAuthConsumer(JsonWebsocketConsumer): class JsonAuthConsumer(JsonWebsocketConsumer):
def connect(self): def connect(self):
try: try:
assert self.scope['user'].pk is not None assert self.scope["user"].pk is not None
except (AssertionError, AttributeError, KeyError): except (AssertionError, AttributeError, KeyError):
return self.close() return self.close()

View File

@ -3,18 +3,19 @@ from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
common = types.Section('common') common = types.Section("common")
@global_preferences_registry.register @global_preferences_registry.register
class APIAutenticationRequired( class APIAutenticationRequired(
preferences.DefaultFromSettingMixin, types.BooleanPreference): preferences.DefaultFromSettingMixin, types.BooleanPreference
):
section = common section = common
name = 'api_authentication_required' name = "api_authentication_required"
verbose_name = 'API Requires authentication' verbose_name = "API Requires authentication"
setting = 'API_AUTHENTICATION_REQUIRED' setting = "API_AUTHENTICATION_REQUIRED"
help_text = ( help_text = (
'If disabled, anonymous users will be able to query the API' "If disabled, anonymous users will be able to query the API"
'and access music data (as well as other data exposed in the API ' "and access music data (as well as other data exposed in the API "
'without specific permissions).' "without specific permissions)."
) )

View File

@ -6,34 +6,31 @@ from funkwhale_api.music import utils
PRIVACY_LEVEL_CHOICES = [ PRIVACY_LEVEL_CHOICES = [
('me', 'Only me'), ("me", "Only me"),
('followers', 'Me and my followers'), ("followers", "Me and my followers"),
('instance', 'Everyone on my instance, and my followers'), ("instance", "Everyone on my instance, and my followers"),
('everyone', 'Everyone, including people on other instances'), ("everyone", "Everyone, including people on other instances"),
] ]
def get_privacy_field(): def get_privacy_field():
return models.CharField( return models.CharField(
max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance') max_length=30, choices=PRIVACY_LEVEL_CHOICES, default="instance"
)
def privacy_level_query(user, lookup_field='privacy_level'): def privacy_level_query(user, lookup_field="privacy_level"):
if user.is_anonymous: if user.is_anonymous:
return models.Q(**{ return models.Q(**{lookup_field: "everyone"})
lookup_field: 'everyone',
})
return models.Q(**{ return models.Q(
'{}__in'.format(lookup_field): [ **{"{}__in".format(lookup_field): ["followers", "instance", "everyone"]}
'followers', 'instance', 'everyone' )
]
})
class SearchFilter(django_filters.CharFilter): class SearchFilter(django_filters.CharFilter):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.search_fields = kwargs.pop('search_fields') self.search_fields = kwargs.pop("search_fields")
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def filter(self, qs, value): def filter(self, qs, value):

View File

@ -4,17 +4,20 @@ from funkwhale_api.common import scripts
class Command(BaseCommand): class Command(BaseCommand):
help = 'Run a specific script from funkwhale_api/common/scripts/' help = "Run a specific script from funkwhale_api/common/scripts/"
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument('script_name', nargs='?', type=str) parser.add_argument("script_name", nargs="?", type=str)
parser.add_argument( parser.add_argument(
'--noinput', '--no-input', action='store_false', dest='interactive', "--noinput",
"--no-input",
action="store_false",
dest="interactive",
help="Do NOT prompt the user for input of any kind.", help="Do NOT prompt the user for input of any kind.",
) )
def handle(self, *args, **options): def handle(self, *args, **options):
name = options['script_name'] name = options["script_name"]
if not name: if not name:
self.show_help() self.show_help()
@ -23,44 +26,44 @@ class Command(BaseCommand):
script = available_scripts[name] script = available_scripts[name]
except KeyError: except KeyError:
raise CommandError( raise CommandError(
'{} is not a valid script. Run python manage.py script for a ' "{} is not a valid script. Run python manage.py script for a "
'list of available scripts'.format(name)) "list of available scripts".format(name)
)
self.stdout.write('') self.stdout.write("")
if options['interactive']: if options["interactive"]:
message = ( message = (
'Are you sure you want to execute the script {}?\n\n' "Are you sure you want to execute the script {}?\n\n"
"Type 'yes' to continue, or 'no' to cancel: " "Type 'yes' to continue, or 'no' to cancel: "
).format(name) ).format(name)
if input(''.join(message)) != 'yes': if input("".join(message)) != "yes":
raise CommandError("Script cancelled.") raise CommandError("Script cancelled.")
script['entrypoint'](self, **options) script["entrypoint"](self, **options)
def show_help(self): def show_help(self):
indentation = 4 indentation = 4
self.stdout.write('') self.stdout.write("")
self.stdout.write('Available scripts:') self.stdout.write("Available scripts:")
self.stdout.write('Launch with: python manage.py <script_name>') self.stdout.write("Launch with: python manage.py <script_name>")
available_scripts = self.get_scripts() available_scripts = self.get_scripts()
for name, script in sorted(available_scripts.items()): for name, script in sorted(available_scripts.items()):
self.stdout.write('') self.stdout.write("")
self.stdout.write(self.style.SUCCESS(name)) self.stdout.write(self.style.SUCCESS(name))
self.stdout.write('') self.stdout.write("")
for line in script['help'].splitlines(): for line in script["help"].splitlines():
self.stdout.write('     {}'.format(line)) self.stdout.write("     {}".format(line))
self.stdout.write('') self.stdout.write("")
def get_scripts(self): def get_scripts(self):
available_scripts = [ available_scripts = [
k for k in sorted(scripts.__dict__.keys()) k for k in sorted(scripts.__dict__.keys()) if not k.startswith("__")
if not k.startswith('__')
] ]
data = {} data = {}
for name in available_scripts: for name in available_scripts:
module = getattr(scripts, name) module = getattr(scripts, name)
data[name] = { data[name] = {
'name': name, "name": name,
'help': module.__doc__.strip(), "help": module.__doc__.strip(),
'entrypoint': module.main "entrypoint": module.main,
} }
return data return data

View File

@ -7,6 +7,4 @@ class Migration(migrations.Migration):
dependencies = [] dependencies = []
operations = [ operations = [UnaccentExtension()]
UnaccentExtension()
]

View File

@ -2,5 +2,5 @@ from rest_framework.pagination import PageNumberPagination
class FunkwhalePagination(PageNumberPagination): class FunkwhalePagination(PageNumberPagination):
page_size_query_param = 'page_size' page_size_query_param = "page_size"
max_page_size = 50 max_page_size = 50

View File

@ -9,9 +9,8 @@ from funkwhale_api.common import preferences
class ConditionalAuthentication(BasePermission): class ConditionalAuthentication(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
if preferences.get('common__api_authentication_required'): if preferences.get("common__api_authentication_required"):
return request.user and request.user.is_authenticated return request.user and request.user.is_authenticated
return True return True
@ -28,24 +27,25 @@ class OwnerPermission(BasePermission):
owner_field = 'owner' owner_field = 'owner'
owner_checks = ['read', 'write'] owner_checks = ['read', 'write']
""" """
perms_map = { perms_map = {
'GET': 'read', "GET": "read",
'OPTIONS': 'read', "OPTIONS": "read",
'HEAD': 'read', "HEAD": "read",
'POST': 'write', "POST": "write",
'PUT': 'write', "PUT": "write",
'PATCH': 'write', "PATCH": "write",
'DELETE': 'write', "DELETE": "write",
} }
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
method_check = self.perms_map[request.method] method_check = self.perms_map[request.method]
owner_checks = getattr(view, 'owner_checks', ['read', 'write']) owner_checks = getattr(view, "owner_checks", ["read", "write"])
if method_check not in owner_checks: if method_check not in owner_checks:
# check not enabled # check not enabled
return True return True
owner_field = getattr(view, 'owner_field', 'user') owner_field = getattr(view, "owner_field", "user")
owner = operator.attrgetter(owner_field)(obj) owner = operator.attrgetter(owner_field)(obj)
if owner != request.user: if owner != request.user:
raise Http404 raise Http404

View File

@ -17,7 +17,7 @@ def get(pref):
class StringListSerializer(serializers.BaseSerializer): class StringListSerializer(serializers.BaseSerializer):
separator = ',' separator = ","
sort = True sort = True
@classmethod @classmethod
@ -27,8 +27,8 @@ class StringListSerializer(serializers.BaseSerializer):
if type(value) not in [list, tuple]: if type(value) not in [list, tuple]:
raise cls.exception( raise cls.exception(
"Cannot serialize, value {} is not a list or a tuple".format( "Cannot serialize, value {} is not a list or a tuple".format(value)
value)) )
if cls.sort: if cls.sort:
value = sorted(value) value = sorted(value)
@ -38,7 +38,7 @@ class StringListSerializer(serializers.BaseSerializer):
def to_python(cls, value, **kwargs): def to_python(cls, value, **kwargs):
if not value: if not value:
return [] return []
return value.split(',') return value.split(",")
class StringListPreference(types.BasePreferenceType): class StringListPreference(types.BasePreferenceType):
@ -47,5 +47,5 @@ class StringListPreference(types.BasePreferenceType):
def get_api_additional_data(self): def get_api_additional_data(self):
d = super(StringListPreference, self).get_api_additional_data() d = super(StringListPreference, self).get_api_additional_data()
d['choices'] = self.get('choices') d["choices"] = self.get("choices")
return d return d

View File

@ -8,22 +8,22 @@ from funkwhale_api.users import models
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
mapping = { mapping = {
'dynamic_preferences.change_globalpreferencemodel': 'settings', "dynamic_preferences.change_globalpreferencemodel": "settings",
'music.add_importbatch': 'library', "music.add_importbatch": "library",
'federation.change_library': 'federation', "federation.change_library": "federation",
} }
def main(command, **kwargs): def main(command, **kwargs):
for codename, user_permission in sorted(mapping.items()): for codename, user_permission in sorted(mapping.items()):
app_label, c = codename.split('.') app_label, c = codename.split(".")
p = Permission.objects.get( p = Permission.objects.get(content_type__app_label=app_label, codename=c)
content_type__app_label=app_label, codename=c)
users = models.User.objects.filter( users = models.User.objects.filter(
Q(groups__permissions=p) | Q(user_permissions=p)).distinct() Q(groups__permissions=p) | Q(user_permissions=p)
).distinct()
total = users.count() total = users.count()
command.stdout.write('Updating {} users with {} permission...'.format( command.stdout.write(
total, user_permission "Updating {} users with {} permission...".format(total, user_permission)
)) )
users.update(**{'permission_{}'.format(user_permission): True}) users.update(**{"permission_{}".format(user_permission): True})

View File

@ -5,4 +5,4 @@ You can launch it just to check how it works.
def main(command, **kwargs): def main(command, **kwargs):
command.stdout.write('Test script run successfully') command.stdout.write("Test script run successfully")

View File

@ -17,67 +17,68 @@ class ActionSerializer(serializers.Serializer):
dangerous_actions = [] dangerous_actions = []
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.queryset = kwargs.pop('queryset') self.queryset = kwargs.pop("queryset")
if self.actions is None: if self.actions is None:
raise ValueError( raise ValueError(
'You must declare a list of actions on ' "You must declare a list of actions on " "the serializer class"
'the serializer class') )
for action in self.actions: for action in self.actions:
handler_name = 'handle_{}'.format(action) handler_name = "handle_{}".format(action)
assert hasattr(self, handler_name), ( assert hasattr(self, handler_name), "{} miss a {} method".format(
'{} miss a {} method'.format( self.__class__.__name__, handler_name
self.__class__.__name__, handler_name)
) )
super().__init__(self, *args, **kwargs) super().__init__(self, *args, **kwargs)
def validate_action(self, value): def validate_action(self, value):
if value not in self.actions: if value not in self.actions:
raise serializers.ValidationError( raise serializers.ValidationError(
'{} is not a valid action. Pick one of {}.'.format( "{} is not a valid action. Pick one of {}.".format(
value, ', '.join(self.actions) value, ", ".join(self.actions)
) )
) )
return value return value
def validate_objects(self, value): def validate_objects(self, value):
qs = None qs = None
if value == 'all': if value == "all":
return self.queryset.all().order_by('id') return self.queryset.all().order_by("id")
if type(value) in [list, tuple]: if type(value) in [list, tuple]:
return self.queryset.filter(pk__in=value).order_by('id') return self.queryset.filter(pk__in=value).order_by("id")
raise serializers.ValidationError( raise serializers.ValidationError(
'{} is not a valid value for objects. You must provide either a ' "{} is not a valid value for objects. You must provide either a "
'list of identifiers or the string "all".'.format(value)) 'list of identifiers or the string "all".'.format(value)
)
def validate(self, data): def validate(self, data):
dangerous = data['action'] in self.dangerous_actions dangerous = data["action"] in self.dangerous_actions
if dangerous and self.initial_data['objects'] == 'all': if dangerous and self.initial_data["objects"] == "all":
raise serializers.ValidationError( raise serializers.ValidationError(
'This action is to dangerous to be applied to all objects') "This action is to dangerous to be applied to all objects"
if self.filterset_class and 'filters' in data: )
if self.filterset_class and "filters" in data:
qs_filterset = self.filterset_class( qs_filterset = self.filterset_class(
data['filters'], queryset=data['objects']) data["filters"], queryset=data["objects"]
)
try: try:
assert qs_filterset.form.is_valid() assert qs_filterset.form.is_valid()
except (AssertionError, TypeError): except (AssertionError, TypeError):
raise serializers.ValidationError('Invalid filters') raise serializers.ValidationError("Invalid filters")
data['objects'] = qs_filterset.qs data["objects"] = qs_filterset.qs
data['count'] = data['objects'].count() data["count"] = data["objects"].count()
if data['count'] < 1: if data["count"] < 1:
raise serializers.ValidationError( raise serializers.ValidationError("No object matching your request")
'No object matching your request')
return data return data
def save(self): def save(self):
handler_name = 'handle_{}'.format(self.validated_data['action']) handler_name = "handle_{}".format(self.validated_data["action"])
handler = getattr(self, handler_name) handler = getattr(self, handler_name)
result = handler(self.validated_data['objects']) result = handler(self.validated_data["objects"])
payload = { payload = {
'updated': self.validated_data['count'], "updated": self.validated_data["count"],
'action': self.validated_data['action'], "action": self.validated_data["action"],
'result': result, "result": result,
} }
return payload return payload

View File

@ -6,13 +6,12 @@ import funkwhale_api
def get_user_agent(): def get_user_agent():
return 'python-requests (funkwhale/{}; +{})'.format( return "python-requests (funkwhale/{}; +{})".format(
funkwhale_api.__version__, funkwhale_api.__version__, settings.FUNKWHALE_URL
settings.FUNKWHALE_URL
) )
def get_session(): def get_session():
s = requests.Session() s = requests.Session()
s.headers['User-Agent'] = get_user_agent() s.headers["User-Agent"] = get_user_agent()
return s return s

View File

@ -7,6 +7,7 @@ class ASCIIFileSystemStorage(FileSystemStorage):
""" """
Convert unicode characters in name to ASCII characters. Convert unicode characters in name to ASCII characters.
""" """
def get_valid_name(self, name): def get_valid_name(self, name):
name = unicodedata.normalize('NFKD', name).encode('ascii', 'ignore') name = unicodedata.normalize("NFKD", name).encode("ascii", "ignore")
return super().get_valid_name(name) return super().get_valid_name(name)

View File

@ -9,13 +9,13 @@ def rename_file(instance, field_name, new_name, allow_missing_file=False):
field = getattr(instance, field_name) field = getattr(instance, field_name)
current_name, extension = os.path.splitext(field.name) current_name, extension = os.path.splitext(field.name)
new_name_with_extension = '{}{}'.format(new_name, extension) new_name_with_extension = "{}{}".format(new_name, extension)
try: try:
shutil.move(field.path, new_name_with_extension) shutil.move(field.path, new_name_with_extension)
except FileNotFoundError: except FileNotFoundError:
if not allow_missing_file: if not allow_missing_file:
raise raise
print('Skipped missing file', field.path) print("Skipped missing file", field.path)
initial_path = os.path.dirname(field.name) initial_path = os.path.dirname(field.name)
field.name = os.path.join(initial_path, new_name_with_extension) field.name = os.path.join(initial_path, new_name_with_extension)
instance.save() instance.save()
@ -23,9 +23,7 @@ def rename_file(instance, field_name, new_name, allow_missing_file=False):
def on_commit(f, *args, **kwargs): def on_commit(f, *args, **kwargs):
return transaction.on_commit( return transaction.on_commit(lambda: f(*args, **kwargs))
lambda: f(*args, **kwargs)
)
def set_query_parameter(url, **kwargs): def set_query_parameter(url, **kwargs):

View File

@ -7,25 +7,39 @@ import django.contrib.sites.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = []
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Site', name="Site",
fields=[ 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])), "id",
('name', models.CharField(verbose_name='display name', max_length=50)), 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={ options={
'verbose_name_plural': 'sites', "verbose_name_plural": "sites",
'verbose_name': 'site', "verbose_name": "site",
'db_table': 'django_site', "db_table": "django_site",
'ordering': ('domain',), "ordering": ("domain",),
}, },
managers=[ managers=[("objects", django.contrib.sites.models.SiteManager())],
('objects', django.contrib.sites.models.SiteManager()), )
],
),
] ]

View File

@ -10,10 +10,7 @@ def update_site_forward(apps, schema_editor):
Site = apps.get_model("sites", "Site") Site = apps.get_model("sites", "Site")
Site.objects.update_or_create( Site.objects.update_or_create(
id=settings.SITE_ID, id=settings.SITE_ID,
defaults={ defaults={"domain": "funkwhale.io", "name": "funkwhale_api"},
"domain": "funkwhale.io",
"name": "funkwhale_api"
}
) )
@ -21,20 +18,12 @@ def update_site_backward(apps, schema_editor):
"""Revert site domain and name to default.""" """Revert site domain and name to default."""
Site = apps.get_model("sites", "Site") Site = apps.get_model("sites", "Site")
Site.objects.update_or_create( Site.objects.update_or_create(
id=settings.SITE_ID, id=settings.SITE_ID, defaults={"domain": "example.com", "name": "example.com"}
defaults={
"domain": "example.com",
"name": "example.com"
}
) )
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [("sites", "0001_initial")]
('sites', '0001_initial'),
]
operations = [ operations = [migrations.RunPython(update_site_forward, update_site_backward)]
migrations.RunPython(update_site_forward, update_site_backward),
]

View File

@ -8,20 +8,21 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [("sites", "0002_set_site_domain_and_name")]
('sites', '0002_set_site_domain_and_name'),
]
operations = [ operations = [
migrations.AlterModelManagers( migrations.AlterModelManagers(
name='site', name="site",
managers=[ managers=[("objects", django.contrib.sites.models.SiteManager())],
('objects', django.contrib.sites.models.SiteManager()),
],
), ),
migrations.AlterField( migrations.AlterField(
model_name='site', model_name="site",
name='domain', name="domain",
field=models.CharField(max_length=100, unique=True, validators=[django.contrib.sites.models._simple_domain_name_validator], verbose_name='domain name'), field=models.CharField(
max_length=100,
unique=True,
validators=[django.contrib.sites.models._simple_domain_name_validator],
verbose_name="domain name",
),
), ),
] ]

View File

@ -7,20 +7,15 @@ import glob
def download( def download(
url, url, target_directory=settings.MEDIA_ROOT, name="%(id)s.%(ext)s", bitrate=192
target_directory=settings.MEDIA_ROOT, ):
name="%(id)s.%(ext)s",
bitrate=192):
target_path = os.path.join(target_directory, name) target_path = os.path.join(target_directory, name)
ydl_opts = { ydl_opts = {
'quiet': True, "quiet": True,
'outtmpl': target_path, "outtmpl": target_path,
'postprocessors': [{ "postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": "vorbis"}],
'key': 'FFmpegExtractAudio',
'preferredcodec': 'vorbis',
}],
} }
_downloader = youtube_dl.YoutubeDL(ydl_opts) _downloader = youtube_dl.YoutubeDL(ydl_opts)
info = _downloader.extract_info(url) info = _downloader.extract_info(url)
info['audio_file_path'] = target_path % {'id': info['id'], 'ext': 'ogg'} info["audio_file_path"] = target_path % {"id": info["id"], "ext": "ogg"}
return info return info

View File

@ -3,7 +3,7 @@ import persisting_theory
class FactoriesRegistry(persisting_theory.Registry): class FactoriesRegistry(persisting_theory.Registry):
look_into = 'factories' look_into = "factories"
def prepare_name(self, data, name=None): def prepare_name(self, data, name=None):
return name or data._meta.model._meta.label return name or data._meta.model._meta.label

View File

@ -3,17 +3,14 @@ from funkwhale_api.activity import record
from . import serializers from . import serializers
record.registry.register_serializer( record.registry.register_serializer(serializers.TrackFavoriteActivitySerializer)
serializers.TrackFavoriteActivitySerializer)
@record.registry.register_consumer('favorites.TrackFavorite') @record.registry.register_consumer("favorites.TrackFavorite")
def broadcast_track_favorite_to_instance_activity(data, obj): def broadcast_track_favorite_to_instance_activity(data, obj):
if obj.user.privacy_level not in ['instance', 'everyone']: if obj.user.privacy_level not in ["instance", "everyone"]:
return return
channels.group_send('instance_activity', { channels.group_send(
'type': 'event.send', "instance_activity", {"type": "event.send", "text": "", "data": data}
'text': '', )
'data': data
})

View File

@ -5,8 +5,5 @@ from . import models
@admin.register(models.TrackFavorite) @admin.register(models.TrackFavorite)
class TrackFavoriteAdmin(admin.ModelAdmin): class TrackFavoriteAdmin(admin.ModelAdmin):
list_display = ['user', 'track', 'creation_date'] list_display = ["user", "track", "creation_date"]
list_select_related = [ list_select_related = ["user", "track"]
'user',
'track'
]

View File

@ -12,4 +12,4 @@ class TrackFavorite(factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory) user = factory.SubFactory(UserFactory)
class Meta: class Meta:
model = 'favorites.TrackFavorite' model = "favorites.TrackFavorite"

View File

@ -9,25 +9,47 @@ from django.conf import settings
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('music', '0003_auto_20151222_2233'), ("music", "0003_auto_20151222_2233"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='TrackFavorite', name="TrackFavorite",
fields=[ fields=[
('id', models.AutoField(serialize=False, auto_created=True, verbose_name='ID', primary_key=True)), (
('creation_date', models.DateTimeField(default=django.utils.timezone.now)), "id",
('track', models.ForeignKey(related_name='track_favorites', to='music.Track', on_delete=models.CASCADE)), models.AutoField(
('user', models.ForeignKey(related_name='track_favorites', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), 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",
on_delete=models.CASCADE,
),
),
(
"user",
models.ForeignKey(
related_name="track_favorites",
to=settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
),
),
], ],
options={ options={"ordering": ("-creation_date",)},
'ordering': ('-creation_date',),
},
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='trackfavorite', name="trackfavorite", unique_together=set([("track", "user")])
unique_together=set([('track', 'user')]),
), ),
] ]

View File

@ -8,13 +8,15 @@ from funkwhale_api.music.models import Track
class TrackFavorite(models.Model): class TrackFavorite(models.Model):
creation_date = models.DateTimeField(default=timezone.now) creation_date = models.DateTimeField(default=timezone.now)
user = models.ForeignKey( user = models.ForeignKey(
'users.User', related_name='track_favorites', on_delete=models.CASCADE) "users.User", related_name="track_favorites", on_delete=models.CASCADE
)
track = models.ForeignKey( track = models.ForeignKey(
Track, related_name='track_favorites', on_delete=models.CASCADE) Track, related_name="track_favorites", on_delete=models.CASCADE
)
class Meta: class Meta:
unique_together = ('track', 'user') unique_together = ("track", "user")
ordering = ('-creation_date',) ordering = ("-creation_date",)
@classmethod @classmethod
def add(cls, track, user): def add(cls, track, user):
@ -22,5 +24,4 @@ class TrackFavorite(models.Model):
return favorite return favorite
def get_activity_url(self): def get_activity_url(self):
return '{}/favorites/tracks/{}'.format( return "{}/favorites/tracks/{}".format(self.user.get_activity_url(), self.pk)
self.user.get_activity_url(), self.pk)

View File

@ -11,29 +11,22 @@ from . import models
class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer): class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField() type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source='track') object = TrackActivitySerializer(source="track")
actor = UserActivitySerializer(source='user') actor = UserActivitySerializer(source="user")
published = serializers.DateTimeField(source='creation_date') published = serializers.DateTimeField(source="creation_date")
class Meta: class Meta:
model = models.TrackFavorite model = models.TrackFavorite
fields = [ fields = ["id", "local_id", "object", "type", "actor", "published"]
'id',
'local_id',
'object',
'type',
'actor',
'published'
]
def get_actor(self, obj): def get_actor(self, obj):
return UserActivitySerializer(obj.user).data return UserActivitySerializer(obj.user).data
def get_type(self, obj): def get_type(self, obj):
return 'Like' return "Like"
class UserTrackFavoriteSerializer(serializers.ModelSerializer): class UserTrackFavoriteSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.TrackFavorite model = models.TrackFavorite
fields = ('id', 'track', 'creation_date') fields = ("id", "track", "creation_date")

View File

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

View File

@ -12,13 +12,15 @@ from . import models
from . import serializers from . import serializers
class TrackFavoriteViewSet(mixins.CreateModelMixin, class TrackFavoriteViewSet(
mixins.DestroyModelMixin, mixins.CreateModelMixin,
mixins.ListModelMixin, mixins.DestroyModelMixin,
viewsets.GenericViewSet): mixins.ListModelMixin,
viewsets.GenericViewSet,
):
serializer_class = serializers.UserTrackFavoriteSerializer serializer_class = serializers.UserTrackFavoriteSerializer
queryset = (models.TrackFavorite.objects.all()) queryset = models.TrackFavorite.objects.all()
permission_classes = [ConditionalAuthentication] permission_classes = [ConditionalAuthentication]
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
@ -28,20 +30,22 @@ class TrackFavoriteViewSet(mixins.CreateModelMixin,
serializer = self.get_serializer(instance=instance) serializer = self.get_serializer(instance=instance)
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)
record.send(instance) record.send(instance)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) return Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
def get_queryset(self): def get_queryset(self):
return self.queryset.filter(user=self.request.user) return self.queryset.filter(user=self.request.user)
def perform_create(self, serializer): def perform_create(self, serializer):
track = Track.objects.get(pk=serializer.data['track']) track = Track.objects.get(pk=serializer.data["track"])
favorite = models.TrackFavorite.add(track=track, user=self.request.user) favorite = models.TrackFavorite.add(track=track, user=self.request.user)
return favorite return favorite
@list_route(methods=['delete', 'post']) @list_route(methods=["delete", "post"])
def remove(self, request, *args, **kwargs): def remove(self, request, *args, **kwargs):
try: try:
pk = int(request.data['track']) pk = int(request.data["track"])
favorite = request.user.track_favorites.get(track__pk=pk) favorite = request.user.track_favorites.get(track__pk=pk)
except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist): except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist):
return Response({}, status=400) return Response({}, status=400)

View File

@ -2,66 +2,59 @@ from . import serializers
from . import tasks from . import tasks
ACTIVITY_TYPES = [ ACTIVITY_TYPES = [
'Accept', "Accept",
'Add', "Add",
'Announce', "Announce",
'Arrive', "Arrive",
'Block', "Block",
'Create', "Create",
'Delete', "Delete",
'Dislike', "Dislike",
'Flag', "Flag",
'Follow', "Follow",
'Ignore', "Ignore",
'Invite', "Invite",
'Join', "Join",
'Leave', "Leave",
'Like', "Like",
'Listen', "Listen",
'Move', "Move",
'Offer', "Offer",
'Question', "Question",
'Reject', "Reject",
'Read', "Read",
'Remove', "Remove",
'TentativeReject', "TentativeReject",
'TentativeAccept', "TentativeAccept",
'Travel', "Travel",
'Undo', "Undo",
'Update', "Update",
'View', "View",
] ]
OBJECT_TYPES = [ OBJECT_TYPES = [
'Article', "Article",
'Audio', "Audio",
'Collection', "Collection",
'Document', "Document",
'Event', "Event",
'Image', "Image",
'Note', "Note",
'OrderedCollection', "OrderedCollection",
'Page', "Page",
'Place', "Place",
'Profile', "Profile",
'Relationship', "Relationship",
'Tombstone', "Tombstone",
'Video', "Video",
] + ACTIVITY_TYPES ] + ACTIVITY_TYPES
def deliver(activity, on_behalf_of, to=[]): def deliver(activity, on_behalf_of, to=[]):
return tasks.send.delay( return tasks.send.delay(activity=activity, actor_id=on_behalf_of.pk, to=to)
activity=activity,
actor_id=on_behalf_of.pk,
to=to
)
def accept_follow(follow): def accept_follow(follow):
serializer = serializers.AcceptFollowSerializer(follow) serializer = serializers.AcceptFollowSerializer(follow)
return deliver( return deliver(serializer.data, to=[follow.actor.url], on_behalf_of=follow.target)
serializer.data,
to=[follow.actor.url],
on_behalf_of=follow.target)

View File

@ -29,8 +29,10 @@ logger = logging.getLogger(__name__)
def remove_tags(text): def remove_tags(text):
logger.debug('Removing tags from %s', text) logger.debug("Removing tags from %s", text)
return ''.join(xml.etree.ElementTree.fromstring('<div>{}</div>'.format(text)).itertext()) return "".join(
xml.etree.ElementTree.fromstring("<div>{}</div>".format(text)).itertext()
)
def get_actor_data(actor_url): def get_actor_data(actor_url):
@ -38,16 +40,13 @@ def get_actor_data(actor_url):
actor_url, actor_url,
timeout=5, timeout=5,
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
headers={ headers={"Accept": "application/activity+json"},
'Accept': 'application/activity+json',
}
) )
response.raise_for_status() response.raise_for_status()
try: try:
return response.json() return response.json()
except: except:
raise ValueError( raise ValueError("Invalid actor payload: {}".format(response.text))
'Invalid actor payload: {}'.format(response.text))
def get_actor(actor_url): def get_actor(actor_url):
@ -56,7 +55,8 @@ def get_actor(actor_url):
except models.Actor.DoesNotExist: except models.Actor.DoesNotExist:
actor = None actor = None
fetch_delta = datetime.timedelta( fetch_delta = datetime.timedelta(
minutes=preferences.get('federation__actor_fetch_delay')) minutes=preferences.get("federation__actor_fetch_delay")
)
if actor and actor.last_fetch_date > timezone.now() - fetch_delta: if actor and actor.last_fetch_date > timezone.now() - fetch_delta:
# cache is hot, we can return as is # cache is hot, we can return as is
return actor return actor
@ -73,8 +73,7 @@ class SystemActor(object):
def get_request_auth(self): def get_request_auth(self):
actor = self.get_actor_instance() actor = self.get_actor_instance()
return signing.get_auth( return signing.get_auth(actor.private_key, actor.private_key_id)
actor.private_key, actor.private_key_id)
def serialize(self): def serialize(self):
actor = self.get_actor_instance() actor = self.get_actor_instance()
@ -88,42 +87,35 @@ class SystemActor(object):
pass pass
private, public = keys.get_key_pair() private, public = keys.get_key_pair()
args = self.get_instance_argument( args = self.get_instance_argument(
self.id, self.id, name=self.name, summary=self.summary, **self.additional_attributes
name=self.name,
summary=self.summary,
**self.additional_attributes
) )
args['private_key'] = private.decode('utf-8') args["private_key"] = private.decode("utf-8")
args['public_key'] = public.decode('utf-8') args["public_key"] = public.decode("utf-8")
return models.Actor.objects.create(**args) return models.Actor.objects.create(**args)
def get_actor_url(self): def get_actor_url(self):
return utils.full_url( return utils.full_url(
reverse( reverse("federation:instance-actors-detail", kwargs={"actor": self.id})
'federation:instance-actors-detail', )
kwargs={'actor': self.id}))
def get_instance_argument(self, id, name, summary, **kwargs): def get_instance_argument(self, id, name, summary, **kwargs):
p = { p = {
'preferred_username': id, "preferred_username": id,
'domain': settings.FEDERATION_HOSTNAME, "domain": settings.FEDERATION_HOSTNAME,
'type': 'Person', "type": "Person",
'name': name.format(host=settings.FEDERATION_HOSTNAME), "name": name.format(host=settings.FEDERATION_HOSTNAME),
'manually_approves_followers': True, "manually_approves_followers": True,
'url': self.get_actor_url(), "url": self.get_actor_url(),
'shared_inbox_url': utils.full_url( "shared_inbox_url": utils.full_url(
reverse( reverse("federation:instance-actors-inbox", kwargs={"actor": id})
'federation:instance-actors-inbox', ),
kwargs={'actor': id})), "inbox_url": utils.full_url(
'inbox_url': utils.full_url( reverse("federation:instance-actors-inbox", kwargs={"actor": id})
reverse( ),
'federation:instance-actors-inbox', "outbox_url": utils.full_url(
kwargs={'actor': id})), reverse("federation:instance-actors-outbox", kwargs={"actor": id})
'outbox_url': utils.full_url( ),
reverse( "summary": summary.format(host=settings.FEDERATION_HOSTNAME),
'federation:instance-actors-outbox',
kwargs={'actor': id})),
'summary': summary.format(host=settings.FEDERATION_HOSTNAME)
} }
p.update(kwargs) p.update(kwargs)
return p return p
@ -145,22 +137,19 @@ class SystemActor(object):
Main entrypoint for handling activities posted to the Main entrypoint for handling activities posted to the
actor's inbox actor's inbox
""" """
logger.info('Received activity on %s inbox', self.id) logger.info("Received activity on %s inbox", self.id)
if actor is None: if actor is None:
raise PermissionDenied('Actor not authenticated') raise PermissionDenied("Actor not authenticated")
serializer = serializers.ActivitySerializer( serializer = serializers.ActivitySerializer(data=data, context={"actor": actor})
data=data, context={'actor': actor})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
ac = serializer.data ac = serializer.data
try: try:
handler = getattr( handler = getattr(self, "handle_{}".format(ac["type"].lower()))
self, 'handle_{}'.format(ac['type'].lower()))
except (KeyError, AttributeError): except (KeyError, AttributeError):
logger.debug( logger.debug("No handler for activity %s", ac["type"])
'No handler for activity %s', ac['type'])
return return
return handler(data, actor) return handler(data, actor)
@ -168,9 +157,10 @@ class SystemActor(object):
def handle_follow(self, ac, sender): def handle_follow(self, ac, sender):
system_actor = self.get_actor_instance() system_actor = self.get_actor_instance()
serializer = serializers.FollowSerializer( serializer = serializers.FollowSerializer(
data=ac, context={'follow_actor': sender}) data=ac, context={"follow_actor": sender}
)
if not serializer.is_valid(): if not serializer.is_valid():
return logger.info('Invalid follow payload') return logger.info("Invalid follow payload")
approved = True if not self.manually_approves_followers else None approved = True if not self.manually_approves_followers else None
follow = serializer.save(approved=approved) follow = serializer.save(approved=approved)
if follow.approved: if follow.approved:
@ -179,26 +169,27 @@ class SystemActor(object):
def handle_accept(self, ac, sender): def handle_accept(self, ac, sender):
system_actor = self.get_actor_instance() system_actor = self.get_actor_instance()
serializer = serializers.AcceptFollowSerializer( serializer = serializers.AcceptFollowSerializer(
data=ac, data=ac, context={"follow_target": sender, "follow_actor": system_actor}
context={'follow_target': sender, 'follow_actor': system_actor}) )
if not serializer.is_valid(raise_exception=True): if not serializer.is_valid(raise_exception=True):
return logger.info('Received invalid payload') return logger.info("Received invalid payload")
return serializer.save() return serializer.save()
def handle_undo_follow(self, ac, sender): def handle_undo_follow(self, ac, sender):
system_actor = self.get_actor_instance() system_actor = self.get_actor_instance()
serializer = serializers.UndoFollowSerializer( serializer = serializers.UndoFollowSerializer(
data=ac, context={'actor': sender, 'target': system_actor}) data=ac, context={"actor": sender, "target": system_actor}
)
if not serializer.is_valid(): if not serializer.is_valid():
return logger.info('Received invalid payload') return logger.info("Received invalid payload")
serializer.save() serializer.save()
def handle_undo(self, ac, sender): def handle_undo(self, ac, sender):
if ac['object']['type'] != 'Follow': if ac["object"]["type"] != "Follow":
return return
if ac['object']['actor'] != sender.url: if ac["object"]["actor"] != sender.url:
# not the same actor, permission issue # not the same actor, permission issue
return return
@ -206,55 +197,52 @@ class SystemActor(object):
class LibraryActor(SystemActor): class LibraryActor(SystemActor):
id = 'library' id = "library"
name = '{host}\'s library' name = "{host}'s library"
summary = 'Bot account to federate with {host}\'s library' summary = "Bot account to federate with {host}'s library"
additional_attributes = { additional_attributes = {"manually_approves_followers": True}
'manually_approves_followers': True
}
def serialize(self): def serialize(self):
data = super().serialize() data = super().serialize()
urls = data.setdefault('url', []) urls = data.setdefault("url", [])
urls.append({ urls.append(
'type': 'Link', {
'mediaType': 'application/activity+json', "type": "Link",
'name': 'library', "mediaType": "application/activity+json",
'href': utils.full_url(reverse('federation:music:files-list')) "name": "library",
}) "href": utils.full_url(reverse("federation:music:files-list")),
}
)
return data return data
@property @property
def manually_approves_followers(self): def manually_approves_followers(self):
return preferences.get('federation__music_needs_approval') return preferences.get("federation__music_needs_approval")
@transaction.atomic @transaction.atomic
def handle_create(self, ac, sender): def handle_create(self, ac, sender):
try: try:
remote_library = models.Library.objects.get( remote_library = models.Library.objects.get(
actor=sender, actor=sender, federation_enabled=True
federation_enabled=True,
) )
except models.Library.DoesNotExist: except models.Library.DoesNotExist:
logger.info( logger.info("Skipping import, we're not following %s", sender.url)
'Skipping import, we\'re not following %s', sender.url)
return return
if ac['object']['type'] != 'Collection': if ac["object"]["type"] != "Collection":
return return
if ac['object']['totalItems'] <= 0: if ac["object"]["totalItems"] <= 0:
return return
try: try:
items = ac['object']['items'] items = ac["object"]["items"]
except KeyError: except KeyError:
logger.warning('No items in collection!') logger.warning("No items in collection!")
return return
item_serializers = [ item_serializers = [
serializers.AudioSerializer( serializers.AudioSerializer(data=i, context={"library": remote_library})
data=i, context={'library': remote_library})
for i in items for i in items
] ]
now = timezone.now() now = timezone.now()
@ -263,27 +251,21 @@ class LibraryActor(SystemActor):
if s.is_valid(): if s.is_valid():
valid_serializers.append(s) valid_serializers.append(s)
else: else:
logger.debug( logger.debug("Skipping invalid item %s, %s", s.initial_data, s.errors)
'Skipping invalid item %s, %s', s.initial_data, s.errors)
lts = [] lts = []
for s in valid_serializers: for s in valid_serializers:
lts.append(s.save()) lts.append(s.save())
if remote_library.autoimport: if remote_library.autoimport:
batch = music_models.ImportBatch.objects.create( batch = music_models.ImportBatch.objects.create(source="federation")
source='federation',
)
for lt in lts: for lt in lts:
if lt.creation_date < now: if lt.creation_date < now:
# track was already in the library, we do not trigger # track was already in the library, we do not trigger
# an import # an import
continue continue
job = music_models.ImportJob.objects.create( job = music_models.ImportJob.objects.create(
batch=batch, batch=batch, library_track=lt, mbid=lt.mbid, source=lt.url
library_track=lt,
mbid=lt.mbid,
source=lt.url,
) )
funkwhale_utils.on_commit( funkwhale_utils.on_commit(
music_tasks.import_job_run.delay, music_tasks.import_job_run.delay,
@ -293,15 +275,13 @@ class LibraryActor(SystemActor):
class TestActor(SystemActor): class TestActor(SystemActor):
id = 'test' id = "test"
name = '{host}\'s test account' name = "{host}'s test account"
summary = ( summary = (
'Bot account to test federation with {host}. ' "Bot account to test federation with {host}. "
'Send me /ping and I\'ll answer you.' "Send me /ping and I'll answer you."
) )
additional_attributes = { additional_attributes = {"manually_approves_followers": False}
'manually_approves_followers': False
}
manually_approves_followers = False manually_approves_followers = False
def get_outbox(self, data, actor=None): def get_outbox(self, data, actor=None):
@ -309,15 +289,14 @@ class TestActor(SystemActor):
"@context": [ "@context": [
"https://www.w3.org/ns/activitystreams", "https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1", "https://w3id.org/security/v1",
{} {},
], ],
"id": utils.full_url( "id": utils.full_url(
reverse( reverse("federation:instance-actors-outbox", kwargs={"actor": self.id})
'federation:instance-actors-outbox', ),
kwargs={'actor': self.id})),
"type": "OrderedCollection", "type": "OrderedCollection",
"totalItems": 0, "totalItems": 0,
"orderedItems": [] "orderedItems": [],
} }
def parse_command(self, message): def parse_command(self, message):
@ -327,99 +306,86 @@ class TestActor(SystemActor):
""" """
raw = remove_tags(message) raw = remove_tags(message)
try: try:
return raw.split('/')[1] return raw.split("/")[1]
except IndexError: except IndexError:
return return
def handle_create(self, ac, sender): def handle_create(self, ac, sender):
if ac['object']['type'] != 'Note': if ac["object"]["type"] != "Note":
return return
# we received a toot \o/ # we received a toot \o/
command = self.parse_command(ac['object']['content']) command = self.parse_command(ac["object"]["content"])
logger.debug('Parsed command: %s', command) logger.debug("Parsed command: %s", command)
if command != 'ping': if command != "ping":
return return
now = timezone.now() now = timezone.now()
test_actor = self.get_actor_instance() test_actor = self.get_actor_instance()
reply_url = 'https://{}/activities/note/{}'.format( reply_url = "https://{}/activities/note/{}".format(
settings.FEDERATION_HOSTNAME, now.timestamp() settings.FEDERATION_HOSTNAME, now.timestamp()
) )
reply_content = '{} Pong!'.format( reply_content = "{} Pong!".format(sender.mention_username)
sender.mention_username
)
reply_activity = { reply_activity = {
"@context": [ "@context": [
"https://www.w3.org/ns/activitystreams", "https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1", "https://w3id.org/security/v1",
{} {},
], ],
'type': 'Create', "type": "Create",
'actor': test_actor.url, "actor": test_actor.url,
'id': '{}/activity'.format(reply_url), "id": "{}/activity".format(reply_url),
'published': now.isoformat(), "published": now.isoformat(),
'to': ac['actor'], "to": ac["actor"],
'cc': [], "cc": [],
'object': { "object": {
'type': 'Note', "type": "Note",
'content': 'Pong!', "content": "Pong!",
'summary': None, "summary": None,
'published': now.isoformat(), "published": now.isoformat(),
'id': reply_url, "id": reply_url,
'inReplyTo': ac['object']['id'], "inReplyTo": ac["object"]["id"],
'sensitive': False, "sensitive": False,
'url': reply_url, "url": reply_url,
'to': [ac['actor']], "to": [ac["actor"]],
'attributedTo': test_actor.url, "attributedTo": test_actor.url,
'cc': [], "cc": [],
'attachment': [], "attachment": [],
'tag': [{ "tag": [
"type": "Mention", {
"href": ac['actor'], "type": "Mention",
"name": sender.mention_username "href": ac["actor"],
}] "name": sender.mention_username,
} }
],
},
} }
activity.deliver( activity.deliver(reply_activity, to=[ac["actor"]], on_behalf_of=test_actor)
reply_activity,
to=[ac['actor']],
on_behalf_of=test_actor)
def handle_follow(self, ac, sender): def handle_follow(self, ac, sender):
super().handle_follow(ac, sender) super().handle_follow(ac, sender)
# also, we follow back # also, we follow back
test_actor = self.get_actor_instance() test_actor = self.get_actor_instance()
follow_back = models.Follow.objects.get_or_create( follow_back = models.Follow.objects.get_or_create(
actor=test_actor, actor=test_actor, target=sender, approved=None
target=sender,
approved=None,
)[0] )[0]
activity.deliver( activity.deliver(
serializers.FollowSerializer(follow_back).data, serializers.FollowSerializer(follow_back).data,
to=[follow_back.target.url], to=[follow_back.target.url],
on_behalf_of=follow_back.actor) on_behalf_of=follow_back.actor,
)
def handle_undo_follow(self, ac, sender): def handle_undo_follow(self, ac, sender):
super().handle_undo_follow(ac, sender) super().handle_undo_follow(ac, sender)
actor = self.get_actor_instance() actor = self.get_actor_instance()
# we also unfollow the sender, if possible # we also unfollow the sender, if possible
try: try:
follow = models.Follow.objects.get( follow = models.Follow.objects.get(target=sender, actor=actor)
target=sender,
actor=actor,
)
except models.Follow.DoesNotExist: except models.Follow.DoesNotExist:
return return
undo = serializers.UndoFollowSerializer(follow).data undo = serializers.UndoFollowSerializer(follow).data
follow.delete() follow.delete()
activity.deliver( activity.deliver(undo, to=[sender.url], on_behalf_of=actor)
undo,
to=[sender.url],
on_behalf_of=actor)
SYSTEM_ACTORS = { SYSTEM_ACTORS = {"library": LibraryActor(), "test": TestActor()}
'library': LibraryActor(),
'test': TestActor(),
}

View File

@ -6,61 +6,43 @@ from . import models
@admin.register(models.Actor) @admin.register(models.Actor)
class ActorAdmin(admin.ModelAdmin): class ActorAdmin(admin.ModelAdmin):
list_display = [ list_display = [
'url', "url",
'domain', "domain",
'preferred_username', "preferred_username",
'type', "type",
'creation_date', "creation_date",
'last_fetch_date'] "last_fetch_date",
search_fields = ['url', 'domain', 'preferred_username']
list_filter = [
'type'
] ]
search_fields = ["url", "domain", "preferred_username"]
list_filter = ["type"]
@admin.register(models.Follow) @admin.register(models.Follow)
class FollowAdmin(admin.ModelAdmin): class FollowAdmin(admin.ModelAdmin):
list_display = [ list_display = ["actor", "target", "approved", "creation_date"]
'actor', list_filter = ["approved"]
'target', search_fields = ["actor__url", "target__url"]
'approved',
'creation_date'
]
list_filter = [
'approved'
]
search_fields = ['actor__url', 'target__url']
list_select_related = True list_select_related = True
@admin.register(models.Library) @admin.register(models.Library)
class LibraryAdmin(admin.ModelAdmin): class LibraryAdmin(admin.ModelAdmin):
list_display = [ list_display = ["actor", "url", "creation_date", "fetched_date", "tracks_count"]
'actor', search_fields = ["actor__url", "url"]
'url', list_filter = ["federation_enabled", "download_files", "autoimport"]
'creation_date',
'fetched_date',
'tracks_count']
search_fields = ['actor__url', 'url']
list_filter = [
'federation_enabled',
'download_files',
'autoimport',
]
list_select_related = True list_select_related = True
@admin.register(models.LibraryTrack) @admin.register(models.LibraryTrack)
class LibraryTrackAdmin(admin.ModelAdmin): class LibraryTrackAdmin(admin.ModelAdmin):
list_display = [ list_display = [
'title', "title",
'artist_name', "artist_name",
'album_title', "album_title",
'url', "url",
'library', "library",
'creation_date', "creation_date",
'published_date', "published_date",
] ]
search_fields = [ search_fields = ["library__url", "url", "artist_name", "title", "album_title"]
'library__url', 'url', 'artist_name', 'title', 'album_title']
list_select_related = True list_select_related = True

View File

@ -3,13 +3,7 @@ from rest_framework import routers
from . import views from . import views
router = routers.SimpleRouter() router = routers.SimpleRouter()
router.register( router.register(r"libraries", views.LibraryViewSet, "libraries")
r'libraries', router.register(r"library-tracks", views.LibraryTrackViewSet, "library-tracks")
views.LibraryViewSet,
'libraries')
router.register(
r'library-tracks',
views.LibraryTrackViewSet,
'library-tracks')
urlpatterns = router.urls urlpatterns = router.urls

View File

@ -17,7 +17,7 @@ class SignatureAuthentication(authentication.BaseAuthentication):
def authenticate_actor(self, request): def authenticate_actor(self, request):
headers = utils.clean_wsgi_headers(request.META) headers = utils.clean_wsgi_headers(request.META)
try: try:
signature = headers['Signature'] signature = headers["Signature"]
key_id = keys.get_key_id_from_signature_header(signature) key_id = keys.get_key_id_from_signature_header(signature)
except KeyError: except KeyError:
return return
@ -25,25 +25,25 @@ class SignatureAuthentication(authentication.BaseAuthentication):
raise exceptions.AuthenticationFailed(str(e)) raise exceptions.AuthenticationFailed(str(e))
try: try:
actor = actors.get_actor(key_id.split('#')[0]) actor = actors.get_actor(key_id.split("#")[0])
except Exception as e: except Exception as e:
raise exceptions.AuthenticationFailed(str(e)) raise exceptions.AuthenticationFailed(str(e))
if not actor.public_key: if not actor.public_key:
raise exceptions.AuthenticationFailed('No public key found') raise exceptions.AuthenticationFailed("No public key found")
try: try:
signing.verify_django(request, actor.public_key.encode('utf-8')) signing.verify_django(request, actor.public_key.encode("utf-8"))
except cryptography.exceptions.InvalidSignature: except cryptography.exceptions.InvalidSignature:
raise exceptions.AuthenticationFailed('Invalid signature') raise exceptions.AuthenticationFailed("Invalid signature")
return actor return actor
def authenticate(self, request): def authenticate(self, request):
setattr(request, 'actor', None) setattr(request, "actor", None)
actor = self.authenticate_actor(request) actor = self.authenticate_actor(request)
if not actor: if not actor:
return return
user = AnonymousUser() user = AnonymousUser()
setattr(request, 'actor', actor) setattr(request, "actor", actor)
return (user, None) return (user, None)

View File

@ -4,77 +4,66 @@ from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from funkwhale_api.common import preferences from funkwhale_api.common import preferences
federation = types.Section('federation')
federation = types.Section("federation")
@global_preferences_registry.register @global_preferences_registry.register
class MusicCacheDuration(types.IntPreference): class MusicCacheDuration(types.IntPreference):
show_in_api = True show_in_api = True
section = federation section = federation
name = 'music_cache_duration' name = "music_cache_duration"
default = 60 * 24 * 2 default = 60 * 24 * 2
verbose_name = 'Music cache duration' verbose_name = "Music cache duration"
help_text = ( help_text = (
'How much minutes do you want to keep a copy of federated tracks' "How much minutes do you want to keep a copy of federated tracks"
'locally? Federated files that were not listened in this interval ' "locally? Federated files that were not listened in this interval "
'will be erased and refetched from the remote on the next listening.' "will be erased and refetched from the remote on the next listening."
) )
field_kwargs = { field_kwargs = {"required": False}
'required': False,
}
@global_preferences_registry.register @global_preferences_registry.register
class Enabled(preferences.DefaultFromSettingMixin, types.BooleanPreference): class Enabled(preferences.DefaultFromSettingMixin, types.BooleanPreference):
section = federation section = federation
name = 'enabled' name = "enabled"
setting = 'FEDERATION_ENABLED' setting = "FEDERATION_ENABLED"
verbose_name = 'Federation enabled' verbose_name = "Federation enabled"
help_text = ( help_text = (
'Use this setting to enable or disable federation logic and API' "Use this setting to enable or disable federation logic and API" " globally."
' globally.'
) )
@global_preferences_registry.register @global_preferences_registry.register
class CollectionPageSize( class CollectionPageSize(preferences.DefaultFromSettingMixin, types.IntPreference):
preferences.DefaultFromSettingMixin, types.IntPreference):
section = federation section = federation
name = 'collection_page_size' name = "collection_page_size"
setting = 'FEDERATION_COLLECTION_PAGE_SIZE' setting = "FEDERATION_COLLECTION_PAGE_SIZE"
verbose_name = 'Federation collection page size' verbose_name = "Federation collection page size"
help_text = ( help_text = "How much items to display in ActivityPub collections."
'How much items to display in ActivityPub collections.' field_kwargs = {"required": False}
)
field_kwargs = {
'required': False,
}
@global_preferences_registry.register @global_preferences_registry.register
class ActorFetchDelay( class ActorFetchDelay(preferences.DefaultFromSettingMixin, types.IntPreference):
preferences.DefaultFromSettingMixin, types.IntPreference):
section = federation section = federation
name = 'actor_fetch_delay' name = "actor_fetch_delay"
setting = 'FEDERATION_ACTOR_FETCH_DELAY' setting = "FEDERATION_ACTOR_FETCH_DELAY"
verbose_name = 'Federation actor fetch delay' verbose_name = "Federation actor fetch delay"
help_text = ( help_text = (
'How much minutes to wait before refetching actors on ' "How much minutes to wait before refetching actors on "
'request authentication.' "request authentication."
) )
field_kwargs = { field_kwargs = {"required": False}
'required': False,
}
@global_preferences_registry.register @global_preferences_registry.register
class MusicNeedsApproval( class MusicNeedsApproval(preferences.DefaultFromSettingMixin, types.BooleanPreference):
preferences.DefaultFromSettingMixin, types.BooleanPreference):
section = federation section = federation
name = 'music_needs_approval' name = "music_needs_approval"
setting = 'FEDERATION_MUSIC_NEEDS_APPROVAL' setting = "FEDERATION_MUSIC_NEEDS_APPROVAL"
verbose_name = 'Federation music needs approval' verbose_name = "Federation music needs approval"
help_text = ( help_text = (
'When true, other federation actors will need your approval' "When true, other federation actors will need your approval"
' before being able to browse your library.' " before being able to browse your library."
) )

View File

@ -1,5 +1,3 @@
class MalformedPayload(ValueError): class MalformedPayload(ValueError):
pass pass

View File

@ -12,29 +12,25 @@ from . import keys
from . import models from . import models
registry.register(keys.get_key_pair, name='federation.KeyPair') registry.register(keys.get_key_pair, name="federation.KeyPair")
@registry.register(name='federation.SignatureAuth') @registry.register(name="federation.SignatureAuth")
class SignatureAuthFactory(factory.Factory): class SignatureAuthFactory(factory.Factory):
algorithm = 'rsa-sha256' algorithm = "rsa-sha256"
key = factory.LazyFunction(lambda: keys.get_key_pair()[0]) key = factory.LazyFunction(lambda: keys.get_key_pair()[0])
key_id = factory.Faker('url') key_id = factory.Faker("url")
use_auth_header = False use_auth_header = False
headers = [ headers = ["(request-target)", "user-agent", "host", "date", "content-type"]
'(request-target)',
'user-agent',
'host',
'date',
'content-type',]
class Meta: class Meta:
model = requests_http_signature.HTTPSignatureAuth model = requests_http_signature.HTTPSignatureAuth
@registry.register(name='federation.SignedRequest') @registry.register(name="federation.SignedRequest")
class SignedRequestFactory(factory.Factory): class SignedRequestFactory(factory.Factory):
url = factory.Faker('url') url = factory.Faker("url")
method = 'get' method = "get"
auth = factory.SubFactory(SignatureAuthFactory) auth = factory.SubFactory(SignatureAuthFactory)
class Meta: class Meta:
@ -43,59 +39,62 @@ class SignedRequestFactory(factory.Factory):
@factory.post_generation @factory.post_generation
def headers(self, create, extracted, **kwargs): def headers(self, create, extracted, **kwargs):
default_headers = { default_headers = {
'User-Agent': 'Test', "User-Agent": "Test",
'Host': 'test.host', "Host": "test.host",
'Date': 'Right now', "Date": "Right now",
'Content-Type': 'application/activity+json' "Content-Type": "application/activity+json",
} }
if extracted: if extracted:
default_headers.update(extracted) default_headers.update(extracted)
self.headers.update(default_headers) self.headers.update(default_headers)
@registry.register(name='federation.Link') @registry.register(name="federation.Link")
class LinkFactory(factory.Factory): class LinkFactory(factory.Factory):
type = 'Link' type = "Link"
href = factory.Faker('url') href = factory.Faker("url")
mediaType = 'text/html' mediaType = "text/html"
class Meta: class Meta:
model = dict model = dict
class Params: class Params:
audio = factory.Trait( audio = factory.Trait(mediaType=factory.Iterator(["audio/mp3", "audio/ogg"]))
mediaType=factory.Iterator(['audio/mp3', 'audio/ogg'])
)
@registry.register @registry.register
class ActorFactory(factory.DjangoModelFactory): class ActorFactory(factory.DjangoModelFactory):
public_key = None public_key = None
private_key = None private_key = None
preferred_username = factory.Faker('user_name') preferred_username = factory.Faker("user_name")
summary = factory.Faker('paragraph') summary = factory.Faker("paragraph")
domain = factory.Faker('domain_name') domain = factory.Faker("domain_name")
url = factory.LazyAttribute(lambda o: 'https://{}/users/{}'.format(o.domain, o.preferred_username)) url = factory.LazyAttribute(
inbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/inbox'.format(o.domain, o.preferred_username)) lambda o: "https://{}/users/{}".format(o.domain, o.preferred_username)
outbox_url = factory.LazyAttribute(lambda o: 'https://{}/users/{}/outbox'.format(o.domain, o.preferred_username)) )
inbox_url = factory.LazyAttribute(
lambda o: "https://{}/users/{}/inbox".format(o.domain, o.preferred_username)
)
outbox_url = factory.LazyAttribute(
lambda o: "https://{}/users/{}/outbox".format(o.domain, o.preferred_username)
)
class Meta: class Meta:
model = models.Actor model = models.Actor
class Params: class Params:
local = factory.Trait( local = factory.Trait(
domain=factory.LazyAttribute( domain=factory.LazyAttribute(lambda o: settings.FEDERATION_HOSTNAME)
lambda o: settings.FEDERATION_HOSTNAME)
) )
@classmethod @classmethod
def _generate(cls, create, attrs): def _generate(cls, create, attrs):
has_public = attrs.get('public_key') is not None has_public = attrs.get("public_key") is not None
has_private = attrs.get('private_key') is not None has_private = attrs.get("private_key") is not None
if not has_public and not has_private: if not has_public and not has_private:
private, public = keys.get_key_pair() private, public = keys.get_key_pair()
attrs['private_key'] = private.decode('utf-8') attrs["private_key"] = private.decode("utf-8")
attrs['public_key'] = public.decode('utf-8') attrs["public_key"] = public.decode("utf-8")
return super()._generate(create, attrs) return super()._generate(create, attrs)
@ -108,15 +107,13 @@ class FollowFactory(factory.DjangoModelFactory):
model = models.Follow model = models.Follow
class Params: class Params:
local = factory.Trait( local = factory.Trait(actor=factory.SubFactory(ActorFactory, local=True))
actor=factory.SubFactory(ActorFactory, local=True)
)
@registry.register @registry.register
class LibraryFactory(factory.DjangoModelFactory): class LibraryFactory(factory.DjangoModelFactory):
actor = factory.SubFactory(ActorFactory) actor = factory.SubFactory(ActorFactory)
url = factory.Faker('url') url = factory.Faker("url")
federation_enabled = True federation_enabled = True
download_files = False download_files = False
autoimport = False autoimport = False
@ -126,42 +123,36 @@ class LibraryFactory(factory.DjangoModelFactory):
class ArtistMetadataFactory(factory.Factory): class ArtistMetadataFactory(factory.Factory):
name = factory.Faker('name') name = factory.Faker("name")
class Meta: class Meta:
model = dict model = dict
class Params: class Params:
musicbrainz = factory.Trait( musicbrainz = factory.Trait(musicbrainz_id=factory.Faker("uuid4"))
musicbrainz_id=factory.Faker('uuid4')
)
class ReleaseMetadataFactory(factory.Factory): class ReleaseMetadataFactory(factory.Factory):
title = factory.Faker('sentence') title = factory.Faker("sentence")
class Meta: class Meta:
model = dict model = dict
class Params: class Params:
musicbrainz = factory.Trait( musicbrainz = factory.Trait(musicbrainz_id=factory.Faker("uuid4"))
musicbrainz_id=factory.Faker('uuid4')
)
class RecordingMetadataFactory(factory.Factory): class RecordingMetadataFactory(factory.Factory):
title = factory.Faker('sentence') title = factory.Faker("sentence")
class Meta: class Meta:
model = dict model = dict
class Params: class Params:
musicbrainz = factory.Trait( musicbrainz = factory.Trait(musicbrainz_id=factory.Faker("uuid4"))
musicbrainz_id=factory.Faker('uuid4')
)
@registry.register(name='federation.LibraryTrackMetadata') @registry.register(name="federation.LibraryTrackMetadata")
class LibraryTrackMetadataFactory(factory.Factory): class LibraryTrackMetadataFactory(factory.Factory):
artist = factory.SubFactory(ArtistMetadataFactory) artist = factory.SubFactory(ArtistMetadataFactory)
recording = factory.SubFactory(RecordingMetadataFactory) recording = factory.SubFactory(RecordingMetadataFactory)
@ -174,64 +165,59 @@ class LibraryTrackMetadataFactory(factory.Factory):
@registry.register @registry.register
class LibraryTrackFactory(factory.DjangoModelFactory): class LibraryTrackFactory(factory.DjangoModelFactory):
library = factory.SubFactory(LibraryFactory) library = factory.SubFactory(LibraryFactory)
url = factory.Faker('url') url = factory.Faker("url")
title = factory.Faker('sentence') title = factory.Faker("sentence")
artist_name = factory.Faker('sentence') artist_name = factory.Faker("sentence")
album_title = factory.Faker('sentence') album_title = factory.Faker("sentence")
audio_url = factory.Faker('url') audio_url = factory.Faker("url")
audio_mimetype = 'audio/ogg' audio_mimetype = "audio/ogg"
metadata = factory.SubFactory(LibraryTrackMetadataFactory) metadata = factory.SubFactory(LibraryTrackMetadataFactory)
class Meta: class Meta:
model = models.LibraryTrack model = models.LibraryTrack
class Params: class Params:
with_audio_file = factory.Trait( with_audio_file = factory.Trait(audio_file=factory.django.FileField())
audio_file=factory.django.FileField()
)
@registry.register(name='federation.Note') @registry.register(name="federation.Note")
class NoteFactory(factory.Factory): class NoteFactory(factory.Factory):
type = 'Note' type = "Note"
id = factory.Faker('url') id = factory.Faker("url")
published = factory.LazyFunction( published = factory.LazyFunction(lambda: timezone.now().isoformat())
lambda: timezone.now().isoformat()
)
inReplyTo = None inReplyTo = None
content = factory.Faker('sentence') content = factory.Faker("sentence")
class Meta: class Meta:
model = dict model = dict
@registry.register(name='federation.Activity') @registry.register(name="federation.Activity")
class ActivityFactory(factory.Factory): class ActivityFactory(factory.Factory):
type = 'Create' type = "Create"
id = factory.Faker('url') id = factory.Faker("url")
published = factory.LazyFunction( published = factory.LazyFunction(lambda: timezone.now().isoformat())
lambda: timezone.now().isoformat() actor = factory.Faker("url")
)
actor = factory.Faker('url')
object = factory.SubFactory( object = factory.SubFactory(
NoteFactory, NoteFactory,
actor=factory.SelfAttribute('..actor'), actor=factory.SelfAttribute("..actor"),
published=factory.SelfAttribute('..published')) published=factory.SelfAttribute("..published"),
)
class Meta: class Meta:
model = dict model = dict
@registry.register(name='federation.AudioMetadata') @registry.register(name="federation.AudioMetadata")
class AudioMetadataFactory(factory.Factory): class AudioMetadataFactory(factory.Factory):
recording = factory.LazyAttribute( recording = factory.LazyAttribute(
lambda o: 'https://musicbrainz.org/recording/{}'.format(uuid.uuid4()) lambda o: "https://musicbrainz.org/recording/{}".format(uuid.uuid4())
) )
artist = factory.LazyAttribute( artist = factory.LazyAttribute(
lambda o: 'https://musicbrainz.org/artist/{}'.format(uuid.uuid4()) lambda o: "https://musicbrainz.org/artist/{}".format(uuid.uuid4())
) )
release = factory.LazyAttribute( release = factory.LazyAttribute(
lambda o: 'https://musicbrainz.org/release/{}'.format(uuid.uuid4()) lambda o: "https://musicbrainz.org/release/{}".format(uuid.uuid4())
) )
bitrate = 42 bitrate = 42
length = 43 length = 43
@ -241,14 +227,12 @@ class AudioMetadataFactory(factory.Factory):
model = dict model = dict
@registry.register(name='federation.Audio') @registry.register(name="federation.Audio")
class AudioFactory(factory.Factory): class AudioFactory(factory.Factory):
type = 'Audio' type = "Audio"
id = factory.Faker('url') id = factory.Faker("url")
published = factory.LazyFunction( published = factory.LazyFunction(lambda: timezone.now().isoformat())
lambda: timezone.now().isoformat() actor = factory.Faker("url")
)
actor = factory.Faker('url')
url = factory.SubFactory(LinkFactory, audio=True) url = factory.SubFactory(LinkFactory, audio=True)
metadata = factory.SubFactory(LibraryTrackMetadataFactory) metadata = factory.SubFactory(LibraryTrackMetadataFactory)

View File

@ -6,73 +6,67 @@ from . import models
class LibraryFilter(django_filters.FilterSet): class LibraryFilter(django_filters.FilterSet):
approved = django_filters.BooleanFilter('following__approved') approved = django_filters.BooleanFilter("following__approved")
q = fields.SearchFilter(search_fields=[ q = fields.SearchFilter(search_fields=["actor__domain"])
'actor__domain',
])
class Meta: class Meta:
model = models.Library model = models.Library
fields = { fields = {
'approved': ['exact'], "approved": ["exact"],
'federation_enabled': ['exact'], "federation_enabled": ["exact"],
'download_files': ['exact'], "download_files": ["exact"],
'autoimport': ['exact'], "autoimport": ["exact"],
'tracks_count': ['exact'], "tracks_count": ["exact"],
} }
class LibraryTrackFilter(django_filters.FilterSet): class LibraryTrackFilter(django_filters.FilterSet):
library = django_filters.CharFilter('library__uuid') library = django_filters.CharFilter("library__uuid")
status = django_filters.CharFilter(method='filter_status') status = django_filters.CharFilter(method="filter_status")
q = fields.SearchFilter(search_fields=[ q = fields.SearchFilter(
'artist_name', search_fields=["artist_name", "title", "album_title", "library__actor__domain"]
'title', )
'album_title',
'library__actor__domain',
])
def filter_status(self, queryset, field_name, value): def filter_status(self, queryset, field_name, value):
if value == 'imported': if value == "imported":
return queryset.filter(local_track_file__isnull=False) return queryset.filter(local_track_file__isnull=False)
elif value == 'not_imported': elif value == "not_imported":
return queryset.filter( return queryset.filter(local_track_file__isnull=True).exclude(
local_track_file__isnull=True import_jobs__status="pending"
).exclude(import_jobs__status='pending') )
elif value == 'import_pending': elif value == "import_pending":
return queryset.filter(import_jobs__status='pending') return queryset.filter(import_jobs__status="pending")
return queryset return queryset
class Meta: class Meta:
model = models.LibraryTrack model = models.LibraryTrack
fields = { fields = {
'library': ['exact'], "library": ["exact"],
'artist_name': ['exact', 'icontains'], "artist_name": ["exact", "icontains"],
'title': ['exact', 'icontains'], "title": ["exact", "icontains"],
'album_title': ['exact', 'icontains'], "album_title": ["exact", "icontains"],
'audio_mimetype': ['exact', 'icontains'], "audio_mimetype": ["exact", "icontains"],
} }
class FollowFilter(django_filters.FilterSet): class FollowFilter(django_filters.FilterSet):
pending = django_filters.CharFilter(method='filter_pending') pending = django_filters.CharFilter(method="filter_pending")
ordering = django_filters.OrderingFilter( ordering = django_filters.OrderingFilter(
# tuple-mapping retains order # tuple-mapping retains order
fields=( fields=(
('creation_date', 'creation_date'), ("creation_date", "creation_date"),
('modification_date', 'modification_date'), ("modification_date", "modification_date"),
), )
)
q = fields.SearchFilter(
search_fields=["actor__domain", "actor__preferred_username"]
) )
q = fields.SearchFilter(search_fields=[
'actor__domain',
'actor__preferred_username',
])
class Meta: class Meta:
model = models.Follow model = models.Follow
fields = ['approved', 'pending', 'q'] fields = ["approved", "pending", "q"]
def filter_pending(self, queryset, field_name, value): def filter_pending(self, queryset, field_name, value):
if value.lower() in ['true', '1', 'yes']: if value.lower() in ["true", "1", "yes"]:
queryset = queryset.filter(approved__isnull=True) queryset = queryset.filter(approved__isnull=True)
return queryset return queryset

View File

@ -7,42 +7,40 @@ import urllib.parse
from . import exceptions from . import exceptions
KEY_ID_REGEX = re.compile(r'keyId=\"(?P<id>.*)\"') KEY_ID_REGEX = re.compile(r"keyId=\"(?P<id>.*)\"")
def get_key_pair(size=2048): def get_key_pair(size=2048):
key = rsa.generate_private_key( key = rsa.generate_private_key(
backend=crypto_default_backend(), backend=crypto_default_backend(), public_exponent=65537, key_size=size
public_exponent=65537,
key_size=size
) )
private_key = key.private_bytes( private_key = key.private_bytes(
crypto_serialization.Encoding.PEM, crypto_serialization.Encoding.PEM,
crypto_serialization.PrivateFormat.PKCS8, crypto_serialization.PrivateFormat.PKCS8,
crypto_serialization.NoEncryption()) crypto_serialization.NoEncryption(),
)
public_key = key.public_key().public_bytes( public_key = key.public_key().public_bytes(
crypto_serialization.Encoding.PEM, crypto_serialization.Encoding.PEM, crypto_serialization.PublicFormat.PKCS1
crypto_serialization.PublicFormat.PKCS1
) )
return private_key, public_key return private_key, public_key
def get_key_id_from_signature_header(header_string): def get_key_id_from_signature_header(header_string):
parts = header_string.split(',') parts = header_string.split(",")
try: try:
raw_key_id = [p for p in parts if p.startswith('keyId="')][0] raw_key_id = [p for p in parts if p.startswith('keyId="')][0]
except IndexError: except IndexError:
raise ValueError('Missing key id') raise ValueError("Missing key id")
match = KEY_ID_REGEX.match(raw_key_id) match = KEY_ID_REGEX.match(raw_key_id)
if not match: if not match:
raise ValueError('Invalid key id') raise ValueError("Invalid key id")
key_id = match.groups()[0] key_id = match.groups()[0]
url = urllib.parse.urlparse(key_id) url = urllib.parse.urlparse(key_id)
if not url.scheme or not url.netloc: if not url.scheme or not url.netloc:
raise ValueError('Invalid url') raise ValueError("Invalid url")
if url.scheme not in ['http', 'https']: if url.scheme not in ["http", "https"]:
raise ValueError('Invalid shceme') raise ValueError("Invalid shceme")
return key_id return key_id

View File

@ -24,87 +24,66 @@ def scan_from_account_name(account_name):
""" """
data = {} data = {}
try: try:
username, domain = webfinger.clean_acct( username, domain = webfinger.clean_acct(account_name, ensure_local=False)
account_name, ensure_local=False)
except serializers.ValidationError: except serializers.ValidationError:
return { return {"webfinger": {"errors": ["Invalid account string"]}}
'webfinger': { system_library = actors.SYSTEM_ACTORS["library"].get_actor_instance()
'errors': ['Invalid account string'] library = (
} models.Library.objects.filter(
} actor__domain=domain, actor__preferred_username=username
system_library = actors.SYSTEM_ACTORS['library'].get_actor_instance() )
library = models.Library.objects.filter( .select_related("actor")
actor__domain=domain, .first()
actor__preferred_username=username )
).select_related('actor').first() data["local"] = {"following": False, "awaiting_approval": False}
data['local'] = {
'following': False,
'awaiting_approval': False,
}
try: try:
follow = models.Follow.objects.get( follow = models.Follow.objects.get(
target__preferred_username=username, target__preferred_username=username,
target__domain=username, target__domain=username,
actor=system_library, actor=system_library,
) )
data['local']['awaiting_approval'] = not bool(follow.approved) data["local"]["awaiting_approval"] = not bool(follow.approved)
data['local']['following'] = True data["local"]["following"] = True
except models.Follow.DoesNotExist: except models.Follow.DoesNotExist:
pass pass
try: try:
data['webfinger'] = webfinger.get_resource( data["webfinger"] = webfinger.get_resource("acct:{}".format(account_name))
'acct:{}'.format(account_name))
except requests.ConnectionError: except requests.ConnectionError:
return { return {"webfinger": {"errors": ["This webfinger resource is not reachable"]}}
'webfinger': {
'errors': ['This webfinger resource is not reachable']
}
}
except requests.HTTPError as e: except requests.HTTPError as e:
return { return {
'webfinger': { "webfinger": {
'errors': [ "errors": [
'Error {} during webfinger request'.format( "Error {} during webfinger request".format(e.response.status_code)
e.response.status_code)] ]
} }
} }
except json.JSONDecodeError as e: except json.JSONDecodeError as e:
return { return {"webfinger": {"errors": ["Could not process webfinger response"]}}
'webfinger': {
'errors': ['Could not process webfinger response']
}
}
try: try:
data['actor'] = actors.get_actor_data(data['webfinger']['actor_url']) data["actor"] = actors.get_actor_data(data["webfinger"]["actor_url"])
except requests.ConnectionError: except requests.ConnectionError:
data['actor'] = { data["actor"] = {"errors": ["This actor is not reachable"]}
'errors': ['This actor is not reachable']
}
return data return data
except requests.HTTPError as e: except requests.HTTPError as e:
data['actor'] = { data["actor"] = {
'errors': [ "errors": ["Error {} during actor request".format(e.response.status_code)]
'Error {} during actor request'.format(
e.response.status_code)]
} }
return data return data
serializer = serializers.LibraryActorSerializer(data=data['actor']) serializer = serializers.LibraryActorSerializer(data=data["actor"])
if not serializer.is_valid(): if not serializer.is_valid():
data['actor'] = { data["actor"] = {"errors": ["Invalid ActivityPub actor"]}
'errors': ['Invalid ActivityPub actor']
}
return data return data
data['library'] = get_library_data( data["library"] = get_library_data(serializer.validated_data["library_url"])
serializer.validated_data['library_url'])
return data return data
def get_library_data(library_url): def get_library_data(library_url):
actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
auth = signing.get_auth(actor.private_key, actor.private_key_id) auth = signing.get_auth(actor.private_key, actor.private_key_id)
try: try:
response = session.get_session().get( response = session.get_session().get(
@ -112,55 +91,37 @@ def get_library_data(library_url):
auth=auth, auth=auth,
timeout=5, timeout=5,
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
headers={ headers={"Content-Type": "application/activity+json"},
'Content-Type': 'application/activity+json'
}
) )
except requests.ConnectionError: except requests.ConnectionError:
return { return {"errors": ["This library is not reachable"]}
'errors': ['This library is not reachable']
}
scode = response.status_code scode = response.status_code
if scode == 401: if scode == 401:
return { return {"errors": ["This library requires authentication"]}
'errors': ['This library requires authentication']
}
elif scode == 403: elif scode == 403:
return { return {"errors": ["Permission denied while scanning library"]}
'errors': ['Permission denied while scanning library']
}
elif scode >= 400: elif scode >= 400:
return { return {"errors": ["Error {} while fetching the library".format(scode)]}
'errors': ['Error {} while fetching the library'.format(scode)] serializer = serializers.PaginatedCollectionSerializer(data=response.json())
}
serializer = serializers.PaginatedCollectionSerializer(
data=response.json(),
)
if not serializer.is_valid(): if not serializer.is_valid():
return { return {"errors": ["Invalid ActivityPub response from remote library"]}
'errors': [
'Invalid ActivityPub response from remote library']
}
return serializer.validated_data return serializer.validated_data
def get_library_page(library, page_url): def get_library_page(library, page_url):
actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
auth = signing.get_auth(actor.private_key, actor.private_key_id) auth = signing.get_auth(actor.private_key, actor.private_key_id)
response = session.get_session().get( response = session.get_session().get(
page_url, page_url,
auth=auth, auth=auth,
timeout=5, timeout=5,
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
headers={ headers={"Content-Type": "application/activity+json"},
'Content-Type': 'application/activity+json'
}
) )
serializer = serializers.CollectionPageSerializer( serializer = serializers.CollectionPageSerializer(
data=response.json(), data=response.json(),
context={ context={"library": library, "item_serializer": serializers.AudioSerializer},
'library': library, )
'item_serializer': serializers.AudioSerializer})
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
return serializer.validated_data return serializer.validated_data

View File

@ -8,30 +8,74 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = []
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Actor', name="Actor",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('url', models.URLField(db_index=True, max_length=500, unique=True)), "id",
('outbox_url', models.URLField(max_length=500)), models.AutoField(
('inbox_url', models.URLField(max_length=500)), auto_created=True,
('following_url', models.URLField(blank=True, max_length=500, null=True)), primary_key=True,
('followers_url', models.URLField(blank=True, max_length=500, null=True)), serialize=False,
('shared_inbox_url', models.URLField(blank=True, max_length=500, null=True)), verbose_name="ID",
('type', models.CharField(choices=[('Person', 'Person'), ('Application', 'Application'), ('Group', 'Group'), ('Organization', 'Organization'), ('Service', 'Service')], default='Person', max_length=25)), ),
('name', models.CharField(blank=True, max_length=200, null=True)), ),
('domain', models.CharField(max_length=1000)), ("url", models.URLField(db_index=True, max_length=500, unique=True)),
('summary', models.CharField(blank=True, max_length=500, null=True)), ("outbox_url", models.URLField(max_length=500)),
('preferred_username', models.CharField(blank=True, max_length=200, null=True)), ("inbox_url", models.URLField(max_length=500)),
('public_key', models.CharField(blank=True, max_length=5000, null=True)), (
('private_key', models.CharField(blank=True, max_length=5000, null=True)), "following_url",
('creation_date', models.DateTimeField(default=django.utils.timezone.now)), models.URLField(blank=True, max_length=500, null=True),
('last_fetch_date', models.DateTimeField(default=django.utils.timezone.now)), ),
('manually_approves_followers', models.NullBooleanField(default=None)), (
"followers_url",
models.URLField(blank=True, max_length=500, null=True),
),
(
"shared_inbox_url",
models.URLField(blank=True, max_length=500, null=True),
),
(
"type",
models.CharField(
choices=[
("Person", "Person"),
("Application", "Application"),
("Group", "Group"),
("Organization", "Organization"),
("Service", "Service"),
],
default="Person",
max_length=25,
),
),
("name", models.CharField(blank=True, max_length=200, null=True)),
("domain", models.CharField(max_length=1000)),
("summary", models.CharField(blank=True, max_length=500, null=True)),
(
"preferred_username",
models.CharField(blank=True, max_length=200, null=True),
),
(
"public_key",
models.CharField(blank=True, max_length=5000, null=True),
),
(
"private_key",
models.CharField(blank=True, max_length=5000, null=True),
),
(
"creation_date",
models.DateTimeField(default=django.utils.timezone.now),
),
(
"last_fetch_date",
models.DateTimeField(default=django.utils.timezone.now),
),
("manually_approves_followers", models.NullBooleanField(default=None)),
], ],
), )
] ]

View File

@ -5,13 +5,10 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [("federation", "0001_initial")]
('federation', '0001_initial'),
]
operations = [ operations = [
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='actor', name="actor", unique_together={("domain", "preferred_username")}
unique_together={('domain', 'preferred_username')}, )
),
] ]

View File

@ -10,7 +10,7 @@ import uuid
def delete_system_actors(apps, schema_editor): def delete_system_actors(apps, schema_editor):
"""Revert site domain and name to default.""" """Revert site domain and name to default."""
Actor = apps.get_model("federation", "Actor") Actor = apps.get_model("federation", "Actor")
Actor.objects.filter(preferred_username__in=['test', 'library']).delete() Actor.objects.filter(preferred_username__in=["test", "library"]).delete()
def backward(apps, schema_editor): def backward(apps, schema_editor):
@ -19,76 +19,168 @@ def backward(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [("federation", "0002_auto_20180403_1620")]
('federation', '0002_auto_20180403_1620'),
]
operations = [ operations = [
migrations.RunPython(delete_system_actors, backward), migrations.RunPython(delete_system_actors, backward),
migrations.CreateModel( migrations.CreateModel(
name='Follow', name="Follow",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), "id",
('creation_date', models.DateTimeField(default=django.utils.timezone.now)), models.AutoField(
('modification_date', models.DateTimeField(auto_now=True)), auto_created=True,
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emitted_follows', to='federation.Actor')), primary_key=True,
('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_follows', to='federation.Actor')), serialize=False,
verbose_name="ID",
),
),
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
(
"creation_date",
models.DateTimeField(default=django.utils.timezone.now),
),
("modification_date", models.DateTimeField(auto_now=True)),
(
"actor",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="emitted_follows",
to="federation.Actor",
),
),
(
"target",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="received_follows",
to="federation.Actor",
),
),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='FollowRequest', name="FollowRequest",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('uuid', models.UUIDField(default=uuid.uuid4, unique=True)), "id",
('creation_date', models.DateTimeField(default=django.utils.timezone.now)), models.AutoField(
('modification_date', models.DateTimeField(auto_now=True)), auto_created=True,
('approved', models.NullBooleanField(default=None)), primary_key=True,
('actor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='emmited_follow_requests', to='federation.Actor')), serialize=False,
('target', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='received_follow_requests', to='federation.Actor')), verbose_name="ID",
),
),
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
(
"creation_date",
models.DateTimeField(default=django.utils.timezone.now),
),
("modification_date", models.DateTimeField(auto_now=True)),
("approved", models.NullBooleanField(default=None)),
(
"actor",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="emmited_follow_requests",
to="federation.Actor",
),
),
(
"target",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="received_follow_requests",
to="federation.Actor",
),
),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='Library', name="Library",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('creation_date', models.DateTimeField(default=django.utils.timezone.now)), "id",
('modification_date', models.DateTimeField(auto_now=True)), models.AutoField(
('fetched_date', models.DateTimeField(blank=True, null=True)), auto_created=True,
('uuid', models.UUIDField(default=uuid.uuid4)), primary_key=True,
('url', models.URLField()), serialize=False,
('federation_enabled', models.BooleanField()), verbose_name="ID",
('download_files', models.BooleanField()), ),
('autoimport', models.BooleanField()), ),
('tracks_count', models.PositiveIntegerField(blank=True, null=True)), (
('actor', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='library', to='federation.Actor')), "creation_date",
models.DateTimeField(default=django.utils.timezone.now),
),
("modification_date", models.DateTimeField(auto_now=True)),
("fetched_date", models.DateTimeField(blank=True, null=True)),
("uuid", models.UUIDField(default=uuid.uuid4)),
("url", models.URLField()),
("federation_enabled", models.BooleanField()),
("download_files", models.BooleanField()),
("autoimport", models.BooleanField()),
("tracks_count", models.PositiveIntegerField(blank=True, null=True)),
(
"actor",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="library",
to="federation.Actor",
),
),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='LibraryTrack', name="LibraryTrack",
fields=[ fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), (
('url', models.URLField(unique=True)), "id",
('audio_url', models.URLField()), models.AutoField(
('audio_mimetype', models.CharField(max_length=200)), auto_created=True,
('creation_date', models.DateTimeField(default=django.utils.timezone.now)), primary_key=True,
('modification_date', models.DateTimeField(auto_now=True)), serialize=False,
('fetched_date', models.DateTimeField(blank=True, null=True)), verbose_name="ID",
('published_date', models.DateTimeField(blank=True, null=True)), ),
('artist_name', models.CharField(max_length=500)), ),
('album_title', models.CharField(max_length=500)), ("url", models.URLField(unique=True)),
('title', models.CharField(max_length=500)), ("audio_url", models.URLField()),
('metadata', django.contrib.postgres.fields.jsonb.JSONField(default={}, max_length=10000)), ("audio_mimetype", models.CharField(max_length=200)),
('library', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tracks', to='federation.Library')), (
"creation_date",
models.DateTimeField(default=django.utils.timezone.now),
),
("modification_date", models.DateTimeField(auto_now=True)),
("fetched_date", models.DateTimeField(blank=True, null=True)),
("published_date", models.DateTimeField(blank=True, null=True)),
("artist_name", models.CharField(max_length=500)),
("album_title", models.CharField(max_length=500)),
("title", models.CharField(max_length=500)),
(
"metadata",
django.contrib.postgres.fields.jsonb.JSONField(
default={}, max_length=10000
),
),
(
"library",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="tracks",
to="federation.Library",
),
),
], ],
), ),
migrations.AddField( migrations.AddField(
model_name='actor', model_name="actor",
name='followers', name="followers",
field=models.ManyToManyField(related_name='following', through='federation.Follow', to='federation.Actor'), field=models.ManyToManyField(
related_name="following",
through="federation.Follow",
to="federation.Actor",
),
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(
name='follow', name="follow", unique_together={("actor", "target")}
unique_together={('actor', 'target')},
), ),
] ]

View File

@ -6,30 +6,26 @@ import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [("federation", "0003_auto_20180407_1010")]
('federation', '0003_auto_20180407_1010'),
]
operations = [ operations = [
migrations.RemoveField( migrations.RemoveField(model_name="followrequest", name="actor"),
model_name='followrequest', migrations.RemoveField(model_name="followrequest", name="target"),
name='actor',
),
migrations.RemoveField(
model_name='followrequest',
name='target',
),
migrations.AddField( migrations.AddField(
model_name='follow', model_name="follow",
name='approved', name="approved",
field=models.NullBooleanField(default=None), field=models.NullBooleanField(default=None),
), ),
migrations.AddField( migrations.AddField(
model_name='library', model_name="library",
name='follow', name="follow",
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='library', to='federation.Follow'), field=models.OneToOneField(
), blank=True,
migrations.DeleteModel( null=True,
name='FollowRequest', on_delete=django.db.models.deletion.SET_NULL,
related_name="library",
to="federation.Follow",
),
), ),
migrations.DeleteModel(name="FollowRequest"),
] ]

View File

@ -8,19 +8,25 @@ import funkwhale_api.federation.models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [("federation", "0004_auto_20180410_2025")]
('federation', '0004_auto_20180410_2025'),
]
operations = [ operations = [
migrations.AddField( migrations.AddField(
model_name='librarytrack', model_name="librarytrack",
name='audio_file', name="audio_file",
field=models.FileField(blank=True, null=True, upload_to=funkwhale_api.federation.models.get_file_path), field=models.FileField(
blank=True,
null=True,
upload_to=funkwhale_api.federation.models.get_file_path,
),
), ),
migrations.AlterField( migrations.AlterField(
model_name='librarytrack', model_name="librarytrack",
name='metadata', name="metadata",
field=django.contrib.postgres.fields.jsonb.JSONField(default={}, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=10000), field=django.contrib.postgres.fields.jsonb.JSONField(
default={},
encoder=django.core.serializers.json.DjangoJSONEncoder,
max_length=10000,
),
), ),
] ]

View File

@ -5,24 +5,20 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [("federation", "0005_auto_20180413_1723")]
('federation', '0005_auto_20180413_1723'),
]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='library', model_name="library", name="url", field=models.URLField(max_length=500)
name='url', ),
migrations.AlterField(
model_name="librarytrack",
name="audio_url",
field=models.URLField(max_length=500), field=models.URLField(max_length=500),
), ),
migrations.AlterField( migrations.AlterField(
model_name='librarytrack', model_name="librarytrack",
name='audio_url', name="url",
field=models.URLField(max_length=500),
),
migrations.AlterField(
model_name='librarytrack',
name='url',
field=models.URLField(max_length=500, unique=True), field=models.URLField(max_length=500, unique=True),
), ),
] ]

View File

@ -12,16 +12,16 @@ from funkwhale_api.common import session
from funkwhale_api.music import utils as music_utils from funkwhale_api.music import utils as music_utils
TYPE_CHOICES = [ TYPE_CHOICES = [
('Person', 'Person'), ("Person", "Person"),
('Application', 'Application'), ("Application", "Application"),
('Group', 'Group'), ("Group", "Group"),
('Organization', 'Organization'), ("Organization", "Organization"),
('Service', 'Service'), ("Service", "Service"),
] ]
class Actor(models.Model): class Actor(models.Model):
ap_type = 'Actor' ap_type = "Actor"
url = models.URLField(unique=True, max_length=500, db_index=True) url = models.URLField(unique=True, max_length=500, db_index=True)
outbox_url = models.URLField(max_length=500) outbox_url = models.URLField(max_length=500)
@ -29,49 +29,41 @@ class Actor(models.Model):
following_url = models.URLField(max_length=500, null=True, blank=True) following_url = models.URLField(max_length=500, null=True, blank=True)
followers_url = models.URLField(max_length=500, null=True, blank=True) followers_url = models.URLField(max_length=500, null=True, blank=True)
shared_inbox_url = models.URLField(max_length=500, null=True, blank=True) shared_inbox_url = models.URLField(max_length=500, null=True, blank=True)
type = models.CharField( type = models.CharField(choices=TYPE_CHOICES, default="Person", max_length=25)
choices=TYPE_CHOICES, default='Person', max_length=25)
name = models.CharField(max_length=200, null=True, blank=True) name = models.CharField(max_length=200, null=True, blank=True)
domain = models.CharField(max_length=1000) domain = models.CharField(max_length=1000)
summary = models.CharField(max_length=500, null=True, blank=True) summary = models.CharField(max_length=500, null=True, blank=True)
preferred_username = models.CharField( preferred_username = models.CharField(max_length=200, null=True, blank=True)
max_length=200, null=True, blank=True)
public_key = models.CharField(max_length=5000, null=True, blank=True) public_key = models.CharField(max_length=5000, null=True, blank=True)
private_key = models.CharField(max_length=5000, null=True, blank=True) private_key = models.CharField(max_length=5000, null=True, blank=True)
creation_date = models.DateTimeField(default=timezone.now) creation_date = models.DateTimeField(default=timezone.now)
last_fetch_date = models.DateTimeField( last_fetch_date = models.DateTimeField(default=timezone.now)
default=timezone.now)
manually_approves_followers = models.NullBooleanField(default=None) manually_approves_followers = models.NullBooleanField(default=None)
followers = models.ManyToManyField( followers = models.ManyToManyField(
to='self', to="self",
symmetrical=False, symmetrical=False,
through='Follow', through="Follow",
through_fields=('target', 'actor'), through_fields=("target", "actor"),
related_name='following', related_name="following",
) )
class Meta: class Meta:
unique_together = ['domain', 'preferred_username'] unique_together = ["domain", "preferred_username"]
@property @property
def webfinger_subject(self): def webfinger_subject(self):
return '{}@{}'.format( return "{}@{}".format(self.preferred_username, settings.FEDERATION_HOSTNAME)
self.preferred_username,
settings.FEDERATION_HOSTNAME,
)
@property @property
def private_key_id(self): def private_key_id(self):
return '{}#main-key'.format(self.url) return "{}#main-key".format(self.url)
@property @property
def mention_username(self): def mention_username(self):
return '@{}@{}'.format(self.preferred_username, self.domain) return "@{}@{}".format(self.preferred_username, self.domain)
def save(self, **kwargs): def save(self, **kwargs):
lowercase_fields = [ lowercase_fields = ["domain"]
'domain',
]
for field in lowercase_fields: for field in lowercase_fields:
v = getattr(self, field, None) v = getattr(self, field, None)
if v: if v:
@ -86,58 +78,54 @@ class Actor(models.Model):
@property @property
def is_system(self): def is_system(self):
from . import actors from . import actors
return all([
settings.FEDERATION_HOSTNAME == self.domain, return all(
self.preferred_username in actors.SYSTEM_ACTORS [
]) settings.FEDERATION_HOSTNAME == self.domain,
self.preferred_username in actors.SYSTEM_ACTORS,
]
)
@property @property
def system_conf(self): def system_conf(self):
from . import actors from . import actors
if self.is_system: if self.is_system:
return actors.SYSTEM_ACTORS[self.preferred_username] return actors.SYSTEM_ACTORS[self.preferred_username]
def get_approved_followers(self): def get_approved_followers(self):
follows = self.received_follows.filter(approved=True) follows = self.received_follows.filter(approved=True)
return self.followers.filter( return self.followers.filter(pk__in=follows.values_list("actor", flat=True))
pk__in=follows.values_list('actor', flat=True))
class Follow(models.Model): class Follow(models.Model):
ap_type = 'Follow' ap_type = "Follow"
uuid = models.UUIDField(default=uuid.uuid4, unique=True) uuid = models.UUIDField(default=uuid.uuid4, unique=True)
actor = models.ForeignKey( actor = models.ForeignKey(
Actor, Actor, related_name="emitted_follows", on_delete=models.CASCADE
related_name='emitted_follows',
on_delete=models.CASCADE,
) )
target = models.ForeignKey( target = models.ForeignKey(
Actor, Actor, related_name="received_follows", on_delete=models.CASCADE
related_name='received_follows',
on_delete=models.CASCADE,
) )
creation_date = models.DateTimeField(default=timezone.now) creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField( modification_date = models.DateTimeField(auto_now=True)
auto_now=True)
approved = models.NullBooleanField(default=None) approved = models.NullBooleanField(default=None)
class Meta: class Meta:
unique_together = ['actor', 'target'] unique_together = ["actor", "target"]
def get_federation_url(self): def get_federation_url(self):
return '{}#follows/{}'.format(self.actor.url, self.uuid) return "{}#follows/{}".format(self.actor.url, self.uuid)
class Library(models.Model): class Library(models.Model):
creation_date = models.DateTimeField(default=timezone.now) creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField( modification_date = models.DateTimeField(auto_now=True)
auto_now=True)
fetched_date = models.DateTimeField(null=True, blank=True) fetched_date = models.DateTimeField(null=True, blank=True)
actor = models.OneToOneField( actor = models.OneToOneField(
Actor, Actor, on_delete=models.CASCADE, related_name="library"
on_delete=models.CASCADE, )
related_name='library')
uuid = models.UUIDField(default=uuid.uuid4) uuid = models.UUIDField(default=uuid.uuid4)
url = models.URLField(max_length=500) url = models.URLField(max_length=500)
@ -149,69 +137,60 @@ class Library(models.Model):
autoimport = models.BooleanField() autoimport = models.BooleanField()
tracks_count = models.PositiveIntegerField(null=True, blank=True) tracks_count = models.PositiveIntegerField(null=True, blank=True)
follow = models.OneToOneField( follow = models.OneToOneField(
Follow, Follow, related_name="library", null=True, blank=True, on_delete=models.SET_NULL
related_name='library',
null=True,
blank=True,
on_delete=models.SET_NULL,
) )
def get_file_path(instance, filename): def get_file_path(instance, filename):
uid = str(uuid.uuid4()) uid = str(uuid.uuid4())
chunk_size = 2 chunk_size = 2
chunks = [uid[i:i+chunk_size] for i in range(0, len(uid), chunk_size)] chunks = [uid[i : i + chunk_size] for i in range(0, len(uid), chunk_size)]
parts = chunks[:3] + [filename] parts = chunks[:3] + [filename]
return os.path.join('federation_cache', *parts) return os.path.join("federation_cache", *parts)
class LibraryTrack(models.Model): class LibraryTrack(models.Model):
url = models.URLField(unique=True, max_length=500) url = models.URLField(unique=True, max_length=500)
audio_url = models.URLField(max_length=500) audio_url = models.URLField(max_length=500)
audio_mimetype = models.CharField(max_length=200) audio_mimetype = models.CharField(max_length=200)
audio_file = models.FileField( audio_file = models.FileField(upload_to=get_file_path, null=True, blank=True)
upload_to=get_file_path,
null=True,
blank=True)
creation_date = models.DateTimeField(default=timezone.now) creation_date = models.DateTimeField(default=timezone.now)
modification_date = models.DateTimeField( modification_date = models.DateTimeField(auto_now=True)
auto_now=True)
fetched_date = models.DateTimeField(null=True, blank=True) fetched_date = models.DateTimeField(null=True, blank=True)
published_date = models.DateTimeField(null=True, blank=True) published_date = models.DateTimeField(null=True, blank=True)
library = models.ForeignKey( library = models.ForeignKey(
Library, related_name='tracks', on_delete=models.CASCADE) Library, related_name="tracks", on_delete=models.CASCADE
)
artist_name = models.CharField(max_length=500) artist_name = models.CharField(max_length=500)
album_title = models.CharField(max_length=500) album_title = models.CharField(max_length=500)
title = models.CharField(max_length=500) title = models.CharField(max_length=500)
metadata = JSONField( metadata = JSONField(default={}, max_length=10000, encoder=DjangoJSONEncoder)
default={}, max_length=10000, encoder=DjangoJSONEncoder)
@property @property
def mbid(self): def mbid(self):
try: try:
return self.metadata['recording']['musicbrainz_id'] return self.metadata["recording"]["musicbrainz_id"]
except KeyError: except KeyError:
pass pass
def download_audio(self): def download_audio(self):
from . import actors from . import actors
auth = actors.SYSTEM_ACTORS['library'].get_request_auth()
auth = actors.SYSTEM_ACTORS["library"].get_request_auth()
remote_response = session.get_session().get( remote_response = session.get_session().get(
self.audio_url, self.audio_url,
auth=auth, auth=auth,
stream=True, stream=True,
timeout=20, timeout=20,
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
headers={ headers={"Content-Type": "application/activity+json"},
'Content-Type': 'application/activity+json'
}
) )
with remote_response as r: with remote_response as r:
remote_response.raise_for_status() remote_response.raise_for_status()
extension = music_utils.get_ext_from_type(self.audio_mimetype) extension = music_utils.get_ext_from_type(self.audio_mimetype)
title = ' - '.join([self.title, self.album_title, self.artist_name]) title = " - ".join([self.title, self.album_title, self.artist_name])
filename = '{}.{}'.format(title, extension) filename = "{}.{}".format(title, extension)
tmp_file = tempfile.TemporaryFile() tmp_file = tempfile.TemporaryFile()
for chunk in r.iter_content(chunk_size=512): for chunk in r.iter_content(chunk_size=512):
tmp_file.write(chunk) tmp_file.write(chunk)

View File

@ -2,4 +2,4 @@ from rest_framework import parsers
class ActivityParser(parsers.JSONParser): class ActivityParser(parsers.JSONParser):
media_type = 'application/activity+json' media_type = "application/activity+json"

View File

@ -7,15 +7,13 @@ from . import actors
class LibraryFollower(BasePermission): class LibraryFollower(BasePermission):
def has_permission(self, request, view): def has_permission(self, request, view):
if not preferences.get('federation__music_needs_approval'): if not preferences.get("federation__music_needs_approval"):
return True return True
actor = getattr(request, 'actor', None) actor = getattr(request, "actor", None)
if actor is None: if actor is None:
return False return False
library = actors.SYSTEM_ACTORS['library'].get_actor_instance() library = actors.SYSTEM_ACTORS["library"].get_actor_instance()
return library.received_follows.filter( return library.received_follows.filter(approved=True, actor=actor).exists()
approved=True, actor=actor).exists()

View File

@ -2,8 +2,8 @@ from rest_framework.renderers import JSONRenderer
class ActivityPubRenderer(JSONRenderer): class ActivityPubRenderer(JSONRenderer):
media_type = 'application/activity+json' media_type = "application/activity+json"
class WebfingerRenderer(JSONRenderer): class WebfingerRenderer(JSONRenderer):
media_type = 'application/jrd+json' media_type = "application/jrd+json"

File diff suppressed because it is too large Load Diff

View File

@ -10,9 +10,7 @@ logger = logging.getLogger(__name__)
def verify(request, public_key): def verify(request, public_key):
return requests_http_signature.HTTPSignatureAuth.verify( return requests_http_signature.HTTPSignatureAuth.verify(
request, request, key_resolver=lambda **kwargs: public_key, use_auth_header=False
key_resolver=lambda **kwargs: public_key,
use_auth_header=False,
) )
@ -27,26 +25,24 @@ def verify_django(django_request, public_key):
# with requests_http_signature # with requests_http_signature
headers[h.lower()] = v headers[h.lower()] = v
try: try:
signature = headers['Signature'] signature = headers["Signature"]
except KeyError: except KeyError:
raise exceptions.MissingSignature raise exceptions.MissingSignature
url = 'http://noop{}'.format(django_request.path) url = "http://noop{}".format(django_request.path)
query = django_request.META['QUERY_STRING'] query = django_request.META["QUERY_STRING"]
if query: if query:
url += '?{}'.format(query) url += "?{}".format(query)
signature_headers = signature.split('headers="')[1].split('",')[0] signature_headers = signature.split('headers="')[1].split('",')[0]
expected = signature_headers.split(' ') expected = signature_headers.split(" ")
logger.debug('Signature expected headers: %s', expected) logger.debug("Signature expected headers: %s", expected)
for header in expected: for header in expected:
try: try:
headers[header] headers[header]
except KeyError: except KeyError:
logger.debug('Missing header: %s', header) logger.debug("Missing header: %s", header)
request = requests.Request( request = requests.Request(
method=django_request.method, method=django_request.method, url=url, data=django_request.body, headers=headers
url=url, )
data=django_request.body,
headers=headers)
for h in request.headers.keys(): for h in request.headers.keys():
v = request.headers[h] v = request.headers[h]
if v: if v:
@ -58,13 +54,8 @@ def verify_django(django_request, public_key):
def get_auth(private_key, private_key_id): def get_auth(private_key, private_key_id):
return requests_http_signature.HTTPSignatureAuth( return requests_http_signature.HTTPSignatureAuth(
use_auth_header=False, use_auth_header=False,
headers=[ headers=["(request-target)", "user-agent", "host", "date", "content-type"],
'(request-target)', algorithm="rsa-sha256",
'user-agent', key=private_key.encode("utf-8"),
'host',
'date',
'content-type'],
algorithm='rsa-sha256',
key=private_key.encode('utf-8'),
key_id=private_key_id, key_id=private_key_id,
) )

View File

@ -24,96 +24,100 @@ logger = logging.getLogger(__name__)
@celery.app.task( @celery.app.task(
name='federation.send', name="federation.send",
autoretry_for=[RequestException], autoretry_for=[RequestException],
retry_backoff=30, retry_backoff=30,
max_retries=5) max_retries=5,
@celery.require_instance(models.Actor, 'actor') )
@celery.require_instance(models.Actor, "actor")
def send(activity, actor, to): def send(activity, actor, to):
logger.info('Preparing activity delivery to %s', to) logger.info("Preparing activity delivery to %s", to)
auth = signing.get_auth( auth = signing.get_auth(actor.private_key, actor.private_key_id)
actor.private_key, actor.private_key_id)
for url in to: for url in to:
recipient_actor = actors.get_actor(url) recipient_actor = actors.get_actor(url)
logger.debug('delivering to %s', recipient_actor.inbox_url) logger.debug("delivering to %s", recipient_actor.inbox_url)
logger.debug('activity content: %s', json.dumps(activity)) logger.debug("activity content: %s", json.dumps(activity))
response = session.get_session().post( response = session.get_session().post(
auth=auth, auth=auth,
json=activity, json=activity,
url=recipient_actor.inbox_url, url=recipient_actor.inbox_url,
timeout=5, timeout=5,
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
headers={ headers={"Content-Type": "application/activity+json"},
'Content-Type': 'application/activity+json'
}
) )
response.raise_for_status() response.raise_for_status()
logger.debug('Remote answered with %s', response.status_code) logger.debug("Remote answered with %s", response.status_code)
@celery.app.task( @celery.app.task(
name='federation.scan_library', name="federation.scan_library",
autoretry_for=[RequestException], autoretry_for=[RequestException],
retry_backoff=30, retry_backoff=30,
max_retries=5) max_retries=5,
@celery.require_instance(models.Library, 'library') )
@celery.require_instance(models.Library, "library")
def scan_library(library, until=None): def scan_library(library, until=None):
if not library.federation_enabled: if not library.federation_enabled:
return return
data = lb.get_library_data(library.url) data = lb.get_library_data(library.url)
scan_library_page.delay( scan_library_page.delay(library_id=library.id, page_url=data["first"], until=until)
library_id=library.id, page_url=data['first'], until=until)
library.fetched_date = timezone.now() library.fetched_date = timezone.now()
library.tracks_count = data['totalItems'] library.tracks_count = data["totalItems"]
library.save(update_fields=['fetched_date', 'tracks_count']) library.save(update_fields=["fetched_date", "tracks_count"])
@celery.app.task( @celery.app.task(
name='federation.scan_library_page', name="federation.scan_library_page",
autoretry_for=[RequestException], autoretry_for=[RequestException],
retry_backoff=30, retry_backoff=30,
max_retries=5) max_retries=5,
@celery.require_instance(models.Library, 'library') )
@celery.require_instance(models.Library, "library")
def scan_library_page(library, page_url, until=None): def scan_library_page(library, page_url, until=None):
if not library.federation_enabled: if not library.federation_enabled:
return return
data = lb.get_library_page(library, page_url) data = lb.get_library_page(library, page_url)
lts = [] lts = []
for item_serializer in data['items']: for item_serializer in data["items"]:
item_date = item_serializer.validated_data['published'] item_date = item_serializer.validated_data["published"]
if until and item_date < until: if until and item_date < until:
return return
lts.append(item_serializer.save()) lts.append(item_serializer.save())
next_page = data.get('next') next_page = data.get("next")
if next_page and next_page != page_url: if next_page and next_page != page_url:
scan_library_page.delay(library_id=library.id, page_url=next_page) scan_library_page.delay(library_id=library.id, page_url=next_page)
@celery.app.task(name='federation.clean_music_cache') @celery.app.task(name="federation.clean_music_cache")
def clean_music_cache(): def clean_music_cache():
preferences = global_preferences_registry.manager() preferences = global_preferences_registry.manager()
delay = preferences['federation__music_cache_duration'] delay = preferences["federation__music_cache_duration"]
if delay < 1: if delay < 1:
return # cache clearing disabled return # cache clearing disabled
limit = timezone.now() - datetime.timedelta(minutes=delay) limit = timezone.now() - datetime.timedelta(minutes=delay)
candidates = models.LibraryTrack.objects.filter( candidates = (
Q(audio_file__isnull=False) & ( models.LibraryTrack.objects.filter(
Q(local_track_file__accessed_date__lt=limit) | Q(audio_file__isnull=False)
Q(local_track_file__accessed_date=None) & (
Q(local_track_file__accessed_date__lt=limit)
| Q(local_track_file__accessed_date=None)
)
) )
).exclude(audio_file='').only('audio_file', 'id') .exclude(audio_file="")
.only("audio_file", "id")
)
for lt in candidates: for lt in candidates:
lt.audio_file.delete() lt.audio_file.delete()
# we also delete orphaned files, if any # we also delete orphaned files, if any
storage = models.LibraryTrack._meta.get_field('audio_file').storage storage = models.LibraryTrack._meta.get_field("audio_file").storage
files = get_files(storage, 'federation_cache') files = get_files(storage, "federation_cache")
existing = models.LibraryTrack.objects.filter(audio_file__in=files) existing = models.LibraryTrack.objects.filter(audio_file__in=files)
missing = set(files) - set(existing.values_list('audio_file', flat=True)) missing = set(files) - set(existing.values_list("audio_file", flat=True))
for m in missing: for m in missing:
storage.delete(m) storage.delete(m)
@ -124,12 +128,9 @@ def get_files(storage, *parts):
in a given directory using django's storage. in a given directory using django's storage.
""" """
if not parts: if not parts:
raise ValueError('Missing path') raise ValueError("Missing path")
dirs, files = storage.listdir(os.path.join(*parts)) dirs, files = storage.listdir(os.path.join(*parts))
for dir in dirs: for dir in dirs:
files += get_files(storage, *(list(parts) + [dir])) files += get_files(storage, *(list(parts) + [dir]))
return [ return [os.path.join(parts[-1], path) for path in files]
os.path.join(parts[-1], path)
for path in files
]

View File

@ -6,19 +6,11 @@ from . import views
router = routers.SimpleRouter(trailing_slash=False) router = routers.SimpleRouter(trailing_slash=False)
music_router = routers.SimpleRouter(trailing_slash=False) music_router = routers.SimpleRouter(trailing_slash=False)
router.register( router.register(
r'federation/instance/actors', r"federation/instance/actors", views.InstanceActorViewSet, "instance-actors"
views.InstanceActorViewSet,
'instance-actors')
router.register(
r'.well-known',
views.WellKnownViewSet,
'well-known')
music_router.register(
r'files',
views.MusicFilesViewSet,
'files',
) )
router.register(r".well-known", views.WellKnownViewSet, "well-known")
music_router.register(r"files", views.MusicFilesViewSet, "files")
urlpatterns = router.urls + [ urlpatterns = router.urls + [
url('federation/music/', include((music_router.urls, 'music'), namespace='music')) url("federation/music/", include((music_router.urls, "music"), namespace="music"))
] ]

View File

@ -6,10 +6,10 @@ def full_url(path):
Given a relative path, return a full url usable for federation purpose Given a relative path, return a full url usable for federation purpose
""" """
root = settings.FUNKWHALE_URL root = settings.FUNKWHALE_URL
if path.startswith('/') and root.endswith('/'): if path.startswith("/") and root.endswith("/"):
return root + path[1:] return root + path[1:]
elif not path.startswith('/') and not root.endswith('/'): elif not path.startswith("/") and not root.endswith("/"):
return root + '/' + path return root + "/" + path
else: else:
return root + path return root + path
@ -19,17 +19,14 @@ def clean_wsgi_headers(raw_headers):
Convert WSGI headers from CONTENT_TYPE to Content-Type notation Convert WSGI headers from CONTENT_TYPE to Content-Type notation
""" """
cleaned = {} cleaned = {}
non_prefixed = [ non_prefixed = ["content_type", "content_length"]
'content_type',
'content_length',
]
for raw_header, value in raw_headers.items(): for raw_header, value in raw_headers.items():
h = raw_header.lower() h = raw_header.lower()
if not h.startswith('http_') and h not in non_prefixed: if not h.startswith("http_") and h not in non_prefixed:
continue continue
words = h.replace('http_', '', 1).split('_') words = h.replace("http_", "", 1).split("_")
cleaned_header = '-'.join([w.capitalize() for w in words]) cleaned_header = "-".join([w.capitalize() for w in words])
cleaned[cleaned_header] = value cleaned[cleaned_header] = value
return cleaned return cleaned

View File

@ -34,22 +34,21 @@ from . import webfinger
class FederationMixin(object): class FederationMixin(object):
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
if not preferences.get('federation__enabled'): if not preferences.get("federation__enabled"):
return HttpResponse(status=405) return HttpResponse(status=405)
return super().dispatch(request, *args, **kwargs) return super().dispatch(request, *args, **kwargs)
class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet): class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
lookup_field = 'actor' lookup_field = "actor"
lookup_value_regex = '[a-z]*' lookup_value_regex = "[a-z]*"
authentication_classes = [ authentication_classes = [authentication.SignatureAuthentication]
authentication.SignatureAuthentication]
permission_classes = [] permission_classes = []
renderer_classes = [renderers.ActivityPubRenderer] renderer_classes = [renderers.ActivityPubRenderer]
def get_object(self): def get_object(self):
try: try:
return actors.SYSTEM_ACTORS[self.kwargs['actor']] return actors.SYSTEM_ACTORS[self.kwargs["actor"]]
except KeyError: except KeyError:
raise Http404 raise Http404
@ -59,12 +58,10 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
data = actor.system_conf.serialize() data = actor.system_conf.serialize()
return response.Response(data, status=200) return response.Response(data, status=200)
@detail_route(methods=['get', 'post']) @detail_route(methods=["get", "post"])
def inbox(self, request, *args, **kwargs): def inbox(self, request, *args, **kwargs):
system_actor = self.get_object() system_actor = self.get_object()
handler = getattr(system_actor, '{}_inbox'.format( handler = getattr(system_actor, "{}_inbox".format(request.method.lower()))
request.method.lower()
))
try: try:
data = handler(request.data, actor=request.actor) data = handler(request.data, actor=request.actor)
@ -72,12 +69,10 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
return response.Response(status=405) return response.Response(status=405)
return response.Response({}, status=200) return response.Response({}, status=200)
@detail_route(methods=['get', 'post']) @detail_route(methods=["get", "post"])
def outbox(self, request, *args, **kwargs): def outbox(self, request, *args, **kwargs):
system_actor = self.get_object() system_actor = self.get_object()
handler = getattr(system_actor, '{}_outbox'.format( handler = getattr(system_actor, "{}_outbox".format(request.method.lower()))
request.method.lower()
))
try: try:
data = handler(request.data, actor=request.actor) data = handler(request.data, actor=request.actor)
except NotImplementedError: except NotImplementedError:
@ -90,45 +85,36 @@ class WellKnownViewSet(viewsets.GenericViewSet):
permission_classes = [] permission_classes = []
renderer_classes = [renderers.JSONRenderer, renderers.WebfingerRenderer] renderer_classes = [renderers.JSONRenderer, renderers.WebfingerRenderer]
@list_route(methods=['get']) @list_route(methods=["get"])
def nodeinfo(self, request, *args, **kwargs): def nodeinfo(self, request, *args, **kwargs):
if not preferences.get('instance__nodeinfo_enabled'): if not preferences.get("instance__nodeinfo_enabled"):
return HttpResponse(status=404) return HttpResponse(status=404)
data = { data = {
'links': [ "links": [
{ {
'rel': 'http://nodeinfo.diaspora.software/ns/schema/2.0', "rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
'href': utils.full_url( "href": utils.full_url(reverse("api:v1:instance:nodeinfo-2.0")),
reverse('api:v1:instance:nodeinfo-2.0')
)
} }
] ]
} }
return response.Response(data) return response.Response(data)
@list_route(methods=['get']) @list_route(methods=["get"])
def webfinger(self, request, *args, **kwargs): def webfinger(self, request, *args, **kwargs):
if not preferences.get('federation__enabled'): if not preferences.get("federation__enabled"):
return HttpResponse(status=405) return HttpResponse(status=405)
try: try:
resource_type, resource = webfinger.clean_resource( resource_type, resource = webfinger.clean_resource(request.GET["resource"])
request.GET['resource']) cleaner = getattr(webfinger, "clean_{}".format(resource_type))
cleaner = getattr(webfinger, 'clean_{}'.format(resource_type))
result = cleaner(resource) result = cleaner(resource)
except forms.ValidationError as e: except forms.ValidationError as e:
return response.Response({ return response.Response({"errors": {"resource": e.message}}, status=400)
'errors': {
'resource': e.message
}
}, status=400)
except KeyError: except KeyError:
return response.Response({ return response.Response(
'errors': { {"errors": {"resource": "This field is required"}}, status=400
'resource': 'This field is required', )
}
}, status=400)
handler = getattr(self, 'handler_{}'.format(resource_type)) handler = getattr(self, "handler_{}".format(resource_type))
data = handler(result) data = handler(result)
return response.Response(data) return response.Response(data)
@ -140,28 +126,25 @@ class WellKnownViewSet(viewsets.GenericViewSet):
class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet): class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
authentication_classes = [ authentication_classes = [authentication.SignatureAuthentication]
authentication.SignatureAuthentication]
permission_classes = [permissions.LibraryFollower] permission_classes = [permissions.LibraryFollower]
renderer_classes = [renderers.ActivityPubRenderer] renderer_classes = [renderers.ActivityPubRenderer]
def list(self, request, *args, **kwargs): def list(self, request, *args, **kwargs):
page = request.GET.get('page') page = request.GET.get("page")
library = actors.SYSTEM_ACTORS['library'].get_actor_instance() library = actors.SYSTEM_ACTORS["library"].get_actor_instance()
qs = music_models.TrackFile.objects.order_by( qs = (
'-creation_date' music_models.TrackFile.objects.order_by("-creation_date")
).select_related( .select_related("track__artist", "track__album__artist")
'track__artist', .filter(library_track__isnull=True)
'track__album__artist' )
).filter(library_track__isnull=True)
if page is None: if page is None:
conf = { conf = {
'id': utils.full_url(reverse('federation:music:files-list')), "id": utils.full_url(reverse("federation:music:files-list")),
'page_size': preferences.get( "page_size": preferences.get("federation__collection_page_size"),
'federation__collection_page_size'), "items": qs,
'items': qs, "item_serializer": serializers.AudioSerializer,
'item_serializer': serializers.AudioSerializer, "actor": library,
'actor': library,
} }
serializer = serializers.PaginatedCollectionSerializer(conf) serializer = serializers.PaginatedCollectionSerializer(conf)
data = serializer.data data = serializer.data
@ -169,17 +152,17 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
try: try:
page_number = int(page) page_number = int(page)
except: except:
return response.Response( return response.Response({"page": ["Invalid page number"]}, status=400)
{'page': ['Invalid page number']}, status=400)
p = paginator.Paginator( p = paginator.Paginator(
qs, preferences.get('federation__collection_page_size')) qs, preferences.get("federation__collection_page_size")
)
try: try:
page = p.page(page_number) page = p.page(page_number)
conf = { conf = {
'id': utils.full_url(reverse('federation:music:files-list')), "id": utils.full_url(reverse("federation:music:files-list")),
'page': page, "page": page,
'item_serializer': serializers.AudioSerializer, "item_serializer": serializers.AudioSerializer,
'actor': library, "actor": library,
} }
serializer = serializers.CollectionPageSerializer(conf) serializer = serializers.CollectionPageSerializer(conf)
data = serializer.data data = serializer.data
@ -190,93 +173,76 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
class LibraryViewSet( class LibraryViewSet(
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.UpdateModelMixin, mixins.UpdateModelMixin,
mixins.ListModelMixin, mixins.ListModelMixin,
viewsets.GenericViewSet): viewsets.GenericViewSet,
):
permission_classes = (HasUserPermission,) permission_classes = (HasUserPermission,)
required_permissions = ['federation'] required_permissions = ["federation"]
queryset = models.Library.objects.all().select_related( queryset = models.Library.objects.all().select_related("actor", "follow")
'actor', lookup_field = "uuid"
'follow',
)
lookup_field = 'uuid'
filter_class = filters.LibraryFilter filter_class = filters.LibraryFilter
serializer_class = serializers.APILibrarySerializer serializer_class = serializers.APILibrarySerializer
ordering_fields = ( ordering_fields = (
'id', "id",
'creation_date', "creation_date",
'fetched_date', "fetched_date",
'actor__domain', "actor__domain",
'tracks_count', "tracks_count",
) )
@list_route(methods=['get']) @list_route(methods=["get"])
def fetch(self, request, *args, **kwargs): def fetch(self, request, *args, **kwargs):
account = request.GET.get('account') account = request.GET.get("account")
if not account: if not account:
return response.Response( return response.Response({"account": "This field is mandatory"}, status=400)
{'account': 'This field is mandatory'}, status=400)
data = library.scan_from_account_name(account) data = library.scan_from_account_name(account)
return response.Response(data) return response.Response(data)
@detail_route(methods=['post']) @detail_route(methods=["post"])
def scan(self, request, *args, **kwargs): def scan(self, request, *args, **kwargs):
library = self.get_object() library = self.get_object()
serializer = serializers.APILibraryScanSerializer( serializer = serializers.APILibraryScanSerializer(data=request.data)
data=request.data
)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
result = tasks.scan_library.delay( result = tasks.scan_library.delay(
library_id=library.pk, library_id=library.pk, until=serializer.validated_data.get("until")
until=serializer.validated_data.get('until')
) )
return response.Response({'task': result.id}) return response.Response({"task": result.id})
@list_route(methods=['get']) @list_route(methods=["get"])
def following(self, request, *args, **kwargs): def following(self, request, *args, **kwargs):
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
queryset = models.Follow.objects.filter( queryset = (
actor=library_actor models.Follow.objects.filter(actor=library_actor)
).select_related( .select_related("actor", "target")
'actor', .order_by("-creation_date")
'target', )
).order_by('-creation_date')
filterset = filters.FollowFilter(request.GET, queryset=queryset) filterset = filters.FollowFilter(request.GET, queryset=queryset)
final_qs = filterset.qs final_qs = filterset.qs
serializer = serializers.APIFollowSerializer(final_qs, many=True) serializer = serializers.APIFollowSerializer(final_qs, many=True)
data = { data = {"results": serializer.data, "count": len(final_qs)}
'results': serializer.data,
'count': len(final_qs),
}
return response.Response(data) return response.Response(data)
@list_route(methods=['get', 'patch']) @list_route(methods=["get", "patch"])
def followers(self, request, *args, **kwargs): def followers(self, request, *args, **kwargs):
if request.method.lower() == 'patch': if request.method.lower() == "patch":
serializer = serializers.APILibraryFollowUpdateSerializer( serializer = serializers.APILibraryFollowUpdateSerializer(data=request.data)
data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
follow = serializer.save() follow = serializer.save()
return response.Response( return response.Response(serializers.APIFollowSerializer(follow).data)
serializers.APIFollowSerializer(follow).data
)
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance() library_actor = actors.SYSTEM_ACTORS["library"].get_actor_instance()
queryset = models.Follow.objects.filter( queryset = (
target=library_actor models.Follow.objects.filter(target=library_actor)
).select_related( .select_related("actor", "target")
'actor', .order_by("-creation_date")
'target', )
).order_by('-creation_date')
filterset = filters.FollowFilter(request.GET, queryset=queryset) filterset = filters.FollowFilter(request.GET, queryset=queryset)
final_qs = filterset.qs final_qs = filterset.qs
serializer = serializers.APIFollowSerializer(final_qs, many=True) serializer = serializers.APIFollowSerializer(final_qs, many=True)
data = { data = {"results": serializer.data, "count": len(final_qs)}
'results': serializer.data,
'count': len(final_qs),
}
return response.Response(data) return response.Response(data)
@transaction.atomic @transaction.atomic
@ -287,37 +253,32 @@ class LibraryViewSet(
return response.Response(serializer.data, status=201) return response.Response(serializer.data, status=201)
class LibraryTrackViewSet( class LibraryTrackViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
mixins.ListModelMixin,
viewsets.GenericViewSet):
permission_classes = (HasUserPermission,) permission_classes = (HasUserPermission,)
required_permissions = ['federation'] required_permissions = ["federation"]
queryset = models.LibraryTrack.objects.all().select_related( queryset = (
'library__actor', models.LibraryTrack.objects.all()
'library__follow', .select_related("library__actor", "library__follow", "local_track_file")
'local_track_file', .prefetch_related("import_jobs")
).prefetch_related('import_jobs') )
filter_class = filters.LibraryTrackFilter filter_class = filters.LibraryTrackFilter
serializer_class = serializers.APILibraryTrackSerializer serializer_class = serializers.APILibraryTrackSerializer
ordering_fields = ( ordering_fields = (
'id', "id",
'artist_name', "artist_name",
'title', "title",
'album_title', "album_title",
'creation_date', "creation_date",
'modification_date', "modification_date",
'fetched_date', "fetched_date",
'published_date', "published_date",
) )
@list_route(methods=['post']) @list_route(methods=["post"])
def action(self, request, *args, **kwargs): def action(self, request, *args, **kwargs):
queryset = models.LibraryTrack.objects.filter( queryset = models.LibraryTrack.objects.filter(local_track_file__isnull=True)
local_track_file__isnull=True)
serializer = serializers.LibraryTrackActionSerializer( serializer = serializers.LibraryTrackActionSerializer(
request.data, request.data, queryset=queryset, context={"submitted_by": request.user}
queryset=queryset,
context={'submitted_by': request.user}
) )
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
result = serializer.save() result = serializer.save()

View File

@ -8,36 +8,35 @@ from . import actors
from . import utils from . import utils
from . import serializers from . import serializers
VALID_RESOURCE_TYPES = ['acct'] VALID_RESOURCE_TYPES = ["acct"]
def clean_resource(resource_string): def clean_resource(resource_string):
if not resource_string: if not resource_string:
raise forms.ValidationError('Invalid resource string') raise forms.ValidationError("Invalid resource string")
try: try:
resource_type, resource = resource_string.split(':', 1) resource_type, resource = resource_string.split(":", 1)
except ValueError: except ValueError:
raise forms.ValidationError('Missing webfinger resource type') raise forms.ValidationError("Missing webfinger resource type")
if resource_type not in VALID_RESOURCE_TYPES: if resource_type not in VALID_RESOURCE_TYPES:
raise forms.ValidationError('Invalid webfinger resource type') raise forms.ValidationError("Invalid webfinger resource type")
return resource_type, resource return resource_type, resource
def clean_acct(acct_string, ensure_local=True): def clean_acct(acct_string, ensure_local=True):
try: try:
username, hostname = acct_string.split('@') username, hostname = acct_string.split("@")
except ValueError: except ValueError:
raise forms.ValidationError('Invalid format') raise forms.ValidationError("Invalid format")
if ensure_local and hostname.lower() != settings.FEDERATION_HOSTNAME: if ensure_local and hostname.lower() != settings.FEDERATION_HOSTNAME:
raise forms.ValidationError( raise forms.ValidationError("Invalid hostname {}".format(hostname))
'Invalid hostname {}'.format(hostname))
if ensure_local and username not in actors.SYSTEM_ACTORS: if ensure_local and username not in actors.SYSTEM_ACTORS:
raise forms.ValidationError('Invalid username') raise forms.ValidationError("Invalid username")
return username, hostname return username, hostname
@ -45,12 +44,12 @@ def clean_acct(acct_string, ensure_local=True):
def get_resource(resource_string): def get_resource(resource_string):
resource_type, resource = clean_resource(resource_string) resource_type, resource = clean_resource(resource_string)
username, hostname = clean_acct(resource, ensure_local=False) username, hostname = clean_acct(resource, ensure_local=False)
url = 'https://{}/.well-known/webfinger?resource={}'.format( url = "https://{}/.well-known/webfinger?resource={}".format(
hostname, resource_string) hostname, resource_string
)
response = session.get_session().get( response = session.get_session().get(
url, url, verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, timeout=5
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL, )
timeout=5)
response.raise_for_status() response.raise_for_status()
serializer = serializers.ActorWebfingerSerializer(data=response.json()) serializer = serializers.ActorWebfingerSerializer(data=response.json())
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)

View File

@ -3,17 +3,14 @@ from funkwhale_api.activity import record
from . import serializers from . import serializers
record.registry.register_serializer( record.registry.register_serializer(serializers.ListeningActivitySerializer)
serializers.ListeningActivitySerializer)
@record.registry.register_consumer('history.Listening') @record.registry.register_consumer("history.Listening")
def broadcast_listening_to_instance_activity(data, obj): def broadcast_listening_to_instance_activity(data, obj):
if obj.user.privacy_level not in ['instance', 'everyone']: if obj.user.privacy_level not in ["instance", "everyone"]:
return return
channels.group_send('instance_activity', { channels.group_send(
'type': 'event.send', "instance_activity", {"type": "event.send", "text": "", "data": data}
'text': '', )
'data': data
})

View File

@ -2,11 +2,9 @@ from django.contrib import admin
from . import models from . import models
@admin.register(models.Listening) @admin.register(models.Listening)
class ListeningAdmin(admin.ModelAdmin): class ListeningAdmin(admin.ModelAdmin):
list_display = ['track', 'creation_date', 'user', 'session_key'] list_display = ["track", "creation_date", "user", "session_key"]
search_fields = ['track__name', 'user__username'] search_fields = ["track__name", "user__username"]
list_select_related = [ list_select_related = ["user", "track"]
'user',
'track'
]

View File

@ -11,4 +11,4 @@ class ListeningFactory(factory.django.DjangoModelFactory):
track = factory.SubFactory(factories.TrackFactory) track = factory.SubFactory(factories.TrackFactory)
class Meta: class Meta:
model = 'history.Listening' model = "history.Listening"

View File

@ -9,22 +9,52 @@ import django.utils.timezone
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('music', '0008_auto_20160529_1456'), ("music", "0008_auto_20160529_1456"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL), migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Listening', name="Listening",
fields=[ 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)), "id",
('session_key', models.CharField(null=True, blank=True, max_length=100)), models.AutoField(
('track', models.ForeignKey(related_name='listenings', to='music.Track', on_delete=models.CASCADE)), verbose_name="ID",
('user', models.ForeignKey(blank=True, null=True, related_name='listenings', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), 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",
on_delete=models.CASCADE,
),
),
(
"user",
models.ForeignKey(
blank=True,
null=True,
related_name="listenings",
to=settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
),
),
], ],
options={ options={"ordering": ("-end_date",)},
'ordering': ('-end_date',), )
},
),
] ]

View File

@ -5,18 +5,13 @@ from django.db import migrations
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [("history", "0001_initial")]
('history', '0001_initial'),
]
operations = [ operations = [
migrations.AlterModelOptions( migrations.AlterModelOptions(
name='listening', name="listening", options={"ordering": ("-creation_date",)}
options={'ordering': ('-creation_date',)},
), ),
migrations.RenameField( migrations.RenameField(
model_name='listening', model_name="listening", old_name="end_date", new_name="creation_date"
old_name='end_date',
new_name='creation_date',
), ),
] ]

View File

@ -6,21 +6,21 @@ from funkwhale_api.music.models import Track
class Listening(models.Model): class Listening(models.Model):
creation_date = models.DateTimeField( creation_date = models.DateTimeField(default=timezone.now, null=True, blank=True)
default=timezone.now, null=True, blank=True)
track = models.ForeignKey( track = models.ForeignKey(
Track, related_name="listenings", on_delete=models.CASCADE) Track, related_name="listenings", on_delete=models.CASCADE
)
user = models.ForeignKey( user = models.ForeignKey(
'users.User', "users.User",
related_name="listenings", related_name="listenings",
null=True, null=True,
blank=True, blank=True,
on_delete=models.CASCADE) on_delete=models.CASCADE,
)
session_key = models.CharField(max_length=100, null=True, blank=True) session_key = models.CharField(max_length=100, null=True, blank=True)
class Meta: class Meta:
ordering = ('-creation_date',) ordering = ("-creation_date",)
def get_activity_url(self): def get_activity_url(self):
return '{}/listenings/tracks/{}'.format( return "{}/listenings/tracks/{}".format(self.user.get_activity_url(), self.pk)
self.user.get_activity_url(), self.pk)

View File

@ -9,35 +9,27 @@ from . import models
class ListeningActivitySerializer(activity_serializers.ModelSerializer): class ListeningActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField() type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source='track') object = TrackActivitySerializer(source="track")
actor = UserActivitySerializer(source='user') actor = UserActivitySerializer(source="user")
published = serializers.DateTimeField(source='creation_date') published = serializers.DateTimeField(source="creation_date")
class Meta: class Meta:
model = models.Listening model = models.Listening
fields = [ fields = ["id", "local_id", "object", "type", "actor", "published"]
'id',
'local_id',
'object',
'type',
'actor',
'published'
]
def get_actor(self, obj): def get_actor(self, obj):
return UserActivitySerializer(obj.user).data return UserActivitySerializer(obj.user).data
def get_type(self, obj): def get_type(self, obj):
return 'Listen' return "Listen"
class ListeningSerializer(serializers.ModelSerializer): class ListeningSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.Listening model = models.Listening
fields = ('id', 'user', 'track', 'creation_date') fields = ("id", "user", "track", "creation_date")
def create(self, validated_data): def create(self, validated_data):
validated_data['user'] = self.context['user'] validated_data["user"] = self.context["user"]
return super().create(validated_data) return super().create(validated_data)

View File

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

View File

@ -12,9 +12,8 @@ from . import serializers
class ListeningViewSet( class ListeningViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
mixins.RetrieveModelMixin, ):
viewsets.GenericViewSet):
serializer_class = serializers.ListeningSerializer serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all() queryset = models.Listening.objects.all()
@ -31,5 +30,5 @@ class ListeningViewSet(
def get_serializer_context(self): def get_serializer_context(self):
context = super().get_serializer_context() context = super().get_serializer_context()
context['user'] = self.request.user context["user"] = self.request.user
return context return context

View File

@ -5,4 +5,4 @@ class InstanceActivityConsumer(JsonAuthConsumer):
groups = ["instance_activity"] groups = ["instance_activity"]
def event_send(self, message): def event_send(self, message):
self.send_json(message['data']) self.send_json(message["data"])

View File

@ -3,91 +3,83 @@ from django.forms import widgets
from dynamic_preferences import types from dynamic_preferences import types
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
raven = types.Section('raven') raven = types.Section("raven")
instance = types.Section('instance') instance = types.Section("instance")
@global_preferences_registry.register @global_preferences_registry.register
class InstanceName(types.StringPreference): class InstanceName(types.StringPreference):
show_in_api = True show_in_api = True
section = instance section = instance
name = 'name' name = "name"
default = '' default = ""
verbose_name = 'Public name' verbose_name = "Public name"
help_text = 'The public name of your instance, displayed in the about page.' help_text = "The public name of your instance, displayed in the about page."
field_kwargs = { field_kwargs = {"required": False}
'required': False,
}
@global_preferences_registry.register @global_preferences_registry.register
class InstanceShortDescription(types.StringPreference): class InstanceShortDescription(types.StringPreference):
show_in_api = True show_in_api = True
section = instance section = instance
name = 'short_description' name = "short_description"
default = '' default = ""
verbose_name = 'Short description' verbose_name = "Short description"
help_text = 'Instance succinct description, displayed in the about page.' help_text = "Instance succinct description, displayed in the about page."
field_kwargs = { field_kwargs = {"required": False}
'required': False,
}
@global_preferences_registry.register @global_preferences_registry.register
class InstanceLongDescription(types.StringPreference): class InstanceLongDescription(types.StringPreference):
show_in_api = True show_in_api = True
section = instance section = instance
name = 'long_description' name = "long_description"
verbose_name = 'Long description' verbose_name = "Long description"
default = '' default = ""
help_text = 'Instance long description, displayed in the about page (markdown allowed).' help_text = (
"Instance long description, displayed in the about page (markdown allowed)."
)
widget = widgets.Textarea widget = widgets.Textarea
field_kwargs = { field_kwargs = {"required": False}
'required': False,
}
@global_preferences_registry.register @global_preferences_registry.register
class RavenDSN(types.StringPreference): class RavenDSN(types.StringPreference):
show_in_api = True show_in_api = True
section = raven section = raven
name = 'front_dsn' name = "front_dsn"
default = 'https://9e0562d46b09442bb8f6844e50cbca2b@sentry.eliotberriot.com/4' default = "https://9e0562d46b09442bb8f6844e50cbca2b@sentry.eliotberriot.com/4"
verbose_name = 'Raven DSN key (front-end)' verbose_name = "Raven DSN key (front-end)"
help_text = ( help_text = (
'A Raven DSN key used to report front-ent errors to ' "A Raven DSN key used to report front-ent errors to "
'a sentry instance. Keeping the default one will report errors to ' "a sentry instance. Keeping the default one will report errors to "
'Funkwhale developers.' "Funkwhale developers."
) )
field_kwargs = { field_kwargs = {"required": False}
'required': False,
}
@global_preferences_registry.register @global_preferences_registry.register
class RavenEnabled(types.BooleanPreference): class RavenEnabled(types.BooleanPreference):
show_in_api = True show_in_api = True
section = raven section = raven
name = 'front_enabled' name = "front_enabled"
default = False default = False
verbose_name = ( verbose_name = "Report front-end errors with Raven"
'Report front-end errors with Raven'
)
@global_preferences_registry.register @global_preferences_registry.register
class InstanceNodeinfoEnabled(types.BooleanPreference): class InstanceNodeinfoEnabled(types.BooleanPreference):
show_in_api = False show_in_api = False
section = instance section = instance
name = 'nodeinfo_enabled' name = "nodeinfo_enabled"
default = True default = True
verbose_name = 'Enable nodeinfo endpoint' verbose_name = "Enable nodeinfo endpoint"
help_text = ( help_text = (
'This endpoint is needed for your about page to work. ' "This endpoint is needed for your about page to work. "
'It\'s also helpful for the various monitoring ' "It's also helpful for the various monitoring "
'tools that map and analyzize the fediverse, ' "tools that map and analyzize the fediverse, "
'but you can disable it completely if needed.' "but you can disable it completely if needed."
) )
@ -95,13 +87,13 @@ class InstanceNodeinfoEnabled(types.BooleanPreference):
class InstanceNodeinfoPrivate(types.BooleanPreference): class InstanceNodeinfoPrivate(types.BooleanPreference):
show_in_api = False show_in_api = False
section = instance section = instance
name = 'nodeinfo_private' name = "nodeinfo_private"
default = False default = False
verbose_name = 'Private mode in nodeinfo' verbose_name = "Private mode in nodeinfo"
help_text = ( help_text = (
'Indicate in the nodeinfo endpoint that you do not want your instance ' "Indicate in the nodeinfo endpoint that you do not want your instance "
'to be tracked by third-party services. ' "to be tracked by third-party services. "
'There is no guarantee these tools will honor this setting though.' "There is no guarantee these tools will honor this setting though."
) )
@ -109,10 +101,10 @@ class InstanceNodeinfoPrivate(types.BooleanPreference):
class InstanceNodeinfoStatsEnabled(types.BooleanPreference): class InstanceNodeinfoStatsEnabled(types.BooleanPreference):
show_in_api = False show_in_api = False
section = instance section = instance
name = 'nodeinfo_stats_enabled' name = "nodeinfo_stats_enabled"
default = True default = True
verbose_name = 'Enable usage and library stats in nodeinfo endpoint' verbose_name = "Enable usage and library stats in nodeinfo endpoint"
help_text = ( help_text = (
'Disable this if you don\'t want to share usage and library statistics ' "Disable this if you don't want to share usage and library statistics "
'in the nodeinfo endpoint but don\'t want to disable it completely.' "in the nodeinfo endpoint but don't want to disable it completely."
) )

View File

@ -6,70 +6,47 @@ from funkwhale_api.common import preferences
from . import stats from . import stats
store = memoize.djangocache.Cache('default') store = memoize.djangocache.Cache("default")
memo = memoize.Memoizer(store, namespace='instance:stats') memo = memoize.Memoizer(store, namespace="instance:stats")
def get(): def get():
share_stats = preferences.get('instance__nodeinfo_stats_enabled') share_stats = preferences.get("instance__nodeinfo_stats_enabled")
private = preferences.get('instance__nodeinfo_private') private = preferences.get("instance__nodeinfo_private")
data = { data = {
'version': '2.0', "version": "2.0",
'software': { "software": {"name": "funkwhale", "version": funkwhale_api.__version__},
'name': 'funkwhale', "protocols": ["activitypub"],
'version': funkwhale_api.__version__ "services": {"inbound": [], "outbound": []},
}, "openRegistrations": preferences.get("users__registration_enabled"),
'protocols': ['activitypub'], "usage": {"users": {"total": 0}},
'services': { "metadata": {
'inbound': [], "private": preferences.get("instance__nodeinfo_private"),
'outbound': [] "shortDescription": preferences.get("instance__short_description"),
}, "longDescription": preferences.get("instance__long_description"),
'openRegistrations': preferences.get('users__registration_enabled'), "nodeName": preferences.get("instance__name"),
'usage': { "library": {
'users': { "federationEnabled": preferences.get("federation__enabled"),
'total': 0, "federationNeedsApproval": preferences.get(
} "federation__music_needs_approval"
}, ),
'metadata': { "anonymousCanListen": preferences.get(
'private': preferences.get('instance__nodeinfo_private'), "common__api_authentication_required"
'shortDescription': preferences.get('instance__short_description'), ),
'longDescription': preferences.get('instance__long_description'),
'nodeName': preferences.get('instance__name'),
'library': {
'federationEnabled': preferences.get('federation__enabled'),
'federationNeedsApproval': preferences.get('federation__music_needs_approval'),
'anonymousCanListen': preferences.get('common__api_authentication_required'),
}, },
} },
} }
if share_stats: if share_stats:
getter = memo( getter = memo(lambda: stats.get(), max_age=600)
lambda: stats.get(),
max_age=600
)
statistics = getter() statistics = getter()
data['usage']['users']['total'] = statistics['users'] data["usage"]["users"]["total"] = statistics["users"]
data['metadata']['library']['tracks'] = { data["metadata"]["library"]["tracks"] = {"total": statistics["tracks"]}
'total': statistics['tracks'], data["metadata"]["library"]["artists"] = {"total": statistics["artists"]}
} data["metadata"]["library"]["albums"] = {"total": statistics["albums"]}
data['metadata']['library']['artists'] = { data["metadata"]["library"]["music"] = {"hours": statistics["music_duration"]}
'total': statistics['artists'],
}
data['metadata']['library']['albums'] = {
'total': statistics['albums'],
}
data['metadata']['library']['music'] = {
'hours': statistics['music_duration']
}
data['metadata']['usage'] = { data["metadata"]["usage"] = {
'favorites': { "favorites": {"tracks": {"total": statistics["track_favorites"]}},
'tracks': { "listenings": {"total": statistics["listenings"]},
'total': statistics['track_favorites'],
}
},
'listenings': {
'total': statistics['listenings']
}
} }
return data return data

View File

@ -8,13 +8,13 @@ from funkwhale_api.users.models import User
def get(): def get():
return { return {
'users': get_users(), "users": get_users(),
'tracks': get_tracks(), "tracks": get_tracks(),
'albums': get_albums(), "albums": get_albums(),
'artists': get_artists(), "artists": get_artists(),
'track_favorites': get_track_favorites(), "track_favorites": get_track_favorites(),
'listenings': get_listenings(), "listenings": get_listenings(),
'music_duration': get_music_duration(), "music_duration": get_music_duration(),
} }
@ -43,9 +43,7 @@ def get_artists():
def get_music_duration(): def get_music_duration():
seconds = models.TrackFile.objects.aggregate( seconds = models.TrackFile.objects.aggregate(d=Sum("duration"))["d"]
d=Sum('duration'),
)['d']
if seconds: if seconds:
return seconds / 3600 return seconds / 3600
return 0 return 0

View File

@ -2,10 +2,11 @@ from django.conf.urls import url
from rest_framework import routers from rest_framework import routers
from . import views from . import views
admin_router = routers.SimpleRouter() admin_router = routers.SimpleRouter()
admin_router.register(r'admin/settings', views.AdminSettings, 'admin-settings') admin_router.register(r"admin/settings", views.AdminSettings, "admin-settings")
urlpatterns = [ urlpatterns = [
url(r'^nodeinfo/2.0/$', views.NodeInfo.as_view(), name='nodeinfo-2.0'), url(r"^nodeinfo/2.0/$", views.NodeInfo.as_view(), name="nodeinfo-2.0"),
url(r'^settings/$', views.InstanceSettings.as_view(), name='settings'), url(r"^settings/$", views.InstanceSettings.as_view(), name="settings"),
] + admin_router.urls ] + admin_router.urls

View File

@ -12,15 +12,14 @@ from . import nodeinfo
from . import stats from . import stats
NODEINFO_2_CONTENT_TYPE = ( NODEINFO_2_CONTENT_TYPE = "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8" # noqa
'application/json; profile=http://nodeinfo.diaspora.software/ns/schema/2.0#; charset=utf-8' # noqa
)
class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet): class AdminSettings(preferences_viewsets.GlobalPreferencesViewSet):
pagination_class = None pagination_class = None
permission_classes = (HasUserPermission,) permission_classes = (HasUserPermission,)
required_permissions = ['settings'] required_permissions = ["settings"]
class InstanceSettings(views.APIView): class InstanceSettings(views.APIView):
permission_classes = [] permission_classes = []
@ -29,16 +28,11 @@ class InstanceSettings(views.APIView):
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
manager = global_preferences_registry.manager() manager = global_preferences_registry.manager()
manager.all() manager.all()
all_preferences = manager.model.objects.all().order_by( all_preferences = manager.model.objects.all().order_by("section", "name")
'section', 'name'
)
api_preferences = [ api_preferences = [
p p for p in all_preferences if getattr(p.preference, "show_in_api", False)
for p in all_preferences
if getattr(p.preference, 'show_in_api', False)
] ]
data = serializers.GlobalPreferenceSerializer( data = serializers.GlobalPreferenceSerializer(api_preferences, many=True).data
api_preferences, many=True).data
return Response(data, status=200) return Response(data, status=200)
@ -47,8 +41,7 @@ class NodeInfo(views.APIView):
authentication_classes = [] authentication_classes = []
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
if not preferences.get('instance__nodeinfo_enabled'): if not preferences.get("instance__nodeinfo_enabled"):
return Response(status=404) return Response(status=404)
data = nodeinfo.get() data = nodeinfo.get()
return Response( return Response(data, status=200, content_type=NODEINFO_2_CONTENT_TYPE)
data, status=200, content_type=NODEINFO_2_CONTENT_TYPE)

View File

@ -7,19 +7,15 @@ from funkwhale_api.music import models as music_models
class ManageTrackFileFilterSet(filters.FilterSet): class ManageTrackFileFilterSet(filters.FilterSet):
q = fields.SearchFilter(search_fields=[ q = fields.SearchFilter(
'track__title', search_fields=[
'track__album__title', "track__title",
'track__artist__name', "track__album__title",
'source', "track__artist__name",
]) "source",
]
)
class Meta: class Meta:
model = music_models.TrackFile model = music_models.TrackFile
fields = [ fields = ["q", "track__album", "track__artist", "track", "library_track"]
'q',
'track__album',
'track__artist',
'track',
'library_track'
]

View File

@ -10,12 +10,7 @@ from . import filters
class ManageTrackFileArtistSerializer(serializers.ModelSerializer): class ManageTrackFileArtistSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = music_models.Artist model = music_models.Artist
fields = [ fields = ["id", "mbid", "creation_date", "name"]
'id',
'mbid',
'creation_date',
'name',
]
class ManageTrackFileAlbumSerializer(serializers.ModelSerializer): class ManageTrackFileAlbumSerializer(serializers.ModelSerializer):
@ -24,13 +19,13 @@ class ManageTrackFileAlbumSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = music_models.Album model = music_models.Album
fields = ( fields = (
'id', "id",
'mbid', "mbid",
'title', "title",
'artist', "artist",
'release_date', "release_date",
'cover', "cover",
'creation_date', "creation_date",
) )
@ -40,15 +35,7 @@ class ManageTrackFileTrackSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = music_models.Track model = music_models.Track
fields = ( fields = ("id", "mbid", "title", "album", "artist", "creation_date", "position")
'id',
'mbid',
'title',
'album',
'artist',
'creation_date',
'position',
)
class ManageTrackFileSerializer(serializers.ModelSerializer): class ManageTrackFileSerializer(serializers.ModelSerializer):
@ -57,24 +44,24 @@ class ManageTrackFileSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = music_models.TrackFile model = music_models.TrackFile
fields = ( fields = (
'id', "id",
'path', "path",
'source', "source",
'filename', "filename",
'mimetype', "mimetype",
'track', "track",
'duration', "duration",
'mimetype', "mimetype",
'bitrate', "bitrate",
'size', "size",
'path', "path",
'library_track', "library_track",
) )
class ManageTrackFileActionSerializer(common_serializers.ActionSerializer): class ManageTrackFileActionSerializer(common_serializers.ActionSerializer):
actions = ['delete'] actions = ["delete"]
dangerous_actions = ['delete'] dangerous_actions = ["delete"]
filterset_class = filters.ManageTrackFileFilterSet filterset_class = filters.ManageTrackFileFilterSet
@transaction.atomic @transaction.atomic

View File

@ -2,10 +2,10 @@ from django.conf.urls import include, url
from . import views from . import views
from rest_framework import routers from rest_framework import routers
library_router = routers.SimpleRouter() library_router = routers.SimpleRouter()
library_router.register(r'track-files', views.ManageTrackFileViewSet, 'track-files') library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files")
urlpatterns = [ urlpatterns = [
url(r'^library/', url(r"^library/", include((library_router.urls, "instance"), namespace="library"))
include((library_router.urls, 'instance'), namespace='library')),
] ]

View File

@ -11,38 +11,35 @@ from . import serializers
class ManageTrackFileViewSet( class ManageTrackFileViewSet(
mixins.ListModelMixin, mixins.ListModelMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.DestroyModelMixin, mixins.DestroyModelMixin,
viewsets.GenericViewSet): viewsets.GenericViewSet,
):
queryset = ( queryset = (
music_models.TrackFile.objects.all() music_models.TrackFile.objects.all()
.select_related( .select_related("track__artist", "track__album__artist", "library_track")
'track__artist', .order_by("-id")
'track__album__artist',
'library_track')
.order_by('-id')
) )
serializer_class = serializers.ManageTrackFileSerializer serializer_class = serializers.ManageTrackFileSerializer
filter_class = filters.ManageTrackFileFilterSet filter_class = filters.ManageTrackFileFilterSet
permission_classes = (HasUserPermission,) permission_classes = (HasUserPermission,)
required_permissions = ['library'] required_permissions = ["library"]
ordering_fields = [ ordering_fields = [
'accessed_date', "accessed_date",
'modification_date', "modification_date",
'creation_date', "creation_date",
'track__artist__name', "track__artist__name",
'bitrate', "bitrate",
'size', "size",
'duration', "duration",
] ]
@list_route(methods=['post']) @list_route(methods=["post"])
def action(self, request, *args, **kwargs): def action(self, request, *args, **kwargs):
queryset = self.get_queryset() queryset = self.get_queryset()
serializer = serializers.ManageTrackFileActionSerializer( serializer = serializers.ManageTrackFileActionSerializer(
request.data, request.data, queryset=queryset
queryset=queryset,
) )
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
result = serializer.save() result = serializer.save()

View File

@ -5,85 +5,73 @@ from . import models
@admin.register(models.Artist) @admin.register(models.Artist)
class ArtistAdmin(admin.ModelAdmin): class ArtistAdmin(admin.ModelAdmin):
list_display = ['name', 'mbid', 'creation_date'] list_display = ["name", "mbid", "creation_date"]
search_fields = ['name', 'mbid'] search_fields = ["name", "mbid"]
@admin.register(models.Album) @admin.register(models.Album)
class AlbumAdmin(admin.ModelAdmin): class AlbumAdmin(admin.ModelAdmin):
list_display = ['title', 'artist', 'mbid', 'release_date', 'creation_date'] list_display = ["title", "artist", "mbid", "release_date", "creation_date"]
search_fields = ['title', 'artist__name', 'mbid'] search_fields = ["title", "artist__name", "mbid"]
list_select_related = True list_select_related = True
@admin.register(models.Track) @admin.register(models.Track)
class TrackAdmin(admin.ModelAdmin): class TrackAdmin(admin.ModelAdmin):
list_display = ['title', 'artist', 'album', 'mbid'] list_display = ["title", "artist", "album", "mbid"]
search_fields = ['title', 'artist__name', 'album__title', 'mbid'] search_fields = ["title", "artist__name", "album__title", "mbid"]
list_select_related = True list_select_related = True
@admin.register(models.ImportBatch) @admin.register(models.ImportBatch)
class ImportBatchAdmin(admin.ModelAdmin): class ImportBatchAdmin(admin.ModelAdmin):
list_display = [ list_display = ["submitted_by", "creation_date", "import_request", "status"]
'submitted_by', list_select_related = ["submitted_by", "import_request"]
'creation_date', list_filter = ["status"]
'import_request', search_fields = ["import_request__name", "source", "batch__pk", "mbid"]
'status']
list_select_related = [
'submitted_by',
'import_request',
]
list_filter = ['status']
search_fields = [
'import_request__name', 'source', 'batch__pk', 'mbid']
@admin.register(models.ImportJob) @admin.register(models.ImportJob)
class ImportJobAdmin(admin.ModelAdmin): class ImportJobAdmin(admin.ModelAdmin):
list_display = ['source', 'batch', 'track_file', 'status', 'mbid'] list_display = ["source", "batch", "track_file", "status", "mbid"]
list_select_related = [ list_select_related = ["track_file", "batch"]
'track_file', search_fields = ["source", "batch__pk", "mbid"]
'batch', list_filter = ["status"]
]
search_fields = ['source', 'batch__pk', 'mbid']
list_filter = ['status']
@admin.register(models.Work) @admin.register(models.Work)
class WorkAdmin(admin.ModelAdmin): class WorkAdmin(admin.ModelAdmin):
list_display = ['title', 'mbid', 'language', 'nature'] list_display = ["title", "mbid", "language", "nature"]
list_select_related = True list_select_related = True
search_fields = ['title'] search_fields = ["title"]
list_filter = ['language', 'nature'] list_filter = ["language", "nature"]
@admin.register(models.Lyrics) @admin.register(models.Lyrics)
class LyricsAdmin(admin.ModelAdmin): class LyricsAdmin(admin.ModelAdmin):
list_display = ['url', 'id', 'url'] list_display = ["url", "id", "url"]
list_select_related = True list_select_related = True
search_fields = ['url', 'work__title'] search_fields = ["url", "work__title"]
list_filter = ['work__language'] list_filter = ["work__language"]
@admin.register(models.TrackFile) @admin.register(models.TrackFile)
class TrackFileAdmin(admin.ModelAdmin): class TrackFileAdmin(admin.ModelAdmin):
list_display = [ list_display = [
'track', "track",
'audio_file', "audio_file",
'source', "source",
'duration', "duration",
'mimetype', "mimetype",
'size', "size",
'bitrate' "bitrate",
]
list_select_related = [
'track'
] ]
list_select_related = ["track"]
search_fields = [ search_fields = [
'source', "source",
'acoustid_track_id', "acoustid_track_id",
'track__title', "track__title",
'track__album__title', "track__album__title",
'track__artist__name'] "track__artist__name",
list_filter = ['mimetype'] ]
list_filter = ["mimetype"]

View File

@ -2,78 +2,72 @@ import factory
import os import os
from funkwhale_api.factories import registry, ManyToManyFromList from funkwhale_api.factories import registry, ManyToManyFromList
from funkwhale_api.federation.factories import ( from funkwhale_api.federation.factories import LibraryTrackFactory
LibraryTrackFactory,
)
from funkwhale_api.users.factories import UserFactory from funkwhale_api.users.factories import UserFactory
SAMPLES_PATH = os.path.join( SAMPLES_PATH = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'tests', 'music' "tests",
"music",
) )
@registry.register @registry.register
class ArtistFactory(factory.django.DjangoModelFactory): class ArtistFactory(factory.django.DjangoModelFactory):
name = factory.Faker('name') name = factory.Faker("name")
mbid = factory.Faker('uuid4') mbid = factory.Faker("uuid4")
class Meta: class Meta:
model = 'music.Artist' model = "music.Artist"
@registry.register @registry.register
class AlbumFactory(factory.django.DjangoModelFactory): class AlbumFactory(factory.django.DjangoModelFactory):
title = factory.Faker('sentence', nb_words=3) title = factory.Faker("sentence", nb_words=3)
mbid = factory.Faker('uuid4') mbid = factory.Faker("uuid4")
release_date = factory.Faker('date_object') release_date = factory.Faker("date_object")
cover = factory.django.ImageField() cover = factory.django.ImageField()
artist = factory.SubFactory(ArtistFactory) artist = factory.SubFactory(ArtistFactory)
release_group_id = factory.Faker('uuid4') release_group_id = factory.Faker("uuid4")
class Meta: class Meta:
model = 'music.Album' model = "music.Album"
@registry.register @registry.register
class TrackFactory(factory.django.DjangoModelFactory): class TrackFactory(factory.django.DjangoModelFactory):
title = factory.Faker('sentence', nb_words=3) title = factory.Faker("sentence", nb_words=3)
mbid = factory.Faker('uuid4') mbid = factory.Faker("uuid4")
album = factory.SubFactory(AlbumFactory) album = factory.SubFactory(AlbumFactory)
artist = factory.SelfAttribute('album.artist') artist = factory.SelfAttribute("album.artist")
position = 1 position = 1
tags = ManyToManyFromList('tags') tags = ManyToManyFromList("tags")
class Meta: class Meta:
model = 'music.Track' model = "music.Track"
@registry.register @registry.register
class TrackFileFactory(factory.django.DjangoModelFactory): class TrackFileFactory(factory.django.DjangoModelFactory):
track = factory.SubFactory(TrackFactory) track = factory.SubFactory(TrackFactory)
audio_file = factory.django.FileField( audio_file = factory.django.FileField(
from_path=os.path.join(SAMPLES_PATH, 'test.ogg')) from_path=os.path.join(SAMPLES_PATH, "test.ogg")
)
bitrate = None bitrate = None
size = None size = None
duration = None duration = None
class Meta: class Meta:
model = 'music.TrackFile' model = "music.TrackFile"
class Params: class Params:
in_place = factory.Trait( in_place = factory.Trait(audio_file=None)
audio_file=None,
)
federation = factory.Trait( federation = factory.Trait(
audio_file=None, audio_file=None,
library_track=factory.SubFactory(LibraryTrackFactory), library_track=factory.SubFactory(LibraryTrackFactory),
mimetype=factory.LazyAttribute( mimetype=factory.LazyAttribute(lambda o: o.library_track.audio_mimetype),
lambda o: o.library_track.audio_mimetype source=factory.LazyAttribute(lambda o: o.library_track.audio_url),
),
source=factory.LazyAttribute(
lambda o: o.library_track.audio_url
),
) )
@ -82,26 +76,21 @@ class ImportBatchFactory(factory.django.DjangoModelFactory):
submitted_by = factory.SubFactory(UserFactory) submitted_by = factory.SubFactory(UserFactory)
class Meta: class Meta:
model = 'music.ImportBatch' model = "music.ImportBatch"
class Params: class Params:
federation = factory.Trait( federation = factory.Trait(submitted_by=None, source="federation")
submitted_by=None, finished = factory.Trait(status="finished")
source='federation',
)
finished = factory.Trait(
status='finished',
)
@registry.register @registry.register
class ImportJobFactory(factory.django.DjangoModelFactory): class ImportJobFactory(factory.django.DjangoModelFactory):
batch = factory.SubFactory(ImportBatchFactory) batch = factory.SubFactory(ImportBatchFactory)
source = factory.Faker('url') source = factory.Faker("url")
mbid = factory.Faker('uuid4') mbid = factory.Faker("uuid4")
class Meta: class Meta:
model = 'music.ImportJob' model = "music.ImportJob"
class Params: class Params:
federation = factory.Trait( federation = factory.Trait(
@ -110,53 +99,51 @@ class ImportJobFactory(factory.django.DjangoModelFactory):
batch=factory.SubFactory(ImportBatchFactory, federation=True), batch=factory.SubFactory(ImportBatchFactory, federation=True),
) )
finished = factory.Trait( finished = factory.Trait(
status='finished', status="finished", track_file=factory.SubFactory(TrackFileFactory)
track_file=factory.SubFactory(TrackFileFactory),
)
in_place = factory.Trait(
status='finished',
audio_file=None,
) )
in_place = factory.Trait(status="finished", audio_file=None)
with_audio_file = factory.Trait( with_audio_file = factory.Trait(
status='finished', status="finished",
audio_file=factory.django.FileField( audio_file=factory.django.FileField(
from_path=os.path.join(SAMPLES_PATH, 'test.ogg')), from_path=os.path.join(SAMPLES_PATH, "test.ogg")
),
) )
@registry.register(name='music.FileImportJob') @registry.register(name="music.FileImportJob")
class FileImportJobFactory(ImportJobFactory): class FileImportJobFactory(ImportJobFactory):
source = 'file://' source = "file://"
mbid = None mbid = None
audio_file = factory.django.FileField( audio_file = factory.django.FileField(
from_path=os.path.join(SAMPLES_PATH, 'test.ogg')) from_path=os.path.join(SAMPLES_PATH, "test.ogg")
)
@registry.register @registry.register
class WorkFactory(factory.django.DjangoModelFactory): class WorkFactory(factory.django.DjangoModelFactory):
mbid = factory.Faker('uuid4') mbid = factory.Faker("uuid4")
language = 'eng' language = "eng"
nature = 'song' nature = "song"
title = factory.Faker('sentence', nb_words=3) title = factory.Faker("sentence", nb_words=3)
class Meta: class Meta:
model = 'music.Work' model = "music.Work"
@registry.register @registry.register
class LyricsFactory(factory.django.DjangoModelFactory): class LyricsFactory(factory.django.DjangoModelFactory):
work = factory.SubFactory(WorkFactory) work = factory.SubFactory(WorkFactory)
url = factory.Faker('url') url = factory.Faker("url")
content = factory.Faker('paragraphs', nb=4) content = factory.Faker("paragraphs", nb=4)
class Meta: class Meta:
model = 'music.Lyrics' model = "music.Lyrics"
@registry.register @registry.register
class TagFactory(factory.django.DjangoModelFactory): class TagFactory(factory.django.DjangoModelFactory):
name = factory.SelfAttribute('slug') name = factory.SelfAttribute("slug")
slug = factory.Faker('slug') slug = factory.Faker("slug")
class Meta: class Meta:
model = 'taggit.Tag' model = "taggit.Tag"

View File

@ -10,13 +10,15 @@ from funkwhale_api.music import factories
def create_data(count=25): def create_data(count=25):
artists = factories.ArtistFactory.create_batch(size=count) artists = factories.ArtistFactory.create_batch(size=count)
for artist in artists: for artist in artists:
print('Creating data for', artist) print("Creating data for", artist)
albums = factories.AlbumFactory.create_batch( albums = factories.AlbumFactory.create_batch(
artist=artist, size=random.randint(1, 5)) artist=artist, size=random.randint(1, 5)
)
for album in albums: for album in albums:
factories.TrackFileFactory.create_batch( factories.TrackFileFactory.create_batch(
track__album=album, size=random.randint(3, 18)) track__album=album, size=random.randint(3, 18)
)
if __name__ == '__main__': if __name__ == "__main__":
create_data() create_data()

View File

@ -7,12 +7,10 @@ from . import models
class ListenableMixin(filters.FilterSet): class ListenableMixin(filters.FilterSet):
listenable = filters.BooleanFilter(name='_', method='filter_listenable') listenable = filters.BooleanFilter(name="_", method="filter_listenable")
def filter_listenable(self, queryset, name, value): def filter_listenable(self, queryset, name, value):
queryset = queryset.annotate( queryset = queryset.annotate(files_count=Count("tracks__files"))
files_count=Count('tracks__files')
)
if value: if value:
return queryset.filter(files_count__gt=0) return queryset.filter(files_count__gt=0)
else: else:
@ -20,39 +18,31 @@ class ListenableMixin(filters.FilterSet):
class ArtistFilter(ListenableMixin): class ArtistFilter(ListenableMixin):
q = fields.SearchFilter(search_fields=[ q = fields.SearchFilter(search_fields=["name"])
'name',
])
class Meta: class Meta:
model = models.Artist model = models.Artist
fields = { fields = {
'name': ['exact', 'iexact', 'startswith', 'icontains'], "name": ["exact", "iexact", "startswith", "icontains"],
'listenable': 'exact', "listenable": "exact",
} }
class TrackFilter(filters.FilterSet): class TrackFilter(filters.FilterSet):
q = fields.SearchFilter(search_fields=[ q = fields.SearchFilter(search_fields=["title", "album__title", "artist__name"])
'title', listenable = filters.BooleanFilter(name="_", method="filter_listenable")
'album__title',
'artist__name',
])
listenable = filters.BooleanFilter(name='_', method='filter_listenable')
class Meta: class Meta:
model = models.Track model = models.Track
fields = { fields = {
'title': ['exact', 'iexact', 'startswith', 'icontains'], "title": ["exact", "iexact", "startswith", "icontains"],
'listenable': ['exact'], "listenable": ["exact"],
'artist': ['exact'], "artist": ["exact"],
'album': ['exact'], "album": ["exact"],
} }
def filter_listenable(self, queryset, name, value): def filter_listenable(self, queryset, name, value):
queryset = queryset.annotate( queryset = queryset.annotate(files_count=Count("files"))
files_count=Count('files')
)
if value: if value:
return queryset.filter(files_count__gt=0) return queryset.filter(files_count__gt=0)
else: else:
@ -60,46 +50,32 @@ class TrackFilter(filters.FilterSet):
class ImportBatchFilter(filters.FilterSet): class ImportBatchFilter(filters.FilterSet):
q = fields.SearchFilter(search_fields=[ q = fields.SearchFilter(search_fields=["submitted_by__username", "source"])
'submitted_by__username',
'source',
])
class Meta: class Meta:
model = models.ImportBatch model = models.ImportBatch
fields = { fields = {"status": ["exact"], "source": ["exact"], "submitted_by": ["exact"]}
'status': ['exact'],
'source': ['exact'],
'submitted_by': ['exact'],
}
class ImportJobFilter(filters.FilterSet): class ImportJobFilter(filters.FilterSet):
q = fields.SearchFilter(search_fields=[ q = fields.SearchFilter(search_fields=["batch__submitted_by__username", "source"])
'batch__submitted_by__username',
'source',
])
class Meta: class Meta:
model = models.ImportJob model = models.ImportJob
fields = { fields = {
'batch': ['exact'], "batch": ["exact"],
'batch__status': ['exact'], "batch__status": ["exact"],
'batch__source': ['exact'], "batch__source": ["exact"],
'batch__submitted_by': ['exact'], "batch__submitted_by": ["exact"],
'status': ['exact'], "status": ["exact"],
'source': ['exact'], "source": ["exact"],
} }
class AlbumFilter(ListenableMixin): class AlbumFilter(ListenableMixin):
listenable = filters.BooleanFilter(name='_', method='filter_listenable') listenable = filters.BooleanFilter(name="_", method="filter_listenable")
q = fields.SearchFilter(search_fields=[ q = fields.SearchFilter(search_fields=["title", "artist__name" "source"])
'title',
'artist__name'
'source',
])
class Meta: class Meta:
model = models.Album model = models.Album
fields = ['listenable', 'q', 'artist'] fields = ["listenable", "q", "artist"]

View File

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

View File

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

View File

@ -10,20 +10,20 @@ from funkwhale_api.music import models, utils
class Command(BaseCommand): class Command(BaseCommand):
help = 'Run common checks and fix against imported tracks' help = "Run common checks and fix against imported tracks"
def add_arguments(self, parser): def add_arguments(self, parser):
parser.add_argument( parser.add_argument(
'--dry-run', "--dry-run",
action='store_true', action="store_true",
dest='dry_run', dest="dry_run",
default=False, default=False,
help='Do not execute anything' help="Do not execute anything",
) )
def handle(self, *args, **options): def handle(self, *args, **options):
if options['dry_run']: if options["dry_run"]:
self.stdout.write('Dry-run on, will not commit anything') self.stdout.write("Dry-run on, will not commit anything")
self.fix_mimetypes(**options) self.fix_mimetypes(**options)
self.fix_file_data(**options) self.fix_file_data(**options)
self.fix_file_size(**options) self.fix_file_size(**options)
@ -31,75 +31,73 @@ class Command(BaseCommand):
@transaction.atomic @transaction.atomic
def fix_mimetypes(self, dry_run, **kwargs): def fix_mimetypes(self, dry_run, **kwargs):
self.stdout.write('Fixing missing mimetypes...') self.stdout.write("Fixing missing mimetypes...")
matching = models.TrackFile.objects.filter( matching = models.TrackFile.objects.filter(
source__startswith='file://').exclude(mimetype__startswith='audio/') source__startswith="file://"
).exclude(mimetype__startswith="audio/")
self.stdout.write( self.stdout.write(
'[mimetypes] {} entries found with bad or no mimetype'.format( "[mimetypes] {} entries found with bad or no mimetype".format(
matching.count())) matching.count()
)
)
for extension, mimetype in utils.EXTENSION_TO_MIMETYPE.items(): for extension, mimetype in utils.EXTENSION_TO_MIMETYPE.items():
qs = matching.filter(source__endswith='.{}'.format(extension)) qs = matching.filter(source__endswith=".{}".format(extension))
self.stdout.write( self.stdout.write(
'[mimetypes] setting {} {} files to {}'.format( "[mimetypes] setting {} {} files to {}".format(
qs.count(), extension, mimetype qs.count(), extension, mimetype
)) )
)
if not dry_run: if not dry_run:
self.stdout.write('[mimetypes] commiting...') self.stdout.write("[mimetypes] commiting...")
qs.update(mimetype=mimetype) qs.update(mimetype=mimetype)
def fix_file_data(self, dry_run, **kwargs): def fix_file_data(self, dry_run, **kwargs):
self.stdout.write('Fixing missing bitrate or length...') self.stdout.write("Fixing missing bitrate or length...")
matching = models.TrackFile.objects.filter( matching = models.TrackFile.objects.filter(
Q(bitrate__isnull=True) | Q(duration__isnull=True)) Q(bitrate__isnull=True) | Q(duration__isnull=True)
)
total = matching.count() total = matching.count()
self.stdout.write( self.stdout.write(
'[bitrate/length] {} entries found with missing values'.format( "[bitrate/length] {} entries found with missing values".format(total)
total)) )
if dry_run: if dry_run:
return return
for i, tf in enumerate(matching.only('audio_file')): for i, tf in enumerate(matching.only("audio_file")):
self.stdout.write( self.stdout.write(
'[bitrate/length] {}/{} fixing file #{}'.format( "[bitrate/length] {}/{} fixing file #{}".format(i + 1, total, tf.pk)
i+1, total, tf.pk )
))
try: try:
audio_file = tf.get_audio_file() audio_file = tf.get_audio_file()
if audio_file: if audio_file:
with audio_file as f: with audio_file as f:
data = utils.get_audio_file_data(audio_file) data = utils.get_audio_file_data(audio_file)
tf.bitrate = data['bitrate'] tf.bitrate = data["bitrate"]
tf.duration = data['length'] tf.duration = data["length"]
tf.save(update_fields=['duration', 'bitrate']) tf.save(update_fields=["duration", "bitrate"])
else: else:
self.stderr.write('[bitrate/length] no file found') self.stderr.write("[bitrate/length] no file found")
except Exception as e: except Exception as e:
self.stderr.write( self.stderr.write(
'[bitrate/length] error with file #{}: {}'.format( "[bitrate/length] error with file #{}: {}".format(tf.pk, str(e))
tf.pk, str(e)
)
) )
def fix_file_size(self, dry_run, **kwargs): def fix_file_size(self, dry_run, **kwargs):
self.stdout.write('Fixing missing size...') self.stdout.write("Fixing missing size...")
matching = models.TrackFile.objects.filter(size__isnull=True) matching = models.TrackFile.objects.filter(size__isnull=True)
total = matching.count() total = matching.count()
self.stdout.write( self.stdout.write("[size] {} entries found with missing values".format(total))
'[size] {} entries found with missing values'.format(total))
if dry_run: if dry_run:
return return
for i, tf in enumerate(matching.only('size')): for i, tf in enumerate(matching.only("size")):
self.stdout.write( self.stdout.write(
'[size] {}/{} fixing file #{}'.format( "[size] {}/{} fixing file #{}".format(i + 1, total, tf.pk)
i+1, total, tf.pk )
))
try: try:
tf.size = tf.get_file_size() tf.size = tf.get_file_size()
tf.save(update_fields=['size']) tf.save(update_fields=["size"])
except Exception as e: except Exception as e:
self.stderr.write( self.stderr.write(
'[size] error with file #{}: {}'.format( "[size] error with file #{}: {}".format(tf.pk, str(e))
tf.pk, str(e)
)
) )

View File

@ -14,21 +14,17 @@ class UnsupportedTag(KeyError):
def get_id3_tag(f, k): def get_id3_tag(f, k):
if k == 'pictures': if k == "pictures":
return f.tags.getall('APIC') return f.tags.getall("APIC")
# First we try to grab the standard key # First we try to grab the standard key
try: try:
return f.tags[k].text[0] return f.tags[k].text[0]
except KeyError: except KeyError:
pass pass
# then we fallback on parsing non standard tags # then we fallback on parsing non standard tags
all_tags = f.tags.getall('TXXX') all_tags = f.tags.getall("TXXX")
try: try:
matches = [ matches = [t for t in all_tags if t.desc.lower() == k.lower()]
t
for t in all_tags
if t.desc.lower() == k.lower()
]
return matches[0].text[0] return matches[0].text[0]
except (KeyError, IndexError): except (KeyError, IndexError):
raise TagNotFound(k) raise TagNotFound(k)
@ -37,17 +33,19 @@ def get_id3_tag(f, k):
def clean_id3_pictures(apic): def clean_id3_pictures(apic):
pictures = [] pictures = []
for p in list(apic): for p in list(apic):
pictures.append({ pictures.append(
'mimetype': p.mime, {
'content': p.data, "mimetype": p.mime,
'description': p.desc, "content": p.data,
'type': p.type.real, "description": p.desc,
}) "type": p.type.real,
}
)
return pictures return pictures
def get_flac_tag(f, k): def get_flac_tag(f, k):
if k == 'pictures': if k == "pictures":
return f.pictures return f.pictures
try: try:
return f.get(k, [])[0] return f.get(k, [])[0]
@ -58,22 +56,22 @@ def get_flac_tag(f, k):
def clean_flac_pictures(apic): def clean_flac_pictures(apic):
pictures = [] pictures = []
for p in list(apic): for p in list(apic):
pictures.append({ pictures.append(
'mimetype': p.mime, {
'content': p.data, "mimetype": p.mime,
'description': p.desc, "content": p.data,
'type': p.type.real, "description": p.desc,
}) "type": p.type.real,
}
)
return pictures return pictures
def get_mp3_recording_id(f, k): def get_mp3_recording_id(f, k):
try: try:
return [ return [t for t in f.tags.getall("UFID") if "musicbrainz.org" in t.owner][
t 0
for t in f.tags.getall('UFID') ].data.decode("utf-8")
if 'musicbrainz.org' in t.owner
][0].data.decode('utf-8')
except IndexError: except IndexError:
raise TagNotFound(k) raise TagNotFound(k)
@ -86,18 +84,17 @@ def convert_track_number(v):
pass pass
try: try:
return int(v.split('/')[0]) return int(v.split("/")[0])
except (ValueError, AttributeError, IndexError): except (ValueError, AttributeError, IndexError):
pass pass
class FirstUUIDField(forms.UUIDField): class FirstUUIDField(forms.UUIDField):
def to_python(self, value): def to_python(self, value):
try: try:
# sometimes, Picard leaves to uuids in the field, separated # sometimes, Picard leaves to uuids in the field, separated
# by a slash # by a slash
value = value.split('/')[0] value = value.split("/")[0]
except (AttributeError, IndexError, TypeError): except (AttributeError, IndexError, TypeError):
pass pass
@ -105,150 +102,119 @@ class FirstUUIDField(forms.UUIDField):
VALIDATION = { VALIDATION = {
'musicbrainz_artistid': FirstUUIDField(), "musicbrainz_artistid": FirstUUIDField(),
'musicbrainz_albumid': FirstUUIDField(), "musicbrainz_albumid": FirstUUIDField(),
'musicbrainz_recordingid': FirstUUIDField(), "musicbrainz_recordingid": FirstUUIDField(),
} }
CONF = { CONF = {
'OggVorbis': { "OggVorbis": {
'getter': lambda f, k: f[k][0], "getter": lambda f, k: f[k][0],
'fields': { "fields": {
'track_number': { "track_number": {
'field': 'TRACKNUMBER', "field": "TRACKNUMBER",
'to_application': convert_track_number "to_application": convert_track_number,
}, },
'title': {}, "title": {},
'artist': {}, "artist": {},
'album': {}, "album": {},
'date': { "date": {"field": "date", "to_application": lambda v: arrow.get(v).date()},
'field': 'date', "musicbrainz_albumid": {},
'to_application': lambda v: arrow.get(v).date() "musicbrainz_artistid": {},
}, "musicbrainz_recordingid": {"field": "musicbrainz_trackid"},
'musicbrainz_albumid': {}, },
'musicbrainz_artistid': {},
'musicbrainz_recordingid': {
'field': 'musicbrainz_trackid'
},
}
}, },
'OggTheora': { "OggTheora": {
'getter': lambda f, k: f[k][0], "getter": lambda f, k: f[k][0],
'fields': { "fields": {
'track_number': { "track_number": {
'field': 'TRACKNUMBER', "field": "TRACKNUMBER",
'to_application': convert_track_number "to_application": convert_track_number,
}, },
'title': {}, "title": {},
'artist': {}, "artist": {},
'album': {}, "album": {},
'date': { "date": {"field": "date", "to_application": lambda v: arrow.get(v).date()},
'field': 'date', "musicbrainz_albumid": {"field": "MusicBrainz Album Id"},
'to_application': lambda v: arrow.get(v).date() "musicbrainz_artistid": {"field": "MusicBrainz Artist Id"},
}, "musicbrainz_recordingid": {"field": "MusicBrainz Track Id"},
'musicbrainz_albumid': { },
'field': 'MusicBrainz Album Id'
},
'musicbrainz_artistid': {
'field': 'MusicBrainz Artist Id'
},
'musicbrainz_recordingid': {
'field': 'MusicBrainz Track Id'
},
}
}, },
'MP3': { "MP3": {
'getter': get_id3_tag, "getter": get_id3_tag,
'clean_pictures': clean_id3_pictures, "clean_pictures": clean_id3_pictures,
'fields': { "fields": {
'track_number': { "track_number": {"field": "TRCK", "to_application": convert_track_number},
'field': 'TRCK', "title": {"field": "TIT2"},
'to_application': convert_track_number "artist": {"field": "TPE1"},
"album": {"field": "TALB"},
"date": {
"field": "TDRC",
"to_application": lambda v: arrow.get(str(v)).date(),
}, },
'title': { "musicbrainz_albumid": {"field": "MusicBrainz Album Id"},
'field': 'TIT2' "musicbrainz_artistid": {"field": "MusicBrainz Artist Id"},
"musicbrainz_recordingid": {
"field": "UFID",
"getter": get_mp3_recording_id,
}, },
'artist': { "pictures": {},
'field': 'TPE1' },
},
'album': {
'field': 'TALB'
},
'date': {
'field': 'TDRC',
'to_application': lambda v: arrow.get(str(v)).date()
},
'musicbrainz_albumid': {
'field': 'MusicBrainz Album Id'
},
'musicbrainz_artistid': {
'field': 'MusicBrainz Artist Id'
},
'musicbrainz_recordingid': {
'field': 'UFID',
'getter': get_mp3_recording_id,
},
'pictures': {},
}
}, },
'FLAC': { "FLAC": {
'getter': get_flac_tag, "getter": get_flac_tag,
'clean_pictures': clean_flac_pictures, "clean_pictures": clean_flac_pictures,
'fields': { "fields": {
'track_number': { "track_number": {
'field': 'tracknumber', "field": "tracknumber",
'to_application': convert_track_number "to_application": convert_track_number,
}, },
'title': {}, "title": {},
'artist': {}, "artist": {},
'album': {}, "album": {},
'date': { "date": {
'field': 'date', "field": "date",
'to_application': lambda v: arrow.get(str(v)).date() "to_application": lambda v: arrow.get(str(v)).date(),
}, },
'musicbrainz_albumid': {}, "musicbrainz_albumid": {},
'musicbrainz_artistid': {}, "musicbrainz_artistid": {},
'musicbrainz_recordingid': { "musicbrainz_recordingid": {"field": "musicbrainz_trackid"},
'field': 'musicbrainz_trackid' "test": {},
}, "pictures": {},
'test': {}, },
'pictures': {},
}
}, },
} }
class Metadata(object): class Metadata(object):
def __init__(self, path): def __init__(self, path):
self._file = mutagen.File(path) self._file = mutagen.File(path)
if self._file is None: if self._file is None:
raise ValueError('Cannot parse metadata from {}'.format(path)) raise ValueError("Cannot parse metadata from {}".format(path))
ft = self.get_file_type(self._file) ft = self.get_file_type(self._file)
try: try:
self._conf = CONF[ft] self._conf = CONF[ft]
except KeyError: except KeyError:
raise ValueError('Unsupported format {}'.format(ft)) raise ValueError("Unsupported format {}".format(ft))
def get_file_type(self, f): def get_file_type(self, f):
return f.__class__.__name__ return f.__class__.__name__
def get(self, key, default=NODEFAULT): def get(self, key, default=NODEFAULT):
try: try:
field_conf = self._conf['fields'][key] field_conf = self._conf["fields"][key]
except KeyError: except KeyError:
raise UnsupportedTag( raise UnsupportedTag("{} is not supported for this file format".format(key))
'{} is not supported for this file format'.format(key)) real_key = field_conf.get("field", key)
real_key = field_conf.get('field', key)
try: try:
getter = field_conf.get('getter', self._conf['getter']) getter = field_conf.get("getter", self._conf["getter"])
v = getter(self._file, real_key) v = getter(self._file, real_key)
except KeyError: except KeyError:
if default == NODEFAULT: if default == NODEFAULT:
raise TagNotFound(real_key) raise TagNotFound(real_key)
return default return default
converter = field_conf.get('to_application') converter = field_conf.get("to_application")
if converter: if converter:
v = converter(v) v = converter(v)
field = VALIDATION.get(key) field = VALIDATION.get(key)
@ -256,15 +222,15 @@ class Metadata(object):
v = field.to_python(v) v = field.to_python(v)
return v return v
def get_picture(self, picture_type='cover_front'): def get_picture(self, picture_type="cover_front"):
ptype = getattr(mutagen.id3.PictureType, picture_type.upper()) ptype = getattr(mutagen.id3.PictureType, picture_type.upper())
try: try:
pictures = self.get('pictures') pictures = self.get("pictures")
except (UnsupportedTag, TagNotFound): except (UnsupportedTag, TagNotFound):
return return
cleaner = self._conf.get('clean_pictures', lambda v: v) cleaner = self._conf.get("clean_pictures", lambda v: v)
pictures = cleaner(pictures) pictures = cleaner(pictures)
for p in pictures: for p in pictures:
if p['type'] == ptype: if p["type"] == ptype:
return p return p

View File

@ -8,82 +8,183 @@ import django.utils.timezone
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)]
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Album', name="Album",
fields=[ fields=[
('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), (
('mbid', models.UUIDField(editable=False, blank=True, null=True)), "id",
('creation_date', models.DateTimeField(default=django.utils.timezone.now)), models.AutoField(
('title', models.CharField(max_length=255)), primary_key=True,
('release_date', models.DateField()), auto_created=True,
('type', models.CharField(default='album', choices=[('album', 'Album')], max_length=30)), 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={ options={"abstract": False},
'abstract': False,
},
), ),
migrations.CreateModel( migrations.CreateModel(
name='Artist', name="Artist",
fields=[ fields=[
('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), (
('mbid', models.UUIDField(editable=False, blank=True, null=True)), "id",
('creation_date', models.DateTimeField(default=django.utils.timezone.now)), models.AutoField(
('name', models.CharField(max_length=255)), 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={ options={"abstract": False},
'abstract': False,
},
), ),
migrations.CreateModel( migrations.CreateModel(
name='ImportBatch', name="ImportBatch",
fields=[ fields=[
('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), (
('creation_date', models.DateTimeField(default=django.utils.timezone.now)), "id",
('submitted_by', models.ForeignKey(related_name='imports', to=settings.AUTH_USER_MODEL, on_delete=models.CASCADE)), 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,
on_delete=models.CASCADE,
),
),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='ImportJob', name="ImportJob",
fields=[ fields=[
('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), (
('source', models.URLField()), "id",
('mbid', models.UUIDField(editable=False)), models.AutoField(
('status', models.CharField(default='pending', choices=[('pending', 'Pending'), ('finished', 'finished')], max_length=30)), primary_key=True,
('batch', models.ForeignKey(related_name='jobs', to='music.ImportBatch', on_delete=models.CASCADE)), 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",
on_delete=models.CASCADE,
),
),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(
name='Track', name="Track",
fields=[ fields=[
('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), (
('mbid', models.UUIDField(editable=False, blank=True, null=True)), "id",
('creation_date', models.DateTimeField(default=django.utils.timezone.now)), models.AutoField(
('title', models.CharField(max_length=255)), primary_key=True,
('album', models.ForeignKey(related_name='tracks', blank=True, null=True, to='music.Album', on_delete=models.CASCADE)), auto_created=True,
('artist', models.ForeignKey(related_name='tracks', to='music.Artist', on_delete=models.CASCADE)), 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",
on_delete=models.CASCADE,
),
),
(
"artist",
models.ForeignKey(
related_name="tracks",
to="music.Artist",
on_delete=models.CASCADE,
),
),
], ],
options={ options={"abstract": False},
'abstract': False,
},
), ),
migrations.CreateModel( migrations.CreateModel(
name='TrackFile', name="TrackFile",
fields=[ fields=[
('id', models.AutoField(primary_key=True, auto_created=True, serialize=False, verbose_name='ID')), (
('audio_file', models.FileField(upload_to='tracks')), "id",
('source', models.URLField(blank=True, null=True)), models.AutoField(
('duration', models.IntegerField(blank=True, null=True)), primary_key=True,
('track', models.ForeignKey(related_name='files', to='music.Track', on_delete=models.CASCADE)), 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", on_delete=models.CASCADE
),
),
], ],
), ),
migrations.AddField( migrations.AddField(
model_name='album', model_name="album",
name='artist', name="artist",
field=models.ForeignKey(related_name='albums', to='music.Artist', on_delete=models.CASCADE), field=models.ForeignKey(
related_name="albums", to="music.Artist", on_delete=models.CASCADE
),
), ),
] ]

View File

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

View File

@ -6,14 +6,10 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [("music", "0002_auto_20151215_1645")]
('music', '0002_auto_20151215_1645'),
]
operations = [ operations = [
migrations.AlterField( migrations.AlterField(
model_name='album', model_name="album", name="release_date", field=models.DateField(null=True)
name='release_date', )
field=models.DateField(null=True),
),
] ]

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