From f39d75e7d230be7cf31dd9d84ce8987bf4651835 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:20:10 +0000 Subject: [PATCH 01/12] fix(subsonic): never omit duration for AlbumID3 (#5217) --- .../Responses AlbumWithSongsID3 with data should match .JSON | 1 + .../Responses AlbumWithSongsID3 with data should match .XML | 2 +- ...Responses AlbumWithSongsID3 without data should match .JSON | 3 ++- .../Responses AlbumWithSongsID3 without data should match .XML | 2 +- ...umWithSongsID3 without data should match OpenSubsonic .JSON | 1 + ...bumWithSongsID3 without data should match OpenSubsonic .XML | 2 +- server/subsonic/responses/responses.go | 2 +- server/subsonic/responses/responses_test.go | 2 +- 8 files changed, 9 insertions(+), 6 deletions(-) diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON index 6ed471e8b..07678407a 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .JSON @@ -8,6 +8,7 @@ "id": "1", "name": "album", "artist": "artist", + "duration": 292, "genre": "rock", "userRating": 4, "genres": [ diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML index 67dcf6bd7..f7b23cb4e 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 with data should match .XML @@ -1,5 +1,5 @@ - + diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON index fbeded48a..14e96939e 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .JSON @@ -6,6 +6,7 @@ "openSubsonic": true, "album": { "id": "", - "name": "" + "name": "", + "duration": 0 } } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML index 159967c1d..868265347 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match .XML @@ -1,3 +1,3 @@ - + diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON index 758aef0cb..446368fa5 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .JSON @@ -7,6 +7,7 @@ "album": { "id": "", "name": "", + "duration": 0, "userRating": 0, "genres": [], "musicBrainzId": "", diff --git a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML index 159967c1d..868265347 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumWithSongsID3 without data should match OpenSubsonic .XML @@ -1,3 +1,3 @@ - + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index b70f3b128..b9a39b6f9 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -250,7 +250,7 @@ type AlbumID3 struct { ArtistId string `xml:"artistId,attr,omitempty" json:"artistId,omitempty"` CoverArt string `xml:"coverArt,attr,omitempty" json:"coverArt,omitempty"` SongCount int32 `xml:"songCount,attr,omitempty" json:"songCount,omitempty"` - Duration int32 `xml:"duration,attr,omitempty" json:"duration,omitempty"` + Duration int32 `xml:"duration,attr" json:"duration"` PlayCount int64 `xml:"playCount,attr,omitempty" json:"playCount,omitempty"` Created *time.Time `xml:"created,attr,omitempty" json:"created,omitempty"` Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index ccf15afe3..15f2da9c6 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -288,7 +288,7 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { album := AlbumID3{ - Id: "1", Name: "album", Artist: "artist", Genre: "rock", + Id: "1", Name: "album", Artist: "artist", Duration: 292, Genre: "rock", } album.OpenSubsonicAlbumID3 = &OpenSubsonicAlbumID3{ Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, From ad92b752bec101ef13a88fcace674579ce90b094 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 17 Mar 2026 18:34:13 -0400 Subject: [PATCH 02/12] chore(deps): update dependencies for go-sqlite3, golang.org/x packages Signed-off-by: Deluan --- go.mod | 22 +++++++++++----------- go.sum | 44 ++++++++++++++++++++++---------------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/go.mod b/go.mod index 28fae78e3..487b57ef7 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.0.13 - github.com/mattn/go-sqlite3 v1.14.34 + github.com/mattn/go-sqlite3 v1.14.37 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 github.com/onsi/ginkgo/v2 v2.28.1 @@ -58,12 +58,12 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.36.0 - golang.org/x/net v0.51.0 + golang.org/x/image v0.37.0 + golang.org/x/net v0.52.0 golang.org/x/sync v0.20.0 golang.org/x/sys v0.42.0 - golang.org/x/term v0.40.0 - golang.org/x/text v0.34.0 + golang.org/x/term v0.41.0 + golang.org/x/text v0.35.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -80,13 +80,13 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 // indirect - github.com/ebitengine/purego v0.8.3 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobwas/glob v0.2.3 // indirect - github.com/goccy/go-json v0.10.5 // indirect + github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect @@ -134,10 +134,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.48.0 // indirect - golang.org/x/mod v0.33.0 // indirect - golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 // indirect - golang.org/x/tools v0.42.0 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect + golang.org/x/tools v0.43.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.1 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect diff --git a/go.sum b/go.sum index fd02f812e..d7b16d9d3 100644 --- a/go.sum +++ b/go.sum @@ -56,8 +56,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1 h1:idfl8M8rPW93NehFw5H1qqH8yG158t5POr+LX9avbJY= github.com/dylibso/observe-sdk/go v0.0.0-20240828172851-9145d8ad07e1/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= -github.com/ebitengine/purego v0.8.3 h1:K+0AjQp63JEZTEMZiwsI9g0+hAMNohwUOtY0RPGexmc= -github.com/ebitengine/purego v0.8.3/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/extism/go-sdk v1.7.1 h1:lWJos6uY+tRFdlIHR+SJjwFDApY7OypS/2nMhiVQ9Sw= github.com/extism/go-sdk v1.7.1/go.mod h1:IT+Xdg5AZM9hVtpFUA+uZCJMge/hbvshl8bwzLtFyKA= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= @@ -96,8 +96,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= -github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= -github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= +github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= @@ -177,8 +177,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= -github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg= +github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -319,19 +319,19 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= -golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= -golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= -golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= +golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= +golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= -golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -343,8 +343,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -372,8 +372,8 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4 h1:bTLqdHv7xrGlFbvf5/TXNxy/iUwwdkjhqQTJDjW7aj0= -golang.org/x/telemetry v0.0.0-20260209163413-e7419c687ee4/go.mod h1:g5NllXBEermZrmR51cJDQxmJUHUOfRAaNyWBM+R+548= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc= +golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -382,8 +382,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= -golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -394,8 +394,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -405,8 +405,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= From b013b71ba9fd77999cea8f7465f2faeaa8a8ebb4 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 17 Mar 2026 19:39:00 -0400 Subject: [PATCH 03/12] fix(server): clean up uploaded artist images during GC When artists are purged during garbage collection, any custom uploaded cover images were left orphaned on disk. Modified purgeEmpty() to query for uploaded_image filenames before the bulk DELETE, then remove the corresponding files from disk afterwards. Image cleanup is best-effort to avoid failing the GC if a file is already missing or inaccessible. Also populated album_artists entries in the persistence test suite setup to reflect the actual album-artist relationships from test data, ensuring purgeEmpty() doesn't inadvertently delete shared test artists. --- persistence/artist_repository.go | 30 +++++++++- persistence/artist_repository_test.go | 86 +++++++++++++++++++++++++++ persistence/persistence_suite_test.go | 22 +++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index 7f3d61540..e75a0e58c 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -4,7 +4,9 @@ import ( "cmp" "context" "encoding/json" + "errors" "fmt" + "os" "slices" "strings" "time" @@ -12,6 +14,7 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" @@ -315,7 +318,19 @@ func (r *artistRepository) GetIndex(includeMissing bool, libraryIds []int, roles } func (r *artistRepository) purgeEmpty() error { - del := Delete(r.tableName).Where("id not in (select artist_id from album_artists)") + orphanFilter := "id not in (select artist_id from album_artists)" + + // Collect uploaded image filenames before deleting + sel := Select("uploaded_image").From(r.tableName). + Where(orphanFilter). + Where("uploaded_image != ''") + var imageFiles []string + if err := r.queryAllSlice(sel, &imageFiles); err != nil && !errors.Is(err, model.ErrNotFound) { + return fmt.Errorf("collecting artist images for cleanup: %w", err) + } + + // Delete orphan artists + del := Delete(r.tableName).Where(orphanFilter) c, err := r.executeSQL(del) if err != nil { return fmt.Errorf("purging empty artists: %w", err) @@ -323,6 +338,19 @@ func (r *artistRepository) purgeEmpty() error { if c > 0 { log.Debug(r.ctx, "Purged empty artists", "totalDeleted", c) } + + if len(imageFiles) == 0 { + return nil + } + + // Best-effort cleanup of uploaded image files + log.Debug(r.ctx, "Cleaning up artist images", "totalImages", len(imageFiles)) + for _, filename := range imageFiles { + path := model.UploadedImagePath(consts.EntityArtist, filename) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + log.Warn(r.ctx, "Failed to remove artist image during GC", "path", path, err) + } + } return nil } diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index 90e449e8d..e2904466c 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -3,11 +3,14 @@ package persistence import ( "context" "encoding/json" + "os" + "path/filepath" "github.com/Masterminds/squirrel" "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils" @@ -829,6 +832,89 @@ var _ = Describe("ArtistRepository", func() { }) }) }) + + Describe("purgeEmpty", func() { + var repo *artistRepository + var tmpDir string + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tmpDir = GinkgoT().TempDir() + conf.Server.DataFolder = tmpDir + + ctx := request.WithUser(GinkgoT().Context(), adminUser) + repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) + }) + + // Helper to create an artist image file on disk and return its path + createImageFile := func(filename string) string { + dir := filepath.Join(tmpDir, consts.ArtworkFolder, consts.EntityArtist) + Expect(os.MkdirAll(dir, 0755)).To(Succeed()) + path := filepath.Join(dir, filename) + Expect(os.WriteFile(path, []byte("fake image data"), 0600)).To(Succeed()) + return path + } + + It("removes uploaded image files for purged artists", func() { + // Create an orphan artist (not in album_artists) with an uploaded image + orphanArtist := model.Artist{ID: "orphan-with-image", Name: "Orphan Artist", UploadedImage: "orphan-with-image_Orphan_Artist.jpg"} + Expect(repo.Put(&orphanArtist)).To(Succeed()) + imgPath := createImageFile("orphan-with-image_Orphan_Artist.jpg") + + Expect(repo.purgeEmpty()).To(Succeed()) + + // Artist should be gone from DB + exists, err := repo.Exists("orphan-with-image") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + + // Image file should be removed from disk + _, err = os.Stat(imgPath) + Expect(os.IsNotExist(err)).To(BeTrue()) + }) + + It("handles missing image files gracefully", func() { + // Artist has UploadedImage set but no actual file on disk + orphanArtist := model.Artist{ID: "orphan-no-file", Name: "Ghost Image", UploadedImage: "orphan-no-file_Ghost_Image.jpg"} + Expect(repo.Put(&orphanArtist)).To(Succeed()) + + Expect(repo.purgeEmpty()).To(Succeed()) + + // Artist should be gone from DB + exists, err := repo.Exists("orphan-no-file") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("does not delete images for artists that are kept", func() { + // Create an artist with an uploaded image AND an album_artists entry so it won't be purged + keptArtist := model.Artist{ID: "kept-artist", Name: "Kept Artist", UploadedImage: "kept-artist_Kept_Artist.jpg"} + Expect(repo.Put(&keptArtist)).To(Succeed()) + imgPath := createImageFile("kept-artist_Kept_Artist.jpg") + + // Insert an album_artists record to keep this artist from being purged + _, err := repo.executeSQL(squirrel.Insert("album_artists"). + SetMap(map[string]any{"album_id": "101", "artist_id": "kept-artist", "role": "artist", "sub_role": ""})) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _, _ = repo.executeSQL(squirrel.Delete("album_artists").Where(squirrel.Eq{"artist_id": "kept-artist"})) + _ = repo.delete(squirrel.Eq{"id": "kept-artist"}) + }) + + Expect(repo.purgeEmpty()).To(Succeed()) + + // Artist should still exist (check directly, bypassing library filter) + var ids []string + err = repo.queryAllSlice(squirrel.Select("id").From("artist").Where(squirrel.Eq{"id": "kept-artist"}), &ids) + Expect(err).ToNot(HaveOccurred()) + Expect(ids).To(HaveLen(1)) + + // Image file should still be on disk + _, err = os.Stat(imgPath) + Expect(err).ToNot(HaveOccurred()) + }) + }) }) // Helper function to create an artist with proper library association. diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index 0ee1570a1..3ed443129 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/Masterminds/squirrel" _ "github.com/mattn/go-sqlite3" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/db" @@ -211,6 +212,27 @@ var _ = BeforeSuite(func() { } } + // Populate album_artists based on the AlbumArtistID relationships in testAlbums + artistIDs := map[string]bool{} + for _, a := range testArtists { + artistIDs[a.ID] = true + } + for i := range testAlbums { + a := testAlbums[i] + if a.AlbumArtistID == "" || !artistIDs[a.AlbumArtistID] { + continue + } + _, err := alr.executeSQL(squirrel.Insert("album_artists").SetMap(map[string]any{ + "album_id": a.ID, + "artist_id": a.AlbumArtistID, + "role": "artist", + "sub_role": "", + })) + if err != nil { + panic(err) + } + } + mr := NewMediaFileRepository(ctx, conn) for i := range testSongs { err := mr.Put(&testSongs[i]) From d2a54243a88daed840b946b78ca3612ba8818ce3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 17 Mar 2026 20:24:21 -0400 Subject: [PATCH 04/12] fix(ui): prevent layout flash on album grid during cover loading Added aspect-ratio: 1 to the cover container so it reserves the correct square dimensions immediately on first render, before react-measure reports the container width. Previously, contentRect.bounds.width started as undefined/0, causing images to render with zero height and producing a brief flash of compressed tiles before the measurement callback fired. --- ui/src/album/AlbumGridView.jsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index b7db39730..c0d88d50e 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -94,6 +94,11 @@ const useStyles = makeStyles( ) const useCoverStyles = makeStyles({ + coverContainer: { + width: '100%', + aspectRatio: '1', + overflow: 'hidden', + }, cover: { display: 'inline-block', width: '100%', @@ -150,7 +155,7 @@ const Cover = withContentRect('bounds')(({ }, []) return ( -
+
Date: Tue, 17 Mar 2026 20:49:35 -0400 Subject: [PATCH 05/12] fix(ui): hide pagination during album list loading Added a custom AlbumListPagination component that returns null while the list is loading, preventing stale pagination controls from appearing alongside the Loading spinner when navigating to the Random album view. --- ui/src/album/AlbumList.jsx | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index f10f8dbd3..d00d97701 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -10,6 +10,7 @@ import { ReferenceArrayInput, ReferenceInput, SearchInput, + useListContext, usePermissions, useRefresh, useTranslate, @@ -174,6 +175,14 @@ const AlbumListTitle = ({ albumListType }) => { return } +const AlbumListPagination = (props) => { + const { loading } = useListContext() + if (loading) { + return null + } + return <Pagination {...props} /> +} + const randomStartingSeed = Math.random().toString() const AlbumList = (props) => { @@ -234,7 +243,7 @@ const AlbumList = (props) => { actions={<AlbumListActions />} filters={<AlbumFilter />} perPage={perPage} - pagination={<Pagination rowsPerPageOptions={perPageOptions} />} + pagination={<AlbumListPagination rowsPerPageOptions={perPageOptions} />} title={<AlbumListTitle albumListType={albumListType} />} > {albumView.grid ? ( From b5164c61abfd7485bf51b41709a7bfe6c0d46db7 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Tue, 17 Mar 2026 21:34:00 -0400 Subject: [PATCH 06/12] build(worktree): add script for setting up git worktrees Signed-off-by: Deluan <deluan@navidrome.org> --- .gitignore | 3 ++- Makefile | 33 +++++++++++++++++++++++ scripts/setup-worktree.sh | 57 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100755 scripts/setup-worktree.sh diff --git a/.gitignore b/.gitignore index db8c0abcf..73475a53a 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ AGENTS.md *.wasm *.ndp openspec/ -go.work* \ No newline at end of file +go.work* +.worktrees/ \ No newline at end of file diff --git a/Makefile b/Makefile index c9c88f506..3bad5b620 100644 --- a/Makefile +++ b/Makefile @@ -233,6 +233,39 @@ get-music: ##@Development Download some free music from Navidrome's demo instanc .PHONY: get-music +########################################## +#### Worktrees + +WORKTREES_DIR := .worktrees + +wt: check_go_env ##@Worktrees Create and setup a git worktree. Usage: make wt name=feature-name [go=1] + @if [ -z "${name}" ]; then echo "Usage: make wt name=<branch-name> [go=1]"; exit 1; fi + @mkdir -p $(WORKTREES_DIR) + @echo "Creating worktree for branch '${name}'..." + @git worktree add $(WORKTREES_DIR)/${name} -b ${name} 2>/dev/null || \ + git worktree add $(WORKTREES_DIR)/${name} ${name} + @if [ -n "${go}" ]; then \ + ./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name} --go-only; \ + else \ + ./scripts/setup-worktree.sh $(WORKTREES_DIR)/${name}; \ + fi + @echo "\nWorktree ready at $(WORKTREES_DIR)/${name}" + @echo " cd $(WORKTREES_DIR)/${name}" +.PHONY: wt + +rm-wt: ##@Worktrees Remove a git worktree. Usage: make rm-wt name=feature-name + @if [ -z "${name}" ]; then echo "Usage: make rm-wt name=<branch-name>"; exit 1; fi + @if [ ! -d "$(WORKTREES_DIR)/${name}" ]; then echo "Worktree '${name}' not found in $(WORKTREES_DIR)/"; exit 1; fi + @echo "Removing worktree '${name}'..." + @git worktree remove --force $(WORKTREES_DIR)/${name} + @echo "Worktree '${name}' removed." + @echo "Note: branch '${name}' still exists. Delete it with: git branch -D ${name}" +.PHONY: rm-wt + +ls-wt: ##@Worktrees List all active git worktrees + @git worktree list +.PHONY: ls-wt + ########################################## #### Miscellaneous diff --git a/scripts/setup-worktree.sh b/scripts/setup-worktree.sh new file mode 100755 index 000000000..65113f3b2 --- /dev/null +++ b/scripts/setup-worktree.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# +# Setup a git worktree for Navidrome development. +# This script is called automatically by `make worktree` and by Claude Code's +# worktree isolation, but can also be run standalone: +# +# ./scripts/setup-worktree.sh <worktree-path> [--go-only] +# +# Options: +# --go-only Skip frontend (npm) setup. Useful for agents working only on Go code. +# +set -euo pipefail + +WORKTREE_PATH="${1:?Usage: $0 <worktree-path> [--go-only]}" +GO_ONLY="${2:-}" + +# Resolve the main worktree root (where the original repo lives) +MAIN_WORKTREE="$(git -C "$WORKTREE_PATH" worktree list --porcelain | head -1 | sed 's/^worktree //')" + +if [ ! -d "$WORKTREE_PATH" ]; then + echo "ERROR: Worktree path does not exist: $WORKTREE_PATH" + exit 1 +fi + +cd "$WORKTREE_PATH" + +echo "==> Setting up worktree at $WORKTREE_PATH" + +# 1. Download Go dependencies +echo "==> Downloading Go dependencies..." +go mod download + +# 2. Install frontend dependencies (unless --go-only) +if [ "$GO_ONLY" != "--go-only" ]; then + echo "==> Installing frontend dependencies..." + (cd ui && npm ci --prefer-offline --no-audit 2>/dev/null || npm ci) +else + echo "==> Skipping frontend setup (--go-only)" +fi + +# 3. Create required directories +mkdir -p data + +# 4. Copy navidrome.toml from main worktree if it exists and not already present +if [ ! -f navidrome.toml ] && [ -f "$MAIN_WORKTREE/navidrome.toml" ]; then + echo "==> Copying navidrome.toml from main worktree..." + cp "$MAIN_WORKTREE/navidrome.toml" navidrome.toml +fi + +# 5. Copy existing database from main worktree (already migrated and scanned) +# This is much faster than running migrations + a full scan from scratch. +if [ ! -f data/navidrome.db ] && [ -f "$MAIN_WORKTREE/data/navidrome.db" ]; then + echo "==> Copying database from main worktree (pre-migrated, pre-scanned)..." + cp "$MAIN_WORKTREE/data/navidrome.db" data/navidrome.db +fi + +echo "==> Worktree setup complete: $WORKTREE_PATH" From 31d94acfe7fbbe09759d91d409043bbb4c1004cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 18 Mar 2026 08:03:46 -0400 Subject: [PATCH 07/12] fix(scanner): widen WASM panic recovery to cover tag/property reading (#5223) * fix(scanner): widen WASM panic recovery to cover tag/property reading The panic recovery in gotaglib's extractMetadata was only inside openFile(), which covers taglib.OpenStream(). Panics from f.AllTags() and f.Properties() (e.g. readString crashes on malformed files) were uncaught, crashing the scanner subprocess with exit status 2. Move the recover() up to extractMetadata() so it covers the entire tag reading lifecycle, matching the CGO taglib wrapper's approach. Fixes #5220 * fix(scanner): use consistent log key "filePath" in panic recovery * fix(scanner): include stack trace in WASM panic recovery log Signed-off-by: Deluan <deluan@navidrome.org> --------- Signed-off-by: Deluan <deluan@navidrome.org> --- adapters/gotaglib/gotaglib.go | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/adapters/gotaglib/gotaglib.go b/adapters/gotaglib/gotaglib.go index 9b71cb462..7ea98a442 100644 --- a/adapters/gotaglib/gotaglib.go +++ b/adapters/gotaglib/gotaglib.go @@ -58,7 +58,20 @@ func (e extractor) Version() string { return "unknown" } -func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { +func (e extractor) extractMetadata(filePath string) (info *metadata.Info, err error) { + // Recover from panics in the WASM runtime that can occur during any taglib + // operation (opening, reading tags, or reading properties). This catches crashes + // from malformed files or WASM runtime issues (e.g., wazero mmap failures on + // hardened systems with MemoryDenyWriteExecute=true). + debug.SetPanicOnFault(true) + defer func() { + if r := recover(); r != nil { + log.Error("gotaglib: WASM runtime panic reading file. Skipping", "filePath", filePath, "panic", r) + debug.PrintStack() + err = fmt.Errorf("WASM runtime panic: %v", r) + } + }() + f, close, err := e.openFile(filePath) if err != nil { log.Warn("gotaglib: Error reading metadata from file. Skipping", "filePath", filePath, err) @@ -112,16 +125,6 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) { // openFile opens the file at filePath using the extractor's filesystem. // It returns a TagLib File handle and a cleanup function to close resources. func (e extractor) openFile(filePath string) (f *taglib.File, closeFunc func(), err error) { - // Recover from panics in the WASM runtime (e.g., wazero failing to mmap executable memory - // on hardened systems like NixOS with MemoryDenyWriteExecute=true) - debug.SetPanicOnFault(true) - defer func() { - if r := recover(); r != nil { - log.Error("WASM runtime panic: This may be caused by a hardened system that blocks executable memory mapping.", "file", filePath, "panic", r) - err = fmt.Errorf("WASM runtime panic (hardened system?): %v", r) - } - }() - // Open the file from the filesystem file, err := e.fs.Open(filePath) if err != nil { From 00b8fbd7894a0124973fc77404845a2d0f703e1e Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Wed, 18 Mar 2026 07:59:10 -0400 Subject: [PATCH 08/12] feat(artwork): add UIThumbnailSize constant and update cache warmer to pre-cache thumbnails Signed-off-by: Deluan <deluan@navidrome.org> --- consts/consts.go | 1 + core/artwork/cache_warmer.go | 17 +++++++++-------- core/artwork/cache_warmer_test.go | 31 ++++++++++++++++++++++++++++++- 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/consts/consts.go b/consts/consts.go index 9f4387ae6..38b277eab 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -71,6 +71,7 @@ const ( PlaceholderAlbumArt = "album-placeholder.webp" PlaceholderAvatar = "logo-192x192.png" UICoverArtSize = 300 + UIThumbnailSize = 80 DefaultUIVolume = 100 DefaultUISearchDebounceMs = 200 diff --git a/core/artwork/cache_warmer.go b/core/artwork/cache_warmer.go index f13820d00..83c98c806 100644 --- a/core/artwork/cache_warmer.go +++ b/core/artwork/cache_warmer.go @@ -142,14 +142,15 @@ func (a *cacheWarmer) doCacheImage(ctx context.Context, id model.ArtworkID) erro ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() - r, _, err := a.artwork.Get(ctx, id, consts.UICoverArtSize, true) - if err != nil { - return fmt.Errorf("caching id='%s': %w", id, err) - } - defer r.Close() - _, err = io.Copy(io.Discard, r) - if err != nil { - return err + for _, size := range []int{consts.UICoverArtSize, consts.UIThumbnailSize} { + r, _, err := a.artwork.Get(ctx, id, size, true) + if err != nil { + return fmt.Errorf("caching id='%s', size=%d: %w", id, size, err) + } + defer r.Close() + if _, err = io.Copy(io.Discard, r); err != nil { + return err + } } return nil } diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go index abf4f259a..47e187f41 100644 --- a/core/artwork/cache_warmer_test.go +++ b/core/artwork/cache_warmer_test.go @@ -6,11 +6,13 @@ import ( "fmt" "io" "strings" + "sync" "sync/atomic" "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/cache" . "github.com/onsi/ginkgo/v2" @@ -173,20 +175,47 @@ var _ = Describe("CacheWarmer", func() { return len(cw.buffer) }).Should(Equal(0)) }) + + It("pre-caches both UICoverArtSize and UIThumbnailSize", func() { + cw := NewCacheWarmer(aw, fc).(*cacheWarmer) + cw.PreCache(model.MustParseArtworkID("al-1")) + + Eventually(func() int { + cw.mutex.Lock() + defer cw.mutex.Unlock() + return len(cw.buffer) + }).Should(Equal(0)) + + sizes := aw.getCachedSizes() + Expect(sizes).To(ContainElements(consts.UICoverArtSize, consts.UIThumbnailSize)) + }) }) }) type mockArtwork struct { - err error + err error + mu sync.Mutex + cachedSizes []int } func (m *mockArtwork) Get(ctx context.Context, artID model.ArtworkID, size int, square bool) (io.ReadCloser, time.Time, error) { if m.err != nil { return nil, time.Time{}, m.err } + m.mu.Lock() + m.cachedSizes = append(m.cachedSizes, size) + m.mu.Unlock() return io.NopCloser(strings.NewReader("test")), time.Now(), nil } +func (m *mockArtwork) getCachedSizes() []int { + m.mu.Lock() + defer m.mu.Unlock() + result := make([]int, len(m.cachedSizes)) + copy(result, m.cachedSizes) + return result +} + func (m *mockArtwork) GetOrPlaceholder(ctx context.Context, id string, size int, square bool) (io.ReadCloser, time.Time, error) { return m.Get(ctx, model.ArtworkID{}, size, square) } From 3f7226d253cdde7bdd948f364acf61c64946bc20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 18 Mar 2026 12:39:03 -0400 Subject: [PATCH 09/12] fix(server): improve transcoding failure diagnostics and error responses (#5227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): capture ffmpeg stderr and warn on empty transcoded output When ffmpeg fails during transcoding (e.g., missing codec like libopus), the error was silently discarded because stderr was sent to io.Discard and the HTTP response returned 200 OK with a 0-byte body. - Capture ffmpeg stderr in a bounded buffer (4KB) and include it in the error message when the process exits with a non-zero status code - Log a warning when transcoded output is 0 bytes, guiding users to check codec support and enable Trace logging for details - Remove log level guard so transcoding errors are always logged, not just at Debug level Signed-off-by: Deluan <deluan@navidrome.org> * fix(server): return proper error responses for empty transcoded output Instead of returning HTTP 200 with 0-byte body when transcoding fails, return a Subsonic error response (for stream/download/getTranscodeStream) or HTTP 500 (for public shared streams). This gives clients a clear signal that the request failed rather than a misleading empty success. Signed-off-by: Deluan <deluan@navidrome.org> * test(e2e): add tests for empty transcoded stream error responses Add E2E tests verifying that stream and download endpoints return Subsonic error responses when transcoding produces empty output. Extend spyStreamer with SimulateEmptyStream and SimulateError fields to support failure injection in tests. Signed-off-by: Deluan <deluan@navidrome.org> * refactor(server): extract stream serving logic into Stream.Serve method Extract the duplicated non-seekable stream serving logic (header setup, estimateContentLength, HEAD draining, io.Copy with error/empty detection) from server/subsonic/stream.go and server/public/handle_streams.go into a single Stream.Serve method on core/stream. Both callers now delegate to it, eliminating ~30 lines of near-identical code. * fix(server): return 200 with empty body for stream/download on empty transcoded output Don't return a Subsonic error response when transcoding produces empty output on stream/download endpoints — just log the error and return 200 with an empty body. The getTranscodeStream and public share endpoints still return HTTP 500 for empty output. Stream.Serve now returns (int64, error) so callers can check the byte count. --------- Signed-off-by: Deluan <deluan@navidrome.org> --- core/ffmpeg/ffmpeg.go | 42 +++++++++++++++---- core/ffmpeg/ffmpeg_test.go | 40 ++++++++++++++++++ core/stream/media_streamer.go | 58 +++++++++++++++++++++++++-- server/e2e/e2e_suite_test.go | 17 ++++++-- server/e2e/subsonic_stream_test.go | 55 +++++++++++++++++++++++++ server/e2e/subsonic_transcode_test.go | 31 ++++++++++++++ server/public/handle_streams.go | 32 ++------------- server/subsonic/stream.go | 54 ++++--------------------- server/subsonic/transcode.go | 6 ++- 9 files changed, 244 insertions(+), 91 deletions(-) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 2b06250a1..33d6733c8 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -1,6 +1,7 @@ package ffmpeg import ( + "bytes" "context" "encoding/json" "errors" @@ -258,10 +259,11 @@ func (e *ffmpeg) start(ctx context.Context, args []string, input ...io.Reader) ( type ffCmd struct { *io.PipeReader - out *io.PipeWriter - args []string - cmd *exec.Cmd - input io.Reader // optional stdin source + out *io.PipeWriter + args []string + cmd *exec.Cmd + input io.Reader // optional stdin source + stderr *bytes.Buffer } func (j *ffCmd) start(ctx context.Context) error { @@ -270,10 +272,12 @@ func (j *ffCmd) start(ctx context.Context) error { if j.input != nil { cmd.Stdin = j.input } + j.stderr = &bytes.Buffer{} + stderrWriter := &limitedWriter{buf: j.stderr, limit: 4096} if log.IsGreaterOrEqualTo(log.LevelTrace) { - cmd.Stderr = os.Stderr + cmd.Stderr = io.MultiWriter(os.Stderr, stderrWriter) } else { - cmd.Stderr = io.Discard + cmd.Stderr = stderrWriter } j.cmd = cmd @@ -287,7 +291,11 @@ func (j *ffCmd) wait() { if err := j.cmd.Wait(); err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { - _ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())) + errMsg := fmt.Sprintf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode()) + if stderrOutput := strings.TrimSpace(j.stderr.String()); stderrOutput != "" { + errMsg += ": " + stderrOutput + } + _ = j.out.CloseWithError(errors.New(errMsg)) } else { _ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err)) } @@ -296,6 +304,26 @@ func (j *ffCmd) wait() { _ = j.out.Close() } +// limitedWriter wraps a bytes.Buffer and stops writing once the limit is reached. +// Writes that would exceed the limit are silently discarded to prevent unbounded memory usage. +type limitedWriter struct { + buf *bytes.Buffer + limit int +} + +func (w *limitedWriter) Write(p []byte) (int, error) { + n := len(p) + remaining := w.limit - w.buf.Len() + if remaining <= 0 { + return n, nil // Discard but report success to avoid breaking the writer + } + if len(p) > remaining { + p = p[:remaining] + } + w.buf.Write(p) + return n, nil // Always report full write to avoid ErrShortWrite from io.MultiWriter +} + // formatCodecMap maps target format to ffmpeg codec flag. var formatCodecMap = map[string]string{ "mp3": "libmp3lame", diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 23e419219..04663828f 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -604,6 +604,46 @@ var _ = Describe("ffmpeg", func() { }) }) + Context("stderr capture", func() { + BeforeEach(func() { + if runtime.GOOS == "windows" { + Skip("stderr capture tests use /bin/sh, skipping on Windows") + } + }) + + It("should include stderr in error when process fails", func() { + ff := &ffmpeg{} + ctx := GinkgoT().Context() + + // Directly call start() with a bash command that writes to stderr and fails + args := []string{"/bin/sh", "-c", "echo 'codec not found: libopus' >&2; exit 1"} + stream, err := ff.start(ctx, args) + Expect(err).ToNot(HaveOccurred()) + defer stream.Close() + + buf := make([]byte, 1024) + _, err = stream.Read(buf) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("codec not found: libopus")) + }) + + It("should not include stderr in error when process succeeds", func() { + ff := &ffmpeg{} + ctx := GinkgoT().Context() + + // Command that writes to stderr but exits successfully + args := []string{"/bin/sh", "-c", "echo 'warning: something' >&2; printf 'output'"} + stream, err := ff.start(ctx, args) + Expect(err).ToNot(HaveOccurred()) + defer stream.Close() + + buf := make([]byte, 1024) + n, err := stream.Read(buf) + Expect(err).ToNot(HaveOccurred()) + Expect(string(buf[:n])).To(Equal("output")) + }) + }) + Context("with mock process behavior", func() { var longRunningCmd string BeforeEach(func() { diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go index 062a13884..de03b4d2f 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -5,8 +5,9 @@ import ( "fmt" "io" "mime" + "net/http" "os" - "strings" + "strconv" "sync" "time" @@ -17,6 +18,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/utils/cache" + "github.com/navidrome/navidrome/utils/req" ) type MediaStreamer interface { @@ -51,6 +53,9 @@ func (j *streamJob) Key() string { return fmt.Sprintf("%s.%s.%d.%d.%d.%d.%s.%d", j.mf.ID, j.mf.UpdatedAt.Format(time.RFC3339Nano), j.bitRate, j.sampleRate, j.bitDepth, j.channels, j.format, j.offset) } +// NewStream creates a Stream for the given MediaFile and Request. It handles both raw streaming (no transcoding) +// and transcoded streaming based on the requested format and bitrate. It also logs detailed information about +// the streaming request and whether the transcoding result was served from cache or not. func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req Request) (*Stream, error) { var format string var bitRate int @@ -133,14 +138,59 @@ func (s *Stream) EstimatedContentLength() int { return int(s.mf.Duration * float32(s.bitRate) / 8 * 1024) } -// NewTestStream creates a Stream for testing purposes. -func NewTestStream(mf *model.MediaFile, format string, bitRate int) *Stream { +// Serve writes the stream to the HTTP response. For seekable streams it uses http.ServeContent +// (supporting range requests). For non-seekable streams it writes directly and logs any errors. +// Returns the number of bytes written and an error only when io.Copy fails with 0 bytes written +// (meaning the HTTP 200 status has not been flushed yet and the caller can still send an error response). +// Empty output (0 bytes, no error) is logged but not treated as an error. +func (s *Stream) Serve(ctx context.Context, w http.ResponseWriter, r *http.Request) (int64, error) { + if s.Seekable() { + http.ServeContent(w, r, s.Name(), s.ModTime(), s) + return -1, nil + } + + w.Header().Set("Accept-Ranges", "none") + w.Header().Set("Content-Type", s.ContentType()) + + if req.Params(r).BoolOr("estimateContentLength", false) { + length := strconv.Itoa(s.EstimatedContentLength()) + log.Trace(ctx, "Estimated content-length", "contentLength", length) + w.Header().Set("Content-Length", length) + } + + if r.Method == http.MethodHead { + go func() { _, _ = io.Copy(io.Discard, s) }() + return 0, nil + } + + id := s.mf.ID + c, err := io.Copy(w, s) + if err != nil { + log.Error(ctx, "Error sending transcoded file", "id", id, err) + if c == 0 { + w.Header().Del("Content-Length") + return 0, fmt.Errorf("sending transcoded file: %w", err) + } + return c, nil + } + if c == 0 { + log.Error(ctx, "Transcoding returned empty output, ffmpeg may have failed. "+ + "Check that ffmpeg supports the requested codec. Enable Trace logging for ffmpeg stderr details", + "id", id, "format", s.ContentType()) + } else { + log.Trace(ctx, "Success sending transcoded file", "id", id, "size", c) + } + return c, nil +} + +// NewStream creates a non-seekable Stream from the given components. +func NewStream(mf *model.MediaFile, format string, bitRate int, r io.ReadCloser) *Stream { return &Stream{ ctx: context.Background(), mf: mf, format: format, bitRate: bitRate, - ReadCloser: io.NopCloser(strings.NewReader("")), + ReadCloser: r, } } diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index cb851debf..262a5ed36 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "testing" "testing/fstest" "time" @@ -287,18 +288,28 @@ func (n noopArtwork) GetOrPlaceholder(_ context.Context, _ string, _ int, _ bool // spyStreamer captures the Request passed to NewStream for test assertions, // then returns a minimal fake Stream so the handler completes without error. type spyStreamer struct { - LastRequest stream.Request - LastMediaFile *model.MediaFile + LastRequest stream.Request + LastMediaFile *model.MediaFile + SimulateError error // When set, NewStream returns this error + SimulateEmptyStream bool // When true, returns a 0-byte stream (simulates ffmpeg producing no output) } func (s *spyStreamer) NewStream(_ context.Context, mf *model.MediaFile, req stream.Request) (*stream.Stream, error) { s.LastRequest = req s.LastMediaFile = mf + if s.SimulateError != nil { + return nil, s.SimulateError + } format := req.Format if format == "" || format == "raw" { format = mf.Suffix } - return stream.NewTestStream(mf, format, req.BitRate), nil + content := "fake audio data" + if s.SimulateEmptyStream { + content = "" + } + r := io.NopCloser(strings.NewReader(content)) + return stream.NewStream(mf, format, req.BitRate, r), nil } // noopFFmpeg implements ffmpeg.FFmpeg with no-op methods. diff --git a/server/e2e/subsonic_stream_test.go b/server/e2e/subsonic_stream_test.go index d144dc4eb..6a11c1740 100644 --- a/server/e2e/subsonic_stream_test.go +++ b/server/e2e/subsonic_stream_test.go @@ -1,9 +1,12 @@ package e2e import ( + "encoding/json" + "errors" "net/http" "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -124,4 +127,56 @@ var _ = Describe("stream.view (legacy streaming)", Ordered, func() { Expect(streamerSpy.LastRequest.Offset).To(Equal(30)) }) }) + + Describe("stream creation failure", func() { + BeforeEach(func() { + streamerSpy.SimulateError = errors.New("ffmpeg exited with non-zero status code: 1: Unknown encoder 'libopus'") + }) + AfterEach(func() { + streamerSpy.SimulateError = nil + }) + + It("returns a Subsonic error for stream endpoint", func() { + w := doRawReq("stream", "id", flacTrackID, "format", "opus") + Expect(w.Code).To(Equal(http.StatusOK)) // Subsonic errors are returned as 200 + + var wrapper responses.JsonWrapper + Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed()) + Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed)) + Expect(wrapper.Subsonic.Error).ToNot(BeNil()) + }) + + It("returns a Subsonic error for download endpoint", func() { + conf.Server.EnableDownloads = true + w := doRawReq("download", "id", flacTrackID, "format", "opus") + Expect(w.Code).To(Equal(http.StatusOK)) + + var wrapper responses.JsonWrapper + Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed()) + Expect(wrapper.Subsonic.Status).To(Equal(responses.StatusFailed)) + Expect(wrapper.Subsonic.Error).ToNot(BeNil()) + }) + }) + + Describe("empty transcoded output", func() { + BeforeEach(func() { + streamerSpy.SimulateEmptyStream = true + }) + AfterEach(func() { + streamerSpy.SimulateEmptyStream = false + }) + + It("returns 200 with empty body for stream endpoint", func() { + w := doRawReq("stream", "id", flacTrackID, "format", "opus") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Len()).To(Equal(0)) + }) + + It("returns 200 with empty body for download endpoint", func() { + conf.Server.EnableDownloads = true + w := doRawReq("download", "id", flacTrackID, "format", "opus") + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.Len()).To(Equal(0)) + }) + }) }) diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go index a9a180dc8..f134448df 100644 --- a/server/e2e/subsonic_transcode_test.go +++ b/server/e2e/subsonic_transcode_test.go @@ -1,6 +1,7 @@ package e2e import ( + "errors" "net/http" "time" @@ -602,6 +603,36 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { mf.UpdatedAt = originalUpdatedAt Expect(ds.MediaFile(ctx).Put(mf)).To(Succeed()) }) + + It("returns 500 when stream creation fails", func() { + // Get a valid decision token + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + token := resp.TranscodeDecision.TranscodeParams + Expect(token).ToNot(BeEmpty()) + + // Simulate streamer failure (e.g., ffmpeg missing codec) + streamerSpy.SimulateError = errors.New("ffmpeg exited with non-zero status code: 1: Unknown encoder 'libopus'") + defer func() { streamerSpy.SimulateError = nil }() + + w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + + It("returns 500 when transcoded stream is empty", func() { + // Get a valid decision token + resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") + Expect(resp.Status).To(Equal(responses.StatusOK)) + token := resp.TranscodeDecision.TranscodeParams + Expect(token).ToNot(BeEmpty()) + + // Simulate ffmpeg producing 0 bytes + streamerSpy.SimulateEmptyStream = true + defer func() { streamerSpy.SimulateEmptyStream = false }() + + w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) }) Describe("round-trip: decision then stream", func() { diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 6cdf8b44a..daa09c375 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -2,7 +2,6 @@ package public import ( "errors" - "io" "net/http" "strconv" @@ -54,34 +53,9 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32)) - if stream.Seekable() { - http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream) - } else { - // If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length - w.Header().Set("Accept-Ranges", "none") - w.Header().Set("Content-Type", stream.ContentType()) - - estimateContentLength := p.BoolOr("estimateContentLength", false) - - // if Client requests the estimated content-length, send it - if estimateContentLength { - length := strconv.Itoa(stream.EstimatedContentLength()) - log.Trace(ctx, "Estimated content-length", "contentLength", length) - w.Header().Set("Content-Length", length) - } - - if r.Method == http.MethodHead { - go func() { _, _ = io.Copy(io.Discard, stream) }() - } else { - c, err := io.Copy(w, stream) - if log.IsGreaterOrEqualTo(log.LevelDebug) { - if err != nil { - log.Error(ctx, "Error sending shared transcoded file", "id", info.id, err) - } else { - log.Trace(ctx, "Success sending shared transcode file", "id", info.id, "size", c) - } - } - } + n, err := stream.Serve(ctx, w, r) + if err != nil || n == 0 { + http.Error(w, "internal error", http.StatusInternalServerError) } } diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index ebebb97f1..b49af2b24 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -1,15 +1,12 @@ package subsonic import ( - "context" "fmt" - "io" "net/http" "strconv" "strings" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" @@ -17,38 +14,6 @@ import ( "github.com/navidrome/navidrome/utils/req" ) -func (api *Router) serveStream(ctx context.Context, w http.ResponseWriter, r *http.Request, stream *stream.Stream, id string) { - if stream.Seekable() { - http.ServeContent(w, r, stream.Name(), stream.ModTime(), stream) - } else { - // If the stream doesn't provide a size (i.e. is not seekable), we can't support ranges/content-length - w.Header().Set("Accept-Ranges", "none") - w.Header().Set("Content-Type", stream.ContentType()) - - estimateContentLength := req.Params(r).BoolOr("estimateContentLength", false) - - // if Client requests the estimated content-length, send it - if estimateContentLength { - length := strconv.Itoa(stream.EstimatedContentLength()) - log.Trace(ctx, "Estimated content-length", "contentLength", length) - w.Header().Set("Content-Length", length) - } - - if r.Method == http.MethodHead { - go func() { _, _ = io.Copy(io.Discard, stream) }() - } else { - c, err := io.Copy(w, stream) - if log.IsGreaterOrEqualTo(log.LevelDebug) { - if err != nil { - log.Error(ctx, "Error sending transcoded file", "id", id, err) - } else { - log.Trace(ctx, "Success sending transcode file", "id", id, "size", c) - } - } - } - } -} - func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { ctx := r.Context() p := req.Params(r) @@ -81,9 +46,8 @@ func (api *Router) Stream(w http.ResponseWriter, r *http.Request) (*responses.Su w.Header().Set("X-Content-Type-Options", "nosniff") w.Header().Set("X-Content-Duration", strconv.FormatFloat(float64(stream.Duration()), 'G', -1, 32)) - api.serveStream(ctx, w, r, stream, id) - - return nil, nil + _, err = stream.Serve(ctx, w, r) + return nil, err } func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { @@ -151,20 +115,18 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. disposition := fmt.Sprintf("attachment; filename=\"%s\"", stream.Name()) w.Header().Set("Content-Disposition", disposition) - api.serveStream(ctx, w, r, stream, id) - return nil, nil + _, err = stream.Serve(ctx, w, r) + return nil, err case *model.Album: setHeaders(v.Name) - err = api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w) + return nil, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w) case *model.Artist: setHeaders(v.Name) - err = api.archiver.ZipArtist(ctx, id, format, maxBitRate, w) + return nil, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w) case *model.Playlist: setHeaders(v.Name) - err = api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w) + return nil, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w) default: - err = model.ErrNotFound + return nil, model.ErrNotFound } - - return nil, err } diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 64e74d460..4e494b324 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -395,7 +395,9 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* w.Header().Set("X-Content-Type-Options", "nosniff") - api.serveStream(ctx, w, r, stream, mediaID) - + n, err := stream.Serve(ctx, w, r) + if err != nil || n == 0 { + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } return nil, nil } From ba8d4278900066aee27319998a0c549a6941572d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= <deluan@navidrome.org> Date: Wed, 18 Mar 2026 18:57:33 -0400 Subject: [PATCH 10/12] feat(ui): add cover art support for internet radio stations (#5229) * feat(artwork): add KindRadioArtwork and EntityRadio constant * feat(model): add UploadedImage field and artwork methods to Radio * feat(model): add Radio to GetEntityByID lookup chain * feat(db): add uploaded_image column to radio table * feat(artwork): add radio artwork reader with uploaded image fallback * feat(api): add radio image upload/delete endpoints * feat(ui): add radio artwork ID prefix to getCoverArtUrl * feat(ui): add cover art display and upload to RadioEdit * feat(ui): add cover art thumbnails to radio list * feat(ui): prefer artwork URL in radio player helper * refactor: remove redundant code in radio artwork - Remove duplicate Avatar rendering in RadioList by reusing CoverArtField - Remove redundant UpdatedAt assignment in radio image handlers (already set by repository Put) * refactor(ui): extract shared useImageLoadingState hook Move image loading/error/lightbox state management into a shared useImageLoadingState hook in common/. Consolidates duplicated logic from AlbumDetails, PlaylistDetails, RadioEdit, and artist detail views. * feat(ui): use radio placeholder icon when no uploaded image Remove album placeholder fallback from radio artwork reader so radios without an uploaded image return ErrUnavailable. On the frontend, show the internet-radio-icon.svg placeholder instead of requesting server artwork when no image is uploaded, allowing favicon fallback in the player. * refactor(ui): update defaultOff fields in useSelectedFields for RadioList Signed-off-by: Deluan <deluan@navidrome.org> * fix: address code review feedback - Add missing alt attribute to CardMedia in RadioEdit for accessibility - Fix UpdateInternetRadio to preserve UploadedImage field by fetching existing radio before updating (prevents Subsonic API from clearing custom artwork) - Add Reader() level tests to verify ErrUnavailable is returned when radio has no uploaded image * refactor: add colsToUpdate to RadioRepository.Put Use the base sqlRepository.put with column filtering instead of hand-rolled SQL. UpdateInternetRadio now specifies only the Subsonic API fields, preventing UploadedImage from being cleared. Image upload/delete handlers specify only UploadedImage. * fix: ensure UpdatedAt is included in colsToUpdate for radio Put --------- Signed-off-by: Deluan <deluan@navidrome.org> --- consts/consts.go | 1 + core/artwork/artwork.go | 2 + core/artwork/reader_radio.go | 40 +++++++++ core/artwork/reader_radio_test.go | 84 +++++++++++++++++++ ...20260318182414_add_radio_uploaded_image.go | 22 +++++ model/artwork_id.go | 10 +++ model/get_entity.go | 4 + model/radio.go | 29 +++++-- model/radio_test.go | 42 ++++++++++ persistence/radio_repository.go | 24 ++---- server/nativeapi/native_api.go | 2 +- server/nativeapi/radios.go | 70 ++++++++++++++++ server/subsonic/radio.go | 2 +- tests/mock_radio_repository.go | 2 +- ui/src/album/AlbumDetails.jsx | 39 +++------ ui/src/artist/DesktopArtistDetails.jsx | 10 ++- ui/src/artist/MobileArtistDetails.jsx | 10 ++- ui/src/common/index.js | 1 + .../useImageLoadingState.js} | 10 +-- ui/src/consts.js | 2 + ui/src/playlist/PlaylistDetails.jsx | 38 +++------ ui/src/radio/RadioEdit.jsx | 67 ++++++++++++++- ui/src/radio/RadioList.jsx | 21 ++++- ui/src/radio/helper.jsx | 24 ++++-- ui/src/subsonic/index.js | 3 + 25 files changed, 450 insertions(+), 109 deletions(-) create mode 100644 core/artwork/reader_radio.go create mode 100644 core/artwork/reader_radio_test.go create mode 100644 db/migrations/20260318182414_add_radio_uploaded_image.go create mode 100644 model/radio_test.go create mode 100644 server/nativeapi/radios.go rename ui/src/{artist/useArtistImageState.js => common/useImageLoadingState.js} (76%) diff --git a/consts/consts.go b/consts/consts.go index 38b277eab..6fb6c5dac 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -108,6 +108,7 @@ const ( const ( EntityArtist = "artist" EntityPlaylist = "playlist" + EntityRadio = "radio" ) const ( diff --git a/core/artwork/artwork.go b/core/artwork/artwork.go index 4a0a32afc..b8c395c12 100644 --- a/core/artwork/artwork.go +++ b/core/artwork/artwork.go @@ -124,6 +124,8 @@ func (a *artwork) getArtworkReader(ctx context.Context, artID model.ArtworkID, s artReader, err = newPlaylistArtworkReader(ctx, a, artID) case model.KindDiscArtwork: artReader, err = newDiscArtworkReader(ctx, a, artID) + case model.KindRadioArtwork: + artReader, err = newRadioArtworkReader(ctx, a, artID) default: return nil, ErrUnavailable } diff --git a/core/artwork/reader_radio.go b/core/artwork/reader_radio.go new file mode 100644 index 000000000..22db6e302 --- /dev/null +++ b/core/artwork/reader_radio.go @@ -0,0 +1,40 @@ +package artwork + +import ( + "context" + "io" + "time" + + "github.com/navidrome/navidrome/model" +) + +type radioArtworkReader struct { + cacheKey + a *artwork + radio model.Radio +} + +func newRadioArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*radioArtworkReader, error) { + r, err := artwork.ds.Radio(ctx).Get(artID.ID) + if err != nil { + return nil, err + } + a := &radioArtworkReader{a: artwork, radio: *r} + a.cacheKey.artID = artID + a.cacheKey.lastUpdate = r.UpdatedAt + return a, nil +} + +func (a *radioArtworkReader) LastUpdated() time.Time { + return a.lastUpdate +} + +func (a *radioArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { + return selectImageReader(ctx, a.artID, + a.fromRadioUploadedImage(), + ) +} + +func (a *radioArtworkReader) fromRadioUploadedImage() sourceFunc { + return fromLocalFile(a.radio.UploadedImagePath()) +} diff --git a/core/artwork/reader_radio_test.go b/core/artwork/reader_radio_test.go new file mode 100644 index 000000000..1f5bc9084 --- /dev/null +++ b/core/artwork/reader_radio_test.go @@ -0,0 +1,84 @@ +package artwork + +import ( + "context" + "os" + "path/filepath" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("radioArtworkReader", func() { + var ( + tempDir string + reader *radioArtworkReader + ) + + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + tempDir = GinkgoT().TempDir() + conf.Server.DataFolder = tempDir + + Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed()) + + reader = &radioArtworkReader{} + }) + + Describe("fromRadioUploadedImage", func() { + When("radio has an uploaded image", func() { + It("returns the uploaded image", func() { + imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) + + reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} + sf := reader.fromRadioUploadedImage() + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + Expect(path).To(Equal(imgPath)) + r.Close() + }) + }) + + When("radio has no uploaded image", func() { + It("returns nil reader (falls through)", func() { + reader.radio = model.Radio{ID: "rd-1"} + sf := reader.fromRadioUploadedImage() + r, path, err := sf() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + Expect(path).To(BeEmpty()) + }) + }) + }) + + Describe("Reader", func() { + When("radio has an uploaded image", func() { + It("returns the image reader", func() { + imgPath := filepath.Join(tempDir, "artwork", "radio", "rd-1_test.jpg") + Expect(os.WriteFile(imgPath, []byte("uploaded radio image"), 0600)).To(Succeed()) + + reader.radio = model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} + reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"} + r, _, err := reader.Reader(context.Background()) + Expect(err).ToNot(HaveOccurred()) + Expect(r).ToNot(BeNil()) + r.Close() + }) + }) + + When("radio has no uploaded image", func() { + It("returns ErrUnavailable", func() { + reader.radio = model.Radio{ID: "rd-1"} + reader.cacheKey.artID = model.ArtworkID{Kind: model.KindRadioArtwork, ID: "rd-1"} + r, _, err := reader.Reader(context.Background()) + Expect(err).To(MatchError(ErrUnavailable)) + Expect(r).To(BeNil()) + }) + }) + }) +}) diff --git a/db/migrations/20260318182414_add_radio_uploaded_image.go b/db/migrations/20260318182414_add_radio_uploaded_image.go new file mode 100644 index 000000000..e92a6d2ef --- /dev/null +++ b/db/migrations/20260318182414_add_radio_uploaded_image.go @@ -0,0 +1,22 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upAddRadioUploadedImage, downAddRadioUploadedImage) +} + +func upAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, `ALTER TABLE radio ADD COLUMN uploaded_image VARCHAR(255) NOT NULL DEFAULT ''`) + return err +} + +func downAddRadioUploadedImage(ctx context.Context, tx *sql.Tx) error { + // This code is executed when the migration is rolled back. + return nil +} diff --git a/model/artwork_id.go b/model/artwork_id.go index 8d935f427..1bd146c1f 100644 --- a/model/artwork_id.go +++ b/model/artwork_id.go @@ -23,6 +23,7 @@ var ( KindAlbumArtwork = Kind{"al", "album"} KindPlaylistArtwork = Kind{"pl", "playlist"} KindDiscArtwork = Kind{"dc", "disc"} + KindRadioArtwork = Kind{"ra", "radio"} ) var artworkKindMap = map[string]Kind{ @@ -31,6 +32,7 @@ var artworkKindMap = map[string]Kind{ KindAlbumArtwork.prefix: KindAlbumArtwork, KindPlaylistArtwork.prefix: KindPlaylistArtwork, KindDiscArtwork.prefix: KindDiscArtwork, + KindRadioArtwork.prefix: KindRadioArtwork, } type ArtworkID struct { @@ -139,3 +141,11 @@ func artworkIDFromArtist(ar Artist) ArtworkID { ID: ar.ID, } } + +func artworkIDFromRadio(r Radio) ArtworkID { + return ArtworkID{ + Kind: KindRadioArtwork, + ID: r.ID, + LastUpdate: r.UpdatedAt, + } +} diff --git a/model/get_entity.go b/model/get_entity.go index 26f718396..60972b2e9 100644 --- a/model/get_entity.go +++ b/model/get_entity.go @@ -22,5 +22,9 @@ func GetEntityByID(ctx context.Context, ds DataStore, id string) (any, error) { if err == nil { return mf, nil } + r, err := ds.Radio(ctx).Get(id) + if err == nil { + return r, nil + } return nil, err } diff --git a/model/radio.go b/model/radio.go index 567d32e44..86f27c24c 100644 --- a/model/radio.go +++ b/model/radio.go @@ -1,14 +1,27 @@ package model -import "time" +import ( + "time" + + "github.com/navidrome/navidrome/consts" +) type Radio struct { - ID string `structs:"id" json:"id"` - StreamUrl string `structs:"stream_url" json:"streamUrl"` - Name string `structs:"name" json:"name"` - HomePageUrl string `structs:"home_page_url" json:"homePageUrl"` - CreatedAt time.Time `structs:"created_at" json:"createdAt"` - UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` + ID string `structs:"id" json:"id"` + StreamUrl string `structs:"stream_url" json:"streamUrl"` + Name string `structs:"name" json:"name"` + HomePageUrl string `structs:"home_page_url" json:"homePageUrl"` + UploadedImage string `structs:"uploaded_image" json:"uploadedImage,omitempty"` + CreatedAt time.Time `structs:"created_at" json:"createdAt"` + UpdatedAt time.Time `structs:"updated_at" json:"updatedAt"` +} + +func (r Radio) CoverArtID() ArtworkID { + return artworkIDFromRadio(r) +} + +func (r Radio) UploadedImagePath() string { + return UploadedImagePath(consts.EntityRadio, r.UploadedImage) } type Radios []Radio @@ -19,5 +32,5 @@ type RadioRepository interface { Delete(id string) error Get(id string) (*Radio, error) GetAll(options ...QueryOptions) (Radios, error) - Put(u *Radio) error + Put(u *Radio, colsToUpdate ...string) error } diff --git a/model/radio_test.go b/model/radio_test.go new file mode 100644 index 000000000..dc421454e --- /dev/null +++ b/model/radio_test.go @@ -0,0 +1,42 @@ +package model_test + +import ( + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Radio", func() { + Describe("CoverArtID", func() { + It("returns a radio artwork ID", func() { + now := time.Now() + r := model.Radio{ID: "rd-1", UpdatedAt: now} + artID := r.CoverArtID() + Expect(artID.Kind).To(Equal(model.KindRadioArtwork)) + Expect(artID.ID).To(Equal("rd-1")) + Expect(artID.LastUpdate).To(Equal(now)) + }) + }) + + Describe("UploadedImagePath", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DataFolder = "/data" + }) + + It("returns empty string when no image uploaded", func() { + r := model.Radio{ID: "rd-1"} + Expect(r.UploadedImagePath()).To(BeEmpty()) + }) + + It("returns full path when image is set", func() { + r := model.Radio{ID: "rd-1", UploadedImage: "rd-1_test.jpg"} + Expect(r.UploadedImagePath()).To(Equal(filepath.Join("/data", "artwork", "radio", "rd-1_test.jpg"))) + }) + }) +}) diff --git a/persistence/radio_repository.go b/persistence/radio_repository.go index 543b76c5e..a073643db 100644 --- a/persistence/radio_repository.go +++ b/persistence/radio_repository.go @@ -58,34 +58,20 @@ func (r *radioRepository) GetAll(options ...model.QueryOptions) (model.Radios, e return res, err } -func (r *radioRepository) Put(radio *model.Radio) error { +func (r *radioRepository) Put(radio *model.Radio, colsToUpdate ...string) error { if !r.isPermitted() { return rest.ErrPermissionDenied } - var values map[string]any - radio.UpdatedAt = time.Now() - if radio.ID == "" { radio.CreatedAt = time.Now() radio.ID = id.NewRandom() - values, _ = toSQLArgs(*radio) - } else { - values, _ = toSQLArgs(*radio) - update := Update(r.tableName).Where(Eq{"id": radio.ID}).SetMap(values) - count, err := r.executeSQL(update) - - if err != nil { - return err - } else if count > 0 { - return nil - } } - - values["created_at"] = time.Now() - insert := Insert(r.tableName).SetMap(values) - _, err := r.executeSQL(insert) + if len(colsToUpdate) > 0 { + colsToUpdate = append(colsToUpdate, "UpdatedAt") + } + _, err := r.put(radio.ID, radio, colsToUpdate...) return err } diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go index 3ef00ebb1..669c4d7b5 100644 --- a/server/nativeapi/native_api.go +++ b/server/nativeapi/native_api.go @@ -71,7 +71,7 @@ func (api *Router) routes() http.Handler { api.R(r, "/genre", model.Genre{}, false) api.R(r, "/player", model.Player{}, true) api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig) - api.R(r, "/radio", model.Radio{}, true) + api.addRadioRoute(r) api.R(r, "/tag", model.Tag{}, true) if conf.Server.EnableSharing { api.RX(r, "/share", api.share.NewRepository, true) diff --git a/server/nativeapi/radios.go b/server/nativeapi/radios.go new file mode 100644 index 000000000..701c6c926 --- /dev/null +++ b/server/nativeapi/radios.go @@ -0,0 +1,70 @@ +package nativeapi + +import ( + "context" + "errors" + "io" + "net/http" + + "github.com/deluan/rest" + "github.com/go-chi/chi/v5" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server" +) + +func (api *Router) addRadioRoute(r chi.Router) { + constructor := func(ctx context.Context) rest.Repository { + return api.ds.Resource(ctx, model.Radio{}) + } + r.Route("/radio", func(r chi.Router) { + r.Get("/", rest.GetAll(constructor)) + r.Post("/", rest.Post(constructor)) + r.Route("/{id}", func(r chi.Router) { + r.Use(server.URLParamsMiddleware) + r.Get("/", rest.Get(constructor)) + r.Put("/", rest.Put(constructor)) + r.Delete("/", rest.Delete(constructor)) + r.Post("/image", api.uploadRadioImage()) + r.Delete("/image", api.deleteRadioImage()) + }) + }) +} + +func (api *Router) uploadRadioImage() http.HandlerFunc { + return handleImageUpload(func(ctx context.Context, reader io.Reader, ext string) error { + radioID := chi.URLParamFromCtx(ctx, "id") + radio, err := api.ds.Radio(ctx).Get(radioID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + oldPath := radio.UploadedImagePath() + filename, err := api.imgUpload.SetImage(ctx, consts.EntityRadio, radio.ID, radio.Name, oldPath, reader, ext) + if err != nil { + return err + } + radio.UploadedImage = filename + return api.ds.Radio(ctx).Put(radio, "UploadedImage") + }) +} + +func (api *Router) deleteRadioImage() http.HandlerFunc { + return handleImageDelete(func(ctx context.Context) error { + radioID := chi.URLParamFromCtx(ctx, "id") + radio, err := api.ds.Radio(ctx).Get(radioID) + if err != nil { + if errors.Is(err, model.ErrNotFound) { + return model.ErrNotFound + } + return err + } + if err := api.imgUpload.RemoveImage(ctx, radio.UploadedImagePath()); err != nil { + return err + } + radio.UploadedImage = "" + return api.ds.Radio(ctx).Put(radio, "UploadedImage") + }) +} diff --git a/server/subsonic/radio.go b/server/subsonic/radio.go index 9f2cd48f6..c66268344 100644 --- a/server/subsonic/radio.go +++ b/server/subsonic/radio.go @@ -103,7 +103,7 @@ func (api *Router) UpdateInternetRadio(r *http.Request) (*responses.Subsonic, er Name: name, } - err = api.ds.Radio(ctx).Put(radio) + err = api.ds.Radio(ctx).Put(radio, "StreamUrl", "HomePageUrl", "Name") if err != nil { return nil, err } diff --git a/tests/mock_radio_repository.go b/tests/mock_radio_repository.go index 279b735db..c50a529e5 100644 --- a/tests/mock_radio_repository.go +++ b/tests/mock_radio_repository.go @@ -73,7 +73,7 @@ func (m *MockedRadioRepo) GetAll(qo ...model.QueryOptions) (model.Radios, error) return m.All, nil } -func (m *MockedRadioRepo) Put(radio *model.Radio) error { +func (m *MockedRadioRepo) Put(radio *model.Radio, _ ...string) error { if m.Err { return errors.New("error") } diff --git a/ui/src/album/AlbumDetails.jsx b/ui/src/album/AlbumDetails.jsx index bd6a41523..c5d9a7ac4 100644 --- a/ui/src/album/AlbumDetails.jsx +++ b/ui/src/album/AlbumDetails.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useEffect, useState } from 'react' import { Card, CardContent, @@ -29,6 +29,7 @@ import { RatingField, SizeField, useAlbumsPerPage, + useImageLoadingState, } from '../common' import config from '../config' import { formatFullDate, intersperse } from '../utils' @@ -220,11 +221,17 @@ const AlbumDetails = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) const classes = useStyles() - const [isLightboxOpen, setLightboxOpen] = useState(false) const [expanded, setExpanded] = useState(false) const [albumInfo, setAlbumInfo] = useState() - const [imageLoading, setImageLoading] = useState(false) - const [imageError, setImageError] = useState(false) + const { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } = useImageLoadingState(record.id) let notes = albumInfo?.notes || record.notes @@ -247,33 +254,9 @@ const AlbumDetails = (props) => { }) }, [record]) - // Reset image state when album changes - useEffect(() => { - setImageLoading(true) - setImageError(false) - }, [record.id]) - const imageUrl = subsonic.getCoverArtUrl(record, 300) const fullImageUrl = subsonic.getCoverArtUrl(record) - const handleImageLoad = useCallback(() => { - setImageLoading(false) - setImageError(false) - }, []) - - const handleImageError = useCallback(() => { - setImageLoading(false) - setImageError(true) - }, []) - - const handleOpenLightbox = useCallback(() => { - if (!imageError) { - setLightboxOpen(true) - } - }, [imageError]) - - const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) - return ( <Card className={classes.root}> <div className={classes.cardContents}> diff --git a/ui/src/artist/DesktopArtistDetails.jsx b/ui/src/artist/DesktopArtistDetails.jsx index da8d06014..7052b1634 100644 --- a/ui/src/artist/DesktopArtistDetails.jsx +++ b/ui/src/artist/DesktopArtistDetails.jsx @@ -6,13 +6,17 @@ import CardContent from '@material-ui/core/CardContent' import CardMedia from '@material-ui/core/CardMedia' import ArtistExternalLinks from './ArtistExternalLink' import config from '../config' -import { LoveButton, RatingField, ImageUploadOverlay } from '../common' +import { + LoveButton, + RatingField, + ImageUploadOverlay, + useImageLoadingState, +} from '../common' import Lightbox from 'react-image-lightbox' import ExpandInfoDialog from '../dialogs/ExpandInfoDialog' import AlbumInfo from '../album/AlbumInfo' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' -import useArtistImageState from './useArtistImageState' const useStyles = makeStyles( (theme) => ({ @@ -95,7 +99,7 @@ const DesktopArtistDetails = ({ artistInfo, record, biography }) => { handleImageError, handleOpenLightbox, handleCloseLightbox, - } = useArtistImageState(record.id) + } = useImageLoadingState(record.id) return ( <div className={classes.root}> diff --git a/ui/src/artist/MobileArtistDetails.jsx b/ui/src/artist/MobileArtistDetails.jsx index 3add1e994..03cc4de8f 100644 --- a/ui/src/artist/MobileArtistDetails.jsx +++ b/ui/src/artist/MobileArtistDetails.jsx @@ -4,11 +4,15 @@ import { makeStyles } from '@material-ui/core/styles' import Card from '@material-ui/core/Card' import CardMedia from '@material-ui/core/CardMedia' import config from '../config' -import { LoveButton, RatingField, ImageUploadOverlay } from '../common' +import { + LoveButton, + RatingField, + ImageUploadOverlay, + useImageLoadingState, +} from '../common' import Lightbox from 'react-image-lightbox' import subsonic from '../subsonic' import { SafeHTML } from '../common/SafeHTML' -import useArtistImageState from './useArtistImageState' const useStyles = makeStyles( (theme) => ({ @@ -97,7 +101,7 @@ const MobileArtistDetails = ({ artistInfo, biography, record }) => { handleImageError, handleOpenLightbox, handleCloseLightbox, - } = useArtistImageState(record.id) + } = useImageLoadingState(record.id) return ( <> diff --git a/ui/src/common/index.js b/ui/src/common/index.js index b93e40219..a7d6a43c4 100644 --- a/ui/src/common/index.js +++ b/ui/src/common/index.js @@ -45,3 +45,4 @@ export * from './OverflowTooltip' export * from './useSearchRefocus' export * from './ImageUploadOverlay' export * from './CoverArtAvatar' +export * from './useImageLoadingState' diff --git a/ui/src/artist/useArtistImageState.js b/ui/src/common/useImageLoadingState.js similarity index 76% rename from ui/src/artist/useArtistImageState.js rename to ui/src/common/useImageLoadingState.js index bd7e4ad96..3528b0f3f 100644 --- a/ui/src/artist/useArtistImageState.js +++ b/ui/src/common/useImageLoadingState.js @@ -1,11 +1,11 @@ import { useState, useEffect, useCallback } from 'react' /** - * Manages image loading/error state and lightbox open/close for artist detail views. - * Resets when record.id changes. + * Manages image loading/error state and lightbox open/close. + * Resets when recordId changes. */ -const useArtistImageState = (recordId) => { - const [imageLoading, setImageLoading] = useState(false) +export const useImageLoadingState = (recordId) => { + const [imageLoading, setImageLoading] = useState(true) const [imageError, setImageError] = useState(false) const [isLightboxOpen, setLightboxOpen] = useState(false) @@ -42,5 +42,3 @@ const useArtistImageState = (recordId) => { handleCloseLightbox, } } - -export default useArtistImageState diff --git a/ui/src/consts.js b/ui/src/consts.js index 30731a080..472cd4940 100644 --- a/ui/src/consts.js +++ b/ui/src/consts.js @@ -24,6 +24,8 @@ DraggableTypes.ALL.push( DraggableTypes.ARTIST, ) +export const RADIO_PLACEHOLDER_IMAGE = 'internet-radio-icon.svg' + export const DEFAULT_SHARE_BITRATE = 128 export const BITRATE_CHOICES = [ diff --git a/ui/src/playlist/PlaylistDetails.jsx b/ui/src/playlist/PlaylistDetails.jsx index a2d5e753b..911e6c716 100644 --- a/ui/src/playlist/PlaylistDetails.jsx +++ b/ui/src/playlist/PlaylistDetails.jsx @@ -7,7 +7,6 @@ import { } from '@material-ui/core' import { makeStyles } from '@material-ui/core/styles' import { useTranslate } from 'react-admin' -import { useCallback, useState, useEffect } from 'react' import Lightbox from 'react-image-lightbox' import 'react-image-lightbox/style.css' import { @@ -17,6 +16,7 @@ import { SizeField, isWritable, OverflowTooltip, + useImageLoadingState, } from '../common' import subsonic from '../subsonic' @@ -96,37 +96,19 @@ const PlaylistDetails = (props) => { const translate = useTranslate() const classes = useStyles() const isDesktop = useMediaQuery((theme) => theme.breakpoints.up('lg')) - const [isLightboxOpen, setLightboxOpen] = useState(false) - const [imageLoading, setImageLoading] = useState(false) - const [imageError, setImageError] = useState(false) + const { + imageLoading, + imageError, + isLightboxOpen, + handleImageLoad, + handleImageError, + handleOpenLightbox, + handleCloseLightbox, + } = useImageLoadingState(record.id) const imageUrl = subsonic.getCoverArtUrl(record, 300, true) const fullImageUrl = subsonic.getCoverArtUrl(record) - // Reset image state when playlist changes - useEffect(() => { - setImageLoading(true) - setImageError(false) - }, [record.id]) - - const handleImageLoad = useCallback(() => { - setImageLoading(false) - setImageError(false) - }, []) - - const handleImageError = useCallback(() => { - setImageLoading(false) - setImageError(true) - }, []) - - const handleOpenLightbox = useCallback(() => { - if (!imageError) { - setLightboxOpen(true) - } - }, [imageError]) - - const handleCloseLightbox = useCallback(() => setLightboxOpen(false), []) - return ( <Card className={classes.root}> <div className={classes.cardContents}> diff --git a/ui/src/radio/RadioEdit.jsx b/ui/src/radio/RadioEdit.jsx index f00f889f3..6b1d2df79 100644 --- a/ui/src/radio/RadioEdit.jsx +++ b/ui/src/radio/RadioEdit.jsx @@ -6,8 +6,37 @@ import { TextInput, useTranslate, } from 'react-admin' +import { CardMedia } from '@material-ui/core' +import { makeStyles } from '@material-ui/core/styles' import { urlValidate } from '../utils/validations' -import { Title } from '../common' +import { Title, ImageUploadOverlay, useImageLoadingState } from '../common' +import subsonic from '../subsonic' +import { RADIO_PLACEHOLDER_IMAGE } from '../consts' + +const useStyles = makeStyles({ + coverParent: { + display: 'inline-flex', + position: 'relative', + width: '8rem', + height: '8rem', + marginBottom: '1em', + }, + cover: { + width: '8rem', + height: '8rem', + objectFit: 'cover', + cursor: 'pointer', + transition: 'opacity 0.3s ease-in-out', + }, + coverLoading: { + opacity: 0.5, + }, + placeholder: { + width: '8rem', + height: '8rem', + objectFit: 'contain', + }, +}) const RadioTitle = ({ record }) => { const translate = useTranslate() @@ -21,6 +50,7 @@ const RadioEdit = (props) => { return ( <Edit title={<RadioTitle />} {...props}> <SimpleForm variant="outlined" {...props}> + <RadioCoverArt /> <TextInput source="name" validate={[required()]} /> <TextInput type="url" @@ -41,4 +71,39 @@ const RadioEdit = (props) => { ) } +const RadioCoverArt = ({ record }) => { + const classes = useStyles() + const { imageLoading, handleImageLoad, handleImageError } = + useImageLoadingState(record?.id) + + if (!record) return null + + return ( + <div className={classes.coverParent}> + {record.uploadedImage ? ( + <CardMedia + component="img" + src={subsonic.getCoverArtUrl(record, 300, true)} + className={`${classes.cover} ${imageLoading ? classes.coverLoading : ''}`} + onLoad={handleImageLoad} + onError={handleImageError} + title={record.name} + alt={record.name} + /> + ) : ( + <img + src={RADIO_PLACEHOLDER_IMAGE} + className={classes.placeholder} + alt={record.name} + /> + )} + <ImageUploadOverlay + entityType="radio" + entityId={record.id} + hasUploadedImage={!!record.uploadedImage} + /> + </div> + ) +} + export default RadioEdit diff --git a/ui/src/radio/RadioList.jsx b/ui/src/radio/RadioList.jsx index 3d1adacc9..582fcaffc 100644 --- a/ui/src/radio/RadioList.jsx +++ b/ui/src/radio/RadioList.jsx @@ -1,4 +1,4 @@ -import { makeStyles, useMediaQuery } from '@material-ui/core' +import { Avatar, makeStyles, useMediaQuery } from '@material-ui/core' import React, { cloneElement } from 'react' import { CreateButton, @@ -16,9 +16,11 @@ import { } from 'react-admin' import { List } from '../common' import { ToggleFieldsMenu, useSelectedFields } from '../common' +import subsonic from '../subsonic' import { StreamField } from './StreamField' import { setTrack } from '../actions' import { songFromRadio } from './helper' +import { RADIO_PLACEHOLDER_IMAGE } from '../consts' import { useDispatch } from 'react-redux' const useStyles = makeStyles({ @@ -73,6 +75,19 @@ const RadioListActions = ({ ) } +const avatarStyle = { width: 40, height: 40 } + +const CoverArtField = ({ record }) => { + if (!record) return null + const src = record.uploadedImage + ? subsonic.getCoverArtUrl(record, 40, true) + : RADIO_PLACEHOLDER_IMAGE + return ( + <Avatar src={src} variant="rounded" style={avatarStyle} alt={record.name} /> + ) +} +CoverArtField.defaultProps = { label: '' } + const RadioList = ({ permissions, ...props }) => { const classes = useStyles() const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) @@ -80,6 +95,7 @@ const RadioList = ({ permissions, ...props }) => { const isAdmin = permissions === 'admin' const toggleableFields = { + coverArt: <CoverArtField source="id" sortable={false} />, name: <TextField source="name" />, homePageUrl: ( <UrlField @@ -97,7 +113,7 @@ const RadioList = ({ permissions, ...props }) => { const columns = useSelectedFields({ resource: 'radio', columns: toggleableFields, - defaultOff: ['createdAt'], + defaultOff: ['streamUrl', 'createdAt'], }) const handleRowClick = async (id, basePath, record) => { @@ -117,6 +133,7 @@ const RadioList = ({ permissions, ...props }) => { > {isXsmall ? ( <SimpleList + leftAvatar={(r) => <CoverArtField record={r} />} leftIcon={(r) => ( <StreamField record={r} diff --git a/ui/src/radio/helper.jsx b/ui/src/radio/helper.jsx index 57de244b9..4c313d3bc 100644 --- a/ui/src/radio/helper.jsx +++ b/ui/src/radio/helper.jsx @@ -1,16 +1,24 @@ +import subsonic from '../subsonic' +import { RADIO_PLACEHOLDER_IMAGE } from '../consts' + export async function songFromRadio(radio) { if (!radio) { return undefined } - let cover = 'internet-radio-icon.svg' - try { - const url = new URL(radio.homePageUrl ?? radio.streamUrl) - url.pathname = '/favicon.ico' - await resourceExists(url) - cover = url.toString() - } catch { - // ignore + let cover = RADIO_PLACEHOLDER_IMAGE + if (radio.uploadedImage) { + cover = subsonic.getCoverArtUrl(radio, 300, true) + } else { + // Try favicon as fallback + try { + const url = new URL(radio.homePageUrl ?? radio.streamUrl) + url.pathname = '/favicon.ico' + await resourceExists(url) + cover = url.toString() + } catch { + // No cover available + } } return { diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 65155e8f4..3579619aa 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -86,6 +86,9 @@ const getCoverArtUrl = (record, size, square) => { } else if (record.sync !== undefined) { // This is a playlist return baseUrl(url('getCoverArt', 'pl-' + record.id, options)) + } else if (record.streamUrl !== undefined) { + // This is a radio station + return baseUrl(url('getCoverArt', 'ra-' + record.id, options)) } else { return baseUrl(url('getCoverArt', 'ar-' + record.id, options)) } From f7b60c79527f4ddc10c960acb64d0975cb9ffdb8 Mon Sep 17 00:00:00 2001 From: Deluan <deluan@navidrome.org> Date: Thu, 19 Mar 2026 13:14:24 -0400 Subject: [PATCH 11/12] fix(tests): fix race condition in CacheWarmer pre-cache size test The test was checking that the buffer was drained before asserting on cached sizes, but the buffer is cleared before processBatch completes. Use Eventually on getCachedSizes() directly to properly wait for the artwork caching to finish. --- core/artwork/cache_warmer_test.go | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go index 47e187f41..6ddda00d6 100644 --- a/core/artwork/cache_warmer_test.go +++ b/core/artwork/cache_warmer_test.go @@ -180,14 +180,9 @@ var _ = Describe("CacheWarmer", func() { cw := NewCacheWarmer(aw, fc).(*cacheWarmer) cw.PreCache(model.MustParseArtworkID("al-1")) - Eventually(func() int { - cw.mutex.Lock() - defer cw.mutex.Unlock() - return len(cw.buffer) - }).Should(Equal(0)) - - sizes := aw.getCachedSizes() - Expect(sizes).To(ContainElements(consts.UICoverArtSize, consts.UIThumbnailSize)) + Eventually(func() []int { + return aw.getCachedSizes() + }).Should(ContainElements(consts.UICoverArtSize, consts.UIThumbnailSize)) }) }) }) From a4c289b28c76bac6352b9500e5d783bab5602e99 Mon Sep 17 00:00:00 2001 From: JRoshthen1 <37882816+JRoshthen1@users.noreply.github.com> Date: Thu, 19 Mar 2026 18:33:09 +0100 Subject: [PATCH 12/12] feat(ui): add Slovak language translation (#5231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(i18n): Add Slovak language translation Signed-off-by: jrosh <martin@jrosh.eu> * fix(i18n): Fix typos and add missing translations Signed-off-by: jrosh <martin@jrosh.eu> --------- Signed-off-by: jrosh <martin@jrosh.eu> Co-authored-by: Deluan Quintão <deluan@navidrome.org> --- resources/i18n/sk.json | 723 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 723 insertions(+) create mode 100644 resources/i18n/sk.json diff --git a/resources/i18n/sk.json b/resources/i18n/sk.json new file mode 100644 index 000000000..af5afade7 --- /dev/null +++ b/resources/i18n/sk.json @@ -0,0 +1,723 @@ +{ + "languageName": "Slovenčina", + "resources": { + "song": { + "name": "Skladba |||| Skladieb", + "fields": { + "albumArtist": "Interpret albumu", + "duration": "Dĺžka", + "trackNumber": "#", + "playCount": "Počet prehratí", + "title": "Názov", + "artist": "Interpret", + "composer": "Skladateľ", + "album": "Album", + "path": "Cesta k súboru", + "libraryName": "Knižnica", + "genre": "Žáner", + "compilation": "Kompilácia", + "year": "Rok", + "size": "Veľkosť súboru", + "updatedAt": "Nahrané", + "bitRate": "Prenosová rýchlosť", + "bitDepth": "Bitová hĺbka", + "sampleRate": "Vzorkovacia frekvencia", + "channels": "Kanály", + "disc": "Disk %{discNumber}", + "discSubtitle": "Podtitul disku", + "starred": "Obľúbené", + "comment": "Komentár", + "rating": "Hodnotenie", + "quality": "Kvalita", + "bpm": "BPM", + "playDate": "Naposledy prehraná skladba", + "createdAt": "Pridané", + "grouping": "Zoskupovanie", + "mood": "Nálada", + "participants": "Ďalší účastníci", + "tags": "Ďalšie značky", + "mappedTags": "Mapované značky", + "rawTags": "Nespracované značky", + "missing": "Chýbajúce" + }, + "actions": { + "addToQueue": "Prehrať neskôr", + "playNow": "Prehrať teraz", + "addToPlaylist": "Pridať do zoznamu skladieb", + "showInPlaylist": "Zobraziť v zozname skladieb", + "shuffleAll": "Zamiešať všetko", + "download": "Stiahnuť", + "playNext": "Prehrať ako ďalšie", + "info": "Získať informácie", + "instantMix": "Okamžitý mix" + } + }, + "album": { + "name": "Album |||| Albumy", + "fields": { + "albumArtist": "Interpret albumu", + "artist": "Interpret", + "duration": "Dĺžka", + "songCount": "Skladby", + "playCount": "Počet prehratí", + "size": "Veľkosť", + "name": "Názov", + "libraryName": "Knižnica", + "genre": "Žáner", + "compilation": "Kompilácia", + "year": "Rok", + "date": "Dátum záznamu", + "originalDate": "Pôvodné", + "releaseDate": "Vydané", + "releases": "Vydanie |||| Vydania", + "released": "Vydané", + "updatedAt": "Aktualizované", + "comment": "Komentár", + "rating": "Hodnotenie", + "createdAt": "Pridané", + "recordLabel": "Štítok", + "catalogNum": "Katalógové číslo", + "releaseType": "Typ vydania", + "grouping": "Zoskupovanie", + "media": "Médiá", + "mood": "Nálada", + "missing": "Chýbajúce" + }, + "actions": { + "playAll": "Prehrať", + "playNext": "Prehrať ako ďalšie", + "addToQueue": "Prehrať neskôr", + "share": "Zdieľať", + "shuffle": "Zamiešať", + "addToPlaylist": "Pridať do zoznamu skladieb", + "download": "Stiahnuť", + "info": "Získať informácie" + }, + "lists": { + "all": "Všetko", + "random": "Náhodné", + "recentlyAdded": "Nedávno pridané", + "recentlyPlayed": "Nedávno prehrané", + "mostPlayed": "Najviac prehrávané", + "starred": "Obľúbené", + "topRated": "Najlepšie hodnotené" + } + }, + "artist": { + "name": "Interpret |||| Interpreti", + "fields": { + "name": "Názov", + "albumCount": "Počet albumov", + "songCount": "Počet skladieb", + "size": "Veľkosť", + "playCount": "Prehrania", + "rating": "Hodnotenie", + "genre": "Žáner", + "role": "Rola", + "missing": "Chýbajúci" + }, + "roles": { + "albumartist": "Interpret albumu |||| Interpreti albumov", + "artist": "Interpret |||| Interpreti", + "composer": "Skladateľ |||| Skladatelia", + "conductor": "Dirigent |||| Dirigenti", + "lyricist": "Textár |||| Textári", + "arranger": "Aranžér |||| Aranžéri", + "producer": "Producent |||| Producenti", + "director": "Režisér |||| Režiséri", + "engineer": "Zvukový technik |||| Zvukoví technici", + "mixer": "Mixér |||| Mixéri", + "remixer": "Remixér |||| Remixéri", + "djmixer": "DJ Mixér |||| DJ Mixéri", + "performer": "Účinkujúci |||| Účinkujúci", + "maincredit": "Interpret albumu alebo interpret |||| Interpreti albumov alebo interpreti" + }, + "actions": { + "topSongs": "Najpopulárnejšie skladby", + "shuffle": "Zamiešať", + "radio": "Rádio" + } + }, + "user": { + "name": "Používateľ |||| Používatelia", + "fields": { + "userName": "Používateľské meno", + "isAdmin": "Správca", + "lastLoginAt": "Naposledy prihlásený", + "lastAccessAt": "Posledný Prístup", + "updatedAt": "Upravený", + "name": "Meno", + "password": "Heslo", + "createdAt": "Vytvorený", + "changePassword": "Zmeniť heslo?", + "currentPassword": "Súčastné heslo", + "newPassword": "Nové heslo", + "token": "Token", + "libraries": "Knižnice" + }, + "helperTexts": { + "name": "Zmena mena sa zobrazí až po ďalšom prihlásení", + "libraries": "Vyberte konkrétne knižnice pre tohto používateľa alebo nechajte pole prázdne, ak chcete použiť predvolené knižnice" + }, + "notifications": { + "created": "Používateľ vytvorený", + "updated": "Používateľ upravený", + "deleted": "Používateľ odstránený" + }, + "validation": { + "librariesRequired": "Pre používateľov bez administrátorských práv musí byť vybratá aspoň jedna knižnica" + }, + "message": { + "listenBrainzToken": "Vložte svoj používateľský ListenBrainz token.", + "clickHereForToken": "Kliknite sem pre získanie svojho tokenu", + "selectAllLibraries": "Vybrať všetky knižnice", + "adminAutoLibraries": "Administrátori majú automaticky prístup ku všetkým knižniciam" + } + }, + "player": { + "name": "Prehrávač |||| Prehrávače", + "fields": { + "name": "Názov", + "transcodingId": "ID transkódovania", + "maxBitRate": "Max. prenosová rýchlosť", + "client": "Klient", + "userName": "Používateľské meno", + "lastSeen": "Naposledy videný", + "reportRealPath": "Skutočná cesta hlásenia", + "scrobbleEnabled": "Odosielať scrobbling na externé služby" + } + }, + "transcoding": { + "name": "Transkódovanie |||| Transkódovania", + "fields": { + "name": "Názov", + "targetFormat": "Cieľový formát", + "defaultBitRate": "Predvolená prenosová rýchlosť", + "command": "Príkaz" + } + }, + "playlist": { + "name": "Zoznam skladieb |||| Zoznamy skladieb", + "fields": { + "name": "Názov", + "duration": "Dĺžka", + "ownerName": "Autor", + "public": "Verejný", + "updatedAt": "Nahraný", + "createdAt": "Vytvorený", + "songCount": "Skladby", + "comment": "Komentár", + "sync": "Auto-import", + "path": "Importovať z" + }, + "actions": { + "selectPlaylist": "Vybrať zoznam skladieb:", + "addNewPlaylist": "Vytvoriť \"%{name}\"", + "export": "Export", + "saveQueue": "Uložiť rad do zoznamu skladieb", + "makePublic": "Zverejniť", + "makePrivate": "Nastaviť ako súkromné", + "searchOrCreate": "Vyhľadajte zoznamy skladieb alebo napíšte pre vytvorenie nového...", + "pressEnterToCreate": "Stlačte Enter pre vytvorenie nového zoznamu skladieb", + "removeFromSelection": "Odstrániť z výberu" + }, + "message": { + "duplicate_song": "Pridať duplicitné položky", + "song_exist": "Pridávate duplikát už existujúcej položky v zozname skladieb. Chcete pridať duplikát alebo ho preskočiť?", + "noPlaylistsFound": "Žiadne zoznamy skladieb sa nenašli", + "noPlaylists": "Žiadne zoznamy skladieb nie sú dostupné" + } + }, + "radio": { + "name": "Rádio |||| Rádiá", + "fields": { + "name": "Názov", + "streamUrl": "URL streamu", + "homePageUrl": "URL stránky", + "updatedAt": "Nahrané", + "createdAt": "Vytvorené" + }, + "actions": { + "playNow": "Spustiť" + } + }, + "share": { + "name": "Zdieľanie |||| Zdieľania", + "fields": { + "username": "Zdieľané", + "url": "URL", + "description": "Popis", + "downloadable": "Povoliť sťahovanie?", + "contents": "Obsah", + "expiresAt": "Vyprší", + "lastVisitedAt": "Naposledy navštívené", + "visitCount": "Počet návštev", + "format": "Formát", + "maxBitRate": "Max. Bit Rate", + "updatedAt": "Nahrané", + "createdAt": "Vytvorené" + }, + "notifications": {}, + "actions": {} + }, + "missing": { + "name": "Chýbajúci súbor |||| Chýbajúce súbory", + "empty": "Žiadne chýbajúce súbory", + "fields": { + "path": "Cesta", + "size": "Veľkosť", + "libraryName": "Knižnica", + "updatedAt": "Zmizol dňa" + }, + "actions": { + "remove": "Odstrániť", + "remove_all": "Odstrániť všetky" + }, + "notifications": { + "removed": "Chýbajúce súbory odstránené" + } + }, + "library": { + "name": "Knižnica |||| Knižnice", + "fields": { + "name": "Názov", + "path": "Cesta", + "remotePath": "Vzdialená cesta", + "lastScanAt": "Posledný sken", + "songCount": "Skladby", + "albumCount": "Albumy", + "artistCount": "Interpreti", + "totalSongs": "Skladby", + "totalAlbums": "Albumy", + "totalArtists": "Interpreti", + "totalFolders": "Priečinky", + "totalFiles": "Súbory", + "totalMissingFiles": "Chýbajúce súbory", + "totalSize": "Celková veľkosť", + "totalDuration": "Dĺžka", + "defaultNewUsers": "Predvolené pre nových používateľov", + "createdAt": "Vytvorené", + "updatedAt": "Aktualizované" + }, + "sections": { + "basic": "Základné informácie", + "statistics": "Štatistiky" + }, + "actions": { + "scan": "Skenovať knižnicu", + "quickScan": "Rýchly sken", + "fullScan": "Úplný sken", + "manageUsers": "Spravovať prístup používateľov", + "viewDetails": "Zobraziť detaily" + }, + "notifications": { + "created": "Knižnica úspešne vytvorená", + "updated": "Knižnica úspešne aktualizovaná", + "deleted": "Knižnica úspešne odstránená", + "scanStarted": "Skenovanie knižnice spustené", + "quickScanStarted": "Rýchly sken spustený", + "fullScanStarted": "Úplný sken spustený", + "scanError": "Chyba pri spustení skenu. Skontrolujte logy", + "scanCompleted": "Skenovanie knižnice dokončené" + }, + "validation": { + "nameRequired": "Názov knižnice je povinný", + "pathRequired": "Cesta ku knižnici je povinná", + "pathNotDirectory": "Cesta ku knižnici musí byť priečinok", + "pathNotFound": "Cesta ku knižnici sa nenašla", + "pathNotAccessible": "Cesta ku knižnici nie je dostupná", + "pathInvalid": "Neplatná cesta ku knižnici" + }, + "messages": { + "deleteConfirm": "Ste si istý, že chcete odstrániť túto knižnicu? Tým sa odstránia všetky súvisiace dáta a prístupy používateľov.", + "scanInProgress": "Skenovanie prebieha...", + "noLibrariesAssigned": "Tomuto používateľovi nie sú priradené žiadne knižnice" + } + }, + "plugin": { + "name": "Plugin |||| Pluginy", + "fields": { + "id": "ID", + "name": "Názov", + "description": "Popis", + "version": "Verzia", + "author": "Autor", + "website": "Webová stránka", + "permissions": "Oprávnenia", + "enabled": "Povolený", + "status": "Stav", + "path": "Cesta", + "lastError": "Chyba", + "hasError": "Chyba", + "updatedAt": "Aktualizovaný", + "createdAt": "Nainštalovaný", + "configKey": "Kľúč", + "configValue": "Hodnota", + "allUsers": "Povoliť všetkých používateľov", + "selectedUsers": "Vybraní používatelia", + "allLibraries": "Povoliť všetky knižnice", + "selectedLibraries": "Vybrané knižnice", + "allowWriteAccess": "Povoliť prístup na zápis" + }, + "sections": { + "status": "Stav", + "info": "Informácie o plugine", + "configuration": "Konfigurácia", + "manifest": "Manifest", + "usersPermission": "Oprávnenia používateľov", + "libraryPermission": "Oprávnenia knižnice" + }, + "status": { + "enabled": "Povolený", + "disabled": "Zakázaný" + }, + "actions": { + "enable": "Povoliť", + "disable": "Zakázať", + "disabledDueToError": "Opravte chybu pred povolením", + "disabledUsersRequired": "Vyberte používateľov pred povolením", + "disabledLibrariesRequired": "Vyberte knižnice pred povolením", + "addConfig": "Pridať konfiguráciu", + "rescan": "Znovu skenovať" + }, + "notifications": { + "enabled": "Plugin povolený", + "disabled": "Plugin zakázaný", + "updated": "Plugin aktualizovaný", + "error": "Chyba pri aktualizácii pluginu" + }, + "validation": { + "invalidJson": "Konfigurácia musí byť platný JSON" + }, + "messages": { + "configHelp": "Nakonfigurujte plugin pomocou párov kľúč-hodnota. Nechajte prázdne, ak plugin nevyžaduje žiadnu konfiguráciu.", + "configValidationError": "Overenie konfigurácie zlyhalo:", + "schemaRenderError": "Nie je možné zobraziť konfiguračný formulár. Schéma pluginu môže byť neplatná.", + "clickPermissions": "Kliknite na oprávnenie pre detaily", + "noConfig": "Žiadna konfigurácia nastavená", + "allUsersHelp": "Keď je povolené, plugin bude mať prístup ku všetkým používateľom, vrátane tých vytvorených v budúcnosti.", + "noUsers": "Žiadni používatelia nevybraní", + "permissionReason": "Dôvod", + "usersRequired": "Tento plugin vyžaduje prístup k informáciám o používateľoch. Vyberte, ku ktorým používateľom má plugin prístup, alebo povolte 'Povoliť všetkých používateľov'.", + "allLibrariesHelp": "Keď je povolené, plugin bude mať prístup ku všetkým knižniciam, vrátane tých vytvorených v budúcnosti.", + "noLibraries": "Žiadne knižnice nevybrané", + "librariesRequired": "Tento plugin vyžaduje prístup k informáciám o knižniciach. Vyberte, ku ktorým knižniciam má plugin prístup, alebo povolte 'Povoliť všetky knižnice'.", + "allowWriteAccessHelp": "Keď je povolené, plugin môže upravovať súbory v adresároch knižníc. Predvolene majú pluginy prístup iba na čítanie.", + "requiredHosts": "Požadovaní hostitelia" + }, + "placeholders": { + "configKey": "kľúč", + "configValue": "hodnota" + } + } + }, + "ra": { + "auth": { + "welcome1": "Ďakujeme, že ste si nainštalovali Navidrome!", + "welcome2": "Najskôr vytvorte účet správcu", + "confirmPassword": "Potvrďte heslo", + "buttonCreateAdmin": "Vytvoriť správcu", + "auth_check_error": "Pre pokračovanie sa prosím prihláste", + "user_menu": "Profil", + "username": "Používateľské meno", + "password": "Heslo", + "sign_in": "Prihlásiť sa", + "sign_in_error": "Overenie zlyhalo, skúste to znova", + "logout": "Odhlásiť sa", + "insightsCollectionNote": "Navidrome zhromažďuje anonymné údaje\n o používaní, aby pomohol zlepšiť projekt.\nKliknite [sem] a dozviete sa viac a v prípade\npotreby sa odhláste." + }, + "validation": { + "invalidChars": "Prosím, používajte iba písmená a čísla", + "passwordDoesNotMatch": "Heslá sa nezhodujú", + "required": "Povinné pole", + "minLength": "Musí obsahovať najmenej %{min} znakov", + "maxLength": "Môže obsahovať maximálne %{max} znakov", + "minValue": "Musí byť aspoň %{min}", + "maxValue": "Môže byť maximálne %{max}", + "number": "Musí byť číslo", + "email": "Musí byť platná e-mailová adresa", + "oneOf": "Musí spĺňať jedno z: %{options}", + "regex": "Musí byť v špecifickom formáte (regexp): %{pattern}", + "unique": "Musí byť jedinečný", + "url": "Musí byť platná URL" + }, + "action": { + "add_filter": "Pridať filter", + "add": "Pridať", + "back": "Ísť späť", + "bulk_actions": "1 vybraná |||| %{smart_count} vybraných", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "cancel": "Zrušiť", + "clear_input_value": "Vymazať hodnotu", + "clone": "Klonovať", + "confirm": "Potvrdiť", + "create": "Vytvoriť", + "delete": "Vymazať", + "edit": "Upraviť", + "export": "Exportovať", + "list": "Zoznam", + "refresh": "Obnoviť", + "remove_filter": "Odstrániť filter", + "remove": "Odstrániť", + "save": "Uložiť", + "search": "Vyhľadať", + "show": "Zobraziť", + "sort": "Zoradiť", + "undo": "Vrátiť", + "expand": "Rozbaliť", + "close": "Zavrieť", + "open_menu": "Otvoriť ponuku", + "close_menu": "Zavrieť ponuku", + "unselect": "Zrušiť výber", + "skip": "Preskočiť", + "share": "Zdieľať", + "download": "Stiahnuť" + }, + "boolean": { + "true": "Áno", + "false": "Nie" + }, + "page": { + "create": "Vytvoriť %{name}", + "dashboard": "Dashboard", + "edit": "%{name} #%{id}", + "error": "Niečo sa pokazilo", + "list": "%{name}", + "loading": "Načítavanie", + "not_found": "Nenájdené", + "show": "%{name} #%{id}", + "empty": "Zatiaľ žiaden %{name}.", + "invite": "Chcete pridať nové?" + }, + "input": { + "file": { + "upload_several": "Presuňte súbory pre nahranie alebo kliknite pre výber.", + "upload_single": "Presuňte súbor pre nahranie alebo kliknite pre jeho výber." + }, + "image": { + "upload_several": "Presuňte obrázky pre nahranie alebo kliknite pre výber.", + "upload_single": "Presuňte obrázok pre nahranie alebo kliknite pre jeho výber." + }, + "references": { + "all_missing": "Referencované dáta sa nenašli.", + "many_missing": "Aspoň jedna z referencií už nie je dostupná.", + "single_missing": "Referencia sa zdá byť nedostupná." + }, + "password": { + "toggle_visible": "Skryť heslo", + "toggle_hidden": "Zobraziť heslo" + } + }, + "message": { + "about": "O Navidrome", + "are_you_sure": "Ste si istý?", + "bulk_delete_content": "Ste si istý, že chcete vymazať %{name}? |||| Ste si istý, že chcete vymazať týchto %{smart_count} položiek?", + "bulk_delete_title": "Vymazať %{name} |||| Vymazať %{smart_count} %{name} položiek", + "delete_content": "Ste si istý, že chcete vymazať túto položku?", + "delete_title": "Vymazať %{name} #%{id}", + "details": "Detaily", + "error": "Vyskytla sa chyba klienta a vaša požiadavka nemohla byť splnená.", + "invalid_form": "Formulár nie je platný. Prosím skontrolujte ho.", + "loading": "Stránka sa načítava, prosím počkajte", + "no": "Nie", + "not_found": "Zadali ste nesprávnu adresu URL, alebo ste nasledovali nesprávny odkaz.", + "yes": "Áno", + "unsaved_changes": "Niektoré vaše zmeny neboli uložené. Ste si istí, že ich chcete ignorovať?" + }, + "navigation": { + "no_results": "Nenašli sa žiadne výsledky", + "no_more_results": "Stránka číslo %{page} je mimo rozsah. Skúste predchádzajúcu.", + "page_out_of_boundaries": "Stránka číslo %{page} je mimo rozsah", + "page_out_from_end": "Nemožno ísť za poslednú stranu", + "page_out_from_begin": "Nemožno ísť pred prvú stranu", + "page_range_info": "%{offsetBegin}-%{offsetEnd} z %{total}", + "page_rows_per_page": "Položiek na stránke:", + "next": "Ďalší", + "prev": "Predchádzajúci", + "skip_nav": "Preskočiť na obsah" + }, + "notification": { + "updated": "Prvok aktualizovaný |||| %{smart_count} prvkov aktualizovaných", + "created": "Prvok vytvorený", + "deleted": "Prvok vymazaný |||| %{smart_count} prvkov vymazaných", + "bad_item": "Nesprávny prvok", + "item_doesnt_exist": "Prvok neexistuje", + "http_error": "Chyba komunikácie servera", + "data_provider_error": "Chyba dataProvideru. Detaily nájdete v konzole.", + "i18n_error": "Nemožno načítať preklady pre vybraný jazyk", + "canceled": "Akcia zrušená", + "logged_out": "Vaša relácia skončila, prosím pripojte sa znova.", + "new_version": "Je dostupná nová verzia! Prosím obnovte toto okno." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Stĺpce na zobrazenie", + "layout": "Rozloženie", + "grid": "Mriežka", + "table": "Tabuľka" + } + }, + "message": { + "uploadCover": "Nahrať obrázok obalu", + "removeCover": "Odstrániť obrázok obalu", + "coverUploaded": "Obrázok obalu albumu aktualizovaný", + "coverRemoved": "Obrázok obalu albumu odstránený", + "coverUploadError": "Chyba pri nahrávaní obrázku obalu albumu", + "coverRemoveError": "Chyba pri odstraňovaní obrázku obalu albumu", + "note": "POZNÁMKA", + "transcodingDisabled": "Zmena nastavení transkódovania je vo webovom prostredí vypnutá z bezpečnostných dôvodov. Ak chcete zmeniť (upraviť alebo pridať) možnosti transkódovania, reštartujte server s možnosťou %{config}.", + "transcodingEnabled": "Navidrome práve beží s možnosťou %{config}, ktorá umožňuje spúšťanie systémových príkazov z nastavení transkódovania pomocou webového rozhrania. Odporúčame ju vypnúť z bezpečnostných dôvodov a používať ju iba pri úprave nastavení transkódovania.", + "songsAddedToPlaylist": "1 skladba pridaná do zoznamu skladieb |||| %{smart_count} skladieb pridaných do zoznamu skladieb", + "noSimilarSongsFound": "Nenašli sa žiadne podobné skladby", + "startingInstantMix": "Načítava sa Instant Mix...", + "noTopSongsFound": "Nenašli sa žiadne top skladby", + "noPlaylistsAvailable": "Žiadne nie sú dostupné", + "delete_user_title": "Odstrániť používateľa '%{name}'", + "delete_user_content": "Ste si istí, že chcete odstrániť tohto používateľa a všetky jeho dáta (vrátane zoznamov skladieb a nastavení)?", + "remove_missing_title": "Odstráňte chýbajúce súbory", + "remove_missing_content": "Naozaj chcete odstrániť vybraté chýbajúce súbory z databázy? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", + "remove_all_missing_title": "Odstráňte všetky chýbajúce súbory", + "remove_all_missing_content": "Naozaj chcete z databázy odstrániť všetky chýbajúce súbory? Týmto sa natrvalo odstránia všetky odkazy na ne vrátane ich počtu prehratí a hodnotení.", + "notifications_blocked": "Zablokovali ste si oznámenia pre túto stránku v nastaveniach vášho prehliadača", + "notifications_not_available": "Tento prehliadač nepodporuje oznámenia na ploche alebo nepristupujete k Navidrome cez https", + "lastfmLinkSuccess": "Last.fm úspešne pripojené a scrobbling zapnutý", + "lastfmLinkFailure": "Last.fm sa nepodarilo pripojiť", + "lastfmUnlinkSuccess": "Last.fm odpojené a scrobbling vypnutý", + "lastfmUnlinkFailure": "Last.fm sa nepodarilo odpojiť", + "listenBrainzLinkSuccess": "ListenBrainz úspešne pripojený a scrobbling zapnutý ako používateľ: %{user}", + "listenBrainzLinkFailure": "ListenBrainz sa nepodarilo pripojiť: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz odpojený a scrobbling vypnutý", + "listenBrainzUnlinkFailure": "ListenBrainz sa nepodarilo odpojiť", + "openIn": { + "lastfm": "Otvoriť na Last.fm", + "musicbrainz": "Otvoriť na MusicBrainz" + }, + "lastfmLink": "Čítať ďalej...", + "shareOriginalFormat": "Zdieľať v pôvodnom formáte", + "shareDialogTitle": "Zdieľať %{resource} '%{name}'", + "shareBatchDialogTitle": "Zdieľať 1 %{resource} |||| Zdieľať %{smart_count} %{resource}", + "shareCopyToClipboard": "Skopírovať do schránky: Ctrl+C, Enter", + "shareSuccess": "URL skopírovaná do schránky: %{url}", + "shareFailure": "Chyba pri kopírovaní URL %{url} do schránky", + "downloadDialogTitle": "Stiahnuť %{resource} '%{name}' (%{size})", + "downloadOriginalFormat": "Stiahnuť v pôvodnom formáte" + }, + "menu": { + "library": "Knižnica", + "librarySelector": { + "allLibraries": "Všetky knižnice (%{count})", + "multipleLibraries": "%{selected} z %{total} knižníc", + "selectLibraries": "Vyberte knižnice", + "none": "Žiadne" + }, + "settings": "Nastavenia", + "version": "Verzia", + "theme": "Téma", + "personal": { + "name": "Osobné", + "options": { + "theme": "Téma", + "language": "Jazyk", + "defaultView": "Predvolená stránka", + "desktop_notifications": "Oznámenia na ploche", + "lastfmNotConfigured": "Kľúč API Last.fm nie je nakonfigurovaný", + "lastfmScrobbling": "Scrobblovať na Last.fm", + "listenBrainzScrobbling": "Scrobblovať na ListenBrainz", + "replaygain": "Mód ReplayGain", + "preAmp": "ReplayGain PreAmp (dB)", + "gain": { + "none": "Vypnuté", + "album": "Použiť Album Gain", + "track": "Použiť Track Gain" + } + } + }, + "albumList": "Albumy", + "playlists": "Zoznamy skladieb", + "sharedPlaylists": "Zdieľané zoznamy skladieb", + "about": "O Navidrome" + }, + "player": { + "playListsText": "Rad", + "openText": "Otvoriť", + "closeText": "Zavrieť", + "notContentText": "Žiadne skladby", + "clickToPlayText": "Kliknite pre prehranie", + "clickToPauseText": "Kliknite pre pozastavenie", + "nextTrackText": "Ďalšia skladba", + "previousTrackText": "Predchádzajúca skladba", + "reloadText": "Znovu načítať", + "volumeText": "Hlasitosť", + "toggleLyricText": "Prepnúť text", + "toggleMiniModeText": "Zmenšiť", + "destroyText": "Zničiť", + "downloadText": "Stiahnuť", + "removeAudioListsText": "Vymazať zoznam", + "clickToDeleteText": "Kliknite pre odstránenie %{name}", + "emptyLyricText": "Bez textu", + "playModeText": { + "order": "Po poradí", + "orderLoop": "Opakovať", + "singleLoop": "Opakovať raz", + "shufflePlay": "Zamiešať" + } + }, + "about": { + "links": { + "homepage": "Domovská stránka", + "source": "Zdrojový kód", + "featureRequests": "Požiadavky na funkcie", + "lastInsightsCollection": "Posledný zber štatistík", + "insights": { + "disabled": "Zakázané", + "waiting": "Čakanie" + } + }, + "tabs": { + "about": "O aplikácii", + "config": "Konfigurácia" + }, + "config": { + "configName": "Názov konfigurácie", + "environmentVariable": "Premenná prostredia", + "currentValue": "Aktuálna hodnota", + "configurationFile": "Konfiguračný súbor", + "exportToml": "Exportovať konfiguráciu (TOML)", + "downloadToml": "Stiahnuť konfiguráciu (TOML)", + "exportSuccess": "Konfigurácia exportovaná do schránky vo formáte TOML", + "exportFailed": "Nepodarilo sa skopírovať konfiguráciu", + "devFlagsHeader": "Vývojové príznaky (môžu byť zmenené/odstránené)", + "devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách" + } + }, + "activity": { + "title": "Aktivita", + "totalScanned": "Naskenované priečinky", + "quickScan": "Rýchly sken", + "fullScan": "Úplný sken", + "selectiveScan": "Selektívne", + "serverUptime": "Doba od spustenia", + "serverDown": "OFFLINE", + "scanType": "Posledný Sken", + "status": "Chyba skenovania", + "elapsedTime": "Uplynutý čas" + }, + "nowPlaying": { + "title": "Práve hrá", + "empty": "Nič sa neprehráva", + "minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami" + }, + "help": { + "title": "Klávesové skratky Navidrome", + "hotkeys": { + "show_help": "Zobraziť túto nápovedu", + "toggle_menu": "Prepnúť bočné menu", + "toggle_play": "Prehrať / Pozastaviť", + "prev_song": "Predchádzajúca skladba", + "next_song": "Nasledujúca skladba", + "current_song": "Prejsť na aktuálnu skladbu", + "vol_up": "Zvýšiť hlasitosť", + "vol_down": "Znížiť hlasitosť", + "toggle_love": "Pridať túto skladbu do obľúbených" + } + } +} \ No newline at end of file