kopia lustrzana https://dev.funkwhale.audio/funkwhale/funkwhale
Porównaj commity
29 Commity
3da11f9486
...
b46f4e788c
Autor | SHA1 | Data |
---|---|---|
Kasper Seweryn | b46f4e788c | |
Kasper Seweryn | 0f9fab01e9 | |
Kasper Seweryn | 2635180656 | |
Kasper Seweryn | 1ac0e754a3 | |
Kasper Seweryn | b1f89413ac | |
Kasper Seweryn | 971a5d4ae4 | |
Kasper Seweryn | 8c8f71176f | |
Kasper Seweryn | 424eb2223f | |
Kasper Seweryn | c8bbab0369 | |
Kasper Seweryn | 90c84b0a5d | |
Kasper Seweryn | 867663ce9f | |
Petitminion | ba5b657b61 | |
Petitminion | 4fc73c1430 | |
Ciarán Ainsworth | 97e24bcaa6 | |
Ciarán Ainsworth | 1b15fea1ab | |
Ciarán Ainsworth | b624fea2fa | |
Ciarán Ainsworth | e028e8788b | |
Ciarán Ainsworth | 67f74d40a6 | |
Petitminion | 547bd6f371 | |
Petitminion | 05ec6f6d0f | |
Petitminion | a03cc1db24 | |
Petitminion | 2a364d5785 | |
Petitminion | 5bc0171694 | |
Petitminion | 37acfa475d | |
Petitminion | f45fd1e465 | |
Petitminion | 17c4a92f77 | |
Petitminion | 6414302899 | |
Ciarán Ainsworth | 94a5b9e696 | |
Bruno-Van-den-Bosch | d673e77dff |
|
@ -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
|
||||||
|
|
|
@ -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 \
|
||||||
|
|
|
@ -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"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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]
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
|
@ -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")
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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")
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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),
|
||||||
|
),
|
||||||
|
]
|
|
@ -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",)
|
||||||
|
|
|
@ -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.
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -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)
|
|
@ -0,0 +1 @@
|
||||||
|
Add favorite and listening sync ith Listenbrainz (#2079)
|
|
@ -0,0 +1 @@
|
||||||
|
Added `patch-package` to fronted, so that we can automatically patch `fomantic-ui-css` without running scripts every time.
|
|
@ -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`.
|
||||||
|
|
||||||
|
:::
|
||||||
|
::::
|
||||||
|
|
|
@ -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
|
@ -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
|
||||||
|
|
|
@ -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') }}
|
||||||
|
|
|
@ -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')
|
||||||
},
|
},
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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" />
|
<i class="upload icon" />
|
||||||
{{ $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>
|
||||||
|
|
|
@ -87,6 +87,7 @@ onMounted(() => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!(el.value instanceof HTMLElement)) return
|
||||||
$(el.value).find(selector).dropdown(settings)
|
$(el.value).find(selector).dropdown(settings)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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 }"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
2181
front/yarn.lock
2181
front/yarn.lock
Plik diff jest za duży
Load Diff
Ładowanie…
Reference in New Issue