Porównaj commity

...

29 Commity

Autor SHA1 Wiadomość Data
Kasper Seweryn b46f4e788c chore: add changelog snippet 2024-04-16 12:24:19 +00:00
Kasper Seweryn 0f9fab01e9 chore: update rest of the dependencies 2024-04-16 12:24:19 +00:00
Kasper Seweryn 2635180656 chore: update eslint and types 2024-04-16 12:24:19 +00:00
Kasper Seweryn 1ac0e754a3 chore: update audio context 2024-04-16 12:24:19 +00:00
Kasper Seweryn b1f89413ac feat: add patch-package
I'm sick of needing to do `rm -rf node_modules && yarn install` every
single time I add/update/remove a package. fomantic-ui-css does not
receive updates that often and we can still fix new version manually
with old scripts that are still there
2024-04-16 12:24:19 +00:00
Kasper Seweryn 971a5d4ae4 chore: update PWA 2024-04-16 12:24:19 +00:00
Kasper Seweryn 8c8f71176f chore: update vue 2024-04-16 12:24:19 +00:00
Kasper Seweryn 424eb2223f chore: update sentry 2024-04-16 12:24:19 +00:00
Kasper Seweryn c8bbab0369 chore: update vueuse 2024-04-16 12:24:19 +00:00
Kasper Seweryn 90c84b0a5d chore: update workbox 2024-04-16 12:24:19 +00:00
Kasper Seweryn 867663ce9f chore: remove unused dependencies 2024-04-16 12:24:19 +00: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
Ciarán Ainsworth 94a5b9e696
chore(deps): bump py3-pillow in Dockerfile 2024-04-14 15:32:26 +02:00
Bruno-Van-den-Bosch d673e77dff Translated using Weblate (Dutch)
Currently translated at 99.8% (2177 of 2181 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/nl/
2024-04-12 13:50:31 +00:00
37 zmienionych plików z 45166 dodań i 1322 usunięć

Wyświetl plik

@ -6,6 +6,7 @@ repos:
rev: v4.4.0 rev: v4.4.0
hooks: hooks:
- id: check-added-large-files - id: check-added-large-files
exclude: ^(front/patches/)
- id: check-case-conflict - id: check-case-conflict
- id: check-executables-have-shebangs - id: check-executables-have-shebangs
- id: check-shebang-scripts-are-executable - id: check-shebang-scripts-are-executable
@ -20,9 +21,11 @@ repos:
- id: check-vcs-permalinks - id: check-vcs-permalinks
- id: check-merge-conflict - id: check-merge-conflict
- id: end-of-file-fixer - id: end-of-file-fixer
exclude: ^(docs/locales/.*/LC_MESSAGES) exclude: ^(docs/locales/.*/LC_MESSAGES|front/patches/)
- id: mixed-line-ending - id: mixed-line-ending
exclude: ^(front/patches/)
- id: trailing-whitespace - id: trailing-whitespace
exclude: ^(front/patches/)
- repo: https://github.com/python-poetry/poetry - repo: https://github.com/python-poetry/poetry
rev: 1.5.1 rev: 1.5.1
@ -68,6 +71,7 @@ repos:
hooks: hooks:
- id: codespell - id: codespell
additional_dependencies: [tomli] additional_dependencies: [tomli]
exclude: ^(front/patches/)
- repo: https://github.com/shellcheck-py/shellcheck-py - repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.9.0.5 rev: v0.9.0.5

Wyświetl plik

@ -39,7 +39,7 @@ RUN set -eux; \
zlib-dev \ zlib-dev \
py3-cryptography=41.0.7-r0 \ py3-cryptography=41.0.7-r0 \
py3-lxml=4.9.3-r1 \ py3-lxml=4.9.3-r1 \
py3-pillow=10.2.0-r0 \ py3-pillow=10.3.0-r0 \
py3-psycopg2=2.9.9-r0 \ py3-psycopg2=2.9.9-r0 \
py3-watchfiles=0.19.0-r1 \ py3-watchfiles=0.19.0-r1 \
python3-dev python3-dev
@ -99,7 +99,7 @@ RUN set -eux; \
libxslt \ libxslt \
py3-cryptography=41.0.7-r0 \ py3-cryptography=41.0.7-r0 \
py3-lxml=4.9.3-r1 \ py3-lxml=4.9.3-r1 \
py3-pillow=10.2.0-r0 \ py3-pillow=10.3.0-r0 \
py3-psycopg2=2.9.9-r0 \ py3-psycopg2=2.9.9-r0 \
py3-watchfiles=0.19.0-r1 \ py3-watchfiles=0.19.0-r1 \
python3 \ python3 \

Wyświetl plik

@ -303,6 +303,23 @@ LISTENING_CREATED = "listening_created"
""" """
Called when a track is being listened 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" SCAN = "scan"
""" """

Wyświetl plik

@ -276,6 +276,7 @@ LOCAL_APPS = (
# Your stuff: custom apps go here # Your stuff: custom apps go here
"funkwhale_api.instance", "funkwhale_api.instance",
"funkwhale_api.audio", "funkwhale_api.audio",
"funkwhale_api.contrib.listenbrainz",
"funkwhale_api.music", "funkwhale_api.music",
"funkwhale_api.requests", "funkwhale_api.requests",
"funkwhale_api.favorites", "funkwhale_api.favorites",
@ -949,6 +950,16 @@ CELERY_BEAT_SCHEDULE = {
), ),
"options": {"expires": 60 * 60}, "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): if env.str("TYPESENSE_API_KEY", default=None):

Wyświetl plik

@ -48,4 +48,5 @@ def get_activity(user, limit=20):
), ),
] ]
records = combined_recent(limit=limit, querysets=querysets) records = combined_recent(limit=limit, querysets=querysets)
return [r["object"] for r in records] return [r["object"] for r in records]

Wyświetl plik

@ -1,28 +1,31 @@
import liblistenbrainz import liblistenbrainz
from django.utils import timezone
import funkwhale_api import funkwhale_api
from config import plugins 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 from .funkwhale_startup import PLUGIN
@plugins.register_hook(plugins.LISTENING_CREATED, PLUGIN) @plugins.register_hook(plugins.LISTENING_CREATED, PLUGIN)
def submit_listen(listening, conf, **kwargs): def submit_listen(listening, conf, **kwargs):
user_token = conf["user_token"] user_token = conf["user_token"]
if not user_token: if not user_token and not conf["submit_listenings"]:
return return
logger = PLUGIN["logger"] logger = PLUGIN["logger"]
logger.info("Submitting listen to ListenBrainz") logger.info("Submitting listen to ListenBrainz")
client = liblistenbrainz.ListenBrainz() client = liblistenbrainz.ListenBrainz()
client.set_auth_token(user_token) client.set_auth_token(user_token)
listen = get_listen(listening.track) listen = get_lb_listen(listening)
client.submit_single_listen(listen) client.submit_single_listen(listen)
def get_listen(track): def get_lb_listen(listening):
track = listening.track
additional_info = { additional_info = {
"media_player": "Funkwhale", "media_player": "Funkwhale",
"media_player_version": funkwhale_api.__version__, "media_player_version": funkwhale_api.__version__,
@ -51,7 +54,83 @@ def get_listen(track):
return liblistenbrainz.Listen( return liblistenbrainz.Listen(
track_name=track.title, track_name=track.title,
artist_name=track.artist.name, artist_name=track.artist.name,
listened_at=int(timezone.now()), listened_at=listening.creation_date.timestamp(),
release_name=release_name, release_name=release_name,
additional_info=additional_info, 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(user=user)
.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(user=user)
.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( PLUGIN = plugins.get_plugin_config(
name="listenbrainz", name="listenbrainz",
label="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 homepage="https://docs.funkwhale.audio/users/builtinplugins.html#listenbrainz-plugin", # noqa
version="0.3", version="0.3",
user=True, user=True,
@ -13,6 +13,45 @@ PLUGIN = plugins.get_plugin_config(
"type": "text", "type": "text",
"label": "Your ListenBrainz user token", "label": "Your ListenBrainz user token",
"help": "You can find your user token in your ListenBrainz profile at https://listenbrainz.org/profile/", "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,
user=user,
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(
user=user,
creation_date=datetime.datetime.fromtimestamp(
feedback["created"], timezone.utc
),
track=track,
source="Listenbrainz",
)
elif feedback["score"] == 0:
try:
favorites_models.TrackFavorite.objects.get(
user=user, track=track
).delete()
except favorites_models.TrackFavorite.DoesNotExist:
continue
elif feedback["score"] == -1:
logger.info("Funkwhale doesn't support disliked tracks")

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

@ -12,6 +12,7 @@ class TrackFavorite(models.Model):
track = models.ForeignKey( track = models.ForeignKey(
Track, related_name="track_favorites", on_delete=models.CASCADE Track, related_name="track_favorites", on_delete=models.CASCADE
) )
source = models.CharField(max_length=100, null=True, blank=True)
class Meta: class Meta:
unique_together = ("track", "user") unique_together = ("track", "user")

Wyświetl plik

@ -4,6 +4,7 @@ from rest_framework import mixins, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.response import Response from rest_framework.response import Response
from config import plugins
from funkwhale_api.activity import record from funkwhale_api.activity import record
from funkwhale_api.common import fields, permissions from funkwhale_api.common import fields, permissions
from funkwhale_api.music import utils as music_utils from funkwhale_api.music import utils as music_utils
@ -44,6 +45,11 @@ class TrackFavoriteViewSet(
instance = self.perform_create(serializer) instance = self.perform_create(serializer)
serializer = self.get_serializer(instance=instance) serializer = self.get_serializer(instance=instance)
headers = self.get_success_headers(serializer.data) 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) record.send(instance)
return Response( return Response(
serializer.data, status=status.HTTP_201_CREATED, headers=headers serializer.data, status=status.HTTP_201_CREATED, headers=headers
@ -76,6 +82,11 @@ class TrackFavoriteViewSet(
except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist): except (AttributeError, ValueError, models.TrackFavorite.DoesNotExist):
return Response({}, status=400) return Response({}, status=400)
favorite.delete() 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) return Response([], status=status.HTTP_204_NO_CONTENT)
@extend_schema( @extend_schema(

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

@ -17,6 +17,7 @@ class Listening(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
session_key = models.CharField(max_length=100, null=True, blank=True) session_key = models.CharField(max_length=100, null=True, blank=True)
source = models.CharField(max_length=100, null=True, blank=True)
class Meta: class Meta:
ordering = ("-creation_date",) ordering = ("-creation_date",)

Wyświetl plik

@ -36,7 +36,6 @@ def delete_non_alnum_characters(text):
def resolve_recordings_to_fw_track(recordings): def resolve_recordings_to_fw_track(recordings):
""" """
Tries to match a troi recording entity to a fw track using the typesense index. 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 For test purposes : if multiple fw tracks are returned, we log the information
but only keep the best result in db to avoid duplicates. but only keep the best result in db to avoid duplicates.
""" """

Wyświetl plik

@ -0,0 +1,333 @@
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):
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(user=logged_in_client.user)
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"]()
# track lb fav
factories["music.Track"](mbid="195565db-65f9-4d0d-b347-5f0c85509528")
# random track
factories["music.Track"]()
# track lb neutral
track = factories["music.Track"](mbid="c5af5351-dbbf-4481-b52e-a480b6c57986")
favorite = factories["favorites.TrackFavorite"](track=track, user=user)
# 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"]()
# track lb fav
factories["music.Track"](mbid="195565db-65f9-4d0d-b347-5f0c85509528")
# track lb neutral
track = factories["music.Track"](mbid="c5af5351-dbbf-4481-b52e-a480b6c57986")
favorite = factories["favorites.TrackFavorite"](track=track, user=user)
# 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,
user=user,
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

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

Wyświetl plik

@ -0,0 +1 @@
Added `patch-package` to fronted, so that we can automatically patch `fomantic-ui-css` without running scripts every time.

Wyświetl plik

@ -19,8 +19,8 @@ The **ListenBrainz** plugin enables you to submit ({term}`scrobble<Scrobbling>`)
::: :::
:::{tab-item} Desktop :::{tab-item} Mobile
:sync: desktop :sync: mobile
1. Log in to your account. 1. Log in to your account.
2. Select the cog icon ({fa}`cog`) or your avatar to open the {guilabel}`Options` menu. 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. 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`.
:::
::::

Wyświetl plik

@ -15,74 +15,70 @@
"lint": "eslint --cache --cache-strategy content --ext .ts,.js,.vue,.json,.html src test cypress public/embed.html", "lint": "eslint --cache --cache-strategy content --ext .ts,.js,.vue,.json,.html src test cypress public/embed.html",
"lint:tsc": "vue-tsc --noEmit --incremental && tsc --noEmit --incremental -p cypress", "lint:tsc": "vue-tsc --noEmit --incremental && tsc --noEmit --incremental -p cypress",
"fix-fomantic-css": "scripts/fix-fomantic-css.sh", "fix-fomantic-css": "scripts/fix-fomantic-css.sh",
"postinstall": "yarn run fix-fomantic-css" "postinstall": "patch-package"
}, },
"dependencies": { "dependencies": {
"@funkwhale/ui": "0.2.2", "@funkwhale/ui": "0.2.2",
"@sentry/tracing": "7.47.0", "@sentry/tracing": "7.107.0",
"@sentry/vue": "7.47.0", "@sentry/vue": "7.107.0",
"@tauri-apps/api": "2.0.0-beta.1", "@tauri-apps/api": "2.0.0-beta.1",
"@vue/runtime-core": "3.3.11", "@vue/runtime-core": "3.4.21",
"@vueuse/core": "10.3.0", "@vueuse/core": "10.9.0",
"@vueuse/integrations": "10.3.0", "@vueuse/integrations": "10.9.0",
"@vueuse/math": "10.3.0", "@vueuse/math": "10.9.0",
"@vueuse/router": "10.3.0", "@vueuse/router": "10.9.0",
"axios": "1.2.3", "axios": "1.6.7",
"axios-auth-refresh": "3.3.6", "axios-auth-refresh": "3.3.6",
"butterchurn": "3.0.0-beta.4", "butterchurn": "3.0.0-beta.4",
"butterchurn-presets": "3.0.0-beta.4", "butterchurn-presets": "3.0.0-beta.4",
"diff": "5.1.0", "diff": "5.2.0",
"dompurify": "3.0.8", "dompurify": "3.0.9",
"focus-trap": "7.2.0", "focus-trap": "7.5.4",
"fomantic-ui-css": "2.9.3", "fomantic-ui-css": "2.9.3",
"idb-keyval": "6.2.1", "idb-keyval": "6.2.1",
"lodash-es": "4.17.21", "lodash-es": "4.17.21",
"lru-cache": "10.2.0", "lru-cache": "10.2.0",
"moment": "2.29.4", "moment": "2.30.1",
"showdown": "2.1.0", "showdown": "2.1.0",
"stacktrace-js": "2.0.2", "stacktrace-js": "2.0.2",
"standardized-audio-context": "25.3.60", "standardized-audio-context": "25.3.66",
"text-clipper": "2.2.0", "text-clipper": "2.2.0",
"transliteration": "2.3.5", "transliteration": "2.3.5",
"universal-cookie": "4.0.4", "vite-plugin-pwa": "0.19.3",
"vite-plugin-pwa": "0.14.4", "vue": "3.4.21",
"vue": "3.3.11", "vue-i18n": "9.10.1",
"vue-gettext": "2.1.12", "vue-router": "4.3.0",
"vue-i18n": "9.9.1",
"vue-router": "4.2.5",
"vue-upload-component": "3.1.8", "vue-upload-component": "3.1.8",
"vue-virtual-scroller": "2.0.0-beta.8", "vue-virtual-scroller": "2.0.0-beta.8",
"vue3-gettext": "2.3.4",
"vue3-lazyload": "0.3.8", "vue3-lazyload": "0.3.8",
"vuedraggable": "4.1.0", "vuedraggable": "4.1.0",
"vuex": "4.1.0", "vuex": "4.1.0",
"vuex-persistedstate": "4.1.0", "vuex-persistedstate": "4.1.0"
"vuex-router-sync": "5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@faker-js/faker": "8.4.1", "@faker-js/faker": "8.4.1",
"@intlify/eslint-plugin-vue-i18n": "2.0.0", "@intlify/eslint-plugin-vue-i18n": "2.0.0",
"@intlify/unplugin-vue-i18n": "2.0.0", "@intlify/unplugin-vue-i18n": "3.0.1",
"@tauri-apps/cli": "2.0.0-beta.2", "@tauri-apps/cli": "2.0.0-beta.2",
"@types/diff": "5.0.9", "@types/diff": "5.0.9",
"@types/dompurify": "3.0.5", "@types/dompurify": "3.0.5",
"@types/jquery": "3.5.29", "@types/jquery": "3.5.29",
"@types/lodash-es": "4.17.12", "@types/lodash-es": "4.17.12",
"@types/moxios": "0.4.17", "@types/moxios": "0.4.17",
"@types/qs": "6.9.10", "@types/qs": "6.9.12",
"@types/semantic-ui": "2.2.9", "@types/semantic-ui": "2.2.9",
"@types/showdown": "2.0.6", "@types/showdown": "2.0.6",
"@types/vue-virtual-scroller": "npm:@earltp/vue-virtual-scroller", "@types/vue-virtual-scroller": "npm:@earltp/vue-virtual-scroller",
"@typescript-eslint/eslint-plugin": "7.1.0", "@typescript-eslint/eslint-plugin": "7.2.0",
"@vitejs/plugin-vue": "5.0.3", "@vitejs/plugin-vue": "5.0.4",
"@vitest/coverage-v8": "1.3.1", "@vitest/coverage-v8": "1.3.1",
"@vue-macros/volar": "0.13.3", "@vue-macros/volar": "0.18.11",
"@vue/compiler-sfc": "3.3.11", "@vue/compiler-sfc": "3.4.21",
"@vue/eslint-config-standard": "8.0.1", "@vue/eslint-config-standard": "8.0.1",
"@vue/eslint-config-typescript": "12.0.0", "@vue/eslint-config-typescript": "13.0.0",
"@vue/test-utils": "2.2.7", "@vue/test-utils": "2.4.5",
"@vue/tsconfig": "0.5.1", "@vue/tsconfig": "0.5.1",
"cypress": "13.6.4", "cypress": "13.7.0",
"eslint": "8.57.0", "eslint": "8.57.0",
"eslint-config-standard": "17.1.0", "eslint-config-standard": "17.1.0",
"eslint-plugin-html": "8.0.0", "eslint-plugin-html": "8.0.0",
@ -90,25 +86,24 @@
"eslint-plugin-n": "16.6.2", "eslint-plugin-n": "16.6.2",
"eslint-plugin-node": "11.1.0", "eslint-plugin-node": "11.1.0",
"eslint-plugin-promise": "6.1.1", "eslint-plugin-promise": "6.1.1",
"eslint-plugin-vue": "9.22.0", "eslint-plugin-vue": "9.23.0",
"jsdom": "24.0.0", "jsdom": "24.0.0",
"jsonc-eslint-parser": "2.4.0", "jsonc-eslint-parser": "2.4.0",
"msw": "2.2.1", "msw": "2.2.3",
"msw-auto-mock": "0.18.0", "msw-auto-mock": "0.18.0",
"patch-package": "8.0.0", "patch-package": "8.0.0",
"rollup-plugin-visualizer": "5.9.0", "postinstall-postinstall": "2.1.0",
"sass": "1.57.1", "rollup-plugin-visualizer": "5.12.0",
"sinon": "15.0.2", "sass": "1.72.0",
"standardized-audio-context-mock": "9.6.32", "standardized-audio-context-mock": "9.7.2",
"typescript": "5.3.3", "typescript": "5.4.2",
"unplugin-vue-macros": "2.4.6", "unplugin-vue-macros": "2.7.10",
"utility-types": "3.10.0", "vite": "5.1.6",
"vite": "5.1.3",
"vitest": "1.3.1", "vitest": "1.3.1",
"vue-tsc": "1.8.27", "vue-tsc": "2.0.6",
"workbox-core": "6.5.4", "workbox-core": "7.0.0",
"workbox-precaching": "6.5.4", "workbox-precaching": "7.0.0",
"workbox-routing": "6.5.4", "workbox-routing": "7.0.0",
"workbox-strategies": "6.5.4" "workbox-strategies": "7.0.0"
} }
} }

File diff suppressed because one or more lines are too long

Wyświetl plik

@ -38,9 +38,9 @@ export interface Sound {
onSoundEnd: EventHookOn<Sound> onSoundEnd: EventHookOn<Sound>
} }
export const soundImplementations = reactive(new Set<Constructor<Sound>>()) export const soundImplementations: Set<Constructor<Sound>> = reactive(new Set<Constructor<Sound>>())
export const registerSoundImplementation = <T extends Constructor<Sound>>(implementation: T) => { export const registerSoundImplementation = <T extends Sound>(implementation: Constructor<T>): Constructor<T> => {
soundImplementations.add(implementation) soundImplementations.add(implementation)
return implementation return implementation
} }
@ -49,8 +49,8 @@ export const registerSoundImplementation = <T extends Constructor<Sound>>(implem
@registerSoundImplementation @registerSoundImplementation
export class HTMLSound implements Sound { export class HTMLSound implements Sound {
#audio = new Audio() #audio = new Audio()
#soundLoopEventHook = createEventHook<HTMLSound>() #soundLoopEventHook = createEventHook<Sound>()
#soundEndEventHook = createEventHook<HTMLSound>() #soundEndEventHook = createEventHook<Sound>()
#ignoreError = false #ignoreError = false
#scope = effectScope() #scope = effectScope()
@ -59,8 +59,8 @@ export class HTMLSound implements Sound {
readonly isDisposed = ref(false) readonly isDisposed = ref(false)
audioNode = createAudioSource(this.#audio) audioNode = createAudioSource(this.#audio)
onSoundLoop: EventHookOn<HTMLSound> onSoundLoop: EventHookOn<Sound>
onSoundEnd: EventHookOn<HTMLSound> onSoundEnd: EventHookOn<Sound>
constructor (sources: SoundSource[]) { constructor (sources: SoundSource[]) {
this.onSoundLoop = this.#soundLoopEventHook.on this.onSoundLoop = this.#soundLoopEventHook.on

Wyświetl plik

@ -83,6 +83,7 @@ watchEffect(async () => {
const list = ref() const list = ref()
const el = useCurrentElement() const el = useCurrentElement()
const scrollToCurrent = (behavior: ScrollBehavior = 'smooth') => { const scrollToCurrent = (behavior: ScrollBehavior = 'smooth') => {
if (!(el.value instanceof HTMLElement)) return
const item = el.value?.querySelector('.queue-item.active') const item = el.value?.querySelector('.queue-item.active')
item?.scrollIntoView({ item?.scrollIntoView({
behavior, behavior,
@ -275,7 +276,7 @@ if (!isWebGLSupported) {
<h1 class="ui header"> <h1 class="ui header">
<div class="content ellipsis"> <div class="content ellipsis">
<router-link <router-link
class="small header discrete link track" class="header discrete link track small"
:to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}" :to="{name: 'library.tracks.detail', params: {id: currentTrack.id }}"
> >
{{ currentTrack.title }} {{ currentTrack.title }}
@ -301,7 +302,7 @@ if (!isWebGLSupported) {
</h1> </h1>
<div <div
v-if="currentTrack && errored" v-if="currentTrack && errored"
class="ui small warning message" class="ui warning message small"
> >
<h3 class="header"> <h3 class="header">
{{ $t('components.Queue.header.failure') }} {{ $t('components.Queue.header.failure') }}
@ -316,7 +317,7 @@ if (!isWebGLSupported) {
</div> </div>
<div <div
v-else-if="currentSound && !currentSound.playable" v-else-if="currentSound && !currentSound.playable"
class="ui small warning message" class="ui warning message small"
> >
<h3 class="header"> <h3 class="header">
{{ $t('components.Queue.header.noSources') }} {{ $t('components.Queue.header.noSources') }}

Wyświetl plik

@ -64,6 +64,7 @@ const el = useCurrentElement()
const query = ref() const query = ref()
const enter = () => { const enter = () => {
if (!(el.value instanceof HTMLElement)) return
jQuery(el.value).search('cancel query') jQuery(el.value).search('cancel query')
// Cancel any API search request to backend // Cancel any API search request to backend
@ -113,7 +114,7 @@ const categories = computed(() => [
name: labels.value.tag, name: labels.value.tag,
getId: (obj: Tag) => obj.name, getId: (obj: Tag) => obj.name,
getTitle: (obj: Tag) => `#${obj.name}`, getTitle: (obj: Tag) => `#${obj.name}`,
getDescription: (obj: Tag) => '' getDescription: (_: Tag) => ''
}, },
{ {
code: 'more', code: 'more',
@ -132,6 +133,7 @@ const objectId = computed(() => {
}) })
onMounted(() => { onMounted(() => {
if (!(el.value instanceof HTMLElement)) return
jQuery(el.value).search({ jQuery(el.value).search({
type: 'category', type: 'category',
minCharacters: 3, minCharacters: 3,
@ -142,13 +144,14 @@ onMounted(() => {
noResults: t('components.audio.SearchBar.empty.noResults') noResults: t('components.audio.SearchBar.empty.noResults')
}, },
onSelect (result, response) { onSelect (result, _response) {
if (!(el.value instanceof HTMLElement)) return
jQuery(el.value).search('set value', query.value) jQuery(el.value).search('set value', query.value)
router.push(result.routerUrl) router.push(result.routerUrl)
jQuery(el.value).search('hide results') jQuery(el.value).search('hide results')
return false return false
}, },
onSearchQuery (value) { onSearchQuery (_value) {
// query.value = value // query.value = value
emit('search') emit('search')
}, },

Wyświetl plik

@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { BackendError, Application, PrivacyLevel } from '~/types' import type { BackendError, Application, PrivacyLevel } from '~/types'
import type { $ElementType } from 'utility-types'
import axios from 'axios' import axios from 'axios'
import $ from 'jquery' import $ from 'jquery'
@ -21,7 +20,7 @@ const SETTINGS_ORDER: FieldId[] = ['summary', 'privacy_level']
type Field = { id: 'summary', type: 'content', value: { text: string, content_type: 'text/markdown' } } type Field = { id: 'summary', type: 'content', value: { text: string, content_type: 'text/markdown' } }
| { id: 'privacy_level', type: 'dropdown', choices: PrivacyLevel[], value: string } | { id: 'privacy_level', type: 'dropdown', choices: PrivacyLevel[], value: string }
type FieldId = $ElementType<Field, 'id'> type FieldId = Field['id']
interface Settings { interface Settings {
success: boolean success: boolean
@ -274,7 +273,7 @@ fetchOwnedApps()
class="main pusher" class="main pusher"
> >
<div class="ui vertical stripe segment"> <div class="ui vertical stripe segment">
<section class="ui text container"> <section class="container ui text">
<h2 class="ui header"> <h2 class="ui header">
{{ $t('components.auth.Settings.header.accountSettings') }} {{ $t('components.auth.Settings.header.accountSettings') }}
</h2> </h2>
@ -344,8 +343,8 @@ fetchOwnedApps()
</button> </button>
</form> </form>
</section> </section>
<section class="ui text container"> <section class="container ui text">
<div class="ui hidden divider" /> <div class="hidden ui divider" />
<h2 class="ui header"> <h2 class="ui header">
{{ $t('components.auth.Settings.header.avatar') }} {{ $t('components.auth.Settings.header.avatar') }}
</h2> </h2>
@ -378,8 +377,8 @@ fetchOwnedApps()
</div> </div>
</section> </section>
<section class="ui text container"> <section class="container ui text">
<div class="ui hidden divider" /> <div class="hidden ui divider" />
<h2 class="ui header"> <h2 class="ui header">
{{ $t('components.auth.Settings.header.changePassword') }} {{ $t('components.auth.Settings.header.changePassword') }}
</h2> </h2>
@ -452,15 +451,15 @@ fetchOwnedApps()
</template> </template>
</dangerous-button> </dangerous-button>
</form> </form>
<div class="ui hidden divider" /> <div class="hidden ui divider" />
<subsonic-token-form /> <subsonic-token-form />
</section> </section>
<section <section
id="content-filters" id="content-filters"
class="ui text container" class="container ui text"
> >
<div class="ui hidden divider" /> <div class="hidden ui divider" />
<h2 class="ui header"> <h2 class="ui header">
<i class="eye slash outline icon" /> <i class="eye slash outline icon" />
<div class="content"> <div class="content">
@ -481,7 +480,7 @@ fetchOwnedApps()
<h3 class="ui header"> <h3 class="ui header">
{{ $t('components.auth.Settings.header.hiddenArtists') }} {{ $t('components.auth.Settings.header.hiddenArtists') }}
</h3> </h3>
<table class="ui compact very basic unstackable table"> <table class="table ui compact very basic unstackable">
<thead> <thead>
<tr> <tr>
<th> <th>
@ -520,9 +519,9 @@ fetchOwnedApps()
</section> </section>
<section <section
id="grants" id="grants"
class="ui text container" class="container ui text"
> >
<div class="ui hidden divider" /> <div class="hidden ui divider" />
<h2 class="ui header"> <h2 class="ui header">
<i class="open lock icon" /> <i class="open lock icon" />
<div class="content"> <div class="content">
@ -541,7 +540,7 @@ fetchOwnedApps()
</button> </button>
<table <table
v-if="apps.length > 0" v-if="apps.length > 0"
class="ui compact very basic unstackable table" class="table ui compact very basic unstackable"
> >
<thead> <thead>
<tr> <tr>
@ -600,9 +599,9 @@ fetchOwnedApps()
</section> </section>
<section <section
id="apps" id="apps"
class="ui text container" class="container ui text"
> >
<div class="ui hidden divider" /> <div class="hidden ui divider" />
<h2 class="ui header"> <h2 class="ui header">
<i class="code icon" /> <i class="code icon" />
<div class="content"> <div class="content">
@ -620,7 +619,7 @@ fetchOwnedApps()
</router-link> </router-link>
<table <table
v-if="ownedApps.length > 0" v-if="ownedApps.length > 0"
class="ui compact very basic unstackable table" class="table ui compact very basic unstackable"
> >
<thead> <thead>
<tr> <tr>
@ -694,9 +693,9 @@ fetchOwnedApps()
<section <section
id="plugins" id="plugins"
class="ui text container" class="container ui text"
> >
<div class="ui hidden divider" /> <div class="hidden ui divider" />
<h2 class="ui header"> <h2 class="ui header">
<i class="code icon" /> <i class="code icon" />
<div class="content"> <div class="content">
@ -713,8 +712,8 @@ fetchOwnedApps()
{{ $t('components.auth.Settings.link.managePlugins') }} {{ $t('components.auth.Settings.link.managePlugins') }}
</router-link> </router-link>
</section> </section>
<section class="ui text container"> <section class="container ui text">
<div class="ui hidden divider" /> <div class="hidden ui divider" />
<h2 class="ui header"> <h2 class="ui header">
<i class="comment icon" /> <i class="comment icon" />
<div class="content"> <div class="content">
@ -773,8 +772,8 @@ fetchOwnedApps()
</button> </button>
</form> </form>
</section> </section>
<section class="ui text container"> <section class="container ui text">
<div class="ui hidden divider" /> <div class="hidden ui divider" />
<h2 class="ui header"> <h2 class="ui header">
<i class="trash icon" /> <i class="trash icon" />
<div class="content"> <div class="content">

Wyświetl plik

@ -306,6 +306,7 @@ fetchQuota()
// //
const el = useCurrentElement() const el = useCurrentElement()
watch(() => availableChannels.channels, () => { watch(() => availableChannels.channels, () => {
if (!(el.value instanceof HTMLElement)) return
$(el.value).find('#channel-dropdown').dropdown({ $(el.value).find('#channel-dropdown').dropdown({
onChange (value) { onChange (value) {
values.channel = value values.channel = value
@ -496,7 +497,7 @@ defineExpose({
<template v-else> <template v-else>
<div <div
v-if="step === 2 && draftUploads?.length > 0 && includeDraftUploads === undefined" v-if="step === 2 && draftUploads?.length > 0 && includeDraftUploads === undefined"
class="ui visible info message" class="visible ui info message"
> >
<p> <p>
<i class="redo icon" /> <i class="redo icon" />
@ -544,7 +545,7 @@ defineExpose({
</div> </div>
<div <div
v-else-if="file.active && !file.response" v-else-if="file.active && !file.response"
class="ui active slow inline loader" class="inline ui slow loader active"
/> />
</div> </div>
<h4 class="ui header"> <h4 class="ui header">
@ -619,12 +620,12 @@ defineExpose({
<i class="upload icon" />&nbsp; <i class="upload icon" />&nbsp;
{{ $t('components.channels.UploadForm.message.dragAndDrop') }} {{ $t('components.channels.UploadForm.message.dragAndDrop') }}
</div> </div>
<div class="ui very small divider" /> <div class="ui very divider small" />
<div> <div>
{{ $t('components.channels.UploadForm.label.openBrowser') }} {{ $t('components.channels.UploadForm.label.openBrowser') }}
</div> </div>
</file-upload-widget> </file-upload-widget>
<div class="ui hidden divider" /> <div class="hidden ui divider" />
</template> </template>
</template> </template>
</form> </form>

Wyświetl plik

@ -87,6 +87,7 @@ onMounted(() => {
} }
} }
if (!(el.value instanceof HTMLElement)) return
$(el.value).find(selector).dropdown(settings) $(el.value).find(selector).dropdown(settings)
} }
}) })

Wyświetl plik

@ -64,6 +64,7 @@ const privacyLevelChoices = computed(() => [
const el = useCurrentElement() const el = useCurrentElement()
onMounted(async () => { onMounted(async () => {
await nextTick() await nextTick()
if (!(el.value instanceof HTMLElement)) return
$(el.value).find('.dropdown').dropdown() $(el.value).find('.dropdown').dropdown()
}) })

Wyświetl plik

@ -61,11 +61,11 @@ const onTouchmove = (event: TouchEvent) => {
} }
} }
document.addEventListener('touchcancel', (event: TouchEvent) => { document.addEventListener('touchcancel', (_event: TouchEvent) => {
cleanup() cleanup()
}) })
const reorder = (event: MouseEvent | TouchEvent) => { const reorder = (_event: MouseEvent | TouchEvent) => {
if (draggedItem.value) { if (draggedItem.value) {
const from = draggedItem.value.index const from = draggedItem.value.index
let to = hoveredIndex.value let to = hoveredIndex.value
@ -155,6 +155,7 @@ const { resume, pause } = useRafFn(() => {
const now = +new Date() const now = +new Date()
const direction = scrollDirection.value const direction = scrollDirection.value
if (!(el.value instanceof HTMLElement)) return
if (direction && el.value?.children[0] && !isTouch.value) { if (direction && el.value?.children[0] && !isTouch.value) {
el.value.children[0].scrollTop += 200 / (now - lastDate) * (direction === 'up' ? -1 : 1) el.value.children[0].scrollTop += 200 / (now - lastDate) * (direction === 'up' ? -1 : 1)
} }

Wyświetl plik

@ -131,6 +131,7 @@ export default (props: PlayOptionsProps) => {
const el = useCurrentElement() const el = useCurrentElement()
const enqueue = async () => { const enqueue = async () => {
if (!(el.value instanceof HTMLElement)) return
jQuery(el.value).find('.ui.dropdown').dropdown('hide') jQuery(el.value).find('.ui.dropdown').dropdown('hide')
const tracks = await getPlayableTracks() const tracks = await getPlayableTracks()
@ -139,6 +140,7 @@ export default (props: PlayOptionsProps) => {
} }
const enqueueNext = async (next = false) => { const enqueueNext = async (next = false) => {
if (!(el.value instanceof HTMLElement)) return
jQuery(el.value).find('.ui.dropdown').dropdown('hide') jQuery(el.value).find('.ui.dropdown').dropdown('hide')
const tracks = await getPlayableTracks() const tracks = await getPlayableTracks()
@ -157,6 +159,7 @@ export default (props: PlayOptionsProps) => {
const replacePlay = async (index?: number) => { const replacePlay = async (index?: number) => {
await clear() await clear()
if (!(el.value instanceof HTMLElement)) return
jQuery(el.value).find('.ui.dropdown').dropdown('hide') jQuery(el.value).find('.ui.dropdown').dropdown('hide')
const tracksToPlay = await getPlayableTracks() const tracksToPlay = await getPlayableTracks()

Wyświetl plik

@ -3203,6 +3203,7 @@
"allow": "Toelaten", "allow": "Toelaten",
"deny": "Afwijzen", "deny": "Afwijzen",
"funkwhaleInstance": "Officiële Glitchtip server van Funkwhale", "funkwhaleInstance": "Officiële Glitchtip server van Funkwhale",
"message": "De stacksporen zullen gedeeld worden naar { 0 } om ons te helpen begrijpen waarom en hoe deze fout is gebeurd.",
"title": "Om de kwaliteit van onze diensten te verbeteren, zouden we informatie willen verzamelen van de crashes die gebeuren tijdens jouw sessie." "title": "Om de kwaliteit van onze diensten te verbeteren, zouden we informatie willen verzamelen van de crashes die gebeuren tijdens jouw sessie."
}, },
"serviceWorker": { "serviceWorker": {
@ -3214,6 +3215,28 @@
} }
}, },
"views": { "views": {
"ChooseInstance": {
"button": {
"submit": "Indienen"
},
"header": {
"chooseInstance": "Kies je server",
"failure": "Het is niet mogelijk om verbinding te maken met de opgegeven URL",
"suggestions": "Aanbevelingen"
},
"help": {
"notFunkwhaleServer": "Het opgegeven adres is geen Funkwhale-server",
"selectPod": "Selecteer met welke Funkwhale-server je wil verbinden. Voer zelf de URL in, of kies een van de suggesties.",
"serverDown": "De server is mogelijk niet beschikbaar"
},
"label": {
"url": "Server-URL"
},
"message": {
"currentConnection": "U bent nu verbonden met { 0 }. Als je doorgaat wordt je verbinding verbroken met je huidige instance en je lokale data zal worden verwijderd.",
"newUrl": "Je gebruikt nu de Funkwhale-server op { url }"
}
},
"Notifications": { "Notifications": {
"button": { "button": {
"read": "Alles markeren als gelezen", "read": "Alles markeren als gelezen",
@ -4587,28 +4610,6 @@
}, },
"title": "Radio" "title": "Radio"
} }
},
"ChooseInstance": {
"button": {
"submit": "Indienen"
},
"header": {
"chooseInstance": "Kies je server",
"failure": "Het is niet mogelijk om verbinding te maken met de opgegeven URL",
"suggestions": "Aanbevelingen"
},
"help": {
"notFunkwhaleServer": "Het opgegeven adres is geen Funkwhale-server",
"selectPod": "Selecteer met welke Funkwhale-server je wil verbinden. Voer zelf de URL in, of kies een van de suggesties.",
"serverDown": "De server is mogelijk niet beschikbaar"
},
"label": {
"url": "Server-URL"
},
"message": {
"currentConnection": "U bent nu verbonden met { 0 }. Als je doorgaat wordt je verbinding verbroken met je huidige instance en je lokale data zal worden verwijderd.",
"newUrl": "Je gebruikt nu de Funkwhale-server op { url }"
}
} }
} }
} }

Wyświetl plik

@ -1,8 +1,11 @@
/// <reference types="semantic-ui" /> /// <reference types="semantic-ui" />
import type { MaybeElement } from '@vueuse/core'
import $ from 'jquery' import $ from 'jquery'
export const setupDropdown = (selector: string | HTMLElement = '.ui.dropdown', el: Element = document.body) => { export const setupDropdown = (selector: string | HTMLElement = '.ui.dropdown', el: MaybeElement = document.body) => {
if (!(el instanceof Element)) return null
const $dropdown = typeof selector === 'string' const $dropdown = typeof selector === 'string'
? $(el).find(selector) ? $(el).find(selector)
: $(selector) : $(selector)

Wyświetl plik

@ -140,11 +140,13 @@ if (route.hash) {
const el = useCurrentElement() const el = useCurrentElement()
onMounted(async () => { onMounted(async () => {
await nextTick() await nextTick()
if (!(el.value instanceof HTMLElement)) return
$(el.value).find('select.dropdown').dropdown() $(el.value).find('select.dropdown').dropdown()
}) })
watch(settingsData, async () => { watch(settingsData, async () => {
await nextTick() await nextTick()
if (!(el.value instanceof HTMLElement)) return
$(el.value).find('.sticky').sticky({ context: '#settings-grid' }) $(el.value).find('.sticky').sticky({ context: '#settings-grid' })
}) })
@ -172,12 +174,12 @@ await nextTick()
class="main pusher" class="main pusher"
> >
<div class="ui vertical stripe segment"> <div class="ui vertical stripe segment">
<div class="ui text container"> <div class="container ui text">
<div :class="['ui', {'loading': isLoading}, 'form']" /> <div :class="['ui', {'loading': isLoading}, 'form']" />
<div <div
v-if="settingsData" v-if="settingsData"
id="settings-grid" id="settings-grid"
class="ui grid" class="grid ui"
> >
<div class="twelve wide stretched column"> <div class="twelve wide stretched column">
<settings-group <settings-group
@ -188,7 +190,7 @@ await nextTick()
/> />
</div> </div>
<div class="four wide column"> <div class="four wide column">
<div class="ui sticky vertical secondary menu"> <div class="sticky ui vertical secondary menu">
<div class="header item"> <div class="header item">
{{ $t('views.admin.Settings.header.sections') }} {{ $t('views.admin.Settings.header.sections') }}
</div> </div>

Wyświetl plik

@ -100,6 +100,7 @@ fetchData()
const el = useCurrentElement() const el = useCurrentElement()
watch(object, async () => { watch(object, async () => {
await nextTick() await nextTick()
if (!(el.value instanceof HTMLElement)) return
$(el.value).find('select.dropdown').dropdown() $(el.value).find('select.dropdown').dropdown()
}) })
@ -156,7 +157,7 @@ const updatePolicy = (newPolicy: InstancePolicy) => {
v-title="object.full_username" v-title="object.full_username"
:class="['ui', 'head', 'vertical', 'stripe', 'segment']" :class="['ui', 'head', 'vertical', 'stripe', 'segment']"
> >
<div class="ui stackable two column grid"> <div class="grid ui stackable two column">
<div class="ui column"> <div class="ui column">
<div class="segment-content"> <div class="segment-content">
<h2 class="ui header"> <h2 class="ui header">
@ -281,7 +282,7 @@ const updatePolicy = (newPolicy: InstancePolicy) => {
</div> </div>
</section> </section>
<div class="ui vertical stripe segment"> <div class="ui vertical stripe segment">
<div class="ui stackable three column grid"> <div class="grid ui stackable three column">
<div class="column"> <div class="column">
<section> <section>
<h3 class="ui header"> <h3 class="ui header">
@ -290,7 +291,7 @@ const updatePolicy = (newPolicy: InstancePolicy) => {
{{ $t('views.admin.moderation.AccountsDetail.header.accountData') }} {{ $t('views.admin.moderation.AccountsDetail.header.accountData') }}
</div> </div>
</h3> </h3>
<table class="ui very basic table"> <table class="table ui very basic">
<tbody> <tbody>
<tr> <tr>
<td> <td>
@ -448,7 +449,7 @@ const updatePolicy = (newPolicy: InstancePolicy) => {
</div> </div>
<table <table
v-else v-else
class="ui very basic table" class="table ui very basic"
> >
<tbody> <tbody>
<tr v-if="!object.user"> <tr v-if="!object.user">
@ -527,7 +528,7 @@ const updatePolicy = (newPolicy: InstancePolicy) => {
</div> </div>
<table <table
v-else v-else
class="ui very basic table" class="table ui very basic"
> >
<tbody> <tbody>
<tr v-if="!object.user"> <tr v-if="!object.user">

Wyświetl plik

@ -9,7 +9,6 @@
"types": [ "types": [
"vitest/globals", "vitest/globals",
"vite/client", "vite/client",
"vue/ref-macros",
"vite-plugin-pwa/client", "vite-plugin-pwa/client",
"unplugin-vue-macros/macros-global" "unplugin-vue-macros/macros-global"
], ],
@ -29,7 +28,8 @@
"@vue-macros/volar/short-vmodel", "@vue-macros/volar/short-vmodel",
"@vue-macros/volar/define-slots", "@vue-macros/volar/define-slots",
"@vue-macros/volar/export-props", "@vue-macros/volar/export-props",
"@vue-macros/volar/jsx-directive" "@vue-macros/volar/jsx-directive",
"@vue-macros/volar/setup-jsdoc"
] ]
} }
} }

Plik diff jest za duży Load Diff