Merge branch 'remote-scan' into 'develop'
Remote scan/import Closes #137 and #136 See merge request funkwhale/funkwhale!126
This commit is contained in:
commit
18e8e4fa3b
6
.env.dev
6
.env.dev
|
@ -1,9 +1,11 @@
|
|||
API_AUTHENTICATION_REQUIRED=True
|
||||
RAVEN_ENABLED=false
|
||||
RAVEN_DSN=https://44332e9fdd3d42879c7d35bf8562c6a4:0062dc16a22b41679cd5765e5342f716@sentry.eliotberriot.com/5
|
||||
DJANGO_ALLOWED_HOSTS=localhost,nginx
|
||||
DJANGO_ALLOWED_HOSTS=.funkwhale.test,localhost,nginx,0.0.0.0,127.0.0.1
|
||||
DJANGO_SETTINGS_MODULE=config.settings.local
|
||||
DJANGO_SECRET_KEY=dev
|
||||
C_FORCE_ROOT=true
|
||||
FUNKWHALE_URL=http://localhost
|
||||
FUNKWHALE_HOSTNAME=localhost
|
||||
FUNKWHALE_PROTOCOL=http
|
||||
PYTHONDONTWRITEBYTECODE=true
|
||||
WEBPACK_DEVSERVER_PORT=8080
|
||||
|
|
77
README.rst
77
README.rst
|
@ -206,3 +206,80 @@ Typical workflow for a merge request
|
|||
6. Push your branch
|
||||
7. Create your merge request
|
||||
8. Take a step back and enjoy, we're really grateful you did all of this and took the time to contribute!
|
||||
|
||||
|
||||
Working with federation locally
|
||||
-------------------------------
|
||||
|
||||
To achieve that, you'll need:
|
||||
|
||||
1. to update your dns resolver to resolve all your .dev hostnames locally
|
||||
2. a reverse proxy (such as traefik) to catch those .dev requests and
|
||||
and with https certificate
|
||||
3. two instances (or more) running locally, following the regular dev setup
|
||||
|
||||
Resolve .dev names locally
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
If you use dnsmasq, this is as simple as doing::
|
||||
|
||||
echo "address=/test/172.17.0.1" | sudo tee /etc/dnsmasq.d/test.conf
|
||||
sudo systemctl restart dnsmasq
|
||||
|
||||
If you use NetworkManager with dnsmasq integration, use this instead::
|
||||
|
||||
echo "address=/test/172.17.0.1" | sudo tee /etc/NetworkManager/dnsmasq.d/test.conf
|
||||
sudo systemctl restart NetworkManager
|
||||
|
||||
Add wildcard certificate to the trusted certificates
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Simply copy bundled certificates::
|
||||
|
||||
sudo cp docker/ssl/test.crt /usr/local/share/ca-certificates/
|
||||
sudo update-ca-certificates
|
||||
|
||||
This certificate is a wildcard for ``*.funkwhale.test``
|
||||
|
||||
Run a reverse proxy for your instances
|
||||
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
|
||||
Crete docker network
|
||||
^^^^^^^^^^^^^^^^^^^^
|
||||
|
||||
Create the federation network::
|
||||
|
||||
docker network create federation
|
||||
|
||||
Launch everything
|
||||
^^^^^^^^^^^^^^^^^
|
||||
|
||||
Launch the traefik proxy::
|
||||
|
||||
docker-compose -f docker/traefik.yml up -d
|
||||
|
||||
Then, in separate terminals, you can setup as many different instances as you
|
||||
need::
|
||||
|
||||
export COMPOSE_PROJECT_NAME=node2
|
||||
docker-compose -f dev.yml run --rm api python manage.py migrate
|
||||
docker-compose -f dev.yml run --rm api python manage.py createsuperuser
|
||||
docker-compose -f dev.yml up nginx api front
|
||||
|
||||
Note that by default, if you don't export the COMPOSE_PROJECT_NAME,
|
||||
we will default to node1 as the name of your instance.
|
||||
|
||||
Assuming your project name is ``node1``, your server will be reachable
|
||||
at ``https://node1.funkwhale.test/``. Not that you'll have to trust
|
||||
the SSL Certificate as it's self signed.
|
||||
|
||||
When working on federation with traefik, ensure you have this in your ``env``::
|
||||
|
||||
# This will ensure we don't bind any port on the host, and thus enable
|
||||
# multiple instances of funkwhale to be spawned concurrently.
|
||||
WEBPACK_DEVSERVER_PORT_BINDING=
|
||||
# This disable certificate verification
|
||||
EXTERNAL_REQUESTS_VERIFY_SSL=false
|
||||
# this ensure you don't have incorrect urls pointing to http resources
|
||||
FUNKWHALE_PROTOCOL=https
|
||||
|
|
|
@ -32,6 +32,10 @@ v1_patterns += [
|
|||
include(
|
||||
('funkwhale_api.instance.urls', 'instance'),
|
||||
namespace='instance')),
|
||||
url(r'^federation/',
|
||||
include(
|
||||
('funkwhale_api.federation.api_urls', 'federation'),
|
||||
namespace='federation')),
|
||||
url(r'^providers/',
|
||||
include(
|
||||
('funkwhale_api.providers.urls', 'providers'),
|
||||
|
|
|
@ -25,8 +25,26 @@ try:
|
|||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
FUNKWHALE_URL = env('FUNKWHALE_URL')
|
||||
FUNKWHALE_HOSTNAME = urlsplit(FUNKWHALE_URL).netloc
|
||||
FUNKWHALE_HOSTNAME = None
|
||||
FUNKWHALE_HOSTNAME_SUFFIX = env('FUNKWHALE_HOSTNAME_SUFFIX', default=None)
|
||||
FUNKWHALE_HOSTNAME_PREFIX = env('FUNKWHALE_HOSTNAME_PREFIX', default=None)
|
||||
if FUNKWHALE_HOSTNAME_PREFIX and FUNKWHALE_HOSTNAME_SUFFIX:
|
||||
# We're in traefik case, in development
|
||||
FUNKWHALE_HOSTNAME = '{}.{}'.format(
|
||||
FUNKWHALE_HOSTNAME_PREFIX, FUNKWHALE_HOSTNAME_SUFFIX)
|
||||
FUNKWHALE_PROTOCOL = env('FUNKWHALE_PROTOCOL', default='https')
|
||||
else:
|
||||
try:
|
||||
FUNKWHALE_HOSTNAME = env('FUNKWHALE_HOSTNAME')
|
||||
FUNKWHALE_PROTOCOL = env('FUNKWHALE_PROTOCOL', default='https')
|
||||
except Exception:
|
||||
FUNKWHALE_URL = env('FUNKWHALE_URL')
|
||||
_parsed = urlsplit(FUNKWHALE_URL)
|
||||
FUNKWHALE_HOSTNAME = _parsed.netloc
|
||||
FUNKWHALE_PROTOCOL = _parsed.scheme
|
||||
|
||||
FUNKWHALE_URL = '{}://{}'.format(FUNKWHALE_PROTOCOL, FUNKWHALE_HOSTNAME)
|
||||
|
||||
|
||||
FEDERATION_ENABLED = env.bool('FEDERATION_ENABLED', default=True)
|
||||
FEDERATION_HOSTNAME = env('FEDERATION_HOSTNAME', default=FUNKWHALE_HOSTNAME)
|
||||
|
@ -406,3 +424,8 @@ ACCOUNT_USERNAME_BLACKLIST = [
|
|||
'staff',
|
||||
'service',
|
||||
] + env.list('ACCOUNT_USERNAME_BLACKLIST', default=[])
|
||||
|
||||
EXTERNAL_REQUESTS_VERIFY_SSL = env.bool(
|
||||
'EXTERNAL_REQUESTS_VERIFY_SSL',
|
||||
default=True
|
||||
)
|
||||
|
|
|
@ -1,5 +1,9 @@
|
|||
import django_filters
|
||||
|
||||
from django.db import models
|
||||
|
||||
from funkwhale_api.music import utils
|
||||
|
||||
|
||||
PRIVACY_LEVEL_CHOICES = [
|
||||
('me', 'Only me'),
|
||||
|
@ -25,3 +29,15 @@ def privacy_level_query(user, lookup_field='privacy_level'):
|
|||
'followers', 'instance', 'everyone'
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
class SearchFilter(django_filters.CharFilter):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.search_fields = kwargs.pop('search_fields')
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def filter(self, qs, value):
|
||||
if not value:
|
||||
return qs
|
||||
query = utils.get_query(value, self.search_fields)
|
||||
return qs.filter(query)
|
||||
|
|
|
@ -1,14 +1,5 @@
|
|||
import logging
|
||||
import json
|
||||
import requests_http_signature
|
||||
import uuid
|
||||
|
||||
from funkwhale_api.common import session
|
||||
|
||||
from . import models
|
||||
from . import signing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from . import serializers
|
||||
from . import tasks
|
||||
|
||||
ACTIVITY_TYPES = [
|
||||
'Accept',
|
||||
|
@ -61,86 +52,16 @@ OBJECT_TYPES = [
|
|||
|
||||
|
||||
def deliver(activity, on_behalf_of, to=[]):
|
||||
from . import actors
|
||||
logger.info('Preparing activity delivery to %s', to)
|
||||
auth = signing.get_auth(
|
||||
on_behalf_of.private_key, on_behalf_of.private_key_id)
|
||||
for url in to:
|
||||
recipient_actor = actors.get_actor(url)
|
||||
logger.debug('delivering to %s', recipient_actor.inbox_url)
|
||||
logger.debug('activity content: %s', json.dumps(activity))
|
||||
response = session.get_session().post(
|
||||
auth=auth,
|
||||
json=activity,
|
||||
url=recipient_actor.inbox_url,
|
||||
headers={
|
||||
'Content-Type': 'application/activity+json'
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.debug('Remote answered with %s', response.status_code)
|
||||
|
||||
|
||||
def get_follow(follow_id, follower, followed):
|
||||
return {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
{}
|
||||
],
|
||||
'actor': follower.url,
|
||||
'id': follower.url + '#follows/{}'.format(follow_id),
|
||||
'object': followed.url,
|
||||
'type': 'Follow'
|
||||
}
|
||||
|
||||
|
||||
def get_undo(id, actor, object):
|
||||
return {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
{}
|
||||
],
|
||||
'type': 'Undo',
|
||||
'id': id + '/undo',
|
||||
'actor': actor.url,
|
||||
'object': object,
|
||||
}
|
||||
|
||||
|
||||
def get_accept_follow(accept_id, accept_actor, follow, follow_actor):
|
||||
return {
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1",
|
||||
{}
|
||||
],
|
||||
"id": accept_actor.url + '#accepts/follows/{}'.format(
|
||||
accept_id),
|
||||
"type": "Accept",
|
||||
"actor": accept_actor.url,
|
||||
"object": {
|
||||
"id": follow['id'],
|
||||
"type": "Follow",
|
||||
"actor": follow_actor.url,
|
||||
"object": accept_actor.url
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def accept_follow(target, follow, actor):
|
||||
accept_uuid = uuid.uuid4()
|
||||
accept = get_accept_follow(
|
||||
accept_id=accept_uuid,
|
||||
accept_actor=target,
|
||||
follow=follow,
|
||||
follow_actor=actor)
|
||||
deliver(
|
||||
accept,
|
||||
to=[actor.url],
|
||||
on_behalf_of=target)
|
||||
return models.Follow.objects.get_or_create(
|
||||
actor=actor,
|
||||
target=target,
|
||||
return tasks.send.delay(
|
||||
activity=activity,
|
||||
actor_id=on_behalf_of.pk,
|
||||
to=to
|
||||
)
|
||||
|
||||
|
||||
def accept_follow(follow):
|
||||
serializer = serializers.AcceptFollowSerializer(follow)
|
||||
return deliver(
|
||||
serializer.data,
|
||||
to=[follow.actor.url],
|
||||
on_behalf_of=follow.target)
|
||||
|
|
|
@ -31,6 +31,8 @@ def remove_tags(text):
|
|||
def get_actor_data(actor_url):
|
||||
response = session.get_session().get(
|
||||
actor_url,
|
||||
timeout=5,
|
||||
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
||||
headers={
|
||||
'Accept': 'application/activity+json',
|
||||
}
|
||||
|
@ -42,6 +44,7 @@ def get_actor_data(actor_url):
|
|||
raise ValueError(
|
||||
'Invalid actor payload: {}'.format(response.text))
|
||||
|
||||
|
||||
def get_actor(actor_url):
|
||||
data = get_actor_data(actor_url)
|
||||
serializer = serializers.ActorSerializer(data=data)
|
||||
|
@ -150,24 +153,32 @@ class SystemActor(object):
|
|||
|
||||
def handle_follow(self, ac, sender):
|
||||
system_actor = self.get_actor_instance()
|
||||
if self.manually_approves_followers:
|
||||
fr, created = models.FollowRequest.objects.get_or_create(
|
||||
actor=sender,
|
||||
target=system_actor,
|
||||
approved=None,
|
||||
)
|
||||
return fr
|
||||
serializer = serializers.FollowSerializer(
|
||||
data=ac, context={'follow_actor': sender})
|
||||
if not serializer.is_valid():
|
||||
return logger.info('Invalid follow payload')
|
||||
approved = True if not self.manually_approves_followers else None
|
||||
follow = serializer.save(approved=approved)
|
||||
if follow.approved:
|
||||
return activity.accept_follow(follow)
|
||||
|
||||
return activity.accept_follow(
|
||||
system_actor, ac, sender
|
||||
)
|
||||
def handle_accept(self, ac, sender):
|
||||
system_actor = self.get_actor_instance()
|
||||
serializer = serializers.AcceptFollowSerializer(
|
||||
data=ac,
|
||||
context={'follow_target': sender, 'follow_actor': system_actor})
|
||||
if not serializer.is_valid(raise_exception=True):
|
||||
return logger.info('Received invalid payload')
|
||||
|
||||
return serializer.save()
|
||||
|
||||
def handle_undo_follow(self, ac, sender):
|
||||
actor = self.get_actor_instance()
|
||||
models.Follow.objects.filter(
|
||||
actor=sender,
|
||||
target=actor,
|
||||
).delete()
|
||||
system_actor = self.get_actor_instance()
|
||||
serializer = serializers.UndoFollowSerializer(
|
||||
data=ac, context={'actor': sender, 'target': system_actor})
|
||||
if not serializer.is_valid():
|
||||
return logger.info('Received invalid payload')
|
||||
serializer.save()
|
||||
|
||||
def handle_undo(self, ac, sender):
|
||||
if ac['object']['type'] != 'Follow':
|
||||
|
@ -343,15 +354,15 @@ class TestActor(SystemActor):
|
|||
super().handle_follow(ac, sender)
|
||||
# also, we follow back
|
||||
test_actor = self.get_actor_instance()
|
||||
follow_uuid = uuid.uuid4()
|
||||
follow = activity.get_follow(
|
||||
follow_id=follow_uuid,
|
||||
follower=test_actor,
|
||||
followed=sender)
|
||||
follow_back = models.Follow.objects.get_or_create(
|
||||
actor=test_actor,
|
||||
target=sender,
|
||||
approved=None,
|
||||
)[0]
|
||||
activity.deliver(
|
||||
follow,
|
||||
to=[ac['actor']],
|
||||
on_behalf_of=test_actor)
|
||||
serializers.FollowSerializer(follow_back).data,
|
||||
to=[follow_back.target.url],
|
||||
on_behalf_of=follow_back.actor)
|
||||
|
||||
def handle_undo_follow(self, ac, sender):
|
||||
super().handle_undo_follow(ac, sender)
|
||||
|
@ -364,11 +375,7 @@ class TestActor(SystemActor):
|
|||
)
|
||||
except models.Follow.DoesNotExist:
|
||||
return
|
||||
undo = activity.get_undo(
|
||||
id=follow.get_federation_url(),
|
||||
actor=actor,
|
||||
object=serializers.FollowSerializer(follow).data,
|
||||
)
|
||||
undo = serializers.UndoFollowSerializer(follow).data
|
||||
follow.delete()
|
||||
activity.deliver(
|
||||
undo,
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
@admin.register(models.Actor)
|
||||
class ActorAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'url',
|
||||
'domain',
|
||||
'preferred_username',
|
||||
'type',
|
||||
'creation_date',
|
||||
'last_fetch_date']
|
||||
search_fields = ['url', 'domain', 'preferred_username']
|
||||
list_filter = [
|
||||
'type'
|
||||
]
|
||||
|
||||
|
||||
@admin.register(models.Follow)
|
||||
class FollowAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'actor',
|
||||
'target',
|
||||
'approved',
|
||||
'creation_date'
|
||||
]
|
||||
list_filter = [
|
||||
'approved'
|
||||
]
|
||||
search_fields = ['actor__url', 'target__url']
|
||||
list_select_related = True
|
||||
|
||||
|
||||
@admin.register(models.Library)
|
||||
class LibraryAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'actor',
|
||||
'url',
|
||||
'creation_date',
|
||||
'fetched_date',
|
||||
'tracks_count']
|
||||
search_fields = ['actor__url', 'url']
|
||||
list_filter = [
|
||||
'federation_enabled',
|
||||
'download_files',
|
||||
'autoimport',
|
||||
]
|
||||
list_select_related = True
|
||||
|
||||
|
||||
@admin.register(models.LibraryTrack)
|
||||
class LibraryTrackAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'title',
|
||||
'artist_name',
|
||||
'album_title',
|
||||
'url',
|
||||
'library',
|
||||
'creation_date',
|
||||
'published_date',
|
||||
]
|
||||
search_fields = [
|
||||
'library__url', 'url', 'artist_name', 'title', 'album_title']
|
||||
list_select_related = True
|
|
@ -0,0 +1,15 @@
|
|||
from rest_framework import routers
|
||||
|
||||
from . import views
|
||||
|
||||
router = routers.SimpleRouter()
|
||||
router.register(
|
||||
r'libraries',
|
||||
views.LibraryViewSet,
|
||||
'libraries')
|
||||
router.register(
|
||||
r'library-tracks',
|
||||
views.LibraryTrackViewSet,
|
||||
'library-tracks')
|
||||
|
||||
urlpatterns = router.urls
|
|
@ -4,3 +4,17 @@ from dynamic_preferences import types
|
|||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
federation = types.Section('federation')
|
||||
|
||||
|
||||
@global_preferences_registry.register
|
||||
class MusicCacheDuration(types.IntPreference):
|
||||
show_in_api = True
|
||||
section = federation
|
||||
name = 'music_cache_duration'
|
||||
default = 60 * 24 * 2
|
||||
verbose_name = 'Music cache duration'
|
||||
help_text = (
|
||||
'How much minutes do you want to keep a copy of federated tracks'
|
||||
'locally? Federated files that were not listened in this interval '
|
||||
'will be erased and refetched from the remote on the next listening.'
|
||||
)
|
||||
|
|
|
@ -113,15 +113,6 @@ class FollowFactory(factory.DjangoModelFactory):
|
|||
)
|
||||
|
||||
|
||||
@registry.register
|
||||
class FollowRequestFactory(factory.DjangoModelFactory):
|
||||
target = factory.SubFactory(ActorFactory)
|
||||
actor = factory.SubFactory(ActorFactory)
|
||||
|
||||
class Meta:
|
||||
model = models.FollowRequest
|
||||
|
||||
|
||||
@registry.register
|
||||
class LibraryFactory(factory.DjangoModelFactory):
|
||||
actor = factory.SubFactory(ActorFactory)
|
||||
|
@ -194,6 +185,11 @@ class LibraryTrackFactory(factory.DjangoModelFactory):
|
|||
class Meta:
|
||||
model = models.LibraryTrack
|
||||
|
||||
class Params:
|
||||
with_audio_file = factory.Trait(
|
||||
audio_file=factory.django.FileField()
|
||||
)
|
||||
|
||||
|
||||
@registry.register(name='federation.Note')
|
||||
class NoteFactory(factory.Factory):
|
||||
|
|
|
@ -0,0 +1,66 @@
|
|||
import django_filters
|
||||
|
||||
from funkwhale_api.common import fields
|
||||
|
||||
from . import models
|
||||
|
||||
|
||||
class LibraryFilter(django_filters.FilterSet):
|
||||
approved = django_filters.BooleanFilter('following__approved')
|
||||
q = fields.SearchFilter(search_fields=[
|
||||
'actor__domain',
|
||||
])
|
||||
|
||||
class Meta:
|
||||
model = models.Library
|
||||
fields = {
|
||||
'approved': ['exact'],
|
||||
'federation_enabled': ['exact'],
|
||||
'download_files': ['exact'],
|
||||
'autoimport': ['exact'],
|
||||
'tracks_count': ['exact'],
|
||||
}
|
||||
|
||||
|
||||
class LibraryTrackFilter(django_filters.FilterSet):
|
||||
library = django_filters.CharFilter('library__uuid')
|
||||
q = fields.SearchFilter(search_fields=[
|
||||
'artist_name',
|
||||
'title',
|
||||
'album_title',
|
||||
'library__actor__domain',
|
||||
])
|
||||
|
||||
class Meta:
|
||||
model = models.LibraryTrack
|
||||
fields = {
|
||||
'library': ['exact'],
|
||||
'artist_name': ['exact', 'icontains'],
|
||||
'title': ['exact', 'icontains'],
|
||||
'album_title': ['exact', 'icontains'],
|
||||
'audio_mimetype': ['exact', 'icontains'],
|
||||
}
|
||||
|
||||
|
||||
class FollowFilter(django_filters.FilterSet):
|
||||
pending = django_filters.CharFilter(method='filter_pending')
|
||||
ordering = django_filters.OrderingFilter(
|
||||
# tuple-mapping retains order
|
||||
fields=(
|
||||
('creation_date', 'creation_date'),
|
||||
('modification_date', 'modification_date'),
|
||||
),
|
||||
)
|
||||
q = fields.SearchFilter(search_fields=[
|
||||
'actor__domain',
|
||||
'actor__preferred_username',
|
||||
])
|
||||
|
||||
class Meta:
|
||||
model = models.Follow
|
||||
fields = ['approved', 'pending', 'q']
|
||||
|
||||
def filter_pending(self, queryset, field_name, value):
|
||||
if value.lower() in ['true', '1', 'yes']:
|
||||
queryset = queryset.filter(approved__isnull=True)
|
||||
return queryset
|
|
@ -0,0 +1,166 @@
|
|||
import json
|
||||
import requests
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from funkwhale_api.common import session
|
||||
|
||||
from . import actors
|
||||
from . import models
|
||||
from . import serializers
|
||||
from . import signing
|
||||
from . import webfinger
|
||||
|
||||
|
||||
def scan_from_account_name(account_name):
|
||||
"""
|
||||
Given an account name such as library@test.library, will:
|
||||
|
||||
1. Perform the webfinger lookup
|
||||
2. Perform the actor lookup
|
||||
3. Perform the library's collection lookup
|
||||
|
||||
and return corresponding data in a dictionary.
|
||||
"""
|
||||
data = {}
|
||||
try:
|
||||
username, domain = webfinger.clean_acct(
|
||||
account_name, ensure_local=False)
|
||||
except serializers.ValidationError:
|
||||
return {
|
||||
'webfinger': {
|
||||
'errors': ['Invalid account string']
|
||||
}
|
||||
}
|
||||
system_library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
library = models.Library.objects.filter(
|
||||
actor__domain=domain,
|
||||
actor__preferred_username=username
|
||||
).select_related('actor').first()
|
||||
data['local'] = {
|
||||
'following': False,
|
||||
'awaiting_approval': False,
|
||||
}
|
||||
try:
|
||||
follow = models.Follow.objects.get(
|
||||
target__preferred_username=username,
|
||||
target__domain=username,
|
||||
actor=system_library,
|
||||
)
|
||||
data['local']['awaiting_approval'] = not bool(follow.approved)
|
||||
data['local']['following'] = True
|
||||
except models.Follow.DoesNotExist:
|
||||
pass
|
||||
|
||||
try:
|
||||
data['webfinger'] = webfinger.get_resource(
|
||||
'acct:{}'.format(account_name))
|
||||
except requests.ConnectionError:
|
||||
return {
|
||||
'webfinger': {
|
||||
'errors': ['This webfinger resource is not reachable']
|
||||
}
|
||||
}
|
||||
except requests.HTTPError as e:
|
||||
return {
|
||||
'webfinger': {
|
||||
'errors': [
|
||||
'Error {} during webfinger request'.format(
|
||||
e.response.status_code)]
|
||||
}
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
return {
|
||||
'webfinger': {
|
||||
'errors': ['Could not process webfinger response']
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
data['actor'] = actors.get_actor_data(data['webfinger']['actor_url'])
|
||||
except requests.ConnectionError:
|
||||
data['actor'] = {
|
||||
'errors': ['This actor is not reachable']
|
||||
}
|
||||
return data
|
||||
except requests.HTTPError as e:
|
||||
data['actor'] = {
|
||||
'errors': [
|
||||
'Error {} during actor request'.format(
|
||||
e.response.status_code)]
|
||||
}
|
||||
return data
|
||||
|
||||
serializer = serializers.LibraryActorSerializer(data=data['actor'])
|
||||
if not serializer.is_valid():
|
||||
data['actor'] = {
|
||||
'errors': ['Invalid ActivityPub actor']
|
||||
}
|
||||
return data
|
||||
data['library'] = get_library_data(
|
||||
serializer.validated_data['library_url'])
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def get_library_data(library_url):
|
||||
actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
auth = signing.get_auth(actor.private_key, actor.private_key_id)
|
||||
try:
|
||||
response = session.get_session().get(
|
||||
library_url,
|
||||
auth=auth,
|
||||
timeout=5,
|
||||
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
||||
headers={
|
||||
'Content-Type': 'application/activity+json'
|
||||
}
|
||||
)
|
||||
except requests.ConnectionError:
|
||||
return {
|
||||
'errors': ['This library is not reachable']
|
||||
}
|
||||
scode = response.status_code
|
||||
if scode == 401:
|
||||
return {
|
||||
'errors': ['This library requires authentication']
|
||||
}
|
||||
elif scode == 403:
|
||||
return {
|
||||
'errors': ['Permission denied while scanning library']
|
||||
}
|
||||
elif scode >= 400:
|
||||
return {
|
||||
'errors': ['Error {} while fetching the library'.format(scode)]
|
||||
}
|
||||
serializer = serializers.PaginatedCollectionSerializer(
|
||||
data=response.json(),
|
||||
)
|
||||
if not serializer.is_valid():
|
||||
return {
|
||||
'errors': [
|
||||
'Invalid ActivityPub response from remote library']
|
||||
}
|
||||
|
||||
return serializer.validated_data
|
||||
|
||||
|
||||
def get_library_page(library, page_url):
|
||||
actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
auth = signing.get_auth(actor.private_key, actor.private_key_id)
|
||||
response = session.get_session().get(
|
||||
page_url,
|
||||
auth=auth,
|
||||
timeout=5,
|
||||
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
||||
headers={
|
||||
'Content-Type': 'application/activity+json'
|
||||
}
|
||||
)
|
||||
serializer = serializers.CollectionPageSerializer(
|
||||
data=response.json(),
|
||||
context={
|
||||
'library': library,
|
||||
'item_serializer': serializers.AudioSerializer})
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return serializer.validated_data
|
|
@ -0,0 +1,35 @@
|
|||
# Generated by Django 2.0.3 on 2018-04-10 20:25
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('federation', '0003_auto_20180407_1010'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='followrequest',
|
||||
name='actor',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='followrequest',
|
||||
name='target',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='follow',
|
||||
name='approved',
|
||||
field=models.NullBooleanField(default=None),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='library',
|
||||
name='follow',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='library', to='federation.Follow'),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='FollowRequest',
|
||||
),
|
||||
]
|
|
@ -0,0 +1,26 @@
|
|||
# Generated by Django 2.0.3 on 2018-04-13 17:23
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
import django.core.serializers.json
|
||||
from django.db import migrations, models
|
||||
import funkwhale_api.federation.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('federation', '0004_auto_20180410_2025'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='librarytrack',
|
||||
name='audio_file',
|
||||
field=models.FileField(blank=True, null=True, upload_to=funkwhale_api.federation.models.get_file_path),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='librarytrack',
|
||||
name='metadata',
|
||||
field=django.contrib.postgres.fields.jsonb.JSONField(default={}, encoder=django.core.serializers.json.DjangoJSONEncoder, max_length=10000),
|
||||
),
|
||||
]
|
|
@ -1,10 +1,16 @@
|
|||
import os
|
||||
import uuid
|
||||
import tempfile
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.fields import JSONField
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
|
||||
from funkwhale_api.common import session
|
||||
from funkwhale_api.music import utils as music_utils
|
||||
|
||||
TYPE_CHOICES = [
|
||||
('Person', 'Person'),
|
||||
('Application', 'Application'),
|
||||
|
@ -109,6 +115,7 @@ class Follow(models.Model):
|
|||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
modification_date = models.DateTimeField(
|
||||
auto_now=True)
|
||||
approved = models.NullBooleanField(default=None)
|
||||
|
||||
class Meta:
|
||||
unique_together = ['actor', 'target']
|
||||
|
@ -117,49 +124,6 @@ class Follow(models.Model):
|
|||
return '{}#follows/{}'.format(self.actor.url, self.uuid)
|
||||
|
||||
|
||||
class FollowRequest(models.Model):
|
||||
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
|
||||
actor = models.ForeignKey(
|
||||
Actor,
|
||||
related_name='emmited_follow_requests',
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
target = models.ForeignKey(
|
||||
Actor,
|
||||
related_name='received_follow_requests',
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
modification_date = models.DateTimeField(
|
||||
auto_now=True)
|
||||
approved = models.NullBooleanField(default=None)
|
||||
|
||||
def approve(self):
|
||||
from . import activity
|
||||
from . import serializers
|
||||
self.approved = True
|
||||
self.save(update_fields=['approved'])
|
||||
Follow.objects.get_or_create(
|
||||
target=self.target,
|
||||
actor=self.actor
|
||||
)
|
||||
if self.target.is_local:
|
||||
follow = {
|
||||
'@context': serializers.AP_CONTEXT,
|
||||
'actor': self.actor.url,
|
||||
'id': self.actor.url + '#follows/{}'.format(uuid.uuid4()),
|
||||
'object': self.target.url,
|
||||
'type': 'Follow'
|
||||
}
|
||||
activity.accept_follow(
|
||||
self.target, follow, self.actor
|
||||
)
|
||||
|
||||
def refuse(self):
|
||||
self.approved = False
|
||||
self.save(update_fields=['approved'])
|
||||
|
||||
|
||||
class Library(models.Model):
|
||||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
modification_date = models.DateTimeField(
|
||||
|
@ -179,12 +143,32 @@ class Library(models.Model):
|
|||
# should we automatically import new files from this library?
|
||||
autoimport = models.BooleanField()
|
||||
tracks_count = models.PositiveIntegerField(null=True, blank=True)
|
||||
follow = models.OneToOneField(
|
||||
Follow,
|
||||
related_name='library',
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
)
|
||||
|
||||
|
||||
def get_file_path(instance, filename):
|
||||
uid = str(uuid.uuid4())
|
||||
chunk_size = 2
|
||||
chunks = [uid[i:i+chunk_size] for i in range(0, len(uid), chunk_size)]
|
||||
parts = chunks[:3] + [filename]
|
||||
return os.path.join('federation_cache', *parts)
|
||||
|
||||
|
||||
class LibraryTrack(models.Model):
|
||||
url = models.URLField(unique=True)
|
||||
audio_url = models.URLField()
|
||||
audio_mimetype = models.CharField(max_length=200)
|
||||
audio_file = models.FileField(
|
||||
upload_to=get_file_path,
|
||||
null=True,
|
||||
blank=True)
|
||||
|
||||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
modification_date = models.DateTimeField(
|
||||
auto_now=True)
|
||||
|
@ -195,4 +179,35 @@ class LibraryTrack(models.Model):
|
|||
artist_name = models.CharField(max_length=500)
|
||||
album_title = models.CharField(max_length=500)
|
||||
title = models.CharField(max_length=500)
|
||||
metadata = JSONField(default={}, max_length=10000)
|
||||
metadata = JSONField(
|
||||
default={}, max_length=10000, encoder=DjangoJSONEncoder)
|
||||
|
||||
@property
|
||||
def mbid(self):
|
||||
try:
|
||||
return self.metadata['recording']['musicbrainz_id']
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def download_audio(self):
|
||||
from . import actors
|
||||
auth = actors.SYSTEM_ACTORS['library'].get_request_auth()
|
||||
remote_response = session.get_session().get(
|
||||
self.audio_url,
|
||||
auth=auth,
|
||||
stream=True,
|
||||
timeout=20,
|
||||
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
||||
headers={
|
||||
'Content-Type': 'application/activity+json'
|
||||
}
|
||||
)
|
||||
with remote_response as r:
|
||||
remote_response.raise_for_status()
|
||||
extension = music_utils.get_ext_from_type(self.audio_mimetype)
|
||||
title = ' - '.join([self.title, self.album_title, self.artist_name])
|
||||
filename = '{}.{}'.format(title, extension)
|
||||
tmp_file = tempfile.TemporaryFile()
|
||||
for chunk in r.iter_content(chunk_size=512):
|
||||
tmp_file.write(chunk)
|
||||
self.audio_file.save(filename, tmp_file)
|
||||
|
|
|
@ -8,7 +8,7 @@ from django.db import transaction
|
|||
from rest_framework import serializers
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
from funkwhale_api.common.utils import set_query_parameter
|
||||
from funkwhale_api.common import utils as funkwhale_utils
|
||||
|
||||
from . import activity
|
||||
from . import models
|
||||
|
@ -21,38 +21,39 @@ AP_CONTEXT = [
|
|||
{},
|
||||
]
|
||||
|
||||
class ActorSerializer(serializers.ModelSerializer):
|
||||
# left maps to activitypub fields, right to our internal models
|
||||
id = serializers.URLField(source='url')
|
||||
outbox = serializers.URLField(source='outbox_url')
|
||||
inbox = serializers.URLField(source='inbox_url')
|
||||
following = serializers.URLField(source='following_url', required=False)
|
||||
followers = serializers.URLField(source='followers_url', required=False)
|
||||
preferredUsername = serializers.CharField(
|
||||
source='preferred_username', required=False)
|
||||
publicKey = serializers.JSONField(source='public_key', required=False)
|
||||
manuallyApprovesFollowers = serializers.NullBooleanField(
|
||||
source='manually_approves_followers', required=False)
|
||||
summary = serializers.CharField(max_length=None, required=False)
|
||||
|
||||
class Meta:
|
||||
model = models.Actor
|
||||
fields = [
|
||||
'id',
|
||||
'type',
|
||||
'name',
|
||||
'summary',
|
||||
'preferredUsername',
|
||||
'publicKey',
|
||||
'inbox',
|
||||
'outbox',
|
||||
'following',
|
||||
'followers',
|
||||
'manuallyApprovesFollowers',
|
||||
]
|
||||
class ActorSerializer(serializers.Serializer):
|
||||
id = serializers.URLField()
|
||||
outbox = serializers.URLField()
|
||||
inbox = serializers.URLField()
|
||||
type = serializers.ChoiceField(choices=models.TYPE_CHOICES)
|
||||
preferredUsername = serializers.CharField()
|
||||
manuallyApprovesFollowers = serializers.NullBooleanField(required=False)
|
||||
name = serializers.CharField(required=False, max_length=200)
|
||||
summary = serializers.CharField(max_length=None, required=False)
|
||||
followers = serializers.URLField(required=False, allow_null=True)
|
||||
following = serializers.URLField(required=False, allow_null=True)
|
||||
publicKey = serializers.JSONField(required=False)
|
||||
|
||||
def to_representation(self, instance):
|
||||
ret = super().to_representation(instance)
|
||||
ret = {
|
||||
'id': instance.url,
|
||||
'outbox': instance.outbox_url,
|
||||
'inbox': instance.inbox_url,
|
||||
'preferredUsername': instance.preferred_username,
|
||||
'type': instance.type,
|
||||
}
|
||||
if instance.name:
|
||||
ret['name'] = instance.name
|
||||
if instance.followers_url:
|
||||
ret['followers'] = instance.followers_url
|
||||
if instance.following_url:
|
||||
ret['following'] = instance.following_url
|
||||
if instance.summary:
|
||||
ret['summary'] = instance.summary
|
||||
if instance.manually_approves_followers is not None:
|
||||
ret['manuallyApprovesFollowers'] = instance.manually_approves_followers
|
||||
|
||||
ret['@context'] = AP_CONTEXT
|
||||
if instance.public_key:
|
||||
ret['publicKey'] = {
|
||||
|
@ -66,8 +67,21 @@ class ActorSerializer(serializers.ModelSerializer):
|
|||
return ret
|
||||
|
||||
def prepare_missing_fields(self):
|
||||
kwargs = {}
|
||||
domain = urllib.parse.urlparse(self.validated_data['url']).netloc
|
||||
kwargs = {
|
||||
'url': self.validated_data['id'],
|
||||
'outbox_url': self.validated_data['outbox'],
|
||||
'inbox_url': self.validated_data['inbox'],
|
||||
'following_url': self.validated_data.get('following'),
|
||||
'followers_url': self.validated_data.get('followers'),
|
||||
'summary': self.validated_data.get('summary'),
|
||||
'type': self.validated_data['type'],
|
||||
'name': self.validated_data.get('name'),
|
||||
'preferred_username': self.validated_data['preferredUsername'],
|
||||
}
|
||||
maf = self.validated_data.get('manuallyApprovesFollowers')
|
||||
if maf is not None:
|
||||
kwargs['manually_approves_followers'] = maf
|
||||
domain = urllib.parse.urlparse(kwargs['url']).netloc
|
||||
kwargs['domain'] = domain
|
||||
for endpoint, url in self.initial_data.get('endpoints', {}).items():
|
||||
if endpoint == 'sharedInbox':
|
||||
|
@ -80,45 +94,386 @@ class ActorSerializer(serializers.ModelSerializer):
|
|||
return kwargs
|
||||
|
||||
def build(self):
|
||||
d = self.validated_data.copy()
|
||||
d.update(self.prepare_missing_fields())
|
||||
return self.Meta.model(**d)
|
||||
d = self.prepare_missing_fields()
|
||||
return models.Actor(**d)
|
||||
|
||||
def save(self, **kwargs):
|
||||
kwargs.update(self.prepare_missing_fields())
|
||||
return super().save(**kwargs)
|
||||
d = self.prepare_missing_fields()
|
||||
d.update(kwargs)
|
||||
return models.Actor.objects.create(
|
||||
**d
|
||||
)
|
||||
|
||||
def validate_summary(self, value):
|
||||
if value:
|
||||
return value[:500]
|
||||
|
||||
|
||||
class FollowSerializer(serializers.ModelSerializer):
|
||||
# left maps to activitypub fields, right to our internal models
|
||||
id = serializers.URLField(source='get_federation_url')
|
||||
object = serializers.URLField(source='target.url')
|
||||
actor = serializers.URLField(source='actor.url')
|
||||
type = serializers.CharField(source='ap_type')
|
||||
|
||||
class APIActorSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Actor
|
||||
fields = [
|
||||
'id',
|
||||
'object',
|
||||
'actor',
|
||||
'type'
|
||||
'url',
|
||||
'creation_date',
|
||||
'summary',
|
||||
'preferred_username',
|
||||
'name',
|
||||
'last_fetch_date',
|
||||
'domain',
|
||||
'type',
|
||||
'manually_approves_followers',
|
||||
|
||||
]
|
||||
|
||||
|
||||
class LibraryActorSerializer(ActorSerializer):
|
||||
url = serializers.ListField(
|
||||
child=serializers.JSONField())
|
||||
|
||||
def validate(self, validated_data):
|
||||
try:
|
||||
urls = validated_data['url']
|
||||
except KeyError:
|
||||
raise serializers.ValidationError('Missing URL field')
|
||||
|
||||
for u in urls:
|
||||
try:
|
||||
if u['name'] != 'library':
|
||||
continue
|
||||
validated_data['library_url'] = u['href']
|
||||
break
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
return validated_data
|
||||
|
||||
|
||||
class APIFollowSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = models.Follow
|
||||
fields = [
|
||||
'uuid',
|
||||
'actor',
|
||||
'target',
|
||||
'approved',
|
||||
'creation_date',
|
||||
'modification_date',
|
||||
]
|
||||
|
||||
|
||||
class APILibrarySerializer(serializers.ModelSerializer):
|
||||
actor = APIActorSerializer()
|
||||
follow = APIFollowSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.Library
|
||||
|
||||
read_only_fields = [
|
||||
'actor',
|
||||
'uuid',
|
||||
'url',
|
||||
'tracks_count',
|
||||
'follow',
|
||||
'fetched_date',
|
||||
'modification_date',
|
||||
'creation_date',
|
||||
]
|
||||
fields = [
|
||||
'autoimport',
|
||||
'federation_enabled',
|
||||
'download_files',
|
||||
] + read_only_fields
|
||||
|
||||
|
||||
class APILibraryScanSerializer(serializers.Serializer):
|
||||
until = serializers.DateTimeField(required=False)
|
||||
|
||||
|
||||
class APILibraryFollowUpdateSerializer(serializers.Serializer):
|
||||
follow = serializers.IntegerField()
|
||||
approved = serializers.BooleanField()
|
||||
|
||||
def validate_follow(self, value):
|
||||
from . import actors
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
qs = models.Follow.objects.filter(
|
||||
pk=value,
|
||||
target=library_actor,
|
||||
)
|
||||
try:
|
||||
return qs.get()
|
||||
except models.Follow.DoesNotExist:
|
||||
raise serializers.ValidationError('Invalid follow')
|
||||
|
||||
def save(self):
|
||||
new_status = self.validated_data['approved']
|
||||
follow = self.validated_data['follow']
|
||||
if new_status == follow.approved:
|
||||
return follow
|
||||
|
||||
follow.approved = new_status
|
||||
follow.save(update_fields=['approved', 'modification_date'])
|
||||
if new_status:
|
||||
activity.accept_follow(follow)
|
||||
return follow
|
||||
|
||||
|
||||
class APILibraryCreateSerializer(serializers.ModelSerializer):
|
||||
actor = serializers.URLField()
|
||||
federation_enabled = serializers.BooleanField()
|
||||
uuid = serializers.UUIDField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Library
|
||||
fields = [
|
||||
'uuid',
|
||||
'actor',
|
||||
'autoimport',
|
||||
'federation_enabled',
|
||||
'download_files',
|
||||
]
|
||||
|
||||
def validate(self, validated_data):
|
||||
from . import actors
|
||||
from . import library
|
||||
|
||||
actor_url = validated_data['actor']
|
||||
actor_data = actors.get_actor_data(actor_url)
|
||||
acs = LibraryActorSerializer(data=actor_data)
|
||||
acs.is_valid(raise_exception=True)
|
||||
try:
|
||||
actor = models.Actor.objects.get(url=actor_url)
|
||||
except models.Actor.DoesNotExist:
|
||||
actor = acs.save()
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
validated_data['follow'] = models.Follow.objects.get_or_create(
|
||||
actor=library_actor,
|
||||
target=actor,
|
||||
)[0]
|
||||
if validated_data['follow'].approved is None:
|
||||
funkwhale_utils.on_commit(
|
||||
activity.deliver,
|
||||
FollowSerializer(validated_data['follow']).data,
|
||||
on_behalf_of=validated_data['follow'].actor,
|
||||
to=[validated_data['follow'].target.url],
|
||||
)
|
||||
|
||||
library_data = library.get_library_data(
|
||||
acs.validated_data['library_url'])
|
||||
if 'errors' in library_data:
|
||||
# we pass silently because it may means we require permission
|
||||
# before scanning
|
||||
pass
|
||||
validated_data['library'] = library_data
|
||||
validated_data['library'].setdefault(
|
||||
'id', acs.validated_data['library_url']
|
||||
)
|
||||
validated_data['actor'] = actor
|
||||
return validated_data
|
||||
|
||||
def create(self, validated_data):
|
||||
library = models.Library.objects.update_or_create(
|
||||
url=validated_data['library']['id'],
|
||||
defaults={
|
||||
'actor': validated_data['actor'],
|
||||
'follow': validated_data['follow'],
|
||||
'tracks_count': validated_data['library'].get('totalItems'),
|
||||
'federation_enabled': validated_data['federation_enabled'],
|
||||
'autoimport': validated_data['autoimport'],
|
||||
'download_files': validated_data['download_files'],
|
||||
}
|
||||
)[0]
|
||||
return library
|
||||
|
||||
|
||||
class APILibraryTrackSerializer(serializers.ModelSerializer):
|
||||
library = APILibrarySerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.LibraryTrack
|
||||
fields = [
|
||||
'id',
|
||||
'url',
|
||||
'audio_url',
|
||||
'audio_mimetype',
|
||||
'creation_date',
|
||||
'modification_date',
|
||||
'fetched_date',
|
||||
'published_date',
|
||||
'metadata',
|
||||
'artist_name',
|
||||
'album_title',
|
||||
'title',
|
||||
'library',
|
||||
'local_track_file',
|
||||
]
|
||||
|
||||
|
||||
class FollowSerializer(serializers.Serializer):
|
||||
id = serializers.URLField()
|
||||
object = serializers.URLField()
|
||||
actor = serializers.URLField()
|
||||
type = serializers.ChoiceField(choices=['Follow'])
|
||||
|
||||
def validate_object(self, v):
|
||||
expected = self.context.get('follow_target')
|
||||
if expected and expected.url != v:
|
||||
raise serializers.ValidationError('Invalid target')
|
||||
try:
|
||||
return models.Actor.objects.get(url=v)
|
||||
except models.Actor.DoesNotExist:
|
||||
raise serializers.ValidationError('Target not found')
|
||||
|
||||
def validate_actor(self, v):
|
||||
expected = self.context.get('follow_actor')
|
||||
if expected and expected.url != v:
|
||||
raise serializers.ValidationError('Invalid actor')
|
||||
try:
|
||||
return models.Actor.objects.get(url=v)
|
||||
except models.Actor.DoesNotExist:
|
||||
raise serializers.ValidationError('Actor not found')
|
||||
|
||||
def save(self, **kwargs):
|
||||
return models.Follow.objects.get_or_create(
|
||||
actor=self.validated_data['actor'],
|
||||
target=self.validated_data['object'],
|
||||
**kwargs,
|
||||
)[0]
|
||||
|
||||
def to_representation(self, instance):
|
||||
ret = super().to_representation(instance)
|
||||
ret['@context'] = AP_CONTEXT
|
||||
return {
|
||||
'@context': AP_CONTEXT,
|
||||
'actor': instance.actor.url,
|
||||
'id': instance.get_federation_url(),
|
||||
'object': instance.target.url,
|
||||
'type': 'Follow'
|
||||
}
|
||||
return ret
|
||||
|
||||
|
||||
class ActorWebfingerSerializer(serializers.ModelSerializer):
|
||||
class APIFollowSerializer(serializers.ModelSerializer):
|
||||
actor = APIActorSerializer()
|
||||
target = APIActorSerializer()
|
||||
|
||||
class Meta:
|
||||
model = models.Actor
|
||||
fields = ['url']
|
||||
model = models.Follow
|
||||
fields = [
|
||||
'uuid',
|
||||
'id',
|
||||
'approved',
|
||||
'creation_date',
|
||||
'modification_date',
|
||||
'actor',
|
||||
'target',
|
||||
]
|
||||
|
||||
|
||||
class AcceptFollowSerializer(serializers.Serializer):
|
||||
id = serializers.URLField()
|
||||
actor = serializers.URLField()
|
||||
object = FollowSerializer()
|
||||
type = serializers.ChoiceField(choices=['Accept'])
|
||||
|
||||
def validate_actor(self, v):
|
||||
expected = self.context.get('follow_target')
|
||||
if expected and expected.url != v:
|
||||
raise serializers.ValidationError('Invalid actor')
|
||||
try:
|
||||
return models.Actor.objects.get(url=v)
|
||||
except models.Actor.DoesNotExist:
|
||||
raise serializers.ValidationError('Actor not found')
|
||||
|
||||
def validate(self, validated_data):
|
||||
# we ensure the accept actor actually match the follow target
|
||||
if validated_data['actor'] != validated_data['object']['object']:
|
||||
raise serializers.ValidationError('Actor mismatch')
|
||||
try:
|
||||
validated_data['follow'] = models.Follow.objects.filter(
|
||||
target=validated_data['actor'],
|
||||
actor=validated_data['object']['actor']
|
||||
).exclude(approved=True).get()
|
||||
except models.Follow.DoesNotExist:
|
||||
raise serializers.ValidationError('No follow to accept')
|
||||
return validated_data
|
||||
|
||||
def to_representation(self, instance):
|
||||
return {
|
||||
"@context": AP_CONTEXT,
|
||||
"id": instance.get_federation_url() + '/accept',
|
||||
"type": "Accept",
|
||||
"actor": instance.target.url,
|
||||
"object": FollowSerializer(instance).data
|
||||
}
|
||||
|
||||
def save(self):
|
||||
self.validated_data['follow'].approved = True
|
||||
self.validated_data['follow'].save()
|
||||
return self.validated_data['follow']
|
||||
|
||||
|
||||
class UndoFollowSerializer(serializers.Serializer):
|
||||
id = serializers.URLField()
|
||||
actor = serializers.URLField()
|
||||
object = FollowSerializer()
|
||||
type = serializers.ChoiceField(choices=['Undo'])
|
||||
|
||||
def validate_actor(self, v):
|
||||
expected = self.context.get('follow_target')
|
||||
if expected and expected.url != v:
|
||||
raise serializers.ValidationError('Invalid actor')
|
||||
try:
|
||||
return models.Actor.objects.get(url=v)
|
||||
except models.Actor.DoesNotExist:
|
||||
raise serializers.ValidationError('Actor not found')
|
||||
|
||||
def validate(self, validated_data):
|
||||
# we ensure the accept actor actually match the follow actor
|
||||
if validated_data['actor'] != validated_data['object']['actor']:
|
||||
raise serializers.ValidationError('Actor mismatch')
|
||||
try:
|
||||
validated_data['follow'] = models.Follow.objects.filter(
|
||||
actor=validated_data['actor'],
|
||||
target=validated_data['object']['object']
|
||||
).get()
|
||||
except models.Follow.DoesNotExist:
|
||||
raise serializers.ValidationError('No follow to remove')
|
||||
return validated_data
|
||||
|
||||
def to_representation(self, instance):
|
||||
return {
|
||||
"@context": AP_CONTEXT,
|
||||
"id": instance.get_federation_url() + '/undo',
|
||||
"type": "Undo",
|
||||
"actor": instance.actor.url,
|
||||
"object": FollowSerializer(instance).data
|
||||
}
|
||||
|
||||
def save(self):
|
||||
return self.validated_data['follow'].delete()
|
||||
|
||||
|
||||
class ActorWebfingerSerializer(serializers.Serializer):
|
||||
subject = serializers.CharField()
|
||||
aliases = serializers.ListField(child=serializers.URLField())
|
||||
links = serializers.ListField()
|
||||
actor_url = serializers.URLField(required=False)
|
||||
|
||||
def validate(self, validated_data):
|
||||
validated_data['actor_url'] = None
|
||||
for l in validated_data['links']:
|
||||
try:
|
||||
if not l['rel'] == 'self':
|
||||
continue
|
||||
if not l['type'] == 'application/activity+json':
|
||||
continue
|
||||
validated_data['actor_url'] = l['href']
|
||||
break
|
||||
except KeyError:
|
||||
pass
|
||||
if validated_data['actor_url'] is None:
|
||||
raise serializers.ValidationError('No valid actor url found')
|
||||
return validated_data
|
||||
|
||||
def to_representation(self, instance):
|
||||
data = {}
|
||||
|
@ -206,15 +561,22 @@ OBJECT_SERIALIZERS = {
|
|||
|
||||
|
||||
class PaginatedCollectionSerializer(serializers.Serializer):
|
||||
type = serializers.ChoiceField(choices=['Collection'])
|
||||
totalItems = serializers.IntegerField(min_value=0)
|
||||
actor = serializers.URLField()
|
||||
id = serializers.URLField()
|
||||
first = serializers.URLField()
|
||||
last = serializers.URLField()
|
||||
|
||||
def to_representation(self, conf):
|
||||
paginator = Paginator(
|
||||
conf['items'],
|
||||
conf.get('page_size', 20)
|
||||
)
|
||||
first = set_query_parameter(conf['id'], page=1)
|
||||
first = funkwhale_utils.set_query_parameter(conf['id'], page=1)
|
||||
current = first
|
||||
last = set_query_parameter(conf['id'], page=paginator.num_pages)
|
||||
last = funkwhale_utils.set_query_parameter(
|
||||
conf['id'], page=paginator.num_pages)
|
||||
d = {
|
||||
'id': conf['id'],
|
||||
'actor': conf['actor'].url,
|
||||
|
@ -230,12 +592,35 @@ class PaginatedCollectionSerializer(serializers.Serializer):
|
|||
|
||||
|
||||
class CollectionPageSerializer(serializers.Serializer):
|
||||
type = serializers.ChoiceField(choices=['CollectionPage'])
|
||||
totalItems = serializers.IntegerField(min_value=0)
|
||||
items = serializers.ListField()
|
||||
actor = serializers.URLField()
|
||||
id = serializers.URLField()
|
||||
first = serializers.URLField()
|
||||
last = serializers.URLField()
|
||||
next = serializers.URLField(required=False)
|
||||
prev = serializers.URLField(required=False)
|
||||
partOf = serializers.URLField()
|
||||
|
||||
def validate_items(self, v):
|
||||
item_serializer = self.context.get('item_serializer')
|
||||
if not item_serializer:
|
||||
return v
|
||||
raw_items = [item_serializer(data=i, context=self.context) for i in v]
|
||||
for i in raw_items:
|
||||
i.is_valid(raise_exception=True)
|
||||
|
||||
return raw_items
|
||||
|
||||
def to_representation(self, conf):
|
||||
page = conf['page']
|
||||
first = set_query_parameter(conf['id'], page=1)
|
||||
last = set_query_parameter(conf['id'], page=page.paginator.num_pages)
|
||||
id = set_query_parameter(conf['id'], page=page.number)
|
||||
first = funkwhale_utils.set_query_parameter(
|
||||
conf['id'], page=1)
|
||||
last = funkwhale_utils.set_query_parameter(
|
||||
conf['id'], page=page.paginator.num_pages)
|
||||
id = funkwhale_utils.set_query_parameter(
|
||||
conf['id'], page=page.number)
|
||||
d = {
|
||||
'id': id,
|
||||
'partOf': conf['id'],
|
||||
|
@ -256,11 +641,11 @@ class CollectionPageSerializer(serializers.Serializer):
|
|||
}
|
||||
|
||||
if page.has_previous():
|
||||
d['prev'] = set_query_parameter(
|
||||
d['prev'] = funkwhale_utils.set_query_parameter(
|
||||
conf['id'], page=page.previous_page_number())
|
||||
|
||||
if page.has_previous():
|
||||
d['next'] = set_query_parameter(
|
||||
if page.has_next():
|
||||
d['next'] = funkwhale_utils.set_query_parameter(
|
||||
conf['id'], page=page.next_page_number())
|
||||
|
||||
if self.context.get('include_ap_context', True):
|
||||
|
|
|
@ -0,0 +1,111 @@
|
|||
import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from requests.exceptions import RequestException
|
||||
from dynamic_preferences.registries import global_preferences_registry
|
||||
|
||||
from funkwhale_api.common import session
|
||||
from funkwhale_api.history.models import Listening
|
||||
from funkwhale_api.taskapp import celery
|
||||
|
||||
from . import actors
|
||||
from . import library as lb
|
||||
from . import models
|
||||
from . import signing
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@celery.app.task(
|
||||
name='federation.send',
|
||||
autoretry_for=[RequestException],
|
||||
retry_backoff=30,
|
||||
max_retries=5)
|
||||
@celery.require_instance(models.Actor, 'actor')
|
||||
def send(activity, actor, to):
|
||||
logger.info('Preparing activity delivery to %s', to)
|
||||
auth = signing.get_auth(
|
||||
actor.private_key, actor.private_key_id)
|
||||
for url in to:
|
||||
recipient_actor = actors.get_actor(url)
|
||||
logger.debug('delivering to %s', recipient_actor.inbox_url)
|
||||
logger.debug('activity content: %s', json.dumps(activity))
|
||||
response = session.get_session().post(
|
||||
auth=auth,
|
||||
json=activity,
|
||||
url=recipient_actor.inbox_url,
|
||||
timeout=5,
|
||||
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
||||
headers={
|
||||
'Content-Type': 'application/activity+json'
|
||||
}
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.debug('Remote answered with %s', response.status_code)
|
||||
|
||||
|
||||
@celery.app.task(
|
||||
name='federation.scan_library',
|
||||
autoretry_for=[RequestException],
|
||||
retry_backoff=30,
|
||||
max_retries=5)
|
||||
@celery.require_instance(models.Library, 'library')
|
||||
def scan_library(library, until=None):
|
||||
if not library.federation_enabled:
|
||||
return
|
||||
|
||||
data = lb.get_library_data(library.url)
|
||||
scan_library_page.delay(
|
||||
library_id=library.id, page_url=data['first'], until=until)
|
||||
library.fetched_date = timezone.now()
|
||||
library.tracks_count = data['totalItems']
|
||||
library.save(update_fields=['fetched_date', 'tracks_count'])
|
||||
|
||||
|
||||
@celery.app.task(
|
||||
name='federation.scan_library_page',
|
||||
autoretry_for=[RequestException],
|
||||
retry_backoff=30,
|
||||
max_retries=5)
|
||||
@celery.require_instance(models.Library, 'library')
|
||||
def scan_library_page(library, page_url, until=None):
|
||||
if not library.federation_enabled:
|
||||
return
|
||||
|
||||
data = lb.get_library_page(library, page_url)
|
||||
lts = []
|
||||
for item_serializer in data['items']:
|
||||
item_date = item_serializer.validated_data['published']
|
||||
if until and item_date < until:
|
||||
return
|
||||
lts.append(item_serializer.save())
|
||||
|
||||
next_page = data.get('next')
|
||||
if next_page and next_page != page_url:
|
||||
scan_library_page.delay(library_id=library.id, page_url=next_page)
|
||||
|
||||
|
||||
@celery.app.task(name='federation.clean_music_cache')
|
||||
def clean_music_cache():
|
||||
preferences = global_preferences_registry.manager()
|
||||
delay = preferences['federation__music_cache_duration']
|
||||
if delay < 1:
|
||||
return # cache clearing disabled
|
||||
|
||||
candidates = models.LibraryTrack.objects.filter(
|
||||
audio_file__isnull=False
|
||||
).values_list('local_track_file__track', flat=True)
|
||||
listenings = Listening.objects.filter(
|
||||
creation_date__gte=timezone.now() - datetime.timedelta(minutes=delay),
|
||||
track__pk__in=candidates).values_list('track', flat=True)
|
||||
too_old = set(candidates) - set(listenings)
|
||||
|
||||
to_remove = models.LibraryTrack.objects.filter(
|
||||
local_track_file__track__pk__in=too_old).only('audio_file')
|
||||
for lt in to_remove:
|
||||
lt.audio_file.delete()
|
|
@ -1,21 +1,32 @@
|
|||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core import paginator
|
||||
from django.db import transaction
|
||||
from django.http import HttpResponse
|
||||
from django.urls import reverse
|
||||
|
||||
from rest_framework import viewsets
|
||||
from rest_framework import views
|
||||
from rest_framework import mixins
|
||||
from rest_framework import permissions as rest_permissions
|
||||
from rest_framework import response
|
||||
from rest_framework import views
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import list_route, detail_route
|
||||
from rest_framework.serializers import ValidationError
|
||||
|
||||
from funkwhale_api.common import utils as funkwhale_utils
|
||||
from funkwhale_api.common.permissions import HasModelPermission
|
||||
from funkwhale_api.music.models import TrackFile
|
||||
|
||||
from . import activity
|
||||
from . import actors
|
||||
from . import authentication
|
||||
from . import filters
|
||||
from . import library
|
||||
from . import models
|
||||
from . import permissions
|
||||
from . import renderers
|
||||
from . import serializers
|
||||
from . import tasks
|
||||
from . import utils
|
||||
from . import webfinger
|
||||
|
||||
|
@ -58,7 +69,7 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
|
|||
data = handler(request.data, actor=request.actor)
|
||||
except NotImplementedError:
|
||||
return response.Response(status=405)
|
||||
return response.Response(data, status=200)
|
||||
return response.Response({}, status=200)
|
||||
|
||||
@detail_route(methods=['get', 'post'])
|
||||
def outbox(self, request, *args, **kwargs):
|
||||
|
@ -70,7 +81,7 @@ class InstanceActorViewSet(FederationMixin, viewsets.GenericViewSet):
|
|||
data = handler(request.data, actor=request.actor)
|
||||
except NotImplementedError:
|
||||
return response.Response(status=405)
|
||||
return response.Response(data, status=200)
|
||||
return response.Response({}, status=200)
|
||||
|
||||
|
||||
class WellKnownViewSet(FederationMixin, viewsets.GenericViewSet):
|
||||
|
@ -121,7 +132,7 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
|
|||
qs = TrackFile.objects.order_by('-creation_date').select_related(
|
||||
'track__artist',
|
||||
'track__album__artist'
|
||||
)
|
||||
).filter(library_track__isnull=True)
|
||||
if page is None:
|
||||
conf = {
|
||||
'id': utils.full_url(reverse('federation:music:files-list')),
|
||||
|
@ -154,3 +165,127 @@ class MusicFilesViewSet(FederationMixin, viewsets.GenericViewSet):
|
|||
return response.Response(status=404)
|
||||
|
||||
return response.Response(data)
|
||||
|
||||
|
||||
class LibraryPermission(HasModelPermission):
|
||||
model = models.Library
|
||||
|
||||
|
||||
class LibraryViewSet(
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
permission_classes = [LibraryPermission]
|
||||
queryset = models.Library.objects.all().select_related(
|
||||
'actor',
|
||||
'follow',
|
||||
)
|
||||
lookup_field = 'uuid'
|
||||
filter_class = filters.LibraryFilter
|
||||
serializer_class = serializers.APILibrarySerializer
|
||||
ordering_fields = (
|
||||
'id',
|
||||
'creation_date',
|
||||
'fetched_date',
|
||||
'actor__domain',
|
||||
'tracks_count',
|
||||
)
|
||||
|
||||
@list_route(methods=['get'])
|
||||
def fetch(self, request, *args, **kwargs):
|
||||
account = request.GET.get('account')
|
||||
if not account:
|
||||
return response.Response(
|
||||
{'account': 'This field is mandatory'}, status=400)
|
||||
|
||||
data = library.scan_from_account_name(account)
|
||||
return response.Response(data)
|
||||
|
||||
@detail_route(methods=['post'])
|
||||
def scan(self, request, *args, **kwargs):
|
||||
library = self.get_object()
|
||||
serializer = serializers.APILibraryScanSerializer(
|
||||
data=request.data
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
result = tasks.scan_library.delay(
|
||||
library_id=library.pk,
|
||||
until=serializer.validated_data.get('until')
|
||||
)
|
||||
return response.Response({'task': result.id})
|
||||
|
||||
@list_route(methods=['get'])
|
||||
def following(self, request, *args, **kwargs):
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
queryset = models.Follow.objects.filter(
|
||||
actor=library_actor
|
||||
).select_related(
|
||||
'actor',
|
||||
'target',
|
||||
).order_by('-creation_date')
|
||||
filterset = filters.FollowFilter(request.GET, queryset=queryset)
|
||||
final_qs = filterset.qs
|
||||
serializer = serializers.APIFollowSerializer(final_qs, many=True)
|
||||
data = {
|
||||
'results': serializer.data,
|
||||
'count': len(final_qs),
|
||||
}
|
||||
return response.Response(data)
|
||||
|
||||
@list_route(methods=['get', 'patch'])
|
||||
def followers(self, request, *args, **kwargs):
|
||||
if request.method.lower() == 'patch':
|
||||
serializer = serializers.APILibraryFollowUpdateSerializer(
|
||||
data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
follow = serializer.save()
|
||||
return response.Response(
|
||||
serializers.APIFollowSerializer(follow).data
|
||||
)
|
||||
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
queryset = models.Follow.objects.filter(
|
||||
target=library_actor
|
||||
).select_related(
|
||||
'actor',
|
||||
'target',
|
||||
).order_by('-creation_date')
|
||||
filterset = filters.FollowFilter(request.GET, queryset=queryset)
|
||||
final_qs = filterset.qs
|
||||
serializer = serializers.APIFollowSerializer(final_qs, many=True)
|
||||
data = {
|
||||
'results': serializer.data,
|
||||
'count': len(final_qs),
|
||||
}
|
||||
return response.Response(data)
|
||||
|
||||
@transaction.atomic
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = serializers.APILibraryCreateSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
library = serializer.save()
|
||||
return response.Response(serializer.data, status=201)
|
||||
|
||||
|
||||
class LibraryTrackViewSet(
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet):
|
||||
permission_classes = [LibraryPermission]
|
||||
queryset = models.LibraryTrack.objects.all().select_related(
|
||||
'library__actor',
|
||||
'library__follow',
|
||||
'local_track_file',
|
||||
)
|
||||
filter_class = filters.LibraryTrackFilter
|
||||
serializer_class = serializers.APILibraryTrackSerializer
|
||||
ordering_fields = (
|
||||
'id',
|
||||
'artist_name',
|
||||
'title',
|
||||
'album_title',
|
||||
'creation_date',
|
||||
'modification_date',
|
||||
'fetched_date',
|
||||
'published_date',
|
||||
)
|
||||
|
|
|
@ -2,8 +2,11 @@ from django import forms
|
|||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
|
||||
from funkwhale_api.common import session
|
||||
|
||||
from . import actors
|
||||
from . import utils
|
||||
from . import serializers
|
||||
|
||||
VALID_RESOURCE_TYPES = ['acct']
|
||||
|
||||
|
@ -23,17 +26,32 @@ def clean_resource(resource_string):
|
|||
return resource_type, resource
|
||||
|
||||
|
||||
def clean_acct(acct_string):
|
||||
def clean_acct(acct_string, ensure_local=True):
|
||||
try:
|
||||
username, hostname = acct_string.split('@')
|
||||
except ValueError:
|
||||
raise forms.ValidationError('Invalid format')
|
||||
|
||||
if hostname.lower() != settings.FEDERATION_HOSTNAME:
|
||||
if ensure_local and hostname.lower() != settings.FEDERATION_HOSTNAME:
|
||||
raise forms.ValidationError(
|
||||
'Invalid hostname {}'.format(hostname))
|
||||
|
||||
if username not in actors.SYSTEM_ACTORS:
|
||||
if ensure_local and username not in actors.SYSTEM_ACTORS:
|
||||
raise forms.ValidationError('Invalid username')
|
||||
|
||||
return username, hostname
|
||||
|
||||
|
||||
def get_resource(resource_string):
|
||||
resource_type, resource = clean_resource(resource_string)
|
||||
username, hostname = clean_acct(resource, ensure_local=False)
|
||||
url = 'https://{}/.well-known/webfinger?resource={}'.format(
|
||||
hostname, resource_string)
|
||||
response = session.get_session().get(
|
||||
url,
|
||||
verify=settings.EXTERNAL_REQUESTS_VERIFY_SSL,
|
||||
timeout=5)
|
||||
response.raise_for_status()
|
||||
serializer = serializers.ActorWebfingerSerializer(data=response.json())
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return serializer.validated_data
|
||||
|
|
|
@ -19,5 +19,5 @@ class TranscodeForm(forms.Form):
|
|||
choices=BITRATE_CHOICES, required=False)
|
||||
|
||||
track_file = forms.ModelChoiceField(
|
||||
queryset=models.TrackFile.objects.all()
|
||||
queryset=models.TrackFile.objects.exclude(audio_file__isnull=True)
|
||||
)
|
||||
|
|
|
@ -4,7 +4,6 @@ from django.conf import settings
|
|||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
@ -18,17 +17,17 @@ class Migration(migrations.Migration):
|
|||
migrations.AddField(
|
||||
model_name='album',
|
||||
name='uuid',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
field=models.UUIDField(db_index=True, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='artist',
|
||||
name='uuid',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
field=models.UUIDField(db_index=True, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='importbatch',
|
||||
name='uuid',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
field=models.UUIDField(db_index=True, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='importjob',
|
||||
|
@ -38,17 +37,17 @@ class Migration(migrations.Migration):
|
|||
migrations.AddField(
|
||||
model_name='importjob',
|
||||
name='uuid',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
field=models.UUIDField(db_index=True, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='lyrics',
|
||||
name='uuid',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
field=models.UUIDField(db_index=True, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='track',
|
||||
name='uuid',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
field=models.UUIDField(db_index=True, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='trackfile',
|
||||
|
@ -68,12 +67,12 @@ class Migration(migrations.Migration):
|
|||
migrations.AddField(
|
||||
model_name='trackfile',
|
||||
name='uuid',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
field=models.UUIDField(db_index=True, null=True, unique=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='work',
|
||||
name='uuid',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
field=models.UUIDField(db_index=True, null=True, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='importbatch',
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from __future__ import unicode_literals
|
||||
import os
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def populate_uuids(apps, schema_editor):
|
||||
models = [
|
||||
'Album',
|
||||
'Artist',
|
||||
'Importbatch',
|
||||
'Importjob',
|
||||
'Lyrics',
|
||||
'Track',
|
||||
'Trackfile',
|
||||
'Work',
|
||||
]
|
||||
for m in models:
|
||||
kls = apps.get_model('music', m)
|
||||
qs = kls.objects.filter(uuid__isnull=True).only('id')
|
||||
print('Setting uuids for {} ({} objects)'.format(m, len(qs)))
|
||||
for o in qs:
|
||||
o.uuid = uuid.uuid4()
|
||||
o.save(update_fields=['uuid'])
|
||||
|
||||
|
||||
def rewind(apps, schema_editor):
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('music', '0023_auto_20180407_1010'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(populate_uuids, rewind),
|
||||
migrations.AlterField(
|
||||
model_name='album',
|
||||
name='uuid',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='artist',
|
||||
name='uuid',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='importbatch',
|
||||
name='uuid',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='importjob',
|
||||
name='uuid',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='lyrics',
|
||||
name='uuid',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='track',
|
||||
name='uuid',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='trackfile',
|
||||
name='uuid',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='work',
|
||||
name='uuid',
|
||||
field=models.UUIDField(db_index=True, default=uuid.uuid4, unique=True),
|
||||
),
|
||||
]
|
|
@ -3,6 +3,7 @@ from django.conf import settings
|
|||
from rest_framework.permissions import BasePermission
|
||||
|
||||
from funkwhale_api.federation import actors
|
||||
from funkwhale_api.federation import models
|
||||
|
||||
|
||||
class Listen(BasePermission):
|
||||
|
@ -20,4 +21,8 @@ class Listen(BasePermission):
|
|||
return False
|
||||
|
||||
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
return library.followers.filter(url=actor.url).exists()
|
||||
return models.Follow.objects.filter(
|
||||
target=library,
|
||||
actor=actor,
|
||||
approved=True
|
||||
).exists()
|
||||
|
|
|
@ -3,8 +3,9 @@ from rest_framework import serializers
|
|||
from taggit.models import Tag
|
||||
|
||||
from funkwhale_api.activity import serializers as activity_serializers
|
||||
from funkwhale_api.federation.serializers import AP_CONTEXT
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
from funkwhale_api.federation.models import LibraryTrack
|
||||
from funkwhale_api.federation.serializers import AP_CONTEXT
|
||||
|
||||
from . import models
|
||||
|
||||
|
@ -153,3 +154,25 @@ class TrackActivitySerializer(activity_serializers.ModelSerializer):
|
|||
|
||||
def get_type(self, obj):
|
||||
return 'Audio'
|
||||
|
||||
|
||||
class SubmitFederationTracksSerializer(serializers.Serializer):
|
||||
library_tracks = serializers.PrimaryKeyRelatedField(
|
||||
many=True,
|
||||
queryset=LibraryTrack.objects.filter(local_track_file__isnull=True),
|
||||
)
|
||||
|
||||
@transaction.atomic
|
||||
def save(self, **kwargs):
|
||||
batch = models.ImportBatch.objects.create(
|
||||
source='federation',
|
||||
**kwargs
|
||||
)
|
||||
for lt in self.validated_data['library_tracks']:
|
||||
models.ImportJob.objects.create(
|
||||
batch=batch,
|
||||
library_track=lt,
|
||||
mbid=lt.mbid,
|
||||
source=lt.url,
|
||||
)
|
||||
return batch
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import ffmpeg
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import unicodedata
|
||||
import urllib
|
||||
|
@ -22,7 +23,6 @@ from rest_framework import permissions
|
|||
from musicbrainzngs import ResponseError
|
||||
|
||||
from funkwhale_api.common import utils as funkwhale_utils
|
||||
from funkwhale_api.common import session
|
||||
from funkwhale_api.federation import actors
|
||||
from funkwhale_api.requests.models import ImportRequest
|
||||
from funkwhale_api.musicbrainz import api
|
||||
|
@ -40,6 +40,8 @@ from . import serializers
|
|||
from . import tasks
|
||||
from . import utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SearchMixin(object):
|
||||
search_fields = []
|
||||
|
@ -203,31 +205,22 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
return Response(status=404)
|
||||
|
||||
mt = f.mimetype
|
||||
audio_file = f.audio_file
|
||||
try:
|
||||
library_track = f.library_track
|
||||
except ObjectDoesNotExist:
|
||||
library_track = None
|
||||
if library_track and not f.audio_file:
|
||||
# we proxy the response to the remote library
|
||||
# since we did not mirror the file locally
|
||||
if library_track and not audio_file:
|
||||
if not library_track.audio_file:
|
||||
# we need to populate from cache
|
||||
library_track.download_audio()
|
||||
audio_file = library_track.audio_file
|
||||
mt = library_track.audio_mimetype
|
||||
file_extension = utils.get_ext_from_type(mt)
|
||||
filename = '{}.{}'.format(f.track.full_name, file_extension)
|
||||
auth = actors.SYSTEM_ACTORS['library'].get_request_auth()
|
||||
remote_response = session.get_session().get(
|
||||
library_track.audio_url,
|
||||
auth=auth,
|
||||
stream=True,
|
||||
headers={
|
||||
'Content-Type': 'application/activity+json'
|
||||
})
|
||||
response = StreamingHttpResponse(remote_response.iter_content())
|
||||
else:
|
||||
response = Response()
|
||||
filename = f.filename
|
||||
response['X-Accel-Redirect'] = "{}{}".format(
|
||||
settings.PROTECT_FILES_PATH,
|
||||
f.audio_file.url)
|
||||
response = Response()
|
||||
filename = f.filename
|
||||
response['X-Accel-Redirect'] = "{}{}".format(
|
||||
settings.PROTECT_FILES_PATH,
|
||||
audio_file.url)
|
||||
filename = "filename*=UTF-8''{}".format(
|
||||
urllib.parse.quote(filename))
|
||||
response["Content-Disposition"] = "attachment; {}".format(filename)
|
||||
|
@ -247,6 +240,8 @@ class TrackFileViewSet(viewsets.ReadOnlyModelViewSet):
|
|||
return Response(form.errors, status=400)
|
||||
|
||||
f = form.cleaned_data['track_file']
|
||||
if not f.audio_file:
|
||||
return Response(status=400)
|
||||
output_kwargs = {
|
||||
'format': form.cleaned_data['to']
|
||||
}
|
||||
|
@ -390,6 +385,22 @@ class SubmitViewSet(viewsets.ViewSet):
|
|||
data, request, batch=None, import_request=import_request)
|
||||
return Response(import_data)
|
||||
|
||||
@list_route(methods=['post'])
|
||||
@transaction.non_atomic_requests
|
||||
def federation(self, request, *args, **kwargs):
|
||||
serializer = serializers.SubmitFederationTracksSerializer(
|
||||
data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
batch = serializer.save(submitted_by=request.user)
|
||||
for job in batch.jobs.all():
|
||||
funkwhale_utils.on_commit(
|
||||
tasks.import_job_run.delay,
|
||||
import_job_id=job.pk,
|
||||
use_acoustid=False,
|
||||
)
|
||||
|
||||
return Response({'id': batch.id}, status=201)
|
||||
|
||||
@transaction.atomic
|
||||
def _import_album(self, data, request, batch=None, import_request=None):
|
||||
# we import the whole album here to prevent race conditions that occurs
|
||||
|
|
|
@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError
|
|||
from django.contrib.postgres.fields import JSONField
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
from funkwhale_api.music.models import Track
|
||||
|
||||
|
@ -23,7 +24,7 @@ class Radio(models.Model):
|
|||
creation_date = models.DateTimeField(default=timezone.now)
|
||||
is_public = models.BooleanField(default=False)
|
||||
version = models.PositiveIntegerField(default=0)
|
||||
config = JSONField()
|
||||
config = JSONField(encoder=DjangoJSONEncoder)
|
||||
|
||||
def get_candidates(self):
|
||||
return filters.run(self.config)
|
||||
|
|
|
@ -31,6 +31,9 @@ class User(AbstractUser):
|
|||
'dynamic_preferences.change_globalpreferencemodel': {
|
||||
'external_codename': 'settings.change',
|
||||
},
|
||||
'federation.change_library': {
|
||||
'external_codename': 'federation.manage',
|
||||
},
|
||||
}
|
||||
|
||||
privacy_level = fields.get_privacy_field()
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
import uuid
|
||||
|
||||
from funkwhale_api.federation import activity
|
||||
from funkwhale_api.federation import serializers
|
||||
|
||||
|
||||
def test_deliver(nodb_factories, r_mock, mocker):
|
||||
to = nodb_factories['federation.Actor']()
|
||||
def test_deliver(factories, r_mock, mocker, settings):
|
||||
settings.CELERY_TASK_ALWAYS_EAGER = True
|
||||
to = factories['federation.Actor']()
|
||||
mocker.patch(
|
||||
'funkwhale_api.federation.actors.get_actor',
|
||||
return_value=to)
|
||||
sender = nodb_factories['federation.Actor']()
|
||||
sender = factories['federation.Actor']()
|
||||
ac = {
|
||||
'id': 'http://test.federation/activity',
|
||||
'type': 'Create',
|
||||
|
@ -38,37 +40,9 @@ def test_deliver(nodb_factories, r_mock, mocker):
|
|||
def test_accept_follow(mocker, factories):
|
||||
deliver = mocker.patch(
|
||||
'funkwhale_api.federation.activity.deliver')
|
||||
actor = factories['federation.Actor']()
|
||||
target = factories['federation.Actor'](local=True)
|
||||
follow = {
|
||||
'actor': actor.url,
|
||||
'type': 'Follow',
|
||||
'id': 'http://test.federation/user#follows/267',
|
||||
'object': target.url,
|
||||
}
|
||||
uid = uuid.uuid4()
|
||||
mocker.patch('uuid.uuid4', return_value=uid)
|
||||
expected_accept = {
|
||||
"@context": [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
"https://w3id.org/security/v1",
|
||||
{}
|
||||
],
|
||||
"id": target.url + '#accepts/follows/{}'.format(uid),
|
||||
"type": "Accept",
|
||||
"actor": target.url,
|
||||
"object": {
|
||||
"id": follow['id'],
|
||||
"type": "Follow",
|
||||
"actor": actor.url,
|
||||
"object": target.url
|
||||
},
|
||||
}
|
||||
activity.accept_follow(
|
||||
target, follow, actor
|
||||
)
|
||||
follow = factories['federation.Follow'](approved=None)
|
||||
expected_accept = serializers.AcceptFollowSerializer(follow).data
|
||||
activity.accept_follow(follow)
|
||||
deliver.assert_called_once_with(
|
||||
expected_accept, to=[actor.url], on_behalf_of=target
|
||||
expected_accept, to=[follow.actor.url], on_behalf_of=follow.target
|
||||
)
|
||||
follow_instance = actor.emitted_follows.first()
|
||||
assert follow_instance.target == target
|
||||
|
|
|
@ -7,6 +7,7 @@ from django.utils import timezone
|
|||
|
||||
from rest_framework import exceptions
|
||||
|
||||
from funkwhale_api.federation import activity
|
||||
from funkwhale_api.federation import actors
|
||||
from funkwhale_api.federation import models
|
||||
from funkwhale_api.federation import serializers
|
||||
|
@ -261,8 +262,6 @@ def test_test_actor_handles_follow(
|
|||
deliver = mocker.patch(
|
||||
'funkwhale_api.federation.activity.deliver')
|
||||
actor = factories['federation.Actor']()
|
||||
now = timezone.now()
|
||||
mocker.patch('django.utils.timezone.now', return_value=now)
|
||||
accept_follow = mocker.patch(
|
||||
'funkwhale_api.federation.activity.accept_follow')
|
||||
test_actor = actors.SYSTEM_ACTORS['test'].get_actor_instance()
|
||||
|
@ -272,28 +271,15 @@ def test_test_actor_handles_follow(
|
|||
'id': 'http://test.federation/user#follows/267',
|
||||
'object': test_actor.url,
|
||||
}
|
||||
uid = uuid.uuid4()
|
||||
mocker.patch('uuid.uuid4', return_value=uid)
|
||||
expected_follow = {
|
||||
'@context': serializers.AP_CONTEXT,
|
||||
'actor': test_actor.url,
|
||||
'id': test_actor.url + '#follows/{}'.format(uid),
|
||||
'object': actor.url,
|
||||
'type': 'Follow'
|
||||
}
|
||||
|
||||
actors.SYSTEM_ACTORS['test'].post_inbox(data, actor=actor)
|
||||
accept_follow.assert_called_once_with(
|
||||
test_actor, data, actor
|
||||
follow = models.Follow.objects.get(target=test_actor, approved=True)
|
||||
follow_back = models.Follow.objects.get(actor=test_actor, approved=None)
|
||||
accept_follow.assert_called_once_with(follow)
|
||||
deliver.assert_called_once_with(
|
||||
serializers.FollowSerializer(follow_back).data,
|
||||
on_behalf_of=test_actor,
|
||||
to=[actor.url]
|
||||
)
|
||||
expected_calls = [
|
||||
mocker.call(
|
||||
expected_follow,
|
||||
to=[actor.url],
|
||||
on_behalf_of=test_actor,
|
||||
)
|
||||
]
|
||||
deliver.assert_has_calls(expected_calls)
|
||||
|
||||
|
||||
def test_test_actor_handles_undo_follow(
|
||||
|
@ -346,12 +332,10 @@ def test_library_actor_handles_follow_manual_approval(
|
|||
}
|
||||
|
||||
library_actor.system_conf.post_inbox(data, actor=actor)
|
||||
fr = library_actor.received_follow_requests.first()
|
||||
follow = library_actor.received_follows.first()
|
||||
|
||||
assert library_actor.received_follow_requests.count() == 1
|
||||
assert fr.target == library_actor
|
||||
assert fr.actor == actor
|
||||
assert fr.approved is None
|
||||
assert follow.actor == actor
|
||||
assert follow.approved is None
|
||||
|
||||
|
||||
def test_library_actor_handles_follow_auto_approval(
|
||||
|
@ -369,10 +353,27 @@ def test_library_actor_handles_follow_auto_approval(
|
|||
}
|
||||
library_actor.system_conf.post_inbox(data, actor=actor)
|
||||
|
||||
assert library_actor.received_follow_requests.count() == 0
|
||||
accept_follow.assert_called_once_with(
|
||||
library_actor, data, actor
|
||||
follow = library_actor.received_follows.first()
|
||||
|
||||
assert follow.actor == actor
|
||||
assert follow.approved is True
|
||||
|
||||
|
||||
def test_library_actor_handles_accept(
|
||||
mocker, factories):
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
actor = factories['federation.Actor']()
|
||||
pending_follow = factories['federation.Follow'](
|
||||
actor=library_actor,
|
||||
target=actor,
|
||||
approved=None,
|
||||
)
|
||||
serializer = serializers.AcceptFollowSerializer(pending_follow)
|
||||
library_actor.system_conf.post_inbox(serializer.data, actor=actor)
|
||||
|
||||
pending_follow.refresh_from_db()
|
||||
|
||||
assert pending_follow.approved is True
|
||||
|
||||
|
||||
def test_library_actor_handle_create_audio_no_library(mocker, factories):
|
||||
|
|
|
@ -10,8 +10,10 @@ def test_authenticate(factories, mocker, api_request):
|
|||
'funkwhale_api.federation.actors.get_actor_data',
|
||||
return_value={
|
||||
'id': actor_url,
|
||||
'type': 'Person',
|
||||
'outbox': 'https://test.com',
|
||||
'inbox': 'https://test.com',
|
||||
'preferredUsername': 'test',
|
||||
'publicKey': {
|
||||
'publicKeyPem': public.decode('utf-8'),
|
||||
'owner': actor_url,
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
from funkwhale_api.federation import library
|
||||
from funkwhale_api.federation import serializers
|
||||
|
||||
|
||||
def test_library_scan_from_account_name(mocker, factories):
|
||||
actor = factories['federation.Actor'](
|
||||
preferred_username='library',
|
||||
domain='test.library'
|
||||
)
|
||||
get_resource_result = {'actor_url': actor.url}
|
||||
get_resource = mocker.patch(
|
||||
'funkwhale_api.federation.webfinger.get_resource',
|
||||
return_value=get_resource_result)
|
||||
|
||||
actor_data = serializers.ActorSerializer(actor).data
|
||||
actor_data['manuallyApprovesFollowers'] = False
|
||||
actor_data['url'] = [{
|
||||
'type': 'Link',
|
||||
'name': 'library',
|
||||
'mediaType': 'application/activity+json',
|
||||
'href': 'https://test.library'
|
||||
}]
|
||||
get_actor_data = mocker.patch(
|
||||
'funkwhale_api.federation.actors.get_actor_data',
|
||||
return_value=actor_data)
|
||||
|
||||
get_library_data_result = {'test': 'test'}
|
||||
get_library_data = mocker.patch(
|
||||
'funkwhale_api.federation.library.get_library_data',
|
||||
return_value=get_library_data_result)
|
||||
|
||||
result = library.scan_from_account_name('library@test.actor')
|
||||
|
||||
get_resource.assert_called_once_with('acct:library@test.actor')
|
||||
get_actor_data.assert_called_once_with(actor.url)
|
||||
get_library_data.assert_called_once_with(actor_data['url'][0]['href'])
|
||||
|
||||
assert result == {
|
||||
'webfinger': get_resource_result,
|
||||
'actor': actor_data,
|
||||
'library': get_library_data_result,
|
||||
'local': {
|
||||
'following': False,
|
||||
'awaiting_approval': False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_get_library_data(r_mock, factories):
|
||||
actor = factories['federation.Actor']()
|
||||
url = 'https://test.library'
|
||||
conf = {
|
||||
'id': url,
|
||||
'items': [],
|
||||
'actor': actor,
|
||||
'page_size': 5,
|
||||
}
|
||||
data = serializers.PaginatedCollectionSerializer(conf).data
|
||||
r_mock.get(url, json=data)
|
||||
|
||||
result = library.get_library_data(url)
|
||||
for f in ['totalItems', 'actor', 'id', 'type']:
|
||||
assert result[f] == data[f]
|
||||
|
||||
|
||||
def test_get_library_data_requires_authentication(r_mock, factories):
|
||||
url = 'https://test.library'
|
||||
r_mock.get(url, status_code=403)
|
||||
result = library.get_library_data(url)
|
||||
assert result['errors'] == ['Permission denied while scanning library']
|
|
@ -35,50 +35,6 @@ def test_follow_federation_url(factories):
|
|||
assert follow.get_federation_url() == expected
|
||||
|
||||
|
||||
def test_follow_request_approve(mocker, factories):
|
||||
uid = uuid.uuid4()
|
||||
mocker.patch('uuid.uuid4', return_value=uid)
|
||||
accept_follow = mocker.patch(
|
||||
'funkwhale_api.federation.activity.accept_follow')
|
||||
fr = factories['federation.FollowRequest'](target__local=True)
|
||||
fr.approve()
|
||||
|
||||
follow = {
|
||||
'@context': serializers.AP_CONTEXT,
|
||||
'actor': fr.actor.url,
|
||||
'id': fr.actor.url + '#follows/{}'.format(uid),
|
||||
'object': fr.target.url,
|
||||
'type': 'Follow'
|
||||
}
|
||||
|
||||
assert fr.approved is True
|
||||
assert list(fr.target.followers.all()) == [fr.actor]
|
||||
accept_follow.assert_called_once_with(
|
||||
fr.target, follow, fr.actor
|
||||
)
|
||||
|
||||
|
||||
def test_follow_request_approve_non_local(mocker, factories):
|
||||
uid = uuid.uuid4()
|
||||
mocker.patch('uuid.uuid4', return_value=uid)
|
||||
accept_follow = mocker.patch(
|
||||
'funkwhale_api.federation.activity.accept_follow')
|
||||
fr = factories['federation.FollowRequest']()
|
||||
fr.approve()
|
||||
|
||||
assert fr.approved is True
|
||||
assert list(fr.target.followers.all()) == [fr.actor]
|
||||
accept_follow.assert_not_called()
|
||||
|
||||
|
||||
def test_follow_request_refused(mocker, factories):
|
||||
fr = factories['federation.FollowRequest']()
|
||||
fr.refuse()
|
||||
|
||||
assert fr.approved is False
|
||||
assert fr.target.followers.count() == 0
|
||||
|
||||
|
||||
def test_library_model_unique_per_actor(factories):
|
||||
library = factories['federation.Library']()
|
||||
with pytest.raises(db.IntegrityError):
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import arrow
|
||||
import pytest
|
||||
|
||||
from django.urls import reverse
|
||||
from django.core.paginator import Paginator
|
||||
|
@ -34,7 +35,7 @@ def test_actor_serializer_from_ap(db):
|
|||
}
|
||||
|
||||
serializer = serializers.ActorSerializer(data=payload)
|
||||
assert serializer.is_valid()
|
||||
assert serializer.is_valid(raise_exception=True)
|
||||
|
||||
actor = serializer.build()
|
||||
|
||||
|
@ -65,7 +66,7 @@ def test_actor_serializer_only_mandatory_field_from_ap(db):
|
|||
}
|
||||
|
||||
serializer = serializers.ActorSerializer(data=payload)
|
||||
assert serializer.is_valid()
|
||||
assert serializer.is_valid(raise_exception=True)
|
||||
|
||||
actor = serializer.build()
|
||||
|
||||
|
@ -170,6 +171,184 @@ def test_follow_serializer_to_ap(factories):
|
|||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_follow_serializer_save(factories):
|
||||
actor = factories['federation.Actor']()
|
||||
target = factories['federation.Actor']()
|
||||
|
||||
data = expected = {
|
||||
'id': 'https://test.follow',
|
||||
'type': 'Follow',
|
||||
'actor': actor.url,
|
||||
'object': target.url,
|
||||
}
|
||||
serializer = serializers.FollowSerializer(data=data)
|
||||
|
||||
assert serializer.is_valid(raise_exception=True)
|
||||
|
||||
follow = serializer.save()
|
||||
|
||||
assert follow.pk is not None
|
||||
assert follow.actor == actor
|
||||
assert follow.target == target
|
||||
assert follow.approved is None
|
||||
|
||||
|
||||
def test_follow_serializer_save_validates_on_context(factories):
|
||||
actor = factories['federation.Actor']()
|
||||
target = factories['federation.Actor']()
|
||||
impostor = factories['federation.Actor']()
|
||||
|
||||
data = expected = {
|
||||
'id': 'https://test.follow',
|
||||
'type': 'Follow',
|
||||
'actor': actor.url,
|
||||
'object': target.url,
|
||||
}
|
||||
serializer = serializers.FollowSerializer(
|
||||
data=data,
|
||||
context={'follow_actor': impostor, 'follow_target': impostor})
|
||||
|
||||
assert serializer.is_valid() is False
|
||||
|
||||
assert 'actor' in serializer.errors
|
||||
assert 'object' in serializer.errors
|
||||
|
||||
|
||||
def test_accept_follow_serializer_representation(factories):
|
||||
follow = factories['federation.Follow'](approved=None)
|
||||
|
||||
expected = {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
{},
|
||||
],
|
||||
'id': follow.get_federation_url() + '/accept',
|
||||
'type': 'Accept',
|
||||
'actor': follow.target.url,
|
||||
'object': serializers.FollowSerializer(follow).data,
|
||||
}
|
||||
|
||||
serializer = serializers.AcceptFollowSerializer(follow)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_accept_follow_serializer_save(factories):
|
||||
follow = factories['federation.Follow'](approved=None)
|
||||
|
||||
data = {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
{},
|
||||
],
|
||||
'id': follow.get_federation_url() + '/accept',
|
||||
'type': 'Accept',
|
||||
'actor': follow.target.url,
|
||||
'object': serializers.FollowSerializer(follow).data,
|
||||
}
|
||||
|
||||
serializer = serializers.AcceptFollowSerializer(data=data)
|
||||
assert serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
||||
follow.refresh_from_db()
|
||||
|
||||
assert follow.approved is True
|
||||
|
||||
|
||||
def test_accept_follow_serializer_validates_on_context(factories):
|
||||
follow = factories['federation.Follow'](approved=None)
|
||||
impostor = factories['federation.Actor']()
|
||||
data = {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
{},
|
||||
],
|
||||
'id': follow.get_federation_url() + '/accept',
|
||||
'type': 'Accept',
|
||||
'actor': impostor.url,
|
||||
'object': serializers.FollowSerializer(follow).data,
|
||||
}
|
||||
|
||||
serializer = serializers.AcceptFollowSerializer(
|
||||
data=data,
|
||||
context={'follow_actor': impostor, 'follow_target': impostor})
|
||||
|
||||
assert serializer.is_valid() is False
|
||||
assert 'actor' in serializer.errors['object']
|
||||
assert 'object' in serializer.errors['object']
|
||||
|
||||
|
||||
def test_undo_follow_serializer_representation(factories):
|
||||
follow = factories['federation.Follow'](approved=True)
|
||||
|
||||
expected = {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
{},
|
||||
],
|
||||
'id': follow.get_federation_url() + '/undo',
|
||||
'type': 'Undo',
|
||||
'actor': follow.actor.url,
|
||||
'object': serializers.FollowSerializer(follow).data,
|
||||
}
|
||||
|
||||
serializer = serializers.UndoFollowSerializer(follow)
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_undo_follow_serializer_save(factories):
|
||||
follow = factories['federation.Follow'](approved=True)
|
||||
|
||||
data = {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
{},
|
||||
],
|
||||
'id': follow.get_federation_url() + '/undo',
|
||||
'type': 'Undo',
|
||||
'actor': follow.actor.url,
|
||||
'object': serializers.FollowSerializer(follow).data,
|
||||
}
|
||||
|
||||
serializer = serializers.UndoFollowSerializer(data=data)
|
||||
assert serializer.is_valid(raise_exception=True)
|
||||
serializer.save()
|
||||
|
||||
with pytest.raises(models.Follow.DoesNotExist):
|
||||
follow.refresh_from_db()
|
||||
|
||||
|
||||
def test_undo_follow_serializer_validates_on_context(factories):
|
||||
follow = factories['federation.Follow'](approved=True)
|
||||
impostor = factories['federation.Actor']()
|
||||
data = {
|
||||
'@context': [
|
||||
'https://www.w3.org/ns/activitystreams',
|
||||
'https://w3id.org/security/v1',
|
||||
{},
|
||||
],
|
||||
'id': follow.get_federation_url() + '/undo',
|
||||
'type': 'Undo',
|
||||
'actor': impostor.url,
|
||||
'object': serializers.FollowSerializer(follow).data,
|
||||
}
|
||||
|
||||
serializer = serializers.UndoFollowSerializer(
|
||||
data=data,
|
||||
context={'follow_actor': impostor, 'follow_target': impostor})
|
||||
|
||||
assert serializer.is_valid() is False
|
||||
assert 'actor' in serializer.errors['object']
|
||||
assert 'object' in serializer.errors['object']
|
||||
|
||||
|
||||
def test_paginated_collection_serializer(factories):
|
||||
tfs = factories['music.TrackFile'].create_batch(size=5)
|
||||
actor = factories['federation.Actor'](local=True)
|
||||
|
@ -201,6 +380,71 @@ def test_paginated_collection_serializer(factories):
|
|||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_paginated_collection_serializer_validation():
|
||||
data = {
|
||||
'type': 'Collection',
|
||||
'id': 'https://test.federation/test',
|
||||
'totalItems': 5,
|
||||
'actor': 'http://test.actor',
|
||||
'first': 'https://test.federation/test?page=1',
|
||||
'last': 'https://test.federation/test?page=1',
|
||||
'items': []
|
||||
}
|
||||
|
||||
serializer = serializers.PaginatedCollectionSerializer(
|
||||
data=data
|
||||
)
|
||||
|
||||
assert serializer.is_valid(raise_exception=True) is True
|
||||
assert serializer.validated_data['totalItems'] == 5
|
||||
assert serializer.validated_data['id'] == data['id']
|
||||
assert serializer.validated_data['actor'] == data['actor']
|
||||
|
||||
|
||||
def test_collection_page_serializer_validation():
|
||||
base = 'https://test.federation/test'
|
||||
data = {
|
||||
'type': 'CollectionPage',
|
||||
'id': base + '?page=2',
|
||||
'totalItems': 5,
|
||||
'actor': 'https://test.actor',
|
||||
'items': [],
|
||||
'first': 'https://test.federation/test?page=1',
|
||||
'last': 'https://test.federation/test?page=3',
|
||||
'prev': base + '?page=1',
|
||||
'next': base + '?page=3',
|
||||
'partOf': base,
|
||||
}
|
||||
|
||||
serializer = serializers.CollectionPageSerializer(
|
||||
data=data
|
||||
)
|
||||
|
||||
assert serializer.is_valid(raise_exception=True) is True
|
||||
assert serializer.validated_data['totalItems'] == 5
|
||||
assert serializer.validated_data['id'] == data['id']
|
||||
assert serializer.validated_data['actor'] == data['actor']
|
||||
assert serializer.validated_data['items'] == []
|
||||
assert serializer.validated_data['prev'] == data['prev']
|
||||
assert serializer.validated_data['next'] == data['next']
|
||||
assert serializer.validated_data['partOf'] == data['partOf']
|
||||
|
||||
|
||||
def test_collection_page_serializer_can_validate_child():
|
||||
base = 'https://test.federation/test'
|
||||
data = {
|
||||
'items': [{'in': 'valid'}],
|
||||
}
|
||||
|
||||
serializer = serializers.CollectionPageSerializer(
|
||||
data=data,
|
||||
context={'item_serializer': serializers.AudioSerializer}
|
||||
)
|
||||
|
||||
assert serializer.is_valid() is False
|
||||
assert 'items' in serializer.errors
|
||||
|
||||
|
||||
def test_collection_page_serializer(factories):
|
||||
tfs = factories['music.TrackFile'].create_batch(size=5)
|
||||
actor = factories['federation.Actor'](local=True)
|
||||
|
@ -262,6 +506,25 @@ def test_activity_pub_audio_serializer_to_library_track(factories):
|
|||
assert lt.published_date == arrow.get(audio['published'])
|
||||
|
||||
|
||||
def test_activity_pub_audio_serializer_to_library_track_no_duplicate(
|
||||
factories):
|
||||
remote_library = factories['federation.Library']()
|
||||
audio = factories['federation.Audio']()
|
||||
serializer1 = serializers.AudioSerializer(
|
||||
data=audio, context={'library': remote_library})
|
||||
serializer2 = serializers.AudioSerializer(
|
||||
data=audio, context={'library': remote_library})
|
||||
|
||||
assert serializer1.is_valid() is True
|
||||
assert serializer2.is_valid() is True
|
||||
|
||||
lt1 = serializer1.save()
|
||||
lt2 = serializer2.save()
|
||||
|
||||
assert lt1 == lt2
|
||||
assert models.LibraryTrack.objects.count() == 1
|
||||
|
||||
|
||||
def test_activity_pub_audio_serializer_to_ap(factories):
|
||||
tf = factories['music.TrackFile'](mimetype='audio/mp3')
|
||||
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
|
@ -375,3 +638,46 @@ def test_collection_serializer_to_ap(factories):
|
|||
collection, context={'actor': library, 'id': 'https://test.id'})
|
||||
|
||||
assert serializer.data == expected
|
||||
|
||||
|
||||
def test_api_library_create_serializer_save(factories, r_mock):
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
actor = factories['federation.Actor']()
|
||||
follow = factories['federation.Follow'](
|
||||
target=actor,
|
||||
actor=library_actor,
|
||||
)
|
||||
actor_data = serializers.ActorSerializer(actor).data
|
||||
actor_data['url'] = [{
|
||||
'href': 'https://test.library',
|
||||
'name': 'library',
|
||||
'type': 'Link',
|
||||
}]
|
||||
library_conf = {
|
||||
'id': 'https://test.library',
|
||||
'items': range(10),
|
||||
'actor': actor,
|
||||
'page_size': 5,
|
||||
}
|
||||
library_data = serializers.PaginatedCollectionSerializer(library_conf).data
|
||||
r_mock.get(actor.url, json=actor_data)
|
||||
r_mock.get('https://test.library', json=library_data)
|
||||
data = {
|
||||
'actor': actor.url,
|
||||
'autoimport': False,
|
||||
'federation_enabled': True,
|
||||
'download_files': False,
|
||||
}
|
||||
|
||||
serializer = serializers.APILibraryCreateSerializer(data=data)
|
||||
assert serializer.is_valid(raise_exception=True) is True
|
||||
library = serializer.save()
|
||||
follow = models.Follow.objects.get(
|
||||
target=actor, actor=library_actor, approved=None)
|
||||
|
||||
assert library.autoimport is data['autoimport']
|
||||
assert library.federation_enabled is data['federation_enabled']
|
||||
assert library.download_files is data['download_files']
|
||||
assert library.tracks_count == 10
|
||||
assert library.actor == actor
|
||||
assert library.follow == follow
|
||||
|
|
|
@ -0,0 +1,140 @@
|
|||
import datetime
|
||||
|
||||
from django.core.paginator import Paginator
|
||||
from django.utils import timezone
|
||||
|
||||
from funkwhale_api.federation import serializers
|
||||
from funkwhale_api.federation import tasks
|
||||
|
||||
|
||||
def test_scan_library_does_nothing_if_federation_disabled(mocker, factories):
|
||||
library = factories['federation.Library'](federation_enabled=False)
|
||||
tasks.scan_library(library_id=library.pk)
|
||||
|
||||
assert library.tracks.count() == 0
|
||||
|
||||
|
||||
def test_scan_library_page_does_nothing_if_federation_disabled(
|
||||
mocker, factories):
|
||||
library = factories['federation.Library'](federation_enabled=False)
|
||||
tasks.scan_library_page(library_id=library.pk, page_url=None)
|
||||
|
||||
assert library.tracks.count() == 0
|
||||
|
||||
|
||||
def test_scan_library_fetches_page_and_calls_scan_page(
|
||||
mocker, factories, r_mock):
|
||||
now = timezone.now()
|
||||
library = factories['federation.Library'](federation_enabled=True)
|
||||
collection_conf = {
|
||||
'actor': library.actor,
|
||||
'id': library.url,
|
||||
'page_size': 10,
|
||||
'items': range(10),
|
||||
}
|
||||
collection = serializers.PaginatedCollectionSerializer(collection_conf)
|
||||
scan_page = mocker.patch(
|
||||
'funkwhale_api.federation.tasks.scan_library_page.delay')
|
||||
r_mock.get(collection_conf['id'], json=collection.data)
|
||||
tasks.scan_library(library_id=library.pk)
|
||||
|
||||
scan_page.assert_called_once_with(
|
||||
library_id=library.id,
|
||||
page_url=collection.data['first'],
|
||||
until=None,
|
||||
)
|
||||
library.refresh_from_db()
|
||||
assert library.fetched_date > now
|
||||
|
||||
|
||||
def test_scan_page_fetches_page_and_creates_tracks(
|
||||
mocker, factories, r_mock):
|
||||
library = factories['federation.Library'](federation_enabled=True)
|
||||
tfs = factories['music.TrackFile'].create_batch(size=5)
|
||||
page_conf = {
|
||||
'actor': library.actor,
|
||||
'id': library.url,
|
||||
'page': Paginator(tfs, 5).page(1),
|
||||
'item_serializer': serializers.AudioSerializer,
|
||||
}
|
||||
page = serializers.CollectionPageSerializer(page_conf)
|
||||
r_mock.get(page.data['id'], json=page.data)
|
||||
|
||||
tasks.scan_library_page(library_id=library.pk, page_url=page.data['id'])
|
||||
|
||||
lts = list(library.tracks.all().order_by('-published_date'))
|
||||
assert len(lts) == 5
|
||||
|
||||
|
||||
def test_scan_page_trigger_next_page_scan_skip_if_same(
|
||||
mocker, factories, r_mock):
|
||||
patched_scan = mocker.patch(
|
||||
'funkwhale_api.federation.tasks.scan_library_page.delay'
|
||||
)
|
||||
library = factories['federation.Library'](federation_enabled=True)
|
||||
tfs = factories['music.TrackFile'].create_batch(size=1)
|
||||
page_conf = {
|
||||
'actor': library.actor,
|
||||
'id': library.url,
|
||||
'page': Paginator(tfs, 3).page(1),
|
||||
'item_serializer': serializers.AudioSerializer,
|
||||
}
|
||||
page = serializers.CollectionPageSerializer(page_conf)
|
||||
data = page.data
|
||||
data['next'] = data['id']
|
||||
r_mock.get(page.data['id'], json=data)
|
||||
|
||||
tasks.scan_library_page(library_id=library.pk, page_url=data['id'])
|
||||
patched_scan.assert_not_called()
|
||||
|
||||
|
||||
def test_scan_page_stops_once_until_is_reached(
|
||||
mocker, factories, r_mock):
|
||||
library = factories['federation.Library'](federation_enabled=True)
|
||||
tfs = list(reversed(factories['music.TrackFile'].create_batch(size=5)))
|
||||
page_conf = {
|
||||
'actor': library.actor,
|
||||
'id': library.url,
|
||||
'page': Paginator(tfs, 3).page(1),
|
||||
'item_serializer': serializers.AudioSerializer,
|
||||
}
|
||||
page = serializers.CollectionPageSerializer(page_conf)
|
||||
r_mock.get(page.data['id'], json=page.data)
|
||||
|
||||
tasks.scan_library_page(
|
||||
library_id=library.pk,
|
||||
page_url=page.data['id'],
|
||||
until=tfs[1].creation_date)
|
||||
|
||||
lts = list(library.tracks.all().order_by('-published_date'))
|
||||
assert len(lts) == 2
|
||||
for i, tf in enumerate(tfs[:1]):
|
||||
assert tf.creation_date == lts[i].published_date
|
||||
|
||||
|
||||
def test_clean_federation_music_cache_if_no_listen(preferences, factories):
|
||||
preferences['federation__music_cache_duration'] = 60
|
||||
lt1 = factories['federation.LibraryTrack'](with_audio_file=True)
|
||||
lt2 = factories['federation.LibraryTrack'](with_audio_file=True)
|
||||
lt3 = factories['federation.LibraryTrack'](with_audio_file=True)
|
||||
tf1 = factories['music.TrackFile'](library_track=lt1)
|
||||
tf2 = factories['music.TrackFile'](library_track=lt2)
|
||||
tf3 = factories['music.TrackFile'](library_track=lt3)
|
||||
|
||||
# we listen to the first one, and the second one (but weeks ago)
|
||||
listening1 = factories['history.Listening'](
|
||||
track=tf1.track,
|
||||
creation_date=timezone.now())
|
||||
listening2 = factories['history.Listening'](
|
||||
track=tf2.track,
|
||||
creation_date=timezone.now() - datetime.timedelta(minutes=61))
|
||||
|
||||
tasks.clean_music_cache()
|
||||
|
||||
lt1.refresh_from_db()
|
||||
lt2.refresh_from_db()
|
||||
lt3.refresh_from_db()
|
||||
|
||||
assert bool(lt1.audio_file) is True
|
||||
assert bool(lt2.audio_file) is False
|
||||
assert bool(lt3.audio_file) is False
|
|
@ -1,9 +1,12 @@
|
|||
from django.urls import reverse
|
||||
from django.core.paginator import Paginator
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from funkwhale_api.federation import actors
|
||||
from funkwhale_api.federation import activity
|
||||
from funkwhale_api.federation import models
|
||||
from funkwhale_api.federation import serializers
|
||||
from funkwhale_api.federation import utils
|
||||
from funkwhale_api.federation import webfinger
|
||||
|
@ -117,6 +120,19 @@ def test_audio_file_list_actor_page(
|
|||
assert response.data == expected
|
||||
|
||||
|
||||
def test_audio_file_list_actor_page_exclude_federated_files(
|
||||
db, settings, api_client, factories):
|
||||
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
|
||||
library = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
tfs = factories['music.TrackFile'].create_batch(size=5, federation=True)
|
||||
|
||||
url = reverse('federation:music:files-list')
|
||||
response = api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data['totalItems'] == 0
|
||||
|
||||
|
||||
def test_audio_file_list_actor_page_error(
|
||||
db, settings, api_client, factories):
|
||||
settings.FEDERATION_MUSIC_NEEDS_APPROVAL = False
|
||||
|
@ -151,3 +167,216 @@ def test_library_actor_includes_library_link(db, settings, api_client):
|
|||
]
|
||||
assert response.status_code == 200
|
||||
assert response.data['url'] == expected_links
|
||||
|
||||
|
||||
def test_can_fetch_library(superuser_api_client, mocker):
|
||||
result = {'test': 'test'}
|
||||
scan = mocker.patch(
|
||||
'funkwhale_api.federation.library.scan_from_account_name',
|
||||
return_value=result)
|
||||
|
||||
url = reverse('api:v1:federation:libraries-fetch')
|
||||
response = superuser_api_client.get(
|
||||
url, data={'account': 'test@test.library'})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data == result
|
||||
scan.assert_called_once_with('test@test.library')
|
||||
|
||||
|
||||
def test_follow_library(superuser_api_client, mocker, factories, r_mock):
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
actor = factories['federation.Actor']()
|
||||
follow = {'test': 'follow'}
|
||||
on_commit = mocker.patch(
|
||||
'funkwhale_api.common.utils.on_commit')
|
||||
actor_data = serializers.ActorSerializer(actor).data
|
||||
actor_data['url'] = [{
|
||||
'href': 'https://test.library',
|
||||
'name': 'library',
|
||||
'type': 'Link',
|
||||
}]
|
||||
library_conf = {
|
||||
'id': 'https://test.library',
|
||||
'items': range(10),
|
||||
'actor': actor,
|
||||
'page_size': 5,
|
||||
}
|
||||
library_data = serializers.PaginatedCollectionSerializer(library_conf).data
|
||||
r_mock.get(actor.url, json=actor_data)
|
||||
r_mock.get('https://test.library', json=library_data)
|
||||
data = {
|
||||
'actor': actor.url,
|
||||
'autoimport': False,
|
||||
'federation_enabled': True,
|
||||
'download_files': False,
|
||||
}
|
||||
|
||||
url = reverse('api:v1:federation:libraries-list')
|
||||
response = superuser_api_client.post(
|
||||
url, data)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
follow = models.Follow.objects.get(
|
||||
actor=library_actor,
|
||||
target=actor,
|
||||
approved=None,
|
||||
)
|
||||
library = follow.library
|
||||
|
||||
assert response.data == serializers.APILibraryCreateSerializer(
|
||||
library).data
|
||||
|
||||
on_commit.assert_called_once_with(
|
||||
activity.deliver,
|
||||
serializers.FollowSerializer(follow).data,
|
||||
on_behalf_of=library_actor,
|
||||
to=[actor.url]
|
||||
)
|
||||
|
||||
|
||||
def test_can_list_system_actor_following(factories, superuser_api_client):
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
follow1 = factories['federation.Follow'](actor=library_actor)
|
||||
follow2 = factories['federation.Follow']()
|
||||
|
||||
url = reverse('api:v1:federation:libraries-following')
|
||||
response = superuser_api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data['results'] == [
|
||||
serializers.APIFollowSerializer(follow1).data
|
||||
]
|
||||
|
||||
|
||||
def test_can_list_system_actor_followers(factories, superuser_api_client):
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
follow1 = factories['federation.Follow'](actor=library_actor)
|
||||
follow2 = factories['federation.Follow'](target=library_actor)
|
||||
|
||||
url = reverse('api:v1:federation:libraries-followers')
|
||||
response = superuser_api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data['results'] == [
|
||||
serializers.APIFollowSerializer(follow2).data
|
||||
]
|
||||
|
||||
|
||||
def test_can_list_libraries(factories, superuser_api_client):
|
||||
library1 = factories['federation.Library']()
|
||||
library2 = factories['federation.Library']()
|
||||
|
||||
url = reverse('api:v1:federation:libraries-list')
|
||||
response = superuser_api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data['results'] == [
|
||||
serializers.APILibrarySerializer(library1).data,
|
||||
serializers.APILibrarySerializer(library2).data,
|
||||
]
|
||||
|
||||
|
||||
def test_can_detail_library(factories, superuser_api_client):
|
||||
library = factories['federation.Library']()
|
||||
|
||||
url = reverse(
|
||||
'api:v1:federation:libraries-detail',
|
||||
kwargs={'uuid': str(library.uuid)})
|
||||
response = superuser_api_client.get(url)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data == serializers.APILibrarySerializer(library).data
|
||||
|
||||
|
||||
def test_can_patch_library(factories, superuser_api_client):
|
||||
library = factories['federation.Library']()
|
||||
data = {
|
||||
'federation_enabled': not library.federation_enabled,
|
||||
'download_files': not library.download_files,
|
||||
'autoimport': not library.autoimport,
|
||||
}
|
||||
url = reverse(
|
||||
'api:v1:federation:libraries-detail',
|
||||
kwargs={'uuid': str(library.uuid)})
|
||||
response = superuser_api_client.patch(url, data)
|
||||
|
||||
assert response.status_code == 200
|
||||
library.refresh_from_db()
|
||||
|
||||
for k, v in data.items():
|
||||
assert getattr(library, k) == v
|
||||
|
||||
|
||||
def test_scan_library(factories, mocker, superuser_api_client):
|
||||
scan = mocker.patch(
|
||||
'funkwhale_api.federation.tasks.scan_library.delay',
|
||||
return_value=mocker.Mock(id='id'))
|
||||
library = factories['federation.Library']()
|
||||
now = timezone.now()
|
||||
data = {
|
||||
'until': now,
|
||||
}
|
||||
url = reverse(
|
||||
'api:v1:federation:libraries-scan',
|
||||
kwargs={'uuid': str(library.uuid)})
|
||||
response = superuser_api_client.post(url, data)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data == {'task': 'id'}
|
||||
scan.assert_called_once_with(
|
||||
library_id=library.pk,
|
||||
until=now
|
||||
)
|
||||
|
||||
|
||||
def test_list_library_tracks(factories, superuser_api_client):
|
||||
library = factories['federation.Library']()
|
||||
lts = list(reversed(factories['federation.LibraryTrack'].create_batch(
|
||||
size=5, library=library)))
|
||||
factories['federation.LibraryTrack'].create_batch(size=5)
|
||||
url = reverse('api:v1:federation:library-tracks-list')
|
||||
response = superuser_api_client.get(url, {'library': library.uuid})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data == {
|
||||
'results': serializers.APILibraryTrackSerializer(lts, many=True).data,
|
||||
'count': 5,
|
||||
'previous': None,
|
||||
'next': None,
|
||||
}
|
||||
|
||||
|
||||
def test_can_update_follow_status(factories, superuser_api_client, mocker):
|
||||
patched_accept = mocker.patch(
|
||||
'funkwhale_api.federation.activity.accept_follow'
|
||||
)
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
follow = factories['federation.Follow'](target=library_actor)
|
||||
|
||||
payload = {
|
||||
'follow': follow.pk,
|
||||
'approved': True
|
||||
}
|
||||
url = reverse('api:v1:federation:libraries-followers')
|
||||
response = superuser_api_client.patch(url, payload)
|
||||
follow.refresh_from_db()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert follow.approved is True
|
||||
patched_accept.assert_called_once_with(follow)
|
||||
|
||||
|
||||
def test_can_filter_pending_follows(factories, superuser_api_client):
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
follow = factories['federation.Follow'](
|
||||
target=library_actor,
|
||||
approved=True)
|
||||
|
||||
params = {'pending': True}
|
||||
url = reverse('api:v1:federation:libraries-followers')
|
||||
response = superuser_api_client.get(url, params)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(response.data['results']) == 0
|
||||
|
|
|
@ -40,3 +40,29 @@ def test_webfinger_clean_acct_errors(resource, message, settings):
|
|||
webfinger.clean_resource(resource)
|
||||
|
||||
assert message == str(excinfo)
|
||||
|
||||
|
||||
def test_webfinger_get_resource(r_mock):
|
||||
resource = 'acct:test@test.webfinger'
|
||||
payload = {
|
||||
'subject': resource,
|
||||
'aliases': ['https://test.webfinger'],
|
||||
'links': [
|
||||
{
|
||||
'rel': 'self',
|
||||
'type': 'application/activity+json',
|
||||
'href': 'https://test.webfinger/user/test'
|
||||
}
|
||||
]
|
||||
}
|
||||
r_mock.get(
|
||||
'https://test.webfinger/.well-known/webfinger?resource={}'.format(
|
||||
resource
|
||||
),
|
||||
json=payload
|
||||
)
|
||||
|
||||
data = webfinger.get_resource('acct:test@test.webfinger')
|
||||
|
||||
assert data['actor_url'] == 'https://test.webfinger/user/test'
|
||||
assert data['subject'] == resource
|
||||
|
|
|
@ -47,10 +47,25 @@ def test_list_permission_protect_following_actor(
|
|||
factories, api_request, settings):
|
||||
settings.PROTECT_AUDIO_FILES = True
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
follow = factories['federation.Follow'](target=library_actor)
|
||||
follow = factories['federation.Follow'](
|
||||
approved=True, target=library_actor)
|
||||
view = APIView.as_view()
|
||||
permission = permissions.Listen()
|
||||
request = api_request.get('/')
|
||||
setattr(request, 'actor', follow.actor)
|
||||
|
||||
assert permission.has_permission(request, view) is True
|
||||
|
||||
|
||||
def test_list_permission_protect_following_actor_not_approved(
|
||||
factories, api_request, settings):
|
||||
settings.PROTECT_AUDIO_FILES = True
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
follow = factories['federation.Follow'](
|
||||
approved=False, target=library_actor)
|
||||
view = APIView.as_view()
|
||||
permission = permissions.Listen()
|
||||
request = api_request.get('/')
|
||||
setattr(request, 'actor', follow.actor)
|
||||
|
||||
assert permission.has_permission(request, view) is False
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import io
|
||||
import pytest
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
from funkwhale_api.music import views
|
||||
from funkwhale_api.federation import actors
|
||||
|
||||
|
@ -52,7 +54,9 @@ def test_can_serve_track_file_as_remote_library(
|
|||
settings.PROTECT_AUDIO_FILES = True
|
||||
library_actor = actors.SYSTEM_ACTORS['library'].get_actor_instance()
|
||||
follow = factories['federation.Follow'](
|
||||
actor=authenticated_actor, target=library_actor)
|
||||
approved=True,
|
||||
actor=authenticated_actor,
|
||||
target=library_actor)
|
||||
|
||||
track_file = factories['music.TrackFile']()
|
||||
response = api_client.get(track_file.path)
|
||||
|
@ -77,9 +81,31 @@ def test_can_proxy_remote_track(
|
|||
settings.PROTECT_AUDIO_FILES = False
|
||||
track_file = factories['music.TrackFile'](federation=True)
|
||||
|
||||
r_mock.get(track_file.library_track.audio_url, body=io.StringIO('test'))
|
||||
r_mock.get(track_file.library_track.audio_url, body=io.BytesIO(b'test'))
|
||||
response = api_client.get(track_file.path)
|
||||
|
||||
library_track = track_file.library_track
|
||||
library_track.refresh_from_db()
|
||||
assert response.status_code == 200
|
||||
assert list(response.streaming_content) == [b't', b'e', b's', b't']
|
||||
assert response['Content-Type'] == track_file.library_track.audio_mimetype
|
||||
assert response['X-Accel-Redirect'] == "{}{}".format(
|
||||
settings.PROTECT_FILES_PATH,
|
||||
library_track.audio_file.url)
|
||||
assert library_track.audio_file.read() == b'test'
|
||||
|
||||
|
||||
def test_can_create_import_from_federation_tracks(
|
||||
factories, superuser_api_client, mocker):
|
||||
lts = factories['federation.LibraryTrack'].create_batch(size=5)
|
||||
mocker.patch('funkwhale_api.music.tasks.import_job_run')
|
||||
|
||||
payload = {
|
||||
'library_tracks': [l.pk for l in lts]
|
||||
}
|
||||
url = reverse('api:v1:submit-federation')
|
||||
response = superuser_api_client.post(url, payload)
|
||||
|
||||
assert response.status_code == 201
|
||||
batch = superuser_api_client.user.imports.latest('id')
|
||||
assert batch.jobs.count() == 5
|
||||
for i, job in enumerate(batch.jobs.all()):
|
||||
assert job.library_track == lts[i]
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Can now follow and import music from remote libraries (#136, #137)
|
45
dev.yml
45
dev.yml
|
@ -10,22 +10,39 @@ services:
|
|||
- "HOST=0.0.0.0"
|
||||
- "WEBPACK_DEVSERVER_PORT=${WEBPACK_DEVSERVER_PORT-8080}"
|
||||
ports:
|
||||
- "${WEBPACK_DEVSERVER_PORT-8080}:${WEBPACK_DEVSERVER_PORT-8080}"
|
||||
- "${WEBPACK_DEVSERVER_PORT_BINDING-8080:}${WEBPACK_DEVSERVER_PORT-8080}"
|
||||
volumes:
|
||||
- './front:/app'
|
||||
- './po:/po'
|
||||
networks:
|
||||
- federation
|
||||
- internal
|
||||
labels:
|
||||
traefik.backend: "${COMPOSE_PROJECT_NAME-node1}"
|
||||
traefik.frontend.rule: "Host: ${COMPOSE_PROJECT_NAME-node1}.funkwhale.test"
|
||||
traefik.enable: 'true'
|
||||
traefik.federation.protocol: 'http'
|
||||
traefik.federation.port: "${WEBPACK_DEVSERVER_PORT-8080}"
|
||||
|
||||
postgres:
|
||||
env_file:
|
||||
- .env.dev
|
||||
- .env
|
||||
image: postgres
|
||||
volumes:
|
||||
- "./data/${COMPOSE_PROJECT_NAME-node1}/postgres:/var/lib/postgresql/data"
|
||||
networks:
|
||||
- internal
|
||||
|
||||
redis:
|
||||
env_file:
|
||||
- .env.dev
|
||||
- .env
|
||||
image: redis:3.0
|
||||
volumes:
|
||||
- "./data/${COMPOSE_PROJECT_NAME-node1}/redis:/data"
|
||||
networks:
|
||||
- internal
|
||||
|
||||
celeryworker:
|
||||
env_file:
|
||||
|
@ -39,11 +56,17 @@ services:
|
|||
- redis
|
||||
command: celery -A funkwhale_api.taskapp worker -l debug
|
||||
environment:
|
||||
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}"
|
||||
- "FUNKWHALE_HOSTNAME_SUFFIX=funkwhale.test"
|
||||
- "FUNKWHALE_HOSTNAME_PREFIX=${COMPOSE_PROJECT_NAME}"
|
||||
- "FUNKWHALE_PROTOCOL=${FUNKWHALE_PROTOCOL-http}"
|
||||
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
||||
- "CACHE_URL=redis://redis:6379/0"
|
||||
volumes:
|
||||
- ./api:/app
|
||||
- ./data/music:/music
|
||||
networks:
|
||||
- internal
|
||||
api:
|
||||
env_file:
|
||||
- .env.dev
|
||||
|
@ -56,12 +79,17 @@ services:
|
|||
- ./api:/app
|
||||
- ./data/music:/music
|
||||
environment:
|
||||
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}"
|
||||
- "FUNKWHALE_HOSTNAME_SUFFIX=funkwhale.test"
|
||||
- "FUNKWHALE_HOSTNAME_PREFIX=${COMPOSE_PROJECT_NAME}"
|
||||
- "FUNKWHALE_PROTOCOL=${FUNKWHALE_PROTOCOL-http}"
|
||||
- "DATABASE_URL=postgresql://postgres@postgres/postgres"
|
||||
- "CACHE_URL=redis://redis:6379/0"
|
||||
links:
|
||||
- postgres
|
||||
- redis
|
||||
|
||||
networks:
|
||||
- internal
|
||||
nginx:
|
||||
command: /entrypoint.sh
|
||||
env_file:
|
||||
|
@ -70,6 +98,8 @@ services:
|
|||
image: nginx
|
||||
environment:
|
||||
- "WEBPACK_DEVSERVER_PORT=${WEBPACK_DEVSERVER_PORT-8080}"
|
||||
- "COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME- }"
|
||||
- "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}"
|
||||
links:
|
||||
- api
|
||||
- front
|
||||
|
@ -79,8 +109,9 @@ services:
|
|||
- ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf.template:ro
|
||||
- ./api/funkwhale_api/media:/protected/media
|
||||
ports:
|
||||
- "0.0.0.0:6001:6001"
|
||||
|
||||
- "6001"
|
||||
networks:
|
||||
- internal
|
||||
docs:
|
||||
build: docs
|
||||
command: python serve.py
|
||||
|
@ -89,3 +120,9 @@ services:
|
|||
ports:
|
||||
- '35730:35730'
|
||||
- '8001:8001'
|
||||
|
||||
networks:
|
||||
internal:
|
||||
federation:
|
||||
external:
|
||||
name: federation
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
#!/bin/bash -eux
|
||||
FIRST_HOST=$(echo ${DJANGO_ALLOWED_HOSTS} | cut -d, -f1)
|
||||
|
||||
FORWARDED_PORT="$WEBPACK_DEVSERVER_PORT"
|
||||
COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME// /}"
|
||||
if [ -n "$COMPOSE_PROJECT_NAME" ]; then
|
||||
echo
|
||||
FUNKWHALE_HOSTNAME="$COMPOSE_PROJECT_NAME.funkwhale.test"
|
||||
FORWARDED_PORT="443"
|
||||
fi
|
||||
echo "Copying template file..."
|
||||
cp /etc/nginx/funkwhale_proxy.conf{.template,}
|
||||
sed -i "s/X-Forwarded-Host \$host:\$server_port/X-Forwarded-Host ${FIRST_HOST}:${WEBPACK_DEVSERVER_PORT}/" /etc/nginx/funkwhale_proxy.conf
|
||||
sed -i "s/proxy_set_header Host \$host/proxy_set_header Host ${FIRST_HOST}/" /etc/nginx/funkwhale_proxy.conf
|
||||
sed -i "s/proxy_set_header X-Forwarded-Port \$server_port/proxy_set_header X-Forwarded-Port ${WEBPACK_DEVSERVER_PORT}/" /etc/nginx/funkwhale_proxy.conf
|
||||
sed -i "s/X-Forwarded-Host \$host:\$server_port/X-Forwarded-Host ${FUNKWHALE_HOSTNAME}:${FORWARDED_PORT}/" /etc/nginx/funkwhale_proxy.conf
|
||||
sed -i "s/proxy_set_header Host \$host/proxy_set_header Host ${FUNKWHALE_HOSTNAME}/" /etc/nginx/funkwhale_proxy.conf
|
||||
sed -i "s/proxy_set_header X-Forwarded-Port \$server_port/proxy_set_header X-Forwarded-Port ${FORWARDED_PORT}/" /etc/nginx/funkwhale_proxy.conf
|
||||
|
||||
cat /etc/nginx/funkwhale_proxy.conf
|
||||
nginx -g "daemon off;"
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIDljCCAn6gAwIBAgIJAOA/w9NwL3aMMA0GCSqGSIb3DQEBCwUAMGAxCzAJBgNV
|
||||
BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX
|
||||
aWRnaXRzIFB0eSBMdGQxGTAXBgNVBAMMECouZnVua3doYWxlLnRlc3QwHhcNMTgw
|
||||
NDA4MTMwNDAzWhcNMjgwNDA1MTMwNDAzWjBgMQswCQYDVQQGEwJBVTETMBEGA1UE
|
||||
CAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRk
|
||||
MRkwFwYDVQQDDBAqLmZ1bmt3aGFsZS50ZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOC
|
||||
AQ8AMIIBCgKCAQEAyGqRLEMFs1mpRwauTicIRj2zwBUe6JMNRbIvOUkaj2KY6avA
|
||||
7tiNti/ygBoTyJl2JK3mmLqxElqedpMhjVvYde/PyjXoZ+0Vq4FWv89LV6ZM/Scf
|
||||
TCIYwWF1ppi6GYFmU3WCIMISkKiPBtMArB0oZxiUWLmkyd8jih2wnQOpkQ20FfG0
|
||||
CtlrKlQKyAe7X3zPuqGfaMUN7J4w9g3/SC66YulbAtI1/Z4tuG8J4m2RC6jH1hVy
|
||||
364l3ifEC+m9Kax/ystfu/mkLdyQgRfOZTNf2JhS3BL8zpoWMXFK+4+7TYisrV1h
|
||||
0pzIAsoQeBB+cFOOFEwRAv0FxSWnZ+/shjnwbwIDAQABo1MwUTAdBgNVHQ4EFgQU
|
||||
sULmofttRyWUMM93IsD8jBvyCd4wHwYDVR0jBBgwFoAUsULmofttRyWUMM93IsD8
|
||||
jBvyCd4wDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAUg/fiXut
|
||||
hW6fDx9f0JdB4uLiLnv8tDP35ackLLapFJhXtflIXcqCzxStQ46nMs1wjaZPb+ws
|
||||
pLULzvTKTxJbu+JYc2nvis4m2oSFczJ3S9tgug4Ppv8yS7N1pp7kfjOvBjgh6sYW
|
||||
p+Ctb5r8qvgvT9yDTeCnsqktb/OkRHlHwhRYfnuxh+96s4mzifqFUP4uCCcFYPTc
|
||||
RE0Ag3oI5sHOdDk/cdYE5PGQPjSP6gzn0lsrz1Q3x1C8+txSHzsJnvS3Ost+dwcy
|
||||
JSjDBXauy9cZv93Voevcl16Ioo7trtkp4dwAoep52vOT/KMkJ4zm19msV3BP4wMa
|
||||
BUqrV2F7twD5zw==
|
||||
-----END CERTIFICATE-----
|
|
@ -0,0 +1,28 @@
|
|||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEwAIBADANBgkqhkiG9w0BAQEFAASCBKowggSmAgEAAoIBAQDIapEsQwWzWalH
|
||||
Bq5OJwhGPbPAFR7okw1Fsi85SRqPYpjpq8Du2I22L/KAGhPImXYkreaYurESWp52
|
||||
kyGNW9h178/KNehn7RWrgVa/z0tXpkz9Jx9MIhjBYXWmmLoZgWZTdYIgwhKQqI8G
|
||||
0wCsHShnGJRYuaTJ3yOKHbCdA6mRDbQV8bQK2WsqVArIB7tffM+6oZ9oxQ3snjD2
|
||||
Df9ILrpi6VsC0jX9ni24bwnibZELqMfWFXLfriXeJ8QL6b0prH/Ky1+7+aQt3JCB
|
||||
F85lM1/YmFLcEvzOmhYxcUr7j7tNiKytXWHSnMgCyhB4EH5wU44UTBEC/QXFJadn
|
||||
7+yGOfBvAgMBAAECggEBAMVB3lEqRloYTbxSnwzc7g/0ew77usg+tDl8/23qvfGS
|
||||
od6b5fEvw4sl9hCPmhk+skG3x9dbKR1fg8hBWCzB0XOC7YmhNXXUrBd53eA8L3O9
|
||||
gtlHwE424Ra0zg+DEug3rHdImSOU4KDwxpV46Jh+ul1+m8QYNFFdBqXSQxrHmAXj
|
||||
MQ6++rjoJ+bhucmjBouzMYXHTGhdae3kjDFrFJ4cUsH6F03NcDwS+AmZxa/DWQ/H
|
||||
SoBQBeLoE6I1aKhLgY91yO1e7CtSzS2GFCODReN4b3cylaR7jE7Mg87TZcga6Wfa
|
||||
Xcd120VVlVq6HmZc/Xob7aUim3AuY2er8bcvmg1XOsECgYEA5EMM5UlpLdNWv1hp
|
||||
5IMvkeCbXtLJ3IOHO0xLkFdx0CxaR9TyAAqIrSh1t9rFhYqLUNiOdMc2TqrvdgEU
|
||||
B/QZrAevWRc5sjPvFXmYeWSCi/tjRgQh4jClWDX/TlfAlP55z2BFyMPMX6//WbBQ
|
||||
5aL9xymTymzFFcaE8EytT5Jz8rUCgYEA4MVF3IkaQepl6H1gf2T6ev+MtGk9AGg9
|
||||
DSJpio7hfMcY5X3NrTJJFF9DJFXqfo3ILOMyUpIUHqkCGKXil0n9ypLp4vq7l+6c
|
||||
m1gtKFXh7uKAV4XtSnR0nuK/N10JJp2HbbFYGlziRaa1iEPAFvLDQHu4jyf5sXyV
|
||||
HvreuQgGWRMCgYEAlUaQKWaP5UsfoPUGE04DjwfvM9zv7EkL6CimBhhZswU+aVmG
|
||||
haZd6bfa/EiTAhkvsMheqVoaVuoMvgRIgEcPfuRrtPyuW68A/O9PWpvzj+3v5zsO
|
||||
maisiPqPI0HaDNY6/PZ9zKTXhABKIvJehT7JbjTvlOL7JJl2GNxcPvyM3T0CgYEA
|
||||
tnVtUKi69+ce8qtUOhXufwoTXiBPtJTpelAE/MUfpfq46xJEc+PuDuuFxWk5AaJ2
|
||||
bHnBz+VlD76CRR/j4IvfySGZWvfOcHbyCeh6P9P3o8OaC3JcPaRrRs8qCfcsBny6
|
||||
AwGDU2MzCvdZRVQ6CmbmuOG13//DYaCQLKXZRrqM7KECgYEAxDsqtyHA/a38UhS8
|
||||
iQ8HqrZp8CuzJoJw/QILvzjojD1cvmwF73RrPEpRfEaLWVQGQ5F1IlHk/009C5zy
|
||||
eUT4ZaPxLem6khBf7pn3xXaVBGZsYoltek5sUBsu/jA+4Sw6bcUmhBRBCs98JGpR
|
||||
DVJtvOTk9aGW8M8UbgqwW+e/6ng=
|
||||
-----END PRIVATE KEY-----
|
|
@ -0,0 +1,26 @@
|
|||
defaultEntryPoints = ["http", "https"]
|
||||
|
||||
################################################################
|
||||
# Web configuration backend
|
||||
################################################################
|
||||
[web]
|
||||
address = ":8040"
|
||||
################################################################
|
||||
# Docker configuration backend
|
||||
################################################################
|
||||
[docker]
|
||||
domain = "funkwhale.test"
|
||||
watch = true
|
||||
exposedbydefault = false
|
||||
|
||||
[entryPoints]
|
||||
[entryPoints.http]
|
||||
address = ":80"
|
||||
[entryPoints.http.redirect]
|
||||
entryPoint = "https"
|
||||
[entryPoints.https]
|
||||
address = ":443"
|
||||
[entryPoints.https.tls]
|
||||
[[entryPoints.https.tls.certificates]]
|
||||
certFile = "/ssl/traefik.crt"
|
||||
keyFile = "/ssl/traefik.key"
|
|
@ -0,0 +1,22 @@
|
|||
version: '2.1'
|
||||
|
||||
services:
|
||||
traefik:
|
||||
image: traefik:alpine
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./traefik.toml:/traefik.toml
|
||||
- ./ssl/test.key:/ssl/traefik.key
|
||||
- ./ssl/test.crt:/ssl/traefik.crt
|
||||
ports:
|
||||
- '80:80'
|
||||
- '443:443'
|
||||
- '8040:8040'
|
||||
networks:
|
||||
federation:
|
||||
|
||||
|
||||
networks:
|
||||
federation:
|
||||
external:
|
||||
name: federation
|
|
@ -42,3 +42,14 @@ The project itself is splitted in two parts:
|
|||
While the main interface to the server and API is the bundled front-end, the project itself is agnostic in the way you connect to it. Therefore, desktop clients or apps could be developped and implement the same (or even more) features as the bundled frontend.
|
||||
|
||||
This modularity also makes it possible do deploy only a single component from the system.
|
||||
|
||||
Federation
|
||||
----------
|
||||
|
||||
Each Funkwhale instance is able to fetch music from other compatible servers,
|
||||
and share its own library on the network. The federation is implemented
|
||||
using the ActivityPub protocol, in order to leverage existing tools
|
||||
and be compatible with other services such as Mastodon.
|
||||
|
||||
As of today, federation only targets music acquisition, meaning user interaction
|
||||
are not shared via ActivityPub. This will be implemented at a later point.
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
Federation
|
||||
==========
|
||||
|
||||
Each Funkwale instance can federates its music library with other instances
|
||||
of the network. This means that an instance A can acquire music from instance B
|
||||
and share its own library with an instance C.
|
||||
|
||||
We support various levels of controls for federation-related features.
|
||||
|
||||
Acquire music via federation
|
||||
----------------------------
|
||||
|
||||
Instance libraries are protected by default. To access another instance
|
||||
library, you have to follow it. This follow request will be sent to
|
||||
the other instance which can accept or deny it. Once your follow request
|
||||
is accepted, you can start browsing the other instance library
|
||||
and import music from it.
|
||||
|
||||
By default, we do not duplicate audio files from federated tracks, to reduce
|
||||
disk usage on your instance. When someone listens to a federated track,
|
||||
the audio file is requested on the fly from the remote instance, and
|
||||
store in a local cache. It is automatically deleted after a configurable
|
||||
amount of time if it was not listened again in the meantime.
|
||||
|
||||
If you want to mirror a remote instance collection, including its audio files,
|
||||
we offer an option for that.
|
||||
|
||||
We also support an "autoimport" mode for each remote library. When enabled,
|
||||
any new track published in the remote library will be directly imported
|
||||
in your instance.
|
||||
|
||||
Share music via federation
|
||||
--------------------------
|
||||
|
||||
Federation is enabled by default, but requires manually approving
|
||||
each other instance asking for access to library. This is by design,
|
||||
to ensure your library is not shared publicly without your consent.
|
||||
|
||||
However, we offer a configuration option to alter this behaviour and
|
||||
disable the manual approval part.
|
|
@ -67,3 +67,9 @@ under creative commons (courtesy of Jamendo):
|
|||
./download-tracks.sh music.txt
|
||||
|
||||
This will download a bunch of zip archives (one per album) under the ``data/music`` directory and unzip their content.
|
||||
|
||||
From other instances
|
||||
--------------------
|
||||
|
||||
Funkwhale also supports importing music from other instances. Please refer
|
||||
to :doc:`federation` for more details.
|
||||
|
|
|
@ -15,6 +15,7 @@ Funkwhale is a self-hosted, modern free and open-source music server, heavily in
|
|||
installation/index
|
||||
configuration
|
||||
importing-music
|
||||
federation
|
||||
upgrading
|
||||
changelog
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<div class="ui vertical center aligned stripe segment">
|
||||
<div class="ui text container">
|
||||
<h1 class="ui huge header">
|
||||
Welcome on Funkwhale
|
||||
Welcome on Funkwhale
|
||||
</h1>
|
||||
<p>We think listening music should be simple.</p>
|
||||
<router-link class="ui icon button" to="/about">
|
||||
|
|
|
@ -1,25 +1,25 @@
|
|||
<template>
|
||||
<div class="ui pagination borderless menu">
|
||||
<a
|
||||
v-if="current - 1 >= 1"
|
||||
<div class="ui pagination menu">
|
||||
<div
|
||||
:disabled="current - 1 < 1"
|
||||
@click="selectPage(current - 1)"
|
||||
:class="[{'disabled': current - 1 < 1}, 'item']"><i class="angle left icon"></i></a>
|
||||
<template>
|
||||
<a
|
||||
:class="[{'disabled': current - 1 < 1}, 'item']"><i class="angle left icon"></i></div>
|
||||
<template v-if="!compact">
|
||||
<div
|
||||
v-if="page !== 'skip'"
|
||||
v-for="page in pages"
|
||||
@click="selectPage(page)"
|
||||
:class="[{'active': page === current}, 'item']">
|
||||
{{ page }}
|
||||
</a>
|
||||
<a v-else class="disabled item">
|
||||
</div>
|
||||
<div v-else class="disabled item">
|
||||
...
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
<a
|
||||
v-if="current + 1 <= maxPage"
|
||||
<div
|
||||
:disabled="current + 1 > maxPage"
|
||||
@click="selectPage(current + 1)"
|
||||
:class="[{'disabled': current + 1 > maxPage}, 'item']"><i class="angle right icon"></i></a>
|
||||
:class="[{'disabled': current + 1 > maxPage}, 'item']"><i class="angle right icon"></i></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -30,7 +30,8 @@ export default {
|
|||
props: {
|
||||
current: {type: Number, default: 1},
|
||||
paginateBy: {type: Number, default: 25},
|
||||
total: {type: Number}
|
||||
total: {type: Number},
|
||||
compact: {type: Boolean, default: false}
|
||||
},
|
||||
computed: {
|
||||
pages: function () {
|
||||
|
@ -72,6 +73,9 @@ export default {
|
|||
},
|
||||
methods: {
|
||||
selectPage: function (page) {
|
||||
if (page > this.maxPage || page < 1) {
|
||||
return
|
||||
}
|
||||
if (this.current !== page) {
|
||||
this.$emit('page-changed', page)
|
||||
}
|
||||
|
|
|
@ -45,6 +45,9 @@
|
|||
<router-link
|
||||
v-if="$store.state.auth.authenticated"
|
||||
class="item" :to="{path: '/activity'}"><i class="bell icon"></i> Activity</router-link>
|
||||
<router-link
|
||||
class="item" v-if="$store.state.auth.availablePermissions['federation.manage']"
|
||||
:to="{path: '/manage/federation/libraries'}"><i class="sitemap icon"></i> Federation</router-link>
|
||||
</div>
|
||||
|
||||
<player></player>
|
||||
|
|
|
@ -26,7 +26,7 @@ import Modal from '@/components/semantic/Modal'
|
|||
|
||||
export default {
|
||||
props: {
|
||||
action: {type: Function, required: true},
|
||||
action: {type: Function, required: false},
|
||||
disabled: {type: Boolean, default: false},
|
||||
color: {type: String, default: 'red'}
|
||||
},
|
||||
|
@ -41,7 +41,10 @@ export default {
|
|||
methods: {
|
||||
confirm () {
|
||||
this.showModal = false
|
||||
this.action()
|
||||
this.$emit('confirm')
|
||||
if (this.action) {
|
||||
this.action()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,121 @@
|
|||
<template>
|
||||
<div class="ui card">
|
||||
<div class="content">
|
||||
<div class="header">
|
||||
{{ displayName }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<span class="right floated" v-if="following">
|
||||
<i class="check icon"></i> Following
|
||||
</span>
|
||||
<span class="right floated" v-else-if="manuallyApprovesFollowers">
|
||||
<i class="lock icon"></i> Followers only
|
||||
</span>
|
||||
<span class="right floated" v-else>
|
||||
<i class="open lock icon"></i> Open
|
||||
</span>
|
||||
<span v-if="totalItems">
|
||||
<i class="music icon"></i>
|
||||
{{ totalItems }} tracks
|
||||
</span>
|
||||
</div>
|
||||
<div class="extra content">
|
||||
<template v-if="awaitingApproval">
|
||||
<i class="clock icon"></i>
|
||||
Follow request pending approval
|
||||
</template>
|
||||
<div
|
||||
v-if="!library"
|
||||
@click="follow"
|
||||
:disabled="isLoading"
|
||||
:class="['ui', 'basic', {loading: isLoading}, 'green', 'button']">
|
||||
<template v-if="manuallyApprovesFollowers">
|
||||
Send a follow request
|
||||
</template>
|
||||
<template v-else>
|
||||
Follow
|
||||
</template>
|
||||
</div>
|
||||
<router-link
|
||||
v-else
|
||||
class="ui basic button"
|
||||
:to="{name: 'federation.libraries.detail', params: {id: library.uuid }}">
|
||||
Detail
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
|
||||
export default {
|
||||
props: ['libraryData', 'libraryInstance'],
|
||||
data () {
|
||||
return {
|
||||
library: this.libraryInstance,
|
||||
isLoading: false,
|
||||
data: null,
|
||||
errors: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
follow () {
|
||||
let params = {
|
||||
'actor': this.libraryData['actor']['id'],
|
||||
'autoimport': false,
|
||||
'download_files': false,
|
||||
'federation_enabled': true
|
||||
}
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
axios.post('/federation/libraries/', params).then((response) => {
|
||||
self.$emit('follow', {data: self.libraryData, library: response.data})
|
||||
self.library = response.data
|
||||
self.isLoading = false
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
displayName () {
|
||||
if (this.libraryData) {
|
||||
return this.libraryData.display_name
|
||||
} else {
|
||||
return `${this.library.actor.preferred_username}@${this.library.actor.domain}`
|
||||
}
|
||||
},
|
||||
manuallyApprovesFollowers () {
|
||||
if (this.libraryData) {
|
||||
return this.libraryData.actor.manuallyApprovesFollowers
|
||||
} else {
|
||||
return this.library.actor.manually_approves_followers
|
||||
}
|
||||
},
|
||||
totalItems () {
|
||||
if (this.libraryData) {
|
||||
return this.libraryData.library.totalItems
|
||||
} else {
|
||||
return this.library.tracks_count
|
||||
}
|
||||
},
|
||||
awaitingApproval () {
|
||||
if (this.libraryData) {
|
||||
return this.libraryData.local.awaiting_approval
|
||||
} else {
|
||||
return this.library.follow.approved === null
|
||||
}
|
||||
},
|
||||
following () {
|
||||
if (this.libraryData) {
|
||||
return this.libraryData.local.following
|
||||
} else {
|
||||
return this.library.follow.approved
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,161 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui form">
|
||||
<div class="fields">
|
||||
<div class="ui six wide field">
|
||||
<input type="text" v-model="search" placeholder="Search by username, domain..." />
|
||||
</div>
|
||||
<div class="ui four wide inline field">
|
||||
<div class="ui checkbox">
|
||||
<input v-model="pending" type="checkbox">
|
||||
<label>Pending approval</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui hidden divider"></div>
|
||||
<table v-if="result" class="ui very basic single line unstackable table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Actor</th>
|
||||
<th>Creation date</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="follow in result.results">
|
||||
<td>
|
||||
{{ follow.actor.preferred_username }}@{{ follow.actor.domain }}
|
||||
</td>
|
||||
<td>
|
||||
<human-date :date="follow.creation_date"></human-date>
|
||||
</td>
|
||||
<td>
|
||||
<template v-if="follow.approved === true">
|
||||
<i class="check icon"></i> Approved
|
||||
</template>
|
||||
<template v-else-if="follow.approved === false">
|
||||
<i class="x icon"></i> Refused
|
||||
</template>
|
||||
<template v-else>
|
||||
<i class="clock icon"></i> Pending
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
<dangerous-button v-if="follow.approved !== false" class="tiny basic labeled icon" color='red' @confirm="updateFollow(follow, false)">
|
||||
<i class="x icon"></i> Deny
|
||||
<p slot="modal-header">Deny access?</p>
|
||||
<p slot="modal-content">By confirming, {{ follow.actor.preferred_username }}@{{ follow.actor.domain }} will be denied access to your library.</p>
|
||||
<p slot="modal-confirm">Deny</p>
|
||||
</dangerous-button>
|
||||
<dangerous-button v-if="follow.approved !== true" class="tiny basic labeled icon" color='green' @confirm="updateFollow(follow, true)">
|
||||
<i class="x icon"></i> Approve
|
||||
<p slot="modal-header">Approve access?</p>
|
||||
<p slot="modal-content">By confirming, {{ follow.actor.preferred_username }}@{{ follow.actor.domain }} will be granted access to your library.</p>
|
||||
<p slot="modal-confirm">Approve</p>
|
||||
</dangerous-button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot class="full-width">
|
||||
<tr>
|
||||
<th>
|
||||
<pagination
|
||||
v-if="result && result.results.length > 0"
|
||||
@page-changed="selectPage"
|
||||
:compact="true"
|
||||
:current="page"
|
||||
:paginate-by="paginateBy"
|
||||
:total="result.count"
|
||||
></pagination>
|
||||
</th>
|
||||
<th v-if="result && result.results.length > 0">
|
||||
Showing results {{ ((page-1) * paginateBy) + 1 }}-{{ ((page-1) * paginateBy) + result.results.length }} on {{ result.count }}</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import _ from 'lodash'
|
||||
|
||||
import Pagination from '@/components/Pagination'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
filters: {type: Object, required: false, default: () => {}}
|
||||
},
|
||||
components: {
|
||||
Pagination
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
result: null,
|
||||
page: 1,
|
||||
paginateBy: 25,
|
||||
search: '',
|
||||
pending: false
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
let params = _.merge({
|
||||
'page': this.page,
|
||||
'page_size': this.paginateBy,
|
||||
'q': this.search
|
||||
}, this.filters)
|
||||
if (this.pending) {
|
||||
params.pending = true
|
||||
}
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
axios.get('/federation/libraries/followers/', {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
|
||||
},
|
||||
updateFollow (follow, approved) {
|
||||
let payload = {
|
||||
follow: follow.id,
|
||||
approved: approved
|
||||
}
|
||||
let self = this
|
||||
axios.patch('/federation/libraries/followers/', payload).then((response) => {
|
||||
follow.approved = response.data.approved
|
||||
self.isLoading = false
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
search (newValue) {
|
||||
if (newValue.length > 0) {
|
||||
this.fetchData()
|
||||
}
|
||||
},
|
||||
page () {
|
||||
this.fetchData()
|
||||
},
|
||||
pending () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,110 @@
|
|||
<template>
|
||||
<form class="ui form" @submit.prevent="fetchInstanceInfo">
|
||||
<h3 class="ui header">Federate with a new instance</h3>
|
||||
<p>Use this form to scan an instance and setup federation.</p>
|
||||
<div v-if="errors.length > 0 || scanErrors.length > 0" class="ui negative message">
|
||||
<div class="header">Error while scanning library</div>
|
||||
<ul class="list">
|
||||
<li v-for="error in errors">{{ error }}</li>
|
||||
<li v-for="error in scanErrors">{{ error }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="ui two fields">
|
||||
<div class="ui field">
|
||||
<label>Library name</label>
|
||||
<input v-model="libraryUsername" type="text" placeholder="library@demo.funkwhale.audio" />
|
||||
</div>
|
||||
<div class="ui field">
|
||||
<label> </label>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoading"
|
||||
:class="['ui', 'icon', {loading: isLoading}, 'button']">
|
||||
<i class="search icon"></i>
|
||||
Launch scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import TrackTable from '@/components/audio/track/Table'
|
||||
import RadioButton from '@/components/radios/Button'
|
||||
import Pagination from '@/components/Pagination'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
TrackTable,
|
||||
RadioButton,
|
||||
Pagination
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
libraryUsername: '',
|
||||
result: null,
|
||||
errors: []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
follow () {
|
||||
let params = {
|
||||
'actor': this.result['actor']['id'],
|
||||
'autoimport': false,
|
||||
'download_files': false,
|
||||
'federation_enabled': true
|
||||
}
|
||||
let self = this
|
||||
self.isFollowing = false
|
||||
axios.post('/federation/libraries/', params).then((response) => {
|
||||
self.$emit('follow', {data: self.result, library: response.data})
|
||||
self.result = response.data
|
||||
self.isFollowing = false
|
||||
}, error => {
|
||||
self.isFollowing = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
},
|
||||
fetchInstanceInfo () {
|
||||
let self = this
|
||||
this.isLoading = true
|
||||
self.errors = []
|
||||
self.result = null
|
||||
axios.get('/federation/libraries/fetch/', {params: {account: this.libraryUsername}}).then((response) => {
|
||||
self.result = response.data
|
||||
self.result.display_name = self.libraryUsername
|
||||
self.isLoading = false
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
scanErrors () {
|
||||
let errors = []
|
||||
if (!this.result) {
|
||||
return errors
|
||||
}
|
||||
let keys = ['webfinger', 'actor', 'library']
|
||||
keys.forEach(k => {
|
||||
if (this.result[k]) {
|
||||
if (this.result[k].errors) {
|
||||
this.result[k].errors.forEach(e => {
|
||||
errors.push(e)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
return errors
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
result (newValue, oldValue) {
|
||||
this.$emit('scanned', newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -0,0 +1,186 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui inline form">
|
||||
<input type="text" v-model="search" placeholder="Search by title, artist, domain..." />
|
||||
</div>
|
||||
<table v-if="result" class="ui compact very basic single line unstackable table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<div class="ui checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
@change="toggleCheckAll"
|
||||
:checked="result.results.length === checked.length"><label> </label>
|
||||
</div>
|
||||
</th>
|
||||
<th>Title</th>
|
||||
<th>Artist</th>
|
||||
<th>Album</th>
|
||||
<th>Published date</th>
|
||||
<th v-if="showLibrary">Library</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="track in result.results">
|
||||
<td class="collapsing">
|
||||
<div v-if="!track.local_track_file" class="ui checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
@change="toggleCheck(track.id)"
|
||||
:checked="checked.indexOf(track.id) > -1"><label> </label>
|
||||
</div>
|
||||
<div v-else class="ui label">
|
||||
In library
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span :title="track.title">{{ track.title|truncate(30) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :title="track.artist_name">{{ track.artist_name|truncate(30) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span :title="track.album_title">{{ track.album_title|truncate(20) }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<human-date :date="track.published_date"></human-date>
|
||||
</td>
|
||||
<td v-if="showLibrary">
|
||||
{{ track.library.actor.domain }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot class="full-width">
|
||||
<tr>
|
||||
<th>
|
||||
<pagination
|
||||
v-if="result && result.results.length > 0"
|
||||
@page-changed="selectPage"
|
||||
:compact="true"
|
||||
:current="page"
|
||||
:paginate-by="paginateBy"
|
||||
:total="result.count"
|
||||
></pagination>
|
||||
|
||||
</th>
|
||||
<th v-if="result && result.results.length > 0">
|
||||
Showing results {{ ((page-1) * paginateBy) + 1 }}-{{ ((page-1) * paginateBy) + result.results.length }} on {{ result.count }}</th>
|
||||
<th>
|
||||
<button
|
||||
@click="launchImport"
|
||||
:disabled="checked.length === 0 || isImporting"
|
||||
:class="['ui', 'green', {loading: isImporting}, 'button']">Import {{ checked.length }} tracks
|
||||
</button>
|
||||
<router-link
|
||||
v-if="importBatch"
|
||||
:to="{name: 'library.import.batches.detail', params: {id: importBatch.id }}">
|
||||
Import #{{ importBatch.id }} launched
|
||||
</router-link>
|
||||
</th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th v-if="showLibrary"></th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import _ from 'lodash'
|
||||
|
||||
import Pagination from '@/components/Pagination'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
filters: {type: Object, required: false},
|
||||
showLibrary: {type: Boolean, default: false}
|
||||
},
|
||||
components: {
|
||||
Pagination
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: false,
|
||||
result: null,
|
||||
page: 1,
|
||||
paginateBy: 25,
|
||||
search: '',
|
||||
checked: {},
|
||||
isImporting: false,
|
||||
importBatch: null
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
let params = _.merge({
|
||||
'page': this.page,
|
||||
'page_size': this.paginateBy,
|
||||
'q': this.search
|
||||
}, this.filters)
|
||||
let self = this
|
||||
self.isLoading = true
|
||||
self.checked = []
|
||||
axios.get('/federation/library-tracks/', {params: params}).then((response) => {
|
||||
self.result = response.data
|
||||
self.isLoading = false
|
||||
}, error => {
|
||||
self.isLoading = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
},
|
||||
launchImport () {
|
||||
let self = this
|
||||
self.isImporting = true
|
||||
let payload = {
|
||||
library_tracks: this.checked
|
||||
}
|
||||
axios.post('/submit/federation/', payload).then((response) => {
|
||||
self.importBatch = response.data
|
||||
self.isImporting = false
|
||||
self.fetchData()
|
||||
}, error => {
|
||||
self.isImporting = false
|
||||
self.errors = error.backendErrors
|
||||
})
|
||||
},
|
||||
toggleCheckAll () {
|
||||
if (this.checked.length === this.result.results.length) {
|
||||
// we uncheck
|
||||
this.checked = []
|
||||
} else {
|
||||
this.checked = this.result.results.filter(t => {
|
||||
return t.local_track_file === null
|
||||
}).map(t => { return t.id })
|
||||
}
|
||||
},
|
||||
toggleCheck (id) {
|
||||
if (this.checked.indexOf(id) > -1) {
|
||||
// we uncheck
|
||||
this.checked.splice(this.checked.indexOf(id), 1)
|
||||
} else {
|
||||
this.checked.push(id)
|
||||
}
|
||||
},
|
||||
selectPage: function (page) {
|
||||
this.page = page
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
search (newValue) {
|
||||
if (newValue.length > 0) {
|
||||
this.fetchData()
|
||||
}
|
||||
},
|
||||
page () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -25,6 +25,12 @@ 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 FederationBase from '@/views/federation/Base'
|
||||
import FederationScan from '@/views/federation/Scan'
|
||||
import FederationLibraryDetail from '@/views/federation/LibraryDetail'
|
||||
import FederationLibraryList from '@/views/federation/LibraryList'
|
||||
import FederationTrackList from '@/views/federation/LibraryTrackList'
|
||||
import FederationFollowersList from '@/views/federation/LibraryFollowersList'
|
||||
|
||||
Vue.use(Router)
|
||||
|
||||
|
@ -83,6 +89,50 @@ export default new Router({
|
|||
defaultPaginateBy: route.query.paginateBy
|
||||
})
|
||||
},
|
||||
{
|
||||
path: '/manage/federation',
|
||||
component: FederationBase,
|
||||
children: [
|
||||
{
|
||||
path: 'scan',
|
||||
name: 'federation.libraries.scan',
|
||||
component: FederationScan },
|
||||
{
|
||||
path: 'libraries',
|
||||
name: 'federation.libraries.list',
|
||||
component: FederationLibraryList,
|
||||
props: (route) => ({
|
||||
defaultOrdering: route.query.ordering,
|
||||
defaultQuery: route.query.query,
|
||||
defaultPaginateBy: route.query.paginateBy,
|
||||
defaultPage: route.query.page
|
||||
})
|
||||
},
|
||||
{
|
||||
path: 'tracks',
|
||||
name: 'federation.tracks.list',
|
||||
component: FederationTrackList,
|
||||
props: (route) => ({
|
||||
defaultOrdering: route.query.ordering,
|
||||
defaultQuery: route.query.query,
|
||||
defaultPaginateBy: route.query.paginateBy,
|
||||
defaultPage: route.query.page
|
||||
})
|
||||
},
|
||||
{
|
||||
path: 'followers',
|
||||
name: 'federation.followers.list',
|
||||
component: FederationFollowersList,
|
||||
props: (route) => ({
|
||||
defaultOrdering: route.query.ordering,
|
||||
defaultQuery: route.query.query,
|
||||
defaultPaginateBy: route.query.paginateBy,
|
||||
defaultPage: route.query.page
|
||||
})
|
||||
},
|
||||
{ path: 'libraries/:id', name: 'federation.libraries.detail', component: FederationLibraryDetail, props: true }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/library',
|
||||
component: Library,
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
<template>
|
||||
<div class="main pusher" v-title="'Federation'">
|
||||
<div class="ui secondary pointing menu">
|
||||
<router-link
|
||||
class="ui item"
|
||||
:to="{name: 'federation.libraries.list'}">Libraries</router-link>
|
||||
<router-link
|
||||
class="ui item"
|
||||
:to="{name: 'federation.tracks.list'}">Tracks</router-link>
|
||||
<div class="ui secondary right menu">
|
||||
<router-link
|
||||
class="ui item"
|
||||
:to="{name: 'federation.followers.list'}">
|
||||
Followers
|
||||
<div class="ui teal label" title="Pending requests">{{ requestsCount }}</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<router-view :key="$route.fullPath"></router-view>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
requestsCount: 0
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchRequestsCount()
|
||||
},
|
||||
methods: {
|
||||
fetchRequestsCount () {
|
||||
let self = this
|
||||
axios.get('federation/libraries/followers/', {params: {pending: true}}).then(response => {
|
||||
self.requestsCount = response.data.count
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</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>
|
|
@ -0,0 +1,188 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="isLoading" class="ui vertical segment" v-title="'Library'">
|
||||
<div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div>
|
||||
</div>
|
||||
<template v-if="object">
|
||||
<div :class="['ui', 'head', 'vertical', 'center', 'aligned', 'stripe', 'segment']" v-title="libraryUsername">
|
||||
<div class="segment-content">
|
||||
<h2 class="ui center aligned icon header">
|
||||
<i class="circular inverted cloud olive icon"></i>
|
||||
<div class="content">
|
||||
{{ libraryUsername }}
|
||||
</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>Follow status</td>
|
||||
<td>
|
||||
<template v-if="object.follow.approved === null">
|
||||
<i class="loading icon"></i> Pending approval
|
||||
</template>
|
||||
<template v-else-if="object.follow.approved === true">
|
||||
<i class="check icon"></i> Following
|
||||
</template>
|
||||
<template v-else-if="object.follow.approved === false">
|
||||
<i class="x icon"></i> Not following
|
||||
</template>
|
||||
</td>
|
||||
<td>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Federation</td>
|
||||
<td>
|
||||
<div class="ui toggle checkbox">
|
||||
<input
|
||||
@change="update('federation_enabled')"
|
||||
v-model="object.federation_enabled" type="checkbox">
|
||||
<label></label>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Disabled until properly implemented on the backend
|
||||
<tr>
|
||||
<td>Auto importing</td>
|
||||
<td>
|
||||
<div class="ui toggle checkbox">
|
||||
<input
|
||||
@change="update('autoimport')"
|
||||
v-model="object.autoimport" type="checkbox">
|
||||
<label></label>
|
||||
</div>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>File mirroring</td>
|
||||
<td>
|
||||
<div class="ui toggle checkbox">
|
||||
<input
|
||||
@change="update('download_files')"
|
||||
v-model="object.download_files" type="checkbox">
|
||||
<label></label>
|
||||
</div>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
-->
|
||||
<tr>
|
||||
<td>Library size</td>
|
||||
<td>
|
||||
{{ object.tracks_count }} tracks
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Last fetched</td>
|
||||
<td>
|
||||
<human-date v-if="object.fetched_date" :date="object.fetched_date"></human-date>
|
||||
<template v-else>Never</template>
|
||||
<button
|
||||
@click="scan"
|
||||
v-if="!scanTrigerred"
|
||||
:class="['ui', 'basic', {loading: isScanLoading}, 'button']">
|
||||
<i class="sync icon"></i> Trigger scan
|
||||
</button>
|
||||
<button v-else class="ui success button">
|
||||
<i class="check icon"></i> Scan triggered!
|
||||
</button>
|
||||
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="ui hidden divider"></div>
|
||||
<button @click="fetchData" class="ui basic button">Refresh</button>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<h2>Tracks available in this library</h2>
|
||||
<library-track-table v-if="!isLoading" :filters="{library: id}"></library-track-table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import logger from '@/logging'
|
||||
|
||||
import LibraryTrackTable from '@/components/federation/LibraryTrackTable'
|
||||
|
||||
export default {
|
||||
props: ['id'],
|
||||
components: {
|
||||
LibraryTrackTable
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isLoading: true,
|
||||
isScanLoading: false,
|
||||
object: null,
|
||||
scanTrigerred: false
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
methods: {
|
||||
fetchData () {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
let url = 'federation/libraries/' + this.id + '/'
|
||||
logger.default.debug('Fetching library "' + this.id + '"')
|
||||
axios.get(url).then((response) => {
|
||||
self.object = response.data
|
||||
self.isLoading = false
|
||||
})
|
||||
},
|
||||
scan (until) {
|
||||
var self = this
|
||||
this.isScanLoading = true
|
||||
let data = {}
|
||||
let url = 'federation/libraries/' + this.id + '/scan/'
|
||||
logger.default.debug('Triggering scan for library "' + this.id + '"')
|
||||
axios.post(url, data).then((response) => {
|
||||
self.scanTrigerred = true
|
||||
logger.default.debug('Scan triggered with id', response.data)
|
||||
self.isScanLoading = false
|
||||
})
|
||||
},
|
||||
update (attr) {
|
||||
let newValue = this.object[attr]
|
||||
let params = {}
|
||||
let self = this
|
||||
params[attr] = newValue
|
||||
axios.patch('federation/libraries/' + 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)
|
||||
self.object[attr] = !newValue
|
||||
})
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryUsername () {
|
||||
let actor = this.object.actor
|
||||
return `${actor.preferred_username}@${actor.domain}`
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
id () {
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -0,0 +1,27 @@
|
|||
<template>
|
||||
<div v-title="'Followers'">
|
||||
<div class="ui vertical stripe segment">
|
||||
<h2 class="ui header">Browsing followers</h2>
|
||||
<p>
|
||||
Be careful when accepting follow requests, as it means the follower
|
||||
will have access to your entire library.
|
||||
</p>
|
||||
<div class="ui hidden divider"></div>
|
||||
<library-follow-table></library-follow-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LibraryFollowTable from '@/components/federation/LibraryFollowTable'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LibraryFollowTable
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -0,0 +1,172 @@
|
|||
<template>
|
||||
<div v-title="'Libraries'">
|
||||
<div class="ui vertical stripe segment">
|
||||
<h2 class="ui header">Browsing libraries</h2>
|
||||
<router-link
|
||||
class="ui basic green button"
|
||||
:to="{name: 'federation.libraries.scan'}">
|
||||
<i class="plus icon"></i>
|
||||
Add a new library
|
||||
</router-link>
|
||||
<div class="ui hidden divider"></div>
|
||||
<div :class="['ui', {'loading': isLoading}, 'form']">
|
||||
<div class="fields">
|
||||
<div class="field">
|
||||
<label>Search</label>
|
||||
<input type="text" v-model="query" placeholder="Enter an library domain name..."/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>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>Ordering direction</label>
|
||||
<select class="ui dropdown" v-model="orderingDirection">
|
||||
<option value="">Ascending</option>
|
||||
<option value="-">Descending</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>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="library in result.results"
|
||||
:key="library.id"
|
||||
class="column">
|
||||
<library-card class="fluid" :library-instance="library"></library-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 LibraryCard from '@/components/federation/LibraryCard'
|
||||
import Pagination from '@/components/Pagination'
|
||||
|
||||
const FETCH_URL = 'federation/libraries/'
|
||||
|
||||
export default {
|
||||
mixins: [OrderingMixin, PaginationMixin],
|
||||
props: {
|
||||
defaultQuery: {type: String, required: false, default: ''}
|
||||
},
|
||||
components: {
|
||||
LibraryCard,
|
||||
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 || 50),
|
||||
orderingDirection: defaultOrdering.direction,
|
||||
ordering: defaultOrdering.field,
|
||||
orderingOptions: [
|
||||
['creation_date', 'Creation date'],
|
||||
['tracks_count', 'Available tracks']
|
||||
]
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.fetchData()
|
||||
},
|
||||
mounted () {
|
||||
$('.ui.dropdown').dropdown()
|
||||
},
|
||||
methods: {
|
||||
updateQueryString: _.debounce(function () {
|
||||
this.$router.replace({
|
||||
query: {
|
||||
query: this.query,
|
||||
page: this.page,
|
||||
paginateBy: this.paginateBy,
|
||||
ordering: this.getOrderingAsString()
|
||||
}
|
||||
})
|
||||
}, 500),
|
||||
fetchData: _.debounce(function () {
|
||||
var self = this
|
||||
this.isLoading = true
|
||||
let url = FETCH_URL
|
||||
let params = {
|
||||
page: this.page,
|
||||
q: this.query,
|
||||
ordering: this.getOrderingAsString()
|
||||
}
|
||||
logger.default.debug('Fetching libraries')
|
||||
axios.get(url, {params: params}).then((response) => {
|
||||
self.result = response.data
|
||||
self.isLoading = false
|
||||
})
|
||||
}, 500),
|
||||
selectPage: function (page) {
|
||||
this.page = page
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
page () {
|
||||
this.updateQueryString()
|
||||
this.fetchData()
|
||||
},
|
||||
ordering () {
|
||||
this.updateQueryString()
|
||||
this.fetchData()
|
||||
},
|
||||
orderingDirection () {
|
||||
this.updateQueryString()
|
||||
this.fetchData()
|
||||
},
|
||||
query () {
|
||||
this.updateQueryString()
|
||||
this.fetchData()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -0,0 +1,23 @@
|
|||
<template>
|
||||
<div v-title="'Federated tracks'">
|
||||
<div class="ui vertical stripe segment">
|
||||
<h2 class="ui header">Browsing federated tracks</h2>
|
||||
<div class="ui hidden divider"></div>
|
||||
<library-track-table :show-library="true"></library-track-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LibraryTrackTable from '@/components/federation/LibraryTrackTable'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
LibraryTrackTable
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Add "scoped" attribute to limit CSS to this component only -->
|
||||
<style scoped>
|
||||
</style>
|
|
@ -0,0 +1,39 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="ui vertical stripe segment">
|
||||
<library-form @scanned="updateLibraryData"></library-form>
|
||||
<library-card v-if="libraryData" :library-data="libraryData"></library-card>
|
||||
</div>
|
||||
<div class="ui vertical stripe segment">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
// import axios from 'axios'
|
||||
import TrackTable from '@/components/audio/track/Table'
|
||||
import RadioButton from '@/components/radios/Button'
|
||||
import Pagination from '@/components/Pagination'
|
||||
import LibraryForm from '@/components/federation/LibraryForm'
|
||||
import LibraryCard from '@/components/federation/LibraryCard'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
TrackTable,
|
||||
RadioButton,
|
||||
Pagination,
|
||||
LibraryForm,
|
||||
LibraryCard
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
libraryData: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateLibraryData (data) {
|
||||
this.libraryData = data
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
Loading…
Reference in New Issue