kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
Porównaj commity
26 Commity
e2d827eea2
...
67b5392c30
Autor | SHA1 | Data |
---|---|---|
petitminion | 67b5392c30 | |
Petitminion | 18b4c6e5f3 | |
Petitminion | 890963db30 | |
Petitminion | 878cb32b96 | |
Petitminion | 4bef27552f | |
Ciarán Ainsworth | ec368e0cd3 | |
Ciarán Ainsworth | a2579bdc60 | |
Ciarán Ainsworth | e1e0045a23 | |
Ciarán Ainsworth | 85c2be6a5b | |
Ciarán Ainsworth | 35de9bd48e | |
Petitminion | ba5b657b61 | |
Petitminion | 4fc73c1430 | |
Ciarán Ainsworth | 97e24bcaa6 | |
Ciarán Ainsworth | 1b15fea1ab | |
Ciarán Ainsworth | b624fea2fa | |
Ciarán Ainsworth | e028e8788b | |
Ciarán Ainsworth | 67f74d40a6 | |
Petitminion | 547bd6f371 | |
Petitminion | 05ec6f6d0f | |
Petitminion | a03cc1db24 | |
Petitminion | 2a364d5785 | |
Petitminion | 5bc0171694 | |
Petitminion | 37acfa475d | |
Petitminion | f45fd1e465 | |
Petitminion | 17c4a92f77 | |
Petitminion | 6414302899 |
2
.env.dev
2
.env.dev
|
@ -18,6 +18,6 @@ MEDIA_ROOT=/data/media
|
|||
# FORCE_HTTPS_URLS=True
|
||||
|
||||
# Customize to your needs
|
||||
POSTGRES_VERSION=11
|
||||
POSTGRES_VERSION=15
|
||||
DEBUG=true
|
||||
TYPESENSE_API_KEY="apikey"
|
||||
|
|
|
@ -303,6 +303,23 @@ LISTENING_CREATED = "listening_created"
|
|||
"""
|
||||
Called when a track is being listened
|
||||
"""
|
||||
LISTENING_SYNC = "listening_sync"
|
||||
"""
|
||||
Called by the task manager to trigger listening sync
|
||||
"""
|
||||
FAVORITE_CREATED = "favorite_created"
|
||||
"""
|
||||
Called when a track is being favorited
|
||||
"""
|
||||
FAVORITE_DELETED = "favorite_deleted"
|
||||
"""
|
||||
Called when a favorited track is being unfavorited
|
||||
"""
|
||||
FAVORITE_SYNC = "favorite_sync"
|
||||
"""
|
||||
Called by the task manager to trigger favorite sync
|
||||
"""
|
||||
|
||||
SCAN = "scan"
|
||||
"""
|
||||
|
||||
|
|
|
@ -276,6 +276,7 @@ LOCAL_APPS = (
|
|||
# Your stuff: custom apps go here
|
||||
"funkwhale_api.instance",
|
||||
"funkwhale_api.audio",
|
||||
"funkwhale_api.contrib.listenbrainz",
|
||||
"funkwhale_api.music",
|
||||
"funkwhale_api.requests",
|
||||
"funkwhale_api.favorites",
|
||||
|
@ -949,6 +950,16 @@ CELERY_BEAT_SCHEDULE = {
|
|||
),
|
||||
"options": {"expires": 60 * 60},
|
||||
},
|
||||
"listenbrainz.trigger_listening_sync_with_listenbrainz": {
|
||||
"task": "listenbrainz.trigger_listening_sync_with_listenbrainz",
|
||||
"schedule": crontab(day_of_week="*", minute="0", hour="3"),
|
||||
"options": {"expires": 60 * 60 * 24},
|
||||
},
|
||||
"listenbrainz.trigger_favorite_sync_with_listenbrainz": {
|
||||
"task": "listenbrainz.trigger_favorite_sync_with_listenbrainz",
|
||||
"schedule": crontab(day_of_week="*", minute="0", hour="3"),
|
||||
"options": {"expires": 60 * 60 * 24},
|
||||
},
|
||||
}
|
||||
|
||||
if env.str("TYPESENSE_API_KEY", default=None):
|
||||
|
|
|
@ -38,13 +38,13 @@ 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__privacy_level", "actor__user")
|
||||
querysets = [
|
||||
Listening.objects.filter(query).select_related(
|
||||
"track", "user", "track__artist", "track__album__artist"
|
||||
"track", "actor", "track__artist", "track__album__artist"
|
||||
),
|
||||
TrackFavorite.objects.filter(query).select_related(
|
||||
"track", "user", "track__artist", "track__album__artist"
|
||||
"track", "actor", "track__artist", "track__album__artist"
|
||||
),
|
||||
]
|
||||
records = combined_recent(limit=limit, querysets=querysets)
|
||||
|
|
|
@ -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}
|
||||
actors_follows = user.actor.user_follows.filter(approved=True).values_list(
|
||||
"target", flat=True
|
||||
)
|
||||
|
||||
followers_query = models.Q(
|
||||
**{
|
||||
f"{lookup_field}": "followers",
|
||||
f"{user_field}__actor__pk__in": actors_follows,
|
||||
}
|
||||
)
|
||||
return (
|
||||
models.Q(**{f"{lookup_field}__in": ["instance", "everyone"]})
|
||||
| models.Q(**{lookup_field: "me", user_field: user})
|
||||
| followers_query
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -56,3 +56,43 @@ 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 user have acces to the object considering the privacylevel configuration
|
||||
of the user. Could be expanded to other objects type.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if not hasattr(obj, "user"):
|
||||
# to do : it's a remote actor. We could trigger an update of the remote actor data
|
||||
# to avoid leaking data
|
||||
return True
|
||||
if obj.user.actor.privacy_level == "everyone":
|
||||
return True
|
||||
# user is anonymous
|
||||
elif not hasattr(request.user, "actor"):
|
||||
return False
|
||||
elif obj.user.actor.privacy_level == "instance":
|
||||
# user is local
|
||||
if hasattr(request.user, "actor"):
|
||||
return True
|
||||
elif request.actor.is_local():
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
elif (
|
||||
obj.user.actor.privacy_level == "me"
|
||||
and obj.user.actor == request.user.actor
|
||||
):
|
||||
return True
|
||||
|
||||
elif (
|
||||
obj.user.actor.privacy_level == "followers"
|
||||
and request.user.actor in obj.user.actor.get_followers()
|
||||
):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
|
|
@ -1,28 +1,31 @@
|
|||
import liblistenbrainz
|
||||
from django.utils import timezone
|
||||
|
||||
import funkwhale_api
|
||||
from config import plugins
|
||||
from funkwhale_api.favorites import models as favorites_models
|
||||
from funkwhale_api.history import models as history_models
|
||||
|
||||
from . import tasks
|
||||
from .funkwhale_startup import PLUGIN
|
||||
|
||||
|
||||
@plugins.register_hook(plugins.LISTENING_CREATED, PLUGIN)
|
||||
def submit_listen(listening, conf, **kwargs):
|
||||
user_token = conf["user_token"]
|
||||
if not user_token:
|
||||
if not user_token and not conf["submit_listenings"]:
|
||||
return
|
||||
|
||||
logger = PLUGIN["logger"]
|
||||
logger.info("Submitting listen to ListenBrainz")
|
||||
client = liblistenbrainz.ListenBrainz()
|
||||
client.set_auth_token(user_token)
|
||||
listen = get_listen(listening.track)
|
||||
listen = get_lb_listen(listening)
|
||||
|
||||
client.submit_single_listen(listen)
|
||||
|
||||
|
||||
def get_listen(track):
|
||||
def get_lb_listen(listening):
|
||||
track = listening.track
|
||||
additional_info = {
|
||||
"media_player": "Funkwhale",
|
||||
"media_player_version": funkwhale_api.__version__,
|
||||
|
@ -51,7 +54,83 @@ def get_listen(track):
|
|||
return liblistenbrainz.Listen(
|
||||
track_name=track.title,
|
||||
artist_name=track.artist.name,
|
||||
listened_at=int(timezone.now()),
|
||||
listened_at=listening.creation_date.timestamp(),
|
||||
release_name=release_name,
|
||||
additional_info=additional_info,
|
||||
)
|
||||
|
||||
|
||||
@plugins.register_hook(plugins.FAVORITE_CREATED, PLUGIN)
|
||||
def submit_favorite_creation(track_favorite, conf, **kwargs):
|
||||
user_token = conf["user_token"]
|
||||
if not user_token or not conf["submit_favorites"]:
|
||||
return
|
||||
logger = PLUGIN["logger"]
|
||||
logger.info("Submitting favorite to ListenBrainz")
|
||||
client = liblistenbrainz.ListenBrainz()
|
||||
track = track_favorite.track
|
||||
if not track.mbid:
|
||||
logger.warning(
|
||||
"This tracks doesn't have a mbid. Feedback will not be submitted to Listenbrainz"
|
||||
)
|
||||
return
|
||||
client.submit_user_feedback(1, track.mbid)
|
||||
|
||||
|
||||
@plugins.register_hook(plugins.FAVORITE_DELETED, PLUGIN)
|
||||
def submit_favorite_deletion(track_favorite, conf, **kwargs):
|
||||
user_token = conf["user_token"]
|
||||
if not user_token or not conf["submit_favorites"]:
|
||||
return
|
||||
logger = PLUGIN["logger"]
|
||||
logger.info("Submitting favorite deletion to ListenBrainz")
|
||||
client = liblistenbrainz.ListenBrainz()
|
||||
track = track_favorite.track
|
||||
if not track.mbid:
|
||||
logger.warning(
|
||||
"This tracks doesn't have a mbid. Feedback will not be submitted to Listenbrainz"
|
||||
)
|
||||
return
|
||||
client.submit_user_feedback(0, track.mbid)
|
||||
|
||||
|
||||
@plugins.register_hook(plugins.LISTENING_SYNC, PLUGIN)
|
||||
def sync_listenings_from_listenbrainz(user, conf):
|
||||
user_name = conf["user_name"]
|
||||
|
||||
if not user_name or not conf["sync_listenings"]:
|
||||
return
|
||||
logger = PLUGIN["logger"]
|
||||
logger.info("Getting listenings from ListenBrainz")
|
||||
try:
|
||||
last_ts = (
|
||||
history_models.Listening.objects.filter(actor=user.actor)
|
||||
.filter(source="Listenbrainz")
|
||||
.latest("creation_date")
|
||||
.values_list("creation_date", flat=True)
|
||||
).timestamp()
|
||||
except funkwhale_api.history.models.Listening.DoesNotExist:
|
||||
tasks.import_listenbrainz_listenings(user, user_name, 0)
|
||||
return
|
||||
|
||||
tasks.import_listenbrainz_listenings(user, user_name, last_ts)
|
||||
|
||||
|
||||
@plugins.register_hook(plugins.FAVORITE_SYNC, PLUGIN)
|
||||
def sync_favorites_from_listenbrainz(user, conf):
|
||||
user_name = conf["user_name"]
|
||||
|
||||
if not user_name or not conf["sync_favorites"]:
|
||||
return
|
||||
try:
|
||||
last_ts = (
|
||||
favorites_models.TrackFavorite.objects.filter(actor=user.actor)
|
||||
.filter(source="Listenbrainz")
|
||||
.latest("creation_date")
|
||||
.creation_date.timestamp()
|
||||
)
|
||||
except favorites_models.TrackFavorite.DoesNotExist:
|
||||
tasks.import_listenbrainz_favorites(user, user_name, 0)
|
||||
return
|
||||
|
||||
tasks.import_listenbrainz_favorites(user, user_name, last_ts)
|
||||
|
|
|
@ -3,7 +3,7 @@ from config import plugins
|
|||
PLUGIN = plugins.get_plugin_config(
|
||||
name="listenbrainz",
|
||||
label="ListenBrainz",
|
||||
description="A plugin that allows you to submit your listens to ListenBrainz.",
|
||||
description="A plugin that allows you to submit or sync your listens and favorites to ListenBrainz.",
|
||||
homepage="https://docs.funkwhale.audio/users/builtinplugins.html#listenbrainz-plugin", # noqa
|
||||
version="0.3",
|
||||
user=True,
|
||||
|
@ -13,6 +13,45 @@ PLUGIN = plugins.get_plugin_config(
|
|||
"type": "text",
|
||||
"label": "Your ListenBrainz user token",
|
||||
"help": "You can find your user token in your ListenBrainz profile at https://listenbrainz.org/profile/",
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "user_name",
|
||||
"type": "text",
|
||||
"required": False,
|
||||
"label": "Your ListenBrainz user name.",
|
||||
"help": "Required for importing listenings and favorites with ListenBrainz \
|
||||
but not to send activities",
|
||||
},
|
||||
{
|
||||
"name": "submit_listenings",
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"label": "Enable listening submission to ListenBrainz",
|
||||
"help": "If enabled, your listenings from Funkwhale will be imported into ListenBrainz.",
|
||||
},
|
||||
{
|
||||
"name": "sync_listenings",
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"label": "Enable listenings sync",
|
||||
"help": "If enabled, your listening from ListenBrainz will be imported into Funkwhale. This means they \
|
||||
will be used along with Funkwhale listenings to filter out recently listened content or \
|
||||
generate recommendations",
|
||||
},
|
||||
{
|
||||
"name": "sync_favorites",
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"label": "Enable favorite sync",
|
||||
"help": "If enabled, your favorites from ListenBrainz will be imported into Funkwhale. This means they \
|
||||
will be used along with Funkwhale favorites (UI display, federation activity)",
|
||||
},
|
||||
{
|
||||
"name": "submit_favorites",
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"label": "Enable favorite submission to ListenBrainz services",
|
||||
"help": "If enabled, your favorites from Funkwhale will be submitted to ListenBrainz",
|
||||
},
|
||||
],
|
||||
)
|
||||
|
|
|
@ -0,0 +1,165 @@
|
|||
import datetime
|
||||
|
||||
import liblistenbrainz
|
||||
from django.utils import timezone
|
||||
|
||||
from config import plugins
|
||||
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 funkwhale_api.taskapp import celery
|
||||
from funkwhale_api.users import models
|
||||
|
||||
from .funkwhale_startup import PLUGIN
|
||||
|
||||
|
||||
@celery.app.task(name="listenbrainz.trigger_listening_sync_with_listenbrainz")
|
||||
def trigger_listening_sync_with_listenbrainz():
|
||||
now = timezone.now()
|
||||
active_month = now - datetime.timedelta(days=30)
|
||||
users = (
|
||||
models.User.objects.filter(plugins__code="listenbrainz")
|
||||
.filter(plugins__conf__sync_listenings=True)
|
||||
.filter(last_activity__gte=active_month)
|
||||
)
|
||||
for user in users:
|
||||
plugins.trigger_hook(
|
||||
plugins.LISTENING_SYNC,
|
||||
user=user,
|
||||
confs=plugins.get_confs(user),
|
||||
)
|
||||
|
||||
|
||||
@celery.app.task(name="listenbrainz.trigger_favorite_sync_with_listenbrainz")
|
||||
def trigger_favorite_sync_with_listenbrainz():
|
||||
now = timezone.now()
|
||||
active_month = now - datetime.timedelta(days=30)
|
||||
users = (
|
||||
models.User.objects.filter(plugins__code="listenbrainz")
|
||||
.filter(plugins__conf__sync_listenings=True)
|
||||
.filter(last_activity__gte=active_month)
|
||||
)
|
||||
for user in users:
|
||||
plugins.trigger_hook(
|
||||
plugins.FAVORITE_SYNC,
|
||||
user=user,
|
||||
confs=plugins.get_confs(user),
|
||||
)
|
||||
|
||||
|
||||
@celery.app.task(name="listenbrainz.import_listenbrainz_listenings")
|
||||
def import_listenbrainz_listenings(user, user_name, since):
|
||||
client = liblistenbrainz.ListenBrainz()
|
||||
response = client.get_listens(username=user_name, min_ts=since, count=100)
|
||||
listens = response["payload"]["listens"]
|
||||
while listens:
|
||||
add_lb_listenings_to_db(listens, user)
|
||||
new_ts = max(
|
||||
listens,
|
||||
key=lambda obj: datetime.datetime.fromtimestamp(
|
||||
obj.listened_at, timezone.utc
|
||||
),
|
||||
)
|
||||
response = client.get_listens(username=user_name, min_ts=new_ts, count=100)
|
||||
listens = response["payload"]["listens"]
|
||||
|
||||
|
||||
def add_lb_listenings_to_db(listens, user):
|
||||
logger = PLUGIN["logger"]
|
||||
fw_listens = []
|
||||
for listen in listens:
|
||||
if (
|
||||
listen.additional_info.get("submission_client")
|
||||
and listen.additional_info.get("submission_client")
|
||||
== "Funkwhale ListenBrainz plugin"
|
||||
and history_models.Listening.objects.filter(
|
||||
creation_date=datetime.datetime.fromtimestamp(
|
||||
listen.listened_at, timezone.utc
|
||||
)
|
||||
).exists()
|
||||
):
|
||||
logger.info(
|
||||
f"Listen with ts {listen.listened_at} skipped because already in db"
|
||||
)
|
||||
continue
|
||||
|
||||
mbid = (
|
||||
listen.mbid_mapping
|
||||
if hasattr(listen, "mbid_mapping")
|
||||
else listen.recording_mbid
|
||||
)
|
||||
|
||||
if not mbid:
|
||||
logger.info("Received listening that doesn't have a mbid. Skipping...")
|
||||
|
||||
try:
|
||||
track = music_models.Track.objects.get(mbid=mbid)
|
||||
except music_models.Track.DoesNotExist:
|
||||
logger.info(
|
||||
"Received listening that doesn't exist in fw database. Skipping..."
|
||||
)
|
||||
continue
|
||||
|
||||
user = user
|
||||
fw_listen = history_models.Listening(
|
||||
creation_date=datetime.datetime.fromtimestamp(
|
||||
listen.listened_at, timezone.utc
|
||||
),
|
||||
track=track,
|
||||
actor=user.actor,
|
||||
source="Listenbrainz",
|
||||
)
|
||||
fw_listens.append(fw_listen)
|
||||
|
||||
history_models.Listening.objects.bulk_create(fw_listens)
|
||||
|
||||
|
||||
@celery.app.task(name="listenbrainz.import_listenbrainz_favorites")
|
||||
def import_listenbrainz_favorites(user, user_name, since):
|
||||
client = liblistenbrainz.ListenBrainz()
|
||||
response = client.get_user_feedback(username=user_name)
|
||||
offset = 0
|
||||
while response["feedback"]:
|
||||
count = response["count"]
|
||||
offset = offset + count
|
||||
last_sync = min(
|
||||
response["feedback"],
|
||||
key=lambda obj: datetime.datetime.fromtimestamp(
|
||||
obj["created"], timezone.utc
|
||||
),
|
||||
)["created"]
|
||||
add_lb_feedback_to_db(response["feedback"], user)
|
||||
if last_sync <= since or count == 0:
|
||||
return
|
||||
response = client.get_user_feedback(username=user_name, offset=offset)
|
||||
|
||||
|
||||
def add_lb_feedback_to_db(feedbacks, user):
|
||||
logger = PLUGIN["logger"]
|
||||
for feedback in feedbacks:
|
||||
try:
|
||||
track = music_models.Track.objects.get(mbid=feedback["recording_mbid"])
|
||||
except music_models.Track.DoesNotExist:
|
||||
logger.info(
|
||||
"Received feedback track that doesn't exist in fw database. Skipping..."
|
||||
)
|
||||
continue
|
||||
|
||||
if feedback["score"] == 1:
|
||||
favorites_models.TrackFavorite.objects.get_or_create(
|
||||
actor=user.actor,
|
||||
creation_date=datetime.datetime.fromtimestamp(
|
||||
feedback["created"], timezone.utc
|
||||
),
|
||||
track=track,
|
||||
source="Listenbrainz",
|
||||
)
|
||||
elif feedback["score"] == 0:
|
||||
try:
|
||||
favorites_models.TrackFavorite.objects.get(
|
||||
actor=user.actor, track=track
|
||||
).delete()
|
||||
except favorites_models.TrackFavorite.DoesNotExist:
|
||||
continue
|
||||
elif feedback["score"] == -1:
|
||||
logger.info("Funkwhale doesn't support disliked tracks")
|
|
@ -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)
|
||||
|
|
|
@ -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.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"]
|
||||
|
|
|
@ -3,12 +3,28 @@ import factory
|
|||
from funkwhale_api.factories import NoUpdateOnCreate, registry
|
||||
from funkwhale_api.music.factories import TrackFactory
|
||||
from funkwhale_api.users.factories import UserFactory
|
||||
from funkwhale_api.federation.factories import ActorFactory
|
||||
from funkwhale_api.federation import models
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@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=["domain", "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,18 @@
|
|||
# Generated by Django 3.2.20 on 2023-12-09 14:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('favorites', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='trackfavorite',
|
||||
name='source',
|
||||
field=models.CharField(blank=True, max_length=100, null=True),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,75 @@
|
|||
# Generated by Django 4.2.9 on 2024-03-28 23:32
|
||||
|
||||
import uuid
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def gen_uuid(apps, schema_editor):
|
||||
MyModel = apps.get_model("favorites", "TrackFavorite")
|
||||
for row in MyModel.objects.all():
|
||||
row.uuid = uuid.uuid4()
|
||||
row.save(update_fields=["uuid"])
|
||||
|
||||
|
||||
# to do : test_migration (also for listening)
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("federation", "0029_userfollow"),
|
||||
("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.AlterField(
|
||||
model_name="trackfavorite",
|
||||
name="user",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="track_favorites",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="trackfavorite",
|
||||
name="uuid",
|
||||
field=models.UUIDField(default=uuid.uuid4, 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),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,31 @@
|
|||
# Generated by Django 4.2.9 on 2024-04-04 15:56
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
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 = [
|
||||
("federation", "0029_userfollow"),
|
||||
("music", "0057_auto_20221118_2108"),
|
||||
("favorites", "0003_trackfavorite_actor_trackfavorite_fid_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(get_user_actor, reverse_code=migrations.RunPython.noop),
|
||||
migrations.AlterUniqueTogether(
|
||||
name="trackfavorite",
|
||||
unique_together={("track", "actor")},
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name="trackfavorite",
|
||||
name="user",
|
||||
),
|
||||
]
|
|
@ -1,26 +1,88 @@
|
|||
import uuid
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.urls import reverse
|
||||
|
||||
from funkwhale_api.common import fields
|
||||
from funkwhale_api.common import models as common_models
|
||||
|
||||
from funkwhale_api.music.models import Track
|
||||
from funkwhale_api.federation import models as federation_models
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
|
||||
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__privacy_level="everyone")
|
||||
|
||||
if hasattr(actor, "user"):
|
||||
me_query = models.Q(actor__privacy_level="me", actor=actor)
|
||||
me_query = models.Q(actor__privacy_level="me", actor=actor)
|
||||
|
||||
instance_query = models.Q(
|
||||
actor__privacy_level="instance", actor__domain=actor.domain
|
||||
)
|
||||
instance_actor_query = models.Q(
|
||||
actor__privacy_level="instance", actor__domain=actor.domain
|
||||
)
|
||||
|
||||
return self.filter(
|
||||
me_query
|
||||
| instance_query
|
||||
| instance_actor_query
|
||||
| models.Q(actor__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=True,
|
||||
blank=True,
|
||||
)
|
||||
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_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)
|
||||
|
|
|
@ -9,38 +9,28 @@ from funkwhale_api.users.serializers import UserActivitySerializer, UserBasicSer
|
|||
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):
|
||||
|
|
|
@ -4,8 +4,10 @@ from rest_framework import mixins, status, viewsets
|
|||
from rest_framework.decorators import action
|
||||
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
|
||||
|
@ -22,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,
|
||||
|
@ -31,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"]:
|
||||
|
@ -44,7 +47,16 @@ class TrackFavoriteViewSet(
|
|||
instance = self.perform_create(serializer)
|
||||
serializer = self.get_serializer(instance=instance)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
plugins.trigger_hook(
|
||||
plugins.FAVORITE_CREATED,
|
||||
track_favorite=serializer.instance,
|
||||
confs=plugins.get_confs(self.request.user),
|
||||
)
|
||||
record.send(instance)
|
||||
routes.outbox.dispatch(
|
||||
{"type": "Like", "object": {"type": "Track"}},
|
||||
context={"favorite": instance},
|
||||
)
|
||||
return Response(
|
||||
serializer.data, status=status.HTTP_201_CREATED, headers=headers
|
||||
)
|
||||
|
@ -52,7 +64,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__privacy_level", "actor__user"
|
||||
)
|
||||
)
|
||||
tracks = Track.objects.with_playable_uploads(
|
||||
music_utils.get_actor_from_request(self.request)
|
||||
|
@ -64,7 +78,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")
|
||||
|
@ -72,10 +86,19 @@ 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": "Delete", "object": {"type": "Like"}},
|
||||
context={"favorite": favorite},
|
||||
)
|
||||
favorite.delete()
|
||||
plugins.trigger_hook(
|
||||
plugins.FAVORITE_DELETED,
|
||||
track_favorite=favorite,
|
||||
confs=plugins.get_confs(self.request.user),
|
||||
)
|
||||
return Response([], status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@extend_schema(
|
||||
|
@ -92,7 +115,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:
|
||||
|
@ -305,6 +311,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:
|
||||
|
@ -446,11 +453,18 @@ def prepare_deliveries_and_inbox_items(recipient_list, type, allowed_domains=Non
|
|||
elif r == PUBLIC_ADDRESS:
|
||||
urls.append(r)
|
||||
elif isinstance(r, dict) and r["type"] == "followers":
|
||||
# to do : rename user_received_follows to received_follows ? Could clash with Follow model
|
||||
received_follows = (
|
||||
r["target"]
|
||||
.received_follows.filter(approved=True)
|
||||
.select_related("actor__user")
|
||||
)
|
||||
if not received_follows and hasattr(r["target"], "received_user_follows"):
|
||||
received_follows = (
|
||||
r["target"]
|
||||
.received_user_follows.filter(approved=True)
|
||||
.select_related("actor__user")
|
||||
)
|
||||
for follow in received_follows:
|
||||
actor = follow.actor
|
||||
if actor.is_local:
|
||||
|
|
|
@ -77,6 +77,14 @@ class LibraryFollowAdmin(admin.ModelAdmin):
|
|||
list_select_related = True
|
||||
|
||||
|
||||
@admin.register(models.UserFollow)
|
||||
class UserFollowAdmin(admin.ModelAdmin):
|
||||
list_display = ["actor", "target", "approved", "creation_date"]
|
||||
list_filter = ["approved"]
|
||||
search_fields = ["actor__fid", "target__fid"]
|
||||
list_select_related = True
|
||||
|
||||
|
||||
@admin.register(models.InboxItem)
|
||||
class InboxItemAdmin(admin.ModelAdmin):
|
||||
list_display = ["actor", "activity", "type", "is_read"]
|
||||
|
|
|
@ -97,6 +97,30 @@ class LibraryFollowSerializer(serializers.ModelSerializer):
|
|||
return federation_serializers.APIActorSerializer(o.actor).data
|
||||
|
||||
|
||||
class UserFollowSerializer(serializers.ModelSerializer):
|
||||
target = common_serializers.RelatedField(
|
||||
"fid", federation_serializers.APIActorSerializer(), required=True
|
||||
)
|
||||
actor = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.UserFollow
|
||||
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_user_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 +130,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.UserFollow"
|
||||
):
|
||||
data["approved"] = obj.approved
|
||||
|
||||
return data
|
||||
|
||||
|
||||
|
|
|
@ -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,107 @@ 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_user_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.UserFollow.objects.all()
|
||||
.order_by("-creation_date")
|
||||
.select_related("actor", "target")
|
||||
)
|
||||
serializer_class = api_serializers.UserFollowSerializer
|
||||
permission_classes = [oauth_permissions.ScopePermission]
|
||||
required_scope = "follows"
|
||||
# to do :
|
||||
# filterset_class = filters.UserFollowFilter
|
||||
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.UserFollow.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.UserFollow.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)
|
||||
|
|
|
@ -245,6 +245,15 @@ class LibraryFollowFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
|||
model = "federation.LibraryFollow"
|
||||
|
||||
|
||||
@registry.register
|
||||
class UserFollowFactory(NoUpdateOnCreate, factory.django.DjangoModelFactory):
|
||||
target = factory.SubFactory(ActorFactory)
|
||||
actor = factory.SubFactory(ActorFactory)
|
||||
|
||||
class Meta:
|
||||
model = "federation.UserFollow"
|
||||
|
||||
|
||||
class ArtistMetadataFactory(factory.Factory):
|
||||
name = factory.Faker("name")
|
||||
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
# Generated by Django 4.2.9 on 2024-03-27 17:28
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("federation", "0028_auto_20221027_1141"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="UserFollow",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"fid",
|
||||
models.URLField(blank=True, max_length=500, null=True, unique=True),
|
||||
),
|
||||
("uuid", models.UUIDField(default=uuid.uuid4, unique=True)),
|
||||
(
|
||||
"creation_date",
|
||||
models.DateTimeField(default=django.utils.timezone.now),
|
||||
),
|
||||
("modification_date", models.DateTimeField(auto_now=True)),
|
||||
("approved", models.BooleanField(default=None, null=True)),
|
||||
(
|
||||
"actor",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="user_follows",
|
||||
to="federation.actor",
|
||||
),
|
||||
),
|
||||
(
|
||||
"target",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="received_user_follows",
|
||||
to="federation.actor",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("actor", "target")},
|
||||
},
|
||||
),
|
||||
]
|
|
@ -0,0 +1,34 @@
|
|||
# Generated by Django 4.2.9 on 2024-04-17 19:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def gen_privacy_level(apps, schema_editor):
|
||||
user_model = apps.get_model("users", "User")
|
||||
for user in user_model.objects.all():
|
||||
user.actor.privacy_level = user.actor.privacy_level
|
||||
user.actor.save(update_fields=["privacy_level"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("federation", "0029_userfollow"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="actor",
|
||||
name="privacy_level",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("me", "Only me"),
|
||||
("followers", "Me and my followers"),
|
||||
("instance", "Everyone on my instance, and my followers"),
|
||||
("everyone", "Everyone, including people on other instances"),
|
||||
],
|
||||
default="instance",
|
||||
max_length=30,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(gen_privacy_level, reverse_code=migrations.RunPython.noop),
|
||||
]
|
|
@ -14,7 +14,7 @@ from django.dispatch import receiver
|
|||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from funkwhale_api.common import session
|
||||
from funkwhale_api.common import session, fields
|
||||
from funkwhale_api.common import utils as common_utils
|
||||
from funkwhale_api.common import validators as common_validators
|
||||
from funkwhale_api.music import utils as music_utils
|
||||
|
@ -218,6 +218,7 @@ class Actor(models.Model):
|
|||
on_delete=models.SET_NULL,
|
||||
related_name="iconed_actor",
|
||||
)
|
||||
privacy_level = fields.get_privacy_field()
|
||||
|
||||
objects = ActorQuerySet.as_manager()
|
||||
|
||||
|
@ -254,6 +255,8 @@ class Actor(models.Model):
|
|||
def should_autoapprove_follow(self, actor):
|
||||
if self.get_channel():
|
||||
return True
|
||||
if self.user.actor.privacy_level == "public":
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_user(self):
|
||||
|
@ -638,3 +641,15 @@ def update_denormalization_follow_deleted(sender, instance, **kwargs):
|
|||
music_models.TrackActor.objects.filter(
|
||||
actor=instance.actor, upload__in=instance.target.uploads.all()
|
||||
).delete()
|
||||
|
||||
|
||||
class UserFollow(AbstractFollow):
|
||||
actor = models.ForeignKey(
|
||||
Actor, related_name="user_follows", on_delete=models.CASCADE
|
||||
)
|
||||
target = models.ForeignKey(
|
||||
Actor, related_name="received_user_follows", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = ["actor", "target"]
|
||||
|
|
|
@ -5,6 +5,8 @@ from django.db.models import Q
|
|||
|
||||
from funkwhale_api.music import models as music_models
|
||||
|
||||
from funkwhale_api.favorites import models as favorites_models
|
||||
|
||||
from . import activity, actors, models, serializers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -608,3 +610,64 @@ def outbox_delete_album(context):
|
|||
to=[activity.PUBLIC_ADDRESS, {"type": "instances_with_followers"}],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@outbox.register({"type": "Like", "object.type": "Track"})
|
||||
def outbox_create_favorite(context):
|
||||
favorite = context["favorite"]
|
||||
actor = favorite.actor
|
||||
|
||||
serializer = serializers.ActivitySerializer(
|
||||
{
|
||||
"type": "Like",
|
||||
"id": favorite.fid,
|
||||
"object": {"type": "Track", "id": favorite.track.fid},
|
||||
}
|
||||
)
|
||||
yield {
|
||||
"type": "Like",
|
||||
"actor": actor,
|
||||
"payload": with_recipients(
|
||||
serializer.data,
|
||||
to=[{"type": "followers", "target": actor}],
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@outbox.register({"type": "Delete", "object.type": "Like"})
|
||||
def outbox_delete_favorite(context):
|
||||
favorite = context["favorite"]
|
||||
actor = favorite.actor
|
||||
serializer = serializers.ActivitySerializer(
|
||||
{"type": "Delete", "object": {"type": "Like", "id": favorite.fid}}
|
||||
)
|
||||
yield {
|
||||
"type": "Delete",
|
||||
"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": "Delete", "object.type": "Like"})
|
||||
def inbox_delete_favorite(payload, context):
|
||||
actor = context["actor"]
|
||||
favorite_id = payload["object"].get("id")
|
||||
|
||||
query = Q(fid=favorite_id) & Q(actor=actor)
|
||||
try:
|
||||
favorite = favorites_models.TrackFavorite.objects.get(query)
|
||||
except favorites_models.TrackFavorite.DoesNotExist:
|
||||
logger.debug("Discarding deletion of unkwnown favorite %s", favorite_id)
|
||||
return
|
||||
favorite.delete()
|
||||
|
|
|
@ -12,6 +12,7 @@ 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.moderation import models as moderation_models
|
||||
from funkwhale_api.moderation import serializers as moderation_serializers
|
||||
from funkwhale_api.moderation import signals as moderation_signals
|
||||
|
@ -20,7 +21,7 @@ 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
|
||||
from funkwhale_api.federation import activity, actors, contexts, jsonld, models, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -644,9 +645,14 @@ class FollowSerializer(serializers.Serializer):
|
|||
|
||||
def save(self, **kwargs):
|
||||
target = self.validated_data["object"]
|
||||
|
||||
if target._meta.label == "music.Library":
|
||||
follow_class = models.LibraryFollow
|
||||
elif (
|
||||
target._meta.label == "federation.Actor"
|
||||
and target.type == "Person"
|
||||
and not target.get_channel()
|
||||
):
|
||||
follow_class = models.UserFollow
|
||||
else:
|
||||
follow_class = models.Follow
|
||||
defaults = kwargs
|
||||
|
@ -723,6 +729,10 @@ class FollowActionSerializer(serializers.Serializer):
|
|||
if target._meta.label == "music.Library":
|
||||
expected = target.actor
|
||||
follow_class = models.LibraryFollow
|
||||
# to do : what if the follow is an AP follow of an non fw object ?
|
||||
elif target._meta.label == "federation.Actor" and not target.get_channel():
|
||||
expected = target
|
||||
follow_class = models.UserFollow
|
||||
else:
|
||||
expected = target
|
||||
follow_class = models.Follow
|
||||
|
@ -804,6 +814,8 @@ class UndoFollowSerializer(serializers.Serializer):
|
|||
|
||||
if target._meta.label == "music.Library":
|
||||
follow_class = models.LibraryFollow
|
||||
elif target._meta.label == "federation.Actor" and not target.get_channel():
|
||||
follow_class = models.UserFollow
|
||||
else:
|
||||
follow_class = models.Follow
|
||||
|
||||
|
@ -812,7 +824,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):
|
||||
|
@ -879,7 +893,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
|
||||
|
@ -2076,3 +2089,42 @@ 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,
|
||||
)
|
||||
|
|
|
@ -19,6 +19,8 @@ music_router.register(r"uploads", views.MusicUploadViewSet, "uploads")
|
|||
music_router.register(r"artists", views.MusicArtistViewSet, "artists")
|
||||
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,8 +7,11 @@ 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.history import models as history_models
|
||||
from funkwhale_api.favorites import models as favorites_models
|
||||
from funkwhale_api.federation import utils as federation_utils
|
||||
from funkwhale_api.moderation import models as moderation_models
|
||||
from funkwhale_api.music import models as music_models
|
||||
|
@ -170,17 +173,83 @@ 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())
|
||||
followers.extend(
|
||||
actor.received_user_follows.filter(approved=True).values_list(
|
||||
"actor", flat=True
|
||||
)
|
||||
)
|
||||
actors_followers = models.Actor.objects.filter(pk__in=followers)
|
||||
conf = {
|
||||
"id": federation_utils.full_url(
|
||||
reverse(
|
||||
"federation:actors-followers",
|
||||
kwargs={"preferred_username": actor.preferred_username},
|
||||
)
|
||||
),
|
||||
"items": actors_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)
|
||||
)
|
||||
followings.extend(
|
||||
actor.user_follows.filter(approved=True).values_list("target", flat=True)
|
||||
)
|
||||
actors_followings = models.Actor.objects.filter(pk__in=followings).order_by(
|
||||
"preferred_username"
|
||||
)
|
||||
conf = {
|
||||
"id": federation_utils.full_url(
|
||||
reverse(
|
||||
"federation:actors-following",
|
||||
kwargs={"preferred_username": actor.preferred_username},
|
||||
)
|
||||
),
|
||||
"items": actors_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()
|
||||
# to do : listens endpoint :
|
||||
history_models.Listening.objects.filter(actor=actor)
|
||||
|
||||
|
||||
class EditViewSet(FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
|
||||
|
@ -527,3 +596,43 @@ class IndexViewSet(FederationMixin, viewsets.GenericViewSet):
|
|||
)
|
||||
|
||||
return response.Response({}, status=200)
|
||||
|
||||
|
||||
# to do : this should follow privacy_level setting
|
||||
class TrackFavoriteViewSet(
|
||||
FederationMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet
|
||||
):
|
||||
authentication_classes = [authentication.SignatureAuthentication]
|
||||
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]
|
||||
renderer_classes = renderers.get_ap_renderers()
|
||||
queryset = history_models.Listening.objects.local().select_related("track", "actor")
|
||||
# to do :
|
||||
# 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.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,15 @@
|
|||
import factory
|
||||
|
||||
from funkwhale_api.factories import NoUpdateOnCreate, registry
|
||||
from funkwhale_api.music import factories
|
||||
from funkwhale_api.users.factories import UserFactory
|
||||
from funkwhale_api.federation.factories import ActorFactory
|
||||
|
||||
|
||||
@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"
|
||||
|
|
|
@ -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,18 @@
|
|||
# Generated by Django 3.2.20 on 2023-12-09 14:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('history', '0002_auto_20180325_1433'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='listening',
|
||||
name='source',
|
||||
field=models.CharField(blank=True, max_length=100, null=True),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,60 @@
|
|||
# Generated by Django 4.2.9 on 2024-03-28 23:32
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def gen_uuid(apps, schema_editor):
|
||||
MyModel = apps.get_model("history", "Listening")
|
||||
for row in MyModel.objects.all():
|
||||
row.uuid = uuid.uuid4()
|
||||
row.save(update_fields=["uuid"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("federation", "0029_userfollow"),
|
||||
("history", "0003_listening_source"),
|
||||
]
|
||||
|
||||
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(
|
||||
db_index=True,
|
||||
default="https://default.fid",
|
||||
max_length=500,
|
||||
unique=True,
|
||||
),
|
||||
preserve_default=False,
|
||||
),
|
||||
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),
|
||||
),
|
||||
]
|
|
@ -0,0 +1,25 @@
|
|||
# Generated by Django 4.2.9 on 2024-04-04 15:10
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
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", "0004_listening_actor_listening_fid_listening_url"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(get_user_actor, reverse_code=migrations.RunPython.noop),
|
||||
migrations.RemoveField(
|
||||
model_name="listening",
|
||||
name="user",
|
||||
),
|
||||
]
|
|
@ -1,25 +1,59 @@
|
|||
import uuid
|
||||
|
||||
from django.db import models
|
||||
from django.utils import timezone
|
||||
from django.urls import reverse
|
||||
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",
|
||||
# if actor is null it's a local TrackFavorite, maybe we should use `attributed_to` ?
|
||||
# Maybe we should use user instead : if user is null it's a remote object :
|
||||
# and delete the user attribute, but might be more work
|
||||
actor = models.ForeignKey(
|
||||
"federation.Actor",
|
||||
related_name="listenings",
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
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_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)
|
||||
|
|
|
@ -12,47 +12,37 @@ 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):
|
||||
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)
|
||||
|
|
|
@ -18,9 +18,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 +27,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):
|
||||
|
@ -49,7 +48,9 @@ class ListeningViewSet(
|
|||
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__privacy_level", "actor__user"
|
||||
)
|
||||
)
|
||||
tracks = Track.objects.with_playable_uploads(
|
||||
music_utils.get_actor_from_request(self.request)
|
||||
|
|
|
@ -295,7 +295,7 @@ class ManageActorFilterSet(filters.FilterSet):
|
|||
|
||||
class Meta:
|
||||
model = federation_models.Actor
|
||||
fields = ["domain", "type", "manually_approves_followers"]
|
||||
fields = ["domain", "type", "manually_approves_followers", "privacy_level"]
|
||||
|
||||
def filter_local(self, queryset, name, value):
|
||||
return queryset.local(value)
|
||||
|
@ -316,7 +316,6 @@ class ManageUserFilterSet(filters.FilterSet):
|
|||
model = users_models.User
|
||||
fields = [
|
||||
"is_active",
|
||||
"privacy_level",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
"permission_library",
|
||||
|
|
|
@ -43,7 +43,6 @@ class ManageUserSimpleSerializer(serializers.ModelSerializer):
|
|||
"is_superuser",
|
||||
"date_joined",
|
||||
"last_activity",
|
||||
"privacy_level",
|
||||
"upload_quota",
|
||||
)
|
||||
|
||||
|
@ -67,7 +66,6 @@ class ManageUserSerializer(serializers.ModelSerializer):
|
|||
"date_joined",
|
||||
"last_activity",
|
||||
"permissions",
|
||||
"privacy_level",
|
||||
"upload_quota",
|
||||
"full_username",
|
||||
)
|
||||
|
@ -224,6 +222,7 @@ class ManageBaseActorSerializer(serializers.ModelSerializer):
|
|||
"shared_inbox_url",
|
||||
"manually_approves_followers",
|
||||
"is_local",
|
||||
"privacy_level",
|
||||
]
|
||||
read_only_fields = ["creation_date", "instance_policy"]
|
||||
|
||||
|
|
|
@ -94,7 +94,7 @@ class PlaylistViewSet(
|
|||
return serializer.save(
|
||||
user=self.request.user,
|
||||
privacy_level=serializer.validated_data.get(
|
||||
"privacy_level", self.request.user.privacy_level
|
||||
"privacy_level", self.request.user.actor.privacy_level
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
@ -148,7 +148,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__content_category="music")
|
||||
|
||||
|
||||
|
@ -290,17 +292,17 @@ class SimilarRadio(RelatedObjectRadio):
|
|||
FROM (
|
||||
SELECT
|
||||
track_id,
|
||||
creation_date,
|
||||
h.creation_date,
|
||||
LEAD(track_id) OVER (
|
||||
PARTITION by user_id order by creation_date asc
|
||||
PARTITION by actor_id ORDER BY h.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
|
||||
) t WHERE track_id = %s AND next != %s GROUP BY next ORDER BY c DESC;
|
||||
FROM history_listening h
|
||||
INNER JOIN federation_actor fa ON (fa.id = h.actor_id)
|
||||
WHERE fa.privacy_level = 'instance' OR fa.privacy_level = 'everyone' OR h.actor_id = %s
|
||||
ORDER BY h.creation_date ASC
|
||||
) t WHERE track_id = %s AND next IS NOT NULL AND next != %s GROUP BY next ORDER BY c DESC;
|
||||
"""
|
||||
cursor.execute(query, [self.session.user_id, seed, seed])
|
||||
cursor.execute(query, [self.session.user.actor, seed, seed])
|
||||
next_candidates = list(cursor.fetchall())
|
||||
|
||||
if not next_candidates:
|
||||
|
@ -334,7 +336,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__content_category="music")
|
||||
.exclude(pk__in=listened)
|
||||
|
@ -350,7 +354,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
|
||||
)
|
||||
|
|
|
@ -188,7 +188,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__content_category="music")
|
||||
|
||||
|
||||
|
@ -325,22 +327,21 @@ class SimilarRadio(RelatedObjectRadio):
|
|||
|
||||
def find_next_id(self, queryset, seed):
|
||||
with connection.cursor() as cursor:
|
||||
query = """
|
||||
SELECT next, count(next) AS c
|
||||
query = """SELECT next, count(next) AS c
|
||||
FROM (
|
||||
SELECT
|
||||
track_id,
|
||||
creation_date,
|
||||
h.creation_date,
|
||||
LEAD(track_id) OVER (
|
||||
PARTITION by user_id order by creation_date asc
|
||||
PARTITION by actor_id ORDER BY h.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
|
||||
) t WHERE track_id = %s AND next != %s GROUP BY next ORDER BY c DESC;
|
||||
FROM history_listening h
|
||||
INNER JOIN federation_actor fa ON (fa.id = h.actor_id)
|
||||
WHERE fa.privacy_level = 'instance' OR fa.privacy_level = 'everyone' OR h.actor_id = %s
|
||||
ORDER BY h.creation_date ASC
|
||||
) t WHERE track_id = %s AND next IS NOT NULL AND next != %s GROUP BY next ORDER BY c DESC;
|
||||
"""
|
||||
cursor.execute(query, [self.session.user_id, seed, seed])
|
||||
cursor.execute(query, [self.session.user.actor, seed, seed])
|
||||
next_candidates = list(cursor.fetchall())
|
||||
|
||||
if not next_candidates:
|
||||
|
@ -374,7 +375,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__content_category="music")
|
||||
.exclude(pk__in=listened)
|
||||
|
@ -390,7 +393,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
|
||||
)
|
||||
|
|
|
@ -314,7 +314,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"]
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -333,14 +333,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(
|
||||
|
@ -350,7 +350,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)
|
||||
|
||||
|
@ -438,7 +438,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)
|
||||
|
||||
|
|
|
@ -36,7 +36,6 @@ def delete_non_alnum_characters(text):
|
|||
def resolve_recordings_to_fw_track(recordings):
|
||||
"""
|
||||
Tries to match a troi recording entity to a fw track using the typesense index.
|
||||
It will save the results in the match_mbid attribute of the Track table.
|
||||
For test purposes : if multiple fw tracks are returned, we log the information
|
||||
but only keep the best result in db to avoid duplicates.
|
||||
"""
|
||||
|
|
|
@ -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")},
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
# Generated by Django 4.2.9 on 2024-04-18 17:24
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
("users", "0023_merge_20221125_1902"),
|
||||
("federation", "0030_actor_privacy_level"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="user",
|
||||
name="privacy_level",
|
||||
),
|
||||
]
|
|
@ -120,7 +120,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
|
||||
|
@ -214,6 +213,10 @@ class User(AbstractUser):
|
|||
u.settings[key] = value
|
||||
u.save(update_fields=["settings"])
|
||||
self.settings = u.settings
|
||||
# to do : this is never called
|
||||
if "privacy_level" in settings:
|
||||
u.actor.privacy_level = settings["privacy_level"]
|
||||
u.actor.save()
|
||||
|
||||
def has_permissions(self, *perms, **kwargs):
|
||||
operator = kwargs.pop("operator", "and")
|
||||
|
|
|
@ -157,7 +157,6 @@ class UserWriteSerializer(serializers.ModelSerializer):
|
|||
model = models.User
|
||||
fields = [
|
||||
"name",
|
||||
"privacy_level",
|
||||
"avatar",
|
||||
"instance_support_message_display_date",
|
||||
"funkwhale_support_message_display_date",
|
||||
|
@ -204,7 +203,6 @@ class UserReadSerializer(serializers.ModelSerializer):
|
|||
"is_superuser",
|
||||
"permissions",
|
||||
"date_joined",
|
||||
"privacy_level",
|
||||
"avatar",
|
||||
]
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ from rest_framework.decorators import action
|
|||
from rest_framework.response import Response
|
||||
|
||||
from funkwhale_api.common import preferences, throttling
|
||||
|
||||
from funkwhale_api.federation import routes
|
||||
from . import models, serializers, tasks
|
||||
|
||||
|
||||
|
@ -94,6 +94,8 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
|
|||
"""Return information about the current user or delete it"""
|
||||
new_settings = request.data
|
||||
request.user.set_settings(**new_settings)
|
||||
if "privacy_level" in new_settings:
|
||||
dispatch_privacy_downgrade(new_settings["privacy_level"], request.user)
|
||||
return Response(request.user.settings)
|
||||
|
||||
@action(
|
||||
|
@ -137,9 +139,17 @@ class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
|
|||
serializer.save(request)
|
||||
return Response(status=204)
|
||||
|
||||
# to do : this work but maybe front should send privacy level update on the actor endpoint an not hte user endpoint ?
|
||||
def update(self, request, *args, **kwargs):
|
||||
if not self.request.user.username == kwargs.get("username"):
|
||||
return Response(status=403)
|
||||
if "privacy_level" in request.data:
|
||||
user = self.get_object()
|
||||
request.data._mutable = True
|
||||
privacy_level = request.data.pop("privacy_level")
|
||||
request.data._mutable = False
|
||||
user.actor.privacy_level = privacy_level[0]
|
||||
user.actor.save()
|
||||
return super().update(request, *args, **kwargs)
|
||||
|
||||
def partial_update(self, request, *args, **kwargs):
|
||||
|
@ -179,3 +189,18 @@ def logout(request):
|
|||
response = http.HttpResponse(status=200)
|
||||
response.set_cookie("csrftoken", token, max_age=None)
|
||||
return response
|
||||
|
||||
|
||||
# to do : privacy downgrade
|
||||
def dispatch_privacy_downgrade(privacy_level, user):
|
||||
if privacy_level == "me" or privacy_level == "instance":
|
||||
# this will automatically delete all related actor acitivities
|
||||
routes.outbox.dispatch(
|
||||
{"type": "Delete", "object": {"type": user.actor.type}},
|
||||
context={"actor": user.actor},
|
||||
)
|
||||
if privacy_level == "followers":
|
||||
routes.outbox.dispatch(
|
||||
{"type": "Update", "object": {"type": user.actor.type}},
|
||||
context={"actor": user.actor},
|
||||
)
|
||||
|
|
|
@ -2,18 +2,27 @@ 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)
|
||||
|
||||
# to do : only support local activities update to suport federated activities
|
||||
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"]()
|
||||
user.create_actor(privacy_level="everyone")
|
||||
user2 = factories["users.User"]()
|
||||
user2.create_actor(privacy_level="instance")
|
||||
|
||||
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]
|
||||
# to do : test others
|
||||
|
|
|
@ -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"]()
|
||||
user.create_actor(privacy_level="everyone")
|
||||
factories["favorites.TrackFavorite"](actor=user.actor)
|
||||
factories["history.Listening"]()
|
||||
url = reverse("api:v1:activity-list")
|
||||
objects = utils.get_activity(anonymous_user)
|
||||
|
|
|
@ -1,25 +1,91 @@
|
|||
import pytest
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.db.models import Q
|
||||
from django.db.models import Q, QuerySet
|
||||
|
||||
from funkwhale_api.common import fields
|
||||
from funkwhale_api.users.factories import UserFactory
|
||||
|
||||
from funkwhale_api.history import models
|
||||
from funkwhale_api.favorites import models as favorite_models
|
||||
from funkwhale_api.federation import models as federation_models
|
||||
|
||||
|
||||
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__pk__in=user.actor.user_follows.filter(
|
||||
approved=True
|
||||
).values_list("target", flat=True),
|
||||
)
|
||||
)
|
||||
|
||||
@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):
|
||||
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"]()
|
||||
target.create_actor(privacy_level="followers")
|
||||
|
||||
target.refresh_from_db()
|
||||
|
||||
userfollow = factories["federation.UserFollow"](
|
||||
actor=user.actor, 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__privacy_level", "actor__user")
|
||||
)
|
||||
fav_qs = favorite_models.TrackFavorite.objects.all().filter(
|
||||
fields.privacy_level_query(user, "actor__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"]()
|
||||
target.create_actor(privacy_level="followers")
|
||||
|
||||
target.refresh_from_db()
|
||||
|
||||
userfollow = factories["federation.UserFollow"](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__privacy_level", "actor__user")
|
||||
)
|
||||
fav_qs = favorite_models.TrackFavorite.objects.all().filter(
|
||||
fields.privacy_level_query(user, "actor__privacy_level", "actor__user")
|
||||
)
|
||||
|
||||
assert listening not in queryset
|
||||
assert favorite not in fav_qs
|
||||
|
||||
|
||||
def test_generic_relation_field(factories):
|
||||
|
|
|
@ -39,3 +39,58 @@ 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), ("instance", False), ("everyone", True)],
|
||||
)
|
||||
def test_privacylevel_permission_anonymous(
|
||||
factories, api_request, anonymous_user, privacy_level, expected
|
||||
):
|
||||
user = factories["users.User"]()
|
||||
user.create_actor(privacy_level=privacy_level)
|
||||
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), ("instance", True), ("everyone", True)],
|
||||
)
|
||||
def test_privacylevel_permission_instance(
|
||||
factories, api_request, anonymous_user, privacy_level, expected, mocker
|
||||
):
|
||||
user = factories["users.User"]()
|
||||
user.create_actor(privacy_level=privacy_level)
|
||||
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), ("instance", True), ("everyone", True)],
|
||||
)
|
||||
def test_privacylevel_permission_me(
|
||||
factories, api_request, anonymous_user, privacy_level, expected, mocker
|
||||
):
|
||||
user = factories["users.User"]()
|
||||
user.create_actor(privacy_level=privacy_level)
|
||||
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
|
||||
|
|
|
@ -0,0 +1,334 @@
|
|||
import datetime
|
||||
import logging
|
||||
|
||||
import liblistenbrainz
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from config import plugins
|
||||
from funkwhale_api.contrib.listenbrainz import funkwhale_ready
|
||||
from funkwhale_api.favorites import models as favorites_models
|
||||
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.",
|
||||
conf=[],
|
||||
source=False,
|
||||
)
|
||||
handler = mocker.Mock()
|
||||
plugins.register_hook(plugins.LISTENING_CREATED, config)(handler)
|
||||
plugins.set_conf(
|
||||
"listenbrainz",
|
||||
{
|
||||
"sync_listenings": True,
|
||||
"sync_favorites": True,
|
||||
"submit_favorites": True,
|
||||
"sync_favorites": True,
|
||||
"user_token": "blablabla",
|
||||
},
|
||||
user=logged_in_client.user,
|
||||
)
|
||||
plugins.enable_conf("listenbrainz", True, logged_in_client.user)
|
||||
|
||||
track = factories["music.Track"]()
|
||||
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(actor=logged_in_client.user.actor)
|
||||
handler.assert_called_once_with(listening=listening, conf=None)
|
||||
|
||||
|
||||
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"]()
|
||||
|
||||
factories["music.Track"](mbid="f89db7f8-4a1f-4228-a0a1-e7ba028b7476")
|
||||
track = factories["music.Track"](mbid="54c60860-f43d-484e-b691-7ab7ec8de559")
|
||||
factories["history.Listening"](
|
||||
creation_date=datetime.datetime.fromtimestamp(1871, timezone.utc), track=track
|
||||
)
|
||||
|
||||
conf = {
|
||||
"user_name": user.username,
|
||||
"user_token": "user_tolkien",
|
||||
"sync_listenings": True,
|
||||
}
|
||||
|
||||
listens = {
|
||||
"payload": {
|
||||
"count": 25,
|
||||
"user_id": "-- the MusicBrainz ID of the user --",
|
||||
"listens": [
|
||||
liblistenbrainz.Listen(
|
||||
track_name="test",
|
||||
artist_name="artist_test",
|
||||
recording_mbid="f89db7f8-4a1f-4228-a0a1-e7ba028b7476",
|
||||
additional_info={"submission_client": "not funkwhale"},
|
||||
listened_at=-3124224000,
|
||||
),
|
||||
liblistenbrainz.Listen(
|
||||
track_name="test2",
|
||||
artist_name="artist_test2",
|
||||
recording_mbid="54c60860-f43d-484e-b691-7ab7ec8de559",
|
||||
additional_info={
|
||||
"submission_client": "Funkwhale ListenBrainz plugin"
|
||||
},
|
||||
listened_at=1871,
|
||||
),
|
||||
liblistenbrainz.Listen(
|
||||
track_name="test3",
|
||||
artist_name="artist_test3",
|
||||
listened_at=0,
|
||||
),
|
||||
],
|
||||
}
|
||||
}
|
||||
no_more_listen = {
|
||||
"payload": {
|
||||
"count": 25,
|
||||
"user_id": "Bilbo",
|
||||
"listens": [],
|
||||
}
|
||||
}
|
||||
|
||||
mocker.patch.object(
|
||||
funkwhale_ready.tasks.liblistenbrainz.ListenBrainz,
|
||||
"get_listens",
|
||||
side_effect=[listens, no_more_listen],
|
||||
)
|
||||
|
||||
funkwhale_ready.sync_listenings_from_listenbrainz(user, conf)
|
||||
|
||||
assert history_models.Listening.objects.filter(
|
||||
track__mbid="f89db7f8-4a1f-4228-a0a1-e7ba028b7476"
|
||||
).exists()
|
||||
|
||||
assert "Listen with ts 1871 skipped because already in db" in caplog.text
|
||||
assert "Received listening that doesn't have a mbid. Skipping..." in caplog.text
|
||||
|
||||
|
||||
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"](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, actor=user.actor)
|
||||
# last_sync
|
||||
track_last_sync = factories["music.Track"](
|
||||
mbid="c878ef2f-c08d-4a81-a047-f2a9f978cec7"
|
||||
)
|
||||
factories["favorites.TrackFavorite"](track=track_last_sync, source="Listenbrainz")
|
||||
|
||||
conf = {
|
||||
"user_name": user.username,
|
||||
"user_token": "user_tolkien",
|
||||
"sync_favorites": True,
|
||||
}
|
||||
|
||||
feedbacks = {
|
||||
"count": 5,
|
||||
"feedback": [
|
||||
{
|
||||
"created": 1701116226,
|
||||
"recording_mbid": "195565db-65f9-4d0d-b347-5f0c85509528",
|
||||
"score": 1,
|
||||
"user_id": user.username,
|
||||
},
|
||||
{
|
||||
"created": 1701116214,
|
||||
"recording_mbid": "c5af5351-dbbf-4481-b52e-a480b6c57986",
|
||||
"score": 0,
|
||||
"user_id": user.username,
|
||||
},
|
||||
{
|
||||
# last sync
|
||||
"created": 1690775094,
|
||||
"recording_mbid": "c878ef2f-c08d-4a81-a047-f2a9f978cec7",
|
||||
"score": -1,
|
||||
"user_id": user.username,
|
||||
},
|
||||
{
|
||||
"created": 1690775093,
|
||||
"recording_mbid": "1fd02cf2-7247-4715-8862-c378ec1965d2",
|
||||
"score": 1,
|
||||
"user_id": user.username,
|
||||
},
|
||||
],
|
||||
"offset": 0,
|
||||
"total_count": 4,
|
||||
}
|
||||
empty_feedback = {"count": 0, "feedback": [], "offset": 0, "total_count": 0}
|
||||
mocker.patch.object(
|
||||
funkwhale_ready.tasks.liblistenbrainz.ListenBrainz,
|
||||
"get_user_feedback",
|
||||
side_effect=[feedbacks, empty_feedback],
|
||||
)
|
||||
|
||||
funkwhale_ready.sync_favorites_from_listenbrainz(user, conf)
|
||||
|
||||
assert favorites_models.TrackFavorite.objects.filter(
|
||||
track__mbid="195565db-65f9-4d0d-b347-5f0c85509528"
|
||||
).exists()
|
||||
with pytest.raises(favorites_models.TrackFavorite.DoesNotExist):
|
||||
favorite.refresh_from_db()
|
||||
|
||||
|
||||
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"](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, actor=user.actor)
|
||||
# track should be not synced
|
||||
factories["music.Track"](mbid="1fd02cf2-7247-4715-8862-c378ec196000")
|
||||
# last_sync
|
||||
track_last_sync = factories["music.Track"](
|
||||
mbid="c878ef2f-c08d-4a81-a047-f2a9f978cec7"
|
||||
)
|
||||
factories["favorites.TrackFavorite"](
|
||||
track=track_last_sync,
|
||||
actor=user.actor,
|
||||
source="Listenbrainz",
|
||||
creation_date=datetime.datetime.fromtimestamp(1690775094),
|
||||
)
|
||||
|
||||
conf = {
|
||||
"user_name": user.username,
|
||||
"user_token": "user_tolkien",
|
||||
"sync_favorites": True,
|
||||
}
|
||||
|
||||
feedbacks = {
|
||||
"count": 5,
|
||||
"feedback": [
|
||||
{
|
||||
"created": 1701116226,
|
||||
"recording_mbid": "195565db-65f9-4d0d-b347-5f0c85509528",
|
||||
"score": 1,
|
||||
"user_id": user.username,
|
||||
},
|
||||
{
|
||||
"created": 1701116214,
|
||||
"recording_mbid": "c5af5351-dbbf-4481-b52e-a480b6c57986",
|
||||
"score": 0,
|
||||
"user_id": user.username,
|
||||
},
|
||||
{
|
||||
# last sync
|
||||
"created": 1690775094,
|
||||
"recording_mbid": "c878ef2f-c08d-4a81-a047-f2a9f978cec7",
|
||||
"score": -1,
|
||||
"user_id": user.username,
|
||||
},
|
||||
{
|
||||
"created": 1690775093,
|
||||
"recording_mbid": "1fd02cf2-7247-4715-8862-c378ec1965d2",
|
||||
"score": 1,
|
||||
"user_id": user.username,
|
||||
},
|
||||
],
|
||||
"offset": 0,
|
||||
"total_count": 4,
|
||||
}
|
||||
second_feedback = {
|
||||
"count": 0,
|
||||
"feedback": [
|
||||
{
|
||||
"created": 0,
|
||||
"recording_mbid": "1fd02cf2-7247-4715-8862-c378ec196000",
|
||||
"score": 1,
|
||||
"user_id": user.username,
|
||||
},
|
||||
],
|
||||
"offset": 0,
|
||||
"total_count": 0,
|
||||
}
|
||||
mocker.patch.object(
|
||||
funkwhale_ready.tasks.liblistenbrainz.ListenBrainz,
|
||||
"get_user_feedback",
|
||||
side_effect=[feedbacks, second_feedback],
|
||||
)
|
||||
|
||||
funkwhale_ready.sync_favorites_from_listenbrainz(user, conf)
|
||||
|
||||
assert favorites_models.TrackFavorite.objects.filter(
|
||||
track__mbid="195565db-65f9-4d0d-b347-5f0c85509528"
|
||||
).exists()
|
||||
assert not favorites_models.TrackFavorite.objects.filter(
|
||||
track__mbid="1fd02cf2-7247-4715-8862-c378ec196000"
|
||||
).exists()
|
||||
with pytest.raises(favorites_models.TrackFavorite.DoesNotExist):
|
||||
favorite.refresh_from_db()
|
||||
|
||||
|
||||
def test_submit_favorites_to_listenbrainz(factories, mocker, caplog):
|
||||
logger = logging.getLogger("plugins")
|
||||
caplog.set_level(logging.INFO)
|
||||
logger.addHandler(caplog.handler)
|
||||
user = factories["users.User"]()
|
||||
|
||||
factories["music.Track"](mbid="195565db-65f9-4d0d-b347-5f0c85509528")
|
||||
|
||||
factories["music.Track"](mbid="54c60860-f43d-484e-b691-7ab7ec8de559")
|
||||
track = factories["music.Track"](mbid="c5af5351-dbbf-4481-b52e-a480b6c57986")
|
||||
|
||||
favorite = factories["favorites.TrackFavorite"](track=track)
|
||||
conf = {
|
||||
"user_name": user.username,
|
||||
"user_token": "user_tolkien",
|
||||
"submit_favorites": True,
|
||||
}
|
||||
|
||||
patch = mocker.patch.object(
|
||||
funkwhale_ready.tasks.liblistenbrainz.ListenBrainz,
|
||||
"submit_user_feedback",
|
||||
return_value="Success",
|
||||
)
|
||||
funkwhale_ready.submit_favorite_creation(favorite, conf)
|
||||
|
||||
patch.assert_called_once_with(1, track.mbid)
|
||||
|
||||
|
||||
def test_submit_favorites_deletion(factories, mocker, caplog):
|
||||
logger = logging.getLogger("plugins")
|
||||
caplog.set_level(logging.INFO)
|
||||
logger.addHandler(caplog.handler)
|
||||
user = factories["users.User"]()
|
||||
|
||||
factories["music.Track"](mbid="195565db-65f9-4d0d-b347-5f0c85509528")
|
||||
|
||||
factories["music.Track"](mbid="54c60860-f43d-484e-b691-7ab7ec8de559")
|
||||
track = factories["music.Track"](mbid="c5af5351-dbbf-4481-b52e-a480b6c57986")
|
||||
|
||||
favorite = factories["favorites.TrackFavorite"](track=track)
|
||||
conf = {
|
||||
"user_name": user.username,
|
||||
"user_token": "user_tolkien",
|
||||
"submit_favorites": True,
|
||||
}
|
||||
|
||||
patch = mocker.patch.object(
|
||||
funkwhale_ready.tasks.liblistenbrainz.ListenBrainz,
|
||||
"submit_user_feedback",
|
||||
return_value="Success",
|
||||
)
|
||||
funkwhale_ready.submit_favorite_deletion(favorite, conf)
|
||||
|
||||
patch.assert_called_once_with(0, track.mbid)
|
|
@ -1,19 +1,20 @@
|
|||
from funkwhale_api.favorites import activities, serializers
|
||||
from funkwhale_api.music.serializers import TrackActivitySerializer
|
||||
from funkwhale_api.users.serializers import UserActivitySerializer
|
||||
from funkwhale_api.federation.serializers import APIActorSerializer
|
||||
|
||||
|
||||
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,10 @@ 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"]()
|
||||
user.create_actor(privacy_level="me")
|
||||
|
||||
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,9 @@ 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)
|
||||
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,33 @@
|
|||
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"]()
|
||||
user.create_actor(privacy_level=privacy_level)
|
||||
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"]()
|
||||
user.create_actor(privacy_level=privacy_level)
|
||||
|
||||
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,9 @@ 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"]()
|
||||
user.create_actor(privacy_level=level)
|
||||
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_user_follow_serializer_do_not_allow_already_followed(factories):
|
||||
actor = factories["federation.Actor"]()
|
||||
follow = factories["federation.UserFollow"](actor=actor)
|
||||
|
||||
serializer = api_serializers.UserFollowSerializer(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.UserFollow"](target=target_actor, actor=actor)
|
||||
factories["federation.UserFollow"]()
|
||||
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.UserFollow"](target=target_actor, actor=actor)
|
||||
factories["federation.UserFollow"]()
|
||||
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_user_follows(factories, logged_in_api_client):
|
||||
# followed by someont else
|
||||
factories["federation.UserFollow"]()
|
||||
follow = factories["federation.UserFollow"](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_user_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.UserFollow"](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.UserFollow"]()
|
||||
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_user_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.UserFollow"](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}
|
||||
)
|
||||
|
|
|
@ -7,8 +7,11 @@ from funkwhale_api.federation import (
|
|||
jsonld,
|
||||
routes,
|
||||
serializers,
|
||||
utils,
|
||||
)
|
||||
from funkwhale_api.moderation import serializers as moderation_serializers
|
||||
from funkwhale_api.favorites import models as favorites_models
|
||||
from funkwhale_api.favorites import serializers as favorites_serializers
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
@ -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_favorite,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_outbox_routes(route, handler):
|
||||
|
@ -127,6 +138,41 @@ def test_inbox_follow_library_autoapprove(factories, mocker):
|
|||
)
|
||||
|
||||
|
||||
# to do : autoapprove
|
||||
def test_inbox_follow_user_autoapprove(factories, mocker):
|
||||
mocked_outbox_dispatch = mocker.patch(
|
||||
"funkwhale_api.federation.activity.OutboxRouter.dispatch"
|
||||
)
|
||||
|
||||
local_actor = factories["users.User"]().create_actor(privacy_level="public")
|
||||
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_user_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 +1034,74 @@ 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_favorite(factories, mocker):
|
||||
user = factories["users.User"](with_actor=True)
|
||||
favorite = factories["favorites.TrackFavorite"](actor=user.actor)
|
||||
userfollow = factories["federation.UserFollow"](target=favorite.actor)
|
||||
|
||||
activity = list(routes.outbox_create_favorite({"favorite": favorite}))[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_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 args[1]["context"] == {"activity": activity, "actor": favorite.actor}
|
||||
assert save.call_count == 1
|
||||
assert favorites_models.TrackFavorite.objects.filter(
|
||||
track=favorite.track, actor=favorite.actor
|
||||
).exists()
|
||||
|
||||
|
||||
def test_routes_user(factories):
|
||||
favorite = factories["favorites.TrackFavorite"]()
|
||||
follow = factories["federation.UserFollow"](target=favorite.actor, approved=True)
|
||||
data = serializers.TrackFavoriteSerializer(favorite).data
|
||||
serializer = serializers.ActivitySerializer(
|
||||
{
|
||||
"type": "Create",
|
||||
"object": data,
|
||||
"actor": favorite.actor.fid,
|
||||
}
|
||||
)
|
||||
|
||||
activities = routes.inbox.dispatch(
|
||||
serializer.data,
|
||||
context={
|
||||
"activity": serializer.data,
|
||||
"actor": favorite.actor.fid,
|
||||
# "inbox_items": activity.inbox_items.filter(is_read=False).order_by("id"),
|
||||
},
|
||||
)
|
||||
assert len(activities) == 1
|
||||
|
|
|
@ -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.UserFollow")],
|
||||
)
|
||||
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.UserFollow.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,35 @@ 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.UserFollow"](target=actor, approved=True)
|
||||
factories["federation.UserFollow"](target=actor, approved=True)
|
||||
factories["federation.UserFollow"](target=actor, approved=True)
|
||||
factories["federation.UserFollow"](target=actor, approved=True)
|
||||
factories["federation.UserFollow"](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.UserFollow"](actor=actor, approved=True)
|
||||
factories["federation.UserFollow"](actor=actor, approved=True)
|
||||
factories["federation.UserFollow"](actor=actor, approved=True)
|
||||
factories["federation.UserFollow"](actor=actor, approved=True)
|
||||
factories["federation.UserFollow"](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
|
||||
|
|
|
@ -1,11 +1,12 @@
|
|||
from funkwhale_api.history import activities, serializers
|
||||
from funkwhale_api.music.serializers import TrackActivitySerializer
|
||||
from funkwhale_api.users.serializers import UserActivitySerializer
|
||||
from funkwhale_api.federation.serializers import APIActorSerializer
|
||||
|
||||
|
||||
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"]()
|
||||
user.create_actor(privacy_level="me")
|
||||
listening = factories["history.Listening"](actor=user.actor)
|
||||
data = serializers.ListeningActivitySerializer(listening).data
|
||||
consumer = activities.broadcast_listening_to_instance_activity
|
||||
consumer(data=data, obj=listening)
|
||||
|
|
|
@ -6,7 +6,7 @@ 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)
|
||||
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(
|
||||
|
|
|
@ -6,14 +6,13 @@ 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,9 @@ 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"]()
|
||||
user.create_actor(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
|
||||
|
|
|
@ -89,6 +89,7 @@ def test_manage_actor_serializer(factories, now, to_api_date):
|
|||
"user": None,
|
||||
"instance_policy": None,
|
||||
"is_local": False,
|
||||
"privacy_level": "instance",
|
||||
}
|
||||
s = serializers.ManageActorSerializer(actor)
|
||||
|
||||
|
|
|
@ -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,16 +52,17 @@ 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"
|
||||
user.actor.privacy_level = "me"
|
||||
user.save()
|
||||
|
||||
data = {"name": "test"}
|
||||
|
||||
logged_in_api_client.post(url, data)
|
||||
playlist = user.playlists.latest("id")
|
||||
assert playlist.privacy_level == user.privacy_level
|
||||
assert playlist.privacy_level == user.actor.privacy_level
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
@ -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):
|
||||
|
@ -324,10 +324,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()
|
||||
|
@ -346,10 +346,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
|
||||
|
||||
|
||||
|
|
|
@ -339,6 +339,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"]()
|
||||
|
@ -347,30 +348,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})
|
||||
|
||||
|
@ -427,11 +432,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})
|
||||
|
||||
|
@ -627,6 +633,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"](
|
||||
|
@ -658,6 +665,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"](
|
||||
|
@ -832,6 +840,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")
|
||||
|
@ -840,7 +849,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,16 +190,16 @@ 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
|
||||
user.refresh_from_db()
|
||||
|
||||
assert user.privacy_level == "me"
|
||||
assert user.actor.privacy_level == "me"
|
||||
|
||||
|
||||
def test_user_can_patch_description(logged_in_api_client):
|
||||
|
@ -540,3 +540,18 @@ def test_user_change_email(logged_in_api_client, mocker, mailoutbox):
|
|||
assert address.verified is False
|
||||
assert response.status_code == 204
|
||||
assert len(mailoutbox) == 1
|
||||
|
||||
|
||||
# to do :
|
||||
# def test_user_changing_privacy_level_dispatch_delete_activity(
|
||||
# logged_in_api_client, mocker
|
||||
# ):
|
||||
# user = logged_in_api_client.user
|
||||
# payload = {"privacy_level": "me"}
|
||||
# url = reverse("api:v1:users:users-detail", kwargs={"username": user.username})
|
||||
# # mocker.patch("funkwhale_api.users.views.")
|
||||
# response = logged_in_api_client.patch(url, payload)
|
||||
|
||||
# assert response.status_code == 200
|
||||
# user.refresh_from_db()
|
||||
# assert user.actor.privacy_level == "me"
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
Add favorite and listening sync ith Listenbrainz (#2079)
|
|
@ -0,0 +1 @@
|
|||
Add genre tags spec.
|
4
dev.yml
4
dev.yml
|
@ -25,7 +25,7 @@ services:
|
|||
env_file:
|
||||
- .env.dev
|
||||
- .env
|
||||
image: postgres:${POSTGRES_VERSION-11}-alpine
|
||||
image: postgres:${POSTGRES_VERSION-15}-alpine
|
||||
environment:
|
||||
- "POSTGRES_HOST_AUTH_METHOD=trust"
|
||||
command: postgres ${POSTGRES_ARGS-}
|
||||
|
@ -139,6 +139,8 @@ services:
|
|||
- "./front:/frontend:ro"
|
||||
- "./data/staticfiles:/staticfiles:ro"
|
||||
- "./data/media:/protected/media:ro"
|
||||
- "./data/media:/data/media:ro"
|
||||
|
||||
networks:
|
||||
- federation
|
||||
- internal
|
||||
|
|
|
@ -109,6 +109,7 @@ specs/multi-artist/index
|
|||
specs/user-follow/index
|
||||
specs/user-deletion/index
|
||||
specs/upload-process/index
|
||||
specs/genre-tags/index
|
||||
|
||||
```
|
||||
|
||||
|
|
|
@ -0,0 +1,239 @@
|
|||
# Genre tags
|
||||
|
||||
## The issue
|
||||
|
||||
Funkwhale offers users a facility to assign genre tags to items such as tracks, albums, and artists. The `tags_tag` table is populated automatically when new tags are found in uploaded content, and users can also enter custom tags. By default, the table is empty. This means that a user on a new pod won't see any results when attempting to tag items in the frontend.
|
||||
|
||||
## The solution
|
||||
|
||||
To provide the best experience for new Funkwhale users, we should pre-populate this table with [genre tags from Musicbrainz](https://musicbrainz.org/genres). Doing this enables users to easily search for and select the tags they want to assign to their content without needing to create custom tags or upload tagged content.
|
||||
|
||||
Having these tags easily available also facilitates better tagging within Funkwhale in future, reducing the reliance on external tools such as Picard.
|
||||
|
||||
## Feature behavior
|
||||
|
||||
### Backend behavior
|
||||
|
||||
The `tags_tag` table contains the following fields:
|
||||
|
||||
| Field | Data type | Description | Relations | Constraints |
|
||||
| ---------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -------------- |
|
||||
| `id` | Integer | The randomly generated table ID | `tags_taggeditem.tag_id` foreign key | None |
|
||||
| `musicbrainz_id` | UUID | The Musicbrainz genre tag `id`. Used to identify the tag in Musicbrainz fetches | None | None |
|
||||
| `name` | String | The name of the tag. Assigned by Funkwhale during creation for use in URLs. Uses Pascal casing for consistency | None | Must be unique |
|
||||
| `display_name` | String | The name of the tag as the user entered it or as it was originally written by Musicbrainz. Lowercase, normalizes spaces | None | None |
|
||||
| `creation_date` | Timestamp with time zone | The date on which the tag was created | None | None |
|
||||
|
||||
#### Musicbrainz fetch task
|
||||
|
||||
To keep Funkwhale's database up-to-date with Musicbrainz's genre tags, we must fetch information from Musicbrainz periodically. We can use the following endpoint to fetch the information:
|
||||
|
||||
```text
|
||||
https://musicbrainz.org/ws/2/genre/all
|
||||
```
|
||||
|
||||
This endpoint accepts the `application/json` header for a JSON response. See the [Musicbrainz API documentation](https://musicbrainz.org/doc/MusicBrainz_API) for more information. The pagination can be controlled by passing the following options:
|
||||
|
||||
- `limit`: the number of results to return
|
||||
- `offset`: the starting point of the page
|
||||
|
||||
The fetch task should fetch **all** pages, using the response `genre-count` to determine how many offset positions to pass.
|
||||
|
||||
```json
|
||||
{
|
||||
"genre-count": 1913,
|
||||
"genre-offset": 24,
|
||||
"genres": [
|
||||
{
|
||||
"disambiguation": "",
|
||||
"id": "243975aa-1250-4429-8bd3-97080af44cf7",
|
||||
"name": "afro trap"
|
||||
},
|
||||
{
|
||||
"name": "afro-cuban jazz",
|
||||
"id": "cdb11433-1ff1-4c88-be16-717567e1342f",
|
||||
"disambiguation": ""
|
||||
},
|
||||
{
|
||||
"name": "afro-funk",
|
||||
"disambiguation": "",
|
||||
"id": "fc00175b-2be9-4d73-ba91-27b3ca827223"
|
||||
},
|
||||
{
|
||||
"name": "afro-jazz",
|
||||
"disambiguation": "",
|
||||
"id": "6f33d775-b4e2-473c-a7db-e34c525cc52d"
|
||||
},
|
||||
{
|
||||
"disambiguation": "",
|
||||
"id": "a7e0229c-6e53-45f1-a6f2-a791e78b159e",
|
||||
"name": "afro-zouk"
|
||||
},
|
||||
{
|
||||
"disambiguation": "funk/soul + West African sounds",
|
||||
"id": "fcc58a18-9326-4c92-8b29-c294d44379c3",
|
||||
"name": "afrobeat"
|
||||
},
|
||||
{
|
||||
"id": "b8793fdb-bbc8-4418-a6f8-05eafbbe07ef",
|
||||
"disambiguation": "West African urban/pop music",
|
||||
"name": "afrobeats"
|
||||
},
|
||||
{
|
||||
"name": "afropiano",
|
||||
"id": "d42b567f-0952-424b-959d-bee6e5961cc0",
|
||||
"disambiguation": ""
|
||||
},
|
||||
{
|
||||
"disambiguation": "",
|
||||
"id": "52349b68-9cad-496e-8785-00d53f410246",
|
||||
"name": "afroswing"
|
||||
},
|
||||
{
|
||||
"name": "agbadza",
|
||||
"disambiguation": "",
|
||||
"id": "c6d1e78b-ac82-4bb8-89d5-21e3226dc906"
|
||||
},
|
||||
{
|
||||
"disambiguation": "",
|
||||
"id": "b8ae0a3c-5826-4104-9663-fe8f828effa9",
|
||||
"name": "agbekor"
|
||||
},
|
||||
{
|
||||
"name": "aggrotech",
|
||||
"disambiguation": "",
|
||||
"id": "c844c144-90a8-4288-981e-e38275592688"
|
||||
},
|
||||
{
|
||||
"name": "ahwash",
|
||||
"id": "4802e6e4-f403-41d1-8e58-76e5cf4df81d",
|
||||
"disambiguation": ""
|
||||
},
|
||||
{
|
||||
"id": "50cc5641-b4f9-40b7-bf7a-6d903ac6c1c5",
|
||||
"disambiguation": "",
|
||||
"name": "aita"
|
||||
},
|
||||
{
|
||||
"id": "aebbce35-0e8b-40e9-b04c-bebbbda124d0",
|
||||
"disambiguation": "",
|
||||
"name": "akishibu-kei"
|
||||
},
|
||||
{
|
||||
"name": "al jeel",
|
||||
"disambiguation": "",
|
||||
"id": "0f8d3ff4-8cda-42c4-b462-10352cd01606"
|
||||
},
|
||||
{
|
||||
"name": "algerian chaabi",
|
||||
"id": "998efb76-2f98-41c8-8c5f-74c32e405e9f",
|
||||
"disambiguation": ""
|
||||
},
|
||||
{
|
||||
"name": "algorave",
|
||||
"id": "e0a9d0d1-b86f-4344-82a9-022a84627087",
|
||||
"disambiguation": ""
|
||||
},
|
||||
{
|
||||
"name": "alloukou",
|
||||
"disambiguation": "",
|
||||
"id": "e367c884-d94d-4fba-abc4-8ac51d167ccf"
|
||||
},
|
||||
{
|
||||
"disambiguation": "",
|
||||
"id": "ef1d11cc-e70f-4885-ad6c-103f060d33b2",
|
||||
"name": "alpenrock"
|
||||
},
|
||||
{
|
||||
"disambiguation": "",
|
||||
"id": "5f9cba3d-1a9f-46cd-8c49-7ed78d1f3354",
|
||||
"name": "alternative country"
|
||||
},
|
||||
{
|
||||
"name": "alternative dance",
|
||||
"id": "8301f73c-9166-4108-bfeb-4fd22dc19083",
|
||||
"disambiguation": ""
|
||||
},
|
||||
{
|
||||
"name": "alternative folk",
|
||||
"id": "0b48a36c-630f-4ee7-8cf3-480e3dd8be65",
|
||||
"disambiguation": ""
|
||||
},
|
||||
{
|
||||
"name": "alternative hip hop",
|
||||
"disambiguation": "",
|
||||
"id": "924943cd-73c8-45c0-96eb-74f2a15e5d6e"
|
||||
},
|
||||
{
|
||||
"disambiguation": "",
|
||||
"id": "7c4d0994-4c49-4c74-8763-df27fc0084cc",
|
||||
"name": "alté"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The fetch task should run _initially upon first startup_ and then _monthly_ thereafter. The pod admin must be able to disable this job or run it manually at their discretion.
|
||||
|
||||
The task should use the following logic:
|
||||
|
||||
1. Call the Musicbrainz API to fetch new data
|
||||
2. Verify the listed entries against the Funkwhale tag table. The `id` field in the response should be checked against the `musicbrainz_id` field
|
||||
3. Any entries that do not currently exist in Funkwhale should be added with the following mapping:
|
||||
|
||||
| Musicbrainz response field | Tags table column | Notes |
|
||||
| -------------------------- | ----------------- | --------------------------------------------------------------------------------- |
|
||||
| `id` | `musicbrainz_id` | |
|
||||
| `name` | `display_name` | Funkwhale should automatically generate a Pascal cased `name` based on this entry |
|
||||
|
||||
4. If the `display_name` of a tag **exactly matches** a `name` in the Musicbrainz response but the tag has no `musicbrainz_id`, the `musicbrainz_id` should be populated
|
||||
|
||||
### Frontend behavior
|
||||
|
||||
#### Tagged uploads
|
||||
|
||||
When a user uploads new content with genre tags, the tagged item should be linked to any existing tags and new ones should be created if they're not found.
|
||||
|
||||
#### In-app tagging
|
||||
|
||||
When a user uploads new content with _no_ genre tags, they should be able to select tags from a dropdown menu. This menu is populated with the tags from the database with the `display_name` shown in the list. When a tag is selected, the item is linked to the associated tag.
|
||||
|
||||
If a user inserts a new tag, Funkwhale should:
|
||||
|
||||
1. Store the entered string as the tag's `display_name`
|
||||
2. Generate a Pascal cased `name` for the tag
|
||||
3. Associate the targeted object with the new tag
|
||||
|
||||
#### Search results
|
||||
|
||||
Users should be able to search for tags using Funkwhale's in-app search. In search autocomplete and search results page, the `display_name` should be used. The `name` of the tag should be used to populate the search URL.
|
||||
|
||||
#### Cards
|
||||
|
||||
The `display_name` of the tag should be shown in pills against cards.
|
||||
|
||||
### Admin options
|
||||
|
||||
If the admin of a server wants to **disable** MusicBrainz tagging, they should be able to toggle this in their instance settings. If the setting is **disabled**:
|
||||
|
||||
- The sync task should stop running
|
||||
- Any tags with an `musicbrainz_id` should be excluded from API queries.
|
||||
|
||||
## Availability
|
||||
|
||||
- [x] Admin panel
|
||||
- [x] App frontend
|
||||
- [x] CLI
|
||||
|
||||
## Responsible parties
|
||||
|
||||
- Backend group:
|
||||
- Update the tracks table to support the new information
|
||||
- Update the API to support the new information, or create a new v2 endpoint
|
||||
- Create the new fetch task
|
||||
- Add admin controls for the new task
|
||||
- Frontend group:
|
||||
- Update views to use `display_name` instead of `name` for tag results
|
||||
- Update API calls to use the new API structure created by the backend group
|
||||
- Documentation group:
|
||||
- Document the new task and settings for admins
|
|
@ -19,8 +19,8 @@ The **ListenBrainz** plugin enables you to submit ({term}`scrobble<Scrobbling>`)
|
|||
|
||||
:::
|
||||
|
||||
:::{tab-item} Desktop
|
||||
:sync: desktop
|
||||
:::{tab-item} Mobile
|
||||
:sync: mobile
|
||||
|
||||
1. Log in to your account.
|
||||
2. Select the cog icon ({fa}`cog`) or your avatar to open the {guilabel}`Options` menu.
|
||||
|
@ -36,3 +36,48 @@ The **ListenBrainz** plugin enables you to submit ({term}`scrobble<Scrobbling>`)
|
|||
::::
|
||||
|
||||
That's it! You've set up the **ListenBrainz** plugin. When you listen to tracks, the plugin sends the information to ListenBrainz.
|
||||
|
||||
## Enable data synchronization
|
||||
|
||||
The ListenBrainz plugin supports synchronizing listenings and track favorites with Funkwhale. To enable support for synchronization:
|
||||
|
||||
::::{tab-set}
|
||||
|
||||
:::{tab-item} Desktop
|
||||
:sync: desktop
|
||||
|
||||
1. Log in to your account.
|
||||
2. Select the cog icon ({fa}`cog`) or your avatar to expand the user menu.
|
||||
3. Select {guilabel}`Settings`.
|
||||
4. Scroll down to the {guilabel}`Plugins` section.
|
||||
5. Select {guilabel}`Manage plugins`.
|
||||
6. Find the {guilabel}`ListenBrainz` plugin.
|
||||
7. Enter {guilabel}`Your ListenBrainz user name`. You can find this on your ListenBrainz profile.
|
||||
8. Select the data you want to synchronize. The following options are available:
|
||||
- {guilabel}`Enable listenings submission to ListenBrainz`: submit your Funkwhale listens to ListenBrainz.
|
||||
- {guilabel}`Enable listenings sync`: pull listening data from ListenBrainz into Funkwhale.
|
||||
- {guilabel}`Enable favorite submission to ListenBrainz services`: submit your Funkwhale favorites activity to ListenBrainz.
|
||||
- {guilabel}`Enable favorite sync`: pull favorites data from ListenBrainz into Funkwhale.
|
||||
9. Select {guilabel}`Save`.
|
||||
|
||||
:::
|
||||
|
||||
:::{tab-item} Mobile
|
||||
:sync: mobile
|
||||
|
||||
1. Log in to your account.
|
||||
2. Select the cog icon ({fa}`cog`) or your avatar to open the {guilabel}`Options` menu.
|
||||
3. Select {guilabel}`Settings`.
|
||||
4. Scroll down to the {guilabel}`Plugins` section.
|
||||
5. Select {guilabel}`Manage plugins`.
|
||||
6. Find the {guilabel}`ListenBrainz` plugin.
|
||||
7. Enter {guilabel}`Your ListenBrainz user name`. You can find this on your ListenBrainz profile.
|
||||
8. Select the data you want to synchronize. The following options are available:
|
||||
- {guilabel}`Enable listenings submission to ListenBrainz`: submit your Funkwhale listens to ListenBrainz.
|
||||
- {guilabel}`Enable listenings sync`: pull listening data from ListenBrainz into Funkwhale.
|
||||
- {guilabel}`Enable favorite submission to ListenBrainz services`: submit your Funkwhale favorites activity to ListenBrainz.
|
||||
- {guilabel}`Enable favorite sync`: pull favorites data from ListenBrainz into Funkwhale.
|
||||
9. Select {guilabel}`Save`.
|
||||
|
||||
:::
|
||||
::::
|
||||
|
|
|
@ -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'
|
||||
|
@ -61,6 +61,38 @@ 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 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.full_username }),
|
||||
acceptFollow: {
|
||||
buttonClass: 'success',
|
||||
icon: 'check',
|
||||
label: t('components.notifications.NotificationRow.button.approve'),
|
||||
handler: () => approveUserFollow(activity.related_object)
|
||||
},
|
||||
rejectFollow: {
|
||||
buttonClass: 'danger',
|
||||
icon: 'x',
|
||||
label: t('components.notifications.NotificationRow.button.reject'),
|
||||
handler: () => rejectUserFollow(activity.related_object)
|
||||
}
|
||||
}
|
||||
} 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 +102,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 +138,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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -164,6 +164,16 @@ export interface LibraryFollow {
|
|||
target: Library
|
||||
}
|
||||
|
||||
// to do : can't get Activity typescript to accept library follow and user follow
|
||||
export interface UserFollow {
|
||||
uuid: string
|
||||
approved: boolean
|
||||
|
||||
name: string
|
||||
type?: 'federation.Actor' | 'federation.UserFollow'
|
||||
target?: Actor
|
||||
}
|
||||
|
||||
export interface Cover {
|
||||
uuid: string
|
||||
urls: {
|
||||
|
@ -474,10 +484,17 @@ export interface UserRequest {
|
|||
export type Activity = {
|
||||
actor: Actor
|
||||
creation_date: string
|
||||
related_object: LibraryFollow
|
||||
related_object: UserFollow
|
||||
type: 'Follow' | 'Accept'
|
||||
object: LibraryFollow
|
||||
object: UserFollow
|
||||
}
|
||||
export type UserFollowActivity = {
|
||||
actor: Actor
|
||||
creation_date: string
|
||||
related_object: UserFollow
|
||||
type: 'Follow' | 'Accept'
|
||||
object: UserFollow
|
||||
}
|
||||
|
||||
export interface Notification {
|
||||
id: number
|
||||
|
|
Ładowanie…
Reference in New Issue