Compare commits

...

21 Commits

Author SHA1 Message Date
FunTranslater 5f5b185cfa Translated using Weblate (Russian)
Currently translated at 95.1% (2171 of 2282 strings)

Translation: Funkwhale/Funkwhale Web
Translate-URL: https://translate.funkwhale.audio/projects/funkwhale/front/ru/
2025-07-10 14:34:13 +00:00
FunTranslater 2c0f0fd86b Added translation using Weblate (Ukrainian) 2025-07-10 14:19:27 +00:00
petitminion bd12cc389e fix(front):make soundCache reactive to allow player to get track metadata when the queue only contains one track 2025-06-18 11:36:56 +00:00
JuniorJPDJ aaa4e6eb92 fix(api): allow importing single files using fs-import API endpoint 2025-06-13 11:15:46 +00:00
ArneBo e1bc3d2474 fix(front): syntax and performance improvements in playlist detail 2025-06-12 15:29:09 +02:00
Petitminion f8bfbb4434 fix(schema):update schema and documentation for release 2025-06-11 12:16:33 +02:00
Petitminion 6bc858986b fix(pylint):add default FUNKWHALE_URL var 2025-06-11 10:25:32 +02:00
Petitminion df34dcb452 fix(backend):pylint django setting variable not properly set 2025-06-11 10:04:31 +02:00
Petitminion ba385553b3 Version bump and changelog for 2.0.0-alpha.2 2025-06-10 17:42:44 +02:00
petitminion b09ba1c366 fix(front):do not use "usePage" in homepage 2025-06-09 22:24:04 +00:00
Marie cbaa823bad fix(front): fallback to medium sized attachments if small doesn't exist 2025-06-09 21:43:10 +00:00
Petitminion 34203afe44 fix(backend):AttributeError: 'AnonymousUser' object has no attribute 'get_channels_groups' 2025-06-04 12:24:36 +00:00
Petitminion 724d85a2d9 enhancement(backend): Only refresh_nodeinfo_known_nodes for Funkwhale instances (#2442) 2025-06-03 17:40:23 +02:00
petitminion ad9a829af6 enhancement(plugin):make playlist detail reactive to upload upgrades from plugins 2025-06-02 15:46:59 +00:00
Marie 5f0414138b chore(api): update to latest storage spec 2025-06-02 13:45:52 +00:00
Marie 611631213a fix(api): openSubsonic not returning as boolean NOCHANGELOG 2025-06-02 12:35:28 +00:00
Philipp Wolfer 0bf217dbcd fix(migrations):playlist and playlisttrack fid migrations 2025-06-02 11:51:21 +00:00
petitminion b063a3616e enhancement(front):use paginated page for playlist
fix(front): enable more link on artist and user profiles
refactor(front): use new ui components in track modal and track-mobile-row, render track-modal only once in parent and populate with data from mobile-row
2025-06-02 11:27:52 +00:00
ArneBo 0d9c5e77c4 fix(front): remove manual hash and use absolute directory for bootstrap icons 2025-05-30 19:55:42 +00:00
ArneBo ad1ca7658c fix(front): use futureproof sass import and make firefox load the app 2025-05-30 19:55:42 +00:00
ArneBo 7a7cc246aa fix(nginx): proxy Vite dev server deps for HMR in multi-node setup 2025-05-30 19:55:42 +00:00
52 changed files with 5146 additions and 223 deletions

View File

@ -9,6 +9,19 @@ 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.

View File

@ -556,7 +556,15 @@ 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/")
DEFAULT_FILE_STORAGE = "funkwhale_api.common.storage.ASCIIFileSystemStorage" STORAGES = {
"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)
""" """
@ -655,7 +663,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.
""" """
DEFAULT_FILE_STORAGE = "funkwhale_api.common.storage.ASCIIS3Boto3Storage" STORAGES["default"]["BACKEND"] = "funkwhale_api.common.storage.ASCIIS3Boto3Storage"
# See: # See:

View File

@ -41,10 +41,6 @@ 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:

View File

@ -19,6 +19,10 @@ 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):
groups = self.scope["user"].get_channels_groups() + self.groups if self.scope.get("user", False) and self.scope.get("user").pk is not None:
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)

View File

@ -1,7 +1,7 @@
openapi: 3.0.3 openapi: 3.0.3
info: info:
title: Funkwhale API title: Funkwhale API
version: 1.4.0 version: 2.0.0a2
description: | description: |
# Funkwhale API # Funkwhale API

View File

@ -55,12 +55,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name="trackfavorite", model_name="trackfavorite",
name="fid", name="fid",
field=models.URLField( field=models.URLField(default="https://default.fid"),
db_index=True,
default="https://default.fid",
max_length=500,
unique=True,
),
preserve_default=False, preserve_default=False,
), ),
migrations.AddField( migrations.AddField(
@ -79,6 +74,15 @@ 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",

View File

@ -236,8 +236,10 @@ 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 = models.Domain.objects.external().exclude( candidates = (
nodeinfo_fetch_date__gte=limit models.Domain.objects.external()
.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))

View File

@ -1,3 +1,5 @@
import os
import pathlib
import urllib.parse import urllib.parse
from django import urls from django import urls
@ -903,13 +905,17 @@ 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, FileNotFoundError, ValueError): except NotADirectoryError:
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

View File

@ -1209,6 +1209,7 @@ 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,
): ):
@ -1229,7 +1230,7 @@ def fs_import(
"batch_size": batch_size, "batch_size": batch_size,
"async_": False, "async_": False,
"prune": prune, "prune": prune,
"replace": False, "replace": replace,
"verbosity": verbosity, "verbosity": verbosity,
"exit_on_failure": False, "exit_on_failure": False,
"outbox": outbox, "outbox": outbox,

View File

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

View File

@ -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:playlist-detail", kwargs={"uuid": unique_uuid}) reverse("federation:music:playlists-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 ), field=models.URLField(max_length=500, null=True),
), ),
migrations.AddField( migrations.AddField(
model_name="playlist", model_name="playlist",
@ -63,8 +63,13 @@ class Migration(migrations.Migration):
migrations.AlterField( migrations.AlterField(
model_name="playlist", model_name="playlist",
name="fid", name="fid",
field=models.URLField(max_length=500, unique=True, db_index=True, field=models.URLField(
),), max_length=500,
unique=True,
db_index=True,
null=False,
),
),
migrations.AddField( migrations.AddField(
model_name="playlist", model_name="playlist",
name="actor", name="actor",

View File

@ -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", "Playlist") MyModel = apps.get_model("playlists", "PlaylistTrack")
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:playlist-detail", kwargs={"uuid": unique_uuid}) reverse("federation:music:playlists-detail", kwargs={"uuid": unique_uuid})
) )
row.uuid = unique_uuid row.uuid = unique_uuid
row.fid = fid row.fid = fid
@ -38,8 +38,7 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name="playlisttrack", model_name="playlisttrack",
name="fid", name="fid",
field=models.URLField(max_length=500 field=models.URLField(max_length=500, null=True),
),
), ),
migrations.AddField( migrations.AddField(
model_name="playlisttrack", model_name="playlisttrack",
@ -47,8 +46,8 @@ class Migration(migrations.Migration):
field=models.URLField(blank=True, max_length=500, null=True), field=models.URLField(blank=True, max_length=500, null=True),
), ),
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop), migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
migrations.AlterField( migrations.AlterField(
model_name="playlist", model_name="playlisttrack",
name="uuid", name="uuid",
field=models.UUIDField(default=uuid.uuid4, null=False, unique=True), field=models.UUIDField(default=uuid.uuid4, null=False, unique=True),
), ),
@ -56,6 +55,7 @@ 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 db_index=True, max_length=500, unique=True, null=False
),), ),
),
] ]

View File

@ -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, include) tracks = music_models.Track.objects.playable_by(actor)
if include: if include:
return self.filter(track__pk__in=tracks).distinct() return self.filter(track__pk__in=tracks).distinct()
else: else:

View File

@ -67,8 +67,10 @@ 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 self.context.get("request", False) and hasattr( if (
self.context["request"], "user" self.context.get("request", False)
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)

View File

@ -7,6 +7,7 @@ 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
@ -129,18 +130,25 @@ 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()
plts = playlist.playlist_tracks.all().for_nested_serialization( actor = music_utils.get_actor_from_request(request)
music_utils.get_actor_from_request(request) plts = playlist.playlist_tracks.all().for_nested_serialization(actor)
) for plt in plts.playable_by(actor, include=False)[
plts_without_upload = plts.filter(track__uploads__isnull=True) : settings.THIRD_PARTY_UPLOAD_MAX_UPLOADS
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)
data = {"count": len(plts), "results": serializer.data} # Apply pagination
return Response(data, status=200) paginator = PageNumberPagination()
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

View File

@ -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,6 +70,7 @@ 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"

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "funkwhale-api" name = "funkwhale-api"
version = "2.0.0-alpha.1" version = "2.0.0-alpha.2"
description = "Funkwhale API" description = "Funkwhale API"
authors = ["Funkwhale Collective"] authors = ["Funkwhale Collective"]
@ -135,7 +135,8 @@ 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.testing" django-settings-module = "config.settings.local"
init-hook = 'import os; os.environ.setdefault("FUNKWHALE_URL", "https://test.federation")'
[tool.pylint.messages_control] [tool.pylint.messages_control]
disable = [ disable = [

View File

@ -233,15 +233,19 @@ 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"](nodeinfo_fetch_date=None), factories["federation.Domain"](
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")

View File

@ -1528,6 +1528,7 @@ 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,
) )

View File

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

View File

@ -0,0 +1 @@
Updated the django-storage specification to the latest version (#2459)

View File

@ -0,0 +1 @@
Small sized attachments now fallback to medium sized if small doesn't exist (#2460)

View File

@ -0,0 +1 @@
Allow importing single files using fs-import API endpoint

View File

@ -74,6 +74,11 @@ 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;

View File

@ -58,6 +58,8 @@ 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 ..

View File

@ -6,7 +6,6 @@ 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'
@ -34,7 +33,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 = usePage() const page = ref(1)
const nextPage = ref() const nextPage = ref()
const isLoading = ref(false) const isLoading = ref(false)

View File

@ -7,7 +7,6 @@ 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'
@ -34,7 +33,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 = usePage() const page = ref(1)
const nextPage = ref() const nextPage = ref()
const isLoading = ref(false) const isLoading = ref(false)

View File

@ -48,6 +48,7 @@ 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

View File

@ -28,6 +28,7 @@ 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

View File

@ -7,8 +7,6 @@ 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'
@ -33,7 +31,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 = usePage() const page = ref(1)
const count = ref(0) const count = ref(0)
const isLoading = ref(false) const isLoading = ref(false)

View File

@ -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" v-if="filterableArtist && !props.playlist"
:disabled="!filterableArtist" :disabled="!filterableArtist"
:title="labels.hideArtist" :title="labels.hideArtist"
icon="bi-eye-slash" icon="bi-eye-slash"

View File

@ -77,12 +77,14 @@ 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"

View File

@ -90,12 +90,14 @@ 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"

View File

@ -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 { ref, computed } from 'vue' import { 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,9 +12,10 @@ 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
@ -25,7 +26,6 @@ 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,7 +39,6 @@ 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,
@ -48,7 +47,9 @@ const props = withDefaults(defineProps<Props>(), {
account: null account: null
}) })
const showTrackModal = ref(false) const emit = defineEmits<{
(e: 'open-modal', track: Track, index: number): void
}>()
const { currentTrack } = useQueue() const { currentTrack } = useQueue()
const { isPlaying } = usePlayer() const { isPlaying } = usePlayer()
@ -77,12 +78,14 @@ 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
@ -115,8 +118,10 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
/> />
</p> </p>
</div> </div>
<div <track-favorite-icon
v-if="store.state.auth.authenticated" v-if="store.state.auth.authenticated"
ghost
tiny
:class="[ :class="[
'meta', 'meta',
'right', 'right',
@ -125,17 +130,14 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
'mobile', 'mobile',
{ 'with-art': showArt }, { 'with-art': showArt },
]" ]"
role="button" :track="track"
> />
<track-favorite-icon <!-- TODO: Replace with <PlayButton :dropdown-only="true"> after its display is fixed for mobile -->
class="tiny" <Button
:border="false"
:track="track"
/>
</div>
<div
role="button"
:aria-label="actionsButtonLabel" :aria-label="actionsButtonLabel"
icon="bi-three-dots-vertical"
ghost
tiny
:class="[ :class="[
'modal-button', 'modal-button',
'right', 'right',
@ -144,16 +146,7 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
'mobile', 'mobile',
{ 'with-art': showArt }, { 'with-art': showArt },
]" ]"
@click.prevent.exact="showTrackModal = !showTrackModal" @click.prevent.exact="emit('open-modal', track, index)"
>
<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>

View File

@ -12,6 +12,8 @@ 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
@ -92,9 +94,11 @@ 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">
@ -127,126 +131,105 @@ const labels = computed(() => ({
{{ generateTrackCreditString(track) }} {{ generateTrackCreditString(track) }}
</h4> </h4>
</div> </div>
<div class="ui hidden divider" />
<div class="content"> <div class="content">
<div class="ui one column unstackable grid"> <Layout
<div stack
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'"
class="row" full
ghost
:aria-label="favoriteButton"
:icon="isFavorite ? 'bi-heart-fill' : 'bi-heart'"
:is-active="isFavorite"
@click.stop="store.dispatch('favorites/toggle', track.id)"
> >
<div {{ favoriteButton }}
tabindex="0" </Button>
class="column" <Button
role="button" full
:aria-label="favoriteButton" ghost
@click.stop="store.dispatch('favorites/toggle', track.id)" :aria-label="labels.addToQueue"
> icon="bi-plus"
<i :class="[ 'heart', 'favorite-icon', { favorited: isFavorite, pink: isFavorite }, 'icon', 'track-modal', 'list-icon' ]" /> @click.stop.prevent="enqueue(); show = false"
<span class="track-modal list-item">{{ favoriteButton }}</span> >
</div> {{ labels.addToQueue }}
</div> </Button>
<div class="row"> <Button
<div full
class="column" ghost
role="button" :aria-label="labels.playNext"
:aria-label="labels.addToQueue" icon="bi-skip-end"
@click.stop.prevent="enqueue(); show = false" @click.stop.prevent="enqueueNext(true); show = false"
> >
<i class="plus icon track-modal list-icon" /> {{ labels.playNext }}
<span class="track-modal list-item">{{ labels.addToQueue }}</span> </Button>
</div> <Button
</div> full
<div class="row"> ghost
<div :aria-label="labels.startRadio"
class="column" icon="bi-rss"
role="button" @click.stop.prevent="store.dispatch('radios/start', { type: 'similar', objectId: track.id }); show = false"
:aria-label="labels.playNext" >
@click.stop.prevent="enqueueNext(true);show = false" {{ labels.startRadio }}
> </Button>
<i class="step forward icon track-modal list-icon" /> <Button
<span class="track-modal list-item">{{ labels.playNext }}</span> full
</div> ghost
</div> :aria-label="labels.addToPlaylist"
<div class="row"> icon="bi-list"
<div @click.stop="store.commit('playlists/chooseTrack', track)"
class="column" >
role="button" {{ labels.addToPlaylist }}
:aria-label="labels.startRadio" </Button>
@click.stop.prevent="() => { store.dispatch('radios/start', { type: 'similar', objectId: track.id }); show = false }" <hr>
> <Button
<i class="rss icon track-modal list-icon" />
<span class="track-modal list-item">{{ labels.startRadio }}</span>
</div>
</div>
<div class="row">
<div
class="column"
role="button"
:aria-label="labels.addToPlaylist"
@click.stop="store.commit('playlists/chooseTrack', track)"
>
<i class="list icon track-modal list-icon" />
<span class="track-modal list-item">
{{ labels.addToPlaylist }}
</span>
</div>
</div>
<div class="ui divider" />
<div
v-if="!isAlbum && track.album" v-if="!isAlbum && track.album"
class="row" full
ghost
:aria-label="albumDetailsButton"
icon="bi-disc"
@click.prevent.exact="router.push({ name: 'library.albums.detail', params: { id: track.album?.id } })"
> >
<div {{ albumDetailsButton }}
class="column" </Button>
role="button" <template
:aria-label="albumDetailsButton"
@click.prevent.exact="router.push({ name: 'library.albums.detail', params: { id: track.album?.id } })"
>
<i class="compact disc icon track-modal list-icon" />
<span class="track-modal list-item">{{ albumDetailsButton }}</span>
</div>
</div>
<div
v-if="!isArtist" v-if="!isArtist"
class="row"
> >
<div <Button
v-for="ac in track.artist_credit" v-for="ac in track.artist_credit"
:key="ac.artist.id" :key="ac.artist.id"
class="column" full
role="button" ghost
: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 } })"
> >
<i class="user icon track-modal list-icon" /> {{ ac.credit }}<span v-if="ac.joinphrase">{{ ac.joinphrase }}</span>
<span class="track-modal list-item">{{ ac.credit }}</span> </Button>
<span v-if="ac.joinphrase">{{ ac.joinphrase }}</span> </template>
</div> <Button
</div> full
<div class="row"> ghost
<div :aria-label="trackDetailsButton"
class="column" icon="bi-info-circle"
role="button" @click.prevent.exact="router.push({ name: 'library.tracks.detail', params: { id: track.id } })"
:aria-label="trackDetailsButton" >
@click.prevent.exact="router.push({ name: 'library.tracks.detail', params: { id: track.id } })" {{ trackDetailsButton }}
> </Button>
<i class="info icon track-modal list-icon" /> <hr>
<span class="track-modal list-item">{{ trackDetailsButton }}</span> <Button
</div>
</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"
class="row" full
ghost
icon="bi-share"
@click.stop.prevent="report(obj)" @click.stop.prevent="report(obj)"
> >
<div class="column"> {{ obj.label }}
<i class="share icon track-modal list-icon" /> </Button>
<span class="track-modal list-item">{{ obj.label }}</span> </Layout>
</div>
</div>
</div>
</div> </div>
</Modal> </Modal>
</template> </template>

View File

@ -127,12 +127,14 @@ 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"

View File

@ -8,6 +8,8 @@ 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'
@ -43,7 +45,7 @@ interface Props {
paginateResults?: boolean paginateResults?: boolean
total?: number total?: number
page?: number page?: number
paginateBy?: number, paginateBy?: number
unique?: boolean unique?: boolean
} }
@ -143,6 +145,16 @@ 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>
@ -251,13 +263,22 @@ const updatePage = (page: 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"

View File

@ -8,7 +8,6 @@ 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'
@ -50,7 +49,7 @@ const { t } = useI18n()
const objects = reactive([] as Listening[]) const objects = reactive([] as Listening[])
const count = ref(0) const count = ref(0)
const page = usePage() const page = ref(1)
const isLoading = ref(false) const isLoading = ref(false)
@ -141,11 +140,13 @@ 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"

View File

@ -40,6 +40,7 @@ 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

View File

@ -176,7 +176,6 @@ 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

View File

@ -8,7 +8,6 @@ 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'
@ -31,7 +30,7 @@ const props = defineProps<Props>()
const store = useStore() const store = useStore()
const objects = reactive([] as Playlist[]) const objects = reactive([] as Playlist[])
const page = usePage() const page = ref(1)
const nextPage = ref('') const nextPage = ref('')
const count = ref(0) const count = ref(0)

View File

@ -27,6 +27,14 @@ 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[]> => {
@ -139,7 +147,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
soundCache.set(track.id, sound) setSoundCache(track.id, sound)
soundPromises.delete(track.id) soundPromises.delete(track.id)
return sound return sound
@ -224,7 +232,12 @@ export const useTracks = createGlobalState(() => {
}) })
}) })
const currentSound = computed(() => soundCache.get(currentTrack.value?.id ?? -1)) const currentSound = computed(() => {
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()

View File

@ -1,4 +1,4 @@
import type { Notification } from '~/types' import type { Notification, Track } from '~/types'
import store from '~/store' import store from '~/store'
import { tryOnScopeDispose } from '@vueuse/core' import { tryOnScopeDispose } from '@vueuse/core'
@ -51,6 +51,7 @@ 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()}`

View File

@ -4597,7 +4597,8 @@
"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"

View File

@ -4611,5 +4611,8 @@
"title": "Радио" "title": "Радио"
} }
} }
},
"vui": {
"radio": "Радио"
} }
} }

4804
front/src/locales/uk.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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' | 'report.created' | 'user_request.created' | 'Listen' | 'playlist.track_updated'
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,7 +91,8 @@ 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([]),
@ -273,6 +274,7 @@ 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
} }

View File

@ -5,11 +5,10 @@
*/ */
$bootstrap-icons-font: "bootstrap-icons" !default; $bootstrap-icons-font: "bootstrap-icons" !default;
$bootstrap-icons-font-dir: "./fonts" !default; $bootstrap-icons-font-dir: "~/style/bootstrap-icons/font/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-hash: "24e3eb84d0bcaf83d77f904c78ac1f47" !default; $bootstrap-icons-font-src: url("#{$bootstrap-icons-font-file}.woff2") format("woff2"),
$bootstrap-icons-font-src: url("#{$bootstrap-icons-font-file}.woff2?#{$bootstrap-icons-font-hash}") format("woff2"), url("#{$bootstrap-icons-font-file}.woff") format("woff") !default;
url("#{$bootstrap-icons-font-file}.woff?#{$bootstrap-icons-font-hash}") format("woff") !default;
@font-face { @font-face {
font-display: block; font-display: block;

View File

@ -1,4 +1,4 @@
@import url("~/style/bootstrap-icons/font/bootstrap-icons.css"); @use "~/style/bootstrap-icons/font/bootstrap-icons.scss";
@font-face { @font-face {
font-family: Lato; font-family: Lato;

View File

@ -62,6 +62,7 @@ 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"

View File

@ -190,7 +190,6 @@ 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

View File

@ -24,6 +24,7 @@ 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 {
@ -31,7 +32,7 @@ import useErrorHandler from '~/composables/useErrorHandler'
// } // }
interface Props { interface Props {
id: number id: string
defaultEdit?: boolean defaultEdit?: boolean
} }
@ -40,7 +41,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[]>([])
@ -51,23 +52,39 @@ 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/`) axios.get(`playlists/${props.id}/tracks?page=1`)
]) ])
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)
} }
@ -75,6 +92,24 @@ 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(() => {
@ -113,17 +148,6 @@ 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>
@ -178,7 +202,6 @@ 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
@ -189,6 +212,7 @@ 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>
@ -240,6 +264,14 @@ 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"