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 -->
## 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)
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
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)
"""
@ -655,7 +663,7 @@ if AWS_ACCESS_KEY_ID:
A directory in your S3 bucket where you store files.
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:

View File

@ -41,10 +41,6 @@ SECRET_KEY = env("DJANGO_SECRET_KEY")
# SESSION_COOKIE_HTTPONLY = True
# SECURE_SSL_REDIRECT = env.bool("DJANGO_SECURE_SSL_REDIRECT", default=True)
# Static Assets
# ------------------------
STATICFILES_STORAGE = "django.contrib.staticfiles.storage.StaticFilesStorage"
# TEMPLATE CONFIGURATION
# ------------------------------------------------------------------------------
# See:

View File

@ -19,6 +19,10 @@ class JsonAuthConsumer(JsonWebsocketConsumer):
channels.group_add(group, self.channel_name)
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:
channels.group_discard(group, self.channel_name)

View File

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

View File

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

View File

@ -236,8 +236,10 @@ def refresh_nodeinfo_known_nodes():
settings.NODEINFO_REFRESH_DELAY
"""
limit = timezone.now() - datetime.timedelta(seconds=settings.NODEINFO_REFRESH_DELAY)
candidates = models.Domain.objects.external().exclude(
nodeinfo_fetch_date__gte=limit
candidates = (
models.Domain.objects.external()
.exclude(nodeinfo_fetch_date__gte=limit)
.filter(nodeinfo__software__name="Funkwhale")
)
names = candidates.values_list("name", flat=True)
logger.info("Launching periodic nodeinfo refresh on %s domains", len(names))

View File

@ -1,3 +1,5 @@
import os
import pathlib
import urllib.parse
from django import urls
@ -903,13 +905,17 @@ class FSImportSerializer(serializers.Serializer):
prune = serializers.BooleanField(required=False, default=True)
outbox = 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)
verbosity = serializers.IntegerField(required=False, default=1)
def validate_path(self, value):
try:
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")
return value

View File

@ -1209,6 +1209,7 @@ def fs_import(
prune=True,
outbox=False,
broadcast=False,
replace=False,
batch_size=1000,
verbosity=1,
):
@ -1229,7 +1230,7 @@ def fs_import(
"batch_size": batch_size,
"async_": False,
"prune": prune,
"replace": False,
"replace": replace,
"verbosity": verbosity,
"exit_on_failure": False,
"outbox": outbox,

View File

@ -386,6 +386,7 @@ class LibraryViewSet(
prune=serializer.validated_data["prune"],
outbox=serializer.validated_data["outbox"],
broadcast=serializer.validated_data["broadcast"],
replace=serializer.validated_data["replace"],
batch_size=serializer.validated_data["batch_size"],
verbosity=serializer.validated_data["verbosity"],
)

View File

@ -25,7 +25,7 @@ def gen_uuid(apps, schema_editor):
unique_uuid = uuid.uuid4()
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.fid = fid
@ -42,7 +42,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="playlist",
name="fid",
field=models.URLField(max_length=500 ),
field=models.URLField(max_length=500, null=True),
),
migrations.AddField(
model_name="playlist",
@ -63,8 +63,13 @@ class Migration(migrations.Migration):
migrations.AlterField(
model_name="playlist",
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(
model_name="playlist",
name="actor",

View File

@ -9,14 +9,14 @@ from funkwhale_api.federation import utils
from django.urls import reverse
def gen_uuid(apps, schema_editor):
MyModel = apps.get_model("playlists", "Playlist")
MyModel = apps.get_model("playlists", "PlaylistTrack")
for row in MyModel.objects.all():
unique_uuid = uuid.uuid4()
while MyModel.objects.filter(uuid=unique_uuid).exists():
unique_uuid = uuid.uuid4()
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.fid = fid
@ -38,8 +38,7 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name="playlisttrack",
name="fid",
field=models.URLField(max_length=500
),
field=models.URLField(max_length=500, null=True),
),
migrations.AddField(
model_name="playlisttrack",
@ -47,8 +46,8 @@ class Migration(migrations.Migration):
field=models.URLField(blank=True, max_length=500, null=True),
),
migrations.RunPython(gen_uuid, reverse_code=migrations.RunPython.noop),
migrations.AlterField(
model_name="playlist",
migrations.AlterField(
model_name="playlisttrack",
name="uuid",
field=models.UUIDField(default=uuid.uuid4, null=False, unique=True),
),
@ -56,6 +55,7 @@ class Migration(migrations.Migration):
model_name="playlisttrack",
name="fid",
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)
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:
return self.filter(track__pk__in=tracks).distinct()
else:

View File

@ -67,8 +67,10 @@ class PlaylistSerializer(serializers.ModelSerializer):
@extend_schema_field(OpenApiTypes.BOOL)
def get_library_followed(self, obj):
if self.context.get("request", False) and hasattr(
self.context["request"], "user"
if (
self.context.get("request", False)
and hasattr(self.context["request"], "user")
and hasattr(self.context["request"].user, "actor")
):
actor = self.context["request"].user.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 rest_framework import exceptions, mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.pagination import PageNumberPagination
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
@ -129,18 +130,25 @@ class PlaylistViewSet(
@action(methods=["get"], detail=True)
def tracks(self, request, *args, **kwargs):
playlist = self.get_object()
plts = playlist.playlist_tracks.all().for_nested_serialization(
music_utils.get_actor_from_request(request)
)
plts_without_upload = plts.filter(track__uploads__isnull=True)
for plt in plts_without_upload[: settings.THIRD_PARTY_UPLOAD_MAX_UPLOADS]:
actor = 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)[
: settings.THIRD_PARTY_UPLOAD_MAX_UPLOADS
]:
plugins.trigger_hook(
plugins.TRIGGER_THIRD_PARTY_UPLOAD,
track=plt.track,
actor=actor,
)
serializer = serializers.PlaylistTrackSerializer(plts, many=True)
data = {"count": len(plts), "results": serializer.data}
return Response(data, status=200)
# Apply pagination
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(
operation_id="add_to_playlist", request=serializers.PlaylistAddManySerializer

View File

@ -42,7 +42,7 @@ def structure_payload(data):
"status": "ok",
"type": "funkwhale",
"version": "1.16.0",
"openSubsonic": "true",
"openSubsonic": True,
}
payload.update(data)
if "detail" in payload:
@ -70,6 +70,7 @@ class SubsonicXMLRenderer(renderers.JSONRenderer):
return super().render(data, accepted_media_type, renderer_context)
final = structure_payload(data)
final["xmlns"] = "http://subsonic.org/restapi"
final["openSubsonic"] = "true"
tree = dict_to_xml_tree("subsonic-response", final)
return b'<?xml version="1.0" encoding="UTF-8"?>\n' + ET.tostring(
tree, encoding="utf-8"

View File

@ -1,6 +1,6 @@
[tool.poetry]
name = "funkwhale-api"
version = "2.0.0-alpha.1"
version = "2.0.0-alpha.2"
description = "Funkwhale API"
authors = ["Funkwhale Collective"]
@ -135,7 +135,8 @@ build-backend = "poetry.core.masonry.api"
[tool.pylint.master]
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]
disable = [

View File

@ -233,15 +233,19 @@ def test_refresh_nodeinfo_known_nodes(settings, factories, mocker, now):
settings.NODEINFO_REFRESH_DELAY = 666
refreshed = [
factories["federation.Domain"](nodeinfo_fetch_date=None),
factories["federation.Domain"](
nodeinfo_fetch_date=None,
nodeinfo={"software": {"name": "Funkwhale"}},
),
factories["federation.Domain"](
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"](
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")

View File

@ -1528,6 +1528,7 @@ def test_fs_import_post(
prune=True,
outbox=False,
broadcast=False,
replace=False,
batch_size=1000,
verbosity=1,
)

View File

@ -18,7 +18,7 @@ from funkwhale_api.subsonic import renderers
"type": "funkwhale",
"funkwhaleVersion": funkwhale_api.__version__,
"serverVersion": funkwhale_api.__version__,
"openSubsonic": "true",
"openSubsonic": True,
"hello": "world",
},
),
@ -33,7 +33,7 @@ from funkwhale_api.subsonic import renderers
"type": "funkwhale",
"funkwhaleVersion": funkwhale_api.__version__,
"serverVersion": funkwhale_api.__version__,
"openSubsonic": "true",
"openSubsonic": True,
"hello": "world",
"error": {"code": 10, "message": "something went wrong"},
},
@ -46,7 +46,7 @@ from funkwhale_api.subsonic import renderers
"type": "funkwhale",
"funkwhaleVersion": funkwhale_api.__version__,
"serverVersion": funkwhale_api.__version__,
"openSubsonic": "true",
"openSubsonic": True,
"hello": "world",
"error": {"code": 0, "message": "something went wrong"},
},
@ -66,7 +66,7 @@ def test_json_renderer():
"type": "funkwhale",
"funkwhaleVersion": funkwhale_api.__version__,
"serverVersion": funkwhale_api.__version__,
"openSubsonic": "true",
"openSubsonic": True,
"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;
}
location /node_modules/.vite/ {
include /etc/nginx/funkwhale_proxy.conf;
proxy_pass http://funkwhale-front;
}
location ~ ^/@(vite-plugin-pwa|vite|id)/ {
include /etc/nginx/funkwhale_proxy.conf;
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
```sh
docker compose build api
docker compose run --rm api funkwhale-manage spectacular > ./api/funkwhale_api/common/schema.yml
cd api
poetry version "$NEXT_RELEASE"
cd ..

View File

@ -6,7 +6,6 @@ import { useStore } from '~/store'
import axios from 'axios'
import usePage from '~/composables/navigation/usePage'
import useErrorHandler from '~/composables/useErrorHandler'
import AlbumCard from '~/components/album/Card.vue'
@ -34,7 +33,7 @@ const store = useStore()
const query = ref('')
const albums = reactive([] as Album[])
const count = ref(0)
const page = usePage()
const page = ref(1)
const nextPage = ref()
const isLoading = ref(false)

View File

@ -7,7 +7,6 @@ import { useStore } from '~/store'
import axios from 'axios'
import useErrorHandler from '~/composables/useErrorHandler'
import usePage from '~/composables/navigation/usePage'
import ArtistCard from '~/components/artist/Card.vue'
import Section from '~/components/ui/Section.vue'
@ -34,7 +33,7 @@ const store = useStore()
const query = ref('')
const artists = reactive([] as Artist[])
const count = ref(0)
const page = usePage()
const page = ref(1)
const nextPage = ref()
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-lazy="store.getters['instance/absoluteUrl'](ac.artist.cover.urls.small_square_crop)"
: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
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)"
alt=""
: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
v-else

View File

@ -7,8 +7,6 @@ import { clone } from 'lodash-es'
import axios from 'axios'
import usePage from '~/composables/navigation/usePage'
import ChannelCard from '~/components/audio/ChannelCard.vue'
import Loader from '~/components/ui/Loader.vue'
import Section from '~/components/ui/Section.vue'
@ -33,7 +31,7 @@ const props = withDefaults(defineProps<Props>(), {
const result = ref<PaginatedChannelList>()
const errors = ref([] as string[])
const nextPage = ref()
const page = usePage()
const page = ref(1)
const count = ref(0)
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">
<PopoverItem
v-if="filterableArtist"
v-if="filterableArtist && !props.playlist"
:disabled="!filterableArtist"
:title="labels.hideArtist"
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)"
alt=""
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
v-else-if="track.cover"
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
alt=""
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
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)"
alt=""
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
v-if="track.album?.cover?.urls.original"
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
alt=""
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
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 { PlayOptionsProps } from '~/composables/audio/usePlayOptions'
import { ref, computed } from 'vue'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { usePlayer } from '~/composables/audio/player'
@ -12,9 +12,10 @@ import { useStore } from '~/store'
import usePlayOptions from '~/composables/audio/usePlayOptions'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackModal from '~/components/audio/track/Modal.vue'
import { generateTrackCreditString } from '~/utils/utils'
import Button from '~/components/ui/Button.vue'
interface Props extends PlayOptionsProps {
track: Track
index: number
@ -25,7 +26,6 @@ interface Props extends PlayOptionsProps {
// TODO(wvffle): Remove after https://github.com/vuejs/core/pull/4512 is merged
isPlayable?: boolean
tracks?: Track[]
artist?: Artist | null
album?: Album | null
playlist?: Playlist | null
@ -39,7 +39,6 @@ const props = withDefaults(defineProps<Props>(), {
isArtist: false,
isAlbum: false,
tracks: () => [],
artist: null,
album: null,
playlist: null,
@ -48,7 +47,9 @@ const props = withDefaults(defineProps<Props>(), {
account: null
})
const showTrackModal = ref(false)
const emit = defineEmits<{
(e: 'open-modal', track: Track, index: number): void
}>()
const { currentTrack } = useQueue()
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)"
alt=""
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
v-else-if="track.album?.cover?.urls.original"
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
alt=""
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
v-else
@ -115,8 +118,10 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
/>
</p>
</div>
<div
<track-favorite-icon
v-if="store.state.auth.authenticated"
ghost
tiny
:class="[
'meta',
'right',
@ -125,17 +130,14 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
'mobile',
{ 'with-art': showArt },
]"
role="button"
>
<track-favorite-icon
class="tiny"
:border="false"
:track="track"
/>
</div>
<div
role="button"
:track="track"
/>
<!-- TODO: Replace with <PlayButton :dropdown-only="true"> after its display is fixed for mobile -->
<Button
:aria-label="actionsButtonLabel"
icon="bi-three-dots-vertical"
ghost
tiny
:class="[
'modal-button',
'right',
@ -144,16 +146,7 @@ const actionsButtonLabel = computed(() => t('components.audio.track.MobileRow.bu
'mobile',
{ 'with-art': showArt },
]"
@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"
@click.prevent.exact="emit('open-modal', track, index)"
/>
</div>
</template>

View File

@ -12,6 +12,8 @@ import { useVModel } from '@vueuse/core'
import { generateTrackCreditString, getArtistCoverUrl } from '~/utils/utils'
import Modal from '~/components/ui/Modal.vue'
import Button from '~/components/ui/Button.vue'
import Layout from '~/components/ui/Layout.vue'
interface Events {
(e: 'update:show', value: boolean): void
@ -92,9 +94,11 @@ const labels = computed(() => ({
</script>
<template>
<!-- TODO: Delete this file after this modal is replaced with playbutton dropdown-only popover -->
<Modal
v-model="show"
:title="track.title"
class="small"
>
<div class="header">
<div class="ui large centered rounded image">
@ -127,126 +131,105 @@ const labels = computed(() => ({
{{ generateTrackCreditString(track) }}
</h4>
</div>
<div class="ui hidden divider" />
<div class="content">
<div class="ui one column unstackable grid">
<div
<Layout
stack
no-gap
>
<Button
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
tabindex="0"
class="column"
role="button"
:aria-label="favoriteButton"
@click.stop="store.dispatch('favorites/toggle', track.id)"
>
<i :class="[ 'heart', 'favorite-icon', { favorited: isFavorite, pink: isFavorite }, 'icon', 'track-modal', 'list-icon' ]" />
<span class="track-modal list-item">{{ favoriteButton }}</span>
</div>
</div>
<div class="row">
<div
class="column"
role="button"
:aria-label="labels.addToQueue"
@click.stop.prevent="enqueue(); show = false"
>
<i class="plus icon track-modal list-icon" />
<span class="track-modal list-item">{{ labels.addToQueue }}</span>
</div>
</div>
<div class="row">
<div
class="column"
role="button"
:aria-label="labels.playNext"
@click.stop.prevent="enqueueNext(true);show = false"
>
<i class="step forward icon track-modal list-icon" />
<span class="track-modal list-item">{{ labels.playNext }}</span>
</div>
</div>
<div class="row">
<div
class="column"
role="button"
:aria-label="labels.startRadio"
@click.stop.prevent="() => { store.dispatch('radios/start', { type: 'similar', objectId: track.id }); show = false }"
>
<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
{{ favoriteButton }}
</Button>
<Button
full
ghost
:aria-label="labels.addToQueue"
icon="bi-plus"
@click.stop.prevent="enqueue(); show = false"
>
{{ labels.addToQueue }}
</Button>
<Button
full
ghost
:aria-label="labels.playNext"
icon="bi-skip-end"
@click.stop.prevent="enqueueNext(true); show = false"
>
{{ labels.playNext }}
</Button>
<Button
full
ghost
:aria-label="labels.startRadio"
icon="bi-rss"
@click.stop.prevent="store.dispatch('radios/start', { type: 'similar', objectId: track.id }); show = false"
>
{{ labels.startRadio }}
</Button>
<Button
full
ghost
:aria-label="labels.addToPlaylist"
icon="bi-list"
@click.stop="store.commit('playlists/chooseTrack', track)"
>
{{ labels.addToPlaylist }}
</Button>
<hr>
<Button
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
class="column"
role="button"
: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
{{ albumDetailsButton }}
</Button>
<template
v-if="!isArtist"
class="row"
>
<div
<Button
v-for="ac in track.artist_credit"
:key="ac.artist.id"
class="column"
role="button"
full
ghost
:aria-label="artistDetailsButton"
icon="bi-person-fill"
@click.prevent.exact="router.push({ name: 'library.artists.detail', params: { id: ac.artist.id } })"
>
<i class="user icon track-modal list-icon" />
<span class="track-modal list-item">{{ ac.credit }}</span>
<span v-if="ac.joinphrase">{{ ac.joinphrase }}</span>
</div>
</div>
<div class="row">
<div
class="column"
role="button"
:aria-label="trackDetailsButton"
@click.prevent.exact="router.push({ name: 'library.tracks.detail', params: { id: track.id } })"
>
<i class="info icon track-modal list-icon" />
<span class="track-modal list-item">{{ trackDetailsButton }}</span>
</div>
</div>
<div class="ui divider" />
<div
{{ ac.credit }}<span v-if="ac.joinphrase">{{ ac.joinphrase }}</span>
</Button>
</template>
<Button
full
ghost
:aria-label="trackDetailsButton"
icon="bi-info-circle"
@click.prevent.exact="router.push({ name: 'library.tracks.detail', params: { id: track.id } })"
>
{{ trackDetailsButton }}
</Button>
<hr>
<Button
v-for="obj in getReportableObjects({ track, album: track.album, artistCredit: track.artist_credit })"
:key="obj.target.type + obj.target.id"
class="row"
full
ghost
icon="bi-share"
@click.stop.prevent="report(obj)"
>
<div class="column">
<i class="share icon track-modal list-icon" />
<span class="track-modal list-item">{{ obj.label }}</span>
</div>
</div>
</div>
{{ obj.label }}
</Button>
</Layout>
</div>
</Modal>
</template>

View File

@ -127,12 +127,14 @@ const hover = ref(false)
v-lazy="store.getters['instance/absoluteUrl'](track.cover.urls.small_square_crop)"
:alt="track.title"
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
v-else-if="showArt && track.album?.cover?.urls.original"
v-lazy="store.getters['instance/absoluteUrl'](track.album.cover.urls.small_square_crop)"
alt=""
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
v-else-if="showArt"

View File

@ -8,6 +8,8 @@ import { ref, computed } from 'vue'
import axios from 'axios'
import TrackMobileRow from '~/components/audio/track/MobileRow.vue'
import TrackModal from '~/components/audio/track/Modal.vue'
import Pagination from '~/components/ui/Pagination.vue'
import TrackRow from '~/components/audio/track/Row.vue'
import Input from '~/components/ui/Input.vue'
@ -43,7 +45,7 @@ interface Props {
paginateResults?: boolean
total?: number
page?: number
paginateBy?: number,
paginateBy?: number
unique?: boolean
}
@ -143,6 +145,16 @@ const updatePage = (page: number) => {
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>
<template>
@ -251,13 +263,22 @@ const updatePage = (page: number) => {
:key="track.id"
:track="track"
:index="index"
:tracks="allTracks"
:show-position="showPosition"
:show-art="showArt"
:show-duration="showDuration"
:is-artist="isArtist"
:is-album="isAlbum"
: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
v-if="paginateResults"

View File

@ -8,7 +8,6 @@ import { useI18n } from 'vue-i18n'
import { getArtistCoverUrl } from '~/utils/utils'
import axios from 'axios'
import usePage from '~/composables/navigation/usePage'
import useWebSocketHandler from '~/composables/useWebSocketHandler'
import PlayButton from '~/components/audio/PlayButton.vue'
@ -50,7 +49,7 @@ const { t } = useI18n()
const objects = reactive([] as Listening[])
const count = ref(0)
const page = usePage()
const page = ref(1)
const isLoading = ref(false)
@ -141,11 +140,13 @@ watch(() => props.websocketHandlers.includes('Listen'), (to) => {
v-if="object.track.album && object.track.album.cover"
v-lazy="store.getters['instance/absoluteUrl'](object.track.album.cover.urls.small_square_crop)"
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
v-else-if="object.track.cover"
v-lazy="store.getters['instance/absoluteUrl'](object.track.cover.urls.small_square_crop)"
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
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)"
class="ui avatar tiny circular image"
alt=""
@error="(e) => { e.target && user.avatar ? (e.target as HTMLImageElement).src = store.getters['instance/absoluteUrl'](user.avatar.urls.medium_square_crop) : null }"
>
<span
v-else

View File

@ -176,7 +176,6 @@ const isOpen = useModal('artist-description').isOpen
class="description"
:content="{ ...object.description, text: object.description.text ?? undefined }"
:truncate-length="100"
:more-link="false"
/>
<Spacer grow />
<Link

View File

@ -8,7 +8,6 @@ import { useI18n } from 'vue-i18n'
import axios from 'axios'
import useErrorHandler from '~/composables/useErrorHandler'
import usePage from '~/composables/navigation/usePage'
import PlaylistCard from '~/components/playlists/Card.vue'
import Button from '~/components/ui/Button.vue'
@ -31,7 +30,7 @@ const props = defineProps<Props>()
const store = useStore()
const objects = reactive([] as Playlist[])
const page = usePage()
const page = ref(1)
const nextPage = ref('')
const count = ref(0)

View File

@ -27,6 +27,14 @@ const soundCache = new LRUCache<number, Sound>({
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>()
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
soundCache.set(track.id, sound)
setSoundCache(track.id, sound)
soundPromises.delete(track.id)
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 = () => {
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 { 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: 'user_request.created', handler: (event: PendingReviewRequests) => 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 {
const id = `${+new Date() + Math.random()}`

View File

@ -4597,7 +4597,8 @@
"edit": "Edit",
"embed": "Embed",
"playAll": "Play all",
"stopEdit": "Stop Editing"
"stopEdit": "Stop Editing",
"loadMoreTracks": "Load more tracks"
},
"empty": {
"noTracks": "There are no tracks in this playlist yet"

View File

@ -4611,5 +4611,8 @@
"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'
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'
| 'applied_date' | 'followers_count' | 'uploads_count' | 'length' | 'items_count' | 'modification_date' | 'size'
@ -91,7 +91,8 @@ const store: Module<State, RootState> = {
'mutation.updated': {},
'report.created': {},
'user_request.created': {},
Listen: {}
Listen: {},
'playlist.track_updated': {}
},
pageTitle: null,
modalsOpen: new Set([]),
@ -273,6 +274,7 @@ const store: Module<State, RootState> = {
const handlers = state.websocketEventsHandlers[event.type]
logger.log('Dispatching websocket event', event, handlers)
if (!handlers) {
logger.log('No websocket handlers for this event', event, handlers)
return
}

View File

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

View File

@ -62,6 +62,7 @@ const labels = computed(() => ({
alt=""
:src="store.getters['instance/absoluteUrl'](store.state.auth.profile?.avatar.urls.small_square_crop)"
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
v-else-if="store.state.auth.authenticated"

View File

@ -190,7 +190,6 @@ const isOpen = useModal('artist-description').isOpen
class="description"
:content="{ html: object?.summary.html || '' }"
:truncate-length="100"
:more-link="false"
/>
<Spacer grow />
<Link

View File

@ -24,6 +24,7 @@ import Alert from '~/components/ui/Alert.vue'
import PlaylistDropdown from '~/components/playlists/PlaylistDropdown.vue'
import useErrorHandler from '~/composables/useErrorHandler'
import useWebSocketHandler from '~/composables/useWebSocketHandler'
// TODO: Is this event ever caught somewhere?
// interface Events {
@ -31,7 +32,7 @@ import useErrorHandler from '~/composables/useErrorHandler'
// }
interface Props {
id: number
id: string
defaultEdit?: boolean
}
@ -40,7 +41,7 @@ const props = withDefaults(defineProps<Props>(), {
})
const store = useStore()
const isLoadingMoreTracks = ref(false)
const edit = ref(props.defaultEdit)
const playlist = ref<Playlist | null>(null)
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 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 labels = computed(() => ({
playlist: t('views.playlists.Detail.title')
}))
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 () => {
isLoading.value = true
try {
const [playlistResponse, tracksResponse] = await Promise.all([
axios.get(`playlists/${props.id}/`),
axios.get(`playlists/${props.id}/tracks/`)
axios.get(`playlists/${props.id}/tracks?page=1`)
])
playlist.value = playlistResponse.data
fullPlaylistTracks.value = tracksResponse.data.results
nextPage.value = tracksResponse.data.next
previousPage.value = tracksResponse.data.previous
totalTracks.value = tracksResponse.data.count
} catch (error) {
useErrorHandler(error as Error)
}
@ -75,6 +92,24 @@ const fetchData = async () => {
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()
const images = computed(() => {
@ -113,17 +148,6 @@ const randomizedColors = computed(() => shuffleArray(bgcolors.value))
// 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
const shuffle = () => {}
</script>
@ -178,7 +202,6 @@ const shuffle = () => {}
<RenderedDescription
:content="{ html: playlist.description }"
:truncate-length="100"
:show-more="true"
/>
<Layout
flex
@ -189,6 +212,7 @@ const shuffle = () => {}
low-height
:is-playable="true"
:tracks="tracks"
:playlist="playlist"
>
{{ t('views.playlists.Detail.button.playAll') }}
</PlayButton>
@ -240,6 +264,14 @@ const shuffle = () => {}
:tracks="tracks"
:unique="false"
/>
<Button
v-if="nextPage"
primary
:is-loading="isLoadingMoreTracks"
@click="loadMoreTracks"
>
{{ t('views.playlists.Detail.button.loadMoreTracks') }}
</Button>
</template>
<Alert
v-else-if="!isLoading"