Merge branch 'release/0.5.3'
This commit is contained in:
commit
98e5fdeb54
27
CHANGELOG
27
CHANGELOG
|
@ -1,8 +1,31 @@
|
||||||
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 = (
|
||||||
'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',
|
||||||
|
|
|
@ -1,3 +1,3 @@
|
||||||
# -*- 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('.')])
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
# 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()
|
||||||
|
]
|
|
@ -1,6 +1,8 @@
|
||||||
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):
|
||||||
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)
|
||||||
|
)
|
||||||
|
|
|
@ -0,0 +1,12 @@
|
||||||
|
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
|
||||||
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'
|
||||||
|
]
|
||||||
|
|
|
@ -0,0 +1,51 @@
|
||||||
|
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
|
|
@ -1,7 +1,11 @@
|
||||||
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
|
||||||
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):
|
||||||
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):
|
||||||
|
|
||||||
@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):
|
||||||
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):
|
||||||
|
|
||||||
|
|
||||||
@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
|
||||||
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):
|
||||||
'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):
|
||||||
'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(
|
||||||
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):
|
||||||
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):
|
||||||
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):
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
|
|
||||||
|
|
||||||
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):
|
||||||
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):
|
||||||
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
|
||||||
|
|
||||||
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):
|
||||||
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):
|
||||||
# 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):
|
||||||
|
|
||||||
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
|
||||||
|
|
|
@ -0,0 +1,48 @@
|
||||||
|
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'
|
||||||
|
]
|
|
@ -1,5 +1,6 @@
|
||||||
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):
|
||||||
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):
|
||||||
@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()
|
qs = super().get_queryset(**kwargs)
|
||||||
|
return qs.order_by('?')
|
||||||
|
|
||||||
|
|
||||||
@registry.register(name='favorites')
|
@registry.register(name='favorites')
|
||||||
class FavoritesRadio(SessionRadio):
|
class FavoritesRadio(SessionRadio):
|
||||||
|
@ -87,8 +93,9 @@ class FavoritesRadio(SessionRadio):
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def get_queryset(self, **kwargs):
|
def get_queryset(self, **kwargs):
|
||||||
|
qs = super().get_queryset(**kwargs)
|
||||||
track_ids = kwargs['user'].track_favorites.all().values_list('track', flat=True)
|
track_ids = kwargs['user'].track_favorites.all().values_list('track', flat=True)
|
||||||
return Track.objects.filter(pk__in=track_ids)
|
return qs.filter(pk__in=track_ids)
|
||||||
|
|
||||||
|
|
||||||
@registry.register(name='custom')
|
@registry.register(name='custom')
|
||||||
|
@ -101,7 +108,11 @@ class CustomRadio(SessionRadio):
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
def get_queryset(self, **kwargs):
|
def get_queryset(self, **kwargs):
|
||||||
return filters.run(kwargs['custom_radio'].config)
|
qs = super().get_queryset(**kwargs)
|
||||||
|
return filters.run(
|
||||||
|
kwargs['custom_radio'].config,
|
||||||
|
candidates=qs,
|
||||||
|
)
|
||||||
|
|
||||||
def validate_session(self, data, **context):
|
def validate_session(self, data, **context):
|
||||||
data = super().validate_session(data, **context)
|
data = super().validate_session(data, **context)
|
||||||
|
@ -141,6 +152,7 @@ class TagRadio(RelatedObjectRadio):
|
||||||
model = Tag
|
model = Tag
|
||||||
|
|
||||||
def get_queryset(self, **kwargs):
|
def get_queryset(self, **kwargs):
|
||||||
|
qs = super().get_queryset(**kwargs)
|
||||||
return Track.objects.filter(tags__in=[self.session.related_object])
|
return Track.objects.filter(tags__in=[self.session.related_object])
|
||||||
|
|
||||||
@registry.register(name='artist')
|
@registry.register(name='artist')
|
||||||
|
@ -148,7 +160,8 @@ class ArtistRadio(RelatedObjectRadio):
|
||||||
model = Artist
|
model = Artist
|
||||||
|
|
||||||
def get_queryset(self, **kwargs):
|
def get_queryset(self, **kwargs):
|
||||||
return self.session.related_object.tracks.all()
|
qs = super().get_queryset(**kwargs)
|
||||||
|
return qs.filter(artist=self.session.related_object)
|
||||||
|
|
||||||
|
|
||||||
@registry.register(name='less-listened')
|
@registry.register(name='less-listened')
|
||||||
|
@ -160,5 +173,6 @@ class LessListenedRadio(RelatedObjectRadio):
|
||||||
super().clean(instance)
|
super().clean(instance)
|
||||||
|
|
||||||
def get_queryset(self, **kwargs):
|
def get_queryset(self, **kwargs):
|
||||||
|
qs = super().get_queryset(**kwargs)
|
||||||
listened = self.session.user.listenings.all().values_list('track', flat=True)
|
listened = self.session.user.listenings.all().values_list('track', flat=True)
|
||||||
return Track.objects.exclude(pk__in=listened).order_by('?')
|
return qs.exclude(pk__in=listened).order_by('?')
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(models.ImportRequest)
|
||||||
|
class ImportRequestAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['artist_name', 'user', 'status', 'creation_date']
|
||||||
|
list_select_related = [
|
||||||
|
'user',
|
||||||
|
'track'
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'status',
|
||||||
|
]
|
||||||
|
search_fields = ['artist_name', 'comment', 'albums']
|
|
@ -0,0 +1,84 @@
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from funkwhale_api.instance import stats
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_get_stats_via_api(db, api_client, mocker):
|
||||||
|
stats = {
|
||||||
|
'foo': 'bar'
|
||||||
|
}
|
||||||
|
mocker.patch('funkwhale_api.instance.stats.get', return_value=stats)
|
||||||
|
url = reverse('api:v1:instance:stats')
|
||||||
|
response = api_client.get(url)
|
||||||
|
assert response.data == stats
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_users(mocker):
|
||||||
|
mocker.patch(
|
||||||
|
'funkwhale_api.users.models.User.objects.count', return_value=42)
|
||||||
|
|
||||||
|
assert stats.get_users() == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_music_duration(factories):
|
||||||
|
factories['music.TrackFile'].create_batch(size=5, duration=360)
|
||||||
|
|
||||||
|
# duration is in hours
|
||||||
|
assert stats.get_music_duration() == 0.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_listenings(mocker):
|
||||||
|
mocker.patch(
|
||||||
|
'funkwhale_api.history.models.Listening.objects.count',
|
||||||
|
return_value=42)
|
||||||
|
assert stats.get_listenings() == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_track_favorites(mocker):
|
||||||
|
mocker.patch(
|
||||||
|
'funkwhale_api.favorites.models.TrackFavorite.objects.count',
|
||||||
|
return_value=42)
|
||||||
|
assert stats.get_track_favorites() == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_tracks(mocker):
|
||||||
|
mocker.patch(
|
||||||
|
'funkwhale_api.music.models.Track.objects.count',
|
||||||
|
return_value=42)
|
||||||
|
assert stats.get_tracks() == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_albums(mocker):
|
||||||
|
mocker.patch(
|
||||||
|
'funkwhale_api.music.models.Album.objects.count',
|
||||||
|
return_value=42)
|
||||||
|
assert stats.get_albums() == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_artists(mocker):
|
||||||
|
mocker.patch(
|
||||||
|
'funkwhale_api.music.models.Artist.objects.count',
|
||||||
|
return_value=42)
|
||||||
|
assert stats.get_artists() == 42
|
||||||
|
|
||||||
|
|
||||||
|
def test_get(mocker):
|
||||||
|
keys = [
|
||||||
|
'users',
|
||||||
|
'tracks',
|
||||||
|
'albums',
|
||||||
|
'artists',
|
||||||
|
'track_favorites',
|
||||||
|
'listenings',
|
||||||
|
'music_duration',
|
||||||
|
]
|
||||||
|
mocks = [
|
||||||
|
mocker.patch.object(stats, 'get_{}'.format(k), return_value=i)
|
||||||
|
for i, k in enumerate(keys)
|
||||||
|
]
|
||||||
|
|
||||||
|
expected = {
|
||||||
|
k: i for i, k in enumerate(keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert stats.get() == expected
|
|
@ -6,6 +6,7 @@ from django.urls import reverse
|
||||||
from funkwhale_api.music import models
|
from funkwhale_api.music import models
|
||||||
from funkwhale_api.musicbrainz import api
|
from funkwhale_api.musicbrainz import api
|
||||||
from funkwhale_api.music import serializers
|
from funkwhale_api.music import serializers
|
||||||
|
from funkwhale_api.music import tasks
|
||||||
|
|
||||||
from . import data as api_data
|
from . import data as api_data
|
||||||
|
|
||||||
|
@ -208,7 +209,7 @@ def test_user_can_create_an_empty_batch(client, factories):
|
||||||
|
|
||||||
def test_user_can_create_import_job_with_file(client, factories, mocker):
|
def test_user_can_create_import_job_with_file(client, factories, mocker):
|
||||||
path = os.path.join(DATA_DIR, 'test.ogg')
|
path = os.path.join(DATA_DIR, 'test.ogg')
|
||||||
m = mocker.patch('funkwhale_api.music.tasks.import_job_run.delay')
|
m = mocker.patch('funkwhale_api.common.utils.on_commit')
|
||||||
user = factories['users.SuperUser']()
|
user = factories['users.SuperUser']()
|
||||||
batch = factories['music.ImportBatch'](submitted_by=user)
|
batch = factories['music.ImportBatch'](submitted_by=user)
|
||||||
url = reverse('api:v1:import-jobs-list')
|
url = reverse('api:v1:import-jobs-list')
|
||||||
|
@ -231,7 +232,9 @@ def test_user_can_create_import_job_with_file(client, factories, mocker):
|
||||||
assert 'test.ogg' in job.source
|
assert 'test.ogg' in job.source
|
||||||
assert job.audio_file.read() == content
|
assert job.audio_file.read() == content
|
||||||
|
|
||||||
m.assert_called_once_with(import_job_id=job.pk)
|
m.assert_called_once_with(
|
||||||
|
tasks.import_job_run.delay,
|
||||||
|
import_job_id=job.pk)
|
||||||
|
|
||||||
|
|
||||||
def test_can_search_artist(factories, client):
|
def test_can_search_artist(factories, client):
|
||||||
|
|
|
@ -51,7 +51,8 @@ def test_can_pick_by_weight():
|
||||||
|
|
||||||
|
|
||||||
def test_can_get_choices_for_favorites_radio(factories):
|
def test_can_get_choices_for_favorites_radio(factories):
|
||||||
tracks = factories['music.Track'].create_batch(10)
|
files = factories['music.TrackFile'].create_batch(10)
|
||||||
|
tracks = [f.track for f in files]
|
||||||
user = factories['users.User']()
|
user = factories['users.User']()
|
||||||
for i in range(5):
|
for i in range(5):
|
||||||
TrackFavorite.add(track=random.choice(tracks), user=user)
|
TrackFavorite.add(track=random.choice(tracks), user=user)
|
||||||
|
@ -71,8 +72,12 @@ def test_can_get_choices_for_favorites_radio(factories):
|
||||||
|
|
||||||
def test_can_get_choices_for_custom_radio(factories):
|
def test_can_get_choices_for_custom_radio(factories):
|
||||||
artist = factories['music.Artist']()
|
artist = factories['music.Artist']()
|
||||||
tracks = factories['music.Track'].create_batch(5, artist=artist)
|
files = factories['music.TrackFile'].create_batch(
|
||||||
wrong_tracks = factories['music.Track'].create_batch(5)
|
5, track__artist=artist)
|
||||||
|
tracks = [f.track for f in files]
|
||||||
|
wrong_files = factories['music.TrackFile'].create_batch(5)
|
||||||
|
wrong_tracks = [f.track for f in wrong_files]
|
||||||
|
|
||||||
session = factories['radios.CustomRadioSession'](
|
session = factories['radios.CustomRadioSession'](
|
||||||
custom_radio__config=[{'type': 'artist', 'ids': [artist.pk]}]
|
custom_radio__config=[{'type': 'artist', 'ids': [artist.pk]}]
|
||||||
)
|
)
|
||||||
|
@ -113,7 +118,8 @@ def test_can_start_custom_radio_from_api(logged_in_client, factories):
|
||||||
|
|
||||||
|
|
||||||
def test_can_use_radio_session_to_filter_choices(factories):
|
def test_can_use_radio_session_to_filter_choices(factories):
|
||||||
tracks = factories['music.Track'].create_batch(30)
|
files = factories['music.TrackFile'].create_batch(30)
|
||||||
|
tracks = [f.track for f in files]
|
||||||
user = factories['users.User']()
|
user = factories['users.User']()
|
||||||
radio = radios.RandomRadio()
|
radio = radios.RandomRadio()
|
||||||
session = radio.start_session(user)
|
session = radio.start_session(user)
|
||||||
|
@ -156,8 +162,8 @@ def test_can_start_radio_for_anonymous_user(client, db):
|
||||||
|
|
||||||
|
|
||||||
def test_can_get_track_for_session_from_api(factories, logged_in_client):
|
def test_can_get_track_for_session_from_api(factories, logged_in_client):
|
||||||
tracks = factories['music.Track'].create_batch(size=1)
|
files = factories['music.TrackFile'].create_batch(1)
|
||||||
|
tracks = [f.track for f in files]
|
||||||
url = reverse('api:v1:radios:sessions-list')
|
url = reverse('api:v1:radios:sessions-list')
|
||||||
response = logged_in_client.post(url, {'radio_type': 'random'})
|
response = logged_in_client.post(url, {'radio_type': 'random'})
|
||||||
session = models.RadioSession.objects.latest('id')
|
session = models.RadioSession.objects.latest('id')
|
||||||
|
@ -169,7 +175,7 @@ def test_can_get_track_for_session_from_api(factories, logged_in_client):
|
||||||
assert data['track']['id'] == tracks[0].id
|
assert data['track']['id'] == tracks[0].id
|
||||||
assert data['position'] == 1
|
assert data['position'] == 1
|
||||||
|
|
||||||
next_track = factories['music.Track']()
|
next_track = factories['music.TrackFile']().track
|
||||||
response = logged_in_client.post(url, {'session': session.pk})
|
response = logged_in_client.post(url, {'session': session.pk})
|
||||||
data = json.loads(response.content.decode('utf-8'))
|
data = json.loads(response.content.decode('utf-8'))
|
||||||
|
|
||||||
|
@ -193,8 +199,11 @@ def test_related_object_radio_validate_related_object(factories):
|
||||||
def test_can_start_artist_radio(factories):
|
def test_can_start_artist_radio(factories):
|
||||||
user = factories['users.User']()
|
user = factories['users.User']()
|
||||||
artist = factories['music.Artist']()
|
artist = factories['music.Artist']()
|
||||||
wrong_tracks = factories['music.Track'].create_batch(5)
|
wrong_files = factories['music.TrackFile'].create_batch(5)
|
||||||
good_tracks = factories['music.Track'].create_batch(5, artist=artist)
|
wrong_tracks = [f.track for f in wrong_files]
|
||||||
|
good_files = factories['music.TrackFile'].create_batch(
|
||||||
|
5, track__artist=artist)
|
||||||
|
good_tracks = [f.track for f in good_files]
|
||||||
|
|
||||||
radio = radios.ArtistRadio()
|
radio = radios.ArtistRadio()
|
||||||
session = radio.start_session(user, related_object=artist)
|
session = radio.start_session(user, related_object=artist)
|
||||||
|
@ -206,8 +215,11 @@ def test_can_start_artist_radio(factories):
|
||||||
def test_can_start_tag_radio(factories):
|
def test_can_start_tag_radio(factories):
|
||||||
user = factories['users.User']()
|
user = factories['users.User']()
|
||||||
tag = factories['taggit.Tag']()
|
tag = factories['taggit.Tag']()
|
||||||
wrong_tracks = factories['music.Track'].create_batch(5)
|
wrong_files = factories['music.TrackFile'].create_batch(5)
|
||||||
good_tracks = factories['music.Track'].create_batch(5, tags=[tag])
|
wrong_tracks = [f.track for f in wrong_files]
|
||||||
|
good_files = factories['music.TrackFile'].create_batch(
|
||||||
|
5, track__tags=[tag])
|
||||||
|
good_tracks = [f.track for f in good_files]
|
||||||
|
|
||||||
radio = radios.TagRadio()
|
radio = radios.TagRadio()
|
||||||
session = radio.start_session(user, related_object=tag)
|
session = radio.start_session(user, related_object=tag)
|
||||||
|
@ -229,9 +241,11 @@ def test_can_start_artist_radio_from_api(client, factories):
|
||||||
|
|
||||||
def test_can_start_less_listened_radio(factories):
|
def test_can_start_less_listened_radio(factories):
|
||||||
user = factories['users.User']()
|
user = factories['users.User']()
|
||||||
history = factories['history.Listening'].create_batch(5, user=user)
|
wrong_files = factories['music.TrackFile'].create_batch(5)
|
||||||
wrong_tracks = [h.track for h in history]
|
for f in wrong_files:
|
||||||
good_tracks = factories['music.Track'].create_batch(size=5)
|
factories['history.Listening'](track=f.track, user=user)
|
||||||
|
good_files = factories['music.TrackFile'].create_batch(5)
|
||||||
|
good_tracks = [f.track for f in good_files]
|
||||||
radio = radios.LessListenedRadio()
|
radio = radios.LessListenedRadio()
|
||||||
session = radio.start_session(user)
|
session = radio.start_session(user)
|
||||||
assert session.related_object == user
|
assert session.related_object == user
|
||||||
|
|
|
@ -6,6 +6,7 @@ from django.core.management import call_command
|
||||||
from django.core.management.base import CommandError
|
from django.core.management.base import CommandError
|
||||||
|
|
||||||
from funkwhale_api.providers.audiofile import tasks
|
from funkwhale_api.providers.audiofile import tasks
|
||||||
|
from funkwhale_api.music import tasks as music_tasks
|
||||||
|
|
||||||
DATA_DIR = os.path.join(
|
DATA_DIR = os.path.join(
|
||||||
os.path.dirname(os.path.abspath(__file__)),
|
os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
@ -53,7 +54,7 @@ def test_management_command_requires_a_valid_username(factories, mocker):
|
||||||
|
|
||||||
|
|
||||||
def test_import_files_creates_a_batch_and_job(factories, mocker):
|
def test_import_files_creates_a_batch_and_job(factories, mocker):
|
||||||
m = mocker.patch('funkwhale_api.music.tasks.import_job_run.delay')
|
m = m = mocker.patch('funkwhale_api.common.utils.on_commit')
|
||||||
user = factories['users.User'](username='me')
|
user = factories['users.User'](username='me')
|
||||||
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
|
path = os.path.join(DATA_DIR, 'dummy_file.ogg')
|
||||||
call_command(
|
call_command(
|
||||||
|
@ -74,4 +75,6 @@ def test_import_files_creates_a_batch_and_job(factories, mocker):
|
||||||
assert job.audio_file.read() == f.read()
|
assert job.audio_file.read() == f.read()
|
||||||
|
|
||||||
assert job.source == 'file://' + path
|
assert job.source == 'file://' + path
|
||||||
m.assert_called_once_with(import_job_id=job.pk)
|
m.assert_called_once_with(
|
||||||
|
music_tasks.import_job_run.delay,
|
||||||
|
import_job_id=job.pk)
|
||||||
|
|
|
@ -0,0 +1,27 @@
|
||||||
|
{% for section, _ in sections.items() %}
|
||||||
|
{% if sections[section] %}
|
||||||
|
{% for category, val in definitions.items() if category in sections[section]%}
|
||||||
|
{{ definitions[category]['name'] }}:
|
||||||
|
|
||||||
|
{% if definitions[category]['showcontent'] %}
|
||||||
|
{% for text in sections[section][category].keys()|sort() %}
|
||||||
|
- {{ text }}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
- {{ sections[section][category]['']|join(', ') }}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% if sections[section][category]|length == 0 %}
|
||||||
|
No significant changes.
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
No significant changes.
|
||||||
|
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
|
@ -1,38 +1 @@
|
||||||
Changelog
|
.. include:: ../CHANGELOG
|
||||||
=========
|
|
||||||
|
|
||||||
0.2.1
|
|
||||||
-----
|
|
||||||
|
|
||||||
2017-07-17
|
|
||||||
|
|
||||||
* Now return media files with absolute URL
|
|
||||||
* Now display CLI instructions to download a set of tracks
|
|
||||||
* Fixed #33: sort by track position in album in API by default, also reuse that information on frontend side
|
|
||||||
* More robust audio player and queue in various situations:
|
|
||||||
* upgrade to latest dynamic_preferences and use redis as cache even locally
|
|
||||||
|
|
||||||
|
|
||||||
0.2
|
|
||||||
-------
|
|
||||||
|
|
||||||
2017-07-09
|
|
||||||
|
|
||||||
* [feature] can now import artist and releases from youtube and musicbrainz.
|
|
||||||
This requires a YouTube API key for the search
|
|
||||||
* [breaking] we now check for user permission before serving audio files, which requires
|
|
||||||
a specific configuration block in your reverse proxy configuration::
|
|
||||||
|
|
||||||
location /_protected/media {
|
|
||||||
internal;
|
|
||||||
alias /srv/funkwhale/data/media;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
0.1
|
|
||||||
-------
|
|
||||||
|
|
||||||
2017-06-26
|
|
||||||
|
|
||||||
Initial release
|
|
||||||
|
|
|
@ -57,9 +57,7 @@ author = 'Eliot Berriot'
|
||||||
# built documents.
|
# built documents.
|
||||||
#
|
#
|
||||||
# The short X.Y version.
|
# The short X.Y version.
|
||||||
# version = funkwhale_api.__version__
|
version = funkwhale_api.__version__
|
||||||
# @TODO use real version here
|
|
||||||
version = 'feature/22-debian-installation'
|
|
||||||
# The full version, including alpha/beta/rc tags.
|
# The full version, including alpha/beta/rc tags.
|
||||||
release = version
|
release = version
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
<template v-if="instance.name.value">About {{ instance.name.value }}</template>
|
<template v-if="instance.name.value">About {{ instance.name.value }}</template>
|
||||||
<template v-else="instance.name.value">About this instance</template>
|
<template v-else="instance.name.value">About this instance</template>
|
||||||
</h1>
|
</h1>
|
||||||
|
<stats></stats>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ui vertical stripe segment">
|
<div class="ui vertical stripe segment">
|
||||||
|
@ -27,8 +28,12 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {mapState} from 'vuex'
|
import {mapState} from 'vuex'
|
||||||
|
import Stats from '@/components/instance/Stats'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
components: {
|
||||||
|
Stats
|
||||||
|
},
|
||||||
created () {
|
created () {
|
||||||
this.$store.dispatch('instance/fetchSettings')
|
this.$store.dispatch('instance/fetchSettings')
|
||||||
},
|
},
|
||||||
|
|
|
@ -52,32 +52,28 @@
|
||||||
|
|
||||||
<div class="two wide column controls ui grid">
|
<div class="two wide column controls ui grid">
|
||||||
<div
|
<div
|
||||||
@click="previous"
|
|
||||||
title="Previous track"
|
title="Previous track"
|
||||||
class="two wide column control"
|
class="two wide column control"
|
||||||
:disabled="!hasPrevious">
|
:disabled="!hasPrevious">
|
||||||
<i :class="['ui', {'disabled': !hasPrevious}, 'step', 'backward', 'big', 'icon']" ></i>
|
<i @click="previous" :class="['ui', {'disabled': !hasPrevious}, 'step', 'backward', 'big', 'icon']" ></i>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="!playing"
|
v-if="!playing"
|
||||||
@click="togglePlay"
|
|
||||||
title="Play track"
|
title="Play track"
|
||||||
class="two wide column control">
|
class="two wide column control">
|
||||||
<i :class="['ui', 'play', {'disabled': !currentTrack}, 'big', 'icon']"></i>
|
<i @click="togglePlay" :class="['ui', 'play', {'disabled': !currentTrack}, 'big', 'icon']"></i>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@click="togglePlay"
|
|
||||||
title="Pause track"
|
title="Pause track"
|
||||||
class="two wide column control">
|
class="two wide column control">
|
||||||
<i :class="['ui', 'pause', {'disabled': !currentTrack}, 'big', 'icon']"></i>
|
<i @click="togglePlay" :class="['ui', 'pause', {'disabled': !currentTrack}, 'big', 'icon']"></i>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@click="next"
|
|
||||||
title="Next track"
|
title="Next track"
|
||||||
class="two wide column control"
|
class="two wide column control"
|
||||||
:disabled="!hasNext">
|
:disabled="!hasNext">
|
||||||
<i :class="['ui', {'disabled': !hasNext}, 'step', 'forward', 'big', 'icon']" ></i>
|
<i @click="next" :class="['ui', {'disabled': !hasNext}, 'step', 'forward', 'big', 'icon']" ></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="two wide column control volume-control">
|
<div class="two wide column control volume-control">
|
||||||
<i title="Unmute" @click="$store.commit('player/volume', 1)" v-if="volume === 0" class="volume off secondary icon"></i>
|
<i title="Unmute" @click="$store.commit('player/volume', 1)" v-if="volume === 0" class="volume off secondary icon"></i>
|
||||||
|
@ -109,19 +105,17 @@
|
||||||
</i>
|
</i>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@click="shuffle()"
|
|
||||||
:disabled="queue.tracks.length === 0"
|
:disabled="queue.tracks.length === 0"
|
||||||
title="Shuffle your queue"
|
title="Shuffle your queue"
|
||||||
class="two wide column control">
|
class="two wide column control">
|
||||||
<i :class="['ui', 'random', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" ></i>
|
<i @click="shuffle()" :class="['ui', 'random', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" ></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="one wide column"></div>
|
<div class="one wide column"></div>
|
||||||
<div
|
<div
|
||||||
@click="clean()"
|
|
||||||
:disabled="queue.tracks.length === 0"
|
:disabled="queue.tracks.length === 0"
|
||||||
title="Clear your queue"
|
title="Clear your queue"
|
||||||
class="two wide column control">
|
class="two wide column control">
|
||||||
<i :class="['ui', 'trash', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" ></i>
|
<i @click="clean()" :class="['ui', 'trash', 'secondary', {'disabled': queue.tracks.length === 0}, 'icon']" ></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<GlobalEvents
|
<GlobalEvents
|
||||||
|
|
|
@ -7,7 +7,11 @@
|
||||||
@timeupdate="updateProgress"
|
@timeupdate="updateProgress"
|
||||||
@ended="ended"
|
@ended="ended"
|
||||||
preload>
|
preload>
|
||||||
<source v-for="src in srcs" :src="src.url" :type="src.type">
|
<source
|
||||||
|
@error="sourceErrored"
|
||||||
|
v-for="src in srcs"
|
||||||
|
src="src.url"
|
||||||
|
:type="src.type">
|
||||||
</audio>
|
</audio>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -25,6 +29,11 @@ export default {
|
||||||
startTime: {type: Number, default: 0},
|
startTime: {type: Number, default: 0},
|
||||||
autoplay: {type: Boolean, default: false}
|
autoplay: {type: Boolean, default: false}
|
||||||
},
|
},
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
sourceErrors: 0
|
||||||
|
}
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapState({
|
...mapState({
|
||||||
playing: state => state.player.playing,
|
playing: state => state.player.playing,
|
||||||
|
@ -65,11 +74,19 @@ export default {
|
||||||
errored: function () {
|
errored: function () {
|
||||||
this.$store.dispatch('player/trackErrored')
|
this.$store.dispatch('player/trackErrored')
|
||||||
},
|
},
|
||||||
|
sourceErrored: function () {
|
||||||
|
this.sourceErrors += 1
|
||||||
|
if (this.sourceErrors >= this.srcs.length) {
|
||||||
|
// all sources failed
|
||||||
|
this.errored()
|
||||||
|
}
|
||||||
|
},
|
||||||
updateDuration: function (e) {
|
updateDuration: function (e) {
|
||||||
this.$store.commit('player/duration', this.$refs.audio.duration)
|
this.$store.commit('player/duration', this.$refs.audio.duration)
|
||||||
},
|
},
|
||||||
loaded: function () {
|
loaded: function () {
|
||||||
this.$refs.audio.volume = this.volume
|
this.$refs.audio.volume = this.volume
|
||||||
|
this.$store.commit('player/resetErrorCount')
|
||||||
if (this.isCurrent) {
|
if (this.isCurrent) {
|
||||||
this.$store.commit('player/duration', this.$refs.audio.duration)
|
this.$store.commit('player/duration', this.$refs.audio.duration)
|
||||||
if (this.startTime) {
|
if (this.startTime) {
|
||||||
|
|
|
@ -0,0 +1,104 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="stats" class="ui stackable two column grid">
|
||||||
|
<div class="column">
|
||||||
|
<h3 class="ui left aligned header">User activity</h3>
|
||||||
|
<div class="ui mini horizontal statistics">
|
||||||
|
<div class="statistic">
|
||||||
|
<div class="value">
|
||||||
|
<i class="green user icon"></i>
|
||||||
|
{{ stats.users }}
|
||||||
|
</div>
|
||||||
|
<div class="label">
|
||||||
|
Users
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="statistic">
|
||||||
|
<div class="value">
|
||||||
|
<i class="orange sound icon"></i> {{ stats.listenings }}
|
||||||
|
</div>
|
||||||
|
<div class="label">
|
||||||
|
tracks listened
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="statistic">
|
||||||
|
<div class="value">
|
||||||
|
<i class="pink heart icon"></i> {{ stats.track_favorites }}
|
||||||
|
</div>
|
||||||
|
<div class="label">
|
||||||
|
Tracks favorited
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<h3 class="ui left aligned header">Library</h3>
|
||||||
|
<div class="ui mini horizontal statistics">
|
||||||
|
<div class="statistic">
|
||||||
|
<div class="value">
|
||||||
|
{{ parseInt(stats.music_duration) }}
|
||||||
|
</div>
|
||||||
|
<div class="label">
|
||||||
|
hours of music
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="statistic">
|
||||||
|
<div class="value">
|
||||||
|
{{ stats.artists }}
|
||||||
|
</div>
|
||||||
|
<div class="label">
|
||||||
|
Artists
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="statistic">
|
||||||
|
<div class="value">
|
||||||
|
{{ stats.albums }}
|
||||||
|
</div>
|
||||||
|
<div class="label">
|
||||||
|
Albums
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="statistic">
|
||||||
|
<div class="value">
|
||||||
|
{{ stats.tracks }}
|
||||||
|
</div>
|
||||||
|
<div class="label">
|
||||||
|
tracks
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import axios from 'axios'
|
||||||
|
import logger from '@/logging'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
stats: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created () {
|
||||||
|
this.fetchData()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
fetchData () {
|
||||||
|
var self = this
|
||||||
|
this.isLoading = true
|
||||||
|
logger.default.debug('Fetching instance stats...')
|
||||||
|
axios.get('instance/stats/').then((response) => {
|
||||||
|
self.stats = response.data
|
||||||
|
self.isLoading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||||
|
<style scoped>
|
||||||
|
</style>
|
|
@ -89,6 +89,7 @@ export default {
|
||||||
logger.default.info('Successfully fetched user profile')
|
logger.default.info('Successfully fetched user profile')
|
||||||
let data = response.data
|
let data = response.data
|
||||||
commit('profile', data)
|
commit('profile', data)
|
||||||
|
commit('username', data.username)
|
||||||
dispatch('favorites/fetch', null, {root: true})
|
dispatch('favorites/fetch', null, {root: true})
|
||||||
Object.keys(data.permissions).forEach(function (key) {
|
Object.keys(data.permissions).forEach(function (key) {
|
||||||
// this makes it easier to check for permissions in templates
|
// this makes it easier to check for permissions in templates
|
||||||
|
|
|
@ -5,6 +5,8 @@ import time from '@/utils/time'
|
||||||
export default {
|
export default {
|
||||||
namespaced: true,
|
namespaced: true,
|
||||||
state: {
|
state: {
|
||||||
|
maxConsecutiveErrors: 5,
|
||||||
|
errorCount: 0,
|
||||||
playing: false,
|
playing: false,
|
||||||
volume: 0.5,
|
volume: 0.5,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
|
@ -25,6 +27,12 @@ export default {
|
||||||
value = Math.max(value, 0)
|
value = Math.max(value, 0)
|
||||||
state.volume = value
|
state.volume = value
|
||||||
},
|
},
|
||||||
|
incrementErrorCount (state) {
|
||||||
|
state.errorCount += 1
|
||||||
|
},
|
||||||
|
resetErrorCount (state) {
|
||||||
|
state.errorCount = 0
|
||||||
|
},
|
||||||
duration (state, value) {
|
duration (state, value) {
|
||||||
state.duration = value
|
state.duration = value
|
||||||
},
|
},
|
||||||
|
@ -78,12 +86,20 @@ export default {
|
||||||
logger.default.error('Could not record track in history')
|
logger.default.error('Could not record track in history')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
trackEnded ({dispatch}, track) {
|
trackEnded ({dispatch, rootState}, track) {
|
||||||
dispatch('trackListened', track)
|
dispatch('trackListened', track)
|
||||||
|
let queueState = rootState.queue
|
||||||
|
if (queueState.currentIndex === queueState.tracks.length - 1) {
|
||||||
|
// we've reached last track of queue, trigger a reload
|
||||||
|
// from radio if any
|
||||||
|
dispatch('radios/populateQueue', null, {root: true})
|
||||||
|
}
|
||||||
|
dispatch('queue/next', null, {root: true})
|
||||||
dispatch('queue/next', null, {root: true})
|
dispatch('queue/next', null, {root: true})
|
||||||
},
|
},
|
||||||
trackErrored ({commit, dispatch}) {
|
trackErrored ({commit, dispatch, state}) {
|
||||||
commit('errored', true)
|
commit('errored', true)
|
||||||
|
commit('incrementErrorCount')
|
||||||
dispatch('queue/next', null, {root: true})
|
dispatch('queue/next', null, {root: true})
|
||||||
},
|
},
|
||||||
updateProgress ({commit}, t) {
|
updateProgress ({commit}, t) {
|
||||||
|
|
|
@ -53,10 +53,13 @@ export default {
|
||||||
commit('current', null)
|
commit('current', null)
|
||||||
commit('running', false)
|
commit('running', false)
|
||||||
},
|
},
|
||||||
populateQueue ({state, dispatch}) {
|
populateQueue ({rootState, state, dispatch}) {
|
||||||
if (!state.running) {
|
if (!state.running) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (rootState.player.errorCount >= rootState.player.maxConsecutiveErrors - 1) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var params = {
|
var params = {
|
||||||
session: state.current.session
|
session: state.current.session
|
||||||
}
|
}
|
||||||
|
|
|
@ -176,6 +176,7 @@ describe('store/auth', () => {
|
||||||
action: store.actions.fetchProfile,
|
action: store.actions.fetchProfile,
|
||||||
expectedMutations: [
|
expectedMutations: [
|
||||||
{ type: 'profile', payload: profile },
|
{ type: 'profile', payload: profile },
|
||||||
|
{ type: 'username', payload: profile.username },
|
||||||
{ type: 'permission', payload: {key: 'admin', status: true} }
|
{ type: 'permission', payload: {key: 'admin', status: true} }
|
||||||
],
|
],
|
||||||
expectedActions: [
|
expectedActions: [
|
||||||
|
|
|
@ -74,6 +74,16 @@ describe('store/player', () => {
|
||||||
store.mutations.toggleLooping(state)
|
store.mutations.toggleLooping(state)
|
||||||
expect(state.looping).to.equal(0)
|
expect(state.looping).to.equal(0)
|
||||||
})
|
})
|
||||||
|
it('increment error count', () => {
|
||||||
|
const state = { errorCount: 0 }
|
||||||
|
store.mutations.incrementErrorCount(state)
|
||||||
|
expect(state.errorCount).to.equal(1)
|
||||||
|
})
|
||||||
|
it('reset error count', () => {
|
||||||
|
const state = { errorCount: 10 }
|
||||||
|
store.mutations.resetErrorCount(state)
|
||||||
|
expect(state.errorCount).to.equal(0)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
describe('getters', () => {
|
describe('getters', () => {
|
||||||
it('durationFormatted', () => {
|
it('durationFormatted', () => {
|
||||||
|
@ -122,18 +132,33 @@ describe('store/player', () => {
|
||||||
testAction({
|
testAction({
|
||||||
action: store.actions.trackEnded,
|
action: store.actions.trackEnded,
|
||||||
payload: {test: 'track'},
|
payload: {test: 'track'},
|
||||||
|
params: {rootState: {queue: {currentIndex:0, tracks: [1, 2]}}},
|
||||||
expectedActions: [
|
expectedActions: [
|
||||||
{ type: 'trackListened', payload: {test: 'track'} },
|
{ type: 'trackListened', payload: {test: 'track'} },
|
||||||
{ type: 'queue/next', payload: null, options: {root: true} }
|
{ type: 'queue/next', payload: null, options: {root: true} }
|
||||||
]
|
]
|
||||||
}, done)
|
}, done)
|
||||||
})
|
})
|
||||||
|
it('trackEnded calls populateQueue if last', (done) => {
|
||||||
|
testAction({
|
||||||
|
action: store.actions.trackEnded,
|
||||||
|
payload: {test: 'track'},
|
||||||
|
params: {rootState: {queue: {currentIndex:1, tracks: [1, 2]}}},
|
||||||
|
expectedActions: [
|
||||||
|
{ type: 'trackListened', payload: {test: 'track'} },
|
||||||
|
{ type: 'radios/populateQueue', payload: null, options: {root: true} },
|
||||||
|
{ type: 'queue/next', payload: null, options: {root: true} }
|
||||||
|
]
|
||||||
|
}, done)
|
||||||
|
})
|
||||||
it('trackErrored', (done) => {
|
it('trackErrored', (done) => {
|
||||||
testAction({
|
testAction({
|
||||||
action: store.actions.trackErrored,
|
action: store.actions.trackErrored,
|
||||||
payload: {test: 'track'},
|
payload: {test: 'track'},
|
||||||
|
params: {state: {errorCount: 0, maxConsecutiveErrors: 5}},
|
||||||
expectedMutations: [
|
expectedMutations: [
|
||||||
{ type: 'errored', payload: true }
|
{ type: 'errored', payload: true },
|
||||||
|
{ type: 'incrementErrorCount' }
|
||||||
],
|
],
|
||||||
expectedActions: [
|
expectedActions: [
|
||||||
{ type: 'queue/next', payload: null, options: {root: true} }
|
{ type: 'queue/next', payload: null, options: {root: true} }
|
||||||
|
|
|
@ -69,7 +69,11 @@ describe('store/radios', () => {
|
||||||
})
|
})
|
||||||
testAction({
|
testAction({
|
||||||
action: store.actions.populateQueue,
|
action: store.actions.populateQueue,
|
||||||
params: {state: {running: true, current: {session: 1}}},
|
params: {
|
||||||
|
state: {running: true, current: {session: 1}},
|
||||||
|
rootState: {player: {errorCount: 0, maxConsecutiveErrors: 5}}
|
||||||
|
|
||||||
|
},
|
||||||
expectedActions: [
|
expectedActions: [
|
||||||
{ type: 'queue/append', payload: {track: {id: 1}}, options: {root: true} }
|
{ type: 'queue/append', payload: {track: {id: 1}}, options: {root: true} }
|
||||||
]
|
]
|
||||||
|
@ -82,5 +86,17 @@ describe('store/radios', () => {
|
||||||
expectedActions: []
|
expectedActions: []
|
||||||
}, done)
|
}, done)
|
||||||
})
|
})
|
||||||
|
it('populateQueue does nothing when too much errors', (done) => {
|
||||||
|
testAction({
|
||||||
|
action: store.actions.populateQueue,
|
||||||
|
payload: {test: 'track'},
|
||||||
|
params: {
|
||||||
|
rootState: {player: {errorCount: 5, maxConsecutiveErrors: 5}},
|
||||||
|
state: {running: true}
|
||||||
|
},
|
||||||
|
expectedActions: []
|
||||||
|
}, done)
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
@ -0,0 +1,33 @@
|
||||||
|
[tool.towncrier]
|
||||||
|
package = "changes"
|
||||||
|
package_dir = ""
|
||||||
|
filename = "CHANGELOG"
|
||||||
|
directory = "changes/changelog.d/"
|
||||||
|
start_string = ".. towncrier\n"
|
||||||
|
template = "changes/template.rst"
|
||||||
|
issue_format = ""
|
||||||
|
title_format = "{version} (unreleased)"
|
||||||
|
underlines = "-"
|
||||||
|
|
||||||
|
[[tool.towncrier.section]]
|
||||||
|
path = ""
|
||||||
|
|
||||||
|
[[tool.towncrier.type]]
|
||||||
|
directory = "feature"
|
||||||
|
name = "Features"
|
||||||
|
showcontent = true
|
||||||
|
|
||||||
|
[[tool.towncrier.type]]
|
||||||
|
directory = "bugfix"
|
||||||
|
name = "Bugfixes"
|
||||||
|
showcontent = true
|
||||||
|
|
||||||
|
[[tool.towncrier.type]]
|
||||||
|
directory = "doc"
|
||||||
|
name = "Documentation"
|
||||||
|
showcontent = true
|
||||||
|
|
||||||
|
[[tool.towncrier.type]]
|
||||||
|
directory = "misc"
|
||||||
|
name = "Other"
|
||||||
|
showcontent = true
|
Loading…
Reference in New Issue