User follow with trackfavorite and listening activity (#1810 and #2075)

This commit is contained in:
petitminion 2024-12-06 14:17:21 +00:00
parent 33ec6783aa
commit 9804de3650
85 changed files with 2933 additions and 920 deletions

View File

@ -38,12 +38,14 @@ def combined_recent(limit, **kwargs):
def get_activity(user, limit=20):
query = fields.privacy_level_query(user, lookup_field="user__privacy_level")
query = fields.privacy_level_query(
user, "actor__user__privacy_level", "actor__user"
)
querysets = [
Listening.objects.filter(query)
.select_related(
"track",
"user",
"actor",
)
.prefetch_related(
"track__artist_credit__artist",
@ -52,7 +54,7 @@ def get_activity(user, limit=20):
TrackFavorite.objects.filter(query)
.select_related(
"track",
"user",
"actor",
)
.prefetch_related(
"track__artist_credit__artist",
@ -60,5 +62,4 @@ def get_activity(user, limit=20):
),
]
records = combined_recent(limit=limit, querysets=querysets)
return [r["object"] for r in records]

View File

@ -24,8 +24,20 @@ def privacy_level_query(user, lookup_field="privacy_level", user_field="user"):
if user.is_anonymous:
return models.Q(**{lookup_field: "everyone"})
return models.Q(**{f"{lookup_field}__in": ["instance", "everyone"]}) | models.Q(
**{lookup_field: "me", user_field: user}
followers_query = models.Q(
**{
f"{lookup_field}": "followers",
f"{user_field}__actor__in": user.actor.get_approved_followings(),
}
)
# Federated TrackFavorite don't have an user associated with the trackfavorite.actor
no_user_query = models.Q(**{f"{user_field}__isnull": True})
return (
models.Q(**{f"{lookup_field}__in": ["instance", "everyone"]})
| models.Q(**{lookup_field: "me", user_field: user})
| followers_query
| no_user_query
)

View File

@ -56,3 +56,59 @@ class OwnerPermission(BasePermission):
if not owner or not request.user.is_authenticated or owner != request.user:
raise owner_exception
return True
class PrivacyLevelPermission(BasePermission):
"""
Ensure the request actor have access to the object considering the privacylevel configuration
of the user.
request.user is None if actor, else its Anonymous if user is not auth.
"""
def has_object_permission(self, request, view, obj):
if (
not hasattr(obj, "user")
and hasattr(obj, "actor")
and not obj.actor.is_local
):
# it's a remote actor object. It should be public.
# But we could trigger an update of the remote actor data
# to avoid leaking data (#2326)
return True
privacy_level = (
obj.actor.user.privacy_level
if hasattr(obj, "actor")
else obj.user.privacy_level
)
obj_actor = obj.actor if hasattr(obj, "actor") else obj.user.actor
if privacy_level == "everyone":
return True
# user is anonymous
if hasattr(request, "actor"):
request_actor = request.actor
elif request.user and request.user.is_authenticated:
request_actor = request.user.actor
else:
return False
if privacy_level == "instance":
# user is local
if request.user and hasattr(request.user, "actor"):
return True
elif hasattr(request, "actor") and request.actor and request.actor.is_local:
return True
else:
return False
elif privacy_level == "me" and obj_actor == request_actor:
return True
elif privacy_level == "followers" and (
request_actor in obj.user.actor.get_approved_followers()
):
return True
else:
return False

View File

@ -104,7 +104,7 @@ def sync_listenings_from_listenbrainz(user, conf):
logger.info("Getting listenings from ListenBrainz")
try:
last_ts = (
history_models.Listening.objects.filter(user=user)
history_models.Listening.objects.filter(actor=user.actor)
.filter(source="Listenbrainz")
.latest("creation_date")
.values_list("creation_date", flat=True)
@ -124,7 +124,7 @@ def sync_favorites_from_listenbrainz(user, conf):
return
try:
last_ts = (
favorites_models.TrackFavorite.objects.filter(user=user)
favorites_models.TrackFavorite.objects.filter(actor=user.actor)
.filter(source="Listenbrainz")
.latest("creation_date")
.creation_date.timestamp()

View File

@ -106,7 +106,7 @@ def add_lb_listenings_to_db(listens, user):
listen.listened_at, timezone.utc
),
track=track,
user=user,
actor=user.actor,
source="Listenbrainz",
)
fw_listens.append(fw_listen)
@ -147,7 +147,7 @@ def add_lb_feedback_to_db(feedbacks, user):
if feedback["score"] == 1:
favorites_models.TrackFavorite.objects.get_or_create(
user=user,
actor=user.actor,
creation_date=datetime.datetime.fromtimestamp(
feedback["created"], timezone.utc
),
@ -157,7 +157,7 @@ def add_lb_feedback_to_db(feedbacks, user):
elif feedback["score"] == 0:
try:
favorites_models.TrackFavorite.objects.get(
user=user, track=track
actor=user.actor, track=track
).delete()
except favorites_models.TrackFavorite.DoesNotExist:
continue

View File

@ -29,7 +29,7 @@ def forward_to_scrobblers(listening, conf, **kwargs):
(username + " " + password).encode("utf-8")
).hexdigest()
cache_key = "lastfm:sessionkey:{}".format(
":".join([str(listening.user.pk), hashed_auth])
":".join([str(listening.actor.pk), hashed_auth])
)
PLUGIN["logger"].info("Forwarding scrobble to %s", LASTFM_SCROBBLER_URL)
session_key = PLUGIN["cache"].get(cache_key)

View File

@ -314,9 +314,12 @@ class FunkwhaleProvider(internet_provider.Provider):
not random enough
"""
def federation_url(self, prefix="", local=False):
def federation_url(self, prefix="", obj_uuid=None, local=False):
if not obj_uuid:
obj_uuid = uuid.uuid4()
def path_generator():
return f"{prefix}/{uuid.uuid4()}"
return f"{prefix}/{obj_uuid}"
domain = settings.FEDERATION_HOSTNAME if local else self.domain_name()
protocol = "https"

View File

@ -8,7 +8,7 @@ record.registry.register_serializer(serializers.TrackFavoriteActivitySerializer)
@record.registry.register_consumer("favorites.TrackFavorite")
def broadcast_track_favorite_to_instance_activity(data, obj):
if obj.user.privacy_level not in ["instance", "everyone"]:
if obj.actor.user.privacy_level not in ["instance", "everyone"]:
return
channels.group_send(

View File

@ -5,5 +5,5 @@ from . import models
@admin.register(models.TrackFavorite)
class TrackFavoriteAdmin(admin.ModelAdmin):
list_display = ["user", "track", "creation_date"]
list_select_related = ["user", "track"]
list_display = ["actor", "track", "creation_date"]
list_select_related = ["actor", "track"]

View File

@ -1,14 +1,28 @@
import factory
from django.conf import settings
from funkwhale_api.factories import NoUpdateOnCreate, registry
from funkwhale_api.federation import models
from funkwhale_api.federation.factories import ActorFactory
from funkwhale_api.music.factories import TrackFactory
from funkwhale_api.users.factories import UserFactory
@registry.register
class TrackFavorite(NoUpdateOnCreate, factory.django.DjangoModelFactory):
track = factory.SubFactory(TrackFactory)
user = factory.SubFactory(UserFactory)
actor = factory.SubFactory(ActorFactory)
fid = factory.Faker("federation_url")
uuid = factory.Faker("uuid4")
class Meta:
model = "favorites.TrackFavorite"
@factory.post_generation
def local(self, create, extracted, **kwargs):
if not extracted and not kwargs:
return
domain = models.Domain.objects.get_or_create(name=settings.FEDERATION_HOSTNAME)[
0
]
self.fid = f"https://{domain}/federation/music/favorite/{self.uuid}"
self.save(update_fields=["fid"])

View File

@ -9,7 +9,7 @@ class TrackFavoriteFilter(moderation_filters.HiddenContentFilterSet):
q = fields.SearchFilter(
search_fields=["track__title", "track__artist__name", "track__album__title"]
)
scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True)
scope = common_filters.ActorScopeFilter(actor_field="actor", distinct=True)
class Meta:
model = models.TrackFavorite

View File

@ -0,0 +1,102 @@
# Generated by Django 4.2.9 on 2024-03-28 23:32
import uuid
from django.db import migrations, models, transaction
import django.db.models.deletion
from django.conf import settings
from funkwhale_api.federation import utils
from django.urls import reverse
def gen_uuid(apps, schema_editor):
MyModel = apps.get_model("favorites", "TrackFavorite")
for row in MyModel.objects.all():
unique_uuid = uuid.uuid4()
while MyModel.objects.filter(uuid=unique_uuid).exists():
unique_uuid = uuid.uuid4()
fid = utils.full_url(
reverse("federation:music:likes-detail", kwargs={"uuid": unique_uuid})
)
row.uuid = unique_uuid
row.fid = fid
row.save(update_fields=["uuid", "fid"])
def get_user_actor(apps, schema_editor):
MyModel = apps.get_model("favorites", "TrackFavorite")
for row in MyModel.objects.all():
actor = row.user.actor
row.actor = actor
row.save(update_fields=["actor"])
class Migration(migrations.Migration):
dependencies = [
("favorites", "0002_trackfavorite_source"),
]
operations = [
migrations.AddField(
model_name="trackfavorite",
name="actor",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="track_favorites",
to="federation.actor",
),
),
migrations.AddField(
model_name="trackfavorite",
name="fid",
field=models.URLField(
db_index=True,
default="https://default.fid",
max_length=500,
unique=True,
),
preserve_default=False,
),
migrations.AddField(
model_name="trackfavorite",
name="url",
field=models.URLField(blank=True, max_length=500, null=True),
),
migrations.AddField(
model_name="trackfavorite",
name="uuid",
field=models.UUIDField(null=True),
),
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="trackfavorite",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, unique=True, null=False),
),
migrations.RunPython(get_user_actor, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="trackfavorite",
name="actor",
field=models.ForeignKey(
blank=False,
null=False,
on_delete=django.db.models.deletion.CASCADE,
related_name="track_favorites",
to="federation.actor",
), ),
migrations.AlterUniqueTogether(
name="trackfavorite",
unique_together={("track", "actor")},
),
migrations.RemoveField(
model_name="trackfavorite",
name="user",
),
]

View File

@ -1,27 +1,91 @@
import uuid
from django.db import models
from django.urls import reverse
from django.utils import timezone
from funkwhale_api.common import fields
from funkwhale_api.common import models as common_models
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music.models import Track
FAVORITE_PRIVACY_LEVEL_CHOICES = [
(k, l) for k, l in fields.PRIVACY_LEVEL_CHOICES if k != "followers"
]
class TrackFavorite(models.Model):
class TrackFavoriteQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet):
def viewable_by(self, actor):
if actor is None:
return self.filter(actor__user__privacy_level="everyone")
if hasattr(actor, "user"):
me_query = models.Q(actor__user__privacy_level="me", actor=actor)
me_query = models.Q(actor__user__privacy_level="me", actor=actor)
instance_query = models.Q(
actor__user__privacy_level="instance", actor__domain=actor.domain
)
instance_actor_query = models.Q(
actor__user__privacy_level="instance", actor__domain=actor.domain
)
return self.filter(
me_query
| instance_query
| instance_actor_query
| models.Q(actor__user__privacy_level="everyone")
)
class TrackFavorite(federation_models.FederationMixin):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now)
user = models.ForeignKey(
"users.User", related_name="track_favorites", on_delete=models.CASCADE
actor = models.ForeignKey(
"federation.Actor",
related_name="track_favorites",
on_delete=models.CASCADE,
null=False,
blank=False,
)
track = models.ForeignKey(
Track, related_name="track_favorites", on_delete=models.CASCADE
)
source = models.CharField(max_length=100, null=True, blank=True)
federation_namespace = "likes"
objects = TrackFavoriteQuerySet.as_manager()
class Meta:
unique_together = ("track", "user")
unique_together = ("track", "actor")
ordering = ("-creation_date",)
@classmethod
def add(cls, track, user):
favorite, created = cls.objects.get_or_create(user=user, track=track)
def add(cls, track, actor):
favorite, created = cls.objects.get_or_create(actor=actor, track=track)
return favorite
def get_activity_url(self):
return f"{self.user.get_activity_url()}/favorites/tracks/{self.pk}"
return f"{self.actor.get_absolute_url()}/favorites/tracks/{self.pk}"
def get_absolute_url(self):
return f"/library/tracks/{self.track.pk}"
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse(
f"federation:music:{self.federation_namespace}-detail",
kwargs={"uuid": self.uuid},
)
)
def save(self, **kwargs):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
return super().save(**kwargs)

View File

@ -1,46 +1,34 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer
from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer
from . import models
# to do : to deprecate ? this is only a local activity, the federated activities serializers are in `/federation`
class TrackFavoriteActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source="track")
actor = UserActivitySerializer(source="user")
actor = federation_serializers.APIActorSerializer(read_only=True)
published = serializers.DateTimeField(source="creation_date")
class Meta:
model = models.TrackFavorite
fields = ["id", "local_id", "object", "type", "actor", "published"]
def get_actor(self, obj):
return UserActivitySerializer(obj.user).data
def get_type(self, obj):
return "Like"
class UserTrackFavoriteSerializer(serializers.ModelSerializer):
track = TrackSerializer(read_only=True)
user = UserBasicSerializer(read_only=True)
actor = serializers.SerializerMethodField()
actor = federation_serializers.APIActorSerializer(read_only=True)
class Meta:
model = models.TrackFavorite
fields = ("id", "user", "track", "creation_date", "actor")
actor = serializers.SerializerMethodField()
@extend_schema_field(federation_serializers.APIActorSerializer)
def get_actor(self, obj):
actor = obj.user.actor
if actor:
return federation_serializers.APIActorSerializer(actor).data
fields = ("id", "actor", "track", "creation_date", "actor")
class UserTrackFavoriteWriteSerializer(serializers.ModelSerializer):

View File

@ -7,6 +7,7 @@ from rest_framework.response import Response
from config import plugins
from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions
from funkwhale_api.federation import routes
from funkwhale_api.music import utils as music_utils
from funkwhale_api.music.models import Track
from funkwhale_api.users.oauth import permissions as oauth_permissions
@ -23,7 +24,7 @@ class TrackFavoriteViewSet(
filterset_class = filters.TrackFavoriteFilter
serializer_class = serializers.UserTrackFavoriteSerializer
queryset = models.TrackFavorite.objects.all().select_related(
"user__actor__attachment_icon"
"actor__attachment_icon"
)
permission_classes = [
oauth_permissions.ScopePermission,
@ -32,6 +33,7 @@ class TrackFavoriteViewSet(
required_scope = "favorites"
anonymous_policy = "setting"
owner_checks = ["write"]
owner_field = "actor.user"
def get_serializer_class(self):
if self.request.method.lower() in ["head", "get", "options"]:
@ -51,6 +53,14 @@ class TrackFavoriteViewSet(
confs=plugins.get_confs(self.request.user),
)
record.send(instance)
routes.outbox.dispatch(
{"type": "Like", "object": {"type": "Track"}},
context={
"track": instance.track,
"actor": instance.actor,
"id": instance.fid,
},
)
return Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers
)
@ -58,7 +68,9 @@ class TrackFavoriteViewSet(
def get_queryset(self):
queryset = super().get_queryset()
queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level")
fields.privacy_level_query(
self.request.user, "actor__user__privacy_level", "actor__user"
)
)
tracks = (
Track.objects.with_playable_uploads(
@ -79,7 +91,7 @@ class TrackFavoriteViewSet(
def perform_create(self, serializer):
track = Track.objects.get(pk=serializer.data["track"])
favorite = models.TrackFavorite.add(track=track, user=self.request.user)
favorite = models.TrackFavorite.add(track=track, actor=self.request.user.actor)
return favorite
@extend_schema(operation_id="unfavorite_track")
@ -87,9 +99,13 @@ class TrackFavoriteViewSet(
def remove(self, request, *args, **kwargs):
try:
pk = int(request.data["track"])
favorite = request.user.track_favorites.get(track__pk=pk)
favorite = request.user.actor.track_favorites.get(track__pk=pk)
except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist):
return Response({}, status=400)
routes.outbox.dispatch(
{"type": "Dislike", "object": {"type": "Track"}},
context={"favorite": favorite},
)
favorite.delete()
plugins.trigger_hook(
plugins.FAVORITE_DELETED,
@ -112,7 +128,9 @@ class TrackFavoriteViewSet(
if not request.user.is_authenticated:
return Response({"results": [], "count": 0}, status=401)
favorites = request.user.track_favorites.values("id", "track").order_by("id")
favorites = request.user.actor.track_favorites.values("id", "track").order_by(
"id"
)
payload = serializers.AllFavoriteSerializer(favorites).data
return Response(payload, status=200)

View File

@ -119,6 +119,9 @@ def should_reject(fid, actor_id=None, payload={}):
@transaction.atomic
def receive(activity, on_behalf_of, inbox_actor=None):
"""
Receive an activity, find his recipients and save it to the database before dispatching it
"""
from funkwhale_api.moderation import mrf
from . import models, serializers, tasks
@ -223,6 +226,9 @@ class InboxRouter(Router):
"""
from . import api_serializers, models
logger.debug(
f"[federation] Inbox dispatch payload : {payload} with context : {context}"
)
handlers = self.get_matching_handlers(payload)
for handler in handlers:
if call_handlers:
@ -293,6 +299,33 @@ def schedule_key_rotation(actor_id, delay):
tasks.rotate_actor_key.apply_async(kwargs={"actor_id": actor_id}, countdown=delay)
def activity_pass_privacy_level(context, routing):
TYPE_FOLLOW_USER_PRIVACY_LEVEL = ["Listen", "Like"]
TYPE_IGNORE_USER_PRIVACY_LEVEL = ["Delete", "Accept", "Follow"]
MUSIC_OBJECT_TYPE = ["Audio", "Track", "Album", "Artist"]
actor = context.get("actor", False)
type = routing.get("type", False)
object_type = routing.get("object", {}).get("type", None)
if type:
if type in TYPE_IGNORE_USER_PRIVACY_LEVEL:
return True
if type in TYPE_FOLLOW_USER_PRIVACY_LEVEL and actor and actor.is_local:
if actor.user.privacy_level in [
"me",
"instance",
]:
return False
return True
# We do not consider music metadata has private
if object_type in MUSIC_OBJECT_TYPE:
return True
return True
class OutboxRouter(Router):
@transaction.atomic
def dispatch(self, routing, context):
@ -305,6 +338,7 @@ class OutboxRouter(Router):
from . import models, tasks
logger.debug(f"[federation] Outbox dispatch context : {context}")
allow_list_enabled = preferences.get("moderation__allow_list_enabled")
allowed_domains = None
if allow_list_enabled:
@ -314,6 +348,10 @@ class OutboxRouter(Router):
)
)
if not activity_pass_privacy_level(context, routing):
logger.info("[federation] Discarding outbox dispatch due to privacy_level")
return
for route, handler in self.routes:
if not match_route(route, routing):
continue

View File

@ -1,4 +1,5 @@
import datetime
from urllib.parse import urlparse
from django.conf import settings
from django.core import validators
@ -97,6 +98,30 @@ class LibraryFollowSerializer(serializers.ModelSerializer):
return federation_serializers.APIActorSerializer(o.actor).data
class FollowSerializer(serializers.ModelSerializer):
target = common_serializers.RelatedField(
"fid", federation_serializers.APIActorSerializer(), required=True
)
actor = serializers.SerializerMethodField()
class Meta:
model = models.Follow
fields = ["creation_date", "actor", "uuid", "target", "approved"]
read_only_fields = ["uuid", "actor", "approved", "creation_date"]
def validate_target(self, v):
request_actor = self.context["actor"]
if v == request_actor:
raise serializers.ValidationError("You cannot follow yourself")
if v.received_follows.filter(actor=request_actor).exists():
raise serializers.ValidationError("You are already following this user")
return v
@extend_schema_field(federation_serializers.APIActorSerializer)
def get_actor(self, o):
return federation_serializers.APIActorSerializer(o.actor).data
def serialize_generic_relation(activity, obj):
data = {"type": obj._meta.label}
if data["type"] == "federation.Actor":
@ -106,9 +131,11 @@ def serialize_generic_relation(activity, obj):
if data["type"] == "music.Library":
data["name"] = obj.name
if data["type"] == "federation.LibraryFollow":
if (
data["type"] == "federation.LibraryFollow"
or data["type"] == "federation.Follow"
):
data["approved"] = obj.approved
return data
@ -178,6 +205,17 @@ FETCH_OBJECT_CONFIG = {
FETCH_OBJECT_FIELD = common_fields.GenericRelation(FETCH_OBJECT_CONFIG)
def convert_url_to_webginfer(url):
parsed_url = urlparse(url)
domain = parsed_url.netloc # e.g., "node1.funkwhale.test"
path_parts = parsed_url.path.strip("/").split("/")
# Ensure the path is in the expected format
if len(path_parts) > 0 and path_parts[0].startswith("@"):
username = path_parts[0][1:] # Remove the '@'
return f"{username}@{domain}"
return None
class FetchSerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer(read_only=True)
object = serializers.CharField(write_only=True)
@ -207,6 +245,10 @@ class FetchSerializer(serializers.ModelSerializer):
]
def validate_object(self, value):
if value.startswith("https://"):
converted = convert_url_to_webginfer(value)
if converted:
value = converted
# if value is a webginfer lookup, we craft a special url
if value.startswith("@"):
value = value.lstrip("@")

View File

@ -5,6 +5,7 @@ from . import api_views
router = routers.OptionalSlashRouter()
router.register(r"fetches", api_views.FetchViewSet, "fetches")
router.register(r"follows/library", api_views.LibraryFollowViewSet, "library-follows")
router.register(r"follows/user", api_views.UserFollowViewSet, "user-follows")
router.register(r"inbox", api_views.InboxItemViewSet, "inbox")
router.register(r"libraries", api_views.LibraryViewSet, "libraries")
router.register(r"domains", api_views.DomainViewSet, "domains")

View File

@ -311,3 +311,106 @@ class ActorViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet):
filter_uploads=lambda o, uploads: uploads.filter(library__actor=o)
)
)
@extend_schema_view(
list=extend_schema(operation_id="get_federation_received_follows"),
create=extend_schema(operation_id="create_federation_user_follow"),
)
class UserFollowViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
lookup_field = "uuid"
queryset = (
models.Follow.objects.all()
.order_by("-creation_date")
.select_related("actor", "target")
.filter(actor__type="Person")
)
serializer_class = api_serializers.FollowSerializer
permission_classes = [oauth_permissions.ScopePermission]
required_scope = "follows"
ordering_fields = ("creation_date",)
@extend_schema(operation_id="get_federation_user_follow")
def retrieve(self, request, *args, **kwargs):
return super().retrieve(request, *args, **kwargs)
@extend_schema(operation_id="delete_federation_user_follow")
def destroy(self, request, uuid=None):
return super().destroy(request, uuid)
def get_queryset(self):
qs = super().get_queryset()
return qs.filter(
Q(target=self.request.user.actor) | Q(actor=self.request.user.actor)
).exclude(approved=False)
def perform_create(self, serializer):
follow = serializer.save(actor=self.request.user.actor)
routes.outbox.dispatch({"type": "Follow"}, context={"follow": follow})
@transaction.atomic
def perform_destroy(self, instance):
routes.outbox.dispatch(
{"type": "Undo", "object": {"type": "Follow"}}, context={"follow": instance}
)
instance.delete()
def get_serializer_context(self):
context = super().get_serializer_context()
context["actor"] = self.request.user.actor
return context
@extend_schema(
operation_id="accept_federation_user_follow",
responses={404: None, 204: None},
)
@decorators.action(methods=["post"], detail=True)
def accept(self, request, *args, **kwargs):
try:
follow = self.queryset.get(
target=self.request.user.actor, uuid=kwargs["uuid"]
)
except models.Follow.DoesNotExist:
return response.Response({}, status=404)
update_follow(follow, approved=True)
return response.Response(status=204)
@extend_schema(operation_id="reject_federation_user_follow")
@decorators.action(methods=["post"], detail=True)
def reject(self, request, *args, **kwargs):
try:
follow = self.queryset.get(
target=self.request.user.actor, uuid=kwargs["uuid"]
)
except models.Follow.DoesNotExist:
return response.Response({}, status=404)
update_follow(follow, approved=False)
return response.Response(status=204)
@extend_schema(operation_id="get_all_federation_library_follows")
@decorators.action(methods=["get"], detail=False)
def all(self, request, *args, **kwargs):
"""
Return all the subscriptions of the current user, with only limited data
to have a performant endpoint and avoid lots of queries just to display
subscription status in the UI
"""
follows = list(
self.get_queryset().values_list("uuid", "target__fid", "approved")
)
payload = {
"results": [
{"uuid": str(u[0]), "actor": str(u[1]), "approved": u[2]}
for u in follows
],
"count": len(follows),
}
return response.Response(payload, status=200)

View File

@ -170,6 +170,7 @@ class FollowFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
@registry.register
class MusicLibraryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
uuid = factory.Faker("uuid4")
actor = factory.SubFactory(ActorFactory)
privacy_level = "me"
name = factory.Faker("sentence")
@ -185,7 +186,13 @@ class MusicLibraryFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
class Params:
local = factory.Trait(
fid=None, actor=factory.SubFactory(ActorFactory, local=True)
fid=factory.Faker(
"federation_url",
local=True,
prefix="federation/music/libraries",
obj_uuid=factory.SelfAttribute("..uuid"),
),
actor=factory.SubFactory(ActorFactory, local=True),
)

View File

@ -218,7 +218,6 @@ class Actor(models.Model):
on_delete=models.SET_NULL,
related_name="iconed_actor",
)
objects = ActorQuerySet.as_manager()
class Meta:
@ -251,9 +250,15 @@ class Actor(models.Model):
follows = self.received_follows.filter(approved=True)
return self.followers.filter(pk__in=follows.values_list("actor", flat=True))
def get_approved_followings(self):
follows = self.emitted_follows.filter(approved=True)
return Actor.objects.filter(pk__in=follows.values_list("target", flat=True))
def should_autoapprove_follow(self, actor):
if self.get_channel():
return True
if self.user.privacy_level == "public":
return True
return False
def get_user(self):

View File

@ -3,6 +3,8 @@ import uuid
from django.db.models import Q
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.history import models as history_models
from funkwhale_api.music import models as music_models
from . import activity, actors, models, serializers
@ -611,3 +613,130 @@ def outbox_delete_album(context):
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
),
}
@outbox.register({"type": "Like", "object.type": "Track"})
def outbox_create_track_favorite(context):
track = context["track"]
actor = context["actor"]
serializer = serializers.ActivitySerializer(
{
"type": "Like",
"id": context["id"],
"object": {"type": "Track", "id": track.fid},
}
)
yield {
"type": "Like",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@outbox.register({"type": "Dislike", "object.type": "Track"})
def outbox_delete_favorite(context):
favorite = context["favorite"]
actor = favorite.actor
serializer = serializers.ActivitySerializer(
{"type": "Dislike", "object": {"type": "Track", "id": favorite.track.fid}}
)
yield {
"type": "Dislike",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@inbox.register({"type": "Like", "object.type": "Track"})
def inbox_create_favorite(payload, context):
serializer = serializers.TrackFavoriteSerializer(data=payload)
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return {"object": instance}
@inbox.register({"type": "Dislike", "object.type": "Track"})
def inbox_delete_favorite(payload, context):
actor = context["actor"]
track_id = payload["object"].get("id")
query = Q(track__fid=track_id) & Q(actor=actor)
try:
favorite = favorites_models.TrackFavorite.objects.get(query)
except favorites_models.TrackFavorite.DoesNotExist:
logger.debug(
"Discarding deletion of unkwnown favorite with track : %s", track_id
)
return
favorite.delete()
# to do : test listening routes and broadcast
@outbox.register({"type": "Listen", "object.type": "Track"})
def outbox_create_listening(context):
track = context["track"]
actor = context["actor"]
serializer = serializers.ActivitySerializer(
{
"type": "Listen",
"id": context["id"],
"object": {"type": "Track", "id": track.fid},
}
)
yield {
"type": "Listen",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@outbox.register({"type": "Delete", "object.type": "Listen"})
def outbox_delete_listening(context):
listening = context["listening"]
actor = listening.actor
serializer = serializers.ActivitySerializer(
{"type": "Delete", "object": {"type": "Listen", "id": listening.fid}}
)
yield {
"type": "Delete",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
}
@inbox.register({"type": "Listen", "object.type": "Track"})
def inbox_create_listening(payload, context):
serializer = serializers.ListeningSerializer(data=payload)
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return {"object": instance}
@inbox.register({"type": "Delete", "object.type": "Listen"})
def inbox_delete_listening(payload, context):
actor = context["actor"]
listening_id = payload["object"].get("id")
query = Q(fid=listening_id) & Q(actor=actor)
try:
favorite = history_models.Listening.objects.get(query)
except history_models.Listening.DoesNotExist:
logger.debug("Discarding deletion of unkwnown listening %s", listening_id)
return
favorite.delete()

View File

@ -13,6 +13,9 @@ from rest_framework import serializers
from funkwhale_api.common import models as common_models
from funkwhale_api.common import utils as common_utils
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import activity, actors, contexts, jsonld, models, utils
from funkwhale_api.history import models as history_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.moderation import serializers as moderation_serializers
from funkwhale_api.moderation import signals as moderation_signals
@ -21,8 +24,6 @@ from funkwhale_api.music import models as music_models
from funkwhale_api.music import tasks as music_tasks
from funkwhale_api.tags import models as tags_models
from . import activity, actors, contexts, jsonld, models, utils
logger = logging.getLogger(__name__)
@ -341,9 +342,11 @@ class ActorSerializer(jsonld.JsonLdSerializer):
ret["url"] = [
{
"type": "Link",
"href": instance.channel.get_absolute_url()
if instance.channel.artist.is_local
else instance.get_absolute_url(),
"href": (
instance.channel.get_absolute_url()
if instance.channel.artist.is_local
else instance.get_absolute_url()
),
"mediaType": "text/html",
},
{
@ -437,9 +440,11 @@ class ActorSerializer(jsonld.JsonLdSerializer):
common_utils.attach_file(
actor,
"attachment_icon",
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None,
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None
),
)
rss_url = get_by_media_type(
@ -492,9 +497,11 @@ def create_or_update_channel(actor, rss_url, attributed_to_fid, **validated_data
common_utils.attach_file(
artist,
"attachment_cover",
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None,
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None
),
)
tags = [t["name"] for t in validated_data.get("tags", []) or []]
tags_models.set_tags(artist, *tags)
@ -645,7 +652,6 @@ class FollowSerializer(serializers.Serializer):
def save(self, **kwargs):
target = self.validated_data["object"]
if target._meta.label == "music.Library":
follow_class = models.LibraryFollow
else:
@ -813,7 +819,9 @@ class UndoFollowSerializer(serializers.Serializer):
actor=validated_data["actor"], target=target
).get()
except follow_class.DoesNotExist:
raise serializers.ValidationError("No follow to remove")
raise serializers.ValidationError(
f"No follow to remove follow_class = {follow_class}"
)
return validated_data
def to_representation(self, instance):
@ -880,7 +888,6 @@ class ActivitySerializer(serializers.Serializer):
object_serializer = OBJECT_SERIALIZERS[type]
except KeyError:
raise serializers.ValidationError(f"Unsupported type {type}")
serializer = object_serializer(data=value)
serializer.is_valid(raise_exception=True)
return serializer.data
@ -1310,9 +1317,9 @@ class ArtistSerializer(MusicEntitySerializer):
"name": instance.name,
"published": instance.creation_date.isoformat(),
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
"attributedTo": instance.attributed_to.fid
if instance.attributed_to
else None,
"attributedTo": (
instance.attributed_to.fid if instance.attributed_to else None
),
"tag": self.get_tags_repr(instance),
}
include_content(d, instance.description)
@ -1404,12 +1411,12 @@ class AlbumSerializer(MusicEntitySerializer):
"name": instance.title,
"published": instance.creation_date.isoformat(),
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
"released": instance.release_date.isoformat()
if instance.release_date
else None,
"attributedTo": instance.attributed_to.fid
if instance.attributed_to
else None,
"released": (
instance.release_date.isoformat() if instance.release_date else None
),
"attributedTo": (
instance.attributed_to.fid if instance.attributed_to else None
),
"tag": self.get_tags_repr(instance),
}
@ -1501,9 +1508,11 @@ class TrackSerializer(MusicEntitySerializer):
"musicbrainzId": str(instance.mbid) if instance.mbid else None,
"position": instance.position,
"disc": instance.disc_number,
"license": instance.local_license["identifiers"][0]
if instance.local_license
else None,
"license": (
instance.local_license["identifiers"][0]
if instance.local_license
else None
),
"copyright": instance.copyright if instance.copyright else None,
"artist_credit": ArtistCreditSerializer(
instance.artist_credit.all(),
@ -1513,9 +1522,9 @@ class TrackSerializer(MusicEntitySerializer):
"album": AlbumSerializer(
instance.album, context={"include_ap_context": False}
).data,
"attributedTo": instance.attributed_to.fid
if instance.attributed_to
else None,
"attributedTo": (
instance.attributed_to.fid if instance.attributed_to else None
),
"tag": self.get_tags_repr(instance),
}
include_content(data, instance.description)
@ -1713,9 +1722,11 @@ class UploadSerializer(jsonld.JsonLdSerializer):
},
],
"track": TrackSerializer(track, context={"include_ap_context": False}).data,
"to": contexts.AS.Public
if instance.library.privacy_level == "everyone"
else "",
"to": (
contexts.AS.Public
if instance.library.privacy_level == "everyone"
else ""
),
"attributedTo": instance.library.actor.fid,
}
if instance.modification_date:
@ -1935,9 +1946,9 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
"name": upload.track.title,
"attributedTo": upload.library.channel.actor.fid,
"published": upload.creation_date.isoformat(),
"to": contexts.AS.Public
if upload.library.privacy_level == "everyone"
else "",
"to": (
contexts.AS.Public if upload.library.privacy_level == "everyone" else ""
),
"url": [
{
"type": "Link",
@ -2026,9 +2037,11 @@ class ChannelUploadSerializer(jsonld.JsonLdSerializer):
common_utils.attach_file(
track,
"attachment_cover",
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None,
(
{"url": new_value["url"], "mimetype": new_value.get("mediaType")}
if new_value
else None
),
)
common_utils.attach_content(
@ -2152,3 +2165,79 @@ class IndexSerializer(jsonld.JsonLdSerializer):
if self.context.get("include_ap_context", True):
d["@context"] = jsonld.get_default_context()
return d
class TrackFavoriteSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Like])
id = serializers.URLField(max_length=500)
object = serializers.URLField(max_length=500)
actor = serializers.URLField(max_length=500)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"actor": jsonld.first_id(contexts.AS.actor),
}
def to_representation(self, favorite):
payload = {
"type": "Like",
"id": favorite.fid,
"actor": favorite.actor.fid,
"object": favorite.track.fid,
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = actors.get_actor(validated_data["actor"])
track = utils.retrieve_ap_object(
validated_data["object"],
actor=actors.get_service_actor(),
serializer_class=TrackSerializer,
)
return favorites_models.TrackFavorite.objects.create(
fid=validated_data.get("id"),
uuid=uuid.uuid4(),
actor=actor,
track=track,
)
class ListeningSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Listen])
id = serializers.URLField(max_length=500)
object = serializers.URLField(max_length=500)
actor = serializers.URLField(max_length=500)
class Meta:
jsonld_mapping = {
"object": jsonld.first_id(contexts.AS.object),
"actor": jsonld.first_id(contexts.AS.actor),
}
def to_representation(self, listening):
payload = {
"type": "Listen",
"id": listening.fid,
"actor": listening.actor.fid,
"object": listening.track.fid,
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
return payload
def create(self, validated_data):
actor = actors.get_actor(validated_data["actor"])
track = utils.retrieve_ap_object(
validated_data["object"],
actor=actors.get_service_actor(),
serializer_class=TrackSerializer,
)
return history_models.Listening.objects.create(
fid=validated_data.get("id"),
uuid=validated_data["id"].rstrip("/").split("/")[-1],
actor=actor,
track=track,
)

View File

@ -20,6 +20,8 @@ music_router.register(r"artists", views.MusicArtistViewSet, "artists")
music_router.register(r"artistcredit", views.MusicArtistCreditViewSet, "artistcredit")
music_router.register(r"albums", views.MusicAlbumViewSet, "albums")
music_router.register(r"tracks", views.MusicTrackViewSet, "tracks")
music_router.register(r"likes", views.TrackFavoriteViewSet, "likes")
music_router.register(r"listenings", views.ListeningsViewSet, "listenings")
index_router.register(r"index", views.IndexViewSet, "index")

View File

@ -7,9 +7,12 @@ from django.urls import reverse
from rest_framework import exceptions, mixins, permissions, response, viewsets
from rest_framework.decorators import action
from funkwhale_api.common import permissions as common_permissions
from funkwhale_api.common import preferences
from funkwhale_api.common import utils as common_utils
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.history import models as history_models
from funkwhale_api.moderation import models as moderation_models
from funkwhale_api.music import models as music_models
from funkwhale_api.music import utils as music_utils
@ -172,17 +175,115 @@ class ActorViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericV
collection_serializer=serializers.ChannelOutboxSerializer(channel),
)
@action(methods=["get"], detail=True)
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def followers(self, request, *args, **kwargs):
self.get_object()
# XXX to implement
return response.Response({})
actor = self.get_object()
followers = list(actor.get_approved_followers())
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-followers",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": followers,
"item_serializer": serializers.ActorSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(methods=["get"], detail=True)
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def following(self, request, *args, **kwargs):
self.get_object()
# XXX to implement
return response.Response({})
actor = self.get_object()
followings = list(
actor.emitted_follows.filter(approved=True).values_list("target", flat=True)
)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-following",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": followings,
"item_serializer": serializers.ActorSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def listens(self, request, *args, **kwargs):
actor = self.get_object()
listenings = history_models.Listening.objects.filter(actor=actor)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-listens",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": listenings,
"item_serializer": serializers.ListeningSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
@action(
methods=["get"],
detail=True,
permission_classes=[common_permissions.PrivacyLevelPermission],
)
def likes(self, request, *args, **kwargs):
actor = self.get_object()
likes = favorites_models.TrackFavorite.objects.filter(actor=actor)
conf = {
"id": federation_utils.full_url(
reverse(
"federation:actors-likes",
kwargs={"preferred_username": actor.preferred_username},
)
),
"items": likes,
"item_serializer": serializers.TrackFavoriteSerializer,
"page_size": 100,
"actor": None,
}
response = get_collection_response(
conf=conf,
querystring=request.GET,
collection_serializer=serializers.IndexSerializer(conf),
)
return response
class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
@ -562,3 +663,43 @@ class IndexViewSet(FederationMixin, viewsets.GenericViewSet):
)
return response.Response({}, status=200)
class TrackFavoriteViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [common_permissions.PrivacyLevelPermission]
renderer_classes = renderers.get_ap_renderers()
queryset = favorites_models.TrackFavorite.objects.local().select_related(
"track", "actor"
)
serializer_class = serializers.TrackFavoriteSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)
class ListeningsViewSet(
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
):
authentication_classes = [authentication.SignatureAuthentication]
permission_classes = [common_permissions.PrivacyLevelPermission]
renderer_classes = renderers.get_ap_renderers()
queryset = history_models.Listening.objects.local().select_related("track", "actor")
serializer_class = serializers.ListeningSerializer
lookup_field = "uuid"
def retrieve(self, request, *args, **kwargs):
instance = self.get_object()
if utils.should_redirect_ap_to_html(request.headers.get("accept")):
return redirect_to_html(instance.get_absolute_url())
serializer = self.get_serializer(instance)
return response.Response(serializer.data)

View File

@ -8,7 +8,7 @@ record.registry.register_serializer(serializers.ListeningActivitySerializer)
@record.registry.register_consumer("history.Listening")
def broadcast_listening_to_instance_activity(data, obj):
if obj.user.privacy_level not in ["instance", "everyone"]:
if obj.actor.user.privacy_level not in ["instance", "everyone"]:
return
channels.group_send(

View File

@ -5,6 +5,6 @@ from . import models
@admin.register(models.Listening)
class ListeningAdmin(admin.ModelAdmin):
list_display = ["track", "creation_date", "user", "session_key"]
search_fields = ["track__name", "user__username"]
list_select_related = ["user", "track"]
list_display = ["track", "creation_date", "actor", "session_key"]
search_fields = ["track__name", "actor__user__username"]
list_select_related = ["actor", "track"]

View File

@ -1,14 +1,28 @@
import factory
from django.conf import settings
from funkwhale_api.factories import NoUpdateOnCreate, registry
from funkwhale_api.federation import models
from funkwhale_api.federation.factories import ActorFactory
from funkwhale_api.music import factories
from funkwhale_api.users.factories import UserFactory
@registry.register
class ListeningFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
user = factory.SubFactory(UserFactory)
actor = factory.SubFactory(ActorFactory)
track = factory.SubFactory(factories.TrackFactory)
fid = factory.Faker("federation_url")
uuid = factory.Faker("uuid4")
class Meta:
model = "history.Listening"
@factory.post_generation
def local(self, create, extracted, **kwargs):
if not extracted and not kwargs:
return
domain = models.Domain.objects.get_or_create(name=settings.FEDERATION_HOSTNAME)[
0
]
self.fid = f"https://{domain}/federation/music/favorite/{self.uuid}"
self.save(update_fields=["fid"])

View File

@ -7,9 +7,9 @@ from . import models
class ListeningFilter(moderation_filters.HiddenContentFilterSet):
username = django_filters.CharFilter("user__username")
domain = django_filters.CharFilter("user__actor__domain_id")
scope = common_filters.ActorScopeFilter(actor_field="user__actor", distinct=True)
username = django_filters.CharFilter("actor__user__username")
domain = django_filters.CharFilter("actor__domain_id")
scope = common_filters.ActorScopeFilter(actor_field="actor", distinct=True)
class Meta:
model = models.Listening

View File

@ -0,0 +1,107 @@
import uuid
from django.db import migrations, models
from django.urls import reverse
from funkwhale_api.federation import utils
import django.db.models.deletion
def get_user_actor(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
for row in MyModel.objects.all():
actor = row.user.actor
row.actor = actor
row.save(update_fields=["actor"])
def gen_uuid(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
for row in MyModel.objects.all():
unique_uuid = uuid.uuid4()
while MyModel.objects.filter(uuid=unique_uuid).exists():
unique_uuid = uuid.uuid4()
fid = utils.full_url(
reverse("federation:music:listenings-detail", kwargs={"uuid": unique_uuid})
)
row.uuid = unique_uuid
row.fid = fid
row.save(update_fields=["uuid", "fid"])
def get_user_actor(apps, schema_editor):
MyModel = apps.get_model("history", "Listening")
for row in MyModel.objects.all():
actor = row.user.actor
row.actor = actor
row.save(update_fields=["actor"])
class Migration(migrations.Migration):
dependencies = [
("history", "0003_listening_source"),
("federation", "0028_auto_20221027_1141"),
]
operations = [
migrations.AddField(
model_name="listening",
name="actor",
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="listenings",
to="federation.actor",
),
),
migrations.AddField(
model_name="listening",
name="fid",
field=models.URLField(
max_length=500,
null=True,
),
),
migrations.AddField(
model_name="listening",
name="url",
field=models.URLField(blank=True, max_length=500, null=True),
),
migrations.AddField(
model_name="listening",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, null=True),
),
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="listening",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, unique=True),
),
migrations.AlterField(
model_name="listening",
name="fid",
field=models.URLField(
unique=True,
db_index=True,
max_length=500,
),
),
migrations.RunPython(get_user_actor, reverse_code=migrations.RunPython.noop),
migrations.RemoveField(
model_name="listening",
name="user",
),
migrations.AlterField(
model_name="listening",
name="actor",
field=models.ForeignKey(
blank=False,
null=False,
on_delete=django.db.models.deletion.CASCADE,
related_name="listenings",
to="federation.actor",
),
),
]

View File

@ -1,26 +1,59 @@
import uuid
from django.db import models
from django.urls import reverse
from django.utils import timezone
from funkwhale_api.common import models as common_models
from funkwhale_api.federation import models as federation_models
from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music.models import Track
class Listening(models.Model):
class ListeningQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet):
pass
class Listening(federation_models.FederationMixin):
uuid = models.UUIDField(default=uuid.uuid4, unique=True)
creation_date = models.DateTimeField(default=timezone.now, null=True, blank=True)
track = models.ForeignKey(
Track, related_name="listenings", on_delete=models.CASCADE
)
user = models.ForeignKey(
"users.User",
actor = models.ForeignKey(
"federation.Actor",
related_name="listenings",
null=True,
blank=True,
on_delete=models.CASCADE,
null=False,
blank=False,
)
session_key = models.CharField(max_length=100, null=True, blank=True)
source = models.CharField(max_length=100, null=True, blank=True)
federation_namespace = "listenings"
objects = ListeningQuerySet.as_manager()
class Meta:
ordering = ("-creation_date",)
def get_activity_url(self):
return f"{self.user.get_activity_url()}/listenings/tracks/{self.pk}"
return f"{self.actor.get_absolute_url()}/listenings/tracks/{self.pk}"
def get_absolute_url(self):
return f"/library/tracks/{self.track.pk}"
def get_federation_id(self):
if self.fid:
return self.fid
return federation_utils.full_url(
reverse(
f"federation:music:{self.federation_namespace}-detail",
kwargs={"uuid": self.uuid},
)
)
def save(self, **kwargs):
if not self.pk and not self.fid:
self.fid = self.get_federation_id()
return super().save(**kwargs)

View File

@ -1,10 +1,8 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from funkwhale_api.activity import serializers as activity_serializers
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.music.serializers import TrackActivitySerializer, TrackSerializer
from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSerializer
from . import models
@ -12,47 +10,39 @@ from . import models
class ListeningActivitySerializer(activity_serializers.ModelSerializer):
type = serializers.SerializerMethodField()
object = TrackActivitySerializer(source="track")
actor = UserActivitySerializer(source="user")
actor = federation_serializers.APIActorSerializer()
published = serializers.DateTimeField(source="creation_date")
class Meta:
model = models.Listening
fields = ["id", "local_id", "object", "type", "actor", "published"]
def get_actor(self, obj):
return UserActivitySerializer(obj.user).data
def get_type(self, obj):
return "Listen"
class ListeningSerializer(serializers.ModelSerializer):
track = TrackSerializer(read_only=True)
user = UserBasicSerializer(read_only=True)
actor = serializers.SerializerMethodField()
actor = federation_serializers.APIActorSerializer(read_only=True)
class Meta:
model = models.Listening
fields = ("id", "user", "track", "creation_date", "actor")
fields = ("id", "actor", "track", "creation_date", "actor")
def create(self, validated_data):
validated_data["user"] = self.context["user"]
validated_data["actor"] = self.context["user"].actor
return super().create(validated_data)
@extend_schema_field(federation_serializers.APIActorSerializer)
def get_actor(self, obj):
actor = obj.user.actor
if actor:
return federation_serializers.APIActorSerializer(actor).data
class ListeningWriteSerializer(serializers.ModelSerializer):
actor = federation_serializers.APIActorSerializer(read_only=True, required=False)
class Meta:
model = models.Listening
fields = ("id", "user", "track", "creation_date")
fields = ("id", "actor", "track", "creation_date")
def create(self, validated_data):
validated_data["user"] = self.context["user"]
validated_data["actor"] = self.context["user"].actor
return super().create(validated_data)

View File

@ -4,6 +4,7 @@ from rest_framework import mixins, viewsets
from config import plugins
from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions
from funkwhale_api.federation import routes
from funkwhale_api.music import utils as music_utils
from funkwhale_api.music.models import Track
from funkwhale_api.users.oauth import permissions as oauth_permissions
@ -18,9 +19,7 @@ class ListeningViewSet(
viewsets.GenericViewSet,
):
serializer_class = serializers.ListeningSerializer
queryset = models.Listening.objects.all().select_related(
"user__actor__attachment_icon"
)
queryset = models.Listening.objects.all().select_related("actor__attachment_icon")
permission_classes = [
oauth_permissions.ScopePermission,
@ -29,6 +28,7 @@ class ListeningViewSet(
required_scope = "listenings"
anonymous_policy = "setting"
owner_checks = ["write"]
owner_field = "actor.user"
filterset_class = filters.ListeningFilter
def get_serializer_class(self):
@ -38,18 +38,29 @@ class ListeningViewSet(
def perform_create(self, serializer):
r = super().perform_create(serializer)
instance = serializer.instance
plugins.trigger_hook(
plugins.LISTENING_CREATED,
listening=serializer.instance,
listening=instance,
confs=plugins.get_confs(self.request.user),
)
routes.outbox.dispatch(
{"type": "Listen", "object": {"type": "Track"}},
context={
"track": instance.track,
"actor": instance.actor,
"id": instance.fid,
},
)
record.send(serializer.instance)
return r
def get_queryset(self):
queryset = super().get_queryset()
queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "user__privacy_level")
fields.privacy_level_query(
self.request.user, "actor__user__privacy_level", "actor__user"
)
)
tracks = (
Track.objects.with_playable_uploads(

View File

@ -67,8 +67,8 @@ class ManageUserSerializer(serializers.ModelSerializer):
"date_joined",
"last_activity",
"permissions",
"privacy_level",
"upload_quota",
"privacy_level",
"full_username",
)
read_only_fields = [

View File

@ -88,6 +88,11 @@ class ArtistCreditFactory(factory.django.DjangoModelFactory):
class Meta:
model = "music.ArtistCredit"
class Params:
local = factory.Trait(
artist=factory.SubFactory(ArtistFactory, local=True),
)
@registry.register
class AlbumFactory(
@ -128,6 +133,7 @@ class AlbumFactory(
class TrackFactory(
tags_factories.TaggableFactory, NoUpdateOnCreate, factory.django.DjangoModelFactory
):
uuid = factory.Faker("uuid4")
fid = factory.Faker("federation_url")
title = factory.Faker("sentence", nb_words=3)
mbid = factory.Faker("uuid4")
@ -144,7 +150,13 @@ class TrackFactory(
)
local = factory.Trait(
fid=factory.Faker("federation_url", local=True), album__local=True
fid=factory.Faker(
"federation_url",
local=True,
prefix="/federation/music/tracks",
obj_uuid=factory.SelfAttribute("..uuid"),
),
album__local=True,
)
with_cover = factory.Trait(
attachment_cover=factory.SubFactory(common_factories.AttachmentFactory)
@ -213,6 +225,11 @@ class UploadFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
playable = factory.Trait(
import_status="finished", library__privacy_level="everyone"
)
local = factory.Trait(
fid=factory.Faker("federation_url", local=True),
track__local=True,
library__local=True,
)
@factory.post_generation
def channel(self, created, extracted, **kwargs):

View File

@ -1,25 +1,63 @@
"""
Populates the database with fake data
"""
import logging
import random
from funkwhale_api.music import factories
from funkwhale_api.cli import users
from funkwhale_api.federation import factories as federation_factories
from funkwhale_api.history import factories as history_factories
from funkwhale_api.music import factories as music_factories
from funkwhale_api.users import serializers
logger = logging.getLogger(__name__)
def create_data(count=25):
acs = factories.ArtistCreditFactory.create_batch(size=count)
for ac in acs:
print("Creating data for", ac.artist)
albums = factories.AlbumFactory.create_batch(
artist_credit=ac, size=random.randint(1, 5)
)
for album in albums:
factories.UploadFactory.create_batch(
track__album=album,
size=random.randint(3, 18),
playable=True,
in_place=True,
def create_data(count=2, super_user_name=None):
super_user = None
if super_user_name:
try:
super_user = users.handler_create_user(
username=str(super_user_name),
password="funkwhale",
email=f"{super_user_name}eat@the.rich",
is_superuser=True,
is_staff=True,
upload_quota=None,
)
except serializers.ValidationError as e:
for field, errors in e.detail.items():
if (
"A user with that username already exists"
or "A user is already registered with this e-mail address"
in errors[0]
):
print(
f"Superuser {super_user_name} already in db. Skipping fake-data creation"
)
continue
else:
raise e
print(f"Superuser with username {super_user_name} and password `funkwhale`")
library = federation_factories.MusicLibraryFactory(
actor=(
super_user.actor if super_user else federation_factories.ActorFactory()
),
local=True,
)
uploads = music_factories.UploadFactory.create_batch(
size=random.randint(3, 18),
playable=True,
library=library,
local=True,
)
for upload in uploads:
history_factories.ListeningFactory(
track=upload.track, actor=upload.library.actor
)
print("Created fid", upload.track.fid)
if __name__ == "__main__":

View File

@ -158,7 +158,9 @@ class FavoritesRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
track_ids = kwargs["user"].track_favorites.all().values_list("track", flat=True)
track_ids = (
kwargs["user"].actor.track_favorites.all().values_list("track", flat=True)
)
return qs.filter(
pk__in=track_ids, artist_credit__artist__content_category="music"
)
@ -301,15 +303,17 @@ class SimilarRadio(RelatedObjectRadio):
SELECT next, count(next) AS c
FROM (
SELECT
track_id,
creation_date,
LEAD(track_id) OVER (
PARTITION by user_id order by creation_date asc
history_listening.track_id,
history_listening.creation_date,
LEAD(history_listening.track_id) OVER (
PARTITION BY history_listening.actor_id ORDER BY history_listening.creation_date ASC
) AS next
FROM history_listening
INNER JOIN users_user ON (users_user.id = user_id)
WHERE users_user.privacy_level = 'instance' OR users_user.privacy_level = 'everyone' OR user_id = %s
ORDER BY creation_date ASC
INNER JOIN federation_actor ON federation_actor.id = history_listening.actor_id
INNER JOIN users_user ON users_user.actor_id = federation_actor.id
WHERE users_user.privacy_level = 'instance' OR users_user.privacy_level = 'everyone' OR \
history_listening.actor_id = %s
ORDER BY history_listening.creation_date ASC
) t WHERE track_id = %s AND next != %s GROUP BY next ORDER BY c DESC;
"""
cursor.execute(query, [self.session.user_id, seed, seed])
@ -346,7 +350,9 @@ class LessListenedRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
listened = self.session.user.listenings.all().values_list("track", flat=True)
listened = self.session.user.actor.listenings.all().values_list(
"track", flat=True
)
return (
qs.filter(artist_credit__artist__content_category="music")
.exclude(pk__in=listened)
@ -362,7 +368,9 @@ class LessListenedLibraryRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
listened = self.session.user.listenings.all().values_list("track", flat=True)
listened = self.session.user.actor.listenings.all().values_list(
"track", flat=True
)
tracks_ids = self.session.user.actor.attributed_tracks.all().values_list(
"id", flat=True
)

View File

@ -192,7 +192,9 @@ class FavoritesRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
track_ids = kwargs["user"].track_favorites.all().values_list("track", flat=True)
track_ids = (
kwargs["user"].actor.track_favorites.all().values_list("track", flat=True)
)
return qs.filter(
pk__in=track_ids, artist_credit__artist__content_category="music"
)
@ -335,15 +337,17 @@ class SimilarRadio(RelatedObjectRadio):
SELECT next, count(next) AS c
FROM (
SELECT
track_id,
creation_date,
LEAD(track_id) OVER (
PARTITION by user_id order by creation_date asc
history_listening.track_id,
history_listening.creation_date,
LEAD(history_listening.track_id) OVER (
PARTITION BY history_listening.actor_id ORDER BY history_listening.creation_date ASC
) AS next
FROM history_listening
INNER JOIN users_user ON (users_user.id = user_id)
WHERE users_user.privacy_level = 'instance' OR users_user.privacy_level = 'everyone' OR user_id = %s
ORDER BY creation_date ASC
INNER JOIN federation_actor ON federation_actor.id = history_listening.actor_id
INNER JOIN users_user ON users_user.actor_id = federation_actor.id
WHERE users_user.privacy_level = 'instance' OR users_user.privacy_level = 'everyone' \
OR history_listening.actor_id = %s
ORDER BY history_listening.creation_date ASC
) t WHERE track_id = %s AND next != %s GROUP BY next ORDER BY c DESC;
"""
cursor.execute(query, [self.session.user_id, seed, seed])
@ -380,7 +384,9 @@ class LessListenedRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
listened = self.session.user.listenings.all().values_list("track", flat=True)
listened = self.session.user.actor.listenings.all().values_list(
"track", flat=True
)
return (
qs.filter(artist_credit__artist__content_category="music")
.exclude(pk__in=listened)
@ -396,7 +402,9 @@ class LessListenedLibraryRadio(SessionRadio):
def get_queryset(self, **kwargs):
qs = super().get_queryset(**kwargs)
listened = self.session.user.listenings.all().values_list("track", flat=True)
listened = self.session.user.actor.listenings.all().values_list(
"track", flat=True
)
tracks_ids = self.session.user.actor.attributed_tracks.all().values_list(
"id", flat=True
)

View File

@ -318,7 +318,7 @@ class ScrobbleSerializer(serializers.Serializer):
def create(self, data):
return history_models.Listening.objects.create(
user=self.context["user"], track=data["id"]
actor=self.context["user"].actor, track=data["id"]
)

View File

@ -337,14 +337,14 @@ class SubsonicViewSet(viewsets.GenericViewSet):
@find_object(music_models.Track.objects.all())
def star(self, request, *args, **kwargs):
track = kwargs.pop("obj")
TrackFavorite.add(user=request.user, track=track)
TrackFavorite.add(actor=request.user.actor, track=track)
return response.Response({"status": "ok"})
@action(detail=False, methods=["get", "post"], url_name="unstar", url_path="unstar")
@find_object(music_models.Track.objects.all())
def unstar(self, request, *args, **kwargs):
track = kwargs.pop("obj")
request.user.track_favorites.filter(track=track).delete()
request.user.actor.track_favorites.filter(track=track).delete()
return response.Response({"status": "ok"})
@action(
@ -354,7 +354,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
url_path="getStarred2",
)
def get_starred2(self, request, *args, **kwargs):
favorites = request.user.track_favorites.all()
favorites = request.user.actor.track_favorites.all()
data = {"starred2": {"song": serializers.get_starred_tracks_data(favorites)}}
return response.Response(data)
@ -444,7 +444,7 @@ class SubsonicViewSet(viewsets.GenericViewSet):
url_path="getStarred",
)
def get_starred(self, request, *args, **kwargs):
favorites = request.user.track_favorites.all()
favorites = request.user.actor.track_favorites.all()
data = {"starred": {"song": serializers.get_starred_tracks_data(favorites)}}
return response.Response(data)

View File

@ -60,14 +60,13 @@ class UserAdmin(AuthUserAdmin):
list_filter = [
"is_superuser",
"is_staff",
"privacy_level",
"permission_settings",
"permission_library",
"permission_moderation",
]
actions = [disable, enable]
fieldsets = (
(None, {"fields": ("username", "password", "privacy_level")}),
(None, {"fields": ("username", "password")}),
(
_("Personal info"),
{"fields": ("first_name", "last_name", "email", "avatar")},

View File

@ -121,7 +121,6 @@ class User(AbstractUser):
# updated on logout or password change, to invalidate JWT
secret_key = models.UUIDField(default=uuid.uuid4, null=True)
privacy_level = fields.get_privacy_field()
# Unfortunately, Subsonic API assumes a MD5/password authentication
# scheme, which is weak in terms of security, and not achievable
# anyway since django use stronger schemes for storing passwords.

View File

@ -2,18 +2,24 @@ from funkwhale_api.activity import utils
def test_get_activity(factories):
user = factories["users.User"]()
listening = factories["history.Listening"]()
favorite = factories["favorites.TrackFavorite"]()
user = factories["users.User"](with_actor=True)
activity_user = factories["users.User"](with_actor=True)
listening = factories["history.Listening"](actor=activity_user.actor)
favorite = factories["favorites.TrackFavorite"](actor=activity_user.actor)
objects = list(utils.get_activity(user))
assert objects == [favorite, listening]
def test_get_activity_honors_privacy_level(factories, anonymous_user):
factories["history.Listening"](user__privacy_level="me")
favorite1 = factories["favorites.TrackFavorite"](user__privacy_level="everyone")
factories["favorites.TrackFavorite"](user__privacy_level="instance")
user = factories["users.User"](privacy_level="everyone")
user.create_actor()
user2 = factories["users.User"](privacy_level="instance")
user2.create_actor()
listening1 = factories["history.Listening"](actor=user.actor)
favorite1 = factories["favorites.TrackFavorite"](actor=user.actor)
factories["favorites.TrackFavorite"](actor=user2.actor)
objects = list(utils.get_activity(anonymous_user))
assert objects == [favorite1]
assert objects == [favorite1, listening1]

View File

@ -5,7 +5,9 @@ from funkwhale_api.activity import serializers, utils
def test_activity_view(factories, api_client, preferences, anonymous_user):
preferences["common__api_authentication_required"] = False
factories["favorites.TrackFavorite"](user__privacy_level="everyone")
user = factories["users.User"](privacy_level="everyone")
user.create_actor()
factories["favorites.TrackFavorite"](actor=user.actor)
factories["history.Listening"]()
url = reverse("api:v1:activity-list")
objects = utils.get_activity(anonymous_user)

View File

@ -3,23 +3,79 @@ from django.contrib.auth.models import AnonymousUser
from django.db.models import Q
from funkwhale_api.common import fields
from funkwhale_api.users.factories import UserFactory
from funkwhale_api.favorites import models as favorite_models
from funkwhale_api.history import models
@pytest.mark.parametrize(
"user,expected",
[
(AnonymousUser(), Q(privacy_level="everyone")),
(
UserFactory.build(pk=1),
Q(privacy_level__in=["instance", "everyone"])
| Q(privacy_level="me", user=UserFactory.build(pk=1)),
),
],
)
def test_privacy_level_query(user, expected):
def test_privacy_level_query(factories):
user = factories["users.User"](with_actor=True)
user_query = (
Q(privacy_level__in=["instance", "everyone"])
| Q(privacy_level="me", user=user)
| Q(
privacy_level="followers",
user__actor__in=user.actor.get_approved_followings(),
)
| Q(user__isnull=True)
)
query = fields.privacy_level_query(user)
assert query == expected
assert str(query) == str(user_query)
user = AnonymousUser()
user_query = Q(privacy_level="everyone")
query = fields.privacy_level_query(user)
assert str(query) == str(user_query)
def test_privacy_level_query_followers(factories):
user = factories["users.User"](with_actor=True)
target = factories["users.User"](with_actor=True, privacy_level="followers")
userfollow = factories["federation.Follow"](
actor=user.actor, target=target.actor, approved=True
)
assert user.actor.get_approved_followings()[0] == target.actor
listening = factories["history.Listening"](actor=userfollow.target)
favorite = factories["favorites.TrackFavorite"](actor=userfollow.target)
factories["history.Listening"]()
factories["favorites.TrackFavorite"]()
factories["favorites.TrackFavorite"]()
queryset = models.Listening.objects.all().filter(
fields.privacy_level_query(user, "actor__user__privacy_level", "actor__user")
)
fav_qs = favorite_models.TrackFavorite.objects.all().filter(
fields.privacy_level_query(user, "actor__user__privacy_level", "actor__user")
)
assert listening in queryset
assert favorite in fav_qs
def test_privacy_level_query_not_followers(factories):
user = factories["users.User"](with_actor=True)
target = factories["users.User"](privacy_level="followers")
target.create_actor()
target.refresh_from_db()
userfollow = factories["federation.Follow"](target=target.actor, approved=True)
listening = factories["history.Listening"](actor=userfollow.target)
favorite = factories["favorites.TrackFavorite"](actor=userfollow.target)
factories["history.Listening"]()
factories["history.Listening"]()
factories["favorites.TrackFavorite"]()
factories["favorites.TrackFavorite"]()
queryset = models.Listening.objects.all().filter(
fields.privacy_level_query(user, "actor__user__privacy_level", "actor__user")
)
fav_qs = favorite_models.TrackFavorite.objects.all().filter(
fields.privacy_level_query(user, "actor__user__privacy_level", "actor__user")
)
assert listening not in queryset
assert favorite not in fav_qs
def test_generic_relation_field(factories):

View File

@ -39,3 +39,78 @@ def test_owner_permission_read_only(anonymous_user, nodb_factories, api_request)
check = permission.has_object_permission(request, view, playlist)
assert check is True
@pytest.mark.parametrize(
"privacy_level,expected",
[("me", False), ("followers", False), ("instance", False), ("everyone", True)],
)
def test_privacylevel_permission_anonymous(
factories, api_request, anonymous_user, privacy_level, expected
):
user = factories["users.User"](privacy_level=privacy_level)
user.create_actor()
view = APIView.as_view()
permission = permissions.PrivacyLevelPermission()
request = api_request.get("/")
setattr(request, "user", anonymous_user)
check = permission.has_object_permission(request, view, user.actor)
assert check is expected
@pytest.mark.parametrize(
"privacy_level,expected",
[("me", False), ("followers", False), ("instance", True), ("everyone", True)],
)
def test_privacylevel_permission_instance(
factories, api_request, anonymous_user, privacy_level, expected, mocker
):
user = factories["users.User"](privacy_level=privacy_level)
user.create_actor()
request_user = factories["users.User"](with_actor=True)
view = APIView.as_view()
permission = permissions.PrivacyLevelPermission()
request = api_request.get("/")
setattr(request, "user", request_user)
check = permission.has_object_permission(request, view, user.actor)
assert check is expected
@pytest.mark.parametrize(
"privacy_level,expected",
[("me", True), ("followers", False), ("instance", True), ("everyone", True)],
)
def test_privacylevel_permission_me(
factories, api_request, anonymous_user, privacy_level, expected, mocker
):
user = factories["users.User"](privacy_level=privacy_level)
user.create_actor()
view = APIView.as_view()
permission = permissions.PrivacyLevelPermission()
request = api_request.get("/")
setattr(request, "user", user)
check = permission.has_object_permission(request, view, user.actor)
assert check is expected
@pytest.mark.parametrize(
"privacy_level,expected",
[("me", False), ("followers", True), ("instance", False), ("everyone", True)],
)
def test_privacylevel_permission_followers(
factories, api_request, anonymous_user, privacy_level, expected, mocker
):
user = factories["users.User"](privacy_level=privacy_level)
user.create_actor()
user_follow = factories["federation.Follow"](target=user.actor, approved=True)
view = APIView.as_view()
permission = permissions.PrivacyLevelPermission()
request = api_request.get("/")
setattr(request, "user", anonymous_user)
setattr(request, "actor", user_follow.actor)
check = permission.has_object_permission(request, view, user.actor)
assert check is expected

View File

@ -13,6 +13,7 @@ from funkwhale_api.history import models as history_models
def test_listenbrainz_submit_listen(logged_in_client, mocker, factories):
logged_in_client.user.create_actor()
config = plugins.get_plugin_config(
name="listenbrainz",
description="A plugin that allows you to submit or sync your listens and favorites to ListenBrainz.",
@ -38,7 +39,7 @@ def test_listenbrainz_submit_listen(logged_in_client, mocker, factories):
url = reverse("api:v1:history:listenings-list")
logged_in_client.post(url, {"track": track.pk})
logged_in_client.get(url)
listening = history_models.Listening.objects.get(user=logged_in_client.user)
listening = history_models.Listening.objects.get(actor=logged_in_client.user.actor)
handler.assert_called_once_with(listening=listening, conf=None)
@ -46,7 +47,7 @@ def test_sync_listenings_from_listenbrainz(factories, mocker, caplog):
logger = logging.getLogger("plugins")
caplog.set_level(logging.INFO)
logger.addHandler(caplog.handler)
user = factories["users.User"]()
user = factories["users.User"](with_actor=True)
factories["music.Track"](mbid="f89db7f8-4a1f-4228-a0a1-e7ba028b7476")
track = factories["music.Track"](mbid="54c60860-f43d-484e-b691-7ab7ec8de559")
@ -117,14 +118,14 @@ def test_sync_favorites_from_listenbrainz(factories, mocker, caplog):
logger = logging.getLogger("plugins")
caplog.set_level(logging.INFO)
logger.addHandler(caplog.handler)
user = factories["users.User"]()
user = factories["users.User"](with_actor=True)
# track lb fav
factories["music.Track"](mbid="195565db-65f9-4d0d-b347-5f0c85509528")
# random track
factories["music.Track"]()
# track lb neutral
track = factories["music.Track"](mbid="c5af5351-dbbf-4481-b52e-a480b6c57986")
favorite = factories["favorites.TrackFavorite"](track=track, user=user)
favorite = factories["favorites.TrackFavorite"](track=track, actor=user.actor)
# last_sync
track_last_sync = factories["music.Track"](
mbid="c878ef2f-c08d-4a81-a047-f2a9f978cec7"
@ -189,12 +190,12 @@ def test_sync_favorites_from_listenbrainz_since(factories, mocker, caplog):
logger = logging.getLogger("plugins")
caplog.set_level(logging.INFO)
logger.addHandler(caplog.handler)
user = factories["users.User"]()
user = factories["users.User"](with_actor=True)
# track lb fav
factories["music.Track"](mbid="195565db-65f9-4d0d-b347-5f0c85509528")
# track lb neutral
track = factories["music.Track"](mbid="c5af5351-dbbf-4481-b52e-a480b6c57986")
favorite = factories["favorites.TrackFavorite"](track=track, user=user)
favorite = factories["favorites.TrackFavorite"](track=track, actor=user.actor)
# track should be not synced
factories["music.Track"](mbid="1fd02cf2-7247-4715-8862-c378ec196000")
# last_sync
@ -203,7 +204,7 @@ def test_sync_favorites_from_listenbrainz_since(factories, mocker, caplog):
)
factories["favorites.TrackFavorite"](
track=track_last_sync,
user=user,
actor=user.actor,
source="Listenbrainz",
creation_date=datetime.datetime.fromtimestamp(1690775094),
)

View File

@ -1,19 +1,20 @@
from funkwhale_api.favorites import activities, serializers
from funkwhale_api.federation.serializers import APIActorSerializer
from funkwhale_api.music.serializers import TrackActivitySerializer
from funkwhale_api.users.serializers import UserActivitySerializer
def test_get_favorite_activity_url(settings, factories):
favorite = factories["favorites.TrackFavorite"]()
user_url = favorite.user.get_activity_url()
user = factories["users.User"](with_actor=True)
favorite = factories["favorites.TrackFavorite"](actor=user.actor)
user_url = favorite.actor.user.get_activity_url()
expected = f"{user_url}/favorites/tracks/{favorite.pk}"
assert favorite.get_activity_url() == expected
def test_activity_favorite_serializer(factories):
favorite = factories["favorites.TrackFavorite"]()
actor = UserActivitySerializer(favorite.user).data
user = factories["users.User"](with_actor=True)
favorite = factories["favorites.TrackFavorite"](actor=user.actor)
actor = APIActorSerializer(favorite.actor).data
field = serializers.serializers.DateTimeField()
expected = {
"type": "Like",
@ -42,7 +43,8 @@ def test_track_favorite_serializer_instance_activity_consumer(activity_registry)
def test_broadcast_track_favorite_to_instance_activity(factories, mocker):
p = mocker.patch("funkwhale_api.common.channels.group_send")
favorite = factories["favorites.TrackFavorite"]()
user = factories["users.User"](with_actor=True)
favorite = factories["favorites.TrackFavorite"](actor=user.actor)
data = serializers.TrackFavoriteActivitySerializer(favorite).data
consumer = activities.broadcast_track_favorite_to_instance_activity
message = {"type": "event.send", "text": "", "data": data}
@ -52,7 +54,9 @@ def test_broadcast_track_favorite_to_instance_activity(factories, mocker):
def test_broadcast_track_favorite_to_instance_activity_private(factories, mocker):
p = mocker.patch("funkwhale_api.common.channels.group_send")
favorite = factories["favorites.TrackFavorite"](user__privacy_level="me")
user = factories["users.User"](privacy_level="me")
user.create_actor()
favorite = factories["favorites.TrackFavorite"](actor=user.actor)
data = serializers.TrackFavoriteActivitySerializer(favorite).data
consumer = activities.broadcast_track_favorite_to_instance_activity
consumer(data=data, obj=favorite)

View File

@ -9,11 +9,11 @@ from funkwhale_api.favorites.models import TrackFavorite
def test_user_can_add_favorite(factories):
track = factories["music.Track"]()
user = factories["users.User"]()
f = TrackFavorite.add(track, user)
user = factories["users.User"](with_actor=True)
f = TrackFavorite.add(track, user.actor)
assert f.track == track
assert f.user == user
assert f.actor.user == user
def test_user_can_get_his_favorites(
@ -21,7 +21,9 @@ def test_user_can_get_his_favorites(
):
request = api_request.get("/")
logged_in_api_client.user.create_actor()
favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user)
favorite = factories["favorites.TrackFavorite"](
actor=logged_in_api_client.user.actor
)
factories["favorites.TrackFavorite"]()
url = reverse("api:v1:favorites:tracks-list")
response = logged_in_api_client.get(url, {"scope": "me"})
@ -38,7 +40,10 @@ def test_user_can_get_his_favorites(
def test_user_can_retrieve_all_favorites_at_once(
api_request, factories, logged_in_api_client, client
):
favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user)
logged_in_api_client.user.create_actor()
favorite = factories["favorites.TrackFavorite"](
actor=logged_in_api_client.user.actor
)
factories["favorites.TrackFavorite"]()
url = reverse("api:v1:favorites:tracks-all")
response = logged_in_api_client.get(url, {"user": logged_in_api_client.user.pk})
@ -49,6 +54,8 @@ def test_user_can_retrieve_all_favorites_at_once(
def test_user_can_add_favorite_via_api(factories, logged_in_api_client, activity_muted):
track = factories["music.Track"]()
logged_in_api_client.user.create_actor()
url = reverse("api:v1:favorites:tracks-list")
response = logged_in_api_client.post(url, {"track": track.pk})
@ -62,12 +69,13 @@ def test_user_can_add_favorite_via_api(factories, logged_in_api_client, activity
assert expected == parsed_json
assert favorite.track == track
assert favorite.user == logged_in_api_client.user
assert favorite.actor.user == logged_in_api_client.user
def test_adding_favorites_calls_activity_record(
factories, logged_in_api_client, activity_muted
):
logged_in_api_client.user.create_actor()
track = factories["music.Track"]()
url = reverse("api:v1:favorites:tracks-list")
response = logged_in_api_client.post(url, {"track": track.pk})
@ -82,13 +90,16 @@ def test_adding_favorites_calls_activity_record(
assert expected == parsed_json
assert favorite.track == track
assert favorite.user == logged_in_api_client.user
assert favorite.actor.user == logged_in_api_client.user
activity_muted.assert_called_once_with(favorite)
def test_user_can_remove_favorite_via_api(logged_in_api_client, factories):
favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user)
logged_in_api_client.user.create_actor()
favorite = factories["favorites.TrackFavorite"](
actor=logged_in_api_client.user.actor
)
url = reverse("api:v1:favorites:tracks-detail", kwargs={"pk": favorite.pk})
response = logged_in_api_client.delete(url, {"track": favorite.track.pk})
assert response.status_code == 204
@ -99,7 +110,10 @@ def test_user_can_remove_favorite_via_api(logged_in_api_client, factories):
def test_user_can_remove_favorite_via_api_using_track_id(
method, factories, logged_in_api_client
):
favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user)
logged_in_api_client.user.create_actor()
favorite = factories["favorites.TrackFavorite"](
actor=logged_in_api_client.user.actor
)
url = reverse("api:v1:favorites:tracks-remove")
response = getattr(logged_in_api_client, method)(
@ -119,7 +133,10 @@ def test_url_require_auth(url, method, db, preferences, client):
def test_can_filter_tracks_by_favorites(factories, logged_in_api_client):
favorite = factories["favorites.TrackFavorite"](user=logged_in_api_client.user)
logged_in_api_client.user.create_actor()
favorite = factories["favorites.TrackFavorite"](
actor=logged_in_api_client.user.actor
)
url = reverse("api:v1:tracks-list")
response = logged_in_api_client.get(url, data={"favorites": True})

View File

@ -0,0 +1,32 @@
import pytest
from funkwhale_api.favorites import models
@pytest.mark.parametrize(
"privacy_level,expected",
[("me", False), ("instance", True), ("everyone", True)],
)
def test_playable_by_local_actor(privacy_level, expected, factories):
actor = factories["federation.Actor"](local=True)
# default user actor is local
user = factories["users.User"](privacy_level=privacy_level)
user.create_actor()
favorite = factories["favorites.TrackFavorite"](actor=user.actor)
queryset = models.TrackFavorite.objects.all().viewable_by(actor)
match = favorite in list(queryset)
assert match is expected
@pytest.mark.parametrize(
"privacy_level,expected", [("me", False), ("instance", False), ("everyone", True)]
)
def test_not_playable_by_remote_actor(privacy_level, expected, factories):
actor = factories["federation.Actor"]()
# default user actor is local
user = factories["users.User"](privacy_level=privacy_level)
user.create_actor()
favorite = factories["favorites.TrackFavorite"](actor=user.actor)
queryset = models.TrackFavorite.objects.all().viewable_by(actor)
match = favorite in list(queryset)
assert match is expected

View File

@ -1,19 +1,16 @@
from funkwhale_api.favorites import serializers
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.music import serializers as music_serializers
from funkwhale_api.users import serializers as users_serializers
def test_track_favorite_serializer(factories, to_api_date):
favorite = factories["favorites.TrackFavorite"]()
actor = favorite.user.create_actor()
expected = {
"id": favorite.pk,
"creation_date": to_api_date(favorite.creation_date),
"track": music_serializers.TrackSerializer(favorite.track).data,
"actor": federation_serializers.APIActorSerializer(actor).data,
"user": users_serializers.UserBasicSerializer(favorite.user).data,
"actor": federation_serializers.APIActorSerializer(favorite.actor).data,
}
serializer = serializers.UserTrackFavoriteSerializer(favorite)

View File

@ -5,7 +5,8 @@ from django.urls import reverse
@pytest.mark.parametrize("level", ["instance", "me", "followers"])
def test_privacy_filter(preferences, level, factories, api_client):
preferences["common__api_authentication_required"] = False
factories["favorites.TrackFavorite"](user__privacy_level=level)
user = factories["users.User"](privacy_level=level, with_actor=True)
factories["favorites.TrackFavorite"](actor=user.actor)
url = reverse("api:v1:favorites:tracks-list")
response = api_client.get(url)
assert response.status_code == 200

View File

@ -180,3 +180,17 @@ def test_fetch_serializer_unhandled_obj(factories, to_api_date):
}
assert api_serializers.FetchSerializer(fetch).data == expected
def test_follow_serializer_do_not_allow_already_followed(factories):
actor = factories["federation.Actor"]()
follow = factories["federation.Follow"](actor=actor)
serializer = api_serializers.FollowSerializer(context={"actor": actor})
with pytest.raises(
api_serializers.serializers.ValidationError, match=r"You cannot follow yourself"
):
serializer.validate_target(actor)
with pytest.raises(api_serializers.serializers.ValidationError, match=r"already"):
serializer.validate_target(follow.target)

View File

@ -316,3 +316,120 @@ def test_library_follow_get_all(factories, logged_in_api_client):
],
"count": 1,
}
def test_user_follow_get_all(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
target_actor = factories["federation.Actor"]()
follow = factories["federation.Follow"](target=target_actor, actor=actor)
factories["federation.Follow"]()
url = reverse("api:v1:federation:user-follows-all")
response = logged_in_api_client.get(url)
assert response.status_code == 200
assert response.data == {
"results": [
{
"uuid": str(follow.uuid),
"actor": str(target_actor.fid),
"approved": follow.approved,
}
],
"count": 1,
}
def test_user_follow_retrieve(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
target_actor = factories["federation.Actor"]()
follow = factories["federation.Follow"](target=target_actor, actor=actor)
factories["federation.Follow"]()
url = reverse("api:v1:federation:user-follows-detail", kwargs={"uuid": follow.uuid})
response = logged_in_api_client.get(url)
assert response.status_code == 200
def test_user_can_list_their_received_follows(factories, logged_in_api_client):
# followed by someont else
factories["federation.Follow"]()
follow = factories["federation.Follow"](actor__user=logged_in_api_client.user)
url = reverse("api:v1:federation:user-follows-list")
response = logged_in_api_client.get(url)
assert response.data["count"] == 1
assert response.data["results"][0]["uuid"] == str(follow.uuid)
def test_can_follow_user_actor(factories, logged_in_api_client, mocker):
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
actor = logged_in_api_client.user.create_actor()
target_actor = factories["federation.Actor"]()
url = reverse("api:v1:federation:user-follows-list")
response = logged_in_api_client.post(url, {"target": target_actor.fid})
assert response.status_code == 201
follow = target_actor.received_follows.latest("id")
assert follow.approved is None
assert follow.actor == actor
dispatch.assert_called_once_with({"type": "Follow"}, context={"follow": follow})
def test_can_undo_user_follow(factories, logged_in_api_client, mocker):
dispatch = mocker.patch("funkwhale_api.federation.routes.outbox.dispatch")
actor = logged_in_api_client.user.create_actor()
follow = factories["federation.Follow"](actor=actor)
delete = mocker.patch.object(follow.__class__, "delete")
url = reverse("api:v1:federation:user-follows-detail", kwargs={"uuid": follow.uuid})
response = logged_in_api_client.delete(url)
assert response.status_code == 204
delete.assert_called_once_with()
dispatch.assert_called_once_with(
{"type": "Undo", "object": {"type": "Follow"}}, context={"follow": follow}
)
@pytest.mark.parametrize("action", ["accept", "reject"])
def test_user_cannot_edit_someone_else_user_follow(
factories, logged_in_api_client, action
):
logged_in_api_client.user.create_actor()
follow = factories["federation.Follow"]()
url = reverse(
f"api:v1:federation:user-follows-{action}",
kwargs={"uuid": follow.uuid},
)
response = logged_in_api_client.post(url)
assert response.status_code == 404
@pytest.mark.parametrize("action,expected", [("accept", True), ("reject", False)])
def test_user_can_accept_or_reject_own_received_follows(
factories, logged_in_api_client, action, expected, mocker
):
mocked_dispatch = mocker.patch(
"funkwhale_api.federation.activity.OutboxRouter.dispatch"
)
actor = logged_in_api_client.user.create_actor()
follow = factories["federation.Follow"](target=actor)
url = reverse(
f"api:v1:federation:user-follows-{action}",
kwargs={"uuid": follow.uuid},
)
response = logged_in_api_client.post(url)
assert response.status_code == 204
follow.refresh_from_db()
assert follow.approved is expected
mocked_dispatch.assert_called_once_with(
{"type": action.title()}, context={"follow": follow}
)

View File

@ -1,5 +1,6 @@
import pytest
from funkwhale_api.favorites import models as favorites_models
from funkwhale_api.federation import (
activity,
actors,
@ -7,7 +8,9 @@ from funkwhale_api.federation import (
jsonld,
routes,
serializers,
utils,
)
from funkwhale_api.history import models as history_models
from funkwhale_api.moderation import serializers as moderation_serializers
@ -36,6 +39,10 @@ from funkwhale_api.moderation import serializers as moderation_serializers
({"type": "Delete", "object": {"type": "Person"}}, routes.inbox_delete_actor),
({"type": "Delete", "object": {"type": "Tombstone"}}, routes.inbox_delete),
({"type": "Flag"}, routes.inbox_flag),
(
{"type": "Like", "object": {"type": "Track"}},
routes.inbox_create_favorite,
),
],
)
def test_inbox_routes(route, handler):
@ -82,6 +89,10 @@ def test_inbox_routes(route, handler):
{"type": "Delete", "object": {"type": "Organization"}},
routes.outbox_delete_actor,
),
(
{"type": "Like", "object": {"type": "Track"}},
routes.outbox_create_track_favorite,
),
],
)
def test_outbox_routes(route, handler):
@ -127,6 +138,40 @@ def test_inbox_follow_library_autoapprove(factories, mocker):
)
def test_inbox_follow_user_autoapprove(factories, mocker):
mocked_outbox_dispatch = mocker.patch(
"funkwhale_api.federation.activity.OutboxRouter.dispatch"
)
local_actor = factories["users.User"](privacy_level="public").create_actor()
remote_actor = factories["federation.Actor"]()
ii = factories["federation.InboxItem"](actor=local_actor)
payload = {
"type": "Follow",
"id": "https://test.follow",
"actor": remote_actor.fid,
"object": local_actor.fid,
}
result = routes.inbox_follow(
payload,
context={"actor": remote_actor, "inbox_items": [ii], "raise_exception": True},
)
follow = local_actor.received_follows.latest("id")
assert result["object"] == local_actor
assert result["related_object"] == follow
assert follow.fid == payload["id"]
assert follow.actor == remote_actor
assert follow.approved is True
mocked_outbox_dispatch.assert_called_once_with(
{"type": "Accept"}, context={"follow": follow}
)
def test_inbox_follow_channel_autoapprove(factories, mocker):
mocked_outbox_dispatch = mocker.patch(
"funkwhale_api.federation.activity.OutboxRouter.dispatch"
@ -988,3 +1033,106 @@ def test_outbox_flag(factory_name, factory_kwargs, factories, mocker):
expected["to"] = [{"type": "actor_inbox", "actor": report.target_owner}]
assert activity["payload"] == expected
assert activity["actor"] == actors.get_service_actor()
def test_outbox_create_track_favorite(factories, mocker):
user = factories["users.User"](with_actor=True)
favorite = factories["favorites.TrackFavorite"](actor=user.actor)
activity = list(
routes.outbox_create_track_favorite(
{"track": favorite.track, "actor": user.actor, "id": favorite.fid}
)
)[0]
serializer = serializers.ActivitySerializer(
{
"type": "Like",
"id": favorite.fid,
"object": {"type": "Track", "id": favorite.track.fid},
}
)
expected = serializer.data
expected["to"] = [{"type": "followers", "target": favorite.actor}]
assert dict(activity["payload"]) == dict(expected)
assert activity["actor"] == favorite.actor
def test_inbox_create_track_favorite(factories, mocker):
actor = factories["federation.Actor"]()
favorite = factories["favorites.TrackFavorite"](actor=actor)
serializer = serializers.TrackFavoriteSerializer(favorite)
init = mocker.spy(serializers.TrackFavoriteSerializer, "__init__")
save = mocker.spy(serializers.TrackFavoriteSerializer, "save")
mocker.patch.object(utils, "retrieve_ap_object", return_value=favorite.track)
favorite.delete()
result = routes.inbox_create_favorite(
serializer.data,
context={
"actor": favorite.actor,
"raise_exception": True,
},
)
assert init.call_count == 1
args = init.call_args
assert args[1]["data"] == serializers.TrackFavoriteSerializer(result["object"]).data
assert save.call_count == 1
assert favorites_models.TrackFavorite.objects.filter(
track=favorite.track, actor=favorite.actor
).exists()
def test_outbox_create_listening(factories, mocker):
user = factories["users.User"](with_actor=True)
listening = factories["history.Listening"](actor=user.actor)
activity = list(
routes.outbox_create_listening(
{"track": listening.track, "actor": user.actor, "id": listening.fid}
)
)[0]
serializer = serializers.ActivitySerializer(
{
"type": "Listen",
"id": listening.fid,
"object": {"type": "Track", "id": listening.track.fid},
}
)
expected = serializer.data
expected["to"] = [{"type": "followers", "target": listening.actor}]
assert dict(activity["payload"]) == dict(expected)
assert activity["actor"] == listening.actor
def test_inbox_create_listening(factories, mocker):
actor = factories["federation.Actor"]()
listening = factories["history.Listening"](actor=actor)
serializer = serializers.ListeningSerializer(listening)
init = mocker.spy(serializers.ListeningSerializer, "__init__")
save = mocker.spy(serializers.ListeningSerializer, "save")
mocker.patch.object(utils, "retrieve_ap_object", return_value=listening.track)
listening.delete()
result = routes.inbox_create_listening(
serializer.data,
context={
"actor": listening.actor,
"raise_exception": True,
},
)
assert init.call_count == 1
args = init.call_args
assert args[1]["data"] == serializers.ListeningSerializer(result["object"]).data
assert save.call_count == 1
assert history_models.Listening.objects.filter(
track=listening.track, actor=listening.actor
).exists()
# to do : test dislike

View File

@ -282,6 +282,7 @@ def test_accept_follow_serializer_representation(factories):
def test_accept_follow_serializer_save(factories):
follow = factories["federation.Follow"](approved=None)
factories["audio.Channel"](actor=follow.target)
data = {
"@context": jsonld.get_default_context(),
@ -352,8 +353,16 @@ def test_undo_follow_serializer_representation(factories):
assert serializer.data == expected
def test_undo_follow_serializer_save(factories):
follow = factories["federation.Follow"](approved=True)
@pytest.mark.parametrize(
(
"followed_name",
"follow_factory",
),
[("audio.Channel", "federation.Follow"), ("users.User", "federation.Follow")],
)
def test_undo_follow_serializer_save(factories, followed_name, follow_factory):
follow = factories[follow_factory](approved=True)
factories[followed_name](actor=follow.target)
data = {
"@context": jsonld.get_default_context(),
@ -366,9 +375,12 @@ def test_undo_follow_serializer_save(factories):
serializer = serializers.UndoFollowSerializer(data=data)
assert serializer.is_valid(raise_exception=True)
serializer.save()
with pytest.raises(models.Follow.DoesNotExist):
follow.refresh_from_db()
if followed_name == "audio.Channel":
with pytest.raises(models.Follow.DoesNotExist):
follow.refresh_from_db()
else:
with pytest.raises(models.Follow.DoesNotExist):
follow.refresh_from_db()
def test_undo_follow_serializer_validates_on_context(factories):

View File

@ -701,3 +701,34 @@ def test_check_all_remote_instance_skips_local(settings, factories, r_mock):
settings.FUNKWHALE_HOSTNAME = domain.name
tasks.check_all_remote_instance_availability()
assert not r_mock.called
def test_fetch_webfinger_create_actor(factories, r_mock, mocker):
actor = factories["federation.Actor"]()
fetch = factories["federation.Fetch"](url=f"webfinger://{actor.full_username}")
payload = serializers.ActorSerializer(actor).data
init = mocker.spy(serializers.ActorSerializer, "__init__")
save = mocker.spy(serializers.ActorSerializer, "save")
webfinger_payload = {
"subject": f"acct:{actor.full_username}",
"aliases": ["https://test.webfinger"],
"links": [
{"rel": "self", "type": "application/activity+json", "href": actor.fid}
],
}
webfinger_url = "https://{}/.well-known/webfinger?resource={}".format(
actor.domain_id, webfinger_payload["subject"]
)
r_mock.get(actor.fid, json=payload)
r_mock.get(webfinger_url, json=webfinger_payload)
tasks.fetch(fetch_id=fetch.pk)
fetch.refresh_from_db()
assert fetch.status == "finished"
assert fetch.object == actor
assert init.call_count == 1
assert init.call_args[0][1] == actor
assert init.call_args[1]["data"] == payload
assert save.call_count == 1

View File

@ -642,3 +642,126 @@ def test_index_libraries_page(factories, api_client, preferences):
assert response.status_code == 200
assert response.data == expected
def test_get_followers(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
factories["federation.Follow"](target=actor, approved=True)
factories["federation.Follow"](target=actor, approved=True)
factories["federation.Follow"](target=actor, approved=True)
factories["federation.Follow"](target=actor, approved=True)
factories["federation.Follow"](target=actor, approved=True)
url = reverse(
"federation:actors-followers",
kwargs={"preferred_username": actor.preferred_username},
)
response = logged_in_api_client.get(url)
assert response.data["totalItems"] == 5
def test_get_following(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
factories["federation.Follow"](actor=actor, approved=True)
factories["federation.Follow"](actor=actor, approved=True)
factories["federation.Follow"](actor=actor, approved=True)
factories["federation.Follow"](actor=actor, approved=True)
factories["federation.Follow"](actor=actor, approved=True)
url = reverse(
"federation:actors-following",
kwargs={"preferred_username": actor.preferred_username},
)
response = logged_in_api_client.get(url)
assert response.data["totalItems"] == 5
def test_get_likes(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
factories["favorites.TrackFavorite"](actor=actor)
factories["favorites.TrackFavorite"](actor=actor)
factories["favorites.TrackFavorite"](actor=actor)
factories["favorites.TrackFavorite"](actor=actor)
factories["favorites.TrackFavorite"](actor=actor)
url = reverse(
"federation:actors-likes",
kwargs={"preferred_username": actor.preferred_username},
)
response = logged_in_api_client.get(url)
assert response.data["totalItems"] == 5
def test_get_listenings(factories, logged_in_api_client):
actor = logged_in_api_client.user.create_actor()
factories["history.Listening"](actor=actor)
factories["history.Listening"](actor=actor)
factories["history.Listening"](actor=actor)
factories["history.Listening"](actor=actor)
factories["history.Listening"](actor=actor)
url = reverse(
"federation:actors-listens",
kwargs={"preferred_username": actor.preferred_username},
)
response = logged_in_api_client.get(url)
assert response.data["totalItems"] == 5
@pytest.mark.parametrize(
"privacy_level, expected", [("me", 403), ("instance", 200), ("everyone", 200)]
)
def test_get_listenings_honours_privacy_level(
factories, logged_in_api_client, privacy_level, expected
):
user = factories["users.User"](with_actor=True, privacy_level=privacy_level)
factories["history.Listening"](actor=user.actor)
url = reverse(
"federation:actors-listens",
kwargs={"preferred_username": user.actor.preferred_username},
)
response = logged_in_api_client.get(url)
assert response.status_code == expected
@pytest.mark.parametrize(
"privacy_level, expected", [("me", 403), ("instance", 200), ("everyone", 200)]
)
def test_get_favorite(factories, logged_in_api_client, privacy_level, expected):
user = factories["users.User"](with_actor=True, privacy_level=privacy_level)
favorite = factories["favorites.TrackFavorite"](actor=user.actor, local=True)
url = reverse(
"federation:music:likes-detail",
kwargs={"uuid": favorite.uuid},
)
response = logged_in_api_client.get(url)
assert response.status_code == expected
@pytest.mark.parametrize(
"privacy_level, expected", [("me", 403), ("instance", 403), ("everyone", 200)]
)
def test_get_favorite_anonymous(factories, api_client, privacy_level, expected):
user = factories["users.User"](with_actor=True, privacy_level=privacy_level)
favorite = factories["favorites.TrackFavorite"](actor=user.actor, local=True)
url = reverse(
"federation:music:likes-detail",
kwargs={"uuid": favorite.uuid},
)
response = api_client.get(url)
assert response.status_code == expected
@pytest.mark.parametrize(
"privacy_level, expected", [("me", 403), ("instance", 200), ("everyone", 200)]
)
def test_get_listening(factories, logged_in_api_client, privacy_level, expected):
user = factories["users.User"](with_actor=True, privacy_level=privacy_level)
listening = factories["history.Listening"](actor=user.actor, local=True)
url = reverse(
"federation:music:listenings-detail",
kwargs={"uuid": listening.uuid},
)
response = logged_in_api_client.get(url)
assert response.status_code == expected

View File

@ -1,11 +1,12 @@
from funkwhale_api.federation.serializers import APIActorSerializer
from funkwhale_api.history import activities, serializers
from funkwhale_api.music.serializers import TrackActivitySerializer
from funkwhale_api.users.serializers import UserActivitySerializer
def test_get_listening_activity_url(settings, factories):
listening = factories["history.Listening"]()
user_url = listening.user.get_activity_url()
user = factories["users.User"](with_actor=True)
listening = factories["history.Listening"](actor=user.actor)
user_url = listening.actor.user.get_activity_url()
expected = f"{user_url}/listenings/tracks/{listening.pk}"
assert listening.get_activity_url() == expected
@ -13,7 +14,7 @@ def test_get_listening_activity_url(settings, factories):
def test_activity_listening_serializer(factories):
listening = factories["history.Listening"]()
actor = UserActivitySerializer(listening.user).data
actor = APIActorSerializer(listening.actor).data
field = serializers.serializers.DateTimeField()
expected = {
"type": "Listen",
@ -42,7 +43,8 @@ def test_track_listening_serializer_instance_activity_consumer(activity_registry
def test_broadcast_listening_to_instance_activity(factories, mocker):
p = mocker.patch("funkwhale_api.common.channels.group_send")
listening = factories["history.Listening"]()
user = factories["users.User"](with_actor=True)
listening = factories["history.Listening"](actor=user.actor)
data = serializers.ListeningActivitySerializer(listening).data
consumer = activities.broadcast_listening_to_instance_activity
message = {"type": "event.send", "text": "", "data": data}
@ -52,7 +54,9 @@ def test_broadcast_listening_to_instance_activity(factories, mocker):
def test_broadcast_listening_to_instance_activity_private(factories, mocker):
p = mocker.patch("funkwhale_api.common.channels.group_send")
listening = factories["history.Listening"](user__privacy_level="me")
user = factories["users.User"](privacy_level="me")
user.create_actor()
listening = factories["history.Listening"](actor=user.actor)
data = serializers.ListeningActivitySerializer(listening).data
consumer = activities.broadcast_listening_to_instance_activity
consumer(data=data, obj=listening)

View File

@ -5,8 +5,8 @@ from funkwhale_api.history import models
def test_can_create_listening(factories):
track = factories["music.Track"]()
user = factories["users.User"]()
models.Listening.objects.create(user=user, track=track)
user = factories["users.User"](with_actor=True)
models.Listening.objects.create(actor=user.actor, track=track)
def test_logged_in_user_can_create_listening_via_api(
@ -20,7 +20,7 @@ def test_logged_in_user_can_create_listening_via_api(
listening = models.Listening.objects.latest("id")
assert listening.track == track
assert listening.user == logged_in_client.user
assert listening.actor.user == logged_in_client.user
def test_adding_listening_calls_activity_record(

View File

@ -1,19 +1,17 @@
from funkwhale_api.federation import serializers as federation_serializers
from funkwhale_api.history import serializers
from funkwhale_api.music import serializers as music_serializers
from funkwhale_api.users import serializers as users_serializers
def test_listening_serializer(factories, to_api_date):
listening = factories["history.Listening"]()
actor = listening.user.create_actor()
actor = listening.actor
expected = {
"id": listening.pk,
"creation_date": to_api_date(listening.creation_date),
"track": music_serializers.TrackSerializer(listening.track).data,
"actor": federation_serializers.APIActorSerializer(actor).data,
"user": users_serializers.UserBasicSerializer(listening.user).data,
}
serializer = serializers.ListeningSerializer(listening)

View File

@ -5,7 +5,8 @@ from django.urls import reverse
@pytest.mark.parametrize("level", ["instance", "me", "followers"])
def test_privacy_filter(preferences, level, factories, api_client):
preferences["common__api_authentication_required"] = False
factories["history.Listening"](user__privacy_level=level)
user = factories["users.User"](privacy_level=level)
factories["history.Listening"](actor__user=user)
url = reverse("api:v1:history:listenings-list")
response = api_client.get(url)
assert response.status_code == 200

View File

@ -6,6 +6,7 @@ from django.urls import reverse
def test_can_get_playlist_list(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
factories["playlists.Playlist"].create_batch(5)
url = reverse("api:v2:playlists:playlists-list")
headers = {"Content-Type": "application/json"}
@ -17,6 +18,7 @@ def test_can_get_playlist_list(factories, logged_in_api_client):
def test_can_get_playlists_octet_stream(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
pl = factories["playlists.Playlist"]()
factories["playlists.PlaylistTrack"](playlist=pl)
factories["playlists.PlaylistTrack"](playlist=pl)
@ -32,6 +34,7 @@ def test_can_get_playlists_octet_stream(factories, logged_in_api_client):
def test_can_get_playlists_json(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
pl = factories["playlists.Playlist"]()
url = reverse("api:v2:playlists:playlists-detail", kwargs={"pk": pl.pk})
response = logged_in_api_client.get(url, format="json")
@ -40,6 +43,7 @@ def test_can_get_playlists_json(factories, logged_in_api_client):
def test_can_get_user_playlists_list(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
user = factories["users.User"]()
factories["playlists.Playlist"](user=user)
@ -52,7 +56,8 @@ def test_can_get_user_playlists_list(factories, logged_in_api_client):
assert data["count"] == 1
def test_can_post_user_playlists(factories, logged_in_api_client):
def test_can_post_user_playlists(logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = {"name": "Les chiennes de l'hexagone", "privacy_level": "me"}
url = reverse("api:v2:playlists:playlists-list")
@ -64,6 +69,7 @@ def test_can_post_user_playlists(factories, logged_in_api_client):
def test_can_post_playlists_octet_stream(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
artist = factories["music.Artist"](name="Davinhor")
album = factories["music.Album"](
title="Racisme en pls", artist_credit__artist=artist
@ -80,6 +86,7 @@ def test_can_post_playlists_octet_stream(factories, logged_in_api_client):
def test_can_post_playlists_octet_stream_invalid_track(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
url = reverse("api:v2:playlists:playlists-list")
data = open("./tests/playlists/test.xspf", "rb").read()
response = logged_in_api_client.post(url, data=data, format="xspf")
@ -89,6 +96,7 @@ def test_can_post_playlists_octet_stream_invalid_track(factories, logged_in_api_
def test_can_patch_playlists_octet_stream(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
pl = factories["playlists.Playlist"](user=logged_in_api_client.user)
artist = factories["music.Artist"](name="Davinhor")
album = factories["music.Album"](
@ -107,6 +115,7 @@ def test_can_patch_playlists_octet_stream(factories, logged_in_api_client):
def test_can_get_playlists_track(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
pl = factories["playlists.Playlist"]()
plt = factories["playlists.PlaylistTrack"](playlist=pl)
url = reverse("api:v2:playlists:playlists-tracks", kwargs={"pk": pl.pk})
@ -118,6 +127,7 @@ def test_can_get_playlists_track(factories, logged_in_api_client):
def test_can_get_playlists_releases(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"]()
plt = factories["playlists.PlaylistTrack"](playlist=playlist)
url = reverse("api:v2:playlists:playlists-albums", kwargs={"pk": playlist.pk})
@ -128,6 +138,7 @@ def test_can_get_playlists_releases(factories, logged_in_api_client):
def test_can_get_playlists_artists(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"]()
plt = factories["playlists.PlaylistTrack"](playlist=playlist)
url = reverse("api:v2:playlists:playlists-artists", kwargs={"pk": playlist.pk})

View File

@ -5,6 +5,7 @@ from funkwhale_api.playlists import models
def test_can_create_playlist_via_api(logged_in_api_client):
logged_in_api_client.user.create_actor()
url = reverse("api:v1:playlists-list")
data = {"name": "test", "privacy_level": "everyone"}
@ -16,6 +17,7 @@ def test_can_create_playlist_via_api(logged_in_api_client):
def test_serializer_includes_tracks_count(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"]()
factories["playlists.PlaylistTrack"](playlist=playlist)
@ -26,6 +28,7 @@ def test_serializer_includes_tracks_count(factories, logged_in_api_client):
def test_serializer_includes_tracks_count_986(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"]()
plt = factories["playlists.PlaylistTrack"](playlist=playlist)
factories["music.Upload"].create_batch(
@ -38,6 +41,7 @@ def test_serializer_includes_tracks_count_986(factories, logged_in_api_client):
def test_serializer_includes_is_playable(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"]()
factories["playlists.PlaylistTrack"](playlist=playlist)
@ -48,6 +52,7 @@ def test_serializer_includes_is_playable(factories, logged_in_api_client):
def test_playlist_inherits_user_privacy(logged_in_api_client):
logged_in_api_client.user.create_actor()
url = reverse("api:v1:playlists-list")
user = logged_in_api_client.user
user.privacy_level = "me"
@ -73,6 +78,7 @@ def test_url_requires_login(name, method, factories, api_client):
def test_only_can_add_track_on_own_playlist_via_api(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
track = factories["music.Track"]()
playlist = factories["playlists.Playlist"]()
url = reverse("api:v1:playlists-add", kwargs={"pk": playlist.pk})
@ -84,6 +90,7 @@ def test_only_can_add_track_on_own_playlist_via_api(factories, logged_in_api_cli
def test_deleting_plt_updates_indexes(mocker, factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
remove = mocker.spy(models.Playlist, "remove")
factories["music.Track"]()
playlist = factories["playlists.Playlist"](user=logged_in_api_client.user)
@ -115,6 +122,7 @@ def test_playlist_privacy_respected_in_list_anon(
@pytest.mark.parametrize("method", ["PUT", "PATCH", "DELETE"])
def test_only_owner_can_edit_playlist(method, factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"]()
url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk})
response = getattr(logged_in_api_client, method.lower())(url)
@ -125,6 +133,7 @@ def test_only_owner_can_edit_playlist(method, factories, logged_in_api_client):
def test_can_add_multiple_tracks_at_once_via_api(
factories, mocker, logged_in_api_client
):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"](user=logged_in_api_client.user)
tracks = factories["music.Track"].create_batch(size=5)
track_ids = [t.id for t in tracks]
@ -141,6 +150,7 @@ def test_can_add_multiple_tracks_at_once_via_api(
def test_honor_max_playlist_size(factories, mocker, logged_in_api_client, preferences):
logged_in_api_client.user.create_actor()
preferences["playlists__max_tracks"] = 3
playlist = factories["playlists.Playlist"](user=logged_in_api_client.user)
tracks = factories["music.Track"].create_batch(
@ -155,6 +165,7 @@ def test_honor_max_playlist_size(factories, mocker, logged_in_api_client, prefer
def test_can_clear_playlist_from_api(factories, mocker, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"](user=logged_in_api_client.user)
factories["playlists.PlaylistTrack"].create_batch(size=5, playlist=playlist)
url = reverse("api:v1:playlists-clear", kwargs={"pk": playlist.pk})
@ -165,6 +176,7 @@ def test_can_clear_playlist_from_api(factories, mocker, logged_in_api_client):
def test_update_playlist_from_api(factories, mocker, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"](user=logged_in_api_client.user)
factories["playlists.PlaylistTrack"].create_batch(size=5, playlist=playlist)
url = reverse("api:v1:playlists-detail", kwargs={"pk": playlist.pk})
@ -176,6 +188,7 @@ def test_update_playlist_from_api(factories, mocker, logged_in_api_client):
def test_move_plt_updates_indexes(mocker, factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
playlist = factories["playlists.Playlist"](user=logged_in_api_client.user)
plt0 = factories["playlists.PlaylistTrack"](index=0, playlist=playlist)
plt1 = factories["playlists.PlaylistTrack"](index=1, playlist=playlist)

View File

@ -49,10 +49,10 @@ def test_can_pick_by_weight():
def test_session_radio_excludes_previous_picks(factories):
tracks = factories["music.Track"].create_batch(5)
user = factories["users.User"]()
user = factories["users.User"](with_actor=True)
previous_choices = []
for i in range(5):
TrackFavorite.add(track=random.choice(tracks), user=user)
TrackFavorite.add(track=random.choice(tracks), actor=user.actor)
radio = radios.SessionRadio()
radio.radio_type = "favorites"
@ -72,16 +72,16 @@ def test_session_radio_excludes_previous_picks(factories):
def test_can_get_choices_for_favorites_radio(factories):
files = factories["music.Upload"].create_batch(10)
tracks = [f.track for f in files]
user = factories["users.User"]()
user = factories["users.User"](with_actor=True)
for i in range(5):
TrackFavorite.add(track=random.choice(tracks), user=user)
TrackFavorite.add(track=random.choice(tracks), actor=user.actor)
radio = radios.FavoritesRadio()
choices = radio.get_choices(user=user)
assert choices.count() == user.track_favorites.all().count()
assert choices.count() == user.actor.track_favorites.all().count()
for favorite in user.track_favorites.all():
for favorite in user.actor.track_favorites.all():
assert favorite.track in choices
for i in range(5):
@ -328,10 +328,10 @@ def test_can_start_artist_radio_from_api(logged_in_api_client, preferences, fact
def test_can_start_less_listened_radio(factories):
user = factories["users.User"]()
user = factories["users.User"](with_actor=True)
wrong_files = factories["music.Upload"].create_batch(5)
for f in wrong_files:
factories["history.Listening"](track=f.track, user=user)
factories["history.Listening"](track=f.track, actor=user.actor)
good_files = factories["music.Upload"].create_batch(5)
good_tracks = [f.track for f in good_files]
radio = radios.LessListenedRadio()
@ -350,10 +350,11 @@ def test_similar_radio_track(factories):
factories["music.Track"].create_batch(5)
# one user listened to this track
l1 = factories["history.Listening"](track=seed)
l1user = factories["users.User"](with_actor=True)
l1 = factories["history.Listening"](track=seed, actor=l1user.actor)
expected_next = factories["music.Track"]()
factories["history.Listening"](track=expected_next, user=l1.user)
factories["history.Listening"](track=expected_next, actor=l1.actor)
assert radio.pick(filter_playable=False) == expected_next

View File

@ -77,9 +77,9 @@ def test_session_radio_excludes_previous_picks_v2(factories, logged_in_api_clien
def test_can_get_choices_for_favorites_radio_v2(factories):
files = factories["music.Upload"].create_batch(10)
tracks = [f.track for f in files]
user = factories["users.User"]()
user = factories["users.User"](with_actor=True)
for i in range(5):
TrackFavorite.add(track=random.choice(tracks), user=user)
TrackFavorite.add(track=random.choice(tracks), actor=user.actor)
radio = radios_v2.FavoritesRadio()
session = radio.start_session(user=user)
@ -87,9 +87,9 @@ def test_can_get_choices_for_favorites_radio_v2(factories):
quantity=100, filter_playable=False
)
assert len(choices) == user.track_favorites.all().count()
assert len(choices) == user.actor.track_favorites.all().count()
for favorite in user.track_favorites.all():
for favorite in user.actor.track_favorites.all():
assert favorite.track in choices

View File

@ -308,7 +308,7 @@ def test_playlist_detail_serializer(factories):
def test_scrobble_serializer(factories):
upload = factories["music.Upload"]()
track = upload.track
user = factories["users.User"]()
user = factories["users.User"](with_actor=True)
payload = {"id": track.pk, "submission": True}
serializer = serializers.ScrobbleSerializer(data=payload, context={"user": user})
@ -316,7 +316,7 @@ def test_scrobble_serializer(factories):
listening = serializer.save()
assert listening.user == user
assert listening.actor.user == user
assert listening.track == track

View File

@ -344,6 +344,7 @@ def test_stream_transcode(
@pytest.mark.parametrize("f", ["json"])
def test_star(f, db, logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
url = reverse("api:subsonic:subsonic-star")
assert url.endswith("star") is True
track = factories["music.Track"]()
@ -352,30 +353,34 @@ def test_star(f, db, logged_in_api_client, factories):
assert response.status_code == 200
assert response.data == {"status": "ok"}
favorite = logged_in_api_client.user.track_favorites.latest("id")
favorite = logged_in_api_client.user.actor.track_favorites.latest("id")
assert favorite.track == track
@pytest.mark.parametrize("f", ["json"])
def test_unstar(f, db, logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
url = reverse("api:subsonic:subsonic-unstar")
assert url.endswith("unstar") is True
track = factories["music.Track"]()
factories["favorites.TrackFavorite"](track=track, user=logged_in_api_client.user)
factories["favorites.TrackFavorite"](
track=track, actor=logged_in_api_client.user.actor
)
response = logged_in_api_client.get(url, {"f": f, "id": track.pk})
assert response.status_code == 200
assert response.data == {"status": "ok"}
assert logged_in_api_client.user.track_favorites.count() == 0
assert logged_in_api_client.user.actor.track_favorites.count() == 0
@pytest.mark.parametrize("f", ["json"])
def test_get_starred2(f, db, logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
url = reverse("api:subsonic:subsonic-get_starred2")
assert url.endswith("getStarred2") is True
track = factories["music.Track"]()
favorite = factories["favorites.TrackFavorite"](
track=track, user=logged_in_api_client.user
track=track, actor=logged_in_api_client.user.actor
)
response = logged_in_api_client.get(url, {"f": f, "id": track.pk})
@ -432,11 +437,12 @@ def test_get_genres(f, db, logged_in_api_client, factories, mocker):
@pytest.mark.parametrize("f", ["json"])
def test_get_starred(f, db, logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
url = reverse("api:subsonic:subsonic-get_starred")
assert url.endswith("getStarred") is True
track = factories["music.Track"]()
favorite = factories["favorites.TrackFavorite"](
track=track, user=logged_in_api_client.user
track=track, actor=logged_in_api_client.user.actor
)
response = logged_in_api_client.get(url, {"f": f, "id": track.pk})
@ -639,6 +645,7 @@ def test_search3(f, db, logged_in_api_client, factories):
@pytest.mark.parametrize("f", ["json"])
def test_get_playlists(f, db, logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
url = reverse("api:subsonic:subsonic-get_playlists")
assert url.endswith("getPlaylists") is True
playlist1 = factories["playlists.PlaylistTrack"](
@ -670,6 +677,7 @@ def test_get_playlists(f, db, logged_in_api_client, factories):
@pytest.mark.parametrize("f", ["json"])
def test_get_playlist(f, db, logged_in_api_client, factories):
logged_in_api_client.user.create_actor()
url = reverse("api:subsonic:subsonic-get_playlist")
assert url.endswith("getPlaylist") is True
playlist = factories["playlists.PlaylistTrack"](
@ -844,6 +852,7 @@ def test_get_avatar(factories, logged_in_api_client):
def test_scrobble(factories, logged_in_api_client):
logged_in_api_client.user.create_actor()
upload = factories["music.Upload"]()
track = upload.track
url = reverse("api:subsonic:subsonic-scrobble")
@ -852,7 +861,7 @@ def test_scrobble(factories, logged_in_api_client):
assert response.status_code == 200
listening = logged_in_api_client.user.listenings.latest("id")
listening = logged_in_api_client.user.actor.listenings.latest("id")
assert listening.track == track

View File

@ -190,10 +190,10 @@ def test_can_request_password_reset(
def test_user_can_patch_his_own_settings(logged_in_api_client):
logged_in_api_client.user.create_actor()
user = logged_in_api_client.user
payload = {"privacy_level": "me"}
url = reverse("api:v1:users:users-detail", kwargs={"username": user.username})
response = logged_in_api_client.patch(url, payload)
assert response.status_code == 200

View File

@ -0,0 +1 @@
User follow with listening and track favorite activities (#1810)

View File

@ -1,5 +1,16 @@
# Funkwhale federation
```{toctree}
---
maxdepth: 1
---
frontend
copy
api
```
```{note}
We aim to keep this document up-to-date to reflect the current state of federation. If you notice an issue, please open a thread on [our forum](https://forum.funkwhale.audio/t/documentation).
```
@ -42,7 +53,7 @@ Funkwhale users are associated to an `Actor`. Remote and local actors are stord
### Activity creation and delivery
Any action carried out by a local actor should trigger an `Activity`. This is the equivalent to posting an activity to an object. Funkwhale creates an object with the activity payload and stores it in the `Activity` table. Funkwhale triggers 2 types of deliveries:
Actions carried out by a local actor can trigger an `Activity`. This is the equivalent to posting an activity to an object. Funkwhale creates an object with the activity payload and stores it in the `Activity` table. Funkwhale triggers 2 types of deliveries:
1. Local recipients: Funkwhale creates an `InboxItem` linked to the activity for each local recipient. Funkwhale then creates a feed of available inbox items for each local actor. Items in this feed have both a `Read` and `Unread` status to allow users to mark items as handled.
2. Remote recipients: Funkwhale collects the inboxes and shared inbox URLs of all remote recipients. Funkwhale then creates a `Delivery` object and linked to the initial activity and the inbox or shared inbox URL. The worker uses this `Delivery` object to post the activity to the correct inbox.
@ -69,583 +80,6 @@ You can query a pod's nodeinfo endpoint to return the ID of the service actor in
Funkwhale considers a pod's service actor to be an authoritative source for activities associated with **all** objects on its pod's domain. If the service actor sends an activity linked to an object on its domain, remote pods will recognize its authority.
## Supported activities
### Follow
A **follow** enables actors to access and retrieve content from other actors as soon as it updates.
::::{dropdown} Supported on
- [Library objects](#library)
::::
#### Internal logic
When Funkwhale receives a follow on a [library object](#library), it performs one of the following actions depending on the library's visibility:
- Automatically accept: If the library is public, Funkwhale automatically accepts the follow activity. Funkwhale sends a notification to the owner of the library and an [`Accept`](#accept) activity to the actor who sent the follow
- Accept request: If the library isn't public, Funkwhale sends a notification to the library owner. If the owner approves the request, Funkwhale sends an [`Accept`](#accept) activity to the actor who sent the follow
Funkwhale uses the library follow status to grant access to the actor who sent the follow request. If the library isn't public and the owner doesn't send an approval, the requesting actor can't access the library's content.
#### Checks
Funkwhale ensures the activity is being sent to the library's owner before handling it.
#### Example
In this example, **Alice** sends a follow activity for a [library object](#library) owned by **Bob**.
```{code-block} json
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"type": "Follow",
"id": "https://music.rocks/federation/actors/Alice#follows/99fc40d7-9bc8-4c4a-add1-f637339e1ded",
"actor": "https://music.rocks/federation/actors/Alice",
"to": ["https://awesome.music/federation/actors/Bob"],
"object": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6"
}
```
### Accept
The `Accept` activity sends a positive response, such as confirming a [`Follow` activity](#follow).
::::{dropdown} Supported on
- `Activity` objects
::::
#### Internal logic
When Funkwhale receives an `Accept` activity related to a [`Follow`](#follow) activity, it marks the `Follow` as accepted in the database. If the `Follow` activity relates to a [`Library` object](#library), the requester receives future activities associated with the library. This includes [`Create`](#create), [`Audio`](#audio), and [`Delete`](#delete) activities. They can also browse and download the library's audio files. See the section on [Audio fetching on restricted libraries](#audio-fetching-on-restricted-libraries) for more details.
#### Checks
Funkwhale ensures the activity is sent by the library's owner before handling it.
#### Example
In this example, **Bob** accepts a follow request from **Alice**.
```{code-block} json
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"type": "Accept",
"id": "https://music.rocks/federation/actors/Alice#follows/99fc40d7-9bc8-4c4a-add1-f637339e1ded/accept",
"to": ["https://music.rocks/federation/actors/Alice"],
"actor": "https://awesome.music/federation/actors/Bob",
"object": {
"id": "https://music.rocks/federation/actors/Alice#follows/99fc40d7-9bc8-4c4a-add1-f637339e1ded",
"type": "Follow",
"actor": "https://music.rocks/federation/actors/Alice",
"object": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6",
},
}
```
### Undo
::::{dropdown} Supported on
- [`Follow` objects](#follow)
::::
#### Internal logic
When Funkwhale receives an `Undo` activity, it deletes the corresponding `Follow` from the database.
#### Checks
Funkwhale ensures the request actor is the same actor who sent the `Follow` activity before handling it.
#### Example
In this example, **Alice** notifies **Bob** that she's undoing her follow.
```{code-block} json
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"type": "Undo",
"id": "https://music.rocks/federation/actors/Alice#follows/99fc40d7-9bc8-4c4a-add1-f637339e1ded/accept",
"to": ["https://awesome.music/federation/actors/Bob"],
"actor": "https://music.rocks/federation/actors/Alice",
"object": {
"id": "https://music.rocks/federation/actors/Alice#follows/99fc40d7-9bc8-4c4a-add1-f637339e1ded",
"type": "Follow",
"actor": "https://music.rocks/federation/actors/Alice",
"object": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6",
},
}
```
### Create
::::{dropdown} Supported on
- [`Audio` objects](#audio)
::::
#### Internal logic
```{note}
See [the `Audio` object reference](#audio) for details on the object's structure.
```
When Funkwhale receives a `Create` activity with an [`Audio` object](#audio), it persists a local upload in the database. It then associates the upload to related library and track information. If no track matches the audio metadata, Funkwhale creates on using the `metadata` attribute in the object.
#### Checks
Funkwhale ensures the activity actor and library owner are the same before handling the activity. If the associated library has no local followers, Funkwhale discards the activity.
#### Example
In this example, **Bob** creates new content in his library and sends a message to its followers.
```{code-block} json
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"to": [
"https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6/followers"
],
"type": "Create",
"actor": "https://awesome.music/federation/actors/Bob",
"object": {}
}
```
### Update
::::{dropdown} Supported on
- [`Library` objects](#library)
- [`Track` objects](#track)
::::
#### Internal logic
```{note}
See [the `Track` object reference](#track) and [`Library` object reference](#library) for details on the object's structure.
```
When Funkwhale receives an update associated with a [`Library`](#library) or [`Track`](#track) object, it attempts to update the corresponding object in its database.
#### Checks
Funkwhale performs different checks depending on the target of the update:
- For [`Library`](#library) objects, Funkwhale ensures the actor sending the message is the library owner
- For [`Track`](#track) objects, Funkwhale ensures the actor sending the message **either**:
- Matches the [`attributedTo`](#attributedto) property on the local copy of the object
- Is the [service actor](#service-actor)
#### Example
In this example, **Bob** updates his library and sends a message to its followers.
```{code-block} json
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"to": [
"https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6/followers"
],
"type": "Update",
"actor": "https://awesome.music/federation/actors/Bob",
"object": {}
}
```
### Delete
::::{dropdown} Supported on
- [`Audio` objects](#audio)
- [`Library` objects](#library)
::::
#### Internal logic
When Funkwhale receives a `Delete` activity, it deletes the associated object from the database.
#### Checks
Funkwhale ensures the actor initiating the activity is the owner of the associated object before handling it.
#### Example
::::{tab-set}
:::{tab-item} Library
In this example, **Bob** deletes a library and notifies its followers.
```{code-block} json
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"type": "Delete",
"to": [
"https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6/followers"
],
"actor": "https://awesome.music/federation/actors/Bob",
"object": {
"type": "Library",
"id": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6"
}
}
```
:::
:::{tab-item} Audio
In this example, **Bob** deletes three audio objects in a library and notifies the library's followers.
```{code-block} json
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"type": "Delete",
"to": [
"https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6/followers"
],
"actor": "https://awesome.music/federation/actors/Bob",
"object": {
"type": "Audio",
"id": [
"https://awesome.music/federation/music/uploads/19420073-3572-48a9-8c6c-b385ee1b7905",
"https://awesome.music/federation/music/uploads/11d99680-23c6-4f72-997a-073b980ab204",
"https://awesome.music/federation/music/uploads/1efadc1c-a704-4b8a-a71a-b288b1d1f423"
]
}
}
```
:::
::::
## Supported objects
### Artist
An `Artist` is a custom object used to store musical artist and podcast creator information.
#### Properties
```{list-table}
:header-rows: 1
* - Property
- Data type
- Description
* - `type`*
- String
- The object type (`Artist`)
* - `id`*
- String (URI)
- A URI that identifies the artist over federation
* - `name`*
- String
- The artist's name
* - `published`*
- Datetime
- The date on which the artist was published over the federation
* - `musicbrainzId`
- String (UUID)
- The Musicbrainz artist ID
```
#### Example
```{code-block} json
{
"type": "Artist",
"id": "https://awesome.music/federation/music/artists/73c32807-a199-4682-8068-e967f734a320",
"name": "Metallica",
"published": "2018-04-08T12:19:05.920415+00:00",
"musicbrainzId": "65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab"
}
```
### Album
An `Album` is a custom object used to store album and podcast series information.
#### Properties
```{list-table}
:header-rows: 1
* - Property
- Data type
- Description
* - `type`*
- String
- The object type (`Album`)
* - `id`*
- String (URI)
- A URI that identifies the album over federation
* - `name`*
- String
- The album's title
* - `artists`
- Array of strings
- A list of [`Artist` objects](#artist) associated with the albums
* - `published`*
- Datetime
- The date on which the artist was published over the federation
* - `released`
- Datetime
- The date on which the album was released
* - `musicbrainzId`
- String (UUID)
- The Musicbrainz release ID
* - `cover`
- [`Link` object](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-link)
- A `Link` object representing the album cover
```
#### Example
```{code-block} json
{
"type": "Album",
"id": "https://awesome.music/federation/music/albums/69d488b5-fdf6-4803-b47c-9bb7098ea57e",
"name": "Ride the Lightning",
"released": "1984-01-01",
"published": "2018-10-02T19:49:17.412546+00:00",
"musicbrainzId": "589ff96d-0be8-3f82-bdd2-299592e51b40",
"cover": {
"href": "https://awesome.music/media/albums/covers/2018/10/02/b69d398b5-fdf6-4803-b47c-9bb7098ea57e.jpg",
"type": "Link",
"mediaType": "image/jpeg"
},
"artists": [
{}
]
}
```
### Track
A `Track` is a custom object used to store track information.
#### Properties
```{list-table}
:header-rows: 1
* - Property
- Data type
- Description
* - `type`*
- String
- The object type (`Track`)
* - `id`*
- String (URI)
- A URI that identifies the track over federation
* - `name`*
- String
- The track title
* - `position`*
- Integer
- The position of the track in the album
* - `published`*
- Datetime
- The date on which the track was published over the federation
* - `musicbrainzId`
- String (UUID)
- The Musicbrainz recording ID
* - `album`
- [`Album` object](#album)
- The album that contains the track
* - `artists`
- Array of [`Artist` objects](#artist)
- A list of artists associated to the track. This can differ from the album artists
```
#### Example
```{code-block} json
{
"type": "Track",
"id": "https://awesome.music/federation/music/tracks/82ece296-6397-4e26-be90-bac5f9990240",
"name": "For Whom the Bell Tolls",
"position": 3,
"published": "2018-10-02T19:49:35.822537+00:00",
"musicbrainzId": "771ab043-8821-44f9-b8e0-2733c3126c6d",
"artists": [
{}
],
"album": {}
}
```
### Library
```{note}
Crawling library pages requires authentication and an approved follow unless the library is public.
```
A `Library` is a custom object used to store music collection information. It inherits its behavior and properties from ActivityPub's [`Actor`](https://www.w3.org/TR/activitypub/#actors) and [`Collection`](https://www.w3.org/TR/activitypub/#collections) objects.
#### Properties
```{list-table}
:header-rows: 1
* - Property
- Data type
- Description
* - `type`*
- String
- The object type (`Library`)
* - `id`*
- String (URI)
- A URI that identifies the library over federation
* - `name`*
- String
- The library's name
* - `followers`*
- String (URI)
- The ID of the library's followers collection
* - `totalItems`*
- Integer
- The number of [`Audio` objects](#audio) in the library
* - `first`*
- String (URI)
- The URL of the library's first page
* - `last`*
- String (URI)
- The URL of the library's last page
* - `summary`
- String
- The library's description
```
#### Example
```{code-block} json
{
"type": "Library",
"id": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6",
"attributedTo": "https://awesome.music/federation/actors/Alice",
"name": "My awesome library",
"followers": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6/followers",
"summary": "This library is for restricted use only",
"totalItems": 4234,
"first": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6?page=1",
"last": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6?page=56",
}
```
### Audio
```{note}
Accessing audio files requires authentication and an approved follow for the containing library unless the library is public.
```
An `Audio` object is a custom object used to store upload information. It extends the [ActivityStreams Audio object](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audio) with custom attributes.
#### Properties
```{list-table}
:header-rows: 1
* - Property
- Data type
- Description
* - `type`*
- String
- The object type (`Audio`)
* - `id`*
- String (URI)
- A URI that identifies the audio over federation
* - `name`*
- String
- A readable title for the order. Funkwhale concatenates the track name, album title, and artist name
* - `size`*
- Integer
- The size of the audio in bytes
* - `bitrate`*
- Integer
- The bitrate of the audio in bytes/s
* - `duration`*
- Integer
- The duration of the audio in seconds
* - `library`*
- String (URI)
- The ID of the audio's containing [`Library` object](#library)
* - `published`*
- Datetime
- The date on which the audio was published over the federation
* - `updated`*
- Datetime
- The date on which the audio was last updated over the federation
* - `url`*
- [`Link` object](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-link)
- A `Link` object object containing the download location of the audio file
* - `track`
- [`Track` object](#track)
- The track associated with the audio file
```
#### Example
```{code-block} json
{
"type": "Audio",
"id": "https://awesome.music/federation/music/uploads/88f0bc20-d7fd-461d-a641-dd9ac485e096",
"name": "For Whom the Bell Tolls - Ride the Lightning - Metallica",
"size": 8656581,
"bitrate": 320000,
"duration": 213,
"library": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6",
"updated": "2018-10-02T19:49:35.646372+00:00",
"published": "2018-10-02T19:49:35.646359+00:00",
"track": {},
"url": {
"href": "https://awesome.music/api/v1/listen/82ece296-6397-4e26-be90-bac5f9990240/?upload=88f0bc20-d7fd-461d-a641-dd9ac485e096",
"type": "Link",
"mediaType": "audio/mpeg"
}
}
```
## Audio fetching on restricted libraries
[`Library` objects](#library) and [`Audio` objects] are subject to the following access rules:

View File

@ -0,0 +1,690 @@
# Activities and Objects
```{contents}
:local:
:depth: 2
```
## Supported activities
### Follow
A **follow** enables actors to access and retrieve content from other actors as soon as it updates.
::::{dropdown} Supported on
- [Library objects](#library)
- [Actor](https://www.w3.org/TR/activitypub/#actors)
::::
#### Internal logic
When Funkwhale receives a follow on a [library object](#library), it performs one of the following actions depending on the library's visibility:
- Automatically accept: If the library is public, Funkwhale automatically accepts the follow activity. Funkwhale sends a notification to the owner of the library and an [`Accept`](#accept) activity to the actor who sent the follow
- Accept request: If the library isn't public, Funkwhale sends a notification to the library owner. If the owner approves the request, Funkwhale sends an [`Accept`](#accept) activity to the actor who sent the follow
Funkwhale uses the library follow status to grant access to the actor who sent the follow request. If the library isn't public and the owner doesn't send an approval, the requesting actor can't access the library's content.
For User's actors the logic is the same but we use the privacy_level of the user instead of the public to automaically accept the Follow.
#### Checks
Funkwhale ensures the activity is being sent to the library's owner before handling it.
#### Example
In this example, **Alice** sends a follow activity for a [library object](#library) owned by **Bob**.
```{code-block} json
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"type": "Follow",
"id": "https://music.rocks/federation/actors/Alice#follows/99fc40d7-9bc8-4c4a-add1-f637339e1ded",
"actor": "https://music.rocks/federation/actors/Alice",
"to": ["https://awesome.music/federation/actors/Bob"],
"object": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6"
}
```
#### Supported Activities
Activities sent to followings users (or to the world if the account is set to public) are :
- [Listen](#listen)
- [Likes](#like)
### Accept
The `Accept` activity sends a positive response, such as confirming a [`Follow` activity](#follow).
::::{dropdown} Supported on
- `Activity` objects
::::
#### Internal logic
When Funkwhale receives an `Accept` activity related to a [`Follow`](#follow) activity, it marks the `Follow` as accepted in the database. If the `Follow` activity relates to a [`Library` object](#library), the requester receives future activities associated with the library. This includes [`Create`](#create), [`Audio`](#audio), and [`Delete`](#delete) activities. They can also browse and download the library's audio files. See the section on [Audio fetching on restricted libraries](./index.md#audio-fetching-on-restricted-libraries) for more details.
#### Checks
Funkwhale ensures the activity is sent by the library's owner before handling it.
#### Example
In this example, **Bob** accepts a follow request from **Alice**.
```{code-block} json
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"type": "Accept",
"id": "https://music.rocks/federation/actors/Alice#follows/99fc40d7-9bc8-4c4a-add1-f637339e1ded/accept",
"to": ["https://music.rocks/federation/actors/Alice"],
"actor": "https://awesome.music/federation/actors/Bob",
"object": {
"id": "https://music.rocks/federation/actors/Alice#follows/99fc40d7-9bc8-4c4a-add1-f637339e1ded",
"type": "Follow",
"actor": "https://music.rocks/federation/actors/Alice",
"object": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6",
},
}
```
### Undo
::::{dropdown} Supported on
- [`Follow` objects](#follow)
::::
#### Internal logic
When Funkwhale receives an `Undo` activity, it deletes the corresponding `Follow` from the database.
#### Checks
Funkwhale ensures the request actor is the same actor who sent the `Follow` activity before handling it.
#### Example
In this example, **Alice** notifies **Bob** that she's undoing her follow.
```{code-block} json
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"type": "Undo",
"id": "https://music.rocks/federation/actors/Alice#follows/99fc40d7-9bc8-4c4a-add1-f637339e1ded/accept",
"to": ["https://awesome.music/federation/actors/Bob"],
"actor": "https://music.rocks/federation/actors/Alice",
"object": {
"id": "https://music.rocks/federation/actors/Alice#follows/99fc40d7-9bc8-4c4a-add1-f637339e1ded",
"type": "Follow",
"actor": "https://music.rocks/federation/actors/Alice",
"object": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6",
},
}
```
### Create
::::{dropdown} Supported on
- [`Audio` objects](#audio)
::::
#### Internal logic
```{note}
See [the `Audio` object reference](#audio) for details on the object's structure.
```
When Funkwhale receives a `Create` activity with an [`Audio` object](#audio), it persists a local upload in the database. It then associates the upload to related library and track information. If no track matches the audio metadata, Funkwhale creates on using the `metadata` attribute in the object.
#### Checks
Funkwhale ensures the activity actor and library owner are the same before handling the activity. If the associated library has no local followers, Funkwhale discards the activity.
#### Example
In this example, **Bob** creates new content in his library and sends a message to its followers.
```{code-block} json
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"to": [
"https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6/followers"
],
"type": "Create",
"actor": "https://awesome.music/federation/actors/Bob",
"object": {}
}
```
### Update
::::{dropdown} Supported on
- [`Library` objects](#library)
- [`Track` objects](#track)
::::
#### Internal logic
```{note}
See [the `Track` object reference](#track) and [`Library` object reference](#library) for details on the object's structure.
```
When Funkwhale receives an update associated with a [`Library`](#library) or [`Track`](#track) object, it attempts to update the corresponding object in its database.
#### Checks
Funkwhale performs different checks depending on the target of the update:
- For [`Library`](#library) objects, Funkwhale ensures the actor sending the message is the library owner
- For [`Track`](#track) objects, Funkwhale ensures the actor sending the message **either**:
- Matches the [`attributedTo`](./index.md#attributedto) property on the local copy of the object
- Is the [service actor](./index.md#service-actor)
#### Example
In this example, **Bob** updates his library and sends a message to its followers.
```{code-block} json
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"to": [
"https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6/followers"
],
"type": "Update",
"actor": "https://awesome.music/federation/actors/Bob",
"object": {}
}
```
### Delete
::::{dropdown} Supported on
- [`Audio` objects](#audio)
- [`Library` objects](#library)
- [`Listen` activity](#listen)
::::
#### Internal logic
When Funkwhale receives a `Delete` activity, it deletes the associated object from the database.
#### Checks
Funkwhale ensures the actor initiating the activity is the owner of the associated object before handling it.
#### Example
::::{tab-set}
:::{tab-item} Library
In this example, **Bob** deletes a library and notifies its followers.
```{code-block} json
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"type": "Delete",
"to": [
"https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6/followers"
],
"actor": "https://awesome.music/federation/actors/Bob",
"object": {
"type": "Library",
"id": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6"
}
}
```
:::
:::{tab-item} Audio
In this example, **Bob** deletes three audio objects in a library and notifies the library's followers.
```{code-block} json
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{}
],
"type": "Delete",
"to": [
"https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6/followers"
],
"actor": "https://awesome.music/federation/actors/Bob",
"object": {
"type": "Audio",
"id": [
"https://awesome.music/federation/music/uploads/19420073-3572-48a9-8c6c-b385ee1b7905",
"https://awesome.music/federation/music/uploads/11d99680-23c6-4f72-997a-073b980ab204",
"https://awesome.music/federation/music/uploads/1efadc1c-a704-4b8a-a71a-b288b1d1f423"
]
}
}
```
:::
::::
### Like
We send the [Like](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-like) object with its type : this allow proper routing and avoid useless database and network queries.
::::{dropdown} Supported on
- [`Track` objects](#track)
::::
#### Example
```{code-block} json
{
"type": "Like",
"id": "https://burn.patriachy//7b54d361-c513-4756-a085-13f97573237b",
""object": {
"Type": "Track",
"id": "https://Le_Rn.areRacists//aebd2be4-49a1-4ef5-aadf-27bff1001d4d",
},
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
"https://funkwhale.audio/ns",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"Hashtag": "as:Hashtag",
},
],
}
```
### Dislike
We send the [Dislike](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-dislike) object with its type : this allow proper routing and avoid useless database and network queries.
::::{dropdown} Supported on
- [`Track` objects](#track)
::::
#### Example
```{code-block} json
{
"type": "Dislike",
"id": "https://burn.patriachy//7b54d361-c513-4756-a085-13f97573237b",
""object": {
"Type": "Track",
"id": "https://Le_Rn.areRacists//aebd2be4-49a1-4ef5-aadf-27bff1001d4d",
},
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
"https://funkwhale.audio/ns",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"Hashtag": "as:Hashtag",
},
],
}
```
### Listen
We send the [Listen](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-listen) object with its type : this allow proper routing and avoid useless database and network queries.
::::{dropdown} Supported on
- [`Track` objects](#track)
::::
#### Example
```{code-block} json
{
"type": "Listen",
"id": "https://makerichpeoplepay.forclimatechange//5355f8b7-bbcc-4285-9c13-d21748084cc1",
"object": {
"Type": "Track",
"id": "https://Le_Rn.areRacists//aebd2be4-49a1-4ef5-aadf-27bff1001d4d",
},
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
"https://funkwhale.audio/ns",
{
"manuallyApprovesFollowers": "as:manuallyApprovesFollowers",
"Hashtag": "as:Hashtag",
},
],
}
```
## Supported objects
### Artist
An `Artist` is a custom object used to store musical artist and podcast creator information.
#### Properties
```{list-table}
:header-rows: 1
* - Property
- Data type
- Description
* - `type`*
- String
- The object type (`Artist`)
* - `id`*
- String (URI)
- A URI that identifies the artist over federation
* - `name`*
- String
- The artist's name
* - `published`*
- Datetime
- The date on which the artist was published over the federation
* - `musicbrainzId`
- String (UUID)
- The Musicbrainz artist ID
```
#### Example
```{code-block} json
{
"type": "Artist",
"id": "https://awesome.music/federation/music/artists/73c32807-a199-4682-8068-e967f734a320",
"name": "Metallica",
"published": "2018-04-08T12:19:05.920415+00:00",
"musicbrainzId": "65f4f0c5-ef9e-490c-aee3-909e7ae6b2ab"
}
```
### Album
An `Album` is a custom object used to store album and podcast series information.
#### Properties
```{list-table}
:header-rows: 1
* - Property
- Data type
- Description
* - `type`*
- String
- The object type (`Album`)
* - `id`*
- String (URI)
- A URI that identifies the album over federation
* - `name`*
- String
- The album's title
* - `artists`
- Array of strings
- A list of [`Artist` objects](#artist) associated with the albums
* - `published`*
- Datetime
- The date on which the artist was published over the federation
* - `released`
- Datetime
- The date on which the album was released
* - `musicbrainzId`
- String (UUID)
- The Musicbrainz release ID
* - `cover`
- [`Link` object](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-link)
- A `Link` object representing the album cover
```
#### Example
```{code-block} json
{
"type": "Album",
"id": "https://awesome.music/federation/music/albums/69d488b5-fdf6-4803-b47c-9bb7098ea57e",
"name": "Ride the Lightning",
"released": "1984-01-01",
"published": "2018-10-02T19:49:17.412546+00:00",
"musicbrainzId": "589ff96d-0be8-3f82-bdd2-299592e51b40",
"cover": {
"href": "https://awesome.music/media/albums/covers/2018/10/02/b69d398b5-fdf6-4803-b47c-9bb7098ea57e.jpg",
"type": "Link",
"mediaType": "image/jpeg"
},
"artists": [
{}
]
}
```
### Track
A `Track` is a custom object used to store track information.
#### Properties
```{list-table}
:header-rows: 1
* - Property
- Data type
- Description
* - `type`*
- String
- The object type (`Track`)
* - `id`*
- String (URI)
- A URI that identifies the track over federation
* - `name`*
- String
- The track title
* - `position`*
- Integer
- The position of the track in the album
* - `published`*
- Datetime
- The date on which the track was published over the federation
* - `musicbrainzId`
- String (UUID)
- The Musicbrainz recording ID
* - `album`
- [`Album` object](#album)
- The album that contains the track
* - `artists`
- Array of [`Artist` objects](#artist)
- A list of artists associated to the track. This can differ from the album artists
```
#### Example
```{code-block} json
{
"type": "Track",
"id": "https://awesome.music/federation/music/tracks/82ece296-6397-4e26-be90-bac5f9990240",
"name": "For Whom the Bell Tolls",
"position": 3,
"published": "2018-10-02T19:49:35.822537+00:00",
"musicbrainzId": "771ab043-8821-44f9-b8e0-2733c3126c6d",
"artists": [
{}
],
"album": {}
}
```
### Library
```{note}
Crawling library pages requires authentication and an approved follow unless the library is public.
```
A `Library` is a custom object used to store music collection information. It inherits its behavior and properties from ActivityPub's [`Actor`](https://www.w3.org/TR/activitypub/#actors) and [`Collection`](https://www.w3.org/TR/activitypub/#collections) objects.
#### Properties
```{list-table}
:header-rows: 1
* - Property
- Data type
- Description
* - `type`*
- String
- The object type (`Library`)
* - `id`*
- String (URI)
- A URI that identifies the library over federation
* - `name`*
- String
- The library's name
* - `followers`*
- String (URI)
- The ID of the library's followers collection
* - `totalItems`*
- Integer
- The number of [`Audio` objects](#audio) in the library
* - `first`*
- String (URI)
- The URL of the library's first page
* - `last`*
- String (URI)
- The URL of the library's last page
* - `summary`
- String
- The library's description
```
#### Example
```{code-block} json
{
"type": "Library",
"id": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6",
"attributedTo": "https://awesome.music/federation/actors/Alice",
"name": "My awesome library",
"followers": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6/followers",
"summary": "This library is for restricted use only",
"totalItems": 4234,
"first": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6?page=1",
"last": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6?page=56",
}
```
### Audio
```{note}
Accessing audio files requires authentication and an approved follow for the containing library unless the library is public.
```
An `Audio` object is a custom object used to store upload information. It extends the [ActivityStreams Audio object](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-audio) with custom attributes.
#### Properties
```{list-table}
:header-rows: 1
* - Property
- Data type
- Description
* - `type`*
- String
- The object type (`Audio`)
* - `id`*
- String (URI)
- A URI that identifies the audio over federation
* - `name`*
- String
- A readable title for the order. Funkwhale concatenates the track name, album title, and artist name
* - `size`*
- Integer
- The size of the audio in bytes
* - `bitrate`*
- Integer
- The bitrate of the audio in bytes/s
* - `duration`*
- Integer
- The duration of the audio in seconds
* - `library`*
- String (URI)
- The ID of the audio's containing [`Library` object](#library)
* - `published`*
- Datetime
- The date on which the audio was published over the federation
* - `updated`*
- Datetime
- The date on which the audio was last updated over the federation
* - `url`*
- [`Link` object](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-link)
- A `Link` object object containing the download location of the audio file
* - `track`
- [`Track` object](#track)
- The track associated with the audio file
```
#### Example
```{code-block} json
{
"type": "Audio",
"id": "https://awesome.music/federation/music/uploads/88f0bc20-d7fd-461d-a641-dd9ac485e096",
"name": "For Whom the Bell Tolls - Ride the Lightning - Metallica",
"size": 8656581,
"bitrate": 320000,
"duration": 213,
"library": "https://awesome.music/federation/music/libraries/dc702491-f6ce-441b-9da0-cecbed08bcc6",
"updated": "2018-10-02T19:49:35.646372+00:00",
"published": "2018-10-02T19:49:35.646359+00:00",
"track": {},
"url": {
"href": "https://awesome.music/api/v1/listen/82ece296-6397-4e26-be90-bac5f9990240/?upload=88f0bc20-d7fd-461d-a641-dd9ac485e096",
"type": "Link",
"mediaType": "audio/mpeg"
}
}
```

View File

@ -257,6 +257,25 @@ Review the configuration:
docker compose config
```
### Set up local data for development
You can create local data to mimic a live environment.
Add some fake data to populate the database. The following command creates 25 artists with random albums, tracks, and metadata.
```sh
artists=25 # Adds 25 fake artists
command="from funkwhale_api.music import fake_data; fake_data.create_data($artists)"
echo $command | docker compose run --rm -T api funkwhale-manage shell -i python
```
This will launch a development funkwhale instance with a super user having `COMPOSE_PROJECT_NAME` as username and `funkwhale` as password. Libraries, listenings and music data will be associated with the superuser :
```sh
export COMPOSE_PROJECT_NAME=node2 ; export VUE_PORT=8882 ; docker compose run --rm api funkwhale-manage migrate ; echo "from funkwhale_api.music import fake_data; fake_data.create_data(super_user_name=\"$COMPOSE_PROJECT_NAME\")" | docker compose run --rm -T api funkwhale-manage shell -i python ; docker compose up
```
### Lifecycle
Recycle individual containers:

View File

@ -33,6 +33,7 @@ Funkwhale users broadcast the following activity when using the software:
1. **Favoriting** content
2. **Listening** to content
3. **Playlist** creation and update
Users across the federated web should be able to follow Funkwhale accounts to receive this activity in their streams.
@ -50,7 +51,7 @@ This specification outlines the workflows for the following actions for **local*
1. User discovery
2. User follows
3. User unfollows
4. User blocking
4. User blocking (#1456)
### User discovery
@ -119,7 +120,7 @@ Following a user is a process by which a **requesting user** subscribes to the a
Follow requests should be handled by an endpoint using a `POST` request. This request must immediately return a status message to the client.
```text
POST /api/v2/users/{id}/follow
POST api/v1/federation/follows/user/ -d '{"target":"https://node1.funkwhale.test/federation/actors/{id}"}'
```
When the server receives a `follow` request, it creates a `follow_request` object containing the status of the follow request which is used to display request information to the target user in their notifications.
@ -178,7 +179,7 @@ Following a user is a process by which a **requesting user** unsubscribes from t
Follow requests should be handled by an endpoint using a `POST` request. This request must immediately return a status message to the client.
```text
POST /api/v2/users/{id}/unfollow
DELETE api/v1/federation/follows/user/{id}
```
#### ActivityPub behavior
@ -203,56 +204,6 @@ sequenceDiagram
When a **requesting user** unfollows a **target user**, the UI must update to visually indicate that the action has succeeded. All activities relating to the **target user** must be visually hidden.
### Blocking users
When one user blocks another, no information may be shared between them. Blocking is a unilateral action that can be taken by both **requesting** and **target** actors to prevent the other from interacting with them.
#### API behavior
Block requests should be handled by an endpoint using a `POST` request. This request must immediately return a status message to the client.
```text
POST /api/v2/users/{id}/block
```
#### ActivityPub behavior
If the the **blocked user** is on a different server to the **blocking user**, the request is handled using the [ActivityPub `Block` activity][block] with the **blocked user's** [`Actor`][actor] as a target.
1. A [`Block` activity][block] is posted to the **blocking user's** [outbox collection][outbox] with the **blocked user's** [`Actor`][actor] the target
- If the **blocked user** was previously in the **blocking user's** [following collection][following], they are removed
- If the **blocked user** was previously in the **blocking user's** [followers collection][followers], they are removed
:::{warning}
As noted in the ActivityPub spec, the **blocked user** must _not_ be informed of the `Block` activity.
:::
#### Web app behavior
When a **blocking user** blocks a **blocked user**, the UI must update to visually indicate that the action has succeeded. All activities relating to the **blocked user** must be visually hidden.
If a **blocking user** navigates to the profile of a **blocked user** who has blocked them, the UI _must not_ reflect that they are blocked. The **blocking user** must be able to send a follow request which is _not_ sent to the **blocked user**.
### Unblocking users
**Blocking users** can unilaterally reverse blocks they have imposed on **blocked users**. This enables them to request to follow the **blocked user's** activities again.
#### API behavior
Unblock requests should be handled by an endpoint using a `POST` request. This request must immediately return a status message to the client.
```text
POST /api/v2/users/{id}/unblock
```
#### ActivityPub behavior
If the **blocked user** is on a different server to the **blocking user**, the request is handled using the [ActivityPub `Undo` activity][undo].
#### Web app behavior
When a **blocking user** unblocks a **blocked user**, the UI must update to visually indicate that the action has succeeded. The **Follow** button must become active and interactive again.
## Availability
- [x] App frontend

View File

@ -174,9 +174,9 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
>
<router-link
class="left floated"
:to="{name: 'profile.overview', params: {username: object.user.username}}"
:to="{name: 'profile.overview', params: {username: object.actor.name}}"
>
<span class="at symbol" />{{ object.user.username }}
<span class="at symbol" />{{ object.actor.name }}
</router-link>
<span class="right floated"><human-date :date="object.creation_date" /></span>
</div>

View File

@ -0,0 +1,51 @@
<script setup lang="ts">
import type { Actor } from '~/types'
import { computed } from 'vue'
import { useStore } from '~/store'
interface Events {
(e: 'unfollowed'): void
(e: 'followed'): void
}
interface Props {
actor: Actor
}
const emit = defineEmits<Events>()
const props = defineProps<Props>()
const store = useStore()
const follow = computed(() => store.getters['users/follow'](props.actor.fid))
const isPending = computed(() => follow.value && follow.value.approved === null)
const isApproved = computed(() => follow.value && follow.value.approved === true)
const toggle = () => {
if (isPending.value || isApproved.value) {
emit('unfollowed')
} else {
emit('followed')
}
return store.dispatch('users/toggle', props.actor.fid)
}
</script>
<template>
<button
:class="['ui', 'pink', {'inverted': isApproved || isPending}, {'favorited': isApproved}, 'icon', 'labeled', 'button']"
@click.stop="toggle"
>
<i class="heart icon" />
<span v-if="isApproved">
{{ $t('components.audio.LibraryFollowButton.button.unfollow') }}
</span>
<span v-else-if="isPending">
{{ $t('components.audio.LibraryFollowButton.button.cancel') }}
</span>
<span v-else>
{{ $t('components.audio.LibraryFollowButton.button.follow') }}
</span>
</button>
</template>

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Notification, LibraryFollow } from '~/types'
import type { Notification, LibraryFollow, UserFollow } from '~/types'
import { computed, ref, watchEffect, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@ -29,7 +29,9 @@ const notificationData = computed(() => {
const activity = props.initialItem.activity
if (activity.type === 'Follow') {
if (activity.object && activity.object.type === 'music.Library') {
if (activity.type === 'Follow' && activity.object?.type === 'music.Library') {
const libraryFollow = activity.related_object as LibraryFollow
const detailUrl = { name: 'library.detail.edit', params: { id: activity.object.uuid } }
if (activity.related_object?.approved === null) {
@ -40,13 +42,13 @@ const notificationData = computed(() => {
buttonClass: 'success',
icon: 'check',
label: t('components.notifications.NotificationRow.button.approve'),
handler: () => approveLibraryFollow(activity.related_object)
handler: () => approveLibraryFollow(libraryFollow)
},
rejectFollow: {
buttonClass: 'danger',
icon: 'x',
label: t('components.notifications.NotificationRow.button.reject'),
handler: () => rejectLibraryFollow(activity.related_object)
handler: () => rejectLibraryFollow(libraryFollow)
}
}
} else if (activity.related_object?.approved) {
@ -61,6 +63,39 @@ const notificationData = computed(() => {
message: t('components.notifications.NotificationRow.message.libraryReject', { username: username.value, library: activity.object.name })
}
}
if (activity.object && activity.object.type === 'federation.Actor') {
const userFollow = activity.related_object as UserFollow
const detailUrl = { name: 'profile.full', params: { username: activity.actor.preferred_username, domain: activity.actor.domain } }
if (activity.related_object?.approved === null) {
return {
detailUrl,
message: t('components.notifications.NotificationRow.message.userPendingFollow', { username: username.value, user: activity.object.target?.full_username }),
acceptFollow: {
buttonClass: 'success',
icon: 'check',
label: t('components.notifications.NotificationRow.button.approve'),
handler: () => approveUserFollow(userFollow)
},
rejectFollow: {
buttonClass: 'danger',
icon: 'x',
label: t('components.notifications.NotificationRow.button.reject'),
handler: () => rejectUserFollow(userFollow)
}
}
} else if (activity.related_object?.approved) {
return {
detailUrl,
message: t('components.notifications.NotificationRow.message.userFollow', { username: username.value, user: activity.actor.full_username })
}
}
return {
detailUrl,
message: t('components.notifications.NotificationRow.message.userReject', { username: username.value, user: activity.actor.full_username })
}
}
}
if (activity.type === 'Accept') {
@ -70,6 +105,12 @@ const notificationData = computed(() => {
message: t('components.notifications.NotificationRow.message.libraryAcceptFollow', { username: username.value, library: activity.related_object.name })
}
}
if (activity.object?.type === 'federation.Actor') {
return {
detailUrl: { name: 'content.remote.index' },
message: t('components.notifications.NotificationRow.message.userAcceptFollow', { username: username.value, user: activity.actor.full_username })
}
}
}
return {}
@ -100,6 +141,18 @@ const rejectLibraryFollow = async (follow: LibraryFollow) => {
follow.approved = false
item.value.is_read = true
}
const approveUserFollow = async (follow: UserFollow) => {
await axios.post(`federation/follows/user/${follow.uuid}/accept/`)
follow.approved = true
item.value.is_read = true
}
const rejectUserFollow = async (follow: UserFollow) => {
await axios.post(`federation/follows/user/${follow.uuid}/reject/`)
follow.approved = false
item.value.is_read = true
}
</script>
<template>

View File

@ -2781,7 +2781,11 @@
"libraryAcceptFollow": "{username} accepted your follow on library \"{library}\"",
"libraryFollow": "{username} followed your library \"{library}\"",
"libraryPendingFollow": "{username} wants to follow your library \"{library}\"",
"libraryReject": "You rejected {username}'s request to follow \"{library}\""
"libraryReject": "You rejected {username}'s request to follow \"{library}\"",
"userAcceptFollow": "{username} accepted your follow",
"userFollow": "{username} followed you",
"userPendingFollow": "{username} wants to follow you",
"userReject": "You rejected {username}'s request to follow you"
}
}
},

View File

@ -2781,7 +2781,11 @@
"libraryAcceptFollow": "{username} accepted your follow on library \"{library}\"",
"libraryFollow": "{username} followed your library \"{library}\"",
"libraryPendingFollow": "{username} wants to follow your library \"{library}\"",
"libraryReject": "You rejected {username}'s request to follow \"{library}\""
"libraryReject": "You rejected {username}'s request to follow \"{library}\"",
"userAcceptFollow": "{username} accepted your follow",
"userFollow": "{username} followed you",
"userPendingFollow": "{username} wants to follow you",
"userReject": "You rejected {username}'s request to follow you"
}
}
},

View File

@ -222,6 +222,7 @@ const store: Module<State, RootState> = {
dispatch('favorites/fetch', null, { root: true }),
dispatch('playlists/fetchOwn', null, { root: true }),
dispatch('libraries/fetchFollows', null, { root: true }),
dispatch('users/fetchFollows', null, { root: true }),
dispatch('channels/fetchSubscriptions', null, { root: true }),
dispatch('moderation/fetchContentFilters', null, { root: true })
])

View File

@ -7,6 +7,8 @@ import type { State as InstanceState } from './instance'
import type { State as RadiosState } from './radios'
import type { State as AuthState } from './auth'
import type { State as UiState } from './ui'
import type { State as Users } from './users'
import type { InjectionKey } from 'vue'
import { createStore, Store, useStore as baseUseStore } from 'vuex'
@ -21,7 +23,7 @@ import instance from './instance'
import radios from './radios'
import auth from './auth'
import ui from './ui'
import users from './users'
export interface RootState {
moderation: ModerationState
playlists: PlaylistsState
@ -32,6 +34,7 @@ export interface RootState {
radios: RadiosState
auth: AuthState
ui: UiState
users: Users
}
export const key: InjectionKey<Store<RootState>> = Symbol('vuex state injection key')
@ -45,7 +48,8 @@ export default createStore<RootState>({
instance,
radios,
auth,
ui
ui,
users
},
plugins: [
createPersistedState({

78
front/src/store/users.ts Normal file
View File

@ -0,0 +1,78 @@
import type { Module } from 'vuex'
import type { RootState } from '~/store/index'
import axios from 'axios'
import useLogger from '~/composables/useLogger'
export interface State {
followsByActor: {
[key: string]: Follow
}
count: number
}
interface Follow {
uuid: string
}
const logger = useLogger()
const store: Module<State, RootState> = {
namespaced: true,
state: {
followsByActor: {},
count: 0
},
mutations: {
follows: (state, { fid, follow }) => {
if (follow) {
state.followsByActor[fid] = follow
} else {
delete state.followsByActor[fid]
}
state.count = Object.keys(state.followsByActor).length
},
reset (state) {
state.followsByActor = {}
state.count = 0
}
},
getters: {
follow: (state) => (fid: string) => {
return state.followsByActor[fid]
}
},
actions: {
set ({ commit, state }, { fid, value }) {
if (value) {
return axios.post('federation/follows/user/', { target: fid }).then((response) => {
logger.info('Successfully subscribed to actor')
commit('follows', { fid, follow: response.data })
}, () => {
logger.info('Error while subscribing to actor')
commit('follows', { fid, follow: null })
})
} else {
const follow = state.followsByActor[fid]
return axios.delete(`federation/follows/user/${follow.uuid}/`).then(() => {
logger.info('Successfully unsubscribed from actor')
commit('follows', { fid, follow: null })
}, () => {
logger.info('Error while unsubscribing from actor')
commit('follows', { fid, follow })
})
}
},
toggle ({ getters, dispatch }, fid) {
dispatch('set', { fid, value: !getters.follow(fid) })
},
async fetchFollows ({ dispatch, state, commit, rootState }, url) {
const response = await axios.get('federation/follows/user/all/')
for (const result of response.data.results) {
commit('follows', { fid: result.actor, follow: result })
}
}
}
}
export default store

View File

@ -170,6 +170,14 @@ export interface LibraryFollow {
type?: 'music.Library' | 'federation.LibraryFollow'
target: Library
}
export interface UserFollow {
uuid: string
approved: boolean
name: string
type?: 'federation.Actor' | 'federation.UserFollow'
target?: Actor
}
export interface Cover {
uuid: string
@ -481,11 +489,10 @@ export interface UserRequest {
export type Activity = {
actor: Actor
creation_date: string
related_object: LibraryFollow
related_object: LibraryFollow | UserFollow
type: 'Follow' | 'Accept'
object: LibraryFollow
object: LibraryFollow | UserFollow
}
export interface Notification {
id: number
is_read: boolean

View File

@ -5,7 +5,7 @@ import { onBeforeRouteUpdate } from 'vue-router'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useStore } from '~/store'
import UserFollowButton from '~/components/federation/UserFollowButton.vue'
import axios from 'axios'
import useErrorHandler from '~/composables/useErrorHandler'
@ -123,6 +123,10 @@ watch(props, fetchData, { immediate: true })
</router-link>
</div>
</button>
<user-follow-button
v-if="$store.state.auth.authenticated && object && object.full_username !== $store.state.auth.fullUsername"
:actor="object"
/>
<h1 class="ui center aligned icon header">
<i
v-if="!object.icon"