Merge branch 'channels' into 'develop'

Channels and instance timeline

See merge request funkwhale/funkwhale!74
This commit is contained in:
Eliot Berriot 2018-03-04 14:05:22 +00:00
commit 1bbfa8f70b
76 changed files with 1263 additions and 63 deletions

View File

@ -22,7 +22,7 @@ test_api:
variables:
DJANGO_ALLOWED_HOSTS: "localhost"
DATABASE_URL: "postgresql://postgres@postgres/postgres"
FUNKWHALE_URL: "https://funkwhale.ci"
before_script:
- cd api
- pip install -r requirements/base.txt

View File

@ -5,7 +5,11 @@ ENV PYTHONUNBUFFERED 1
# Requirements have to be pulled and installed here, otherwise caching won't work
RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list
COPY ./requirements.apt /requirements.apt
RUN apt-get update -qq && grep "^[^#;]" requirements.apt | xargs apt-get install -y
RUN apt-get update; \
grep "^[^#;]" requirements.apt | \
grep -Fv "python3-dev" | \
xargs apt-get install -y --no-install-recommends; \
rm -rf /usr/share/doc/* /usr/share/locale/*
RUN curl -L https://github.com/acoustid/chromaprint/releases/download/v1.4.2/chromaprint-fpcalc-1.4.2-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin --strip 1
COPY ./requirements/base.txt /requirements/base.txt
RUN pip install -r /requirements/base.txt
@ -20,3 +24,4 @@ RUN pip install --upgrade youtube-dl
WORKDIR /app
ENTRYPOINT ["./compose/django/entrypoint.sh"]
CMD ["./compose/django/daphne.sh"]

3
api/compose/django/daphne.sh Executable file
View File

@ -0,0 +1,3 @@
#!/bin/bash -eux
python /app/manage.py collectstatic --noinput
/usr/local/bin/daphne --root-path=/app -b 0.0.0.0 -p 5000 config.asgi:application

View File

@ -1,3 +0,0 @@
#!/bin/sh
python /app/manage.py collectstatic --noinput
/usr/local/bin/gunicorn config.wsgi -w 4 -b 0.0.0.0:5000 --chdir=/app

8
api/config/asgi.py Normal file
View File

@ -0,0 +1,8 @@
import django
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.production")
django.setup()
from .routing import application

18
api/config/routing.py Normal file
View File

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

View File

@ -25,11 +25,12 @@ except FileNotFoundError:
pass
ALLOWED_HOSTS = env.list('DJANGO_ALLOWED_HOSTS')
FUNKWHALE_URL = env('FUNKWHALE_URL')
# APP CONFIGURATION
# ------------------------------------------------------------------------------
DJANGO_APPS = (
'channels',
# Default Django apps:
'django.contrib.auth',
'django.contrib.contenttypes',
@ -82,6 +83,7 @@ if RAVEN_ENABLED:
# Apps specific for this project go here.
LOCAL_APPS = (
'funkwhale_api.common',
'funkwhale_api.activity.apps.ActivityConfig',
'funkwhale_api.users', # custom users app
# Your stuff: custom apps go here
'funkwhale_api.instance',
@ -253,9 +255,9 @@ MEDIA_URL = env("MEDIA_URL", default='/media/')
# URL Configuration
# ------------------------------------------------------------------------------
ROOT_URLCONF = 'config.urls'
# See: https://docs.djangoproject.com/en/dev/ref/settings/#wsgi-application
WSGI_APPLICATION = 'config.wsgi.application'
ASGI_APPLICATION = "config.routing.application"
# AUTHENTICATION CONFIGURATION
# ------------------------------------------------------------------------------
@ -284,6 +286,17 @@ CACHES = {
}
CACHES["default"]["BACKEND"] = "django_redis.cache.RedisCache"
from urllib.parse import urlparse
cache_url = urlparse(CACHES['default']['LOCATION'])
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels_redis.core.RedisChannelLayer",
"CONFIG": {
"hosts": [(cache_url.hostname, cache_url.port)],
},
},
}
CACHES["default"]["OPTIONS"] = {
"CLIENT_CLASS": "django_redis.client.DefaultClient",
"IGNORE_EXCEPTIONS": True, # mimics memcache behavior.

View File

@ -58,8 +58,6 @@ CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
# END SITE CONFIGURATION
INSTALLED_APPS += ("gunicorn", )
# STORAGE CONFIGURATION
# ------------------------------------------------------------------------------
# Uploaded Media Files

View File

@ -1,13 +1,16 @@
FROM python:3.5
ENV PYTHONUNBUFFERED 1
ENV PYTHONDONTWRITEBYTECODE 1
# Requirements have to be pulled and installed here, otherwise caching won't work
RUN echo 'deb http://httpredir.debian.org/debian/ jessie-backports main' > /etc/apt/sources.list.d/ffmpeg.list
COPY ./requirements.apt /requirements.apt
COPY ./install_os_dependencies.sh /install_os_dependencies.sh
RUN bash install_os_dependencies.sh install
RUN apt-get update; \
grep "^[^#;]" requirements.apt | \
grep -Fv "python3-dev" | \
xargs apt-get install -y --no-install-recommends; \
rm -rf /usr/share/doc/* /usr/share/locale/*
RUN curl -L https://github.com/acoustid/chromaprint/releases/download/v1.4.2/chromaprint-fpcalc-1.4.2-linux-x86_64.tar.gz | tar -xz -C /usr/local/bin --strip 1
RUN mkdir /requirements
@ -18,4 +21,5 @@ RUN pip install -r /requirements/local.txt
COPY ./requirements/test.txt /requirements/test.txt
RUN pip install -r /requirements/test.txt
COPY . /app
WORKDIR /app

View File

View File

@ -0,0 +1,12 @@
from django.apps import AppConfig, apps
from . import record
class ActivityConfig(AppConfig):
name = 'funkwhale_api.activity'
def ready(self):
super(ActivityConfig, self).ready()
app_names = [app.name for app in apps.app_configs.values()]
record.registry.autodiscover(app_names)

View File

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

View File

@ -0,0 +1,10 @@
from rest_framework import serializers
class ModelSerializer(serializers.ModelSerializer):
id = serializers.CharField(source='get_activity_url')
local_id = serializers.IntegerField(source='id')
# url = serializers.SerializerMethodField()
def get_url(self, obj):
return self.get_id(obj)

View File

@ -0,0 +1,47 @@
from urllib.parse import parse_qs
import jwt
from django.contrib.auth.models import AnonymousUser
from django.utils.encoding import smart_text
from rest_framework import exceptions
from rest_framework_jwt.settings import api_settings
from rest_framework_jwt.authentication import BaseJSONWebTokenAuthentication
class TokenHeaderAuth(BaseJSONWebTokenAuthentication):
def get_jwt_value(self, request):
try:
qs = request.get('query_string', b'').decode('utf-8')
parsed = parse_qs(qs)
token = parsed['token'][0]
except KeyError:
raise exceptions.AuthenticationFailed('No token')
if not token:
raise exceptions.AuthenticationFailed('Empty token')
return token
class TokenAuthMiddleware:
"""
Custom middleware (insecure) that takes user IDs from the query string.
"""
def __init__(self, inner):
# Store the ASGI application we were passed
self.inner = inner
def __call__(self, scope):
auth = TokenHeaderAuth()
try:
user, token = auth.authenticate(scope)
except exceptions.AuthenticationFailed:
user = AnonymousUser()
scope['user'] = user
return self.inner(scope)

View File

@ -0,0 +1,6 @@
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
channel_layer = get_channel_layer()
group_send = async_to_sync(channel_layer.group_send)
group_add = async_to_sync(channel_layer.group_add)

View File

@ -0,0 +1,17 @@
from channels.generic.websocket import JsonWebsocketConsumer
from funkwhale_api.common import channels
class JsonAuthConsumer(JsonWebsocketConsumer):
def connect(self):
try:
assert self.scope['user'].pk is not None
except (AssertionError, AttributeError, KeyError):
return self.close()
return self.accept()
def accept(self):
super().accept()
for group in self.groups:
channels.group_add(group, self.channel_name)

View File

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

View File

View File

@ -1,8 +1,10 @@
from django.conf import settings
from django.db import models
from django.utils import timezone
from funkwhale_api.music.models import Track
class TrackFavorite(models.Model):
creation_date = models.DateTimeField(default=timezone.now)
user = models.ForeignKey(
@ -18,3 +20,7 @@ class TrackFavorite(models.Model):
def add(cls, track, user):
favorite, created = cls.objects.get_or_create(user=user, track=track)
return favorite
def get_activity_url(self):
return '{}/favorites/tracks/{}'.format(
self.user.get_activity_url(), self.pk)

View File

@ -1,10 +1,39 @@
from django.conf import settings
from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.music.serializers import TrackSerializerNested
from funkwhale_api.music.serializers import TrackActivitySerializer
from funkwhale_api.users.serializers import UserActivitySerializer
from . import models
class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source='track')
actor = UserActivitySerializer(source='user')
published = serializers.DateTimeField(source='creation_date')
class Meta:
model = models.TrackFavorite
fields = [
'id',
'local_id',
'object',
'type',
'actor',
'published'
]
def get_actor(self, obj):
return UserActivitySerializer(obj.user).data
def get_type(self, obj):
return 'Like'
class UserTrackFavoriteSerializer(serializers.ModelSerializer):
# track = TrackSerializerNested(read_only=True)
class Meta:

View File

@ -4,6 +4,7 @@ from rest_framework.response import Response
from rest_framework import pagination
from rest_framework.decorators import list_route
from funkwhale_api.activity import record
from funkwhale_api.music.models import Track
from funkwhale_api.common.permissions import ConditionalAuthentication
@ -33,6 +34,7 @@ class TrackFavoriteViewSet(mixins.CreateModelMixin,
instance = self.perform_create(serializer)
serializer = self.get_serializer(instance=instance)
headers = self.get_success_headers(serializer.data)
record.send(instance)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def get_queryset(self):

View File

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

View File

@ -25,3 +25,8 @@ class Listening(models.Model):
raise ValidationError('Cannot have both session_key and user empty for listening')
super().save(**kwargs)
def get_activity_url(self):
return '{}/listenings/tracks/{}'.format(
self.user.get_activity_url(), self.pk)

View File

@ -1,9 +1,37 @@
from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.music.serializers import TrackSerializerNested
from funkwhale_api.music.serializers import TrackActivitySerializer
from funkwhale_api.users.serializers import UserActivitySerializer
from . import models
class ListeningActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source='track')
actor = UserActivitySerializer(source='user')
published = serializers.DateTimeField(source='end_date')
class Meta:
model = models.Listening
fields = [
'id',
'local_id',
'object',
'type',
'actor',
'published'
]
def get_actor(self, obj):
return UserActivitySerializer(obj.user).data
def get_type(self, obj):
return 'Listen'
class ListeningSerializer(serializers.ModelSerializer):
class Meta:

View File

@ -3,8 +3,9 @@ from rest_framework import status
from rest_framework.response import Response
from rest_framework.decorators import detail_route
from funkwhale_api.music.serializers import TrackSerializerNested
from funkwhale_api.activity import record
from funkwhale_api.common.permissions import ConditionalAuthentication
from funkwhale_api.music.serializers import TrackSerializerNested
from . import models
from . import serializers
@ -17,6 +18,12 @@ class ListeningViewSet(mixins.CreateModelMixin,
queryset = models.Listening.objects.all()
permission_classes = [ConditionalAuthentication]
def perform_create(self, serializer):
r = super().perform_create(serializer)
if self.request.user.is_authenticated:
record.send(serializer.instance)
return r
def get_queryset(self):
queryset = super().get_queryset()
if self.request.user.is_authenticated:

View File

@ -0,0 +1,8 @@
from funkwhale_api.common.consumers import JsonAuthConsumer
class InstanceActivityConsumer(JsonAuthConsumer):
groups = ["instance_activity"]
def event_send(self, message):
self.send_json(message['data'])

View File

@ -360,6 +360,12 @@ class Track(APIModelMixin):
self.title,
)
def get_activity_url(self):
if self.mbid:
return 'https://musicbrainz.org/recording/{}'.format(
self.mbid)
return settings.FUNKWHALE_URL + '/tracks/{}'.format(self.pk)
class TrackFile(models.Model):
track = models.ForeignKey(

View File

@ -1,6 +1,8 @@
from rest_framework import serializers
from taggit.models import Tag
from funkwhale_api.activity import serializers as activity_serializers
from . import models
@ -127,3 +129,24 @@ class ImportBatchSerializer(serializers.ModelSerializer):
model = models.ImportBatch
fields = ('id', 'jobs', 'status', 'creation_date', 'import_request')
read_only_fields = ('creation_date',)
class TrackActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField()
name = serializers.CharField(source='title')
artist = serializers.CharField(source='artist.name')
album = serializers.CharField(source='album.title')
class Meta:
model = models.Track
fields = [
'id',
'local_id',
'name',
'type',
'artist',
'album',
]
def get_type(self, obj):
return 'Audio'

View File

@ -0,0 +1,18 @@
# Generated by Django 2.0.2 on 2018-03-01 19:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0003_auto_20171226_1357'),
]
operations = [
migrations.AddField(
model_name='user',
name='privacy_level',
field=models.CharField(choices=[('me', 'Only me'), ('followers', 'Me and my followers'), ('instance', 'Everyone on my instance, and my followers'), ('everyone', 'Everyone, including people on other instances')], default='instance', max_length=30),
),
]

View File

@ -3,6 +3,7 @@ from __future__ import unicode_literals, absolute_import
import uuid
from django.conf import settings
from django.contrib.auth.models import AbstractUser
from django.urls import reverse
from django.db import models
@ -10,6 +11,14 @@ from django.utils.encoding import python_2_unicode_compatible
from django.utils.translation import ugettext_lazy as _
PRIVACY_LEVEL_CHOICES = [
('me', 'Only me'),
('followers', 'Me and my followers'),
('instance', 'Everyone on my instance, and my followers'),
('everyone', 'Everyone, including people on other instances'),
]
@python_2_unicode_compatible
class User(AbstractUser):
@ -30,6 +39,9 @@ class User(AbstractUser):
},
}
privacy_level = models.CharField(
max_length=30, choices=PRIVACY_LEVEL_CHOICES, default='instance')
def __str__(self):
return self.username
@ -43,3 +55,6 @@ class User(AbstractUser):
def set_password(self, raw_password):
super().set_password(raw_password)
self.update_secret_key()
def get_activity_url(self):
return settings.FUNKWHALE_URL + '/@{}'.format(self.username)

View File

@ -1,15 +1,44 @@
from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers
from . import models
class UserActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField()
name = serializers.CharField(source='username')
local_id = serializers.CharField(source='username')
class Meta:
model = models.User
fields = [
'id',
'local_id',
'name',
'type'
]
def get_type(self, obj):
return 'Person'
class UserBasicSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = ['id', 'username', 'name', 'date_joined']
class UserSerializer(serializers.ModelSerializer):
class UserWriteSerializer(serializers.ModelSerializer):
class Meta:
model = models.User
fields = [
'name',
'privacy_level'
]
class UserReadSerializer(serializers.ModelSerializer):
permissions = serializers.SerializerMethodField()
@ -24,6 +53,7 @@ class UserSerializer(serializers.ModelSerializer):
'is_superuser',
'permissions',
'date_joined',
'privacy_level'
]
def get_permissions(self, o):

View File

@ -1,4 +1,5 @@
from rest_framework.response import Response
from rest_framework import mixins
from rest_framework import viewsets
from rest_framework.decorators import list_route
@ -23,12 +24,25 @@ class RegisterView(BaseRegisterView):
return get_adapter().is_open_for_signup(request)
class UserViewSet(viewsets.GenericViewSet):
class UserViewSet(
mixins.UpdateModelMixin,
viewsets.GenericViewSet):
queryset = models.User.objects.all()
serializer_class = serializers.UserSerializer
serializer_class = serializers.UserWriteSerializer
lookup_field = 'username'
@list_route(methods=['get'])
def me(self, request, *args, **kwargs):
"""Return information about the current user"""
serializer = self.serializer_class(request.user)
serializer = serializers.UserReadSerializer(request.user)
return Response(serializer.data)
def update(self, request, *args, **kwargs):
if not self.request.user.username == kwargs.get('username'):
return Response(status=403)
return super().update(request, *args, **kwargs)
def partial_update(self, request, *args, **kwargs):
if not self.request.user.username == kwargs.get('username'):
return Response(status=403)
return super().partial_update(request, *args, **kwargs)

View File

@ -1,11 +1,8 @@
build-essential
gettext
zlib1g-dev
curl
ffmpeg
libjpeg-dev
zlib1g-dev
libmagic-dev
libpq-dev
postgresql-client
libmagic-dev
ffmpeg
python3-dev
curl

View File

@ -59,3 +59,5 @@ pyacoustid>=1.1.5,<1.2
raven>=6.5,<7
python-magic==0.4.15
ffmpeg-python==0.1.10
channels>=2,<2.1
channels_redis>=2.1,<2.2

View File

@ -4,7 +4,4 @@
# WSGI Handler
# ------------------------------------------------
# there's no python 3 support in stable, have to use the latest release candidate for gevent
gevent==1.1rc1
gunicorn==19.4.1
daphne==2.0.4

View File

@ -12,5 +12,6 @@ services:
environment:
- "DJANGO_ALLOWED_HOSTS=localhost"
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
- "FUNKWHALE_URL=https://funkwhale.test"
postgres:
image: postgres

View File

@ -0,0 +1,45 @@
import pytest
from django.db import models
from rest_framework import serializers
from funkwhale_api.activity import record
class FakeModel(models.Model):
class Meta:
app_label = 'tests'
class FakeSerializer(serializers.ModelSerializer):
class Meta:
model = FakeModel
fields = ['id']
def test_can_bind_serializer_to_model(activity_registry):
activity_registry.register_serializer(FakeSerializer)
assert activity_registry['tests.FakeModel']['serializer'] == FakeSerializer
def test_can_bind_consumer_to_model(activity_registry):
activity_registry.register_serializer(FakeSerializer)
@activity_registry.register_consumer('tests.FakeModel')
def propagate(data, obj):
return True
assert activity_registry['tests.FakeModel']['consumers'] == [propagate]
def test_record_object_calls_consumer(activity_registry, mocker):
activity_registry.register_serializer(FakeSerializer)
stub = mocker.stub()
activity_registry.register_consumer('tests.FakeModel')(stub)
o = FakeModel(id=1)
data = FakeSerializer(o).data
record.send(o)
stub.assert_called_once_with(data=data, obj=o)

View File

@ -0,0 +1,37 @@
import pytest
from rest_framework_jwt.settings import api_settings
from funkwhale_api.common.auth import TokenAuthMiddleware
jwt_payload_handler = api_settings.JWT_PAYLOAD_HANDLER
jwt_encode_handler = api_settings.JWT_ENCODE_HANDLER
@pytest.mark.parametrize('query_string', [
b'token=wrong',
b'',
])
def test_header_anonymous(query_string, factories):
def callback(scope):
assert scope['user'].is_anonymous
scope = {
'query_string': query_string
}
consumer = TokenAuthMiddleware(callback)
consumer(scope)
def test_header_correct_token(factories):
user = factories['users.User']()
payload = jwt_payload_handler(user)
token = jwt_encode_handler(payload)
def callback(scope):
assert scope['user'] == user
scope = {
'query_string': 'token={}'.format(token).encode('utf-8')
}
consumer = TokenAuthMiddleware(callback)
consumer(scope)

View File

@ -0,0 +1,26 @@
from funkwhale_api.common import consumers
def test_auth_consumer_requires_valid_user(mocker):
m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.close')
scope = {'user': None}
consumer = consumers.JsonAuthConsumer(scope=scope)
consumer.connect()
m.assert_called_once_with()
def test_auth_consumer_requires_user_in_scope(mocker):
m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.close')
scope = {}
consumer = consumers.JsonAuthConsumer(scope=scope)
consumer.connect()
m.assert_called_once_with()
def test_auth_consumer_accepts_connection(mocker, factories):
user = factories['users.User']()
m = mocker.patch('funkwhale_api.common.consumers.JsonAuthConsumer.accept')
scope = {'user': user}
consumer = consumers.JsonAuthConsumer(scope=scope)
consumer.connect()
m.assert_called_once_with()

View File

@ -5,6 +5,7 @@ from django.core.cache import cache as django_cache
from dynamic_preferences.registries import global_preferences_registry
from rest_framework.test import APIClient
from funkwhale_api.activity import record
from funkwhale_api.taskapp import celery
@ -81,3 +82,28 @@ def superuser_client(db, factories, client):
setattr(client, 'user', user)
yield client
delattr(client, 'user')
@pytest.fixture
def activity_registry():
r = record.registry
state = list(record.registry.items())
yield record.registry
record.registry.clear()
for key, value in state:
record.registry[key] = value
@pytest.fixture
def activity_registry():
r = record.registry
state = list(record.registry.items())
yield record.registry
record.registry.clear()
for key, value in state:
record.registry[key] = value
@pytest.fixture
def activity_muted(activity_registry, mocker):
yield mocker.patch.object(record, 'send')

View File

@ -0,0 +1,75 @@
from funkwhale_api.users.serializers import UserActivitySerializer
from funkwhale_api.music.serializers import TrackActivitySerializer
from funkwhale_api.favorites import serializers
from funkwhale_api.favorites import activities
def test_get_favorite_activity_url(settings, factories):
favorite = factories['favorites.TrackFavorite']()
user_url = favorite.user.get_activity_url()
expected = '{}/favorites/tracks/{}'.format(
user_url, favorite.pk)
assert favorite.get_activity_url() == expected
def test_activity_favorite_serializer(factories):
favorite = factories['favorites.TrackFavorite']()
actor = UserActivitySerializer(favorite.user).data
field = serializers.serializers.DateTimeField()
expected = {
"type": "Like",
"local_id": favorite.pk,
"id": favorite.get_activity_url(),
"actor": actor,
"object": TrackActivitySerializer(favorite.track).data,
"published": field.to_representation(favorite.creation_date),
}
data = serializers.TrackFavoriteActivitySerializer(favorite).data
assert data == expected
def test_track_favorite_serializer_is_connected(activity_registry):
conf = activity_registry['favorites.TrackFavorite']
assert conf['serializer'] == serializers.TrackFavoriteActivitySerializer
def test_track_favorite_serializer_instance_activity_consumer(
activity_registry):
conf = activity_registry['favorites.TrackFavorite']
consumer = activities.broadcast_track_favorite_to_instance_activity
assert consumer in conf['consumers']
def test_broadcast_track_favorite_to_instance_activity(
factories, mocker):
p = mocker.patch('funkwhale_api.common.channels.group_send')
favorite = factories['favorites.TrackFavorite']()
data = serializers.TrackFavoriteActivitySerializer(favorite).data
consumer = activities.broadcast_track_favorite_to_instance_activity
message = {
"type": 'event.send',
"text": '',
"data": data
}
consumer(data=data, obj=favorite)
p.assert_called_once_with('instance_activity', message)
def test_broadcast_track_favorite_to_instance_activity_private(
factories, mocker):
p = mocker.patch('funkwhale_api.common.channels.group_send')
favorite = factories['favorites.TrackFavorite'](
user__privacy_level='me'
)
data = serializers.TrackFavoriteActivitySerializer(favorite).data
consumer = activities.broadcast_track_favorite_to_instance_activity
message = {
"type": 'event.send',
"text": '',
"data": data
}
consumer(data=data, obj=favorite)
p.assert_not_called()

View File

@ -33,7 +33,8 @@ def test_user_can_get_his_favorites(factories, logged_in_client, client):
assert expected == parsed_json['results']
def test_user_can_add_favorite_via_api(factories, logged_in_client, client):
def test_user_can_add_favorite_via_api(
factories, logged_in_client, activity_muted):
track = factories['music.Track']()
url = reverse('api:v1:favorites:tracks-list')
response = logged_in_client.post(url, {'track': track.pk})
@ -51,6 +52,27 @@ def test_user_can_add_favorite_via_api(factories, logged_in_client, client):
assert favorite.user == logged_in_client.user
def test_adding_favorites_calls_activity_record(
factories, logged_in_client, activity_muted):
track = factories['music.Track']()
url = reverse('api:v1:favorites:tracks-list')
response = logged_in_client.post(url, {'track': track.pk})
favorite = TrackFavorite.objects.latest('id')
expected = {
'track': track.pk,
'id': favorite.id,
'creation_date': favorite.creation_date.isoformat().replace('+00:00', 'Z'),
}
parsed_json = json.loads(response.content.decode('utf-8'))
assert expected == parsed_json
assert favorite.track == track
assert favorite.user == logged_in_client.user
activity_muted.assert_called_once_with(favorite)
def test_user_can_remove_favorite_via_api(logged_in_client, factories, client):
favorite = factories['favorites.TrackFavorite'](user=logged_in_client.user)
url = reverse('api:v1:favorites:tracks-detail', kwargs={'pk': favorite.pk})

View File

View File

@ -0,0 +1,75 @@
from funkwhale_api.users.serializers import UserActivitySerializer
from funkwhale_api.music.serializers import TrackActivitySerializer
from funkwhale_api.history import serializers
from funkwhale_api.history import activities
def test_get_listening_activity_url(settings, factories):
listening = factories['history.Listening']()
user_url = listening.user.get_activity_url()
expected = '{}/listenings/tracks/{}'.format(
user_url, listening.pk)
assert listening.get_activity_url() == expected
def test_activity_listening_serializer(factories):
listening = factories['history.Listening']()
actor = UserActivitySerializer(listening.user).data
field = serializers.serializers.DateTimeField()
expected = {
"type": "Listen",
"local_id": listening.pk,
"id": listening.get_activity_url(),
"actor": actor,
"object": TrackActivitySerializer(listening.track).data,
"published": field.to_representation(listening.end_date),
}
data = serializers.ListeningActivitySerializer(listening).data
assert data == expected
def test_track_listening_serializer_is_connected(activity_registry):
conf = activity_registry['history.Listening']
assert conf['serializer'] == serializers.ListeningActivitySerializer
def test_track_listening_serializer_instance_activity_consumer(
activity_registry):
conf = activity_registry['history.Listening']
consumer = activities.broadcast_listening_to_instance_activity
assert consumer in conf['consumers']
def test_broadcast_listening_to_instance_activity(
factories, mocker):
p = mocker.patch('funkwhale_api.common.channels.group_send')
listening = factories['history.Listening']()
data = serializers.ListeningActivitySerializer(listening).data
consumer = activities.broadcast_listening_to_instance_activity
message = {
"type": 'event.send',
"text": '',
"data": data
}
consumer(data=data, obj=listening)
p.assert_called_once_with('instance_activity', message)
def test_broadcast_listening_to_instance_activity_private(
factories, mocker):
p = mocker.patch('funkwhale_api.common.channels.group_send')
listening = factories['history.Listening'](
user__privacy_level='me'
)
data = serializers.ListeningActivitySerializer(listening).data
consumer = activities.broadcast_listening_to_instance_activity
message = {
"type": 'event.send',
"text": '',
"data": data
}
consumer(data=data, obj=listening)
p.assert_not_called()

View File

@ -28,7 +28,8 @@ def test_anonymous_user_can_create_listening_via_api(client, factories, settings
assert listening.session_key == client.session.session_key
def test_logged_in_user_can_create_listening_via_api(logged_in_client, factories):
def test_logged_in_user_can_create_listening_via_api(
logged_in_client, factories, activity_muted):
track = factories['music.Track']()
url = reverse('api:v1:history:listenings-list')
@ -40,3 +41,17 @@ def test_logged_in_user_can_create_listening_via_api(logged_in_client, factories
assert listening.track == track
assert listening.user == logged_in_client.user
def test_adding_listening_calls_activity_record(
factories, logged_in_client, activity_muted):
track = factories['music.Track']()
url = reverse('api:v1:history:listenings-list')
response = logged_in_client.post(url, {
'track': track.pk,
})
listening = models.Listening.objects.latest('id')
activity_muted.assert_called_once_with(listening)

View File

@ -0,0 +1,17 @@
from funkwhale_api.users.serializers import UserActivitySerializer
from funkwhale_api.favorites import serializers
def test_get_track_activity_url_mbid(factories):
track = factories['music.Track']()
expected = 'https://musicbrainz.org/recording/{}'.format(
track.mbid)
assert track.get_activity_url() == expected
def test_get_track_activity_url_no_mbid(settings, factories):
track = factories['music.Track'](mbid=None)
expected = settings.FUNKWHALE_URL + '/tracks/{}'.format(
track.pk)
assert track.get_activity_url() == expected

View File

View File

@ -0,0 +1,22 @@
from funkwhale_api.users import serializers
def test_get_user_activity_url(settings, factories):
user = factories['users.User']()
assert user.get_activity_url() == '{}/@{}'.format(
settings.FUNKWHALE_URL, user.username)
def test_activity_user_serializer(factories):
user = factories['users.User']()
expected = {
"type": "Person",
"id": user.get_activity_url(),
"local_id": user.username,
"name": user.username,
}
data = serializers.UserActivitySerializer(user).data
assert data == expected

View File

@ -1,4 +1,5 @@
import json
import pytest
from django.test import RequestFactory
from django.urls import reverse
@ -116,3 +117,37 @@ def test_changing_password_updates_secret_key(logged_in_client):
assert user.secret_key != secret_key
assert user.password != password
def test_user_can_patch_his_own_settings(logged_in_api_client):
user = logged_in_api_client.user
payload = {
'privacy_level': 'me',
}
url = reverse(
'api:v1:users:users-detail',
kwargs={'username': user.username})
response = logged_in_api_client.patch(url, payload)
assert response.status_code == 200
user.refresh_from_db()
assert user.privacy_level == 'me'
@pytest.mark.parametrize('method', ['put', 'patch'])
def test_user_cannot_patch_another_user(
method, logged_in_api_client, factories):
user = factories['users.User']()
payload = {
'privacy_level': 'me',
}
url = reverse(
'api:v1:users:users-detail',
kwargs={'username': user.username})
handler = getattr(logged_in_api_client, method)
response = handler(url, payload)
assert response.status_code == 403

View File

@ -0,0 +1,16 @@
Basic activity stream for listening and favorites (#23)
A new "Activity" page is now available from the sidebar, where you can
browse your instance activity. At the moment, this includes other users
favorites and listening, but more activity types will be implemented in the
future.
Internally, we implemented those events by following the Activity Stream
specification, which will help us to be compatible with other networks
in the long-term.
A new settings page has been added to control the visibility of your activity.
By default, your activity will be browsable by anyone on your instance,
but you can switch to a full private mode where nothing is shared.
The setting form is available in your profile.

View File

@ -0,0 +1,59 @@
Switched to django-channels and daphne for serving HTTP and websocket (#34)
Upgrade notes
^^^^^^^^^^^^^
This release include an important change in the way we serve the HTTP API.
To prepare for new realtime features and enable websocket support in Funkwhale,
we are now using django-channels and daphne to serve HTTP and websocket traffic.
This replaces gunicorn and the switch should be easy assuming you
follow the upgrade process described bellow.
If you are using docker, please remove the command instruction inside the
api service, as the up-to-date command is now included directly in the image
as the default entry point:
.. code-block:: yaml
api:
restart: unless-stopped
image: funkwhale/funkwhale:${FUNKWHALE_VERSION:-latest}
command: ./compose/django/gunicorn.sh # You can remove this line
On non docker setups, you'll have to update the [Service] block of your
funkwhale-server systemd unit file to launch the application server using daphne instead of gunicorn.
The new configuration should be similar to this:
.. code-block:: ini
[Service]
User=funkwhale
# adapt this depending on the path of your funkwhale installation
WorkingDirectory=/srv/funkwhale/api
EnvironmentFile=/srv/funkwhale/config/.env
ExecStart=/usr/local/bin/daphne -b ${FUNKWHALE_API_IP} -p ${FUNKWHALE_API_PORT} config.asgi:application
Ensure you update funkwhale's dependencies as usual to install the required
packages.
On both docker and non-docker setup, you'll also have to update your nginx
configuration for websocket support. Ensure you have the following blocks
included in your virtualhost file:
.. code-block:: txt
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
...
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
Remember to reload your nginx server after the edit.

View File

@ -43,7 +43,6 @@ services:
restart: unless-stopped
image: funkwhale/funkwhale:${FUNKWHALE_VERSION:-latest}
env_file: .env
command: ./compose/django/gunicorn.sh
volumes:
- ./data/music:/music:ro
- ./data/media:/app/funkwhale_api/media

View File

@ -2,6 +2,7 @@
# following variables:
# - DJANGO_SECRET_KEY
# - DJANGO_ALLOWED_HOSTS
# - FUNKWHALE_URL
# Additionaly, on non-docker setup, you'll also have to tweak/uncomment those
# variables:
@ -28,6 +29,9 @@ FUNKWHALE_VERSION=latest
FUNKWHALE_API_IP=127.0.0.1
FUNKWHALE_API_PORT=5000
# Replace this by the definitive, public domain you will use for
# your instance
FUNKWHALE_URL=https.//yourdomain.funwhale
# API/Django configuration

View File

@ -8,7 +8,7 @@ User=funkwhale
# adapt this depending on the path of your funkwhale installation
WorkingDirectory=/srv/funkwhale/api
EnvironmentFile=/srv/funkwhale/config/.env
ExecStart=/srv/funkwhale/virtualenv/bin/gunicorn config.wsgi:application -b ${FUNKWHALE_API_IP}:${FUNKWHALE_API_PORT}
ExecStart=/usr/local/bin/daphne -b ${FUNKWHALE_API_IP} -p ${FUNKWHALE_API_PORT} config.asgi:application
[Install]
WantedBy=multi-user.target

View File

@ -19,6 +19,12 @@ server {
location / { return 301 https://$host$request_uri; }
}
# required for websocket support
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
@ -51,6 +57,11 @@ server {
proxy_set_header X-Forwarded-Port $server_port;
proxy_redirect off;
# websocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
location / {
try_files $uri $uri/ @rewrites;
}

View File

@ -36,6 +36,7 @@ services:
- C_FORCE_ROOT=true
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
- "CACHE_URL=redis://redis:6379/0"
- "FUNKWHALE_URL=http://funkwhale.test"
volumes:
- ./api:/app
- ./data/music:/music
@ -54,6 +55,7 @@ services:
- "DJANGO_SECRET_KEY=dev"
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
- "CACHE_URL=redis://redis:6379/0"
- "FUNKWHALE_URL=http://funkwhale.test"
links:
- postgres
- redis

View File

@ -28,6 +28,11 @@ http {
#gzip on;
proxy_cache_path /tmp/funkwhale-transcode levels=1:2 keys_zone=transcode:10m max_size=1g inactive=24h use_temp_path=off;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 6001;
charset utf-8;
@ -40,6 +45,9 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host localhost:8080;
proxy_set_header X-Forwarded-Port 8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_redirect off;
location /_protected/media {

View File

@ -3,8 +3,7 @@ FROM node:9
EXPOSE 8080
WORKDIR /app/
ADD package.json .
RUN yarn install --only=production
RUN yarn install --only=dev
RUN yarn install
VOLUME ["/app/node_modules"]
COPY . .

View File

@ -32,6 +32,7 @@ module.exports = {
'/api': {
target: 'http://nginx:6001',
changeOrigin: true,
ws: true
},
'/media': {
target: 'http://nginx:6001',

View File

@ -17,6 +17,7 @@
"dependencies": {
"axios": "^0.17.1",
"dateformat": "^2.0.0",
"django-channels": "^1.1.6",
"js-logger": "^1.3.0",
"jwt-decode": "^2.2.0",
"lodash": "^4.17.4",

View File

@ -33,6 +33,9 @@
</template>
<script>
import { WebSocketBridge } from 'django-channels'
import logger from '@/logging'
import Sidebar from '@/components/Sidebar'
import Raven from '@/components/Raven'
@ -44,6 +47,31 @@ export default {
},
created () {
this.$store.dispatch('instance/fetchSettings')
this.openWebsocket()
let self = this
setInterval(() => {
// used to redraw ago dates every minute
self.$store.commit('ui/computeLastDate')
}, 1000 * 60)
},
methods: {
openWebsocket () {
let self = this
let token = this.$store.state.auth.token
// let token = 'test'
const bridge = new WebSocketBridge()
bridge.connect(
`/api/v1/instance/activity?token=${token}`,
null,
{reconnectInterval: 5000})
bridge.listen(function (event) {
logger.default.info('Received timeline update', event)
self.$store.commit('instance/event', event)
})
bridge.socket.addEventListener('open', function () {
console.log('Connected to WebSocket')
})
}
}
}
</script>

View File

@ -36,6 +36,9 @@
<router-link class="item" v-else :to="{name: 'login'}"><i class="sign in icon"></i> Login</router-link>
<router-link class="item" :to="{path: '/library'}"><i class="sound icon"> </i>Browse library</router-link>
<router-link class="item" :to="{path: '/favorites'}"><i class="heart icon"></i> Favorites</router-link>
<router-link
v-if="$store.state.auth.authenticated"
class="item" :to="{path: '/activity'}"><i class="bell icon"></i> Activity</router-link>
</div>
</div>
<div v-if="queue.previousQueue " class="ui black icon message">

View File

@ -0,0 +1,35 @@
<template>
<div class="event">
<div class="label">
<i class="pink heart icon"></i>
</div>
<div class="content">
<div class="summary">
<slot name="user"></slot>
favorited a track
<slot name="date"></slot>
</div>
<div class="extra text">
<router-link :to="{name: 'library.tracks.detail', params: {id: event.object.local_id }}">{{ event.object.name }}</router-link>
<template v-if="event.object.album">from album {{ event.object.album }}, by <em>{{ event.object.artist }}</em>
</template>
<template v-else>, by <em>{{ event.object.artist }}</em>
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: ['event']
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
</style>

View File

@ -0,0 +1,35 @@
<template>
<div class="event">
<div class="label">
<i class="orange sound icon"></i>
</div>
<div class="content">
<div class="summary">
<slot name="user"></slot>
listened to a track
<slot name="date"></slot>
</div>
<div class="extra text">
<router-link :to="{name: 'library.tracks.detail', params: {id: event.object.local_id }}">{{ event.object.name }}</router-link>
<template v-if="event.object.album">from album {{ event.object.album }}, by <em>{{ event.object.artist }}</em>
</template>
<template v-else>, by <em>{{ event.object.artist }}</em>
</template>
</div>
</div>
</div>
</template>
<script>
export default {
props: ['event']
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped lang="scss">
</style>

View File

@ -2,12 +2,35 @@
<div class="main pusher">
<div class="ui vertical stripe segment">
<div class="ui small text container">
<h2>Change my password</h2>
<form class="ui form" @submit.prevent="submit()">
<div v-if="error" class="ui negative message">
<h2 class="ui header">Account settings</h2>
<form class="ui form" @submit.prevent="submitSettings()">
<div v-if="settings.success" class="ui positive message">
<div class="header">Settings updated</div>
</div>
<div v-if="settings.errors.length > 0" class="ui negative message">
<div class="header">We cannot save your settings</div>
<ul class="list">
<li v-for="error in settings.errors">{{ error }}</li>
</ul>
</div>
<div class="field" v-for="f in orderedSettingsFields">
<label :for="f.id">{{ f.label }}</label>
<p v-if="f.help">{{ f.help }}</p>
<select v-if="f.type === 'dropdown'" class="ui dropdown" v-model="f.value">
<option :value="c.value" v-for="c in f.choices">{{ c.label }}</option>
</select>
</div>
<button :class="['ui', {'loading': isLoading}, 'button']" type="submit">Update settings</button>
</form>
</div>
<div class="ui hidden divider"></div>
<div class="ui small text container">
<h2 class="ui header">Change my password</h2>
<form class="ui form" @submit.prevent="submitPassword()">
<div v-if="passwordError" class="ui negative message">
<div class="header">Cannot change your password</div>
<ul class="list">
<li v-if="error == 'invalid_credentials'">Please double-check your password is correct</li>
<li v-if="passwordError == 'invalid_credentials'">Please double-check your password is correct</li>
</ul>
</div>
<div class="field">
@ -36,22 +59,68 @@
</template>
<script>
import $ from 'jquery'
import axios from 'axios'
import logger from '@/logging'
export default {
data () {
return {
let d = {
// We need to initialize the component with any
// properties that will be used in it
old_password: '',
new_password: '',
error: '',
isLoading: false
passwordError: '',
isLoading: false,
settings: {
success: false,
errors: [],
order: ['privacy_level'],
fields: {
'privacy_level': {
type: 'dropdown',
initial: this.$store.state.auth.profile.privacy_level,
label: 'Activity visibility',
help: 'Determine the visibility level of your activity',
choices: [
{
value: 'me',
label: 'Nobody except me'
},
{
value: 'instance',
label: 'Everyone on this instance'
}
]
}
}
}
}
d.settings.order.forEach(id => {
d.settings.fields[id].value = d.settings.fields[id].initial
})
return d
},
mounted () {
$('select.dropdown').dropdown()
},
methods: {
submit () {
submitSettings () {
this.settings.success = false
this.settings.errors = []
let self = this
let payload = this.settingsValues
let url = `users/users/${this.$store.state.auth.username}/`
return axios.patch(url, payload).then(response => {
logger.default.info('Updated settings successfully')
self.settings.success = true
}, error => {
logger.default.error('Error while updating settings')
self.isLoading = false
self.settings.errors = error.backendErrors
})
},
submitPassword () {
var self = this
self.isLoading = true
this.error = ''
@ -70,13 +139,30 @@ export default {
}})
}, error => {
if (error.response.status === 400) {
self.error = 'invalid_credentials'
self.passwordError = 'invalid_credentials'
} else {
self.error = 'unknown_error'
self.passwordError = 'unknown_error'
}
self.isLoading = false
})
}
},
computed: {
orderedSettingsFields () {
let self = this
return this.settings.order.map(id => {
return self.settings.fields[id]
})
},
settingsValues () {
let self = this
let s = {}
this.settings.order.forEach(setting => {
let conf = self.settings.fields[setting]
s[setting] = conf.value
})
return s
}
}
}

View File

@ -100,24 +100,9 @@ export default {
username: this.username
}})
}, error => {
self.errors = this.getErrors(error.response)
self.errors = error.backendErrors
self.isLoading = false
})
},
getErrors (response) {
let errors = []
if (response.status !== 400) {
errors.push('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running')
return errors
}
for (var field in response.data) {
if (response.data.hasOwnProperty(field)) {
response.data[field].forEach(e => {
errors.push(e)
})
}
}
return errors
}
},
computed: {

View File

@ -1,8 +1,20 @@
<template>
<time :datetime="date" :title="date | moment">{{ date | ago }}</time>
<time :datetime="date" :title="date | moment">{{ realDate | ago }}</time>
</template>
<script>
import {mapState} from 'vuex'
export default {
props: ['date']
props: ['date'],
computed: {
...mapState({
lastDate: state => state.ui.lastDate
}),
realDate () {
if (this.lastDate) {
// dummy code to trigger a recompute to update the ago render
}
return this.date
}
}
}
</script>

View File

@ -0,0 +1,8 @@
<template>
<span>{{ username }}</span>
</template>
<script>
export default {
props: ['username']
}
</script>

View File

@ -4,4 +4,8 @@ import HumanDate from '@/components/common/HumanDate'
Vue.component('human-date', HumanDate)
import Username from '@/components/common/Username'
Vue.component('username', Username)
export default {}

View File

@ -62,12 +62,18 @@ export default {
data () {
return {
isLoading: true,
batch: null
batch: null,
timeout: null
}
},
created () {
this.fetchData()
},
destroyed () {
if (this.timeout) {
clearTimeout(this.timeout)
}
},
methods: {
fetchData () {
var self = this
@ -78,7 +84,7 @@ export default {
self.batch = response.data
self.isLoading = false
if (self.batch.status === 'pending') {
setTimeout(
self.timeout = setTimeout(
self.fetchData,
5000
)

View File

@ -47,11 +47,28 @@ axios.interceptors.request.use(function (config) {
axios.interceptors.response.use(function (response) {
return response
}, function (error) {
error.backendErrors = []
if (error.response.status === 401) {
store.commit('auth/authenticated', false)
logger.default.warn('Received 401 response from API, redirecting to login form')
router.push({name: 'login', query: {next: router.currentRoute.fullPath}})
}
if (error.response.status === 404) {
error.backendErrors.push('Resource not found')
} 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.backendErrors.length === 0) {
error.backendErrors.push('An unknown error occured, ensure your are connected to the internet and your funkwhale instance is up and running')
}
// Do something with response error
return Promise.reject(error)
})

View File

@ -3,6 +3,7 @@ import Router from 'vue-router'
import PageNotFound from '@/components/PageNotFound'
import About from '@/components/About'
import Home from '@/components/Home'
import InstanceTimeline from '@/views/instance/Timeline'
import Login from '@/components/auth/Login'
import Signup from '@/components/auth/Signup'
import Profile from '@/components/auth/Profile'
@ -39,6 +40,11 @@ export default new Router({
name: 'about',
component: About
},
{
path: '/activity',
name: 'activity',
component: InstanceTimeline
},
{
path: '/login',
name: 'login',

View File

@ -8,11 +8,13 @@ import instance from './instance'
import queue from './queue'
import radios from './radios'
import player from './player'
import ui from './ui'
Vue.use(Vuex)
export default new Vuex.Store({
modules: {
ui,
auth,
favorites,
instance,
@ -28,6 +30,10 @@ export default new Vuex.Store({
return mutation.type.startsWith('auth/')
}
}),
createPersistedState({
key: 'instance',
paths: ['instance.events']
}),
createPersistedState({
key: 'radios',
paths: ['radios'],

View File

@ -5,6 +5,8 @@ import _ from 'lodash'
export default {
namespaced: true,
state: {
maxEvents: 200,
events: [],
settings: {
instance: {
name: {
@ -35,6 +37,12 @@ export default {
mutations: {
settings: (state, value) => {
_.merge(state.settings, value)
},
event: (state, value) => {
state.events.unshift(value)
if (state.events.length > state.maxEvents) {
state.events = state.events.slice(0, state.maxEvents)
}
}
},
actions: {

12
front/src/store/ui.js Normal file
View File

@ -0,0 +1,12 @@
export default {
namespaced: true,
state: {
lastDate: new Date()
},
mutations: {
computeLastDate: (state) => {
state.lastDate = new Date()
}
}
}

View File

@ -0,0 +1,52 @@
<template>
<div class="main pusher">
<div class="ui vertical center aligned stripe segment">
<div class="ui text container">
<h1 class="ui header">Recent activity on this instance</h1>
<div class="ui feed">
<component
class="event"
v-for="(event, index) in events"
:key="event.id + index"
v-if="components[event.type]"
:is="components[event.type]"
:event="event">
<username
class="user"
:username="event.actor.local_id"
slot="user"></username>
{{ event.published }}
<human-date class="date" :date="event.published" slot="date"></human-date>
</component>
</div>
</div>
</div>
</div>
</template>
<script>
import {mapState} from 'vuex'
import Like from '@/components/activity/Like'
import Listen from '@/components/activity/Listen'
export default {
data () {
return {
components: {
'Like': Like,
'Listen': Listen
}
}
},
computed: {
...mapState({
events: state => state.instance.events
})
}
}
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>