diff --git a/api/funkwhale_api/activity/utils.py b/api/funkwhale_api/activity/utils.py
index fc2ca5736..2ace22cfd 100644
--- a/api/funkwhale_api/activity/utils.py
+++ b/api/funkwhale_api/activity/utils.py
@@ -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]
diff --git a/api/funkwhale_api/common/fields.py b/api/funkwhale_api/common/fields.py
index 6217f66b0..81c3a9c1a 100644
--- a/api/funkwhale_api/common/fields.py
+++ b/api/funkwhale_api/common/fields.py
@@ -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
)
diff --git a/api/funkwhale_api/common/permissions.py b/api/funkwhale_api/common/permissions.py
index 13ff696c8..b6500e741 100644
--- a/api/funkwhale_api/common/permissions.py
+++ b/api/funkwhale_api/common/permissions.py
@@ -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
diff --git a/api/funkwhale_api/contrib/listenbrainz/funkwhale_ready.py b/api/funkwhale_api/contrib/listenbrainz/funkwhale_ready.py
index 7364bbac0..67fe60a2a 100644
--- a/api/funkwhale_api/contrib/listenbrainz/funkwhale_ready.py
+++ b/api/funkwhale_api/contrib/listenbrainz/funkwhale_ready.py
@@ -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()
diff --git a/api/funkwhale_api/contrib/listenbrainz/tasks.py b/api/funkwhale_api/contrib/listenbrainz/tasks.py
index 85420c281..a37d60272 100644
--- a/api/funkwhale_api/contrib/listenbrainz/tasks.py
+++ b/api/funkwhale_api/contrib/listenbrainz/tasks.py
@@ -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
diff --git a/api/funkwhale_api/contrib/scrobbler/funkwhale_ready.py b/api/funkwhale_api/contrib/scrobbler/funkwhale_ready.py
index 8b36301ee..b2cad6d2d 100644
--- a/api/funkwhale_api/contrib/scrobbler/funkwhale_ready.py
+++ b/api/funkwhale_api/contrib/scrobbler/funkwhale_ready.py
@@ -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)
diff --git a/api/funkwhale_api/factories.py b/api/funkwhale_api/factories.py
index 8a2c6f76f..e7d77a328 100644
--- a/api/funkwhale_api/factories.py
+++ b/api/funkwhale_api/factories.py
@@ -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"
diff --git a/api/funkwhale_api/favorites/activities.py b/api/funkwhale_api/favorites/activities.py
index 294194e06..cb387692e 100644
--- a/api/funkwhale_api/favorites/activities.py
+++ b/api/funkwhale_api/favorites/activities.py
@@ -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(
diff --git a/api/funkwhale_api/favorites/admin.py b/api/funkwhale_api/favorites/admin.py
index 05530b0c6..3d28e9324 100644
--- a/api/funkwhale_api/favorites/admin.py
+++ b/api/funkwhale_api/favorites/admin.py
@@ -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"]
diff --git a/api/funkwhale_api/favorites/factories.py b/api/funkwhale_api/favorites/factories.py
index df2f47335..403c9fee4 100644
--- a/api/funkwhale_api/favorites/factories.py
+++ b/api/funkwhale_api/favorites/factories.py
@@ -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"])
diff --git a/api/funkwhale_api/favorites/filters.py b/api/funkwhale_api/favorites/filters.py
index 32c07a646..5c60fe86d 100644
--- a/api/funkwhale_api/favorites/filters.py
+++ b/api/funkwhale_api/favorites/filters.py
@@ -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
diff --git a/api/funkwhale_api/favorites/migrations/0003_trackfavorite_actor_trackfavorite_fid_and_more.py b/api/funkwhale_api/favorites/migrations/0003_trackfavorite_actor_trackfavorite_fid_and_more.py
new file mode 100644
index 000000000..b204ba79c
--- /dev/null
+++ b/api/funkwhale_api/favorites/migrations/0003_trackfavorite_actor_trackfavorite_fid_and_more.py
@@ -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",
+ ),
+
+ ]
diff --git a/api/funkwhale_api/favorites/models.py b/api/funkwhale_api/favorites/models.py
index bd0f8be0b..d38ade1bc 100644
--- a/api/funkwhale_api/favorites/models.py
+++ b/api/funkwhale_api/favorites/models.py
@@ -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)
diff --git a/api/funkwhale_api/favorites/serializers.py b/api/funkwhale_api/favorites/serializers.py
index 205732b74..4297219ce 100644
--- a/api/funkwhale_api/favorites/serializers.py
+++ b/api/funkwhale_api/favorites/serializers.py
@@ -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):
diff --git a/api/funkwhale_api/favorites/views.py b/api/funkwhale_api/favorites/views.py
index 040053e52..03d4594b5 100644
--- a/api/funkwhale_api/favorites/views.py
+++ b/api/funkwhale_api/favorites/views.py
@@ -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)
diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py
index dedea8dd8..002c2456a 100644
--- a/api/funkwhale_api/federation/activity.py
+++ b/api/funkwhale_api/federation/activity.py
@@ -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
diff --git a/api/funkwhale_api/federation/api_serializers.py b/api/funkwhale_api/federation/api_serializers.py
index bee31bbeb..8a3fd1a76 100644
--- a/api/funkwhale_api/federation/api_serializers.py
+++ b/api/funkwhale_api/federation/api_serializers.py
@@ -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("@")
diff --git a/api/funkwhale_api/federation/api_urls.py b/api/funkwhale_api/federation/api_urls.py
index df5bfb2f0..4f1c6286e 100644
--- a/api/funkwhale_api/federation/api_urls.py
+++ b/api/funkwhale_api/federation/api_urls.py
@@ -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")
diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py
index e9d7bff01..ed37b02c8 100644
--- a/api/funkwhale_api/federation/api_views.py
+++ b/api/funkwhale_api/federation/api_views.py
@@ -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)
diff --git a/api/funkwhale_api/federation/factories.py b/api/funkwhale_api/federation/factories.py
index f6a20990b..a02834cde 100644
--- a/api/funkwhale_api/federation/factories.py
+++ b/api/funkwhale_api/federation/factories.py
@@ -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),
)
diff --git a/api/funkwhale_api/federation/models.py b/api/funkwhale_api/federation/models.py
index 8ead384df..270e6857a 100644
--- a/api/funkwhale_api/federation/models.py
+++ b/api/funkwhale_api/federation/models.py
@@ -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):
diff --git a/api/funkwhale_api/federation/routes.py b/api/funkwhale_api/federation/routes.py
index f20a02e39..1b16e9f8c 100644
--- a/api/funkwhale_api/federation/routes.py
+++ b/api/funkwhale_api/federation/routes.py
@@ -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()
diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py
index fe1818365..054ae6044 100644
--- a/api/funkwhale_api/federation/serializers.py
+++ b/api/funkwhale_api/federation/serializers.py
@@ -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,
+ )
diff --git a/api/funkwhale_api/federation/urls.py b/api/funkwhale_api/federation/urls.py
index fac6bfdfd..bb4483486 100644
--- a/api/funkwhale_api/federation/urls.py
+++ b/api/funkwhale_api/federation/urls.py
@@ -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")
diff --git a/api/funkwhale_api/federation/views.py b/api/funkwhale_api/federation/views.py
index 505ad2f3c..3989b9cb8 100644
--- a/api/funkwhale_api/federation/views.py
+++ b/api/funkwhale_api/federation/views.py
@@ -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)
diff --git a/api/funkwhale_api/history/activities.py b/api/funkwhale_api/history/activities.py
index b63de1f26..614c930e6 100644
--- a/api/funkwhale_api/history/activities.py
+++ b/api/funkwhale_api/history/activities.py
@@ -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(
diff --git a/api/funkwhale_api/history/admin.py b/api/funkwhale_api/history/admin.py
index 6aac94d0a..defde6656 100644
--- a/api/funkwhale_api/history/admin.py
+++ b/api/funkwhale_api/history/admin.py
@@ -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"]
diff --git a/api/funkwhale_api/history/factories.py b/api/funkwhale_api/history/factories.py
index bae8da505..11d6af0e7 100644
--- a/api/funkwhale_api/history/factories.py
+++ b/api/funkwhale_api/history/factories.py
@@ -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"])
diff --git a/api/funkwhale_api/history/filters.py b/api/funkwhale_api/history/filters.py
index 1e88f1b22..38ec56d84 100644
--- a/api/funkwhale_api/history/filters.py
+++ b/api/funkwhale_api/history/filters.py
@@ -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
diff --git a/api/funkwhale_api/history/migrations/0004_listening_actor_listening_fid_listening_url.py b/api/funkwhale_api/history/migrations/0004_listening_actor_listening_fid_listening_url.py
new file mode 100644
index 000000000..4c4f8afa1
--- /dev/null
+++ b/api/funkwhale_api/history/migrations/0004_listening_actor_listening_fid_listening_url.py
@@ -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",
+ ),
+ ),
+ ]
diff --git a/api/funkwhale_api/history/models.py b/api/funkwhale_api/history/models.py
index 046115d07..139b562ef 100644
--- a/api/funkwhale_api/history/models.py
+++ b/api/funkwhale_api/history/models.py
@@ -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)
diff --git a/api/funkwhale_api/history/serializers.py b/api/funkwhale_api/history/serializers.py
index 66ce34f21..ccb92633d 100644
--- a/api/funkwhale_api/history/serializers.py
+++ b/api/funkwhale_api/history/serializers.py
@@ -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)
diff --git a/api/funkwhale_api/history/views.py b/api/funkwhale_api/history/views.py
index d49702754..f60ad2a61 100644
--- a/api/funkwhale_api/history/views.py
+++ b/api/funkwhale_api/history/views.py
@@ -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(
diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py
index 07a6fb41c..f048ebb64 100644
--- a/api/funkwhale_api/manage/serializers.py
+++ b/api/funkwhale_api/manage/serializers.py
@@ -67,8 +67,8 @@ class ManageUserSerializer(serializers.ModelSerializer):
"date_joined",
"last_activity",
"permissions",
- "privacy_level",
"upload_quota",
+ "privacy_level",
"full_username",
)
read_only_fields = [
diff --git a/api/funkwhale_api/music/factories.py b/api/funkwhale_api/music/factories.py
index e1a66ceb0..122d8cc8b 100644
--- a/api/funkwhale_api/music/factories.py
+++ b/api/funkwhale_api/music/factories.py
@@ -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):
diff --git a/api/funkwhale_api/music/fake_data.py b/api/funkwhale_api/music/fake_data.py
index 09a619886..b9ba05038 100644
--- a/api/funkwhale_api/music/fake_data.py
+++ b/api/funkwhale_api/music/fake_data.py
@@ -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__":
diff --git a/api/funkwhale_api/radios/radios.py b/api/funkwhale_api/radios/radios.py
index c3c86fee6..590705e05 100644
--- a/api/funkwhale_api/radios/radios.py
+++ b/api/funkwhale_api/radios/radios.py
@@ -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
)
diff --git a/api/funkwhale_api/radios/radios_v2.py b/api/funkwhale_api/radios/radios_v2.py
index 7385dace9..d13d1eb82 100644
--- a/api/funkwhale_api/radios/radios_v2.py
+++ b/api/funkwhale_api/radios/radios_v2.py
@@ -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
)
diff --git a/api/funkwhale_api/subsonic/serializers.py b/api/funkwhale_api/subsonic/serializers.py
index 266e3876a..6fa725002 100644
--- a/api/funkwhale_api/subsonic/serializers.py
+++ b/api/funkwhale_api/subsonic/serializers.py
@@ -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"]
)
diff --git a/api/funkwhale_api/subsonic/views.py b/api/funkwhale_api/subsonic/views.py
index 8071db9f7..f9a8a35aa 100644
--- a/api/funkwhale_api/subsonic/views.py
+++ b/api/funkwhale_api/subsonic/views.py
@@ -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)
diff --git a/api/funkwhale_api/users/admin.py b/api/funkwhale_api/users/admin.py
index 26c58f448..6a714fe12 100644
--- a/api/funkwhale_api/users/admin.py
+++ b/api/funkwhale_api/users/admin.py
@@ -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")},
diff --git a/api/funkwhale_api/users/models.py b/api/funkwhale_api/users/models.py
index 14efbca19..689b2a4b2 100644
--- a/api/funkwhale_api/users/models.py
+++ b/api/funkwhale_api/users/models.py
@@ -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.
diff --git a/api/tests/activity/test_utils.py b/api/tests/activity/test_utils.py
index 0dabd3a28..9d4d62db9 100644
--- a/api/tests/activity/test_utils.py
+++ b/api/tests/activity/test_utils.py
@@ -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]
diff --git a/api/tests/activity/test_views.py b/api/tests/activity/test_views.py
index 1f5efae51..270611268 100644
--- a/api/tests/activity/test_views.py
+++ b/api/tests/activity/test_views.py
@@ -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)
diff --git a/api/tests/common/test_fields.py b/api/tests/common/test_fields.py
index 0fdf74cae..8dc6cef4c 100644
--- a/api/tests/common/test_fields.py
+++ b/api/tests/common/test_fields.py
@@ -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):
diff --git a/api/tests/common/test_permissions.py b/api/tests/common/test_permissions.py
index bf4d8bde5..e2a569839 100644
--- a/api/tests/common/test_permissions.py
+++ b/api/tests/common/test_permissions.py
@@ -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
diff --git a/api/tests/contrib/listenbrainz/test_listenbrainz.py b/api/tests/contrib/listenbrainz/test_listenbrainz.py
index 7cf504707..47ecc5dae 100644
--- a/api/tests/contrib/listenbrainz/test_listenbrainz.py
+++ b/api/tests/contrib/listenbrainz/test_listenbrainz.py
@@ -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),
)
diff --git a/api/tests/favorites/test_activity.py b/api/tests/favorites/test_activity.py
index f6a17d4db..b92a1b505 100644
--- a/api/tests/favorites/test_activity.py
+++ b/api/tests/favorites/test_activity.py
@@ -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)
diff --git a/api/tests/favorites/test_favorites.py b/api/tests/favorites/test_favorites.py
index 73e6653e6..cfaad2b13 100644
--- a/api/tests/favorites/test_favorites.py
+++ b/api/tests/favorites/test_favorites.py
@@ -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})
diff --git a/api/tests/favorites/test_models.py b/api/tests/favorites/test_models.py
new file mode 100644
index 000000000..eb3123810
--- /dev/null
+++ b/api/tests/favorites/test_models.py
@@ -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
diff --git a/api/tests/favorites/test_serializers.py b/api/tests/favorites/test_serializers.py
index 3fcf6bca2..47c294543 100644
--- a/api/tests/favorites/test_serializers.py
+++ b/api/tests/favorites/test_serializers.py
@@ -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)
diff --git a/api/tests/favorites/test_views.py b/api/tests/favorites/test_views.py
index a51097156..bfd8e7f8b 100644
--- a/api/tests/favorites/test_views.py
+++ b/api/tests/favorites/test_views.py
@@ -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
diff --git a/api/tests/federation/test_api_serializers.py b/api/tests/federation/test_api_serializers.py
index 78d7a0640..d9d171513 100644
--- a/api/tests/federation/test_api_serializers.py
+++ b/api/tests/federation/test_api_serializers.py
@@ -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)
diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py
index 508b8b576..9e9b8fb10 100644
--- a/api/tests/federation/test_api_views.py
+++ b/api/tests/federation/test_api_views.py
@@ -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}
+ )
diff --git a/api/tests/federation/test_migrations.py b/api/tests/federation/test_migrations.py
deleted file mode 100644
index e69de29bb..000000000
diff --git a/api/tests/federation/test_routes.py b/api/tests/federation/test_routes.py
index cc9c02e3c..fe51edfad 100644
--- a/api/tests/federation/test_routes.py
+++ b/api/tests/federation/test_routes.py
@@ -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
diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py
index ea437cfe4..1205f402b 100644
--- a/api/tests/federation/test_serializers.py
+++ b/api/tests/federation/test_serializers.py
@@ -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):
diff --git a/api/tests/federation/test_tasks.py b/api/tests/federation/test_tasks.py
index 7f19981a6..7f9fd6a93 100644
--- a/api/tests/federation/test_tasks.py
+++ b/api/tests/federation/test_tasks.py
@@ -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
diff --git a/api/tests/federation/test_views.py b/api/tests/federation/test_views.py
index 3227ec720..4a6c2c653 100644
--- a/api/tests/federation/test_views.py
+++ b/api/tests/federation/test_views.py
@@ -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
diff --git a/api/tests/history/test_activity.py b/api/tests/history/test_activity.py
index 5b048cda6..21174ebb3 100644
--- a/api/tests/history/test_activity.py
+++ b/api/tests/history/test_activity.py
@@ -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)
diff --git a/api/tests/history/test_history.py b/api/tests/history/test_history.py
index 9cc4e3d14..ac565c63a 100644
--- a/api/tests/history/test_history.py
+++ b/api/tests/history/test_history.py
@@ -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(
diff --git a/api/tests/history/test_serializers.py b/api/tests/history/test_serializers.py
index 170b44d6b..03e528195 100644
--- a/api/tests/history/test_serializers.py
+++ b/api/tests/history/test_serializers.py
@@ -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)
diff --git a/api/tests/history/test_views.py b/api/tests/history/test_views.py
index 98ea0976a..f1f12c425 100644
--- a/api/tests/history/test_views.py
+++ b/api/tests/history/test_views.py
@@ -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
diff --git a/api/tests/playlists/test_urls_v2.py b/api/tests/playlists/test_urls_v2.py
index fdc20df23..bdc327c91 100644
--- a/api/tests/playlists/test_urls_v2.py
+++ b/api/tests/playlists/test_urls_v2.py
@@ -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})
diff --git a/api/tests/playlists/test_views.py b/api/tests/playlists/test_views.py
index 4803378d5..f1a61f835 100644
--- a/api/tests/playlists/test_views.py
+++ b/api/tests/playlists/test_views.py
@@ -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)
diff --git a/api/tests/radios/test_radios.py b/api/tests/radios/test_radios.py
index b0919dfd1..9f7e20617 100644
--- a/api/tests/radios/test_radios.py
+++ b/api/tests/radios/test_radios.py
@@ -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
diff --git a/api/tests/radios/test_radios_v2.py b/api/tests/radios/test_radios_v2.py
index 76c9a2e15..16c28a956 100644
--- a/api/tests/radios/test_radios_v2.py
+++ b/api/tests/radios/test_radios_v2.py
@@ -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
diff --git a/api/tests/subsonic/test_serializers.py b/api/tests/subsonic/test_serializers.py
index d2286ee7e..2b9df3a36 100644
--- a/api/tests/subsonic/test_serializers.py
+++ b/api/tests/subsonic/test_serializers.py
@@ -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
diff --git a/api/tests/subsonic/test_views.py b/api/tests/subsonic/test_views.py
index a85075795..40978b283 100644
--- a/api/tests/subsonic/test_views.py
+++ b/api/tests/subsonic/test_views.py
@@ -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
diff --git a/api/tests/users/test_views.py b/api/tests/users/test_views.py
index 8b7dd5e55..3f81aa2f2 100644
--- a/api/tests/users/test_views.py
+++ b/api/tests/users/test_views.py
@@ -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
diff --git a/changes/changelog.d/1810.feature b/changes/changelog.d/1810.feature
new file mode 100644
index 000000000..59aec2046
--- /dev/null
+++ b/changes/changelog.d/1810.feature
@@ -0,0 +1 @@
+User follow with listening and track favorite activities (#1810)
diff --git a/docs/developer/federation/index.md b/docs/developer/federation/index.md
index 992e83455..4b858a10a 100644
--- a/docs/developer/federation/index.md
+++ b/docs/developer/federation/index.md
@@ -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:
diff --git a/docs/developer/federation/objects.md b/docs/developer/federation/objects.md
new file mode 100644
index 000000000..d173d76d5
--- /dev/null
+++ b/docs/developer/federation/objects.md
@@ -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"
+ }
+}
+```
diff --git a/docs/developer/setup/docker.md b/docs/developer/setup/docker.md
index 207d2c3d3..a23ee46d8 100644
--- a/docs/developer/setup/docker.md
+++ b/docs/developer/setup/docker.md
@@ -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:
diff --git a/docs/specs/user-follow/index.md b/docs/specs/user-follow/index.md
index de3dced5b..95b324972 100644
--- a/docs/specs/user-follow/index.md
+++ b/docs/specs/user-follow/index.md
@@ -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
diff --git a/front/src/components/audio/track/Widget.vue b/front/src/components/audio/track/Widget.vue
index 137af4525..b5e892bce 100644
--- a/front/src/components/audio/track/Widget.vue
+++ b/front/src/components/audio/track/Widget.vue
@@ -174,9 +174,9 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
>
- {{ object.user.username }}
+ {{ object.actor.name }}
diff --git a/front/src/components/federation/UserFollowButton.vue b/front/src/components/federation/UserFollowButton.vue
new file mode 100644
index 000000000..3029b86a7
--- /dev/null
+++ b/front/src/components/federation/UserFollowButton.vue
@@ -0,0 +1,51 @@
+
+
+
+
+
diff --git a/front/src/components/notifications/NotificationRow.vue b/front/src/components/notifications/NotificationRow.vue
index 06e661589..e4f583a52 100644
--- a/front/src/components/notifications/NotificationRow.vue
+++ b/front/src/components/notifications/NotificationRow.vue
@@ -1,5 +1,5 @@
diff --git a/front/src/locales/en_GB.json b/front/src/locales/en_GB.json
index b2f82ab54..653700930 100644
--- a/front/src/locales/en_GB.json
+++ b/front/src/locales/en_GB.json
@@ -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"
}
}
},
diff --git a/front/src/locales/en_US.json b/front/src/locales/en_US.json
index 6793123f7..9167b6654 100644
--- a/front/src/locales/en_US.json
+++ b/front/src/locales/en_US.json
@@ -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"
}
}
},
diff --git a/front/src/store/auth.ts b/front/src/store/auth.ts
index 34d20073d..67dd9b08c 100644
--- a/front/src/store/auth.ts
+++ b/front/src/store/auth.ts
@@ -222,6 +222,7 @@ const store: Module = {
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 })
])
diff --git a/front/src/store/index.ts b/front/src/store/index.ts
index 57fa0176c..c36dbe235 100644
--- a/front/src/store/index.ts
+++ b/front/src/store/index.ts
@@ -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> = Symbol('vuex state injection key')
@@ -45,7 +48,8 @@ export default createStore({
instance,
radios,
auth,
- ui
+ ui,
+ users
},
plugins: [
createPersistedState({
diff --git a/front/src/store/users.ts b/front/src/store/users.ts
new file mode 100644
index 000000000..69237bf51
--- /dev/null
+++ b/front/src/store/users.ts
@@ -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 = {
+ 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
diff --git a/front/src/types.ts b/front/src/types.ts
index cbe64cb6b..0e79ed398 100644
--- a/front/src/types.ts
+++ b/front/src/types.ts
@@ -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
diff --git a/front/src/views/auth/ProfileBase.vue b/front/src/views/auth/ProfileBase.vue
index e80b74015..9dc52c42f 100644
--- a/front/src/views/auth/ProfileBase.vue
+++ b/front/src/views/auth/ProfileBase.vue
@@ -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 })
+