Merge branch 'release/0.15'

This commit is contained in:
Eliot Berriot 2018-06-24 16:00:37 +02:00
commit b206c3cf3e
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
86 changed files with 2510 additions and 505 deletions

1
.gitignore vendored
View File

@ -90,3 +90,4 @@ data/
po/*.po
docs/swagger
_build

View File

@ -7,11 +7,93 @@ variables:
stages:
- review
- lint
- test
- build
- deploy
review_front:
stage: review
image: node:9
when: manual
allow_failure: true
before_script:
- cd front
script:
- yarn install
# this is to ensure we don't have any errors in the output,
# cf https://code.eliotberriot.com/funkwhale/funkwhale/issues/169
- INSTANCE_URL=$REVIEW_INSTANCE_URL yarn run build | tee /dev/stderr | (! grep -i 'ERROR in')
- mkdir -p /static/front/$CI_BUILD_REF_SLUG
- cp -r dist/* /static/front/$CI_BUILD_REF_SLUG
cache:
key: "$CI_PROJECT_ID__front_dependencies"
paths:
- front/node_modules
- front/yarn.lock
environment:
name: review/front-$CI_BUILD_REF_NAME
url: http://front-$CI_BUILD_REF_SLUG.$REVIEW_DOMAIN
on_stop: stop_front_review
only:
- branches@funkwhale/funkwhale
tags:
- funkwhale-review
stop_front_review:
stage: review
script:
- rm -rf /static/front/$CI_BUILD_REF_SLUG/
variables:
GIT_STRATEGY: none
when: manual
environment:
name: review/front-$CI_BUILD_REF_NAME
action: stop
tags:
- funkwhale-review
review_docs:
stage: review
image: python:3.6
when: manual
allow_failure: true
variables:
BUILD_PATH: "../public"
before_script:
- cd docs
cache:
key: "$CI_PROJECT_ID__sphinx"
paths:
- "$PIP_CACHE_DIR"
script:
- pip install sphinx
- ./build_docs.sh
- mkdir -p /static/docs/$CI_BUILD_REF_SLUG
- cp -r $CI_PROJECT_DIR/public/* /static/docs/$CI_BUILD_REF_SLUG
environment:
name: review/docs-$CI_BUILD_REF_NAME
url: http://docs-$CI_BUILD_REF_SLUG.$REVIEW_DOMAIN
on_stop: stop_docs_review
only:
- branches@funkwhale/funkwhale
tags:
- funkwhale-review
stop_docs_review:
stage: review
script:
- rm -rf /static/docs/$CI_BUILD_REF_SLUG/
variables:
GIT_STRATEGY: none
when: manual
environment:
name: review/docs-$CI_BUILD_REF_NAME
action: stop
tags:
- funkwhale-review
black:
image: python:3.6
stage: lint
@ -20,7 +102,7 @@ black:
before_script:
- pip install black
script:
- black --check --diff api/
- black --exclude "/(\.git|\.hg|\.mypy_cache|\.tox|\.venv|_build|buck-out|build|dist|migrations)/" --check --diff api/
flake8:
image: python:3.6
@ -126,6 +208,10 @@ pages:
script:
- pip install sphinx
- ./build_docs.sh
cache:
key: "$CI_PROJECT_ID__sphinx"
paths:
- "$PIP_CACHE_DIR"
artifacts:
paths:
- public

View File

@ -10,6 +10,83 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.
.. towncrier
0.15 (2018-06-24)
-----------------
Upgrade instructions are available at
https://docs.funkwhale.audio/upgrading.html
Features:
- Added admin interface to manage import requests (#190)
- Added replace flag during import to replace already present tracks with a new
version of their track file (#222)
- Funkwhale's front-end can now point to any instance (#327) Removed front-end
and back-end coupling
- Management interface for users (#212)
- New invite system (#248) New invite system
Enhancements:
- Added "TV" to the list of highlighted words during YouTube import (#154)
- Command line import now accepts unlimited args (#242)
Bugfixes:
- Expose track files date in manage API (#307)
- Fixed current track restart/hiccup when shuffling queue, deleting track from
queue or reordering (#310)
- Include user's current private playlists on playlist list (#302)
- Remove link to generic radios, since they don't have detail pages (#324)
Documentation:
- Document that Funkwhale may be installed with YunoHost (#325)
- Documented a saner layout with symlinks for in-place imports (#254)
- Upgrade documentation now use the correct user on non-docker setups (#265)
Invite system
^^^^^^^^^^^^^
On closed instances, it has always been a little bit painful to create accounts
by hand for new users. This release solve that by adding invitations.
You can generate invitation codes via the "users" admin interface (you'll find a
link in the sidebar). Those codes are valid for 14 days, and can be used once
to create a new account on the instance, even if registrations are closed.
By default, we generate a random code for invitations, but you can also use custom codes
if you need to print them or make them fancier ;)
Invitations generation and management requires the "settings" permission.
Removed front-end and back-end coupling
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Eventhough Funkwhale's front-end has always been a Single Page Application,
talking to an API, it was only able to talk to an API on the same domain.
There was no real technical justification behind this (only lazyness), and it was
also blocking interesting use cases:
- Use multiple customized versions of the front-end with the same instance
- Use a customized version of the front-end with multiple instances
- Use a locally hosted front-end with a remote API, which is especially useful in development
From now on, Funkwhale's front-end can connect to any Funkwhale server. You can
change the server you are connecting to in the footer.
Fixing this also unlocked a really interesting feature in our development/review workflow:
by leveraging Gitlab CI and review apps, we are now able to deploy automatically live versions of
a merge request, making it possible for anyone to review front-end changes easily, without
the need to install a local environment.
0.14.2 (2018-06-16)
-------------------

View File

@ -1,4 +1,4 @@
Contibute to Funkwhale development
Contribute to Funkwhale development
==================================
First of all, thank you for your interest in the project! We really
@ -12,6 +12,42 @@ This document will guide you through common operations such as:
- Writing unit tests to validate your work
- Submit your work
A quick path to contribute on the front-end
-------------------------------------------
The next sections of this document include a full installation guide to help
you setup a local, development version of Funkwhale. If you only want to fix small things
on the front-end, and don't want to manage a full development environment, there is anoter way.
As the front-end can work with any Funkwhale server, you can work with the front-end only,
and make it talk with an existing instance (like the demo one, or you own instance, if you have one).
If even that is too much for you, you can also make your changes without any development environment,
and open a merge request. We will be able to to review your work easily by spawning automatically a
live version of your changes, thanks to Gitlab Review apps.
Setup front-end only development environment
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
1. Clone the repository::
git clone ssh://git@code.eliotberriot.com:2222/funkwhale/funkwhale.git
cd funkwhale
cd front
2. Install [nodejs](https://nodejs.org/en/download/package-manager/) and [yarn](https://yarnpkg.com/lang/en/docs/install/#debian-stable)
3. Install the dependencies::
yarn install
4. Launch the development server::
# this will serve the front-end on http://localhost:8000
WEBPACK_DEVSERVER_PORT=8000 yarn dev
5. Make the front-end talk with an existing server (like https://demo.funkwhale.audio),
by clicking on the corresponding link in the footer
6. Start hacking!
Setup your development environment
----------------------------------

View File

@ -146,6 +146,7 @@ MIDDLEWARE = (
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"funkwhale_api.users.middleware.RecordActivityMiddleware",
)
# MIGRATIONS CONFIGURATION
@ -460,3 +461,7 @@ MUSIC_DIRECTORY_PATH = env("MUSIC_DIRECTORY_PATH", default=None)
MUSIC_DIRECTORY_SERVE_PATH = env(
"MUSIC_DIRECTORY_SERVE_PATH", default=MUSIC_DIRECTORY_PATH
)
USERS_INVITATION_EXPIRATION_DAYS = env.int(
"USERS_INVITATION_EXPIRATION_DAYS", default=14
)

View File

@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
__version__ = "0.14.2"
__version__ = "0.15"
__version_info__ = tuple(
[
int(num) if num.isdigit() else num

View File

@ -17,13 +17,13 @@ def get_privacy_field():
)
def privacy_level_query(user, lookup_field="privacy_level"):
def privacy_level_query(user, lookup_field="privacy_level", user_field="user"):
if user.is_anonymous:
return models.Q(**{lookup_field: "everyone"})
return models.Q(
**{"{}__in".format(lookup_field): ["followers", "instance", "everyone"]}
)
**{"{}__in".format(lookup_field): ["instance", "everyone"]}
) | models.Q(**{lookup_field: "me", user_field: user})
class SearchFilter(django_filters.CharFilter):

View File

@ -1,6 +1,16 @@
from rest_framework import serializers
class Action(object):
def __init__(self, name, allow_all=False, qs_filter=None):
self.name = name
self.allow_all = allow_all
self.qs_filter = qs_filter
def __repr__(self):
return "<Action {}>".format(self.name)
class ActionSerializer(serializers.Serializer):
"""
A special serializer that can operate on a list of objects
@ -11,19 +21,16 @@ class ActionSerializer(serializers.Serializer):
objects = serializers.JSONField(required=True)
filters = serializers.DictField(required=False)
actions = None
filterset_class = None
# those are actions identifier where we don't want to allow the "all"
# selector because it's to dangerous. Like object deletion.
dangerous_actions = []
def __init__(self, *args, **kwargs):
self.actions_by_name = {a.name: a for a in self.actions}
self.queryset = kwargs.pop("queryset")
if self.actions is None:
raise ValueError(
"You must declare a list of actions on " "the serializer class"
)
for action in self.actions:
for action in self.actions_by_name.keys():
handler_name = "handle_{}".format(action)
assert hasattr(self, handler_name), "{} miss a {} method".format(
self.__class__.__name__, handler_name
@ -31,13 +38,14 @@ class ActionSerializer(serializers.Serializer):
super().__init__(self, *args, **kwargs)
def validate_action(self, value):
if value not in self.actions:
try:
return self.actions_by_name[value]
except KeyError:
raise serializers.ValidationError(
"{} is not a valid action. Pick one of {}.".format(
value, ", ".join(self.actions)
value, ", ".join(self.actions_by_name.keys())
)
)
return value
def validate_objects(self, value):
if value == "all":
@ -51,33 +59,35 @@ class ActionSerializer(serializers.Serializer):
)
def validate(self, data):
dangerous = data["action"] in self.dangerous_actions
if dangerous and self.initial_data["objects"] == "all":
allow_all = data["action"].allow_all
if not allow_all and self.initial_data["objects"] == "all":
raise serializers.ValidationError(
"This action is to dangerous to be applied to all objects"
)
if self.filterset_class and "filters" in data:
qs_filterset = self.filterset_class(
data["filters"], queryset=data["objects"]
"You cannot apply this action on all objects"
)
final_filters = data.get("filters", {}) or {}
if self.filterset_class and final_filters:
qs_filterset = self.filterset_class(final_filters, queryset=data["objects"])
try:
assert qs_filterset.form.is_valid()
except (AssertionError, TypeError):
raise serializers.ValidationError("Invalid filters")
data["objects"] = qs_filterset.qs
if data["action"].qs_filter:
data["objects"] = data["action"].qs_filter(data["objects"])
data["count"] = data["objects"].count()
if data["count"] < 1:
raise serializers.ValidationError("No object matching your request")
return data
def save(self):
handler_name = "handle_{}".format(self.validated_data["action"])
handler_name = "handle_{}".format(self.validated_data["action"].name)
handler = getattr(self, handler_name)
result = handler(self.validated_data["objects"])
payload = {
"updated": self.validated_data["count"],
"action": self.validated_data["action"],
"action": self.validated_data["action"].name,
"result": result,
}
return payload

View File

@ -769,7 +769,7 @@ class CollectionSerializer(serializers.Serializer):
class LibraryTrackActionSerializer(common_serializers.ActionSerializer):
actions = ["import"]
actions = [common_serializers.Action("import", allow_all=True)]
filterset_class = filters.LibraryTrackFilter
@transaction.atomic

View File

@ -1,8 +1,9 @@
from django_filters import rest_framework as filters
from funkwhale_api.common import fields
from funkwhale_api.music import models as music_models
from funkwhale_api.requests import models as requests_models
from funkwhale_api.users import models as users_models
class ManageTrackFileFilterSet(filters.FilterSet):
@ -18,3 +19,45 @@ class ManageTrackFileFilterSet(filters.FilterSet):
class Meta:
model = music_models.TrackFile
fields = ["q", "track__album", "track__artist", "track", "library_track"]
class ManageUserFilterSet(filters.FilterSet):
q = fields.SearchFilter(search_fields=["username", "email", "name"])
class Meta:
model = users_models.User
fields = [
"q",
"is_active",
"privacy_level",
"is_staff",
"is_superuser",
"permission_upload",
"permission_library",
"permission_settings",
"permission_federation",
]
class ManageInvitationFilterSet(filters.FilterSet):
q = fields.SearchFilter(search_fields=["owner__username", "code", "owner__email"])
is_open = filters.BooleanFilter(method="filter_is_open")
class Meta:
model = users_models.Invitation
fields = ["q", "is_open"]
def filter_is_open(self, queryset, field_name, value):
if value is None:
return queryset
return queryset.open(value)
class ManageImportRequestFilterSet(filters.FilterSet):
q = fields.SearchFilter(
search_fields=["user__username", "albums", "artist_name", "comment"]
)
class Meta:
model = requests_models.ImportRequest
fields = ["q", "status"]

View File

@ -1,8 +1,11 @@
from django.db import transaction
from django.utils import timezone
from rest_framework import serializers
from funkwhale_api.common import serializers as common_serializers
from funkwhale_api.music import models as music_models
from funkwhale_api.requests import models as requests_models
from funkwhale_api.users import models as users_models
from . import filters
@ -52,6 +55,7 @@ class ManageTrackFileSerializer(serializers.ModelSerializer):
"track",
"duration",
"mimetype",
"creation_date",
"bitrate",
"size",
"path",
@ -60,10 +64,172 @@ class ManageTrackFileSerializer(serializers.ModelSerializer):
class ManageTrackFileActionSerializer(common_serializers.ActionSerializer):
actions = ["delete"]
dangerous_actions = ["delete"]
actions = [common_serializers.Action("delete", allow_all=False)]
filterset_class = filters.ManageTrackFileFilterSet
@transaction.atomic
def handle_delete(self, objects):
return objects.delete()
class PermissionsSerializer(serializers.Serializer):
def to_representation(self, o):
return o.get_permissions(defaults=self.context.get("default_permissions"))
def to_internal_value(self, o):
return {"permissions": o}
class ManageUserSimpleSerializer(serializers.ModelSerializer):
class Meta:
model = users_models.User
fields = (
"id",
"username",
"email",
"name",
"is_active",
"is_staff",
"is_superuser",
"date_joined",
"last_activity",
"privacy_level",
)
class ManageUserSerializer(serializers.ModelSerializer):
permissions = PermissionsSerializer(source="*")
class Meta:
model = users_models.User
fields = (
"id",
"username",
"email",
"name",
"is_active",
"is_staff",
"is_superuser",
"date_joined",
"last_activity",
"permissions",
"privacy_level",
)
read_only_fields = [
"id",
"email",
"privacy_level",
"username",
"date_joined",
"last_activity",
]
def update(self, instance, validated_data):
instance = super().update(instance, validated_data)
permissions = validated_data.pop("permissions", {})
if permissions:
for p, value in permissions.items():
setattr(instance, "permission_{}".format(p), value)
instance.save(
update_fields=["permission_{}".format(p) for p in permissions.keys()]
)
return instance
class ManageInvitationSerializer(serializers.ModelSerializer):
users = ManageUserSimpleSerializer(many=True, required=False)
owner = ManageUserSimpleSerializer(required=False)
code = serializers.CharField(required=False, allow_null=True)
class Meta:
model = users_models.Invitation
fields = ("id", "owner", "code", "expiration_date", "creation_date", "users")
read_only_fields = ["id", "expiration_date", "owner", "creation_date", "users"]
def validate_code(self, value):
if not value:
return value
if users_models.Invitation.objects.filter(code__iexact=value).exists():
raise serializers.ValidationError(
"An invitation with this code already exists"
)
return value
class ManageInvitationActionSerializer(common_serializers.ActionSerializer):
actions = [
common_serializers.Action(
"delete", allow_all=False, qs_filter=lambda qs: qs.open()
)
]
filterset_class = filters.ManageInvitationFilterSet
@transaction.atomic
def handle_delete(self, objects):
return objects.delete()
class ManageImportRequestSerializer(serializers.ModelSerializer):
user = ManageUserSimpleSerializer(required=False)
class Meta:
model = requests_models.ImportRequest
fields = [
"id",
"status",
"creation_date",
"imported_date",
"user",
"albums",
"artist_name",
"comment",
]
read_only_fields = [
"id",
"status",
"creation_date",
"imported_date",
"user",
"albums",
"artist_name",
"comment",
]
def validate_code(self, value):
if not value:
return value
if users_models.Invitation.objects.filter(code__iexact=value).exists():
raise serializers.ValidationError(
"An invitation with this code already exists"
)
return value
class ManageImportRequestActionSerializer(common_serializers.ActionSerializer):
actions = [
common_serializers.Action(
"mark_closed",
allow_all=True,
qs_filter=lambda qs: qs.filter(status__in=["pending", "accepted"]),
),
common_serializers.Action(
"mark_imported",
allow_all=True,
qs_filter=lambda qs: qs.filter(status__in=["pending", "accepted"]),
),
common_serializers.Action("delete", allow_all=False),
]
filterset_class = filters.ManageImportRequestFilterSet
@transaction.atomic
def handle_delete(self, objects):
return objects.delete()
@transaction.atomic
def handle_mark_closed(self, objects):
return objects.update(status="closed")
@transaction.atomic
def handle_mark_imported(self, objects):
now = timezone.now()
return objects.update(status="imported", imported_date=now)

View File

@ -5,7 +5,18 @@ from . import views
library_router = routers.SimpleRouter()
library_router.register(r"track-files", views.ManageTrackFileViewSet, "track-files")
requests_router = routers.SimpleRouter()
requests_router.register(
r"import-requests", views.ManageImportRequestViewSet, "import-requests"
)
users_router = routers.SimpleRouter()
users_router.register(r"users", views.ManageUserViewSet, "users")
users_router.register(r"invitations", views.ManageInvitationViewSet, "invitations")
urlpatterns = [
url(r"^library/", include((library_router.urls, "instance"), namespace="library"))
url(r"^library/", include((library_router.urls, "instance"), namespace="library")),
url(r"^users/", include((users_router.urls, "instance"), namespace="users")),
url(
r"^requests/", include((requests_router.urls, "instance"), namespace="requests")
),
]

View File

@ -1,17 +1,17 @@
from rest_framework import mixins, response, viewsets
from rest_framework.decorators import list_route
from funkwhale_api.common import preferences
from funkwhale_api.music import models as music_models
from funkwhale_api.requests import models as requests_models
from funkwhale_api.users import models as users_models
from funkwhale_api.users.permissions import HasUserPermission
from . import filters, serializers
class ManageTrackFileViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
queryset = (
music_models.TrackFile.objects.all()
@ -41,3 +41,83 @@ class ManageTrackFileViewSet(
serializer.is_valid(raise_exception=True)
result = serializer.save()
return response.Response(result, status=200)
class ManageUserViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
queryset = users_models.User.objects.all().order_by("-id")
serializer_class = serializers.ManageUserSerializer
filter_class = filters.ManageUserFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["settings"]
ordering_fields = ["date_joined", "last_activity", "username"]
def get_serializer_context(self):
context = super().get_serializer_context()
context["default_permissions"] = preferences.get("users__default_permissions")
return context
class ManageInvitationViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
queryset = (
users_models.Invitation.objects.all()
.order_by("-id")
.prefetch_related("users")
.select_related("owner")
)
serializer_class = serializers.ManageInvitationSerializer
filter_class = filters.ManageInvitationFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["settings"]
ordering_fields = ["creation_date", "expiration_date"]
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
@list_route(methods=["post"])
def action(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = serializers.ManageInvitationActionSerializer(
request.data, queryset=queryset
)
serializer.is_valid(raise_exception=True)
result = serializer.save()
return response.Response(result, status=200)
class ManageImportRequestViewSet(
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet,
):
queryset = (
requests_models.ImportRequest.objects.all()
.order_by("-id")
.select_related("user")
)
serializer_class = serializers.ManageImportRequestSerializer
filter_class = filters.ManageImportRequestFilterSet
permission_classes = (HasUserPermission,)
required_permissions = ["library"]
ordering_fields = ["creation_date", "imported_date"]
@list_route(methods=["post"])
def action(self, request, *args, **kwargs):
queryset = self.get_queryset()
serializer = serializers.ManageImportRequestActionSerializer(
request.data, queryset=queryset
)
serializer.is_valid(raise_exception=True)
result = serializer.save()
return response.Response(result, status=200)

View File

@ -89,6 +89,7 @@ class ImportJobFactory(factory.django.DjangoModelFactory):
batch = factory.SubFactory(ImportBatchFactory)
source = factory.Faker("url")
mbid = factory.Faker("uuid4")
replace_if_duplicate = False
class Meta:
model = "music.ImportJob"

View File

@ -0,0 +1,18 @@
# Generated by Django 2.0.6 on 2018-06-22 13:36
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('music', '0027_auto_20180515_1808'),
]
operations = [
migrations.AddField(
model_name='importjob',
name='replace_if_duplicate',
field=models.BooleanField(default=False),
),
]

View File

@ -539,7 +539,7 @@ class ImportBatch(models.Model):
related_name="import_batches",
null=True,
blank=True,
on_delete=models.CASCADE,
on_delete=models.SET_NULL,
)
class Meta:
@ -567,6 +567,7 @@ class ImportBatch(models.Model):
class ImportJob(models.Model):
uuid = models.UUIDField(unique=True, db_index=True, default=uuid.uuid4)
replace_if_duplicate = models.BooleanField(default=False)
batch = models.ForeignKey(
ImportBatch, related_name="jobs", on_delete=models.CASCADE
)

View File

@ -80,10 +80,11 @@ def import_track_from_remote(library_track):
)[0]
def _do_import(import_job, replace=False, use_acoustid=False):
def _do_import(import_job, use_acoustid=False):
logger.info("[Import Job %s] starting job", import_job.pk)
from_file = bool(import_job.audio_file)
mbid = import_job.mbid
replace = import_job.replace_if_duplicate
acoustid_track_id = None
duration = None
track = None
@ -135,8 +136,8 @@ def _do_import(import_job, replace=False, use_acoustid=False):
track_file = None
if replace:
logger.info("[Import Job %s] replacing existing audio file", import_job.pk)
track_file = track.files.first()
logger.info("[Import Job %s] deleting existing audio file", import_job.pk)
track.files.all().delete()
elif track.files.count() > 0:
logger.info(
"[Import Job %s] skipping, we already have a file for this track",
@ -163,7 +164,7 @@ def _do_import(import_job, replace=False, use_acoustid=False):
# no downloading, we hotlink
pass
elif not import_job.audio_file and not import_job.source.startswith("file://"):
# not an implace import, and we have a source, so let's download it
# not an inplace import, and we have a source, so let's download it
logger.info("[Import Job %s] downloading audio file from remote", import_job.pk)
track_file.download_file()
elif not import_job.audio_file and import_job.source.startswith("file://"):
@ -243,14 +244,14 @@ def get_cover_from_fs(dir_path):
@celery.require_instance(
models.ImportJob.objects.filter(status__in=["pending", "errored"]), "import_job"
)
def import_job_run(self, import_job, replace=False, use_acoustid=False):
def import_job_run(self, import_job, use_acoustid=False):
def mark_errored(exc):
logger.error("[Import Job %s] Error during import: %s", import_job.pk, str(exc))
import_job.status = "errored"
import_job.save(update_fields=["status"])
try:
tf = _do_import(import_job, replace, use_acoustid=use_acoustid)
tf = _do_import(import_job, use_acoustid=use_acoustid)
return tf.pk if tf else None
except Exception as exc:
if not settings.DEBUG:

View File

@ -110,7 +110,9 @@ class PlaylistTrackViewSet(
def get_queryset(self):
return self.queryset.filter(
fields.privacy_level_query(
self.request.user, lookup_field="playlist__privacy_level"
self.request.user,
lookup_field="playlist__privacy_level",
user_field="playlist__user",
)
)

View File

@ -13,7 +13,7 @@ class Command(BaseCommand):
help = "Import audio files mathinc given glob pattern"
def add_arguments(self, parser):
parser.add_argument("path", type=str)
parser.add_argument("path", nargs="+", type=str)
parser.add_argument(
"--recursive",
action="store_true",
@ -55,6 +55,17 @@ class Command(BaseCommand):
"import and not much disk space available."
),
)
parser.add_argument(
"--replace",
action="store_true",
dest="replace",
default=False,
help=(
"Use this flag to replace duplicates (tracks with same "
"musicbrainz mbid, or same artist, album and title) on import "
"with their newest version."
),
)
parser.add_argument(
"--noinput",
"--no-input",
@ -65,10 +76,13 @@ class Command(BaseCommand):
def handle(self, *args, **options):
glob_kwargs = {}
matching = []
if options["recursive"]:
glob_kwargs["recursive"] = True
try:
matching = sorted(glob.glob(options["path"], **glob_kwargs))
for import_path in options["path"]:
matching += glob.glob(import_path, **glob_kwargs)
matching = sorted(list(set(matching)))
except TypeError:
raise Exception("You need Python 3.5 to use the --recursive flag")
@ -109,16 +123,23 @@ class Command(BaseCommand):
"No superuser available, please provide a --username"
)
filtered = self.filter_matching(matching, options)
if options["replace"]:
filtered = {"initial": matching, "skipped": [], "new": matching}
message = "- {} files to be replaced"
import_paths = matching
else:
filtered = self.filter_matching(matching)
message = "- {} files already found in database"
import_paths = filtered["new"]
self.stdout.write("Import summary:")
self.stdout.write(
"- {} files found matching this pattern: {}".format(
len(matching), options["path"]
)
)
self.stdout.write(
"- {} files already found in database".format(len(filtered["skipped"]))
)
self.stdout.write(message.format(len(filtered["skipped"])))
self.stdout.write("- {} new files".format(len(filtered["new"])))
self.stdout.write(
@ -138,12 +159,12 @@ class Command(BaseCommand):
if input("".join(message)) != "yes":
raise CommandError("Import cancelled.")
batch, errors = self.do_import(filtered["new"], user=user, options=options)
batch, errors = self.do_import(import_paths, user=user, options=options)
message = "Successfully imported {} tracks"
if options["async"]:
message = "Successfully launched import for {} tracks"
self.stdout.write(message.format(len(filtered["new"])))
self.stdout.write(message.format(len(import_paths)))
if len(errors) > 0:
self.stderr.write("{} tracks could not be imported:".format(len(errors)))
@ -153,7 +174,7 @@ class Command(BaseCommand):
"For details, please refer to import batch #{}".format(batch.pk)
)
def filter_matching(self, matching, options):
def filter_matching(self, matching):
sources = ["file://{}".format(p) for p in matching]
# we skip reimport for path that are already found
# as a TrackFile.source
@ -193,7 +214,9 @@ class Command(BaseCommand):
return batch, errors
def import_file(self, path, batch, import_handler, options):
job = batch.jobs.create(source="file://" + path)
job = batch.jobs.create(
source="file://" + path, replace_if_duplicate=options["replace"]
)
if not options["in_place"]:
name = os.path.basename(path)
with open(path, "rb") as f:

View File

@ -7,12 +7,12 @@ from django.contrib.auth.admin import UserAdmin as AuthUserAdmin
from django.contrib.auth.forms import UserChangeForm, UserCreationForm
from django.utils.translation import ugettext_lazy as _
from .models import User
from . import models
class MyUserChangeForm(UserChangeForm):
class Meta(UserChangeForm.Meta):
model = User
model = models.User
class MyUserCreationForm(UserCreationForm):
@ -22,18 +22,18 @@ class MyUserCreationForm(UserCreationForm):
)
class Meta(UserCreationForm.Meta):
model = User
model = models.User
def clean_username(self):
username = self.cleaned_data["username"]
try:
User.objects.get(username=username)
except User.DoesNotExist:
models.User.objects.get(username=username)
except models.User.DoesNotExist:
return username
raise forms.ValidationError(self.error_messages["duplicate_username"])
@admin.register(User)
@admin.register(models.User)
class UserAdmin(AuthUserAdmin):
form = MyUserChangeForm
add_form = MyUserCreationForm
@ -74,3 +74,11 @@ class UserAdmin(AuthUserAdmin):
(_("Important dates"), {"fields": ("last_login", "date_joined")}),
(_("Useless fields"), {"fields": ("user_permissions", "groups")}),
)
@admin.register(models.Invitation)
class InvitationAdmin(admin.ModelAdmin):
list_select_related = True
list_display = ["owner", "code", "creation_date", "expiration_date"]
search_fields = ["owner__username", "code"]
readonly_fields = ["expiration_date", "code"]

View File

@ -1,5 +1,6 @@
import factory
from django.contrib.auth.models import Permission
from django.utils import timezone
from funkwhale_api.factories import ManyToManyFromList, registry
@ -28,6 +29,17 @@ class GroupFactory(factory.django.DjangoModelFactory):
self.permissions.add(*perms)
@registry.register
class InvitationFactory(factory.django.DjangoModelFactory):
owner = factory.LazyFunction(lambda: UserFactory())
class Meta:
model = "users.Invitation"
class Params:
expired = factory.Trait(expiration_date=factory.LazyFunction(timezone.now))
@registry.register
class UserFactory(factory.django.DjangoModelFactory):
username = factory.Sequence(lambda n: "user-{0}".format(n))
@ -40,6 +52,9 @@ class UserFactory(factory.django.DjangoModelFactory):
model = "users.User"
django_get_or_create = ("username",)
class Params:
invited = factory.Trait(invitation=factory.SubFactory(InvitationFactory))
@factory.post_generation
def perms(self, create, extracted, **kwargs):
if not create:

View File

@ -0,0 +1,9 @@
class RecordActivityMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
if hasattr(request, "user") and request.user.is_authenticated:
request.user.record_activity()
return response

View File

@ -0,0 +1,23 @@
# Generated by Django 2.0.6 on 2018-06-17 15:31
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0007_auto_20180524_2009'),
]
operations = [
migrations.AddField(
model_name='user',
name='last_activity',
field=models.DateTimeField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name='user',
name='permission_library',
field=models.BooleanField(default=False, help_text='Manage library, delete files, tracks, artists, albums...', verbose_name='Manage library'),
),
]

View File

@ -0,0 +1,31 @@
# Generated by Django 2.0.6 on 2018-06-19 20:24
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
class Migration(migrations.Migration):
dependencies = [
('users', '0008_auto_20180617_1531'),
]
operations = [
migrations.CreateModel(
name='Invitation',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('creation_date', models.DateTimeField(default=django.utils.timezone.now)),
('expiration_date', models.DateTimeField()),
('code', models.CharField(max_length=50, unique=True)),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to=settings.AUTH_USER_MODEL)),
],
),
migrations.AddField(
model_name='user',
name='invitation',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='users', to='users.Invitation'),
),
]

View File

@ -2,13 +2,17 @@
from __future__ import absolute_import, unicode_literals
import binascii
import datetime
import os
import random
import string
import uuid
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.db import models
from django.urls import reverse
from django.utils import timezone
from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
@ -75,11 +79,21 @@ class User(AbstractUser):
default=False,
)
last_activity = models.DateTimeField(default=None, null=True, blank=True)
invitation = models.ForeignKey(
"Invitation",
related_name="users",
null=True,
blank=True,
on_delete=models.SET_NULL,
)
def __str__(self):
return self.username
def get_permissions(self):
defaults = preferences.get("users__default_permissions")
def get_permissions(self, defaults=None):
defaults = defaults or preferences.get("users__default_permissions")
perms = {}
for p in PERMISSIONS:
v = (
@ -90,6 +104,10 @@ class User(AbstractUser):
perms[p] = v
return perms
@property
def all_permissions(self):
return self.get_permissions()
def has_permissions(self, *perms, **kwargs):
operator = kwargs.pop("operator", "and")
if operator not in ["and", "or"]:
@ -117,3 +135,53 @@ class User(AbstractUser):
def get_activity_url(self):
return settings.FUNKWHALE_URL + "/@{}".format(self.username)
def record_activity(self):
"""
Simply update the last_activity field if current value is too old
than a threshold. This is useful to keep a track of inactive accounts.
"""
current = self.last_activity
delay = 60 * 15 # fifteen minutes
now = timezone.now()
if current is None or current < now - datetime.timedelta(seconds=delay):
self.last_activity = now
self.save(update_fields=["last_activity"])
def generate_code(length=10):
return "".join(
random.SystemRandom().choice(string.ascii_uppercase) for _ in range(length)
)
class InvitationQuerySet(models.QuerySet):
def open(self, include=True):
now = timezone.now()
qs = self.annotate(_users=models.Count("users"))
query = models.Q(_users=0, expiration_date__gt=now)
if include:
return qs.filter(query)
return qs.exclude(query)
class Invitation(models.Model):
creation_date = models.DateTimeField(default=timezone.now)
expiration_date = models.DateTimeField()
owner = models.ForeignKey(
User, related_name="invitations", on_delete=models.CASCADE
)
code = models.CharField(max_length=50, unique=True)
objects = InvitationQuerySet.as_manager()
def save(self, **kwargs):
if not self.code:
self.code = generate_code()
if not self.expiration_date:
self.expiration_date = self.creation_date + datetime.timedelta(
days=settings.USERS_INVITATION_EXPIRATION_DAYS
)
return super().save(**kwargs)

View File

@ -1,5 +1,6 @@
from django.conf import settings
from rest_auth.serializers import PasswordResetSerializer as PRS
from rest_auth.registration.serializers import RegisterSerializer as RS
from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers
@ -7,6 +8,28 @@ from funkwhale_api.activity import serializers as activity_serializers
from . import models
class RegisterSerializer(RS):
invitation = serializers.CharField(
required=False, allow_null=True, allow_blank=True
)
def validate_invitation(self, value):
if not value:
return
try:
return models.Invitation.objects.open().get(code__iexact=value)
except models.Invitation.DoesNotExist:
raise serializers.ValidationError("Invalid invitation code")
def save(self, request):
user = super().save(request)
if self.validated_data.get("invitation"):
user.invitation = self.validated_data.get("invitation")
user.save(update_fields=["invitation"])
return user
class UserActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField()
name = serializers.CharField(source="username")

View File

@ -10,8 +10,11 @@ from . import models, serializers
class RegisterView(BaseRegisterView):
serializer_class = serializers.RegisterSerializer
def create(self, request, *args, **kwargs):
if not self.is_open_for_signup(request):
invitation_code = request.data.get("invitation")
if not invitation_code and not self.is_open_for_signup(request):
r = {"detail": "Registration has been disabled"}
return Response(r, status=403)
return super().create(request, *args, **kwargs)

View File

@ -12,7 +12,8 @@ from funkwhale_api.users.factories import UserFactory
(AnonymousUser(), Q(privacy_level="everyone")),
(
UserFactory.build(pk=1),
Q(privacy_level__in=["followers", "instance", "everyone"]),
Q(privacy_level__in=["instance", "everyone"])
| Q(privacy_level="me", user=UserFactory.build(pk=1)),
),
],
)

View File

@ -11,7 +11,7 @@ class TestActionFilterSet(django_filters.FilterSet):
class TestSerializer(serializers.ActionSerializer):
actions = ["test"]
actions = [serializers.Action("test", allow_all=True)]
filterset_class = TestActionFilterSet
def handle_test(self, objects):
@ -19,8 +19,10 @@ class TestSerializer(serializers.ActionSerializer):
class TestDangerousSerializer(serializers.ActionSerializer):
actions = ["test", "test_dangerous"]
dangerous_actions = ["test_dangerous"]
actions = [
serializers.Action("test", allow_all=True),
serializers.Action("test_dangerous"),
]
def handle_test(self, objects):
pass
@ -29,6 +31,18 @@ class TestDangerousSerializer(serializers.ActionSerializer):
pass
class TestDeleteOnlyInactiveSerializer(serializers.ActionSerializer):
actions = [
serializers.Action(
"test", allow_all=True, qs_filter=lambda qs: qs.filter(is_active=False)
)
]
filterset_class = TestActionFilterSet
def handle_test(self, objects):
pass
def test_action_serializer_validates_action():
data = {"objects": "all", "action": "nope"}
serializer = TestSerializer(data, queryset=models.User.objects.none())
@ -52,7 +66,7 @@ def test_action_serializers_objects_clean_ids(factories):
data = {"objects": [user1.pk], "action": "test"}
serializer = TestSerializer(data, queryset=models.User.objects.all())
assert serializer.is_valid() is True
assert serializer.is_valid(raise_exception=True) is True
assert list(serializer.validated_data["objects"]) == [user1]
@ -63,7 +77,7 @@ def test_action_serializers_objects_clean_all(factories):
data = {"objects": "all", "action": "test"}
serializer = TestSerializer(data, queryset=models.User.objects.all())
assert serializer.is_valid() is True
assert serializer.is_valid(raise_exception=True) is True
assert list(serializer.validated_data["objects"]) == [user1, user2]
@ -75,7 +89,7 @@ def test_action_serializers_save(factories, mocker):
data = {"objects": "all", "action": "test"}
serializer = TestSerializer(data, queryset=models.User.objects.all())
assert serializer.is_valid() is True
assert serializer.is_valid(raise_exception=True) is True
result = serializer.save()
assert result == {"updated": 2, "action": "test", "result": {"hello": "world"}}
handler.assert_called_once()
@ -88,7 +102,7 @@ def test_action_serializers_filterset(factories):
data = {"objects": "all", "action": "test", "filters": {"is_active": True}}
serializer = TestSerializer(data, queryset=models.User.objects.all())
assert serializer.is_valid() is True
assert serializer.is_valid(raise_exception=True) is True
assert list(serializer.validated_data["objects"]) == [user2]
@ -109,9 +123,14 @@ def test_dangerous_actions_refuses_all(factories):
assert "non_field_errors" in serializer.errors
def test_dangerous_actions_refuses_not_listed(factories):
factories["users.User"]()
data = {"objects": "all", "action": "test"}
serializer = TestDangerousSerializer(data, queryset=models.User.objects.all())
def test_action_serializers_can_require_filter(factories):
user1 = factories["users.User"](is_active=False)
factories["users.User"](is_active=True)
assert serializer.is_valid() is True
data = {"objects": "all", "action": "test"}
serializer = TestDeleteOnlyInactiveSerializer(
data, queryset=models.User.objects.all()
)
assert serializer.is_valid(raise_exception=True) is True
assert list(serializer.validated_data["objects"]) == [user1]

View File

@ -7,6 +7,7 @@ import pytest
import requests_mock
from django.contrib.auth.models import AnonymousUser
from django.core.cache import cache as django_cache
from django.utils import timezone
from django.test import client
from dynamic_preferences.registries import global_preferences_registry
from rest_framework import fields as rest_fields
@ -250,3 +251,10 @@ def to_api_date():
raise ValueError("Invalid value: {}".format(value))
return inner
@pytest.fixture()
def now(mocker):
now = timezone.now()
mocker.patch("django.utils.timezone.now", return_value=now)
return now

View File

@ -8,3 +8,67 @@ def test_manage_track_file_action_delete(factories):
s.handle_delete(tfs.__class__.objects.all())
assert tfs.__class__.objects.count() == 0
def test_user_update_permission(factories):
user = factories["users.User"](
permission_library=False,
permission_upload=False,
permission_federation=True,
permission_settings=True,
is_active=True,
)
s = serializers.ManageUserSerializer(
user,
data={"is_active": False, "permissions": {"federation": False, "upload": True}},
)
s.is_valid(raise_exception=True)
s.save()
user.refresh_from_db()
assert user.is_active is False
assert user.permission_federation is False
assert user.permission_upload is True
assert user.permission_library is False
assert user.permission_settings is True
def test_manage_import_request_mark_closed(factories):
affected = factories["requests.ImportRequest"].create_batch(
size=5, status="pending"
)
# we do not update imported requests
factories["requests.ImportRequest"].create_batch(size=5, status="imported")
s = serializers.ManageImportRequestActionSerializer(
queryset=affected[0].__class__.objects.all(),
data={"objects": "all", "action": "mark_closed"},
)
assert s.is_valid(raise_exception=True) is True
s.save()
assert affected[0].__class__.objects.filter(status="imported").count() == 5
for ir in affected:
ir.refresh_from_db()
assert ir.status == "closed"
def test_manage_import_request_mark_imported(factories, now):
affected = factories["requests.ImportRequest"].create_batch(
size=5, status="pending"
)
# we do not update closed requests
factories["requests.ImportRequest"].create_batch(size=5, status="closed")
s = serializers.ManageImportRequestActionSerializer(
queryset=affected[0].__class__.objects.all(),
data={"objects": "all", "action": "mark_imported"},
)
assert s.is_valid(raise_exception=True) is True
s.save()
assert affected[0].__class__.objects.filter(status="closed").count() == 5
for ir in affected:
ir.refresh_from_db()
assert ir.status == "imported"
assert ir.imported_date == now

View File

@ -5,7 +5,13 @@ from funkwhale_api.manage import serializers, views
@pytest.mark.parametrize(
"view,permissions,operator", [(views.ManageTrackFileViewSet, ["library"], "and")]
"view,permissions,operator",
[
(views.ManageTrackFileViewSet, ["library"], "and"),
(views.ManageUserViewSet, ["settings"], "and"),
(views.ManageInvitationViewSet, ["settings"], "and"),
(views.ManageImportRequestViewSet, ["library"], "and"),
],
)
def test_permissions(assert_user_permission, view, permissions, operator):
assert_user_permission(view, permissions, operator)
@ -23,3 +29,50 @@ def test_track_file_view(factories, superuser_api_client):
assert response.data["count"] == len(tfs)
assert response.data["results"] == expected
def test_user_view(factories, superuser_api_client, mocker):
mocker.patch("funkwhale_api.users.models.User.record_activity")
users = factories["users.User"].create_batch(size=5) + [superuser_api_client.user]
qs = users[0].__class__.objects.order_by("-id")
url = reverse("api:v1:manage:users:users-list")
response = superuser_api_client.get(url, {"sort": "-id"})
expected = serializers.ManageUserSerializer(
qs, many=True, context={"request": response.wsgi_request}
).data
assert response.data["count"] == len(users)
assert response.data["results"] == expected
def test_invitation_view(factories, superuser_api_client, mocker):
invitations = factories["users.Invitation"].create_batch(size=5)
qs = invitations[0].__class__.objects.order_by("-id")
url = reverse("api:v1:manage:users:invitations-list")
response = superuser_api_client.get(url, {"sort": "-id"})
expected = serializers.ManageInvitationSerializer(qs, many=True).data
assert response.data["count"] == len(invitations)
assert response.data["results"] == expected
def test_invitation_view_create(factories, superuser_api_client, mocker):
url = reverse("api:v1:manage:users:invitations-list")
response = superuser_api_client.post(url)
assert response.status_code == 201
assert superuser_api_client.user.invitations.latest("id") is not None
def test_music_requests_view(factories, superuser_api_client, mocker):
invitations = factories["requests.ImportRequest"].create_batch(size=5)
qs = invitations[0].__class__.objects.order_by("-id")
url = reverse("api:v1:manage:requests:import-requests-list")
response = superuser_api_client.get(url, {"sort": "-id"})
expected = serializers.ManageImportRequestSerializer(qs, many=True).data
assert response.data["count"] == len(invitations)
assert response.data["results"] == expected

View File

@ -118,7 +118,7 @@ def test_run_import_skipping_accoustid(factories, mocker):
path = os.path.join(DATA_DIR, "test.ogg")
job = factories["music.FileImportJob"](audio_file__path=path)
tasks.import_job_run(import_job_id=job.pk, use_acoustid=False)
m.assert_called_once_with(job, False, use_acoustid=False)
m.assert_called_once_with(job, use_acoustid=False)
def test__do_import_skipping_accoustid(factories, mocker):
@ -130,7 +130,7 @@ def test__do_import_skipping_accoustid(factories, mocker):
path = os.path.join(DATA_DIR, "test.ogg")
job = factories["music.FileImportJob"](mbid=None, audio_file__path=path)
p = job.audio_file.path
tasks._do_import(job, replace=False, use_acoustid=False)
tasks._do_import(job, use_acoustid=False)
m.assert_called_once_with(p)
@ -144,10 +144,27 @@ def test__do_import_skipping_accoustid_if_no_key(factories, mocker, preferences)
path = os.path.join(DATA_DIR, "test.ogg")
job = factories["music.FileImportJob"](mbid=None, audio_file__path=path)
p = job.audio_file.path
tasks._do_import(job, replace=False, use_acoustid=False)
tasks._do_import(job, use_acoustid=False)
m.assert_called_once_with(p)
def test__do_import_replace_if_duplicate(factories, mocker):
existing_file = factories["music.TrackFile"]()
existing_track = existing_file.track
path = os.path.join(DATA_DIR, "test.ogg")
mocker.patch(
"funkwhale_api.providers.audiofile.tasks.import_track_data_from_path",
return_value=existing_track,
)
job = factories["music.FileImportJob"](
replace_if_duplicate=True, audio_file__path=path
)
tasks._do_import(job)
with pytest.raises(existing_file.__class__.DoesNotExist):
existing_file.refresh_from_db()
assert existing_file.creation_date != job.track_file.creation_date
def test_import_job_skip_if_already_exists(artists, albums, tracks, factories, mocker):
path = os.path.join(DATA_DIR, "test.ogg")
mbid = "9968a9d6-8d92-4051-8f76-674e157b6eed"

View File

@ -6,6 +6,7 @@ from django.core.management import call_command
from django.core.management.base import CommandError
from funkwhale_api.providers.audiofile import tasks
from funkwhale_api.music.models import ImportJob
DATA_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "files")
@ -103,6 +104,31 @@ def test_in_place_import_only_from_music_dir(factories, settings):
)
def test_import_with_multiple_argument(factories, mocker):
factories["users.User"](username="me")
path1 = os.path.join(DATA_DIR, "dummy_file.ogg")
path2 = os.path.join(DATA_DIR, "utf8-éà◌.ogg")
mocked_filter = mocker.patch(
"funkwhale_api.providers.audiofile.management.commands.import_files.Command.filter_matching",
return_value=({"new": [], "skipped": []}),
)
call_command("import_files", path1, path2, username="me", interactive=False)
mocked_filter.assert_called_once_with([path1, path2])
def test_import_with_replace_flag(factories, mocker):
factories["users.User"](username="me")
path = os.path.join(DATA_DIR, "dummy_file.ogg")
mocked_job_run = mocker.patch("funkwhale_api.music.tasks.import_job_run")
call_command("import_files", path, username="me", replace=True, interactive=False)
created_job = ImportJob.objects.latest("id")
assert created_job.replace_if_duplicate is True
mocked_job_run.assert_called_once_with(
import_job_id=created_job.id, use_acoustid=False
)
def test_import_files_creates_a_batch_and_job(factories, mocker):
m = mocker.patch("funkwhale_api.music.tasks.import_job_run")
user = factories["users.User"](username="me")

View File

@ -0,0 +1,18 @@
from funkwhale_api.users import middleware
def test_record_activity_middleware(factories, api_request, mocker):
m = middleware.RecordActivityMiddleware(lambda request: None)
user = factories["users.User"]()
record_activity = mocker.patch("funkwhale_api.users.models.User.record_activity")
request = api_request.get("/")
request.user = user
m(request)
record_activity.assert_called_once_with()
def test_record_activity_middleware_no_user(api_request):
m = middleware.RecordActivityMiddleware(lambda request: None)
request = api_request.get("/")
m(request)

View File

@ -1,3 +1,4 @@
import datetime
import pytest
from funkwhale_api.users import models
@ -78,3 +79,51 @@ def test_has_permissions_and(args, perms, expected, factories):
def test_has_permissions_or(args, perms, expected, factories):
user = factories["users.User"](**args)
assert user.has_permissions(*perms, operator="or") is expected
def test_record_activity(factories, now):
user = factories["users.User"]()
assert user.last_activity is None
user.record_activity()
assert user.last_activity == now
def test_record_activity_does_nothing_if_already(factories, now, mocker):
user = factories["users.User"](last_activity=now)
save = mocker.patch("funkwhale_api.users.models.User.save")
user.record_activity()
save.assert_not_called()
def test_invitation_generates_random_code_on_save(factories):
invitation = factories["users.Invitation"]()
assert len(invitation.code) >= 6
def test_invitation_expires_after_delay(factories, settings):
delay = settings.USERS_INVITATION_EXPIRATION_DAYS
invitation = factories["users.Invitation"]()
assert invitation.expiration_date == (
invitation.creation_date + datetime.timedelta(days=delay)
)
def test_can_filter_open_invitations(factories):
okay = factories["users.Invitation"]()
factories["users.Invitation"](expired=True)
factories["users.User"](invited=True)
assert models.Invitation.objects.count() == 3
assert list(models.Invitation.objects.open()) == [okay]
def test_can_filter_closed_invitations(factories):
factories["users.Invitation"]()
expired = factories["users.Invitation"](expired=True)
used = factories["users.User"](invited=True).invitation
assert models.Invitation.objects.count() == 3
assert list(models.Invitation.objects.open(False)) == [expired, used]

View File

@ -50,6 +50,39 @@ def test_can_disable_registration_view(preferences, api_client, db):
assert response.status_code == 403
def test_can_signup_with_invitation(preferences, factories, api_client):
url = reverse("rest_register")
invitation = factories["users.Invitation"](code="Hello")
data = {
"username": "test1",
"email": "test1@test.com",
"password1": "testtest",
"password2": "testtest",
"invitation": "hello",
}
preferences["users__registration_enabled"] = False
response = api_client.post(url, data)
assert response.status_code == 201
u = User.objects.get(email="test1@test.com")
assert u.username == "test1"
assert u.invitation == invitation
def test_can_signup_with_invitation_invalid(preferences, factories, api_client):
url = reverse("rest_register")
factories["users.Invitation"](code="hello")
data = {
"username": "test1",
"email": "test1@test.com",
"password1": "testtest",
"password2": "testtest",
"invitation": "nope",
}
response = api_client.post(url, data)
assert response.status_code == 400
assert "invitation" in response.data
def test_can_fetch_data_from_api(api_client, factories):
url = reverse("api:v1:users:users-me")
response = api_client.get(url)

View File

@ -79,6 +79,14 @@ server {
alias /srv/funkwhale/data/media/;
}
location /_protected/media {
# this is an internal location that is used to serve
# audio files once correct permission / authentication
# has been checked on API side
internal;
alias /srv/funkwhale/data/media;
}
location /_protected/music {
# this is an internal location that is used to serve
# audio files once correct permission / authentication

View File

@ -76,6 +76,39 @@ configuration options to ensure the webserver can serve them properly:
Thus, be especially careful when you manipulate the source files.
We recommend you symlink all your music directories into ``/srv/funkwhale/data/music``
and run the `import_files` command from that directory. This will make it possible
to use multiple music music directories, without any additional configuration
on the webserver side.
For instance, if you have a NFS share with your music mounted at ``/media/mynfsshare``,
you can create a symlink like this::
ln -s /media/mynfsshare /srv/funkwhale/data/music/nfsshare
And import music from this share with this command::
python api/manage.py import_files "/srv/funkwhale/data/music/nfsshare/**/*.ogg" --recursive --noinput --in-place
On docker setups, it will require a bit more work, because while the ``/srv/funkwhale/data/music`` is mounted
in containers, symlinked directories are not. To fix that, in your ``docker-compose.yml`` file, ensure each symlinked
directory is mounted as a volume as well::
celeryworker:
volumes:
- ./data/music:/music:ro
- ./data/media:/app/funkwhale_api/media
# add your symlinked dirs here
- /media/nfsshare:/media/nfsshare:ro
api:
volumes:
- ./data/music:/music:ro
- ./data/media:/app/funkwhale_api/media
# add your symlinked dirs here
- /media/nfsshare:/media/nfsshare:ro
Album covers
^^^^^^^^^^^^

View File

@ -67,6 +67,11 @@ We also maintain an installation guide for Debian 9.
systemd
Funkwhale packages are available for the following platforms:
- `YunoHost 3 <https://yunohost.org/>`_: https://github.com/YunoHost-Apps/funkwhale_ynh (kindly maintained by `@Jibec <https://github.com/Jibec>`_)
.. _frontend-setup:
Frontend setup

View File

@ -64,9 +64,9 @@ The following example assume your setup match :ref:`frontend-setup`.
# this assumes you want to upgrade to version "|version|"
export FUNKWHALE_VERSION="|version|"
cd /srv/funkwhale
curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front"
unzip -o front.zip
rm front.zip
sudo -u funkwhale curl -L -o front.zip "https://code.eliotberriot.com/funkwhale/funkwhale/builds/artifacts/$FUNKWHALE_VERSION/download?job=build_front"
sudo -u funkwhale unzip -o front.zip
sudo -u funkwhale rm front.zip
Upgrading the API
^^^^^^^^^^^^^^^^^
@ -76,33 +76,33 @@ match what is described in :doc:`debian`:
.. parsed-literal::
# stop the services
sudo systemctl stop funkwhale.target
# this assumes you want to upgrade to version "|version|"
export FUNKWALE_VERSION="|version|"
export FUNKWHALE_VERSION="|version|"
cd /srv/funkwhale
# download more recent API files
curl -L -o "api-|version|.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/$FUNKWALE_VERSION/download?job=build_api"
unzip "api-$FUNKWALE_VERSION.zip" -d extracted
rm -rf api/ && mv extracted/api .
rm -rf extracted
sudo -u funkwhale curl -L -o "api-$FUNKWHALE_VERSION.zip" "https://code.eliotberriot.com/funkwhale/funkwhale/-/jobs/artifacts/$FUNKWHALE_VERSION/download?job=build_api"
sudo -u funkwhale unzip "api-$FUNKWHALE_VERSION.zip" -d extracted
sudo -u funkwhale rm -rf api/ && mv extracted/api .
sudo -u funkwhale rm -rf extracted
# update os dependencies
sudo api/install_os_dependencies.sh install
# update python dependencies
source /srv/funkwhale/load_env
source /srv/funkwhale/virtualenv/bin/activate
pip install -r api/requirements.txt
sudo -u funkwhale -E /srv/funkwhale/virtualenv/bin/pip install -r api/requirements.txt
# collect static files
sudo -u funkwhale -E /srv/funkwhale/virtualenv/bin/python api/manage.py collectstatic --no-input
# stop the services
sudo systemctl stop funkwhale.target
# apply database migrations
python api/manage.py migrate
# collect static files
python api/manage.py collectstatic --no-input
sudo -u funkwhale -E /srv/funkwhale/virtualenv/bin/python api/manage.py migrate
# restart the services
sudo systemctl restart funkwhale.target
sudo systemctl start funkwhale.target
.. warning::

View File

@ -8,7 +8,7 @@ module.exports = {
assetsRoot: path.resolve(__dirname, '../dist'),
assetsSubDirectory: 'static',
assetsPublicPath: '/',
productionSourceMap: true,
productionSourceMap: false,
// Gzip off by default as many popular static hosts such as
// Surge or Netlify already gzip all static assets for you.
// Before setting to `true`, make sure to:

View File

@ -1,4 +1,5 @@
let url = process.env.INSTANCE_URL || '/'
module.exports = {
NODE_ENV: '"production"',
BACKEND_URL: '"/"'
INSTANCE_URL: `"${url}"`
}

View File

@ -1,44 +1,71 @@
<template>
<div id="app">
<sidebar></sidebar>
<service-messages v-if="messages.length > 0" />
<router-view :key="$route.fullPath"></router-view>
<div class="ui fitted divider"></div>
<div id="footer" class="ui vertical footer segment">
<div class="ui container">
<div class="ui stackable equal height stackable grid">
<div class="three wide column">
<i18next tag="h4" class="ui header" path="Links"></i18next>
<div class="ui link list">
<router-link class="item" to="/about">
<i18next path="About this instance" />
</router-link>
<a href="https://funkwhale.audio" class="item" target="_blank">{{ $t('Official website') }}</a>
<a href="https://docs.funkwhale.audio" class="item" target="_blank">{{ $t('Documentation') }}</a>
<a href="https://code.eliotberriot.com/funkwhale/funkwhale" class="item" target="_blank">
<template v-if="version">{{ $t('Source code ({% version %})', {version: version}) }}</template>
<template v-else>{{ $t('Source code') }}</template>
</a>
<a href="https://code.eliotberriot.com/funkwhale/funkwhale/issues" class="item" target="_blank">{{ $t('Issue tracker') }}</a>
<div class="ui main text container instance-chooser" v-if="!$store.state.instance.instanceUrl">
<div class="ui padded segment">
<h1 class="ui header">{{ $t('Choose your instance') }}</h1>
<form class="ui form" @submit.prevent="$store.dispatch('instance/setUrl', instanceUrl)">
<p>{{ $t('You need to select an instance in order to continue') }}</p>
<div class="ui action input">
<input type="text" v-model="instanceUrl">
<button type="submit" class="ui button">{{ $t('Submit') }}</button>
</div>
<p>{{ $t('Suggested choices') }}</p>
<div class="ui bulleted list">
<div class="ui item" v-for="url in suggestedInstances">
<a @click="instanceUrl = url">{{ url }}</a>
</div>
</div>
<div class="ten wide column">
<i18next tag="h4" class="ui header" path="About funkwhale" />
<p>
<i18next path="Funkwhale is a free and open-source project run by volunteers. You can help us improve the platform by reporting bugs, suggesting features and share the project with your friends!"/>
</p>
<p>
<i18next path="The funkwhale logo was kindly designed and provided by Francis Gading."/>
</p>
</form>
</div>
</div>
<template v-else>
<sidebar></sidebar>
<service-messages v-if="messages.length > 0" />
<router-view :key="$route.fullPath"></router-view>
<div class="ui fitted divider"></div>
<div id="footer" class="ui vertical footer segment">
<div class="ui container">
<div class="ui stackable equal height stackable grid">
<div class="three wide column">
<i18next tag="h4" class="ui header" path="Links"></i18next>
<div class="ui link list">
<router-link class="item" to="/about">
<i18next path="About this instance" />
</router-link>
<a href="https://funkwhale.audio" class="item" target="_blank">{{ $t('Official website') }}</a>
<a href="https://docs.funkwhale.audio" class="item" target="_blank">{{ $t('Documentation') }}</a>
<a href="https://code.eliotberriot.com/funkwhale/funkwhale" class="item" target="_blank">
<template v-if="version">{{ $t('Source code ({% version %})', {version: version}) }}</template>
<template v-else>{{ $t('Source code') }}</template>
</a>
<a href="https://code.eliotberriot.com/funkwhale/funkwhale/issues" class="item" target="_blank">{{ $t('Issue tracker') }}</a>
<a @click="switchInstance" class="item" >
{{ $t('Use another instance') }}
<template v-if="$store.state.instance.instanceUrl !== '/'">
<br>
({{ $store.state.instance.instanceUrl }})
</template>
</a>
</div>
</div>
<div class="ten wide column">
<i18next tag="h4" class="ui header" path="About funkwhale" />
<p>
<i18next path="Funkwhale is a free and open-source project run by volunteers. You can help us improve the platform by reporting bugs, suggesting features and share the project with your friends!"/>
</p>
<p>
<i18next path="The funkwhale logo was kindly designed and provided by Francis Gading."/>
</p>
</div>
</div>
</div>
</div>
</div>
<raven
v-if="$store.state.instance.settings.raven.front_enabled.value"
:dsn="$store.state.instance.settings.raven.front_dsn.value">
</raven>
<playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal>
<raven
v-if="$store.state.instance.settings.raven.front_enabled.value"
:dsn="$store.state.instance.settings.raven.front_dsn.value">
</raven>
<playlist-modal v-if="$store.state.auth.authenticated"></playlist-modal>
</template>
</div>
</template>
@ -63,17 +90,22 @@ export default {
},
data () {
return {
nodeinfo: null
nodeinfo: null,
instanceUrl: null
}
},
created () {
this.$store.dispatch('instance/fetchSettings')
let self = this
setInterval(() => {
// used to redraw ago dates every minute
self.$store.commit('ui/computeLastDate')
}, 1000 * 60)
this.fetchNodeInfo()
if (this.$store.state.instance.instanceUrl) {
this.$store.commit('instance/instanceUrl', this.$store.state.instance.instanceUrl)
this.$store.dispatch('auth/check')
this.$store.dispatch('instance/fetchSettings')
this.fetchNodeInfo()
}
},
methods: {
fetchNodeInfo () {
@ -81,18 +113,38 @@ export default {
axios.get('instance/nodeinfo/2.0/').then(response => {
self.nodeinfo = response.data
})
},
switchInstance () {
let confirm = window.confirm(this.$t('This will erase your local data and disconnect you, do you want to continue?'))
if (confirm) {
this.$store.commit('instance/instanceUrl', null)
}
}
},
computed: {
...mapState({
messages: state => state.ui.messages
}),
suggestedInstances () {
let rootUrl = (
window.location.protocol + '//' + window.location.hostname +
(window.location.port ? ':' + window.location.port : '')
)
let instances = [rootUrl, 'https://demo.funkwhale.audio']
return instances
},
version () {
if (!this.nodeinfo) {
return null
}
return _.get(this.nodeinfo, 'software.version')
}
},
watch: {
'$store.state.instance.instanceUrl' () {
this.$store.dispatch('instance/fetchSettings')
this.fetchNodeInfo()
}
}
}
</script>
@ -116,6 +168,11 @@ html, body {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.instance-chooser {
margin-top: 2em;
}
.main.pusher, .footer {
@include media(">desktop") {
margin-left: 350px !important;
@ -173,7 +230,7 @@ html, body {
.ui.icon.header .circular.icon {
display: flex;
justify-content: center;
}
.segment-content .button{

View File

@ -1,5 +1,3 @@
import config from '@/config'
var Album = {
clean (album) {
// we manually rebind the album and artist to each child track
@ -21,21 +19,6 @@ var Artist = {
}
}
export default {
absoluteUrl (url) {
if (url.startsWith('http')) {
return url
}
if (url.startsWith('/')) {
let rootUrl = (
window.location.protocol + '//' + window.location.hostname +
(window.location.port ? ':' + window.location.port : '')
)
return rootUrl + url
} else {
return config.BACKEND_URL + url
}
},
Artist: Artist,
Album: Album
}

View File

@ -1,7 +0,0 @@
import backend from './backend'
export default {
getCover (track) {
return backend.absoluteUrl(track.album.cover)
}
}

View File

@ -58,21 +58,16 @@
<div class="item" v-if="showAdmin">
<div class="header">{{ $t('Administration') }}</div>
<div class="menu">
<router-link
class="item"
v-if="$store.state.auth.availablePermissions['library']"
:to="{name: 'library.requests', query: {status: 'pending' }}">
<i class="download icon"></i>{{ $t('Import requests') }}
<div
:class="['ui', {'teal': notifications.importRequests > 0}, 'label']"
:title="$t('Pending import requests')">
{{ notifications.importRequests }}</div>
</router-link>
<router-link
class="item"
v-if="$store.state.auth.availablePermissions['library']"
:to="{name: 'manage.library.files'}">
<i class="book icon"></i>{{ $t('Library') }}
<div
:class="['ui', {'teal': $store.state.ui.notifications.importRequests > 0}, 'label']"
:title="$t('Pending import requests')">
{{ $store.state.ui.notifications.importRequests }}</div>
</router-link>
<router-link
class="item"
@ -86,9 +81,9 @@
:to="{path: '/manage/federation/libraries'}">
<i class="sitemap icon"></i>{{ $t('Federation') }}
<div
:class="['ui', {'teal': notifications.federation > 0}, 'label']"
:class="['ui', {'teal': $store.state.ui.notifications.federation > 0}, 'label']"
:title="$t('Pending follow requests')">
{{ notifications.federation }}</div>
{{ $store.state.ui.notifications.federation }}</div>
</router-link>
<router-link
class="item"
@ -96,6 +91,12 @@
:to="{path: '/manage/settings'}">
<i class="settings icon"></i>{{ $t('Settings') }}
</router-link>
<router-link
class="item"
v-if="$store.state.auth.availablePermissions['settings']"
:to="{name: 'manage.users.users.list'}">
<i class="users icon"></i>{{ $t('Users') }}
</router-link>
</div>
</div>
</div>
@ -115,11 +116,11 @@
</div>
<div class="ui bottom attached tab" data-tab="queue">
<table class="ui compact inverted very basic fixed single line unstackable table">
<draggable v-model="queue.tracks" element="tbody" @update="reorder">
<tr @click="$store.dispatch('queue/currentIndex', index)" v-for="(track, index) in queue.tracks" :key="index" :class="[{'active': index === queue.currentIndex}]">
<draggable v-model="tracks" element="tbody" @update="reorder">
<tr @click="$store.dispatch('queue/currentIndex', index)" v-for="(track, index) in tracks" :key="index" :class="[{'active': index === queue.currentIndex}]">
<td class="right aligned">{{ index + 1}}</td>
<td class="center aligned">
<img class="ui mini image" v-if="track.album.cover" :src="backend.absoluteUrl(track.album.cover)">
<img class="ui mini image" v-if="track.album.cover" :src="$store.getters['instance/absoluteUrl'](track.album.cover)">
<img class="ui mini image" v-else src="../assets/audio/default-cover.png">
</td>
<td colspan="4">
@ -154,7 +155,6 @@
<script>
import {mapState, mapActions} from 'vuex'
import axios from 'axios'
import Player from '@/components/audio/Player'
import Logo from '@/components/Logo'
@ -176,12 +176,9 @@ export default {
return {
selectedTab: 'library',
backend: backend,
tracksChangeBuffer: null,
isCollapsed: true,
fetchInterval: null,
notifications: {
federation: 0,
importRequests: 0
}
fetchInterval: null
}
},
mounted () {
@ -211,6 +208,14 @@ export default {
return adminPermissions.filter(e => {
return e
}).length > 0
},
tracks: {
get () {
return this.$store.state.queue.tracks
},
set (value) {
this.tracksChangeBuffer = value
}
}
},
methods: {
@ -218,30 +223,12 @@ export default {
cleanTrack: 'queue/cleanTrack'
}),
fetchNotificationsCount () {
this.fetchFederationNotificationsCount()
this.fetchFederationImportRequestsCount()
},
fetchFederationNotificationsCount () {
if (!this.$store.state.auth.availablePermissions['federation']) {
return
}
let self = this
axios.get('federation/libraries/followers/', {params: {pending: true}}).then(response => {
self.notifications.federation = response.data.count
})
},
fetchFederationImportRequestsCount () {
if (!this.$store.state.auth.availablePermissions['library']) {
return
}
let self = this
axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => {
self.notifications.importRequests = response.data.count
})
this.$store.dispatch('ui/fetchFederationNotificationsCount')
this.$store.dispatch('ui/fetchImportRequestsCount')
},
reorder: function (event) {
this.$store.commit('queue/reorder', {
oldIndex: event.oldIndex, newIndex: event.newIndex})
tracks: this.tracksChangeBuffer, oldIndex: event.oldIndex, newIndex: event.newIndex})
},
scrollToCurrent () {
let current = $(this.$el).find('[data-tab="queue"] .active')[0]

View File

@ -1,20 +1,20 @@
<template>
<div class="ui inverted segment player-wrapper" :style="style">
<div class="player">
<audio-track
ref="currentAudio"
v-if="renderAudio && currentTrack"
:key="currentTrack.id"
:is-current="true"
:start-time="$store.state.player.currentTime"
:autoplay="$store.state.player.playing"
:track="currentTrack">
</audio-track>
<keep-alive>
<audio-track
ref="currentAudio"
v-if="renderAudio && currentTrack"
:is-current="true"
:start-time="$store.state.player.currentTime"
:autoplay="$store.state.player.playing"
:track="currentTrack">
</audio-track>
</keep-alive>
<div v-if="currentTrack" class="track-area ui unstackable items">
<div class="ui inverted item">
<div class="ui tiny image">
<img ref="cover" @load="updateBackground" v-if="currentTrack.album.cover" :src="Track.getCover(currentTrack)">
<img ref="cover" @load="updateBackground" v-if="currentTrack.album.cover" :src="$store.getters['instance/absoluteUrl'](currentTrack.album.cover)">
<img v-else src="../../assets/audio/default-cover.png">
</div>
<div class="middle aligned content">
@ -143,7 +143,6 @@ import {mapState, mapGetters, mapActions} from 'vuex'
import GlobalEvents from '@/components/utils/global-events'
import ColorThief from '@/vendor/color-thief'
import Track from '@/audio/track'
import AudioTrack from '@/components/audio/Track'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
@ -162,7 +161,6 @@ export default {
isShuffling: false,
renderAudio: true,
sliderVolume: this.volume,
Track: Track,
defaultAmbiantColors: defaultAmbiantColors,
ambiantColors: defaultAmbiantColors
}

View File

@ -11,11 +11,8 @@
<script>
import jQuery from 'jquery'
import config from '@/config'
import router from '@/router'
const SEARCH_URL = config.API_URL + 'search?query={query}'
export default {
mounted () {
let self = this
@ -94,7 +91,7 @@ export default {
})
return {results: results}
},
url: SEARCH_URL
url: this.$store.getters['instance/absoluteUrl']('api/v1/search?query={query}')
}
})
}

View File

@ -4,7 +4,7 @@
@error="errored"
@loadeddata="loaded"
@durationchange="updateDuration"
@timeupdate="updateProgress"
@timeupdate="updateProgressThrottled"
@ended="ended"
preload>
<source
@ -30,6 +30,7 @@ export default {
},
data () {
return {
realTrack: this.track,
sourceErrors: 0,
isUpdatingTime: false
}
@ -43,13 +44,13 @@ export default {
looping: state => state.player.looping
}),
srcs: function () {
let file = this.track.files[0]
let file = this.realTrack.files[0]
if (!file) {
this.$store.dispatch('player/trackErrored')
return []
}
let sources = [
{type: file.mimetype, url: file.path}
{type: file.mimetype, url: this.$store.getters['instance/absoluteUrl'](file.path)}
]
if (this.$store.state.auth.authenticated) {
// we need to send the token directly in url
@ -61,6 +62,9 @@ export default {
})
}
return sources
},
updateProgressThrottled () {
return _.throttle(this.updateProgress, 250)
}
},
methods: {
@ -100,30 +104,40 @@ export default {
}
}
},
updateProgress: _.throttle(function () {
updateProgress: function () {
this.isUpdatingTime = true
if (this.$refs.audio) {
this.$store.dispatch('player/updateProgress', this.$refs.audio.currentTime)
}
}, 250),
},
ended: function () {
let onlyTrack = this.$store.state.queue.tracks.length === 1
if (this.looping === 1 || (onlyTrack && this.looping === 2)) {
this.setCurrentTime(0)
this.$refs.audio.play()
} else {
this.$store.dispatch('player/trackEnded', this.track)
this.$store.dispatch('player/trackEnded', this.realTrack)
}
},
setCurrentTime (t) {
if (t < 0 | t > this.duration) {
return
}
this.updateProgress(t)
if (t === this.$refs.audio.currentTime) {
return
}
if (t === 0) {
this.updateProgressThrottled.cancel()
}
this.$refs.audio.currentTime = t
}
},
watch: {
track: _.debounce(function (newValue) {
this.realTrack = newValue
this.setCurrentTime(0)
this.$refs.audio.load()
}, 1000, {leading: true, trailing: true}),
playing: function (newValue) {
if (newValue === true) {
this.$refs.audio.play()
@ -131,6 +145,11 @@ export default {
this.$refs.audio.pause()
}
},
'$store.state.queue.currentIndex' () {
if (this.$store.state.player.playing) {
this.$refs.audio.play()
}
},
volume: function (newValue) {
this.$refs.audio.volume = newValue
},

View File

@ -2,7 +2,7 @@
<div class="ui card">
<div class="content">
<div class="right floated tiny ui image">
<img v-if="album.cover" v-lazy="backend.absoluteUrl(album.cover)">
<img v-if="album.cover" v-lazy="$store.getters['instance/absoluteUrl'](album.cover)">
<img v-else src="../../../assets/audio/default-cover.png">
</div>
<div class="header">

View File

@ -11,7 +11,7 @@
<tbody>
<tr v-for="album in albums">
<td>
<img class="ui mini image" v-if="album.cover" :src="backend.absoluteUrl(album.cover)">
<img class="ui mini image" v-if="album.cover" :src="$store.getters['instance/absoluteUrl'](album.cover)">
<img class="ui mini image" v-else src="../../../assets/audio/default-cover.png">
</td>
<td colspan="4">

View File

@ -4,7 +4,7 @@
<play-button class="basic icon" :discrete="true" :track="track"></play-button>
</td>
<td>
<img class="ui mini image" v-if="track.album.cover" v-lazy="backend.absoluteUrl(track.album.cover)">
<img class="ui mini image" v-if="track.album.cover" v-lazy="$store.getters['instance/absoluteUrl'](track.album.cover)">
<img class="ui mini image" v-else src="../../..//assets/audio/default-cover.png">
</td>
<td colspan="6">

View File

@ -35,7 +35,7 @@
<pre>
export PRIVATE_TOKEN="{{ $store.state.auth.token }}"
<template v-for="track in tracks"><template v-if="track.files.length > 0">
curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.authenticated">--header "Authorization: JWT $PRIVATE_TOKEN"</template> "{{ backend.absoluteUrl(track.files[0].path) }}"</template></template>
curl -G -o "{{ track.files[0].filename }}" <template v-if="$store.state.auth.authenticated">--header "Authorization: JWT $PRIVATE_TOKEN"</template> "{{ $store.getters['instance/absoluteUrl'](track.files[0].path) }}"</template></template>
</pre>
</div>
</div>

View File

@ -2,19 +2,22 @@
<div class="main pusher" v-title="'Sign Up'">
<div class="ui vertical stripe segment">
<div class="ui small text container">
<h2><i18next path="Create a funkwhale account"/></h2>
<h2>{{ $t("Create a funkwhale account") }}</h2>
<form
v-if="$store.state.instance.settings.users.registration_enabled.value"
:class="['ui', {'loading': isLoadingInstanceSetting}, 'form']"
@submit.prevent="submit()">
<p class="ui message" v-if="!$store.state.instance.settings.users.registration_enabled.value">
{{ $t('Registration are closed on this instance, you will need an invitation code to signup.') }}
</p>
<div v-if="errors.length > 0" class="ui negative message">
<div class="header"><i18next path="We cannot create your account"/></div>
<div class="header">{{ $t("We cannot create your account") }}</div>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
</ul>
</div>
<div class="field">
<i18next tag="label" path="Username"/>
<label>{{ $t("Username") }}</label>
<input
ref="username"
required
@ -24,7 +27,7 @@
v-model="username">
</div>
<div class="field">
<i18next tag="label" path="Email"/>
<label>{{ $t("Email") }}</label>
<input
ref="email"
required
@ -33,12 +36,22 @@
v-model="email">
</div>
<div class="field">
<i18next tag="label" path="Password"/>
<label>{{ $t("Password") }}</label>
<password-input v-model="password" />
</div>
<button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit"><i18next path="Create my account"/></button>
<div class="field">
<label v-if="!$store.state.instance.settings.users.registration_enabled.value">{{ $t("Invitation code") }}</label>
<label v-else>{{ $t("Invitation code (optional)") }}</label>
<input
:required="!$store.state.instance.settings.users.registration_enabled.value"
type="text"
:placeholder="$t('Enter your invitation code (case insensitive)')"
v-model="invitation">
</div>
<button :class="['ui', 'green', {'loading': isLoading}, 'button']" type="submit">
{{ $t("Create my account") }}
</button>
</form>
<i18next v-else tag="p" path="Registration is currently disabled on this instance, please try again later."/>
</div>
</div>
</div>
@ -51,13 +64,13 @@ import logger from '@/logging'
import PasswordInput from '@/components/forms/PasswordInput'
export default {
name: 'login',
props: {
invitation: {type: String, required: false, default: null},
next: {type: String, default: '/'}
},
components: {
PasswordInput
},
props: {
next: {type: String, default: '/'}
},
data () {
return {
username: '',
@ -85,7 +98,8 @@ export default {
username: this.username,
password1: this.password,
password2: this.password,
email: this.email
email: this.email,
invitation: this.invitation
}
return axios.post('auth/registration/', payload).then(response => {
logger.default.info('Successfully created account')

View File

@ -36,7 +36,7 @@
<div class="count field">
<span v-if="selectAll">{{ $t('{% count %} on {% total %} selected', {count: objectsData.count, total: objectsData.count}) }}</span>
<span v-else>{{ $t('{% count %} on {% total %} selected', {count: checked.length, total: objectsData.count}) }}</span>
<template v-if="!currentAction.isDangerous && checkable.length === checked.length">
<template v-if="!currentAction.isDangerous && checkable.length > 0 && checkable.length === checked.length">
<a @click="selectAll = true" v-if="!selectAll">
{{ $t('Select all {% total %} elements', {total: objectsData.count}) }}
</a>
@ -61,7 +61,7 @@
</th>
</tr>
<tr>
<th>
<th v-if="actions.length > 0">
<div class="ui checkbox">
<input
type="checkbox"
@ -75,7 +75,7 @@
</thead>
<tbody v-if="objectsData.count > 0">
<tr v-for="(obj, index) in objectsData.results">
<td class="collapsing">
<td v-if="actions.length > 0" class="collapsing">
<input
type="checkbox"
:disabled="checkable.indexOf(obj.id) === -1"
@ -157,6 +157,7 @@ export default {
let self = this
self.actionLoading = true
self.result = null
self.actionErrors = []
let payload = {
action: this.currentActionName,
filters: this.filters
@ -184,6 +185,9 @@ export default {
})[0]
},
checkable () {
if (!this.currentAction) {
return []
}
let objs = this.objectsData.results
let filter = this.currentAction.filterCheckable
if (filter) {

View File

@ -87,7 +87,7 @@ export default {
if (!this.album.cover) {
return ''
}
return 'background-image: url(' + backend.absoluteUrl(this.album.cover) + ')'
return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.album.cover) + ')'
}
},
watch: {

View File

@ -127,7 +127,7 @@ export default {
if (!this.cover) {
return ''
}
return 'background-image: url(' + backend.absoluteUrl(this.cover) + ')'
return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.cover) + ')'
}
},
watch: {

View File

@ -6,13 +6,6 @@
<router-link class="ui item" to="/library/radios" exact><i18next path="Radios"/></router-link>
<router-link class="ui item" to="/library/playlists" exact><i18next path="Playlists"/></router-link>
<div class="ui secondary right menu">
<router-link
v-if="$store.state.auth.authenticated"
class="ui item"
:to="{name: 'library.requests', query: {status: 'pending' }}"
exact>
<i18next path="Requests"/>
</router-link>
<router-link v-if="showImports" class="ui item" to="/library/import/launch" exact>
<i18next path="Import"/>
</router-link>

View File

@ -108,7 +108,6 @@ import time from '@/utils/time'
import axios from 'axios'
import url from '@/utils/url'
import logger from '@/logging'
import backend from '@/audio/backend'
import PlayButton from '@/components/audio/PlayButton'
import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon'
import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon'
@ -169,7 +168,7 @@ export default {
},
downloadUrl () {
if (this.track.files.length > 0) {
let u = backend.absoluteUrl(this.track.files[0].path)
let u = this.$store.getters['instance/absoluteUrl'](this.track.files[0].path)
if (this.$store.state.auth.authenticated) {
u = url.updateQueryString(u, 'jwt', this.$store.state.auth.token)
}
@ -191,7 +190,7 @@ export default {
if (!this.cover) {
return ''
}
return 'background-image: url(' + backend.absoluteUrl(this.cover) + ')'
return 'background-image: url(' + this.$store.getters['instance/absoluteUrl'](this.cover) + ')'
}
},
watch: {

View File

@ -102,6 +102,7 @@ export default Vue.extend({
importedUrl: '',
warnings: [
'live',
'tv',
'full',
'cover',
'mix'

View File

@ -63,7 +63,6 @@
</template>
<script>
import axios from 'axios'
import config from '@/config'
import $ from 'jquery'
import _ from 'lodash'
@ -86,7 +85,7 @@ export default {
return {
checkResult: null,
showCandidadesModal: false,
exclude: config.not
exclude: this.config.not
}
},
mounted: function () {

View File

@ -0,0 +1,229 @@
<template>
<div>
<div class="ui inline form">
<div class="fields">
<div class="ui field">
<label>{{ $t('Search') }}</label>
<input type="text" v-model="search" placeholder="Search by artist, username, comment..." />
</div>
<div class="field">
<i18next tag="label" path="Ordering"/>
<select class="ui dropdown" v-model="ordering">
<option v-for="option in orderingOptions" :value="option[0]">
{{ option[1] }}
</option>
</select>
</div>
<div class="field">
<i18next tag="label" path="Ordering direction"/>
<select class="ui dropdown" v-model="orderingDirection">
<option value="+">Ascending</option>
<option value="-">Descending</option>
</select>
</div>
<div class="field">
<label>{{ $t("Status") }}</label>
<select class="ui dropdown" v-model="status">
<option :value="null">{{ $t('All') }}</option>
<option :value="'pending'">{{ $t('Pending') }}</option>
<option :value="'accepted'">{{ $t('Accepted') }}</option>
<option :value="'imported'">{{ $t('Imported') }}</option>
<option :value="'closed'">{{ $t('Closed') }}</option>
</select>
</div>
</div>
</div>
<div class="dimmable">
<div v-if="isLoading" class="ui active inverted dimmer">
<div class="ui loader"></div>
</div>
<action-table
v-if="result"
@action-launched="fetchData"
:objects-data="result"
:actions="actions"
:action-url="'manage/requests/import-requests/action/'"
:filters="actionFilters">
<template slot="header-cells">
<th>{{ $t('User') }}</th>
<th>{{ $t('Status') }}</th>
<th>{{ $t('Artist') }}</th>
<th>{{ $t('Albums') }}</th>
<th>{{ $t('Comment') }}</th>
<th>{{ $t('Creation date') }}</th>
<th>{{ $t('Import date') }}</th>
<th>{{ $t('Actions') }}</th>
</template>
<template slot="row-cells" slot-scope="scope">
<td>
{{ scope.obj.user.username }}
</td>
<td>
<span class="ui green basic label" v-if="scope.obj.status === 'imported'">{{ $t('Imported') }}</span>
<span class="ui pink basic label" v-else-if="scope.obj.status === 'accepted'">{{ $t('Accepted') }}</span>
<span class="ui yellow basic label" v-else-if="scope.obj.status === 'pending'">{{ $t('Pending') }}</span>
<span class="ui red basic label" v-else-if="scope.obj.status === 'closed'">{{ $t('Closed') }}</span>
</td>
<td>
<span :title="scope.obj.artist_name">{{ scope.obj.artist_name|truncate(30) }}</span>
</td>
<td>
<span v-if="scope.obj.albums" :title="scope.obj.albums">{{ scope.obj.albums|truncate(30) }}</span>
<template v-else>{{ $t('N/A') }}</template>
</td>
<td>
<span v-if="scope.obj.comment" :title="scope.obj.comment">{{ scope.obj.comment|truncate(30) }}</span>
<template v-else>{{ $t('N/A') }}</template>
</td>
<td>
<human-date :date="scope.obj.creation_date"></human-date>
</td>
<td>
<human-date v-if="scope.obj.imported_date" :date="scope.obj.creation_date"></human-date>
<template v-else>{{ $t('N/A') }}</template>
</td>
<td>
<router-link
class="ui tiny basic button"
:to="{name: 'library.import.launch', query: {request: scope.obj.id}}"
v-if="scope.obj.status === 'pending'">{{ $t('Create import') }}</router-link>
</td>
</template>
</action-table>
</div>
<div>
<pagination
v-if="result && result.results.length > 0"
@page-changed="selectPage"
:compact="true"
:current="page"
:paginate-by="paginateBy"
:total="result.count"
></pagination>
<span v-if="result && result.results.length > 0">
{{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}}
</span>
</div>
</div>
</template>
<script>
import axios from 'axios'
import _ from 'lodash'
import time from '@/utils/time'
import Pagination from '@/components/Pagination'
import ActionTable from '@/components/common/ActionTable'
import OrderingMixin from '@/components/mixins/Ordering'
export default {
mixins: [OrderingMixin],
props: {
filters: {type: Object, required: false}
},
components: {
Pagination,
ActionTable
},
data () {
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
return {
time,
isLoading: false,
result: null,
page: 1,
paginateBy: 25,
search: '',
status: null,
orderingDirection: defaultOrdering.direction || '+',
ordering: defaultOrdering.field,
orderingOptions: [
['creation_date', 'Creation date'],
['imported_date', 'Imported date']
]
}
},
created () {
this.fetchData()
},
methods: {
fetchData () {
let params = _.merge({
'page': this.page,
'page_size': this.paginateBy,
'q': this.search,
'status': this.status,
'ordering': this.getOrderingAsString()
}, this.filters)
let self = this
self.isLoading = true
self.checked = []
axios.get('/manage/requests/import-requests/', {params: params}).then((response) => {
self.result = response.data
self.isLoading = false
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
selectPage: function (page) {
this.page = page
}
},
computed: {
actionFilters () {
var currentFilters = {
q: this.search
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
} else {
return currentFilters
}
},
actions () {
return [
{
name: 'delete',
label: this.$t('Delete'),
isDangerous: true
},
{
name: 'mark_imported',
label: this.$t('Mark as imported'),
filterCheckable: (obj) => { return ['pending', 'accepted'].indexOf(obj.status) > -1 },
isDangerous: true
},
{
name: 'mark_closed',
label: this.$t('Mark as closed'),
filterCheckable: (obj) => { return ['pending', 'accepted'].indexOf(obj.status) > -1 },
isDangerous: true
}
]
}
},
watch: {
search (newValue) {
this.page = 1
this.fetchData()
},
page () {
this.fetchData()
},
ordering () {
this.page = 1
this.fetchData()
},
status () {
this.page = 1
this.fetchData()
},
orderingDirection () {
this.page = 1
this.fetchData()
}
}
}
</script>

View File

@ -0,0 +1,80 @@
<template>
<div>
<form class="ui form" @submit.prevent="submit">
<div v-if="errors.length > 0" class="ui negative message">
<div class="header">{{ $t('Error while creating invitation') }}</div>
<ul class="list">
<li v-for="error in errors">{{ error }}</li>
</ul>
</div>
<div class="inline fields">
<div class="ui field">
<label>{{ $t('Invitation code')}}</label>
<input type="text" v-model="code" :placeholder="$t('Leave empty for a random code')" />
</div>
<div class="ui field">
<button :class="['ui', {loading: isLoading}, 'button']" :disabled="isLoading" type="submit">
{{ $t('Get a new invitation') }}
</button>
</div>
</div>
</form>
<div v-if="invitations.length > 0">
<div class="ui hidden divider"></div>
<table class="ui ui basic table">
<thead>
<tr>
<th>{{ $t('Code') }}</th>
<th>{{ $t('Share link') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="invitation in invitations" :key="invitation.code">
<td>{{ invitation.code.toUpperCase() }}</td>
<td><a :href="getUrl(invitation.code)" target="_blank">{{ getUrl(invitation.code) }}</a></td>
</tr>
</tbody>
</table>
<button class="ui basic button" @click="invitations = []">{{ $t('Clear') }}</button>
</div>
</div>
</template>
<script>
import axios from 'axios'
export default {
data () {
return {
isLoading: false,
code: null,
invitations: [],
errors: []
}
},
methods: {
submit () {
let self = this
this.isLoading = true
this.errors = []
let url = 'manage/users/invitations/'
let payload = {
code: this.code
}
axios.post(url, payload).then((response) => {
self.isLoading = false
self.invitations.unshift(response.data)
}, (error) => {
self.isLoading = false
self.errors = error.backendErrors
})
},
getUrl (code) {
return this.$store.getters['instance/absoluteUrl'](this.$router.resolve({name: 'signup', query: {invitation: code.toUpperCase()}}).href)
}
}
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,191 @@
<template>
<div>
<div class="ui inline form">
<div class="fields">
<div class="ui field">
<label>{{ $t('Search') }}</label>
<input type="text" v-model="search" placeholder="Search by username, email, code..." />
</div>
<div class="field">
<label>{{ $t("Ordering") }}</label>
<select class="ui dropdown" v-model="ordering">
<option v-for="option in orderingOptions" :value="option[0]">
{{ option[1] }}
</option>
</select>
</div>
<div class="field">
<label>{{ $t("Status") }}</label>
<select class="ui dropdown" v-model="isOpen">
<option :value="null">{{ $t('All') }}</option>
<option :value="true">{{ $t('Open') }}</option>
<option :value="false">{{ $t('Expired/used') }}</option>
</select>
</div>
</div>
</div>
<div class="dimmable">
<div v-if="isLoading" class="ui active inverted dimmer">
<div class="ui loader"></div>
</div>
<action-table
v-if="result"
@action-launched="fetchData"
:objects-data="result"
:actions="actions"
:action-url="'manage/users/invitations/action/'"
:filters="actionFilters">
<template slot="header-cells">
<th>{{ $t('Owner') }}</th>
<th>{{ $t('Status') }}</th>
<th>{{ $t('Creation date') }}</th>
<th>{{ $t('Expiration date') }}</th>
<th>{{ $t('Code') }}</th>
</template>
<template slot="row-cells" slot-scope="scope">
<td>
<router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.owner.username }}</router-link>
</td>
<td>
<span v-if="scope.obj.users.length > 0" class="ui green basic label">{{ $t('Used') }}</span>
<span v-else-if="moment().isAfter(scope.obj.expiration_date)" class="ui red basic label">{{ $t('Expired') }}</span>
<span v-else class="ui basic label">{{ $t('Not used') }}</span>
</td>
<td>
<human-date :date="scope.obj.creation_date"></human-date>
</td>
<td>
<human-date :date="scope.obj.expiration_date"></human-date>
</td>
<td>
{{ scope.obj.code.toUpperCase() }}
</td>
</template>
</action-table>
</div>
<div>
<pagination
v-if="result && result.results.length > 0"
@page-changed="selectPage"
:compact="true"
:current="page"
:paginate-by="paginateBy"
:total="result.count"
></pagination>
<span v-if="result && result.results.length > 0">
{{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}}
</span>
</div>
</div>
</template>
<script>
import axios from 'axios'
import moment from 'moment'
import _ from 'lodash'
import Pagination from '@/components/Pagination'
import ActionTable from '@/components/common/ActionTable'
import OrderingMixin from '@/components/mixins/Ordering'
export default {
mixins: [OrderingMixin],
props: {
filters: {type: Object, required: false}
},
components: {
Pagination,
ActionTable
},
data () {
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
return {
moment,
isLoading: false,
result: null,
page: 1,
paginateBy: 50,
search: '',
isOpen: null,
orderingDirection: defaultOrdering.direction || '+',
ordering: defaultOrdering.field,
orderingOptions: [
['expiration_date', 'Expiration date'],
['creation_date', 'Creation date']
]
}
},
created () {
this.fetchData()
},
methods: {
fetchData () {
let params = _.merge({
'page': this.page,
'page_size': this.paginateBy,
'q': this.search,
'is_open': this.isOpen,
'ordering': this.getOrderingAsString()
}, this.filters)
let self = this
self.isLoading = true
self.checked = []
axios.get('/manage/users/invitations/', {params: params}).then((response) => {
self.result = response.data
self.isLoading = false
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
selectPage: function (page) {
this.page = page
}
},
computed: {
actionFilters () {
var currentFilters = {
q: this.search
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
} else {
return currentFilters
}
},
actions () {
return [
{
name: 'delete',
label: this.$t('Delete'),
filterCheckable: (obj) => {
return obj.users.length === 0 && moment().isBefore(obj.expiration_date)
}
}
]
}
},
watch: {
search (newValue) {
this.page = 1
this.fetchData()
},
page () {
this.fetchData()
},
ordering () {
this.page = 1
this.fetchData()
},
isOpen () {
this.page = 1
this.fetchData()
},
orderingDirection () {
this.page = 1
this.fetchData()
}
}
}
</script>

View File

@ -0,0 +1,216 @@
<template>
<div>
<div class="ui inline form">
<div class="fields">
<div class="ui field">
<label>{{ $t('Search') }}</label>
<input type="text" v-model="search" placeholder="Search by username, email, name..." />
</div>
<div class="field">
<i18next tag="label" path="Ordering"/>
<select class="ui dropdown" v-model="ordering">
<option v-for="option in orderingOptions" :value="option[0]">
{{ option[1] }}
</option>
</select>
</div>
<div class="field">
<i18next tag="label" path="Ordering direction"/>
<select class="ui dropdown" v-model="orderingDirection">
<option value="+">{{ $t('Ascending') }}</option>
<option value="-">{{ $t('Descending') }}</option>
</select>
</div>
</div>
</div>
<div class="dimmable">
<div v-if="isLoading" class="ui active inverted dimmer">
<div class="ui loader"></div>
</div>
<action-table
v-if="result"
@action-launched="fetchData"
:objects-data="result"
:actions="actions"
:action-url="'manage/library/track-files/action/'"
:filters="actionFilters">
<template slot="header-cells">
<th>{{ $t('Username') }}</th>
<th>{{ $t('Email') }}</th>
<th>{{ $t('Account status') }}</th>
<th>{{ $t('Sign-up') }}</th>
<th>{{ $t('Last activity') }}</th>
<th>{{ $t('Permissions') }}</th>
<th>{{ $t('Status') }}</th>
</template>
<template slot="row-cells" slot-scope="scope">
<td>
<router-link :to="{name: 'manage.users.users.detail', params: {id: scope.obj.id }}">{{ scope.obj.username }}</router-link>
</td>
<td>
<span>{{ scope.obj.email }}</span>
</td>
<td>
<span v-if="scope.obj.is_active" class="ui basic green label">{{ $t('Active') }}</span>
<span v-else class="ui basic grey label">{{ $t('Inactive') }}</span>
</td>
<td>
<human-date :date="scope.obj.date_joined"></human-date>
</td>
<td>
<human-date v-if="scope.obj.last_activity" :date="scope.obj.last_activity"></human-date>
<template v-else>{{ $t('N/A') }}</template>
</td>
<td>
<template v-for="p in permissions">
<span class="ui basic tiny label" v-if="scope.obj.permissions[p.code]">{{ p.label }}</span>
</template>
</td>
<td>
<span v-if="scope.obj.is_superuser" class="ui pink label">{{ $t('Admin') }}</span>
<span v-else-if="scope.obj.is_staff" class="ui purple label">{{ $t('Staff member') }}</span>
<span v-else class="ui basic label">{{ $t('regular user') }}</span>
</td>
</template>
</action-table>
</div>
<div>
<pagination
v-if="result && result.results.length > 0"
@page-changed="selectPage"
:compact="true"
:current="page"
:paginate-by="paginateBy"
:total="result.count"
></pagination>
<span v-if="result && result.results.length > 0">
{{ $t('Showing results {%start%}-{%end%} on {%total%}', {start: ((page-1) * paginateBy) + 1 , end: ((page-1) * paginateBy) + result.results.length, total: result.count})}}
</span>
</div>
</div>
</template>
<script>
import axios from 'axios'
import _ from 'lodash'
import time from '@/utils/time'
import Pagination from '@/components/Pagination'
import ActionTable from '@/components/common/ActionTable'
import OrderingMixin from '@/components/mixins/Ordering'
export default {
mixins: [OrderingMixin],
props: {
filters: {type: Object, required: false}
},
components: {
Pagination,
ActionTable
},
data () {
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-date_joined')
return {
time,
isLoading: false,
result: null,
page: 1,
paginateBy: 50,
search: '',
orderingDirection: defaultOrdering.direction || '+',
ordering: defaultOrdering.field,
orderingOptions: [
['date_joined', 'Sign-up date'],
['last_activity', 'Last activity'],
['username', 'Username']
]
}
},
created () {
this.fetchData()
},
methods: {
fetchData () {
let params = _.merge({
'page': this.page,
'page_size': this.paginateBy,
'q': this.search,
'ordering': this.getOrderingAsString()
}, this.filters)
let self = this
self.isLoading = true
self.checked = []
axios.get('/manage/users/users/', {params: params}).then((response) => {
self.result = response.data
self.isLoading = false
}, error => {
self.isLoading = false
self.errors = error.backendErrors
})
},
selectPage: function (page) {
this.page = page
}
},
computed: {
privacyLevels () {
return {}
},
permissions () {
return [
{
'code': 'upload',
'label': this.$t('Upload')
},
{
'code': 'library',
'label': this.$t('Library')
},
{
'code': 'federation',
'label': this.$t('Federation')
},
{
'code': 'settings',
'label': this.$t('Settings')
}
]
},
actionFilters () {
var currentFilters = {
q: this.search
}
if (this.filters) {
return _.merge(currentFilters, this.filters)
} else {
return currentFilters
}
},
actions () {
return [
// {
// name: 'delete',
// label: this.$t('Delete'),
// isDangerous: true
// }
]
}
},
watch: {
search (newValue) {
this.page = 1
this.fetchData()
},
page () {
this.fetchData()
},
ordering () {
this.fetchData()
},
orderingDirection () {
this.fetchData()
}
}
}
</script>

View File

@ -22,7 +22,6 @@
<script>
import jQuery from 'jquery'
import config from '@/config'
export default {
props: {
@ -117,7 +116,7 @@ export default {
})[0]
},
searchUrl: function () {
return config.API_URL + 'providers/musicbrainz/search/' + this.currentTypeObject.value + 's/?query={query}'
return this.$store.getters['instance/absoluteUrl']('api/v1/providers/musicbrainz/search/' + this.currentTypeObject.value + 's/?query={query}')
},
types: function () {
return [

View File

@ -2,9 +2,12 @@
<div class="ui card">
<div class="content">
<div class="header">
<router-link class="discrete link" :to="{name: 'library.radios.detail', params: {id: radio.id}}">
<router-link v-if="radio.id" class="discrete link" :to="{name: 'library.radios.detail', params: {id: radio.id}}">
{{ radio.name }}
</router-link>
<template v-else>
{{ radio.name }}
</template>
</div>
<div class="description">
{{ radio.description }}

View File

@ -1,198 +0,0 @@
<template>
<div v-title="'Import Requests'">
<div class="ui vertical stripe segment">
<h2 class="ui header">{{ $t('Music requests') }}</h2>
<div :class="['ui', {'loading': isLoading}, 'form']">
<div class="fields">
<div class="field">
<label>{{ $t('Search') }}</label>
<input type="text" v-model="query" placeholder="Enter an artist name, a username..."/>
</div>
<div class="field">
<label>{{ $t('Status') }}</label>
<select class="ui dropdown" v-model="status">
<option :value="'any'">{{ $t('Any') }}</option>
<option :value="'pending'">{{ $t('Pending') }}</option>
<option :value="'accepted'">{{ $t('Accepted') }}</option>
<option :value="'imported'">{{ $t('Imported') }}</option>
<option :value="'closed'">{{ $t('Closed') }}</option>
</select>
</div>
<div class="field">
<label>{{ $t('Ordering') }}</label>
<select class="ui dropdown" v-model="ordering">
<option v-for="option in orderingOptions" :value="option[0]">
{{ option[1] }}
</option>
</select>
</div>
<div class="field">
<label>{{ $t('Ordering direction') }}</label>
<select class="ui dropdown" v-model="orderingDirection">
<option value="+">Ascending</option>
<option value="-">Descending</option>
</select>
</div>
<div class="field">
<label>{{ $t('Results per page') }}</label>
<select class="ui dropdown" v-model="paginateBy">
<option :value="parseInt(12)">12</option>
<option :value="parseInt(25)">25</option>
<option :value="parseInt(50)">50</option>
</select>
</div>
</div>
</div>
<div class="ui hidden divider"></div>
<div
v-if="result"
v-masonry
transition-duration="0"
item-selector=".column"
percent-position="true"
stagger="0"
class="ui stackable three column doubling grid">
<div
v-masonry-tile
v-if="result.results.length > 0"
v-for="request in result.results"
:key="request.id"
class="column">
<request-card class="fluid" :request="request"></request-card>
</div>
</div>
<div class="ui center aligned basic segment">
<pagination
v-if="result && result.results.length > 0"
@page-changed="selectPage"
:current="page"
:paginate-by="paginateBy"
:total="result.count"
></pagination>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
import _ from 'lodash'
import $ from 'jquery'
import logger from '@/logging'
import OrderingMixin from '@/components/mixins/Ordering'
import PaginationMixin from '@/components/mixins/Pagination'
import RequestCard from '@/components/requests/Card'
import Pagination from '@/components/Pagination'
const FETCH_URL = 'requests/import-requests/'
export default {
mixins: [OrderingMixin, PaginationMixin],
props: {
defaultQuery: {type: String, required: false, default: ''},
defaultStatus: {required: false, default: 'any'}
},
components: {
RequestCard,
Pagination
},
data () {
let defaultOrdering = this.getOrderingFromString(this.defaultOrdering || '-creation_date')
return {
isLoading: true,
result: null,
page: parseInt(this.defaultPage),
query: this.defaultQuery,
paginateBy: parseInt(this.defaultPaginateBy || 12),
orderingDirection: defaultOrdering.direction || '+',
ordering: defaultOrdering.field,
status: this.defaultStatus || 'any'
}
},
created () {
this.fetchData()
},
mounted () {
$('.ui.dropdown').dropdown()
},
methods: {
updateQueryString: _.debounce(function () {
let query = {
query: {
query: this.query,
page: this.page,
paginateBy: this.paginateBy,
ordering: this.getOrderingAsString()
}
}
if (this.status !== 'any') {
query.query.status = this.status
}
this.$router.replace(query)
}, 500),
fetchData: _.debounce(function () {
var self = this
this.isLoading = true
let url = FETCH_URL
let params = {
page: this.page,
page_size: this.paginateBy,
q: this.query,
ordering: this.getOrderingAsString()
}
if (this.status !== 'any') {
params.status = this.status
}
logger.default.debug('Fetching request...')
axios.get(url, {params: params}).then((response) => {
self.result = response.data
self.isLoading = false
})
}, 500),
selectPage: function (page) {
this.page = page
}
},
computed: {
orderingOptions: function () {
return [
['creation_date', this.$t('Creation date')],
['artist_name', this.$t('Artist name')],
['user__username', this.$t('User')]
]
}
},
watch: {
page () {
this.updateQueryString()
this.fetchData()
},
paginateBy () {
this.updateQueryString()
this.fetchData()
},
ordering () {
this.updateQueryString()
this.fetchData()
},
orderingDirection () {
this.updateQueryString()
this.fetchData()
},
query () {
this.updateQueryString()
this.fetchData()
},
status () {
this.updateQueryString()
this.fetchData()
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -1,8 +0,0 @@
class Config {
constructor () {
this.BACKEND_URL = process.env.BACKEND_URL
this.API_URL = this.BACKEND_URL + 'api/v1/'
}
}
export default new Config()

View File

@ -15,7 +15,6 @@ import i18next from 'i18next'
import i18nextFetch from 'i18next-fetch-backend'
import VueI18Next from '@panter/vue-i18next'
import store from './store'
import config from './config'
import { sync } from 'vuex-router-sync'
import filters from '@/filters' // eslint-disable-line
import globals from '@/components/globals' // eslint-disable-line
@ -56,8 +55,6 @@ Vue.directive('title', {
document.title = parts.join(' - ')
}
})
axios.defaults.baseURL = config.API_URL
axios.interceptors.request.use(function (config) {
// Do something before request is sent
if (store.state.auth.token) {
@ -86,11 +83,15 @@ axios.interceptors.response.use(function (response) {
} else if (error.response.status === 500) {
error.backendErrors.push('A server error occured')
} else if (error.response.data) {
for (var field in error.response.data) {
if (error.response.data.hasOwnProperty(field)) {
error.response.data[field].forEach(e => {
error.backendErrors.push(e)
})
if (error.response.data.detail) {
error.backendErrors.push(error.response.data.detail)
} else {
for (var field in error.response.data) {
if (error.response.data.hasOwnProperty(field)) {
error.response.data[field].forEach(e => {
error.backendErrors.push(e)
})
}
}
}
}
@ -100,7 +101,6 @@ axios.interceptors.response.use(function (response) {
// Do something with response error
return Promise.reject(error)
})
store.dispatch('auth/check')
// i18n
i18next

View File

@ -24,13 +24,17 @@ import RadioBuilder from '@/components/library/radios/Builder'
import RadioDetail from '@/views/radios/Detail'
import BatchList from '@/components/library/import/BatchList'
import BatchDetail from '@/components/library/import/BatchDetail'
import RequestsList from '@/components/requests/RequestsList'
import PlaylistDetail from '@/views/playlists/Detail'
import PlaylistList from '@/views/playlists/List'
import Favorites from '@/components/favorites/List'
import AdminSettings from '@/views/admin/Settings'
import AdminLibraryBase from '@/views/admin/library/Base'
import AdminLibraryFilesList from '@/views/admin/library/FilesList'
import AdminLibraryRequestsList from '@/views/admin/library/RequestsList'
import AdminUsersBase from '@/views/admin/users/Base'
import AdminUsersDetail from '@/views/admin/users/UsersDetail'
import AdminUsersList from '@/views/admin/users/UsersList'
import AdminInvitationsList from '@/views/admin/users/InvitationsList'
import FederationBase from '@/views/federation/Base'
import FederationScan from '@/views/federation/Scan'
import FederationLibraryDetail from '@/views/federation/LibraryDetail'
@ -93,7 +97,10 @@ export default new Router({
{
path: '/signup',
name: 'signup',
component: Signup
component: Signup,
props: (route) => ({
invitation: route.query.invitation
})
},
{
path: '/logout',
@ -177,6 +184,33 @@ export default new Router({
path: 'files',
name: 'manage.library.files',
component: AdminLibraryFilesList
},
{
path: 'requests',
name: 'manage.library.requests',
component: AdminLibraryRequestsList
}
]
},
{
path: '/manage/users',
component: AdminUsersBase,
children: [
{
path: 'users',
name: 'manage.users.users.list',
component: AdminUsersList
},
{
path: 'users/:id',
name: 'manage.users.users.detail',
component: AdminUsersDetail,
props: true
},
{
path: 'invitations',
name: 'manage.users.invitations.list',
component: AdminInvitationsList
}
]
},
@ -249,21 +283,7 @@ export default new Router({
children: [
]
},
{ path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true },
{
path: 'requests/',
name: 'library.requests',
component: RequestsList,
props: (route) => ({
defaultOrdering: route.query.ordering,
defaultQuery: route.query.query,
defaultPaginateBy: route.query.paginateBy,
defaultPage: route.query.page,
defaultStatus: route.query.status || 'any'
}),
children: [
]
}
{ path: 'import/batches/:id', name: 'library.import.batches.detail', component: BatchDetail, props: true }
]
},
{ path: '*', component: PageNotFound }

View File

@ -34,7 +34,7 @@ export default new Vuex.Store({
}),
createPersistedState({
key: 'instance',
paths: ['instance.events']
paths: ['instance.events', 'instance.instanceUrl']
}),
createPersistedState({
key: 'radios',

View File

@ -6,6 +6,7 @@ export default {
namespaced: true,
state: {
maxEvents: 200,
instanceUrl: process.env.INSTANCE_URL,
events: [],
settings: {
instance: {
@ -51,9 +52,46 @@ export default {
},
events: (state, value) => {
state.events = value
},
instanceUrl: (state, value) => {
if (value && !value.endsWith('/')) {
value = value + '/'
}
state.instanceUrl = value
if (!value) {
axios.defaults.baseURL = null
return
}
let suffix = 'api/v1/'
axios.defaults.baseURL = state.instanceUrl + suffix
}
},
getters: {
absoluteUrl: (state) => (relativeUrl) => {
if (relativeUrl.startsWith('http')) {
return relativeUrl
}
if (state.instanceUrl.endsWith('/') && relativeUrl.startsWith('/')) {
relativeUrl = relativeUrl.slice(1)
}
return state.instanceUrl + relativeUrl
}
},
actions: {
setUrl ({commit, dispatch}, url) {
commit('instanceUrl', url)
let modules = [
'auth',
'favorites',
'player',
'playlists',
'queue',
'radios'
]
modules.forEach(m => {
commit(`${m}/reset`, null, {root: true})
})
},
// Send a request to the login URL and save the returned JWT
fetchSettings ({commit}, payload) {
return axios.get('instance/settings/').then(response => {

View File

@ -31,9 +31,10 @@ export default {
insert (state, {track, index}) {
state.tracks.splice(index, 0, track)
},
reorder (state, {oldIndex, newIndex}) {
reorder (state, {tracks, oldIndex, newIndex}) {
// called when the user uses drag / drop to reorder
// tracks in queue
state.tracks = tracks
if (oldIndex === state.currentIndex) {
state.currentIndex = newIndex
return
@ -102,7 +103,7 @@ export default {
}
if (current) {
// we play next track, which now have the same index
dispatch('currentIndex', index)
commit('currentIndex', index)
}
if (state.currentIndex + 1 === state.tracks.length) {
dispatch('radios/populateQueue', null, {root: true})
@ -156,7 +157,6 @@ export default {
let toKeep = state.tracks.slice(0, state.currentIndex + 1)
let toShuffle = state.tracks.slice(state.currentIndex + 1)
let shuffled = toKeep.concat(_.shuffle(toShuffle))
commit('player/currentTime', 0, {root: true})
commit('tracks', [])
let params = {tracks: shuffled}
if (callback) {

View File

@ -1,3 +1,4 @@
import axios from 'axios'
export default {
namespaced: true,
@ -5,7 +6,11 @@ export default {
lastDate: new Date(),
maxMessages: 100,
messageDisplayDuration: 10000,
messages: []
messages: [],
notifications: {
federation: 0,
importRequests: 0
}
},
mutations: {
computeLastDate: (state) => {
@ -16,6 +21,27 @@ export default {
if (state.messages.length > state.maxMessages) {
state.messages.shift()
}
},
notifications (state, {type, count}) {
state.notifications[type] = count
}
},
actions: {
fetchFederationNotificationsCount ({rootState, commit}) {
if (!rootState.auth.availablePermissions['federation']) {
return
}
axios.get('federation/libraries/followers/', {params: {pending: true}}).then(response => {
commit('notifications', {type: 'federation', count: response.data.count})
})
},
fetchImportRequestsCount ({rootState, commit}) {
if (!rootState.auth.availablePermissions['library']) {
return
}
axios.get('requests/import-requests/', {params: {status: 'pending'}}).then(response => {
commit('notifications', {type: 'importRequests', count: response.data.count})
})
}
}
}

View File

@ -4,6 +4,15 @@
<router-link
class="ui item"
:to="{name: 'manage.library.files'}">{{ $t('Files') }}</router-link>
<router-link
class="ui item"
:to="{name: 'manage.library.requests'}">
{{ $t('Import requests') }}
<div
:class="['ui', {'teal': $store.state.ui.notifications.importRequests > 0}, 'label']"
:title="$t('Pending import requests')">
{{ $store.state.ui.notifications.importRequests }}</div>
</router-link>
</div>
<router-view :key="$route.fullPath"></router-view>
</div>

View File

@ -0,0 +1,23 @@
<template>
<div v-title="$t('Import requests')">
<div class="ui vertical stripe segment">
<h2 class="ui header">{{ $t('Import requests') }}</h2>
<div class="ui hidden divider"></div>
<library-requests-table></library-requests-table>
</div>
</div>
</template>
<script>
import LibraryRequestsTable from '@/components/manage/library/RequestsTable'
export default {
components: {
LibraryRequestsTable
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -0,0 +1,31 @@
<template>
<div class="main pusher" v-title="$t('Manage users')">
<div class="ui secondary pointing menu">
<router-link
class="ui item"
:to="{name: 'manage.users.users.list'}">{{ $t('Users') }}</router-link>
<router-link
class="ui item"
:to="{name: 'manage.users.invitations.list'}">{{ $t('Invitations') }}</router-link>
</div>
<router-view :key="$route.fullPath"></router-view>
</div>
</template>
<script>
export default {}
</script>
<style lang="scss">
@import '../../../style/vendor/media';
.main.pusher > .ui.secondary.menu {
@include media(">tablet") {
margin: 0 2.5rem;
}
.item {
padding-top: 1.5em;
padding-bottom: 1.5em;
}
}
</style>

View File

@ -0,0 +1,26 @@
<template>
<div v-title="$t('Invitations')">
<div class="ui vertical stripe segment">
<h2 class="ui header">{{ $t('Invitations') }}</h2>
<invitation-form></invitation-form>
<div class="ui hidden divider"></div>
<invitations-table></invitations-table>
</div>
</div>
</template>
<script>
import InvitationForm from '@/components/manage/users/InvitationForm'
import InvitationsTable from '@/components/manage/users/InvitationsTable'
export default {
components: {
InvitationForm,
InvitationsTable
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -0,0 +1,177 @@
<template>
<div>
<div v-if="isLoading" class="ui vertical segment">
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
</div>
<template v-if="object">
<div :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']" v-title="object.username">
<div class="segment-content">
<h2 class="ui center aligned icon header">
<i class="circular inverted user red icon"></i>
<div class="content">
@{{ object.username }}
</div>
</h2>
</div>
<div class="ui hidden divider"></div>
<div class="ui one column centered grid">
<table class="ui collapsing very basic table">
<tbody>
<tr>
<td>
{{ $t('Name') }}
</td>
<td>
{{ object.name }}
</td>
</tr>
<tr>
<td>
{{ $t('Email address') }}
</td>
<td>
{{ object.email }}
</td>
</tr>
<tr>
<td>
{{ $t('Sign-up') }}
</td>
<td>
<human-date :date="object.date_joined"></human-date>
</td>
</tr>
<tr>
<td>
{{ $t('Last activity') }}
</td>
<td>
<human-date v-if="object.last_activity" :date="object.last_activity"></human-date>
<template v-else>{{ $t('N/A') }}</template>
</td>
</tr>
<tr>
<td>
{{ $t('Account active') }}
<span :data-tooltip="$t('Determine if the user account is active or not. Inactive users cannot login or user the service.')"><i class="question circle icon"></i></span>
</td>
<td>
<div class="ui toggle checkbox">
<input
@change="update('is_active')"
v-model="object.is_active" type="checkbox">
<label></label>
</div>
</td>
</tr>
<tr>
<td>
{{ $t('Permissions') }}
</td>
<td>
<select
@change="update('permissions')"
v-model="permissions"
multiple
class="ui search selection dropdown">
<option v-for="p in allPermissions" :value="p.code">{{ p.label }}</option>
</select>
</td>
</tr>
</tbody>
</table>
</div>
<div class="ui hidden divider"></div>
<button @click="fetchData" class="ui basic button">{{ $t('Refresh') }}</button>
</div>
</template>
</div>
</template>
<script>
import $ from 'jquery'
import axios from 'axios'
import logger from '@/logging'
export default {
props: ['id'],
data () {
return {
isLoading: true,
object: null,
permissions: []
}
},
created () {
this.fetchData()
},
methods: {
fetchData () {
var self = this
this.isLoading = true
let url = 'manage/users/users/' + this.id + '/'
axios.get(url).then((response) => {
self.object = response.data
self.permissions = []
self.allPermissions.forEach(p => {
if (self.object.permissions[p.code]) {
self.permissions.push(p.code)
}
})
self.isLoading = false
})
},
update (attr) {
let newValue = this.object[attr]
let params = {}
if (attr === 'permissions') {
params['permissions'] = {}
this.allPermissions.forEach(p => {
params['permissions'][p.code] = this.permissions.indexOf(p.code) > -1
})
} else {
params[attr] = newValue
}
axios.patch('manage/users/users/' + this.id + '/', params).then((response) => {
logger.default.info(`${attr} was updated succcessfully to ${newValue}`)
}, (error) => {
logger.default.error(`Error while setting ${attr} to ${newValue}`, error)
})
}
},
computed: {
allPermissions () {
return [
{
'code': 'upload',
'label': this.$t('Upload')
},
{
'code': 'library',
'label': this.$t('Library')
},
{
'code': 'federation',
'label': this.$t('Federation')
},
{
'code': 'settings',
'label': this.$t('Settings')
}
]
}
},
watch: {
object () {
this.$nextTick(() => {
$('select.dropdown').dropdown()
})
}
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -0,0 +1,23 @@
<template>
<div v-title="$t('Users')">
<div class="ui vertical stripe segment">
<h2 class="ui header">{{ $t('Users') }}</h2>
<div class="ui hidden divider"></div>
<users-table></users-table>
</div>
</div>
</template>
<script>
import UsersTable from '@/components/manage/users/UsersTable'
export default {
components: {
UsersTable
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

View File

@ -78,8 +78,11 @@ export default {
// let token = 'test'
const bridge = new WebSocketBridge()
this.bridge = bridge
let url = this.$store.getters['instance/absoluteUrl'](`api/v1/instance/activity?token=${token}`)
url = url.replace('http://', 'ws://')
url = url.replace('https://', 'wss://')
bridge.connect(
`/api/v1/instance/activity?token=${token}`,
url,
null,
{reconnectInterval: 5000})
bridge.listen(function (event) {

View File

@ -93,7 +93,7 @@ export default {
let url = 'playlists/' + this.id + '/'
axios.get(url).then((response) => {
self.playlist = response.data
axios.get(url + 'tracks').then((response) => {
axios.get(url + 'tracks/').then((response) => {
self.updatePlts(response.data.results)
}).then(() => {
self.isLoading = false

View File

@ -169,11 +169,11 @@ describe('store/queue', () => {
payload: 2,
params: {state: {currentIndex: 2}},
expectedMutations: [
{ type: 'splice', payload: {start: 2, size: 1} }
{ type: 'splice', payload: {start: 2, size: 1} },
{ type: 'currentIndex', payload: 2 }
],
expectedActions: [
{ type: 'player/stop', payload: null, options: {root: true} },
{ type: 'currentIndex', payload: 2 }
{ type: 'player/stop', payload: null, options: {root: true} }
]
}, done)
})
@ -324,7 +324,6 @@ describe('store/queue', () => {
action: store.actions.shuffle,
params: {state: {currentIndex: 1, tracks: tracks}},
expectedMutations: [
{ type: 'player/currentTime', payload: 0, options: {root: true} },
{ type: 'tracks', payload: [] }
],
expectedActions: [

View File

@ -1,7 +1,5 @@
import store from '@/store/ui'
import { testAction } from '../../utils'
describe('store/ui', () => {
describe('mutations', () => {
it('addMessage', () => {