This commit is contained in:
parent
33ec6783aa
commit
9804de3650
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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"])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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("@")
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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"])
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -67,8 +67,8 @@ class ManageUserSerializer(serializers.ModelSerializer):
|
|||
"date_joined",
|
||||
"last_activity",
|
||||
"permissions",
|
||||
"privacy_level",
|
||||
"upload_quota",
|
||||
"privacy_level",
|
||||
"full_username",
|
||||
)
|
||||
read_only_fields = [
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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__":
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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"]
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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")},
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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}
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
User follow with listening and track favorite activities (#1810)
|
|
@ -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:
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
```
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -174,9 +174,9 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
|
|||
>
|
||||
<router-link
|
||||
class="left floated"
|
||||
:to="{name: 'profile.overview', params: {username: object.user.username}}"
|
||||
:to="{name: 'profile.overview', params: {username: object.actor.name}}"
|
||||
>
|
||||
<span class="at symbol" />{{ object.user.username }}
|
||||
<span class="at symbol" />{{ object.actor.name }}
|
||||
</router-link>
|
||||
<span class="right floated"><human-date :date="object.creation_date" /></span>
|
||||
</div>
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
<script setup lang="ts">
|
||||
import type { Actor } from '~/types'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useStore } from '~/store'
|
||||
|
||||
interface Events {
|
||||
(e: 'unfollowed'): void
|
||||
(e: 'followed'): void
|
||||
}
|
||||
|
||||
interface Props {
|
||||
actor: Actor
|
||||
}
|
||||
|
||||
const emit = defineEmits<Events>()
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const store = useStore()
|
||||
const follow = computed(() => store.getters['users/follow'](props.actor.fid))
|
||||
const isPending = computed(() => follow.value && follow.value.approved === null)
|
||||
const isApproved = computed(() => follow.value && follow.value.approved === true)
|
||||
|
||||
const toggle = () => {
|
||||
if (isPending.value || isApproved.value) {
|
||||
emit('unfollowed')
|
||||
} else {
|
||||
emit('followed')
|
||||
}
|
||||
|
||||
return store.dispatch('users/toggle', props.actor.fid)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:class="['ui', 'pink', {'inverted': isApproved || isPending}, {'favorited': isApproved}, 'icon', 'labeled', 'button']"
|
||||
@click.stop="toggle"
|
||||
>
|
||||
<i class="heart icon" />
|
||||
<span v-if="isApproved">
|
||||
{{ $t('components.audio.LibraryFollowButton.button.unfollow') }}
|
||||
</span>
|
||||
<span v-else-if="isPending">
|
||||
{{ $t('components.audio.LibraryFollowButton.button.cancel') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ $t('components.audio.LibraryFollowButton.button.follow') }}
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import type { Notification, LibraryFollow } from '~/types'
|
||||
import type { Notification, LibraryFollow, UserFollow } from '~/types'
|
||||
|
||||
import { computed, ref, watchEffect, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
@ -29,7 +29,9 @@ const notificationData = computed(() => {
|
|||
const activity = props.initialItem.activity
|
||||
|
||||
if (activity.type === 'Follow') {
|
||||
if (activity.object && activity.object.type === 'music.Library') {
|
||||
if (activity.type === 'Follow' && activity.object?.type === 'music.Library') {
|
||||
const libraryFollow = activity.related_object as LibraryFollow
|
||||
|
||||
const detailUrl = { name: 'library.detail.edit', params: { id: activity.object.uuid } }
|
||||
|
||||
if (activity.related_object?.approved === null) {
|
||||
|
@ -40,13 +42,13 @@ const notificationData = computed(() => {
|
|||
buttonClass: 'success',
|
||||
icon: 'check',
|
||||
label: t('components.notifications.NotificationRow.button.approve'),
|
||||
handler: () => approveLibraryFollow(activity.related_object)
|
||||
handler: () => approveLibraryFollow(libraryFollow)
|
||||
},
|
||||
rejectFollow: {
|
||||
buttonClass: 'danger',
|
||||
icon: 'x',
|
||||
label: t('components.notifications.NotificationRow.button.reject'),
|
||||
handler: () => rejectLibraryFollow(activity.related_object)
|
||||
handler: () => rejectLibraryFollow(libraryFollow)
|
||||
}
|
||||
}
|
||||
} else if (activity.related_object?.approved) {
|
||||
|
@ -61,6 +63,39 @@ const notificationData = computed(() => {
|
|||
message: t('components.notifications.NotificationRow.message.libraryReject', { username: username.value, library: activity.object.name })
|
||||
}
|
||||
}
|
||||
if (activity.object && activity.object.type === 'federation.Actor') {
|
||||
const userFollow = activity.related_object as UserFollow
|
||||
const detailUrl = { name: 'profile.full', params: { username: activity.actor.preferred_username, domain: activity.actor.domain } }
|
||||
|
||||
if (activity.related_object?.approved === null) {
|
||||
return {
|
||||
detailUrl,
|
||||
message: t('components.notifications.NotificationRow.message.userPendingFollow', { username: username.value, user: activity.object.target?.full_username }),
|
||||
acceptFollow: {
|
||||
buttonClass: 'success',
|
||||
icon: 'check',
|
||||
label: t('components.notifications.NotificationRow.button.approve'),
|
||||
handler: () => approveUserFollow(userFollow)
|
||||
},
|
||||
rejectFollow: {
|
||||
buttonClass: 'danger',
|
||||
icon: 'x',
|
||||
label: t('components.notifications.NotificationRow.button.reject'),
|
||||
handler: () => rejectUserFollow(userFollow)
|
||||
}
|
||||
}
|
||||
} else if (activity.related_object?.approved) {
|
||||
return {
|
||||
detailUrl,
|
||||
message: t('components.notifications.NotificationRow.message.userFollow', { username: username.value, user: activity.actor.full_username })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
detailUrl,
|
||||
message: t('components.notifications.NotificationRow.message.userReject', { username: username.value, user: activity.actor.full_username })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activity.type === 'Accept') {
|
||||
|
@ -70,6 +105,12 @@ const notificationData = computed(() => {
|
|||
message: t('components.notifications.NotificationRow.message.libraryAcceptFollow', { username: username.value, library: activity.related_object.name })
|
||||
}
|
||||
}
|
||||
if (activity.object?.type === 'federation.Actor') {
|
||||
return {
|
||||
detailUrl: { name: 'content.remote.index' },
|
||||
message: t('components.notifications.NotificationRow.message.userAcceptFollow', { username: username.value, user: activity.actor.full_username })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
|
@ -100,6 +141,18 @@ const rejectLibraryFollow = async (follow: LibraryFollow) => {
|
|||
follow.approved = false
|
||||
item.value.is_read = true
|
||||
}
|
||||
|
||||
const approveUserFollow = async (follow: UserFollow) => {
|
||||
await axios.post(`federation/follows/user/${follow.uuid}/accept/`)
|
||||
follow.approved = true
|
||||
item.value.is_read = true
|
||||
}
|
||||
|
||||
const rejectUserFollow = async (follow: UserFollow) => {
|
||||
await axios.post(`federation/follows/user/${follow.uuid}/reject/`)
|
||||
follow.approved = false
|
||||
item.value.is_read = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -222,6 +222,7 @@ const store: Module<State, RootState> = {
|
|||
dispatch('favorites/fetch', null, { root: true }),
|
||||
dispatch('playlists/fetchOwn', null, { root: true }),
|
||||
dispatch('libraries/fetchFollows', null, { root: true }),
|
||||
dispatch('users/fetchFollows', null, { root: true }),
|
||||
dispatch('channels/fetchSubscriptions', null, { root: true }),
|
||||
dispatch('moderation/fetchContentFilters', null, { root: true })
|
||||
])
|
||||
|
|
|
@ -7,6 +7,8 @@ import type { State as InstanceState } from './instance'
|
|||
import type { State as RadiosState } from './radios'
|
||||
import type { State as AuthState } from './auth'
|
||||
import type { State as UiState } from './ui'
|
||||
import type { State as Users } from './users'
|
||||
|
||||
import type { InjectionKey } from 'vue'
|
||||
|
||||
import { createStore, Store, useStore as baseUseStore } from 'vuex'
|
||||
|
@ -21,7 +23,7 @@ import instance from './instance'
|
|||
import radios from './radios'
|
||||
import auth from './auth'
|
||||
import ui from './ui'
|
||||
|
||||
import users from './users'
|
||||
export interface RootState {
|
||||
moderation: ModerationState
|
||||
playlists: PlaylistsState
|
||||
|
@ -32,6 +34,7 @@ export interface RootState {
|
|||
radios: RadiosState
|
||||
auth: AuthState
|
||||
ui: UiState
|
||||
users: Users
|
||||
}
|
||||
|
||||
export const key: InjectionKey<Store<RootState>> = Symbol('vuex state injection key')
|
||||
|
@ -45,7 +48,8 @@ export default createStore<RootState>({
|
|||
instance,
|
||||
radios,
|
||||
auth,
|
||||
ui
|
||||
ui,
|
||||
users
|
||||
},
|
||||
plugins: [
|
||||
createPersistedState({
|
||||
|
|
|
@ -0,0 +1,78 @@
|
|||
import type { Module } from 'vuex'
|
||||
import type { RootState } from '~/store/index'
|
||||
|
||||
import axios from 'axios'
|
||||
import useLogger from '~/composables/useLogger'
|
||||
|
||||
export interface State {
|
||||
followsByActor: {
|
||||
[key: string]: Follow
|
||||
}
|
||||
count: number
|
||||
}
|
||||
|
||||
interface Follow {
|
||||
uuid: string
|
||||
}
|
||||
const logger = useLogger()
|
||||
|
||||
const store: Module<State, RootState> = {
|
||||
namespaced: true,
|
||||
state: {
|
||||
followsByActor: {},
|
||||
count: 0
|
||||
},
|
||||
mutations: {
|
||||
follows: (state, { fid, follow }) => {
|
||||
if (follow) {
|
||||
state.followsByActor[fid] = follow
|
||||
} else {
|
||||
delete state.followsByActor[fid]
|
||||
}
|
||||
|
||||
state.count = Object.keys(state.followsByActor).length
|
||||
},
|
||||
reset (state) {
|
||||
state.followsByActor = {}
|
||||
state.count = 0
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
follow: (state) => (fid: string) => {
|
||||
return state.followsByActor[fid]
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
set ({ commit, state }, { fid, value }) {
|
||||
if (value) {
|
||||
return axios.post('federation/follows/user/', { target: fid }).then((response) => {
|
||||
logger.info('Successfully subscribed to actor')
|
||||
commit('follows', { fid, follow: response.data })
|
||||
}, () => {
|
||||
logger.info('Error while subscribing to actor')
|
||||
commit('follows', { fid, follow: null })
|
||||
})
|
||||
} else {
|
||||
const follow = state.followsByActor[fid]
|
||||
return axios.delete(`federation/follows/user/${follow.uuid}/`).then(() => {
|
||||
logger.info('Successfully unsubscribed from actor')
|
||||
commit('follows', { fid, follow: null })
|
||||
}, () => {
|
||||
logger.info('Error while unsubscribing from actor')
|
||||
commit('follows', { fid, follow })
|
||||
})
|
||||
}
|
||||
},
|
||||
toggle ({ getters, dispatch }, fid) {
|
||||
dispatch('set', { fid, value: !getters.follow(fid) })
|
||||
},
|
||||
async fetchFollows ({ dispatch, state, commit, rootState }, url) {
|
||||
const response = await axios.get('federation/follows/user/all/')
|
||||
for (const result of response.data.results) {
|
||||
commit('follows', { fid: result.actor, follow: result })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default store
|
|
@ -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
|
||||
|
|
|
@ -5,7 +5,7 @@ import { onBeforeRouteUpdate } from 'vue-router'
|
|||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useStore } from '~/store'
|
||||
|
||||
import UserFollowButton from '~/components/federation/UserFollowButton.vue'
|
||||
import axios from 'axios'
|
||||
|
||||
import useErrorHandler from '~/composables/useErrorHandler'
|
||||
|
@ -123,6 +123,10 @@ watch(props, fetchData, { immediate: true })
|
|||
</router-link>
|
||||
</div>
|
||||
</button>
|
||||
<user-follow-button
|
||||
v-if="$store.state.auth.authenticated && object && object.full_username !== $store.state.auth.fullUsername"
|
||||
:actor="object"
|
||||
/>
|
||||
<h1 class="ui center aligned icon header">
|
||||
<i
|
||||
v-if="!object.icon"
|
||||
|
|
Loading…
Reference in New Issue