Porównaj commity

..

26 Commity

Autor SHA1 Wiadomość Data
petitminion 67b5392c30 Merge branch '1810-user-follow' into 'develop'
draft : User follow with trackfavorite activity

Closes #1810

See merge request funkwhale/funkwhale!2774
2024-04-20 12:30:13 +00:00
Petitminion 18b4c6e5f3 resolve federation issue (lacking favorite fid) 2024-04-20 14:26:56 +02:00
Petitminion 890963db30 migrate privavy_level to actor, replace "favorite" by "like", delete apiv2 endpoints 2024-04-20 13:15:17 +02:00
Petitminion 878cb32b96 userfollow and favorite listening activities 2024-04-17 18:18:14 +02:00
Petitminion 4bef27552f upgrade docker postgres dev version to postgres15
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2771>
2024-04-16 13:04:32 +00:00
Ciarán Ainsworth ec368e0cd3 Update from attribute information
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Ciarán Ainsworth a2579bdc60 Add from attribute to genre tag spec
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Ciarán Ainsworth e1e0045a23 Add changelog fragment
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Ciarán Ainsworth 85c2be6a5b fix(docs): run pre-commit
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Ciarán Ainsworth 35de9bd48e feat(docs): add genre tags spec
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2592>
2024-04-16 14:47:01 +02:00
Petitminion ba5b657b61 lint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 4fc73c1430 lint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Ciarán Ainsworth 97e24bcaa6 Apply 12 suggestion(s) to 4 file(s)
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Ciarán Ainsworth 1b15fea1ab Apply 1 suggestion(s) to 1 file(s)
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Ciarán Ainsworth b624fea2fa Apply 1 suggestion(s) to 1 file(s)
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Ciarán Ainsworth e028e8788b Apply 1 suggestion(s) to 1 file(s)
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Ciarán Ainsworth 67f74d40a6 Add ListenBrainz sync documentation
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 547bd6f371 lint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 05ec6f6d0f tests
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion a03cc1db24 lint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 2a364d5785 add favorite sync
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 5bc0171694 delete test
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 37acfa475d loads of things
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion f45fd1e465 various reviews
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 17c4a92f77 lint
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
Petitminion 6414302899 implement listening and favorite sync with listenbrainz
Part-of: <https://dev.funkwhale.audio/funkwhale/funkwhale/-/merge_requests/2658>
2024-04-16 11:01:29 +00:00
64 zmienionych plików z 1288 dodań i 642 usunięć

Wyświetl plik

@ -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"
"""

Wyświetl plik

@ -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):

Wyświetl plik

@ -15,11 +15,6 @@ v2_patterns += [
r"^radios/",
include(("funkwhale_api.radios.urls_v2", "radios"), namespace="radios"),
),
# to do : to delete
# re_path(
# r"^users/",
# include(("funkwhale_api.users.api_urls_v2", "users"), namespace="users"),
# ),
]
urlpatterns = [re_path("", include((v2_patterns, "v2"), namespace="v2"))]

Wyświetl plik

@ -37,12 +37,8 @@ def combined_recent(limit, **kwargs):
return records
# to do : this should look into actor privacy_level and not iuser privacy level if we want to handle federated
# privacy_level acces mmanagement
def get_activity(user, limit=20):
query = fields.privacy_level_query(
user, "actor__user__privacy_level", "actor__user"
)
query = fields.privacy_level_query(user, "actor__privacy_level", "actor__user")
querysets = [
Listening.objects.filter(query).select_related(
"track", "actor", "track__artist", "track__album__artist"
@ -52,5 +48,4 @@ def get_activity(user, limit=20):
),
]
records = combined_recent(limit=limit, querysets=querysets)
breakpoint()
return [r["object"] for r in records]

Wyświetl plik

@ -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
)

Wyświetl plik

@ -69,12 +69,12 @@ class PrivacyLevelPermission(BasePermission):
# 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.privacy_level == "everyone":
if obj.user.actor.privacy_level == "everyone":
return True
# user is anonymous
elif not hasattr(request.user, "actor"):
return False
elif obj.user.privacy_level == "instance":
elif obj.user.actor.privacy_level == "instance":
# user is local
if hasattr(request.user, "actor"):
return True
@ -83,11 +83,14 @@ class PrivacyLevelPermission(BasePermission):
else:
return False
elif obj.user.privacy_level == "me" and obj.user.actor == request.user.actor:
elif (
obj.user.actor.privacy_level == "me"
and obj.user.actor == request.user.actor
):
return True
elif (
obj.user.privacy_level == "followers"
obj.user.actor.privacy_level == "followers"
and request.user.actor in obj.user.actor.get_followers()
):
return True

Wyświetl plik

@ -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)

Wyświetl plik

@ -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",
},
],
)

Wyświetl plik

@ -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")

Wyświetl plik

@ -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.actor.user.privacy_level not in ["instance", "everyone"]:
if obj.actor.privacy_level not in ["instance", "everyone"]:
return
channels.group_send(

Wyświetl plik

@ -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),
),
]

Wyświetl plik

@ -19,7 +19,7 @@ def gen_uuid(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("federation", "0029_userfollow"),
("favorites", "0001_initial"),
("favorites", "0002_trackfavorite_source"),
]
operations = [

Wyświetl plik

@ -15,7 +15,7 @@ class Migration(migrations.Migration):
dependencies = [
("federation", "0029_userfollow"),
("music", "0057_auto_20221118_2108"),
("favorites", "0002_trackfavorite_actor_trackfavorite_fid_and_more"),
("favorites", "0003_trackfavorite_actor_trackfavorite_fid_and_more"),
]
operations = [

Wyświetl plik

@ -18,24 +18,24 @@ FAVORITE_PRIVACY_LEVEL_CHOICES = [
class TrackFavoriteQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet):
def viewable_by(self, actor):
if actor is None:
return self.filter(actor__user__privacy_level="everyone")
return self.filter(actor__privacy_level="everyone")
if hasattr(actor, "user"):
me_query = models.Q(actor__user__privacy_level="me", actor=actor)
me_query = models.Q(actor__user__privacy_level="me", actor=actor)
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__user__privacy_level="instance", actor__domain=actor.domain
actor__privacy_level="instance", actor__domain=actor.domain
)
instance_actor_query = models.Q(
actor__user__privacy_level="instance", actor__domain=actor.domain
actor__privacy_level="instance", actor__domain=actor.domain
)
return self.filter(
me_query
| instance_query
| instance_actor_query
| models.Q(actor__user__privacy_level="everyone")
| models.Q(actor__privacy_level="everyone")
)
@ -52,6 +52,7 @@ class TrackFavorite(federation_models.FederationMixin):
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()

Wyświetl plik

@ -4,6 +4,7 @@ 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
@ -46,9 +47,14 @@ 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": "Create", "object": {"type": "Favorite"}},
{"type": "Like", "object": {"type": "Track"}},
context={"favorite": instance},
)
return Response(
@ -59,7 +65,7 @@ class TrackFavoriteViewSet(
queryset = super().get_queryset()
queryset = queryset.filter(
fields.privacy_level_query(
self.request.user, "actor__user__privacy_level", "actor__user"
self.request.user, "actor__privacy_level", "actor__user"
)
)
tracks = Track.objects.with_playable_uploads(
@ -84,10 +90,15 @@ class TrackFavoriteViewSet(
except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist):
return Response({}, status=400)
routes.outbox.dispatch(
{"type": "Delete", "object": {"type": "Favorite"}},
{"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(

Wyświetl plik

@ -55,7 +55,6 @@ FUNKWHALE_OBJECT_TYPES = [
("Album", "Album"),
("Track", "Track"),
("Library", "Library"),
("Favorite", "Favorite"),
]
OBJECT_TYPES = (
[

Wyświetl plik

@ -294,8 +294,6 @@ CONTEXTS = [
"Track": "fw:Track",
"Artist": "fw:Artist",
"Library": "fw:Library",
# might be possible to do "Favorite": "as:Like" ?
"Favorite": "fw:Favorite",
"bitrate": {"@id": "fw:bitrate", "@type": "xsd:nonNegativeInteger"},
"size": {"@id": "fw:size", "@type": "xsd:nonNegativeInteger"},
"position": {"@id": "fw:position", "@type": "xsd:nonNegativeInteger"},

Wyświetl plik

@ -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),
]

Wyświetl plik

@ -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,7 +255,7 @@ class Actor(models.Model):
def should_autoapprove_follow(self, actor):
if self.get_channel():
return True
if self.user.privacy_level == "public":
if self.user.actor.privacy_level == "public":
return True
return False
@ -402,8 +403,6 @@ class Fetch(models.Model):
serializers.ChannelUploadSerializer,
],
contexts.FW.Library: [serializers.LibrarySerializer],
# to do : don't need to fetch a favorite since we can fetch the track and actor already ?
# contexts.FW.Favorite: [serializers.TrackFavoriteSerializer],
contexts.AS.Group: [serializers.ActorSerializer],
contexts.AS.Person: [serializers.ActorSerializer],
contexts.AS.Organization: [serializers.ActorSerializer],

Wyświetl plik

@ -612,46 +612,35 @@ def outbox_delete_album(context):
}
@outbox.register({"type": "Create", "object.type": "Favorite"})
@outbox.register({"type": "Like", "object.type": "Track"})
def outbox_create_favorite(context):
from funkwhale_api.favorites import serializers as favorites_serializers
favorite = context["favorite"]
actor = favorite.actor
serializer = serializers.ActivitySerializer(
{
"type": "Create",
"object": serializers.TrackFavoriteSerializer(favorite).data,
"actor": actor.fid,
"type": "Like",
"id": favorite.fid,
"object": {"type": "Track", "id": favorite.track.fid},
}
)
yield {
"type": "Create",
"type": "Like",
"actor": actor,
"payload": with_recipients(
serializer.data,
to=[{"type": "followers", "target": actor}],
),
"object": favorite,
"target": actor,
}
@outbox.register({"type": "Delete", "object.type": "Favorite"})
@outbox.register({"type": "Delete", "object.type": "Like"})
def outbox_delete_favorite(context):
favorite = context["favorite"]
actor = favorite.actor
serializer = serializers.ActivitySerializer(
{
"type": "Delete",
"object": serializers.TrackFavoriteSerializer(favorite).data,
"actor": actor.fid,
}
{"type": "Delete", "object": {"type": "Like", "id": favorite.fid}}
)
yield {
"type": "Delete",
"actor": actor,
@ -659,24 +648,18 @@ def outbox_delete_favorite(context):
serializer.data,
to=[{"type": "followers", "target": actor}],
),
"object": favorite,
"target": actor,
}
@inbox.register({"type": "Create", "object.type": "Favorite"})
@inbox.register({"type": "Like", "object.type": "Track"})
def inbox_create_favorite(payload, context):
from funkwhale_api.favorites import serializers as favorites_serializers
actor = context["actor"]
favorite = payload["object"]
serializer = serializers.TrackFavoriteSerializer(data=favorite)
serializer = serializers.TrackFavoriteSerializer(data=payload)
serializer.is_valid(raise_exception=True)
instance = serializer.save()
return {"object": instance}
@inbox.register({"type": "Delete", "object.type": "Favorite"})
@inbox.register({"type": "Delete", "object.type": "Like"})
def inbox_delete_favorite(payload, context):
actor = context["actor"]
favorite_id = payload["object"].get("id")

Wyświetl plik

@ -2094,24 +2094,21 @@ class IndexSerializer(jsonld.JsonLdSerializer):
class TrackFavoriteSerializer(jsonld.JsonLdSerializer):
type = serializers.ChoiceField(choices=[contexts.AS.Like])
id = serializers.URLField(max_length=500)
# to do : should thi be target like followserializer ?
track = TrackSerializer(required=True)
object = serializers.URLField(max_length=500)
actor = serializers.URLField(max_length=500)
class Meta:
jsonld_mapping = {
"track": jsonld.first_obj(contexts.FW.track),
"object": jsonld.first_id(contexts.AS.object),
"actor": jsonld.first_id(contexts.AS.actor),
}
def to_representation(self, favorite):
payload = {
"type": "Favorite",
"type": "Like",
"id": favorite.fid,
"actor": favorite.actor.fid,
"track": TrackSerializer(
favorite.track, context={"include_ap_context": False}
).data,
"object": favorite.track.fid,
}
if self.context.get("include_ap_context", True):
payload["@context"] = jsonld.get_default_context()
@ -2119,9 +2116,8 @@ class TrackFavoriteSerializer(jsonld.JsonLdSerializer):
def create(self, validated_data):
actor = actors.get_actor(validated_data["actor"])
track = utils.retrieve_ap_object(
validated_data["track"]["id"],
validated_data["object"],
actor=actors.get_service_actor(),
serializer_class=TrackSerializer,
)
@ -2131,5 +2127,4 @@ class TrackFavoriteSerializer(jsonld.JsonLdSerializer):
uuid=uuid.uuid4(),
actor=actor,
track=track,
user=None,
)

Wyświetl plik

@ -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.actor.user.privacy_level not in ["instance", "everyone"]:
if obj.actor.privacy_level not in ["instance", "everyone"]:
return
channels.group_send(

Wyświetl plik

@ -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

Wyświetl plik

@ -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),
),
]

Wyświetl plik

@ -15,7 +15,7 @@ def gen_uuid(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("federation", "0029_userfollow"),
("history", "0002_auto_20180325_1433"),
("history", "0003_listening_source"),
]
operations = [

Wyświetl plik

@ -13,7 +13,7 @@ def get_user_actor(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
("history", "0003_listening_actor_listening_fid_listening_url"),
("history", "0004_listening_actor_listening_fid_listening_url"),
]
operations = [

Wyświetl plik

@ -10,7 +10,7 @@ from funkwhale_api.federation import utils as federation_utils
from funkwhale_api.music.models import Track
class TrackFavoriteQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet):
class ListeningQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet):
pass
@ -31,8 +31,9 @@ class Listening(federation_models.FederationMixin):
blank=True,
)
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 = TrackFavoriteQuerySet.as_manager()
objects = ListeningQuerySet.as_manager()
class Meta:
ordering = ("-creation_date",)

Wyświetl plik

@ -48,7 +48,9 @@ class ListeningViewSet(
def get_queryset(self):
queryset = super().get_queryset()
queryset = queryset.filter(
fields.privacy_level_query(self.request.user, "actor__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)

Wyświetl plik

@ -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",

Wyświetl plik

@ -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"]

Wyświetl plik

@ -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
),
)

Wyświetl plik

@ -292,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:

Wyświetl plik

@ -327,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:

Wyświetl plik

@ -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.
"""

Wyświetl plik

@ -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")},

Wyświetl plik

@ -1,22 +0,0 @@
# to do : to delete
# from django.urls import re_path, include
# from funkwhale_api.common import routers
# from funkwhale_api.federation import api_views as federation_views
# from . import views_v2
# router = routers.OptionalSlashRouter()
# router.register(r"", views_v2.UserViewSet, "users")
# urlpatterns = [
# re_path(r"^login/?$", views_v2.login, name="login"),
# re_path(r"^logout/?$", views_v2.logout, name="logout"),
# re_path(
# r"^(?P<user_pk>[0-9]+)/follow_requests/(?P<follow_pk>[0-9]+)/?$",
# views_v2.follow_request_patch,
# name="follow_request_patch",
# ),
# ] + router.urls

Wyświetl plik

@ -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",
),
]

Wyświetl plik

@ -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")

Wyświetl plik

@ -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",
]

Wyświetl plik

@ -94,7 +94,6 @@ 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)
# to do : privacy downgrade
if "privacy_level" in new_settings:
dispatch_privacy_downgrade(new_settings["privacy_level"], request.user)
return Response(request.user.settings)
@ -140,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):
@ -187,7 +194,13 @@ def logout(request):
# to do : privacy downgrade
def dispatch_privacy_downgrade(privacy_level, user):
if privacy_level == "me" or privacy_level == "instance":
routes.outbox.dispatch({"type": "Delete"}, context={"actor": user.actor})
# 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"}, context={"actor": user.actor})
routes.outbox.dispatch(
{"type": "Update", "object": {"type": user.actor.type}},
context={"actor": user.actor},
)

Wyświetl plik

@ -1,270 +0,0 @@
# to do : to delete import json
# from allauth.account.adapter import get_adapter
# from allauth.account.utils import send_email_confirmation
# from dj_rest_auth import views as rest_auth_views
# from dj_rest_auth.registration import views as registration_views
# from django import http
# from django.contrib import auth
# from django.middleware import csrf
# from drf_spectacular.utils import extend_schema, extend_schema_view
# from rest_framework import mixins, viewsets, exceptions
# from rest_framework.decorators import action, api_view
# from rest_framework.response import Response
# from funkwhale_api.common import preferences, throttling
# from . import models, serializers, tasks
# from funkwhale_api.federation import models as federation_models
# from funkwhale_api.federation import api_serializers as api_federation_serializers
# from funkwhale_api.federation import serializers as federation_serializers
# from funkwhale_api.federation import routes
# from . import models, serializers, tasks
# from django.shortcuts import get_object_or_404
# class UserViewSet(mixins.UpdateModelMixin, viewsets.GenericViewSet):
# queryset = models.User.objects.all().select_related("actor__attachment_icon")
# serializer_class = serializers.UserWriteSerializer
# lookup_field = "pk"
# lookup_value_regex = r"[a-zA-Z0-9-_.]+"
# required_scope = "profile"
# @extend_schema(operation_id="follow_requests")
# @action(methods=["post"], detail=True)
# def follow_requests(self, *args, **kwargs):
# user_id = kwargs["pk"]
# actor = self.request.user.actor
# serializer = api_federation_serializers.UserFollowSerializer(
# data={"actor": actor, "target": user_id},
# context={"actor": self.request.user.actor},
# )
# serializer.is_valid(raise_exception=True)
# follow = serializer.save(actor=self.request.user.actor)
# routes.outbox.dispatch({"type": "Follow"}, context={"follow": follow})
# return Response(status=204)
# @extend_schema(operation_id="unfollow")
# @action(methods=["post"], detail=True)
# def unfollow(self, *args, **kwargs):
# follow = get_object_or_404(
# federation_models.UserFollow,
# actor=self.request.user.actor,
# target=self.get_object(),
# )
# follow.delete()
# routes.outbox.dispatch({"type": "Delete"}, context={"follow": follow})
# return Response(status=200)
# @extend_schema(operation_id="followings")
# @action(
# methods=["get"],
# detail=True,
# )
# def followings(self, *args, **kwargs):
# user = self.get_object()
# if (
# self.request.user != self.get_object()
# and self.get_object().privacy_level == "private"
# ):
# raise exceptions.PermissionDenied
# if (
# self.request.user != self.get_object()
# and self.request.user.actor.is_local is False
# and self.get_object().privacy_level == "pod"
# ):
# raise exceptions.PermissionDenied
# followings = federation_models.UserFollow.objects.filter(
# actor=user.actor
# ).order_by("creation_date")
# serializer = api_federation_serializers.UserFollowSerializer(
# followings, many=True
# )
# page = self.paginate_queryset(followings)
# if page is not None:
# serializer = api_federation_serializers.UserFollowSerializer(
# page, many=True, required=False
# )
# return self.get_paginated_response(serializer.data)
# serializer = api_federation_serializers.UserFollowSerializer(
# followings, many=True, required=False
# )
# return Response(serializer.data)
# @extend_schema(operation_id="followers")
# @action(
# methods=["get"],
# detail=True,
# )
# def followers(self, *args, **kwargs):
# user = self.get_object()
# if (
# self.request.user != self.get_object()
# and self.get_object().privacy_level == "private"
# ):
# raise exceptions.PermissionDenied
# if (
# self.request.user != self.get_object()
# and self.request.actor.is_local is False
# and self.get_object().privacy_level == "pod"
# ):
# raise exceptions.PermissionDenied
# followers = federation_models.UserFollow.objects.filter(target=user).order_by(
# "creation_date"
# )
# serializer = api_federation_serializers.UserFollowSerializer(
# followers, many=True
# )
# page = self.paginate_queryset(followers)
# if page is not None:
# serializer = api_federation_serializers.UserFollowSerializer(
# page, many=True, required=False
# )
# return self.get_paginated_response(serializer.data)
# serializer = api_federation_serializers.UserFollowSerializer(
# followers, many=True, required=False
# )
# return Response(serializer.data)
# @extend_schema(operation_id="get_authenticated_user", methods=["get"])
# @extend_schema(operation_id="delete_authenticated_user", methods=["delete"])
# @action(methods=["get", "delete"], detail=False)
# def me(self, request, *args, **kwargs):
# """Return information about the current user or delete it"""
# if request.method.lower() == "delete":
# serializer = serializers.UserDeleteSerializer(
# request.user, data=request.data
# )
# serializer.is_valid(raise_exception=True)
# tasks.delete_account.delay(user_id=request.user.pk)
# # at this point, password is valid, we launch deletion
# return Response(status=204)
# serializer = serializers.MeSerializer(request.user)
# return Response(serializer.data)
# @extend_schema(operation_id="update_settings")
# @action(methods=["post"], detail=False, url_name="settings", url_path="settings")
# def set_settings(self, request, *args, **kwargs):
# """Return information about the current user or delete it"""
# new_settings = request.data
# request.user.set_settings(**new_settings)
# return Response(request.user.settings)
# @action(
# methods=["get", "post", "delete"],
# required_scope="security",
# url_path="subsonic-token",
# detail=True,
# )
# def subsonic_token(self, request, *args, **kwargs):
# if not self.request.user.username == kwargs.get("username"):
# return Response(status=403)
# if not preferences.get("subsonic__enabled"):
# return Response(status=405)
# if request.method.lower() == "get":
# return Response(
# {"subsonic_api_token": self.request.user.subsonic_api_token}
# )
# if request.method.lower() == "delete":
# self.request.user.subsonic_api_token = None
# self.request.user.save(update_fields=["subsonic_api_token"])
# return Response(status=204)
# self.request.user.update_subsonic_api_token()
# self.request.user.save(update_fields=["subsonic_api_token"])
# data = {"subsonic_api_token": self.request.user.subsonic_api_token}
# return Response(data)
# @extend_schema(operation_id="change_email", responses={200: None, 403: None})
# @action(
# methods=["post"],
# required_scope="security",
# url_path="change-email",
# detail=False,
# )
# def change_email(self, request, *args, **kwargs):
# if not self.request.user.is_authenticated:
# return Response(status=403)
# serializer = serializers.UserChangeEmailSerializer(
# request.user, data=request.data, context={"user": request.user}
# )
# serializer.is_valid(raise_exception=True)
# serializer.save(request)
# return Response(status=204)
# def update(self, request, *args, **kwargs):
# if not self.request.user.username == kwargs.get("username"):
# return Response(status=403)
# return super().update(request, *args, **kwargs)
# def partial_update(self, request, *args, **kwargs):
# if not self.request.user.username == kwargs.get("username"):
# return Response(status=403)
# return super().partial_update(request, *args, **kwargs)
# @extend_schema(operation_id="login")
# @action(methods=["post"], detail=False)
# def login(request):
# throttling.check_request(request, "login")
# if request.method != "POST":
# return http.HttpResponse(status=405)
# serializer = serializers.LoginSerializer(
# data=request.POST, context={"request": request}
# )
# if not serializer.is_valid():
# return http.HttpResponse(
# json.dumps(serializer.errors), status=400, content_type="application/json"
# )
# serializer.save(request)
# csrf.rotate_token(request)
# token = csrf.get_token(request)
# response = http.HttpResponse(status=200)
# response.set_cookie("csrftoken", token, max_age=None)
# return response
# @extend_schema(operation_id="logout")
# @action(methods=["post"], detail=False)
# def logout(request):
# if request.method != "POST":
# return http.HttpResponse(status=405)
# auth.logout(request)
# token = csrf.get_token(request)
# response = http.HttpResponse(status=200)
# response.set_cookie("csrftoken", token, max_age=None)
# return response
# @action(methods=["patch"], detail=False)
# @extend_schema(operation_id="follow_request_patch")
# def follow_request_patch(request, user_pk, follow_pk):
# try:
# user = models.User.objects.get(pk=user_pk)
# follow = federation_models.UserFollow.objects.get(pk=follow_pk)
# except (models.User.DoesNotExist, federation_models.UserFollow.DoesNotExist):
# raise http.Http404
# if request.user != user:
# raise exceptions.PermissionDenied
# request_body = request.body.decode("utf-8")
# data = json.loads(request_body)
# if not isinstance(data["approved"], bool):
# raise BaseException("Approved typemust be boolean")
# follow.approved = data["approved"]
# follow.save()
# routes.outbox.dispatch({"type": "Update"}, context={"follow": follow})
# return http.HttpResponse(status=204)

Wyświetl plik

@ -13,11 +13,16 @@ def test_get_activity(factories):
def test_get_activity_honors_privacy_level(factories, anonymous_user):
user = factories["users.User"](privacy_level="me")
user2 = factories["users.User"](privacy_level="instance")
factories["history.Listening"](actor=user.actor)
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

Wyświetl plik

@ -5,7 +5,8 @@ from funkwhale_api.activity import serializers, utils
def test_activity_view(factories, api_client, preferences, anonymous_user):
preferences["common__api_authentication_required"] = False
user = factories["users.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")

Wyświetl plik

@ -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):

Wyświetl plik

@ -48,7 +48,8 @@ def test_owner_permission_read_only(anonymous_user, nodb_factories, api_request)
def test_privacylevel_permission_anonymous(
factories, api_request, anonymous_user, privacy_level, expected
):
user = factories["users.User"](with_actor=True, privacy_level=privacy_level)
user = factories["users.User"]()
user.create_actor(privacy_level=privacy_level)
view = APIView.as_view()
permission = permissions.PrivacyLevelPermission()
request = api_request.get("/")
@ -65,7 +66,8 @@ def test_privacylevel_permission_anonymous(
def test_privacylevel_permission_instance(
factories, api_request, anonymous_user, privacy_level, expected, mocker
):
user = factories["users.User"](with_actor=True, privacy_level=privacy_level)
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()
@ -83,7 +85,8 @@ def test_privacylevel_permission_instance(
def test_privacylevel_permission_me(
factories, api_request, anonymous_user, privacy_level, expected, mocker
):
user = factories["users.User"](with_actor=True, privacy_level=privacy_level)
user = factories["users.User"]()
user.create_actor(privacy_level=privacy_level)
view = APIView.as_view()
permission = permissions.PrivacyLevelPermission()
request = api_request.get("/")

Wyświetl plik

@ -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)

Wyświetl plik

@ -54,7 +54,9 @@ def test_broadcast_track_favorite_to_instance_activity(factories, mocker):
def test_broadcast_track_favorite_to_instance_activity_private(factories, mocker):
p = mocker.patch("funkwhale_api.common.channels.group_send")
user = factories["users.User"](privacy_level="me", with_actor=True)
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

Wyświetl plik

@ -10,7 +10,8 @@ from funkwhale_api.favorites import models
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"](with_actor=True, privacy_level=privacy_level)
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)
@ -23,10 +24,9 @@ def test_playable_by_local_actor(privacy_level, expected, factories):
def test_not_playable_by_remote_actor(privacy_level, expected, factories):
actor = factories["federation.Actor"]()
# default user actor is local
user = factories["users.User"](
with_actor=True,
privacy_level=privacy_level,
)
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)

Wyświetl plik

@ -5,7 +5,8 @@ from django.urls import reverse
@pytest.mark.parametrize("level", ["instance", "me", "followers"])
def test_privacy_filter(preferences, level, factories, api_client):
preferences["common__api_authentication_required"] = False
user = factories["users.User"](with_actor=True, 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)

Wyświetl plik

@ -40,7 +40,7 @@ from funkwhale_api.favorites import serializers as favorites_serializers
({"type": "Delete", "object": {"type": "Tombstone"}}, routes.inbox_delete),
({"type": "Flag"}, routes.inbox_flag),
(
{"type": "Create", "object": {"type": "Favorite"}},
{"type": "Like", "object": {"type": "Track"}},
routes.inbox_create_favorite,
),
],
@ -90,7 +90,7 @@ def test_inbox_routes(route, handler):
routes.outbox_delete_actor,
),
(
{"type": "Create", "object": {"type": "Favorite"}},
{"type": "Like", "object": {"type": "Track"}},
routes.outbox_create_favorite,
),
],
@ -144,7 +144,7 @@ def test_inbox_follow_user_autoapprove(factories, mocker):
"funkwhale_api.federation.activity.OutboxRouter.dispatch"
)
local_actor = factories["users.User"](privacy_level="public").create_actor()
local_actor = factories["users.User"]().create_actor(privacy_level="public")
remote_actor = factories["federation.Actor"]()
ii = factories["federation.InboxItem"](actor=local_actor)
@ -1039,43 +1039,33 @@ def test_outbox_flag(factory_name, factory_kwargs, factories, mocker):
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": "Create",
"object": serializers.TrackFavoriteSerializer(favorite).data,
"actor": favorite.actor.fid,
"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
assert activity["target"] == favorite.actor.received_user_follows.all()
assert activity["object"] == favorite
def test_inbox_create_favorite(factories, mocker):
actor = factories["federation.Actor"]()
favorite = factories["favorites.TrackFavorite"](actor=actor)
follow = factories["federation.UserFollow"](target=actor)
data = serializers.TrackFavoriteSerializer(favorite).data
serializer = serializers.ActivitySerializer(
{
"type": "Create",
"object": data,
"actor": actor.fid,
}
)
serializer = serializers.TrackFavoriteSerializer(favorite)
init = mocker.spy(serializers.TrackFavoriteSerializer, "__init__")
save = mocker.spy(serializers.TrackFavoriteSerializer, "save")
track_data = serializers.TrackSerializer(favorite.track).data
mocker.patch.object(utils, "retrieve_ap_object", return_value=favorite.track)
favorite.delete()
result = routes.inbox_create_favorite(
serializer.data,
context={

Wyświetl plik

@ -54,8 +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")
user = factories["users.User"](privacy_level="me", with_actor=True)
listening = factories["history.Listening"](actor__user=user)
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)

Wyświetl plik

@ -5,7 +5,8 @@ from django.urls import reverse
@pytest.mark.parametrize("level", ["instance", "me", "followers"])
def test_privacy_filter(preferences, level, factories, api_client):
preferences["common__api_authentication_required"] = False
user = factories["users.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)

Wyświetl plik

@ -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)

Wyświetl plik

@ -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)

Wyświetl plik

@ -633,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"](
@ -664,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"](

Wyświetl plik

@ -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):
@ -554,4 +554,4 @@ def test_user_change_email(logged_in_api_client, mocker, mailoutbox):
# assert response.status_code == 200
# user.refresh_from_db()
# assert user.privacy_level == "me"
# assert user.actor.privacy_level == "me"

Wyświetl plik

@ -1,170 +0,0 @@
# to do : to delete
# import pytest
# from django.test import Client
# from django.urls import reverse
# from funkwhale_api.common import serializers as common_serializers
# from funkwhale_api.common import utils as common_utils
# from funkwhale_api.moderation import tasks as moderation_tasks
# from funkwhale_api.users.models import User
# def test_can_follow_user(factories, logged_in_api_client, mocker):
# followed_user = factories["users.User"]()
# actor = factories["federation.Actor"]()
# logged_in_api_client.user.actor = actor
# url = reverse("api:v2:users:users-follow-requests", kwargs={"pk": followed_user.pk})
# routes = mocker.patch("funkwhale_api.federation.api_views.routes.outbox.dispatch")
# response = logged_in_api_client.post(url)
# assert response.status_code == 204
# assert routes.call_count == 1
# def test_can_unfollow(factories, logged_in_api_client, mocker):
# logged_in_api_client.user.create_actor()
# followed_user = factories["users.User"](with_actor=True)
# user_follow = factories["federation.UserFollow"](
# target=followed_user, actor=logged_in_api_client.user.actor
# )
# url = reverse("api:v2:users:users-unfollow", kwargs={"pk": followed_user.pk})
# routes = mocker.patch("funkwhale_api.federation.api_views.routes.outbox.dispatch")
# response = logged_in_api_client.post(url)
# assert response.status_code == 200
# # /users/id/follow_requests/
# # def test_can_patch_follow_user(factories, logged_in_api_client, mocker):
# # logged_in_api_client.user.create_actor()
# # following_user = factories["users.User"](with_actor=True)
# # url = reverse(
# # "api:v2:users:users-follow-requests",
# # kwargs={"pk": logged_in_api_client.user.pk},
# # )
# # routes = mocker.patch("funkwhale_api.federation.api_views.routes.outbox.dispatch")
# # data = {
# # "approved": True,
# # "actor": following_user.actor,
# # "target": logged_in_api_client.user.pk,
# # }
# # response = logged_in_api_client.patch(url, data=data)
# # assert response.status_code == 204
# # assert routes.call_count == 1
# def test_can_patch_follow_user_v2(factories, logged_in_api_client, mocker):
# logged_in_api_client.user.create_actor()
# following_user = factories["users.User"](with_actor=True)
# user_follow = factories["federation.UserFollow"](
# target=following_user, actor=following_user.actor
# )
# url = reverse(
# "api:v2:users:follow_request_patch",
# kwargs={
# "user_pk": logged_in_api_client.user.pk,
# "follow_pk": user_follow.pk,
# },
# )
# routes = mocker.patch("funkwhale_api.federation.api_views.routes.outbox.dispatch")
# data = {
# "approved": True,
# }
# response = logged_in_api_client.patch(url, data=data, format="json")
# assert response.status_code == 204
# assert routes.call_count == 1
# # def test_only_target_user_can_patch_follow_user(factories, logged_in_api_client):
# # logged_in_api_client.user.create_actor()
# # followed_user = factories["users.User"]()
# # url = reverse("api:v2:users:users-follow-requests", kwargs={"pk": followed_user.pk})
# # data = {
# # "approved": True,
# # "actor": logged_in_api_client.user.actor,
# # "target": followed_user.pk,
# # }
# # response = logged_in_api_client.patch(url, data=data)
# # assert response.status_code == 403
# def test_can_get_my_userfollowings(factories, logged_in_api_client, mocker):
# logged_in_api_client.user.create_actor()
# followed_user = factories["users.User"](with_actor=True)
# user_follow = factories["federation.UserFollow"](
# actor=logged_in_api_client.user.actor, target=followed_user
# )
# url = reverse(
# "api:v2:users:users-followings", kwargs={"pk": logged_in_api_client.user.pk}
# )
# response = logged_in_api_client.get(url)
# assert response.status_code == 200
# assert str(user_follow.uuid) == response.data["results"][0]["uuid"]
# def test_can_get_user_public_profile_userfollowings(
# factories, logged_in_api_client, mocker
# ):
# logged_in_api_client.user.create_actor()
# following_user = factories["users.User"](with_actor=True, privacy_level="public")
# user_follow = factories["federation.UserFollow"](actor=following_user.actor)
# url = reverse("api:v2:users:users-followings", kwargs={"pk": following_user.pk})
# response = logged_in_api_client.get(url)
# assert response.status_code == 200
# assert str(user_follow.uuid) == response.data["results"][0]["uuid"]
# def test_cannot_get_user_private_profile_userfollowings(
# factories, logged_in_api_client, mocker
# ):
# logged_in_api_client.user.create_actor()
# following_user = factories["users.User"](with_actor=True, privacy_level="private")
# user_follow = factories["federation.UserFollow"](actor=following_user.actor)
# url = reverse("api:v2:users:users-followings", kwargs={"pk": following_user.pk})
# response = logged_in_api_client.get(url)
# assert response.status_code == 403
# def test_can_get_user_pod_profile_userfollowings(
# factories, logged_in_api_client, mocker
# ):
# logged_in_api_client.user.create_actor()
# following_user = factories["users.User"](with_actor=True, privacy_level="pod")
# user_follow = factories["federation.UserFollow"](actor=following_user.actor)
# url = reverse("api:v2:users:users-followings", kwargs={"pk": following_user.pk})
# response = logged_in_api_client.get(url)
# assert response.status_code == 200
# def test_cannot_get_user_pod_profile_userfollowings(
# factories, logged_in_api_client, mocker
# ):
# factories["federation.Domain"](name="notatalllocal")
# logged_in_api_client.user.create_actor(domain_id="notatalllocal")
# following_user = factories["users.User"](with_actor=True, privacy_level="pod")
# user_follow = factories["federation.UserFollow"](actor=following_user.actor)
# url = reverse("api:v2:users:users-followings", kwargs={"pk": following_user.pk})
# response = logged_in_api_client.get(url)
# assert response.status_code == 403
# def test_can_get_my_followers(factories, logged_in_api_client, mocker):
# following_user = factories["users.User"](with_actor=True)
# user_follow = factories["federation.UserFollow"](
# target=logged_in_api_client.user, actor=following_user.actor
# )
# url = reverse(
# "api:v2:users:users-followers", kwargs={"pk": logged_in_api_client.user.pk}
# )
# response = logged_in_api_client.get(url)
# assert response.status_code == 200
# assert str(user_follow.uuid) == response.data["results"][0]["uuid"]
# # to do : do this through should_autoapprove_follow autoapprove = serializer.validated_data["object"].should_autoapprove_follow(
# def test_follow_public_user_autoapprove(factories, logged_in_api_client, mocker):
# logged_in_api_client.user.create_actor()
# followed_user = factories["users.User"](with_actor=True, privacy_level="public")
# url = reverse("api:v2:users:users-follow-requests", kwargs={"pk": followed_user.pk})
# routes = mocker.patch("funkwhale_api.federation.api_views.routes.outbox.dispatch")
# response = logged_in_api_client.post(url)
# assert response.status_code == 204
# assert routes.call_count == 1

Wyświetl plik

@ -0,0 +1 @@
Add favorite and listening sync ith Listenbrainz (#2079)

Wyświetl plik

@ -0,0 +1 @@
Add genre tags spec.

Wyświetl plik

@ -109,6 +109,7 @@ specs/multi-artist/index
specs/user-follow/index
specs/user-deletion/index
specs/upload-process/index
specs/genre-tags/index
```

Wyświetl plik

@ -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

Wyświetl plik

@ -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`.
:::
::::