Compare commits
No commits in common. "develop" and "2.0.0-alpha.1" have entirely different histories.
develop
...
2.0.0-alph
13
CHANGELOG.md
13
CHANGELOG.md
|
@ -9,19 +9,6 @@ This changelog is viewable on the web at https://docs.funkwhale.audio/changelog.
|
||||||
|
|
||||||
<!-- towncrier -->
|
<!-- towncrier -->
|
||||||
|
|
||||||
## 2.0.0-alpha.2 (2025-06-10)
|
|
||||||
|
|
||||||
Upgrade instructions are available at https://docs.funkwhale.audio/administrator/upgrade/index.html
|
|
||||||
|
|
||||||
Enhancements:
|
|
||||||
|
|
||||||
- Make playlist detail page reactive to plugin upload updates (#2464)
|
|
||||||
- Only refresh_nodeinfo_known_nodes for Funkwhale instances (#2442)
|
|
||||||
|
|
||||||
Bugfixes:
|
|
||||||
|
|
||||||
- Fixed database migrations for trackfavorite, playlist and playlisttrack
|
|
||||||
|
|
||||||
## 2.0.0-alpha.1 (2025-05-23)
|
## 2.0.0-alpha.1 (2025-05-23)
|
||||||
|
|
||||||
Carefully read [this blog post](https://blog.funkwhale.audio/2025-funkwhale-2-news.html) before upgrading. This alpha release might break your db.
|
Carefully read [this blog post](https://blog.funkwhale.audio/2025-funkwhale-2-news.html) before upgrading. This alpha release might break your db.
|
||||||
|
|
|
@ -556,15 +556,7 @@ The path where static files are collected.
|
||||||
"""
|
"""
|
||||||
# See: https://docs.djangoproject.com/en/dev/ref/settings/#static-url
|
# See: https://docs.djangoproject.com/en/dev/ref/settings/#static-url
|
||||||
STATIC_URL = env("STATIC_URL", default=FUNKWHALE_URL + "/staticfiles/")
|
STATIC_URL = env("STATIC_URL", default=FUNKWHALE_URL + "/staticfiles/")
|
||||||
STORAGES = {
|
DEFAULT_FILE_STORAGE = "funkwhale_api.common.storage.ASCIIFileSystemStorage"
|
||||||
"default": {
|
|
||||||
"BACKEND": "funkwhale_api.common.storage.ASCIIFileSystemStorage",
|
|
||||||
},
|
|
||||||
"staticfiles": {
|
|
||||||
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
PROXY_MEDIA = env.bool("PROXY_MEDIA", default=True)
|
PROXY_MEDIA = env.bool("PROXY_MEDIA", default=True)
|
||||||
"""
|
"""
|
||||||
|
@ -663,7 +655,7 @@ if AWS_ACCESS_KEY_ID:
|
||||||
A directory in your S3 bucket where you store files.
|
A directory in your S3 bucket where you store files.
|
||||||
Use this if you plan to share the bucket between services.
|
Use this if you plan to share the bucket between services.
|
||||||
"""
|
"""
|
||||||
STORAGES["default"]["BACKEND"] = "funkwhale_api.common.storage.ASCIIS3Boto3Storage"
|
DEFAULT_FILE_STORAGE = "funkwhale_api.common.storage.ASCIIS3Boto3Storage"
|
||||||
|
|
||||||
|
|
||||||
# See:
|
# See:
|
||||||
|
|
|
@ -41,6 +41,10 @@ SECRET_KEY = env("DJANGO_SECRET_KEY")
|
||||||
# SESSION_COOKIE_HTTPONLY = True
|
# SESSION_COOKIE_HTTPONLY = True
|
||||||
# SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=True)
|
# SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=True)
|
||||||
|
|
||||||
|
# Static Assets
|
||||||
|
# ------------------------
|
||||||
|
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
|
||||||
|
|
||||||
# TEMPLATE CONFIGURATION
|
# TEMPLATE CONFIGURATION
|
||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
# See:
|
# See:
|
||||||
|
|
|
@ -19,10 +19,6 @@ class JsonAuthConsumer(JsonWebsocketConsumer):
|
||||||
channels.group_add(group, self.channel_name)
|
channels.group_add(group, self.channel_name)
|
||||||
|
|
||||||
def disconnect(self, close_code):
|
def disconnect(self, close_code):
|
||||||
if self.scope.get("user", False) and self.scope.get("user").pk is not None:
|
|
||||||
groups = self.scope["user"].get_channels_groups() + self.groups
|
groups = self.scope["user"].get_channels_groups() + self.groups
|
||||||
else:
|
|
||||||
groups = self.groups
|
|
||||||
|
|
||||||
for group in groups:
|
for group in groups:
|
||||||
channels.group_discard(group, self.channel_name)
|
channels.group_discard(group, self.channel_name)
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
openapi: 3.0.3
|
openapi: 3.0.3
|
||||||
info:
|
info:
|
||||||
title: Funkwhale API
|
title: Funkwhale API
|
||||||
version: 2.0.0a2
|
version: 1.4.0
|
||||||
description: |
|
description: |
|
||||||
# Funkwhale API
|
# Funkwhale API
|
||||||
|
|
||||||
|
|
|
@ -55,7 +55,12 @@ class Migration(migrations.Migration):
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="trackfavorite",
|
model_name="trackfavorite",
|
||||||
name="fid",
|
name="fid",
|
||||||
field=models.URLField(default="https://default.fid"),
|
field=models.URLField(
|
||||||
|
db_index=True,
|
||||||
|
default="https://default.fid",
|
||||||
|
max_length=500,
|
||||||
|
unique=True,
|
||||||
|
),
|
||||||
preserve_default=False,
|
preserve_default=False,
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
|
@ -74,15 +79,6 @@ class Migration(migrations.Migration):
|
||||||
name="uuid",
|
name="uuid",
|
||||||
field=models.UUIDField(default=uuid.uuid4, unique=True, null=False),
|
field=models.UUIDField(default=uuid.uuid4, unique=True, null=False),
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
|
||||||
model_name="trackfavorite",
|
|
||||||
name="fid",
|
|
||||||
field=models.URLField(
|
|
||||||
db_index=True,
|
|
||||||
max_length=500,
|
|
||||||
unique=True,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.RunPython(get_user_actor, reverse_code=migrations.RunPython.noop),
|
migrations.RunPython(get_user_actor, reverse_code=migrations.RunPython.noop),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="trackfavorite",
|
model_name="trackfavorite",
|
||||||
|
|
|
@ -236,10 +236,8 @@ def refresh_nodeinfo_known_nodes():
|
||||||
settings.NODEINFO_REFRESH_DELAY
|
settings.NODEINFO_REFRESH_DELAY
|
||||||
"""
|
"""
|
||||||
limit = timezone.now() - datetime.timedelta(seconds=settings.NODEINFO_REFRESH_DELAY)
|
limit = timezone.now() - datetime.timedelta(seconds=settings.NODEINFO_REFRESH_DELAY)
|
||||||
candidates = (
|
candidates = models.Domain.objects.external().exclude(
|
||||||
models.Domain.objects.external()
|
nodeinfo_fetch_date__gte=limit
|
||||||
.exclude(nodeinfo_fetch_date__gte=limit)
|
|
||||||
.filter(nodeinfo__software__name="Funkwhale")
|
|
||||||
)
|
)
|
||||||
names = candidates.values_list("name", flat=True)
|
names = candidates.values_list("name", flat=True)
|
||||||
logger.info("Launching periodic nodeinfo refresh on %s domains", len(names))
|
logger.info("Launching periodic nodeinfo refresh on %s domains", len(names))
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
import os
|
|
||||||
import pathlib
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from django import urls
|
from django import urls
|
||||||
|
@ -905,17 +903,13 @@ class FSImportSerializer(serializers.Serializer):
|
||||||
prune = serializers.BooleanField(required=False, default=True)
|
prune = serializers.BooleanField(required=False, default=True)
|
||||||
outbox = serializers.BooleanField(required=False, default=False)
|
outbox = serializers.BooleanField(required=False, default=False)
|
||||||
broadcast = serializers.BooleanField(required=False, default=False)
|
broadcast = serializers.BooleanField(required=False, default=False)
|
||||||
replace = serializers.BooleanField(required=False, default=False)
|
|
||||||
batch_size = serializers.IntegerField(required=False, default=1000)
|
batch_size = serializers.IntegerField(required=False, default=1000)
|
||||||
verbosity = serializers.IntegerField(required=False, default=1)
|
verbosity = serializers.IntegerField(required=False, default=1)
|
||||||
|
|
||||||
def validate_path(self, value):
|
def validate_path(self, value):
|
||||||
try:
|
try:
|
||||||
utils.browse_dir(settings.MUSIC_DIRECTORY_PATH, value)
|
utils.browse_dir(settings.MUSIC_DIRECTORY_PATH, value)
|
||||||
except NotADirectoryError:
|
except (NotADirectoryError, FileNotFoundError, ValueError):
|
||||||
if not os.path.isfile(pathlib.Path(settings.MUSIC_DIRECTORY_PATH) / value):
|
|
||||||
raise serializers.ValidationError("Invalid path")
|
|
||||||
except (FileNotFoundError, ValueError):
|
|
||||||
raise serializers.ValidationError("Invalid path")
|
raise serializers.ValidationError("Invalid path")
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
|
@ -1209,7 +1209,6 @@ def fs_import(
|
||||||
prune=True,
|
prune=True,
|
||||||
outbox=False,
|
outbox=False,
|
||||||
broadcast=False,
|
broadcast=False,
|
||||||
replace=False,
|
|
||||||
batch_size=1000,
|
batch_size=1000,
|
||||||
verbosity=1,
|
verbosity=1,
|
||||||
):
|
):
|
||||||
|
@ -1230,7 +1229,7 @@ def fs_import(
|
||||||
"batch_size": batch_size,
|
"batch_size": batch_size,
|
||||||
"async_": False,
|
"async_": False,
|
||||||
"prune": prune,
|
"prune": prune,
|
||||||
"replace": replace,
|
"replace": False,
|
||||||
"verbosity": verbosity,
|
"verbosity": verbosity,
|
||||||
"exit_on_failure": False,
|
"exit_on_failure": False,
|
||||||
"outbox": outbox,
|
"outbox": outbox,
|
||||||
|
|
|
@ -386,7 +386,6 @@ class LibraryViewSet(
|
||||||
prune=serializer.validated_data["prune"],
|
prune=serializer.validated_data["prune"],
|
||||||
outbox=serializer.validated_data["outbox"],
|
outbox=serializer.validated_data["outbox"],
|
||||||
broadcast=serializer.validated_data["broadcast"],
|
broadcast=serializer.validated_data["broadcast"],
|
||||||
replace=serializer.validated_data["replace"],
|
|
||||||
batch_size=serializer.validated_data["batch_size"],
|
batch_size=serializer.validated_data["batch_size"],
|
||||||
verbosity=serializer.validated_data["verbosity"],
|
verbosity=serializer.validated_data["verbosity"],
|
||||||
)
|
)
|
||||||
|
|
|
@ -25,7 +25,7 @@ def gen_uuid(apps, schema_editor):
|
||||||
unique_uuid = uuid.uuid4()
|
unique_uuid = uuid.uuid4()
|
||||||
|
|
||||||
fid = utils.full_url(
|
fid = utils.full_url(
|
||||||
reverse("federation:music:playlists-detail", kwargs={"uuid": unique_uuid})
|
reverse("federation:music:playlist-detail", kwargs={"uuid": unique_uuid})
|
||||||
)
|
)
|
||||||
row.uuid = unique_uuid
|
row.uuid = unique_uuid
|
||||||
row.fid = fid
|
row.fid = fid
|
||||||
|
@ -42,7 +42,7 @@ class Migration(migrations.Migration):
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="playlist",
|
model_name="playlist",
|
||||||
name="fid",
|
name="fid",
|
||||||
field=models.URLField(max_length=500, null=True),
|
field=models.URLField(max_length=500 ),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="playlist",
|
model_name="playlist",
|
||||||
|
@ -63,13 +63,8 @@ class Migration(migrations.Migration):
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="playlist",
|
model_name="playlist",
|
||||||
name="fid",
|
name="fid",
|
||||||
field=models.URLField(
|
field=models.URLField(max_length=500, unique=True, db_index=True,
|
||||||
max_length=500,
|
),),
|
||||||
unique=True,
|
|
||||||
db_index=True,
|
|
||||||
null=False,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="playlist",
|
model_name="playlist",
|
||||||
name="actor",
|
name="actor",
|
||||||
|
|
|
@ -9,14 +9,14 @@ from funkwhale_api.federation import utils
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
def gen_uuid(apps, schema_editor):
|
def gen_uuid(apps, schema_editor):
|
||||||
MyModel = apps.get_model("playlists", "PlaylistTrack")
|
MyModel = apps.get_model("playlists", "Playlist")
|
||||||
for row in MyModel.objects.all():
|
for row in MyModel.objects.all():
|
||||||
unique_uuid = uuid.uuid4()
|
unique_uuid = uuid.uuid4()
|
||||||
while MyModel.objects.filter(uuid=unique_uuid).exists():
|
while MyModel.objects.filter(uuid=unique_uuid).exists():
|
||||||
unique_uuid = uuid.uuid4()
|
unique_uuid = uuid.uuid4()
|
||||||
|
|
||||||
fid = utils.full_url(
|
fid = utils.full_url(
|
||||||
reverse("federation:music:playlists-detail", kwargs={"uuid": unique_uuid})
|
reverse("federation:music:playlist-detail", kwargs={"uuid": unique_uuid})
|
||||||
)
|
)
|
||||||
row.uuid = unique_uuid
|
row.uuid = unique_uuid
|
||||||
row.fid = fid
|
row.fid = fid
|
||||||
|
@ -38,7 +38,8 @@ class Migration(migrations.Migration):
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="playlisttrack",
|
model_name="playlisttrack",
|
||||||
name="fid",
|
name="fid",
|
||||||
field=models.URLField(max_length=500, null=True),
|
field=models.URLField(max_length=500
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name="playlisttrack",
|
model_name="playlisttrack",
|
||||||
|
@ -47,7 +48,7 @@ class Migration(migrations.Migration):
|
||||||
),
|
),
|
||||||
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
|
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name="playlisttrack",
|
model_name="playlist",
|
||||||
name="uuid",
|
name="uuid",
|
||||||
field=models.UUIDField(default=uuid.uuid4, null=False, unique=True),
|
field=models.UUIDField(default=uuid.uuid4, null=False, unique=True),
|
||||||
),
|
),
|
||||||
|
@ -55,7 +56,6 @@ class Migration(migrations.Migration):
|
||||||
model_name="playlisttrack",
|
model_name="playlisttrack",
|
||||||
name="fid",
|
name="fid",
|
||||||
field=models.URLField(
|
field=models.URLField(
|
||||||
db_index=True, max_length=500, unique=True, null=False
|
db_index=True, max_length=500, unique=True
|
||||||
),
|
),),
|
||||||
),
|
|
||||||
]
|
]
|
||||||
|
|
|
@ -307,7 +307,7 @@ class PlaylistTrackQuerySet(models.QuerySet, common_models.LocalFromFidQuerySet)
|
||||||
return self.annotate(is_playable_by_actor=subquery)
|
return self.annotate(is_playable_by_actor=subquery)
|
||||||
|
|
||||||
def playable_by(self, actor, include=True):
|
def playable_by(self, actor, include=True):
|
||||||
tracks = music_models.Track.objects.playable_by(actor)
|
tracks = music_models.Track.objects.playable_by(actor, include)
|
||||||
if include:
|
if include:
|
||||||
return self.filter(track__pk__in=tracks).distinct()
|
return self.filter(track__pk__in=tracks).distinct()
|
||||||
else:
|
else:
|
||||||
|
|
|
@ -67,10 +67,8 @@ class PlaylistSerializer(serializers.ModelSerializer):
|
||||||
|
|
||||||
@extend_schema_field(OpenApiTypes.BOOL)
|
@extend_schema_field(OpenApiTypes.BOOL)
|
||||||
def get_library_followed(self, obj):
|
def get_library_followed(self, obj):
|
||||||
if (
|
if self.context.get("request", False) and hasattr(
|
||||||
self.context.get("request", False)
|
self.context["request"], "user"
|
||||||
and hasattr(self.context["request"], "user")
|
|
||||||
and hasattr(self.context["request"].user, "actor")
|
|
||||||
):
|
):
|
||||||
actor = self.context["request"].user.actor
|
actor = self.context["request"].user.actor
|
||||||
lib_qs = obj.library.received_follows.filter(actor=actor)
|
lib_qs = obj.library.received_follows.filter(actor=actor)
|
||||||
|
|
|
@ -7,7 +7,6 @@ from django.db.models import Count
|
||||||
from drf_spectacular.utils import extend_schema
|
from drf_spectacular.utils import extend_schema
|
||||||
from rest_framework import exceptions, mixins, status, viewsets
|
from rest_framework import exceptions, mixins, status, viewsets
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.pagination import PageNumberPagination
|
|
||||||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||||||
from rest_framework.renderers import JSONRenderer
|
from rest_framework.renderers import JSONRenderer
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
@ -130,25 +129,18 @@ class PlaylistViewSet(
|
||||||
@action(methods=["get"], detail=True)
|
@action(methods=["get"], detail=True)
|
||||||
def tracks(self, request, *args, **kwargs):
|
def tracks(self, request, *args, **kwargs):
|
||||||
playlist = self.get_object()
|
playlist = self.get_object()
|
||||||
actor = music_utils.get_actor_from_request(request)
|
plts = playlist.playlist_tracks.all().for_nested_serialization(
|
||||||
plts = playlist.playlist_tracks.all().for_nested_serialization(actor)
|
music_utils.get_actor_from_request(request)
|
||||||
for plt in plts.playable_by(actor, include=False)[
|
)
|
||||||
: settings.THIRD_PARTY_UPLOAD_MAX_UPLOADS
|
plts_without_upload = plts.filter(track__uploads__isnull=True)
|
||||||
]:
|
for plt in plts_without_upload[: settings.THIRD_PARTY_UPLOAD_MAX_UPLOADS]:
|
||||||
plugins.trigger_hook(
|
plugins.trigger_hook(
|
||||||
plugins.TRIGGER_THIRD_PARTY_UPLOAD,
|
plugins.TRIGGER_THIRD_PARTY_UPLOAD,
|
||||||
track=plt.track,
|
track=plt.track,
|
||||||
actor=actor,
|
|
||||||
)
|
)
|
||||||
|
serializer = serializers.PlaylistTrackSerializer(plts, many=True)
|
||||||
# Apply pagination
|
data = {"count": len(plts), "results": serializer.data}
|
||||||
paginator = PageNumberPagination()
|
return Response(data, status=200)
|
||||||
paginator.page_size = 50 # Set the page size (number of items per page)
|
|
||||||
paginated_plts = paginator.paginate_queryset(plts, request)
|
|
||||||
|
|
||||||
# Serialize the paginated data
|
|
||||||
serializer = serializers.PlaylistTrackSerializer(paginated_plts, many=True)
|
|
||||||
return paginator.get_paginated_response(serializer.data)
|
|
||||||
|
|
||||||
@extend_schema(
|
@extend_schema(
|
||||||
operation_id="add_to_playlist", request=serializers.PlaylistAddManySerializer
|
operation_id="add_to_playlist", request=serializers.PlaylistAddManySerializer
|
||||||
|
|
|
@ -42,7 +42,7 @@ def structure_payload(data):
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"type": "funkwhale",
|
"type": "funkwhale",
|
||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
"openSubsonic": True,
|
"openSubsonic": "true",
|
||||||
}
|
}
|
||||||
payload.update(data)
|
payload.update(data)
|
||||||
if "detail" in payload:
|
if "detail" in payload:
|
||||||
|
@ -70,7 +70,6 @@ class SubsonicXMLRenderer(renderers.JSONRenderer):
|
||||||
return super().render(data, accepted_media_type, renderer_context)
|
return super().render(data, accepted_media_type, renderer_context)
|
||||||
final = structure_payload(data)
|
final = structure_payload(data)
|
||||||
final["xmlns"] = "http://subsonic.org/restapi"
|
final["xmlns"] = "http://subsonic.org/restapi"
|
||||||
final["openSubsonic"] = "true"
|
|
||||||
tree = dict_to_xml_tree("subsonic-response", final)
|
tree = dict_to_xml_tree("subsonic-response", final)
|
||||||
return b'<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(
|
return b'<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(
|
||||||
tree, encoding="utf-8"
|
tree, encoding="utf-8"
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "funkwhale-api"
|
name = "funkwhale-api"
|
||||||
version = "2.0.0-alpha.2"
|
version = "2.0.0-alpha.1"
|
||||||
description = "Funkwhale API"
|
description = "Funkwhale API"
|
||||||
|
|
||||||
authors = ["Funkwhale Collective"]
|
authors = ["Funkwhale Collective"]
|
||||||
|
@ -135,8 +135,7 @@ build-backend = "poetry.core.masonry.api"
|
||||||
|
|
||||||
[tool.pylint.master]
|
[tool.pylint.master]
|
||||||
load-plugins = ["pylint_django"]
|
load-plugins = ["pylint_django"]
|
||||||
django-settings-module = "config.settings.local"
|
django-settings-module = "config.settings.testing"
|
||||||
init-hook = 'import os; os.environ.setdefault("FUNKWHALE_URL", "https://test.federation")'
|
|
||||||
|
|
||||||
[tool.pylint.messages_control]
|
[tool.pylint.messages_control]
|
||||||
disable = [
|
disable = [
|
||||||
|
|
|
@ -233,19 +233,15 @@ def test_refresh_nodeinfo_known_nodes(settings, factories, mocker, now):
|
||||||
settings.NODEINFO_REFRESH_DELAY = 666
|
settings.NODEINFO_REFRESH_DELAY = 666
|
||||||
|
|
||||||
refreshed = [
|
refreshed = [
|
||||||
factories["federation.Domain"](
|
factories["federation.Domain"](nodeinfo_fetch_date=None),
|
||||||
nodeinfo_fetch_date=None,
|
|
||||||
nodeinfo={"software": {"name": "Funkwhale"}},
|
|
||||||
),
|
|
||||||
factories["federation.Domain"](
|
factories["federation.Domain"](
|
||||||
nodeinfo_fetch_date=now
|
nodeinfo_fetch_date=now
|
||||||
- datetime.timedelta(seconds=settings.NODEINFO_REFRESH_DELAY + 1),
|
- datetime.timedelta(seconds=settings.NODEINFO_REFRESH_DELAY + 1)
|
||||||
nodeinfo={"software": {"name": "Funkwhale"}},
|
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
factories["federation.Domain"](
|
factories["federation.Domain"](
|
||||||
nodeinfo_fetch_date=now
|
nodeinfo_fetch_date=now
|
||||||
- datetime.timedelta(seconds=settings.NODEINFO_REFRESH_DELAY - 1),
|
- datetime.timedelta(seconds=settings.NODEINFO_REFRESH_DELAY - 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
update_domain_nodeinfo = mocker.patch.object(tasks.update_domain_nodeinfo, "delay")
|
update_domain_nodeinfo = mocker.patch.object(tasks.update_domain_nodeinfo, "delay")
|
||||||
|
|
|
@ -1528,7 +1528,6 @@ def test_fs_import_post(
|
||||||
prune=True,
|
prune=True,
|
||||||
outbox=False,
|
outbox=False,
|
||||||
broadcast=False,
|
broadcast=False,
|
||||||
replace=False,
|
|
||||||
batch_size=1000,
|
batch_size=1000,
|
||||||
verbosity=1,
|
verbosity=1,
|
||||||
)
|
)
|
||||||
|
|
|
@ -18,7 +18,7 @@ from funkwhale_api.subsonic import renderers
|
||||||
"type": "funkwhale",
|
"type": "funkwhale",
|
||||||
"funkwhaleVersion": funkwhale_api.__version__,
|
"funkwhaleVersion": funkwhale_api.__version__,
|
||||||
"serverVersion": funkwhale_api.__version__,
|
"serverVersion": funkwhale_api.__version__,
|
||||||
"openSubsonic": True,
|
"openSubsonic": "true",
|
||||||
"hello": "world",
|
"hello": "world",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -33,7 +33,7 @@ from funkwhale_api.subsonic import renderers
|
||||||
"type": "funkwhale",
|
"type": "funkwhale",
|
||||||
"funkwhaleVersion": funkwhale_api.__version__,
|
"funkwhaleVersion": funkwhale_api.__version__,
|
||||||
"serverVersion": funkwhale_api.__version__,
|
"serverVersion": funkwhale_api.__version__,
|
||||||
"openSubsonic": True,
|
"openSubsonic": "true",
|
||||||
"hello": "world",
|
"hello": "world",
|
||||||
"error": {"code": 10, "message": "something went wrong"},
|
"error": {"code": 10, "message": "something went wrong"},
|
||||||
},
|
},
|
||||||
|
@ -46,7 +46,7 @@ from funkwhale_api.subsonic import renderers
|
||||||
"type": "funkwhale",
|
"type": "funkwhale",
|
||||||
"funkwhaleVersion": funkwhale_api.__version__,
|
"funkwhaleVersion": funkwhale_api.__version__,
|
||||||
"serverVersion": funkwhale_api.__version__,
|
"serverVersion": funkwhale_api.__version__,
|
||||||
"openSubsonic": True,
|
"openSubsonic": "true",
|
||||||
"hello": "world",
|
"hello": "world",
|
||||||
"error": {"code": 0, "message": "something went wrong"},
|
"error": {"code": 0, "message": "something went wrong"},
|
||||||
},
|
},
|
||||||
|
@ -66,7 +66,7 @@ def test_json_renderer():
|
||||||
"type": "funkwhale",
|
"type": "funkwhale",
|
||||||
"funkwhaleVersion": funkwhale_api.__version__,
|
"funkwhaleVersion": funkwhale_api.__version__,
|
||||||
"serverVersion": funkwhale_api.__version__,
|
"serverVersion": funkwhale_api.__version__,
|
||||||
"openSubsonic": True,
|
"openSubsonic": "true",
|
||||||
"hello": "world",
|
"hello": "world",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1 +0,0 @@
|
||||||
Updated the django-storage specification to the latest version (#2459)
|
|
|
@ -1 +0,0 @@
|
||||||
Small sized attachments now fallback to medium sized if small doesn't exist (#2460)
|
|
|
@ -1 +0,0 @@
|
||||||
Allow importing single files using fs-import API endpoint
|
|
|
@ -74,11 +74,6 @@ server {
|
||||||
proxy_pass http://funkwhale-api;
|
proxy_pass http://funkwhale-api;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /node_modules/.vite/ {
|
|
||||||
include /etc/nginx/funkwhale_proxy.conf;
|
|
||||||
proxy_pass http://funkwhale-front;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~ ^/@(vite-plugin-pwa|vite|id)/ {
|
location ~ ^/@(vite-plugin-pwa|vite|id)/ {
|
||||||
include /etc/nginx/funkwhale_proxy.conf;
|
include /etc/nginx/funkwhale_proxy.conf;
|
||||||
proxy_pass http://funkwhale-front;
|
proxy_pass http://funkwhale-front;
|
||||||
|
|
|
@ -58,8 +58,6 @@ Once we're ready to release a new version of the software, we can use the follow
|
||||||
7. Update the next release version
|
7. Update the next release version
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker compose build api
|
|
||||||
docker compose run --rm api funkwhale-manage spectacular > ./api/funkwhale_api/common/schema.yml
|
|
||||||
cd api
|
cd api
|
||||||
poetry version "$NEXT_RELEASE"
|
poetry version "$NEXT_RELEASE"
|
||||||
cd ..
|
cd ..
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { useStore } from '~/store'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import usePage from '~/composables/navigation/usePage'
|
||||||
import useErrorHandler from '~/composables/useErrorHandler'
|
import useErrorHandler from '~/composables/useErrorHandler'
|
||||||
|
|
||||||
import AlbumCard from '~/components/album/Card.vue'
|
import AlbumCard from '~/components/album/Card.vue'
|
||||||
|
@ -33,7 +34,7 @@ const store = useStore()
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const albums = reactive([] as Album[])
|
const albums = reactive([] as Album[])
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
const page = ref(1)
|
const page = usePage()
|
||||||
const nextPage = ref()
|
const nextPage = ref()
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { useStore } from '~/store'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import useErrorHandler from '~/composables/useErrorHandler'
|
import useErrorHandler from '~/composables/useErrorHandler'
|
||||||
|
import usePage from '~/composables/navigation/usePage'
|
||||||
|
|
||||||
import ArtistCard from '~/components/artist/Card.vue'
|
import ArtistCard from '~/components/artist/Card.vue'
|
||||||
import Section from '~/components/ui/Section.vue'
|
import Section from '~/components/ui/Section.vue'
|
||||||
|
@ -33,7 +34,7 @@ const store = useStore()
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const artists = reactive([] as Artist[])
|
const artists = reactive([] as Artist[])
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
const page = ref(1)
|
const page = usePage()
|
||||||
const nextPage = ref()
|
const nextPage = ref()
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
|
@ -48,7 +48,6 @@ const getRoute = (ac: ArtistCredit) => {
|
||||||
v-if="ac.artist.cover && ac.artist.cover.urls.original"
|
v-if="ac.artist.cover && ac.artist.cover.urls.original"
|
||||||
v-lazy="store.getters['instance/absoluteUrl'](ac.artist.cover.urls.small_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](ac.artist.cover.urls.small_square_crop)"
|
||||||
:alt="ac.artist.name"
|
:alt="ac.artist.name"
|
||||||
@error="(e) => { e.target && ac.artist.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](ac.artist.cover.urls.medium_square_crop) : null }"
|
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
v-else
|
v-else
|
||||||
|
|
|
@ -28,7 +28,6 @@ const route = computed(() => props.artist.channel
|
||||||
v-lazy="store.getters['instance/absoluteUrl'](artist.cover.urls.small_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](artist.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
:class="[{circular: artist.content_category != 'podcast'}]"
|
:class="[{circular: artist.content_category != 'podcast'}]"
|
||||||
@error="(e) => { e.target && artist.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](artist.cover.urls.medium_square_crop) : null }"
|
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
v-else
|
v-else
|
||||||
|
|
|
@ -7,6 +7,8 @@ import { clone } from 'lodash-es'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
|
import usePage from '~/composables/navigation/usePage'
|
||||||
|
|
||||||
import ChannelCard from '~/components/audio/ChannelCard.vue'
|
import ChannelCard from '~/components/audio/ChannelCard.vue'
|
||||||
import Loader from '~/components/ui/Loader.vue'
|
import Loader from '~/components/ui/Loader.vue'
|
||||||
import Section from '~/components/ui/Section.vue'
|
import Section from '~/components/ui/Section.vue'
|
||||||
|
@ -31,7 +33,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
const result = ref<PaginatedChannelList>()
|
const result = ref<PaginatedChannelList>()
|
||||||
const errors = ref([] as string[])
|
const errors = ref([] as string[])
|
||||||
const nextPage = ref()
|
const nextPage = ref()
|
||||||
const page = ref(1)
|
const page = usePage()
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
|
@ -250,7 +250,7 @@ const playlistLibraryFollowInfo = computed(() => {
|
||||||
<hr v-if="filterableArtist || Object.keys(getReportableObjects({ track, album, artist, playlist, account, channel })).length > 0">
|
<hr v-if="filterableArtist || Object.keys(getReportableObjects({ track, album, artist, playlist, account, channel })).length > 0">
|
||||||
|
|
||||||
<PopoverItem
|
<PopoverItem
|
||||||
v-if="filterableArtist && !props.playlist"
|
v-if="filterableArtist"
|
||||||
:disabled="!filterableArtist"
|
:disabled="!filterableArtist"
|
||||||
:title="labels.hideArtist"
|
:title="labels.hideArtist"
|
||||||
icon="bi-eye-slash"
|
icon="bi-eye-slash"
|
||||||
|
|
|
@ -77,14 +77,12 @@ const actionsButtonLabel = computed(() => t('components.audio.podcast.MobileRow.
|
||||||
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
@error="(e) => { e.target && track.album.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop) : null }"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="track.cover"
|
v-else-if="track.cover"
|
||||||
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
@error="(e) => { e.target && track.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop) : null }"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="!!track.artist_credit.length && track.artist_credit[0].artist.cover"
|
v-else-if="!!track.artist_credit.length && track.artist_credit[0].artist.cover"
|
||||||
|
|
|
@ -90,14 +90,12 @@ await fetchData()
|
||||||
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
@error="(e) => { e.target && track.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop) : null }"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-if="track.album?.cover?.urls.original"
|
v-if="track.album?.cover?.urls.original"
|
||||||
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
@error="(e) => { e.target && track.album.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop) : null }"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="track.artist_credit.length && track.artist_credit[0].artist.cover"
|
v-else-if="track.artist_credit.length && track.artist_credit[0].artist.cover"
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
import type { Track, Artist, Album, Playlist, Library, Channel, Actor } from '~/types'
|
||||||
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
import type { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
|
||||||
|
|
||||||
import { computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { usePlayer } from '~/composables/audio/player'
|
import { usePlayer } from '~/composables/audio/player'
|
||||||
|
@ -12,10 +12,9 @@ import { useStore } from '~/store'
|
||||||
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
import usePlayOptions from '~/composables/audio/usePlayOptions'
|
||||||
|
|
||||||
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
|
||||||
|
import TrackModal from '~/components/audio/track/Modal.vue'
|
||||||
import { generateTrackCreditString } from '~/utils/utils'
|
import { generateTrackCreditString } from '~/utils/utils'
|
||||||
|
|
||||||
import Button from '~/components/ui/Button.vue'
|
|
||||||
|
|
||||||
interface Props extends PlayOptionsProps {
|
interface Props extends PlayOptionsProps {
|
||||||
track: Track
|
track: Track
|
||||||
index: number
|
index: number
|
||||||
|
@ -26,6 +25,7 @@ interface Props extends PlayOptionsProps {
|
||||||
|
|
||||||
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
|
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
|
||||||
isPlayable?: boolean
|
isPlayable?: boolean
|
||||||
|
tracks?: Track[]
|
||||||
artist?: Artist | null
|
artist?: Artist | null
|
||||||
album?: Album | null
|
album?: Album | null
|
||||||
playlist?: Playlist | null
|
playlist?: Playlist | null
|
||||||
|
@ -39,6 +39,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
isArtist: false,
|
isArtist: false,
|
||||||
isAlbum: false,
|
isAlbum: false,
|
||||||
|
|
||||||
|
tracks: () => [],
|
||||||
artist: null,
|
artist: null,
|
||||||
album: null,
|
album: null,
|
||||||
playlist: null,
|
playlist: null,
|
||||||
|
@ -47,9 +48,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
account: null
|
account: null
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const showTrackModal = ref(false)
|
||||||
(e: 'open-modal', track: Track, index: number): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { currentTrack } = useQueue()
|
const { currentTrack } = useQueue()
|
||||||
const { isPlaying } = usePlayer()
|
const { isPlaying } = usePlayer()
|
||||||
|
@ -78,14 +77,12 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
|
||||||
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
@error="(e) => { e.target && track.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop) : null }"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="track.album?.cover?.urls.original"
|
v-else-if="track.album?.cover?.urls.original"
|
||||||
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
class="ui artist-track mini image"
|
class="ui artist-track mini image"
|
||||||
@error="(e) => { e.target && track.album.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop) : null }"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else
|
v-else
|
||||||
|
@ -118,10 +115,8 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<track-favorite-icon
|
<div
|
||||||
v-if="store.state.auth.authenticated"
|
v-if="store.state.auth.authenticated"
|
||||||
ghost
|
|
||||||
tiny
|
|
||||||
:class="[
|
:class="[
|
||||||
'meta',
|
'meta',
|
||||||
'right',
|
'right',
|
||||||
|
@ -130,14 +125,17 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
|
||||||
'mobile',
|
'mobile',
|
||||||
{ 'with-art': showArt },
|
{ 'with-art': showArt },
|
||||||
]"
|
]"
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
<track-favorite-icon
|
||||||
|
class="tiny"
|
||||||
|
:border="false"
|
||||||
:track="track"
|
:track="track"
|
||||||
/>
|
/>
|
||||||
<!-- TODO: Replace with <PlayButton :dropdown-only="true"> after its display is fixed for mobile -->
|
</div>
|
||||||
<Button
|
<div
|
||||||
|
role="button"
|
||||||
:aria-label="actionsButtonLabel"
|
:aria-label="actionsButtonLabel"
|
||||||
icon="bi-three-dots-vertical"
|
|
||||||
ghost
|
|
||||||
tiny
|
|
||||||
:class="[
|
:class="[
|
||||||
'modal-button',
|
'modal-button',
|
||||||
'right',
|
'right',
|
||||||
|
@ -146,7 +144,16 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
|
||||||
'mobile',
|
'mobile',
|
||||||
{ 'with-art': showArt },
|
{ 'with-art': showArt },
|
||||||
]"
|
]"
|
||||||
@click.prevent.exact="emit('open-modal', track, index)"
|
@click.prevent.exact="showTrackModal = !showTrackModal"
|
||||||
|
>
|
||||||
|
<i class="ellipsis large vertical icon" />
|
||||||
|
</div>
|
||||||
|
<track-modal
|
||||||
|
v-model:show="showTrackModal"
|
||||||
|
:track="track"
|
||||||
|
:index="index"
|
||||||
|
:is-artist="isArtist"
|
||||||
|
:is-album="isAlbum"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -12,8 +12,6 @@ import { useVModel } from '@vueuse/core'
|
||||||
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
|
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
|
||||||
|
|
||||||
import Modal from '~/components/ui/Modal.vue'
|
import Modal from '~/components/ui/Modal.vue'
|
||||||
import Button from '~/components/ui/Button.vue'
|
|
||||||
import Layout from '~/components/ui/Layout.vue'
|
|
||||||
|
|
||||||
interface Events {
|
interface Events {
|
||||||
(e: 'update:show', value: boolean): void
|
(e: 'update:show', value: boolean): void
|
||||||
|
@ -94,11 +92,9 @@ const labels = computed(() => ({
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- TODO: Delete this file after this modal is replaced with playbutton dropdown-only popover -->
|
|
||||||
<Modal
|
<Modal
|
||||||
v-model="show"
|
v-model="show"
|
||||||
:title="track.title"
|
:title="track.title"
|
||||||
class="small"
|
|
||||||
>
|
>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="ui large centered rounded image">
|
<div class="ui large centered rounded image">
|
||||||
|
@ -131,105 +127,126 @@ const labels = computed(() => ({
|
||||||
{{ generateTrackCreditString(track) }}
|
{{ generateTrackCreditString(track) }}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="ui hidden divider" />
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<Layout
|
<div class="ui one column unstackable grid">
|
||||||
stack
|
<div
|
||||||
no-gap
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
v-if="store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'"
|
v-if="store.state.auth.authenticated && track.artist_credit?.[0].artist.content_category !== 'podcast'"
|
||||||
full
|
class="row"
|
||||||
ghost
|
>
|
||||||
|
<div
|
||||||
|
tabindex="0"
|
||||||
|
class="column"
|
||||||
|
role="button"
|
||||||
:aria-label="favoriteButton"
|
:aria-label="favoriteButton"
|
||||||
:icon="isFavorite ? 'bi-heart-fill' : 'bi-heart'"
|
|
||||||
:is-active="isFavorite"
|
|
||||||
@click.stop="store.dispatch('favorites/toggle', track.id)"
|
@click.stop="store.dispatch('favorites/toggle', track.id)"
|
||||||
>
|
>
|
||||||
{{ favoriteButton }}
|
<i :class="[ 'heart', 'favorite-icon', { favorited: isFavorite, pink: isFavorite }, 'icon', 'track-modal', 'list-icon' ]" />
|
||||||
</Button>
|
<span class="track-modal list-item">{{ favoriteButton }}</span>
|
||||||
<Button
|
</div>
|
||||||
full
|
</div>
|
||||||
ghost
|
<div class="row">
|
||||||
|
<div
|
||||||
|
class="column"
|
||||||
|
role="button"
|
||||||
:aria-label="labels.addToQueue"
|
:aria-label="labels.addToQueue"
|
||||||
icon="bi-plus"
|
|
||||||
@click.stop.prevent="enqueue(); show = false"
|
@click.stop.prevent="enqueue(); show = false"
|
||||||
>
|
>
|
||||||
{{ labels.addToQueue }}
|
<i class="plus icon track-modal list-icon" />
|
||||||
</Button>
|
<span class="track-modal list-item">{{ labels.addToQueue }}</span>
|
||||||
<Button
|
</div>
|
||||||
full
|
</div>
|
||||||
ghost
|
<div class="row">
|
||||||
|
<div
|
||||||
|
class="column"
|
||||||
|
role="button"
|
||||||
:aria-label="labels.playNext"
|
:aria-label="labels.playNext"
|
||||||
icon="bi-skip-end"
|
@click.stop.prevent="enqueueNext(true);show = false"
|
||||||
@click.stop.prevent="enqueueNext(true); show = false"
|
|
||||||
>
|
>
|
||||||
{{ labels.playNext }}
|
<i class="step forward icon track-modal list-icon" />
|
||||||
</Button>
|
<span class="track-modal list-item">{{ labels.playNext }}</span>
|
||||||
<Button
|
</div>
|
||||||
full
|
</div>
|
||||||
ghost
|
<div class="row">
|
||||||
|
<div
|
||||||
|
class="column"
|
||||||
|
role="button"
|
||||||
:aria-label="labels.startRadio"
|
:aria-label="labels.startRadio"
|
||||||
icon="bi-rss"
|
@click.stop.prevent="() => { store.dispatch('radios/start', { type: 'similar', objectId: track.id }); show = false }"
|
||||||
@click.stop.prevent="store.dispatch('radios/start', { type: 'similar', objectId: track.id }); show = false"
|
|
||||||
>
|
>
|
||||||
{{ labels.startRadio }}
|
<i class="rss icon track-modal list-icon" />
|
||||||
</Button>
|
<span class="track-modal list-item">{{ labels.startRadio }}</span>
|
||||||
<Button
|
</div>
|
||||||
full
|
</div>
|
||||||
ghost
|
<div class="row">
|
||||||
|
<div
|
||||||
|
class="column"
|
||||||
|
role="button"
|
||||||
:aria-label="labels.addToPlaylist"
|
:aria-label="labels.addToPlaylist"
|
||||||
icon="bi-list"
|
|
||||||
@click.stop="store.commit('playlists/chooseTrack', track)"
|
@click.stop="store.commit('playlists/chooseTrack', track)"
|
||||||
>
|
>
|
||||||
|
<i class="list icon track-modal list-icon" />
|
||||||
|
<span class="track-modal list-item">
|
||||||
{{ labels.addToPlaylist }}
|
{{ labels.addToPlaylist }}
|
||||||
</Button>
|
</span>
|
||||||
<hr>
|
</div>
|
||||||
<Button
|
</div>
|
||||||
|
<div class="ui divider" />
|
||||||
|
<div
|
||||||
v-if="!isAlbum && track.album"
|
v-if="!isAlbum && track.album"
|
||||||
full
|
class="row"
|
||||||
ghost
|
>
|
||||||
|
<div
|
||||||
|
class="column"
|
||||||
|
role="button"
|
||||||
:aria-label="albumDetailsButton"
|
:aria-label="albumDetailsButton"
|
||||||
icon="bi-disc"
|
|
||||||
@click.prevent.exact="router.push({ name: 'library.albums.detail', params: { id: track.album?.id } })"
|
@click.prevent.exact="router.push({ name: 'library.albums.detail', params: { id: track.album?.id } })"
|
||||||
>
|
>
|
||||||
{{ albumDetailsButton }}
|
<i class="compact disc icon track-modal list-icon" />
|
||||||
</Button>
|
<span class="track-modal list-item">{{ albumDetailsButton }}</span>
|
||||||
<template
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
v-if="!isArtist"
|
v-if="!isArtist"
|
||||||
|
class="row"
|
||||||
>
|
>
|
||||||
<Button
|
<div
|
||||||
v-for="ac in track.artist_credit"
|
v-for="ac in track.artist_credit"
|
||||||
:key="ac.artist.id"
|
:key="ac.artist.id"
|
||||||
full
|
class="column"
|
||||||
ghost
|
role="button"
|
||||||
:aria-label="artistDetailsButton"
|
:aria-label="artistDetailsButton"
|
||||||
icon="bi-person-fill"
|
|
||||||
@click.prevent.exact="router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })"
|
@click.prevent.exact="router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })"
|
||||||
>
|
>
|
||||||
{{ ac.credit }}<span v-if="ac.joinphrase">{{ ac.joinphrase }}</span>
|
<i class="user icon track-modal list-icon" />
|
||||||
</Button>
|
<span class="track-modal list-item">{{ ac.credit }}</span>
|
||||||
</template>
|
<span v-if="ac.joinphrase">{{ ac.joinphrase }}</span>
|
||||||
<Button
|
</div>
|
||||||
full
|
</div>
|
||||||
ghost
|
<div class="row">
|
||||||
|
<div
|
||||||
|
class="column"
|
||||||
|
role="button"
|
||||||
:aria-label="trackDetailsButton"
|
:aria-label="trackDetailsButton"
|
||||||
icon="bi-info-circle"
|
|
||||||
@click.prevent.exact="router.push({ name: 'library.tracks.detail', params: { id: track.id } })"
|
@click.prevent.exact="router.push({ name: 'library.tracks.detail', params: { id: track.id } })"
|
||||||
>
|
>
|
||||||
{{ trackDetailsButton }}
|
<i class="info icon track-modal list-icon" />
|
||||||
</Button>
|
<span class="track-modal list-item">{{ trackDetailsButton }}</span>
|
||||||
<hr>
|
</div>
|
||||||
<Button
|
</div>
|
||||||
|
<div class="ui divider" />
|
||||||
|
<div
|
||||||
v-for="obj in getReportableObjects({ track, album: track.album, artistCredit: track.artist_credit })"
|
v-for="obj in getReportableObjects({ track, album: track.album, artistCredit: track.artist_credit })"
|
||||||
:key="obj.target.type + obj.target.id"
|
:key="obj.target.type + obj.target.id"
|
||||||
full
|
class="row"
|
||||||
ghost
|
|
||||||
icon="bi-share"
|
|
||||||
@click.stop.prevent="report(obj)"
|
@click.stop.prevent="report(obj)"
|
||||||
>
|
>
|
||||||
{{ obj.label }}
|
<div class="column">
|
||||||
</Button>
|
<i class="share icon track-modal list-icon" />
|
||||||
</Layout>
|
<span class="track-modal list-item">{{ obj.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -127,14 +127,12 @@ const hover = ref(false)
|
||||||
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
|
||||||
:alt="track.title"
|
:alt="track.title"
|
||||||
class="track_image"
|
class="track_image"
|
||||||
@error="(e) => { e.target && track.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](track.cover.urls.medium_square_crop) : null }"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="showArt && track.album?.cover?.urls.original"
|
v-else-if="showArt && track.album?.cover?.urls.original"
|
||||||
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
class="track_image"
|
class="track_image"
|
||||||
@error="(e) => { e.target && track.album.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](track.album.cover.urls.medium_square_crop) : null }"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="showArt"
|
v-else-if="showArt"
|
||||||
|
|
|
@ -8,8 +8,6 @@ import { ref, computed } from 'vue'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import TrackMobileRow from '~/components/audio/track/MobileRow.vue'
|
import TrackMobileRow from '~/components/audio/track/MobileRow.vue'
|
||||||
import TrackModal from '~/components/audio/track/Modal.vue'
|
|
||||||
|
|
||||||
import Pagination from '~/components/ui/Pagination.vue'
|
import Pagination from '~/components/ui/Pagination.vue'
|
||||||
import TrackRow from '~/components/audio/track/Row.vue'
|
import TrackRow from '~/components/audio/track/Row.vue'
|
||||||
import Input from '~/components/ui/Input.vue'
|
import Input from '~/components/ui/Input.vue'
|
||||||
|
@ -45,7 +43,7 @@ interface Props {
|
||||||
paginateResults?: boolean
|
paginateResults?: boolean
|
||||||
total?: number
|
total?: number
|
||||||
page?: number
|
page?: number
|
||||||
paginateBy?: number
|
paginateBy?: number,
|
||||||
|
|
||||||
unique?: boolean
|
unique?: boolean
|
||||||
}
|
}
|
||||||
|
@ -145,16 +143,6 @@ const updatePage = (page: number) => {
|
||||||
emit('page-changed', page)
|
emit('page-changed', page)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const showTrackModal = ref(false)
|
|
||||||
const modalTrack = ref<Track | null>(null)
|
|
||||||
const modalIndex = ref<number | null>(null)
|
|
||||||
|
|
||||||
function openTrackModal(track: Track, index: number) {
|
|
||||||
showTrackModal.value = true
|
|
||||||
modalTrack.value = track
|
|
||||||
modalIndex.value = index
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -263,22 +251,13 @@ function openTrackModal(track: Track, index: number) {
|
||||||
:key="track.id"
|
:key="track.id"
|
||||||
:track="track"
|
:track="track"
|
||||||
:index="index"
|
:index="index"
|
||||||
|
:tracks="allTracks"
|
||||||
:show-position="showPosition"
|
:show-position="showPosition"
|
||||||
:show-art="showArt"
|
:show-art="showArt"
|
||||||
:show-duration="showDuration"
|
:show-duration="showDuration"
|
||||||
:is-artist="isArtist"
|
:is-artist="isArtist"
|
||||||
:is-album="isAlbum"
|
:is-album="isAlbum"
|
||||||
:is-podcast="isPodcast"
|
:is-podcast="isPodcast"
|
||||||
@open-modal="openTrackModal"
|
|
||||||
/>
|
|
||||||
<!-- TODO: Replace with <PlayButton :dropdown-only="true"> after its display is fixed for mobile -->
|
|
||||||
<track-modal
|
|
||||||
v-if="modalTrack"
|
|
||||||
v-model:show="showTrackModal"
|
|
||||||
:track="modalTrack"
|
|
||||||
:index="modalIndex ?? 0"
|
|
||||||
:is-artist="isArtist"
|
|
||||||
:is-album="isAlbum"
|
|
||||||
/>
|
/>
|
||||||
<Pagination
|
<Pagination
|
||||||
v-if="paginateResults"
|
v-if="paginateResults"
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { useI18n } from 'vue-i18n'
|
||||||
import { getArtistCoverUrl } from '~/utils/utils'
|
import { getArtistCoverUrl } from '~/utils/utils'
|
||||||
|
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
import usePage from '~/composables/navigation/usePage'
|
||||||
import useWebSocketHandler from '~/composables/useWebSocketHandler'
|
import useWebSocketHandler from '~/composables/useWebSocketHandler'
|
||||||
|
|
||||||
import PlayButton from '~/components/audio/PlayButton.vue'
|
import PlayButton from '~/components/audio/PlayButton.vue'
|
||||||
|
@ -49,7 +50,7 @@ const { t } = useI18n()
|
||||||
|
|
||||||
const objects = reactive([] as Listening[])
|
const objects = reactive([] as Listening[])
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
const page = ref(1)
|
const page = usePage()
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
|
@ -140,13 +141,11 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
|
||||||
v-if="object.track.album && object.track.album.cover"
|
v-if="object.track.album && object.track.album.cover"
|
||||||
v-lazy="store.getters['instance/absoluteUrl'](object.track.album.cover.urls.small_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](object.track.album.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
@error="(e) => { e.target && object.track.album.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](object.track.album.cover.urls.medium_square_crop) : null }"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="object.track.cover"
|
v-else-if="object.track.cover"
|
||||||
v-lazy="store.getters['instance/absoluteUrl'](object.track.cover.urls.small_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](object.track.cover.urls.small_square_crop)"
|
||||||
alt=""
|
alt=""
|
||||||
@error="(e) => { e.target && object.track.cover ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](object.track.cover.urls.medium_square_crop) : null }"
|
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
v-else-if="object.track.artist_credit && object.track.artist_credit.length > 1"
|
v-else-if="object.track.artist_credit && object.track.artist_credit.length > 1"
|
||||||
|
|
|
@ -40,7 +40,6 @@ const defaultAvatarStyle = computed(() => ({ backgroundColor: `#${userColor.valu
|
||||||
v-lazy="store.getters['instance/absoluteUrl'](user.avatar.urls.small_square_crop)"
|
v-lazy="store.getters['instance/absoluteUrl'](user.avatar.urls.small_square_crop)"
|
||||||
class="ui avatar tiny circular image"
|
class="ui avatar tiny circular image"
|
||||||
alt=""
|
alt=""
|
||||||
@error="(e) => { e.target && user.avatar ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](user.avatar.urls.medium_square_crop) : null }"
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-else
|
v-else
|
||||||
|
|
|
@ -176,6 +176,7 @@ const isOpen = useModal('artist-description').isOpen
|
||||||
class="description"
|
class="description"
|
||||||
:content="{ ...object.description, text: object.description.text ?? undefined }"
|
:content="{ ...object.description, text: object.description.text ?? undefined }"
|
||||||
:truncate-length="100"
|
:truncate-length="100"
|
||||||
|
:more-link="false"
|
||||||
/>
|
/>
|
||||||
<Spacer grow />
|
<Spacer grow />
|
||||||
<Link
|
<Link
|
||||||
|
|
|
@ -8,6 +8,7 @@ import { useI18n } from 'vue-i18n'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
import useErrorHandler from '~/composables/useErrorHandler'
|
import useErrorHandler from '~/composables/useErrorHandler'
|
||||||
|
import usePage from '~/composables/navigation/usePage'
|
||||||
|
|
||||||
import PlaylistCard from '~/components/playlists/Card.vue'
|
import PlaylistCard from '~/components/playlists/Card.vue'
|
||||||
import Button from '~/components/ui/Button.vue'
|
import Button from '~/components/ui/Button.vue'
|
||||||
|
@ -30,7 +31,7 @@ const props = defineProps<Props>()
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
|
|
||||||
const objects = reactive([] as Playlist[])
|
const objects = reactive([] as Playlist[])
|
||||||
const page = ref(1)
|
const page = usePage()
|
||||||
const nextPage = ref('')
|
const nextPage = ref('')
|
||||||
const count = ref(0)
|
const count = ref(0)
|
||||||
|
|
||||||
|
|
|
@ -27,14 +27,6 @@ const soundCache = new LRUCache<number, Sound>({
|
||||||
dispose: (sound) => sound.dispose()
|
dispose: (sound) => sound.dispose()
|
||||||
})
|
})
|
||||||
|
|
||||||
// used to make soundCache reactive
|
|
||||||
const soundCacheVersion = ref(0)
|
|
||||||
function setSoundCache(trackId: number, sound: Sound) {
|
|
||||||
soundCache.set(trackId, sound)
|
|
||||||
soundCacheVersion.value++ // bump to trigger reactivity
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const currentTrack = ref<QueueTrack>()
|
const currentTrack = ref<QueueTrack>()
|
||||||
|
|
||||||
export const fetchTrackSources = async (id: number): Promise<QueueTrackSource[]> => {
|
export const fetchTrackSources = async (id: number): Promise<QueueTrackSource[]> => {
|
||||||
|
@ -147,7 +139,7 @@ export const useTracks = createGlobalState(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add track to the sound cache and remove from the promise cache
|
// Add track to the sound cache and remove from the promise cache
|
||||||
setSoundCache(track.id, sound)
|
soundCache.set(track.id, sound)
|
||||||
soundPromises.delete(track.id)
|
soundPromises.delete(track.id)
|
||||||
|
|
||||||
return sound
|
return sound
|
||||||
|
@ -232,12 +224,7 @@ export const useTracks = createGlobalState(() => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const currentSound = computed(() => {
|
const currentSound = computed(() => soundCache.get(currentTrack.value?.id ?? -1))
|
||||||
soundCacheVersion.value //trigger reactivity
|
|
||||||
const trackId = currentTrack.value?.id ?? -1
|
|
||||||
const sound = soundCache.get(trackId)
|
|
||||||
return sound
|
|
||||||
})
|
|
||||||
|
|
||||||
const clearCache = () => {
|
const clearCache = () => {
|
||||||
return soundCache.clear()
|
return soundCache.clear()
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import type { Notification, Track } from '~/types'
|
import type { Notification } from '~/types'
|
||||||
|
|
||||||
import store from '~/store'
|
import store from '~/store'
|
||||||
import { tryOnScopeDispose } from '@vueuse/core'
|
import { tryOnScopeDispose } from '@vueuse/core'
|
||||||
|
@ -51,7 +51,6 @@ function useWebSocketHandler (eventName: 'mutation.updated', handler: (event: Pe
|
||||||
function useWebSocketHandler (eventName: 'import.status_updated', handler: (event: ImportStatusWS) => void): stopFn
|
function useWebSocketHandler (eventName: 'import.status_updated', handler: (event: ImportStatusWS) => void): stopFn
|
||||||
function useWebSocketHandler (eventName: 'user_request.created', handler: (event: PendingReviewRequests) => void): stopFn
|
function useWebSocketHandler (eventName: 'user_request.created', handler: (event: PendingReviewRequests) => void): stopFn
|
||||||
function useWebSocketHandler (eventName: 'Listen', handler: (event: unknown) => void): stopFn
|
function useWebSocketHandler (eventName: 'Listen', handler: (event: unknown) => void): stopFn
|
||||||
function useWebSocketHandler (eventName: 'playlist.track_updated', handler: (event: {track: Track}) => void): stopFn
|
|
||||||
|
|
||||||
function useWebSocketHandler (eventName: string, handler: (event: any) => void): stopFn {
|
function useWebSocketHandler (eventName: string, handler: (event: any) => void): stopFn {
|
||||||
const id = `${+new Date() + Math.random()}`
|
const id = `${+new Date() + Math.random()}`
|
||||||
|
|
|
@ -4597,8 +4597,7 @@
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"embed": "Embed",
|
"embed": "Embed",
|
||||||
"playAll": "Play all",
|
"playAll": "Play all",
|
||||||
"stopEdit": "Stop Editing",
|
"stopEdit": "Stop Editing"
|
||||||
"loadMoreTracks": "Load more tracks"
|
|
||||||
},
|
},
|
||||||
"empty": {
|
"empty": {
|
||||||
"noTracks": "There are no tracks in this playlist yet"
|
"noTracks": "There are no tracks in this playlist yet"
|
||||||
|
|
|
@ -4611,8 +4611,5 @@
|
||||||
"title": "Радио"
|
"title": "Радио"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"vui": {
|
|
||||||
"radio": "Радио"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -12,7 +12,7 @@ import useLogger from '~/composables/useLogger'
|
||||||
type SupportedExtension = 'flac' | 'ogg' | 'mp3' | 'opus' | 'aac' | 'm4a' | 'aiff' | 'aif'
|
type SupportedExtension = 'flac' | 'ogg' | 'mp3' | 'opus' | 'aac' | 'm4a' | 'aiff' | 'aif'
|
||||||
|
|
||||||
export type WebSocketEventName = 'inbox.item_added' | 'import.status_updated' | 'mutation.created' | 'mutation.updated'
|
export type WebSocketEventName = 'inbox.item_added' | 'import.status_updated' | 'mutation.created' | 'mutation.updated'
|
||||||
| 'report.created' | 'user_request.created' | 'Listen' | 'playlist.track_updated'
|
| 'report.created' | 'user_request.created' | 'Listen'
|
||||||
|
|
||||||
export type OrderingField = 'creation_date' | 'title' | 'album__title' | 'artist__name' | 'release_date' | 'name'
|
export type OrderingField = 'creation_date' | 'title' | 'album__title' | 'artist__name' | 'release_date' | 'name'
|
||||||
| 'applied_date' | 'followers_count' | 'uploads_count' | 'length' | 'items_count' | 'modification_date' | 'size'
|
| 'applied_date' | 'followers_count' | 'uploads_count' | 'length' | 'items_count' | 'modification_date' | 'size'
|
||||||
|
@ -91,8 +91,7 @@ const store: Module<State, RootState> = {
|
||||||
'mutation.updated': {},
|
'mutation.updated': {},
|
||||||
'report.created': {},
|
'report.created': {},
|
||||||
'user_request.created': {},
|
'user_request.created': {},
|
||||||
Listen: {},
|
Listen: {}
|
||||||
'playlist.track_updated': {}
|
|
||||||
},
|
},
|
||||||
pageTitle: null,
|
pageTitle: null,
|
||||||
modalsOpen: new Set([]),
|
modalsOpen: new Set([]),
|
||||||
|
@ -274,7 +273,6 @@ const store: Module<State, RootState> = {
|
||||||
const handlers = state.websocketEventsHandlers[event.type]
|
const handlers = state.websocketEventsHandlers[event.type]
|
||||||
logger.log('Dispatching websocket event', event, handlers)
|
logger.log('Dispatching websocket event', event, handlers)
|
||||||
if (!handlers) {
|
if (!handlers) {
|
||||||
logger.log('No websocket handlers for this event', event, handlers)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,10 +5,11 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
$bootstrap-icons-font: "bootstrap-icons" !default;
|
$bootstrap-icons-font: "bootstrap-icons" !default;
|
||||||
$bootstrap-icons-font-dir: "~/style/bootstrap-icons/font/fonts" !default;
|
$bootstrap-icons-font-dir: "./fonts" !default;
|
||||||
$bootstrap-icons-font-file: "#{$bootstrap-icons-font-dir}/#{$bootstrap-icons-font}" !default;
|
$bootstrap-icons-font-file: "#{$bootstrap-icons-font-dir}/#{$bootstrap-icons-font}" !default;
|
||||||
$bootstrap-icons-font-src: url("#{$bootstrap-icons-font-file}.woff2") format("woff2"),
|
$bootstrap-icons-font-hash: "24e3eb84d0bcaf83d77f904c78ac1f47" !default;
|
||||||
url("#{$bootstrap-icons-font-file}.woff") format("woff") !default;
|
$bootstrap-icons-font-src: url("#{$bootstrap-icons-font-file}.woff2?#{$bootstrap-icons-font-hash}") format("woff2"),
|
||||||
|
url("#{$bootstrap-icons-font-file}.woff?#{$bootstrap-icons-font-hash}") format("woff") !default;
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-display: block;
|
font-display: block;
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
@use "~/style/bootstrap-icons/font/bootstrap-icons.scss";
|
@import url("~/style/bootstrap-icons/font/bootstrap-icons.css");
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: Lato;
|
font-family: Lato;
|
||||||
|
|
|
@ -62,7 +62,6 @@ const labels = computed(() => ({
|
||||||
alt=""
|
alt=""
|
||||||
:src="store.getters['instance/absoluteUrl'](store.state.auth.profile?.avatar.urls.small_square_crop)"
|
:src="store.getters['instance/absoluteUrl'](store.state.auth.profile?.avatar.urls.small_square_crop)"
|
||||||
class="avatar"
|
class="avatar"
|
||||||
@error="(e) => { e.target && store.state.auth.profile?.avatar ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](store.state.auth.profile?.avatar.urls.medium_square_crop) : null }"
|
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-else-if="store.state.auth.authenticated"
|
v-else-if="store.state.auth.authenticated"
|
||||||
|
|
|
@ -190,6 +190,7 @@ const isOpen = useModal('artist-description').isOpen
|
||||||
class="description"
|
class="description"
|
||||||
:content="{ html: object?.summary.html || '' }"
|
:content="{ html: object?.summary.html || '' }"
|
||||||
:truncate-length="100"
|
:truncate-length="100"
|
||||||
|
:more-link="false"
|
||||||
/>
|
/>
|
||||||
<Spacer grow />
|
<Spacer grow />
|
||||||
<Link
|
<Link
|
||||||
|
|
|
@ -24,7 +24,6 @@ import Alert from '~/components/ui/Alert.vue'
|
||||||
import PlaylistDropdown from '~/components/playlists/PlaylistDropdown.vue'
|
import PlaylistDropdown from '~/components/playlists/PlaylistDropdown.vue'
|
||||||
|
|
||||||
import useErrorHandler from '~/composables/useErrorHandler'
|
import useErrorHandler from '~/composables/useErrorHandler'
|
||||||
import useWebSocketHandler from '~/composables/useWebSocketHandler'
|
|
||||||
|
|
||||||
// TODO: Is this event ever caught somewhere?
|
// TODO: Is this event ever caught somewhere?
|
||||||
// interface Events {
|
// interface Events {
|
||||||
|
@ -32,7 +31,7 @@ import useWebSocketHandler from '~/composables/useWebSocketHandler'
|
||||||
// }
|
// }
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string
|
id: number
|
||||||
defaultEdit?: boolean
|
defaultEdit?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,7 +40,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
})
|
})
|
||||||
|
|
||||||
const store = useStore()
|
const store = useStore()
|
||||||
const isLoadingMoreTracks = ref(false)
|
|
||||||
const edit = ref(props.defaultEdit)
|
const edit = ref(props.defaultEdit)
|
||||||
const playlist = ref<Playlist | null>(null)
|
const playlist = ref<Playlist | null>(null)
|
||||||
const playlistTracks = ref<PlaylistTrack[]>([])
|
const playlistTracks = ref<PlaylistTrack[]>([])
|
||||||
|
@ -52,39 +51,23 @@ const fullPlaylistTracks = ref<FullPlaylistTrack[]>([])
|
||||||
|
|
||||||
const tracks = computed(() => fullPlaylistTracks.value.map(({ track }, index) => ({ ...track as Track, position: index + 1 })))
|
const tracks = computed(() => fullPlaylistTracks.value.map(({ track }, index) => ({ ...track as Track, position: index + 1 })))
|
||||||
|
|
||||||
const updateTrack = (updatedTrack: Track) => {
|
|
||||||
fullPlaylistTracks.value = fullPlaylistTracks.value.map((item) =>
|
|
||||||
item.track.id === updatedTrack.id ? { ...item, track: updatedTrack } : item
|
|
||||||
);
|
|
||||||
};
|
|
||||||
useWebSocketHandler('playlist.track_updated', async (event) => {
|
|
||||||
updateTrack(event.track);
|
|
||||||
});
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const labels = computed(() => ({
|
const labels = computed(() => ({
|
||||||
playlist: t('views.playlists.Detail.title')
|
playlist: t('views.playlists.Detail.title')
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const nextPage = ref<string | null>(null) // Tracks the next page URL
|
|
||||||
const previousPage = ref<string | null>(null) // Tracks the previous page URL
|
|
||||||
const totalTracks = ref<number>(0) // Total number of tracks
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [playlistResponse, tracksResponse] = await Promise.all([
|
const [playlistResponse, tracksResponse] = await Promise.all([
|
||||||
axios.get(`playlists/${props.id}/`),
|
axios.get(`playlists/${props.id}/`),
|
||||||
axios.get(`playlists/${props.id}/tracks?page=1`)
|
axios.get(`playlists/${props.id}/tracks/`)
|
||||||
])
|
])
|
||||||
|
|
||||||
playlist.value = playlistResponse.data
|
playlist.value = playlistResponse.data
|
||||||
fullPlaylistTracks.value = tracksResponse.data.results
|
fullPlaylistTracks.value = tracksResponse.data.results
|
||||||
nextPage.value = tracksResponse.data.next
|
|
||||||
previousPage.value = tracksResponse.data.previous
|
|
||||||
totalTracks.value = tracksResponse.data.count
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useErrorHandler(error as Error)
|
useErrorHandler(error as Error)
|
||||||
}
|
}
|
||||||
|
@ -92,24 +75,6 @@ const fetchData = async () => {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadMoreTracks = async () => {
|
|
||||||
if (nextPage.value) {
|
|
||||||
isLoadingMoreTracks.value = true; // Set loading state for the button
|
|
||||||
try {
|
|
||||||
const response = await axios.get(nextPage.value)
|
|
||||||
|
|
||||||
// Append new tracks to the existing list
|
|
||||||
fullPlaylistTracks.value = [...fullPlaylistTracks.value, ...response.data.results]
|
|
||||||
// Update pagination metadata
|
|
||||||
nextPage.value = response.data.next
|
|
||||||
} catch (error) {
|
|
||||||
useErrorHandler(error as Error)
|
|
||||||
} finally {
|
|
||||||
isLoadingMoreTracks.value = false; // Reset loading state
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchData()
|
fetchData()
|
||||||
|
|
||||||
const images = computed(() => {
|
const images = computed(() => {
|
||||||
|
@ -148,6 +113,17 @@ const randomizedColors = computed(() => shuffleArray(bgcolors.value))
|
||||||
// return t('components.audio.ChannelCard.title', { date })
|
// return t('components.audio.ChannelCard.title', { date })
|
||||||
// })
|
// })
|
||||||
|
|
||||||
|
// TODO: Check if this function is still needed
|
||||||
|
// const deletePlaylist = async () => {
|
||||||
|
// try {
|
||||||
|
// await axios.delete(`playlists/${props.id}/`)
|
||||||
|
// store.dispatch('playlists/fetchOwn')
|
||||||
|
// return router.push({ path: '/library' })
|
||||||
|
// } catch (error) {
|
||||||
|
// useErrorHandler(error as Error)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
// TODO: Implement shuffle
|
// TODO: Implement shuffle
|
||||||
const shuffle = () => {}
|
const shuffle = () => {}
|
||||||
</script>
|
</script>
|
||||||
|
@ -202,6 +178,7 @@ const shuffle = () => {}
|
||||||
<RenderedDescription
|
<RenderedDescription
|
||||||
:content="{ html: playlist.description }"
|
:content="{ html: playlist.description }"
|
||||||
:truncate-length="100"
|
:truncate-length="100"
|
||||||
|
:show-more="true"
|
||||||
/>
|
/>
|
||||||
<Layout
|
<Layout
|
||||||
flex
|
flex
|
||||||
|
@ -212,7 +189,6 @@ const shuffle = () => {}
|
||||||
low-height
|
low-height
|
||||||
:is-playable="true"
|
:is-playable="true"
|
||||||
:tracks="tracks"
|
:tracks="tracks"
|
||||||
:playlist="playlist"
|
|
||||||
>
|
>
|
||||||
{{ t('views.playlists.Detail.button.playAll') }}
|
{{ t('views.playlists.Detail.button.playAll') }}
|
||||||
</PlayButton>
|
</PlayButton>
|
||||||
|
@ -264,14 +240,6 @@ const shuffle = () => {}
|
||||||
:tracks="tracks"
|
:tracks="tracks"
|
||||||
:unique="false"
|
:unique="false"
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
v-if="nextPage"
|
|
||||||
primary
|
|
||||||
:is-loading="isLoadingMoreTracks"
|
|
||||||
@click="loadMoreTracks"
|
|
||||||
>
|
|
||||||
{{ t('views.playlists.Detail.button.loadMoreTracks') }}
|
|
||||||
</Button>
|
|
||||||
</template>
|
</template>
|
||||||
<Alert
|
<Alert
|
||||||
v-else-if="!isLoading"
|
v-else-if="!isLoading"
|
||||||
|
|
Loading…
Reference in New Issue