Verified Commit 98e5fdeb authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Merge branch 'release/0.5.3'

parents 9dc69ac1 41404a59
Changelog Changelog
========= =========
0.6 (Unreleased) .. towncrier
----------------
0.5.3 (2018-02-27)
------------------
Features:
- Added admin interface for radios, track files, favorites and import requests (#80)
- Added basic instance stats on /about (#82)
- Search now unaccent letters for queries like "The Dø" or "Björk" yielding more results (#81)
Bugfixes:
- Always use username in sidebar (#89)
- Click event outside of player icons (#83)
- Fixed broken import because of missing transaction
- Now always load next radio track on last queue track ended (#87)
- Now exclude tracks without file from radio candidates (#88)
- skip to next track properly on 40X errors (#86)
Other:
- Switched to towncrier for changelog management and compilation
0.5.2 (2018-02-26) 0.5.2 (2018-02-26)
......
...@@ -37,6 +37,7 @@ DJANGO_APPS = ( ...@@ -37,6 +37,7 @@ DJANGO_APPS = (
'django.contrib.sites', 'django.contrib.sites',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
'django.contrib.postgres',
# Useful template tags: # Useful template tags:
# 'django.contrib.humanize', # 'django.contrib.humanize',
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
__version__ = '0.5.2' __version__ = '0.5.3'
__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('.')])
# Generated by Django 2.0.2 on 2018-02-27 18:43
from django.db import migrations
from django.contrib.postgres.operations import UnaccentExtension
class Migration(migrations.Migration):
dependencies = []
operations = [
UnaccentExtension()
]
import os import os
import shutil import shutil
from django.db import transaction
def rename_file(instance, field_name, new_name, allow_missing_file=False): def rename_file(instance, field_name, new_name, allow_missing_file=False):
field = getattr(instance, field_name) field = getattr(instance, field_name)
...@@ -17,3 +19,9 @@ def rename_file(instance, field_name, new_name, allow_missing_file=False): ...@@ -17,3 +19,9 @@ def rename_file(instance, field_name, new_name, allow_missing_file=False):
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()
return new_name_with_extension return new_name_with_extension
def on_commit(f, *args, **kwargs):
return transaction.on_commit(
lambda: f(*args, **kwargs)
)
from django.contrib import admin
from . import models
@admin.register(models.TrackFavorite)
class TrackFavoriteAdmin(admin.ModelAdmin):
list_display = ['user', 'track', 'creation_date']
list_select_related = [
'user',
'track'
]
...@@ -6,3 +6,7 @@ from . import models ...@@ -6,3 +6,7 @@ from . import models
class ListeningAdmin(admin.ModelAdmin): class ListeningAdmin(admin.ModelAdmin):
list_display = ['track', 'end_date', 'user', 'session_key'] list_display = ['track', 'end_date', 'user', 'session_key']
search_fields = ['track__name', 'user__username'] search_fields = ['track__name', 'user__username']
list_select_related = [
'user',
'track'
]
from django.db.models import Sum
from funkwhale_api.favorites.models import TrackFavorite
from funkwhale_api.history.models import Listening
from funkwhale_api.music import models
from funkwhale_api.users.models import User
def get():
return {
'users': get_users(),
'tracks': get_tracks(),
'albums': get_albums(),
'artists': get_artists(),
'track_favorites': get_track_favorites(),
'listenings': get_listenings(),
'music_duration': get_music_duration(),
}
def get_users():
return User.objects.count()
def get_listenings():
return Listening.objects.count()
def get_track_favorites():
return TrackFavorite.objects.count()
def get_tracks():
return models.Track.objects.count()
def get_albums():
return models.Album.objects.count()
def get_artists():
return models.Artist.objects.count()
def get_music_duration():
seconds = models.TrackFile.objects.aggregate(
d=Sum('duration'),
)['d']
if seconds:
return seconds / 3600
return 0
from django.conf.urls import url from django.conf.urls import url
from django.views.decorators.cache import cache_page
from . import views from . import views
urlpatterns = [ urlpatterns = [
url(r'^settings/$', views.InstanceSettings.as_view(), name='settings'), url(r'^settings/$', views.InstanceSettings.as_view(), name='settings'),
url(r'^stats/$',
cache_page(60 * 5)(views.InstanceStats.as_view()), name='stats'),
] ]
...@@ -4,6 +4,8 @@ from rest_framework.response import Response ...@@ -4,6 +4,8 @@ from rest_framework.response import Response
from dynamic_preferences.api import serializers from dynamic_preferences.api import serializers
from dynamic_preferences.registries import global_preferences_registry from dynamic_preferences.registries import global_preferences_registry
from . import stats
class InstanceSettings(views.APIView): class InstanceSettings(views.APIView):
permission_classes = [] permission_classes = []
...@@ -23,3 +25,12 @@ class InstanceSettings(views.APIView): ...@@ -23,3 +25,12 @@ class InstanceSettings(views.APIView):
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)
class InstanceStats(views.APIView):
permission_classes = []
authentication_classes = []
def get(self, request, *args, **kwargs):
data = stats.get()
return Response(data, status=200)
...@@ -25,13 +25,26 @@ class TrackAdmin(admin.ModelAdmin): ...@@ -25,13 +25,26 @@ class TrackAdmin(admin.ModelAdmin):
@admin.register(models.ImportBatch) @admin.register(models.ImportBatch)
class ImportBatchAdmin(admin.ModelAdmin): class ImportBatchAdmin(admin.ModelAdmin):
list_display = ['creation_date', 'status'] list_display = [
'submitted_by',
'creation_date',
'import_request',
'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 = True list_select_related = [
'track_file',
'batch',
]
search_fields = ['source', 'batch__pk', 'mbid'] search_fields = ['source', 'batch__pk', 'mbid']
list_filter = ['status'] list_filter = ['status']
...@@ -50,3 +63,19 @@ class LyricsAdmin(admin.ModelAdmin): ...@@ -50,3 +63,19 @@ class LyricsAdmin(admin.ModelAdmin):
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)
class TrackFileAdmin(admin.ModelAdmin):
list_display = [
'track',
'audio_file',
'source',
'duration',
'mimetype',
]
list_select_related = [
'track'
]
search_fields = ['source', 'acoustid_track_id']
list_filter = ['mimetype']
...@@ -73,7 +73,10 @@ def _do_import(import_job, replace): ...@@ -73,7 +73,10 @@ def _do_import(import_job, replace):
@celery.app.task(name='ImportJob.run', bind=True) @celery.app.task(name='ImportJob.run', bind=True)
@celery.require_instance(models.ImportJob, 'import_job') @celery.require_instance(
models.ImportJob.objects.filter(
status__in=['pending', 'errored']),
'import_job')
def import_job_run(self, import_job, replace=False): def import_job_run(self, import_job, replace=False):
def mark_errored(): def mark_errored():
import_job.status = 'errored' import_job.status = 'errored'
......
...@@ -19,6 +19,7 @@ from musicbrainzngs import ResponseError ...@@ -19,6 +19,7 @@ from musicbrainzngs import ResponseError
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from funkwhale_api.common import utils as funkwhale_utils
from funkwhale_api.requests.models import ImportRequest from funkwhale_api.requests.models import ImportRequest
from funkwhale_api.musicbrainz import api from funkwhale_api.musicbrainz import api
from funkwhale_api.common.permissions import ( from funkwhale_api.common.permissions import (
...@@ -62,7 +63,7 @@ class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet): ...@@ -62,7 +63,7 @@ class ArtistViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
'albums__tracks__tags')) 'albums__tracks__tags'))
serializer_class = serializers.ArtistSerializerNested serializer_class = serializers.ArtistSerializerNested
permission_classes = [ConditionalAuthentication] permission_classes = [ConditionalAuthentication]
search_fields = ['name'] search_fields = ['name__unaccent']
filter_class = filters.ArtistFilter filter_class = filters.ArtistFilter
ordering_fields = ('id', 'name', 'creation_date') ordering_fields = ('id', 'name', 'creation_date')
...@@ -75,7 +76,7 @@ class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet): ...@@ -75,7 +76,7 @@ class AlbumViewSet(SearchMixin, viewsets.ReadOnlyModelViewSet):
'tracks__files')) 'tracks__files'))
serializer_class = serializers.AlbumSerializerNested serializer_class = serializers.AlbumSerializerNested
permission_classes = [ConditionalAuthentication] permission_classes = [ConditionalAuthentication]
search_fields = ['title'] search_fields = ['title__unaccent']
ordering_fields = ('creation_date',) ordering_fields = ('creation_date',)
...@@ -116,7 +117,10 @@ class ImportJobViewSet( ...@@ -116,7 +117,10 @@ class ImportJobViewSet(
def perform_create(self, serializer): def perform_create(self, serializer):
source = 'file://' + serializer.validated_data['audio_file'].name source = 'file://' + serializer.validated_data['audio_file'].name
serializer.save(source=source) serializer.save(source=source)
tasks.import_job_run.delay(import_job_id=serializer.instance.pk) funkwhale_utils.on_commit(
tasks.import_job_run.delay,
import_job_id=serializer.instance.pk
)
class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet): class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
...@@ -129,9 +133,9 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet): ...@@ -129,9 +133,9 @@ class TrackViewSet(TagViewSetMixin, SearchMixin, viewsets.ReadOnlyModelViewSet):
search_fields = ['title', 'artist__name'] search_fields = ['title', 'artist__name']
ordering_fields = ( ordering_fields = (
'creation_date', 'creation_date',
'title', 'title__unaccent',
'album__title', 'album__title__unaccent',
'artist__name', 'artist__name__unaccent',
) )
def get_queryset(self): def get_queryset(self):
...@@ -245,7 +249,11 @@ class Search(views.APIView): ...@@ -245,7 +249,11 @@ class Search(views.APIView):
return Response(results, status=200) return Response(results, status=200)
def get_tracks(self, query): def get_tracks(self, query):
search_fields = ['mbid', 'title', 'album__title', 'artist__name'] search_fields = [
'mbid',
'title__unaccent',
'album__title__unaccent',
'artist__name__unaccent']
query_obj = utils.get_query(query, search_fields) query_obj = utils.get_query(query, search_fields)
return ( return (
models.Track.objects.all() models.Track.objects.all()
...@@ -259,7 +267,10 @@ class Search(views.APIView): ...@@ -259,7 +267,10 @@ class Search(views.APIView):
def get_albums(self, query): def get_albums(self, query):
search_fields = ['mbid', 'title', 'artist__name'] search_fields = [
'mbid',
'title__unaccent',
'artist__name__unaccent']
query_obj = utils.get_query(query, search_fields) query_obj = utils.get_query(query, search_fields)
return ( return (
models.Album.objects.all() models.Album.objects.all()
...@@ -273,7 +284,7 @@ class Search(views.APIView): ...@@ -273,7 +284,7 @@ class Search(views.APIView):
def get_artists(self, query): def get_artists(self, query):
search_fields = ['mbid', 'name'] search_fields = ['mbid', 'name__unaccent']
query_obj = utils.get_query(query, search_fields) query_obj = utils.get_query(query, search_fields)
return ( return (
models.Artist.objects.all() models.Artist.objects.all()
...@@ -288,7 +299,7 @@ class Search(views.APIView): ...@@ -288,7 +299,7 @@ class Search(views.APIView):
def get_tags(self, query): def get_tags(self, query):
search_fields = ['slug', 'name'] search_fields = ['slug', 'name__unaccent']
query_obj = utils.get_query(query, search_fields) query_obj = utils.get_query(query, search_fields)
# We want the shortest tag first # We want the shortest tag first
...@@ -336,6 +347,7 @@ class SubmitViewSet(viewsets.ViewSet): ...@@ -336,6 +347,7 @@ class SubmitViewSet(viewsets.ViewSet):
data, request, batch=None, import_request=import_request) data, request, batch=None, import_request=import_request)
return Response(import_data) return Response(import_data)
@transaction.atomic
def _import_album(self, data, request, batch=None, import_request=None): def _import_album(self, data, request, batch=None, import_request=None):
# we import the whole album here to prevent race conditions that occurs # we import the whole album here to prevent race conditions that occurs
# when using get_or_create_from_api in tasks # when using get_or_create_from_api in tasks
...@@ -355,7 +367,11 @@ class SubmitViewSet(viewsets.ViewSet): ...@@ -355,7 +367,11 @@ class SubmitViewSet(viewsets.ViewSet):
models.TrackFile.objects.get(track__mbid=row['mbid']) models.TrackFile.objects.get(track__mbid=row['mbid'])
except models.TrackFile.DoesNotExist: except models.TrackFile.DoesNotExist:
job = models.ImportJob.objects.create(mbid=row['mbid'], batch=batch, source=row['source']) job = models.ImportJob.objects.create(mbid=row['mbid'], batch=batch, source=row['source'])
tasks.import_job_run.delay(import_job_id=job.pk) funkwhale_utils.on_commit(
tasks.import_job_run.delay,
import_job_id=job.pk
)
serializer = serializers.ImportBatchSerializer(batch) serializer = serializers.ImportBatchSerializer(batch)
return serializer.data, batch return serializer.data, batch
......
...@@ -3,6 +3,9 @@ import os ...@@ -3,6 +3,9 @@ import os
from django.core.files import File from django.core.files import File
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from funkwhale_api.common import utils
from funkwhale_api.music import tasks from funkwhale_api.music import tasks
from funkwhale_api.users.models import User from funkwhale_api.users.models import User
...@@ -86,6 +89,7 @@ class Command(BaseCommand): ...@@ -86,6 +89,7 @@ class Command(BaseCommand):
self.stdout.write( self.stdout.write(
"For details, please refer to import batch #".format(batch.pk)) "For details, please refer to import batch #".format(batch.pk))
@transaction.atomic
def do_import(self, matching, user, options): def do_import(self, matching, user, options):
message = 'Importing {}...' message = 'Importing {}...'
if options['async']: if options['async']:
...@@ -94,7 +98,7 @@ class Command(BaseCommand): ...@@ -94,7 +98,7 @@ class Command(BaseCommand):
# we create an import batch binded to the user # we create an import batch binded to the user
batch = user.imports.create(source='shell') batch = user.imports.create(source='shell')
async = options['async'] async = options['async']
handler = tasks.import_job_run.delay if async else tasks.import_job_run import_handler = tasks.import_job_run.delay if async else tasks.import_job_run
for path in matching: for path in matching:
job = batch.jobs.create( job = batch.jobs.create(
source='file://' + path, source='file://' + path,
...@@ -105,7 +109,8 @@ class Command(BaseCommand): ...@@ -105,7 +109,8 @@ class Command(BaseCommand):
job.save() job.save()
try: try:
handler(import_job_id=job.pk) utils.on_commit(import_handler, import_job_id=job.pk)
except Exception as e: except Exception as e:
self.stdout.write('Error: {}'.format(e)) self.stdout.write('Error: {}'.format(e))
return batch return batch
from django.contrib import admin
from . import models
@admin.register(models.Radio)
class RadioAdmin(admin.ModelAdmin):
list_display = [
'user', 'name', 'is_public', 'creation_date', 'config']
list_select_related = [
'user',
]
list_filter = [
'is_public',
]
search_fields = ['name', 'description']
@admin.register(models.RadioSession)
class RadioSessionAdmin(admin.ModelAdmin):
list_display = [
'user',
'custom_radio',
'radio_type',
'creation_date',
'related_object']
list_select_related = [
'user',
'custom_radio'
]
list_filter = [
'radio_type',
]
@admin.register(models.RadioSessionTrack)
class RadioSessionTrackAdmin(admin.ModelAdmin):
list_display = [
'id',
'session',
'position',
'track',]
list_select_related = [
'track',
'session'
]
import random import random
from rest_framework import serializers from rest_framework import serializers
from django.db.models import Count
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from taggit.models import Tag from taggit.models import Tag
from funkwhale_api.users.models import User from funkwhale_api.users.models import User
...@@ -39,8 +40,11 @@ class SessionRadio(SimpleRadio): ...@@ -39,8 +40,11 @@ class SessionRadio(SimpleRadio):
self.session = models.RadioSession.objects.create(user=user, radio_type=self.radio_type, **kwargs) self.session = models.RadioSession.objects.create(user=user, radio_type=self.radio_type, **kwargs)
return self.session return self.session
def get_queryset(self): def get_queryset(self, **kwargs):
raise NotImplementedError qs = Track.objects.annotate(
files_count=Count('files')
)
return qs.filter(files_count__gt=0)
def get_queryset_kwargs(self): def get_queryset_kwargs(self):
return {} return {}
...@@ -75,7 +79,9 @@ class SessionRadio(SimpleRadio): ...@@ -75,7 +79,9 @@ class SessionRadio(SimpleRadio):
@registry.register(name='random') @registry.register(name='random')
class RandomRadio(SessionRadio): class RandomRadio(SessionRadio):
def get_queryset(self, **kwargs): def get_queryset(self, **kwargs):
return Track.objects.all()