From 23252e06388ed3d200ebb8a54f21399e50ce26f9 Mon Sep 17 00:00:00 2001 From: VirtualWolf Date: Wed, 20 May 2026 00:01:41 +1000 Subject: [PATCH 01/32] fix(ui): updated the AMusic theme to use the correct text colour for primary confirmation buttons (#5509) --- ui/src/themes/amusic.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/src/themes/amusic.js b/ui/src/themes/amusic.js index 74f7d3fd4..55205baf3 100644 --- a/ui/src/themes/amusic.js +++ b/ui/src/themes/amusic.js @@ -192,6 +192,11 @@ export default { paddingBottom: '1rem', }, }, + RaConfirm: { + confirmPrimary: { + color: '#fff', + }, + }, RaDeleteWithConfirmButton: { deleteButton: { color: '#fff !important', From a84f092d006ec80e9bd940af31a1fe235c1f5100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 19 May 2026 14:23:38 -0300 Subject: [PATCH 02/32] fix(subsonic): require admin access for Subsonic management endpoints (#5510) * fix: require admin for radio mutations Subsonic internet radio station mutation endpoints are admin-only in the Subsonic and OpenSubsonic specs, but the router only required an authenticated player. Add a reusable Subsonic admin middleware and apply it to create, update, and delete radio routes while leaving the list endpoint available to authenticated users. Cover the middleware and router behavior with unit and e2e tests. * fix: streamline admin-only routes for internet radio station management Signed-off-by: Deluan * fix: use admin-only middleware for starting scans Signed-off-by: Deluan * test: align start scan authorization coverage StartScan authorization now lives in the shared Subsonic admin middleware instead of the handler. Remove the obsolete direct handler unit assertion so the package tests reflect the route-level guard covered by middleware and e2e tests. * fix: require admin for getUsers The Subsonic getUsers endpoint exposes user-list semantics and should use the same shared admin middleware as other admin-only management endpoints. Apply the route-level guard while leaving getUser unchanged, and update the multi-user e2e coverage to expect regular users to receive an authorization failure. * test: cover admin-only Subsonic access Add e2e coverage that admins can still call getUsers after the route-level guard and that regular authenticated users can still list internet radio stations. These cases capture the access boundaries raised during PR review. --------- Signed-off-by: Deluan --- server/e2e/subsonic_multiuser_test.go | 18 +++++--- server/e2e/subsonic_radio_test.go | 53 ++++++++++++++++++++++++ server/subsonic/api.go | 13 +++--- server/subsonic/library_scanning.go | 4 -- server/subsonic/library_scanning_test.go | 23 ---------- server/subsonic/middlewares.go | 17 ++++++++ server/subsonic/middlewares_test.go | 30 ++++++++++++++ 7 files changed, 121 insertions(+), 37 deletions(-) diff --git a/server/e2e/subsonic_multiuser_test.go b/server/e2e/subsonic_multiuser_test.go index 4a5c35a7e..d8c5d3689 100644 --- a/server/e2e/subsonic_multiuser_test.go +++ b/server/e2e/subsonic_multiuser_test.go @@ -60,15 +60,23 @@ var _ = Describe("Multi-User Isolation", Ordered, func() { }) }) - Describe("getUsers for regular user", func() { - It("returns only the requesting user's info", func() { - resp := doReqWithUser(regularUser, "getUsers") + Describe("getUsers authorization", func() { + It("succeeds for admin user", func() { + resp := doReqWithUser(adminUser, "getUsers") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.Users).ToNot(BeNil()) Expect(resp.Users.User).To(HaveLen(1)) - Expect(resp.Users.User[0].Username).To(Equal("regular")) - Expect(resp.Users.User[0].AdminRole).To(BeFalse()) + Expect(resp.Users.User[0].Username).To(Equal(adminUser.UserName)) + Expect(resp.Users.User[0].AdminRole).To(BeTrue()) + }) + + It("fails for regular user because getUsers is admin-only", func() { + resp := doReqWithUser(regularUser, "getUsers") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) }) }) }) diff --git a/server/e2e/subsonic_radio_test.go b/server/e2e/subsonic_radio_test.go index ce64c31a1..cd778fa79 100644 --- a/server/e2e/subsonic_radio_test.go +++ b/server/e2e/subsonic_radio_test.go @@ -46,6 +46,30 @@ var _ = Describe("Internet Radio Endpoints", Ordered, func() { Expect(radioID).ToNot(BeEmpty()) }) + It("getInternetRadioStations remains available to regular users", func() { + resp := doReqWithUser(regularUser, "getInternetRadioStations") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.InternetRadioStations).ToNot(BeNil()) + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Test Radio")) + }) + + It("createInternetRadioStation requires admin user", func() { + resp := doReqWithUser(regularUser, "createInternetRadioStation", + "streamUrl", "https://stream.example.com/hacked", + "name", "Hacked Radio", + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) + + resp = doReq("getInternetRadioStations") + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Test Radio")) + }) + It("updateInternetRadioStation modifies the station", func() { resp := doReq("updateInternetRadioStation", "id", radioID, @@ -64,6 +88,35 @@ var _ = Describe("Internet Radio Endpoints", Ordered, func() { Expect(resp.InternetRadioStations.Radios[0].HomepageUrl).To(Equal("https://updated.example.com")) }) + It("updateInternetRadioStation requires admin user", func() { + resp := doReqWithUser(regularUser, "updateInternetRadioStation", + "id", radioID, + "streamUrl", "https://stream.example.com/hacked", + "name", "Hacked Radio", + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) + + resp = doReq("getInternetRadioStations") + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].Name).To(Equal("Updated Radio")) + Expect(resp.InternetRadioStations.Radios[0].StreamUrl).To(Equal("https://stream.example.com/radio-v2")) + }) + + It("deleteInternetRadioStation requires admin user", func() { + resp := doReqWithUser(regularUser, "deleteInternetRadioStation", "id", radioID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + Expect(resp.Error.Code).To(Equal(responses.ErrorAuthorizationFail)) + + resp = doReq("getInternetRadioStations") + Expect(resp.InternetRadioStations.Radios).To(HaveLen(1)) + Expect(resp.InternetRadioStations.Radios[0].ID).To(Equal(radioID)) + }) + It("deleteInternetRadioStation removes it", func() { resp := doReq("deleteInternetRadioStation", "id", radioID) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index fbab02794..7d15125b6 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -171,12 +171,12 @@ func (api *Router) routes() http.Handler { r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) h(r, "getUser", api.GetUser) - h(r, "getUsers", api.GetUsers) + h(r.With(adminOnly), "getUsers", api.GetUsers) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) h(r, "getScanStatus", api.GetScanStatus) - h(r, "startScan", api.StartScan) + h(r.With(adminOnly), "startScan", api.StartScan) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) @@ -195,10 +195,13 @@ func (api *Router) routes() http.Handler { }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) - h(r, "createInternetRadioStation", api.CreateInternetRadio) - h(r, "deleteInternetRadioStation", api.DeleteInternetRadio) h(r, "getInternetRadioStations", api.GetInternetRadios) - h(r, "updateInternetRadioStation", api.UpdateInternetRadio) + r.Group(func(r chi.Router) { + r.Use(adminOnly) + h(r, "createInternetRadioStation", api.CreateInternetRadio) + h(r, "deleteInternetRadioStation", api.DeleteInternetRadio) + h(r, "updateInternetRadioStation", api.UpdateInternetRadio) + }) }) if conf.Server.EnableSharing { r.Group(func(r chi.Router) { diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go index bac27f821..e6f64456d 100644 --- a/server/subsonic/library_scanning.go +++ b/server/subsonic/library_scanning.go @@ -40,10 +40,6 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) { return nil, newError(responses.ErrorGeneric, "Internal error") } - if !loggedUser.IsAdmin { - return nil, newError(responses.ErrorAuthorizationFail) - } - p := req.Params(r) fullScan := p.BoolOr("fullScan", false) diff --git a/server/subsonic/library_scanning_test.go b/server/subsonic/library_scanning_test.go index c62c156bc..771fc3352 100644 --- a/server/subsonic/library_scanning_test.go +++ b/server/subsonic/library_scanning_test.go @@ -23,29 +23,6 @@ var _ = Describe("LibraryScanning", func() { }) Describe("StartScan", func() { - It("requires admin authentication", func() { - // Create non-admin user - ctx := request.WithUser(context.Background(), model.User{ - ID: "user-id", - IsAdmin: false, - }) - - // Create request - r := httptest.NewRequest("GET", "/rest/startScan", nil) - r = r.WithContext(ctx) - - // Call endpoint - response, err := api.StartScan(r) - - // Should return authorization error - Expect(err).To(HaveOccurred()) - Expect(response).To(BeNil()) - var subErr subError - ok := errors.As(err, &subErr) - Expect(ok).To(BeTrue()) - Expect(subErr.code).To(Equal(responses.ErrorAuthorizationFail)) - }) - It("triggers a full scan with no parameters", func() { // Create admin user ctx := request.WithUser(context.Background(), model.User{ diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 5832bb1de..837852d18 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -155,6 +155,23 @@ func authenticate(ds model.DataStore) func(next http.Handler) http.Handler { } } +func adminOnly(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + loggedUser, ok := request.UserFrom(r.Context()) + if !ok { + sendError(w, r, newError(responses.ErrorGeneric, "Internal error")) + return + } + + if !loggedUser.IsAdmin { + sendError(w, r, newError(responses.ErrorAuthorizationFail)) + return + } + + next.ServeHTTP(w, r) + }) +} + func validateCredentials(user *model.User, pass, token, salt, jwt string) error { valid := false diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go index aba14a0aa..3f8c07a56 100644 --- a/server/subsonic/middlewares_test.go +++ b/server/subsonic/middlewares_test.go @@ -308,6 +308,36 @@ var _ = Describe("Middlewares", func() { }) }) + Describe("AdminOnly", func() { + It("passes admin users", func() { + r := newGetRequest() + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "admin-id", IsAdmin: true})) + + adminOnly(next).ServeHTTP(w, r) + + Expect(next.called).To(BeTrue()) + }) + + It("rejects non-admin users", func() { + r := newGetRequest() + r = r.WithContext(request.WithUser(r.Context(), model.User{ID: "user-id", IsAdmin: false})) + + adminOnly(next).ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="50"`)) + Expect(next.called).To(BeFalse()) + }) + + It("returns an internal error when user is missing from context", func() { + r := newGetRequest() + + adminOnly(next).ServeHTTP(w, r) + + Expect(w.Body.String()).To(ContainSubstring(`code="0"`)) + Expect(next.called).To(BeFalse()) + }) + }) + Describe("GetPlayer", func() { var mockedPlayers *mockPlayers var r *http.Request From 545a9ecc3c52f6523c10dbd178ca5f94f55c4288 Mon Sep 17 00:00:00 2001 From: nenadjokic Date: Tue, 19 May 2026 19:50:34 +0200 Subject: [PATCH 03/32] fix(ui): update Serbian translation (#5444) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(i18n): correct grammar errors in Serbian (sr) translation Eight objective grammatical / lexical errors in `resources/i18n/sr.json`. No stylistic or strategic re-wording — only corrections where the current string is grammatically wrong, contains a non-word, or breaks plural / case agreement. | Key | Before | After | Why | | --- | --- | --- | --- | | `resources.song.fields.bitDepth` | `Битова` | `Битска дубина` | "Битова" is genitive plural of "bit" ("of bits") and drops the "depth" semantics. Adjective+noun shape matches `Битски проток` already used for the adjacent "Bit rate" field | | `resources.song.fields.channels` | `Канала` | `Канали` | "Канала" is genitive plural ("of channels"); a column header needs nominative plural "Канали" | | `resources.radio.name` plural | `Радији` | `Радио-станице` | "Радији" is not a valid plural of "Радио" in Serbian. The standard plural for radio stations is "Радио-станице" | | `ra.input.file.upload_*` and `ra.input.image.upload_*` | `Упустите фајлове / слике …` | `Превуците фајлове / слике …` | "Упустити" means "to engage in / to indulge", not "to drop". For drag-and-drop UIs the standard Serbian verb is "Превуците" ("Drag") | | `ra.navigation.prev` | `Претход` | `Претх.` | "Претход" is not a word — looks like a truncated "Претходна" missing the period. Restored as a proper abbreviation | | `about.links.featureRequests` | `Захтеви за функцијама` | `Захтеви за функције` | Wrong case. Serbian "захтев за X" takes accusative ("захтев за помоћ"), not instrumental ("за функцијама") | | `player.clickToPauseText` / `clickToPlayText` | `Кликни за паузирање / пуштање` | `Кликните за паузирање / пуштање` | The rest of the file uses formal plural imperative ("Кликните…"). Only these two used the singular informal "Кликни", which broke the consistent register | JSON validated with `python3 -m json.tool`. No keys added, removed, or re-ordered — diff is purely value substitution. Stylistic and lexical modernization (e.g. "Уметник" → "Извођач" for music artist, filling the missing `library` / `plugin` / `nowPlaying` blocks added in upstream en.json) intentionally left for a follow-up PR after this baseline of objective fixes lands. * fix(i18n): fill missing keys in Serbian (sr) translation — 100% coverage The Serbian translation was at 70% of upstream `en.json` (389 of 553 keys). This commit fills all 164 missing keys, bringing coverage to 100%. The file is also re-ordered to match `en.json`'s key sequence so that future translation drift is easy to detect by diffing the two files side-by-side. This is the same regeneration pattern used by the previous maintainer's PR #3941. ## What was missing | Block | Keys | Notes | | --- | ---: | --- | | `resources.plugin` | 58 | Whole Plugin system block — settings, config schema, permissions, notifications | | `resources.library` | 43 | Whole Library management block — fields, scan actions, validation, notifications | | `about.config` + `about.tabs` | 12 | Configuration export feature (TOML) | | `message.*` | 11 | Cover-art upload/remove + Instant Mix + remove-all-missing | | `resources.song` | 9 | composer, sample rate, gain fields, instant mix, show-in-playlist | | `resources.playlist` | 6 | search-or-create UX, save-queue-to-playlist | | `resources.artist` | 5 | top songs / shuffle / radio actions, missing field, maincredit role | | `resources.user` | 5 | Per-user library access controls | | `menu.librarySelector` | 4 | Multi-library selector | | `activity` | 4 | selectiveScan, scanType, status, elapsedTime | | `nowPlaying` | 3 | Now Playing widget (title / empty / minutesAgo plural) | | `resources.album` | 2 | libraryName, missing | | `resources.missing` | 2 | remove_all action, libraryName field | ## Translation conventions followed - Stuck with the existing terminology already in `sr.json` for consistency — e.g. `Уметник` for "Artist", `Плејлиста` for "Playlist", `Жетон` for "Token". Whether `Уметник` → `Извођач` (music-context "performer") is a worthwhile rename is a separate question that deserves its own PR with a focused review surface; not in scope here. - Cyrillic throughout (matches `languageName: "српски"`). - Variable interpolation (`%{var}`) preserved exactly. - Pluralisation separator (` |||| `) preserved on plural-aware keys (`nowPlaying.minutesAgo`, `resources.library.name`, `resources.plugin.name`, `resources.artist.roles.maincredit`). ## Validation - JSON validated with `python3 -m json.tool` - Key-count parity check: 553 keys in `en.json` → 553 keys in `sr.json`, zero missing, zero extra. - Diff is +408 / -200 (line moves due to canonical ordering plus the net 164 new translations). All existing translations preserved verbatim. ## Builds on PR #5444 This branch sits on top of `i18n-sr-grammar-fixes` (PR #5444). If that PR merges first, this one auto-rebases cleanly. If this one merges first, PR #5444 has trivial conflicts in the same 7 strings (all already resolved here as part of regeneration). --- resources/i18n/sr.json | 654 +++++++++++++++++++++++++++-------------- 1 file changed, 431 insertions(+), 223 deletions(-) diff --git a/resources/i18n/sr.json b/resources/i18n/sr.json index 1cf7e39e7..cf0fc5d2f 100644 --- a/resources/i18n/sr.json +++ b/resources/i18n/sr.json @@ -4,45 +4,54 @@ "song": { "name": "Песма |||| Песме", "fields": { - "album": "Албум", "albumArtist": "Уметник албума", - "artist": "Уметник", - "bitDepth": "Битова", - "bitRate": "Битски проток", - "bpm": "BPM", - "channels": "Канала", - "comment": "Коментар", - "compilation": "Компилација", - "createdAt": "Датум додавања", - "discSubtitle": "Поднаслов диска", "duration": "Трајање", + "trackNumber": "#", + "playCount": "Пуштано", + "title": "Наслов", + "artist": "Уметник", + "composer": "Композитор", + "album": "Албум", + "path": "Путања фајла", + "libraryName": "Библиотека", "genre": "Жанр", + "compilation": "Компилација", + "year": "Година", + "size": "Величина фајла", + "updatedAt": "Ажурирано", + "bitRate": "Битски проток", + "bitDepth": "Битска дубина", + "sampleRate": "Учестаност узорковања", + "albumGain": "Појачање албума", + "trackGain": "Појачање нумере", + "channels": "Канали", + "disc": "Диск %{discNumber}", + "discSubtitle": "Поднаслов диска", + "starred": "Омиљено", + "comment": "Коментар", + "rating": "Рејтинг", + "quality": "Квалитет", + "bpm": "BPM", + "playDate": "Последње пуштано", + "createdAt": "Датум додавања", "grouping": "Груписање", - "mappedTags": "Мапиране ознаке", "mood": "Расположење", "participants": "Додатни учесници", - "path": "Путања фајла", - "playCount": "Пуштано", - "playDate": "Последње пуштано", - "quality": "Квалитет", - "rating": "Рејтинг", - "rawTags": "Сирове ознаке", - "size": "Величина фајла", - "starred": "Омиљено", "tags": "Додатне ознаке", - "title": "Наслов", - "trackNumber": "#", - "updatedAt": "Ажурирано", - "year": "Година" + "mappedTags": "Мапиране ознаке", + "rawTags": "Сирове ознаке", + "missing": "Недостаје" }, "actions": { - "addToPlaylist": "Додај у плејлисту", "addToQueue": "Пусти касније", - "download": "Преузми", - "info": "Прикажи инфо", - "playNext": "Пусти наредно", "playNow": "Пусти одмах", - "shuffleAll": "Измешај све" + "addToPlaylist": "Додај у плејлисту", + "showInPlaylist": "Прикажи у плејлисти", + "shuffleAll": "Измешај све", + "download": "Преузми", + "playNext": "Пусти наредно", + "info": "Прикажи инфо", + "instantMix": "Инстант микс" } }, "album": { @@ -50,46 +59,48 @@ "fields": { "albumArtist": "Уметник албума", "artist": "Уметник", - "catalogNum": "Каталошки број", - "comment": "Коментар", - "compilation": "Компилација", - "createdAt": "Датум додавања", - "date": "Датум снимања", "duration": "Трајање", + "songCount": "Песме", + "playCount": "Пуштано", + "size": "Величина", + "name": "Назив", + "libraryName": "Библиотека", "genre": "Жанр", + "compilation": "Компилација", + "year": "Година", + "date": "Датум снимања", + "originalDate": "Оригинално", + "releaseDate": "Објављено", + "releases": "Издање|||| Издања", + "released": "Објављено", + "updatedAt": "Ажурирано", + "comment": "Коментар", + "rating": "Рејтинг", + "createdAt": "Датум додавања", + "recordLabel": "Издавачка кућа", + "catalogNum": "Каталошки број", + "releaseType": "Тип", "grouping": "Груписање", "media": "Медијум", "mood": "Расположење", - "name": "Назив", - "originalDate": "Оригинално", - "playCount": "Пуштано", - "rating": "Рејтинг", - "recordLabel": "Издавачка кућа", - "releaseDate": "Објављено", - "releaseType": "Тип", - "released": "Објављено", - "releases": "Издање|||| Издања", - "size": "Величина", - "songCount": "Песме", - "updatedAt": "Ажурирано", - "year": "Година" + "missing": "Недостаје" }, "actions": { - "addToPlaylist": "Додај у плејлисту", - "addToQueue": "Пусти касније", - "download": "Преузми", - "info": "Прикажи инфо", "playAll": "Пусти", "playNext": "Пусти наредно", + "addToQueue": "Пусти касније", "share": "Дели", - "shuffle": "Измешај" + "shuffle": "Измешај", + "addToPlaylist": "Додај у плејлисту", + "download": "Преузми", + "info": "Прикажи инфо" }, "lists": { "all": "Све", - "mostPlayed": "Најчешће пуштано", "random": "Насумично", "recentlyAdded": "Додато недавно", "recentlyPlayed": "Пуштано недавно", + "mostPlayed": "Најчешће пуштано", "starred": "Омиљено", "topRated": "Најбоље рангирано" } @@ -97,116 +108,136 @@ "artist": { "name": "Уметник |||| Уметници", "fields": { - "albumCount": "Број албума", - "genre": "Жанр", "name": "Назив", + "albumCount": "Број албума", + "songCount": "Број песама", + "size": "Величина", "playCount": "Пуштано", "rating": "Рејтинг", + "genre": "Жанр", "role": "Улога", - "size": "Величина", - "songCount": "Број песама" + "missing": "Недостаје" }, "roles": { "albumartist": "Уметник албума |||| Уметници албума", - "arranger": "Аранжер |||| Аранжери", "artist": "Уметник |||| Уметници", "composer": "Композитор |||| Композитори", "conductor": "Диригент |||| Диригенти", - "director": "Режисер |||| Режисери", - "djmixer": "Ди-џеј миксер |||| Ди-џеј миксер", - "engineer": "Инжењер |||| Инжењери", "lyricist": "Текстописац |||| Текстописци", - "mixer": "Миксер |||| Миксери", - "performer": "Извођач |||| Извођачи", + "arranger": "Аранжер |||| Аранжери", "producer": "Продуцент |||| Продуценти", - "remixer": "Ремиксер |||| Ремиксери" + "director": "Режисер |||| Режисери", + "engineer": "Инжењер |||| Инжењери", + "mixer": "Миксер |||| Миксери", + "remixer": "Ремиксер |||| Ремиксери", + "djmixer": "Ди-џеј миксер |||| Ди-џеј миксер", + "performer": "Извођач |||| Извођачи", + "maincredit": "Уметник албума или уметник |||| Уметници албума или уметници" + }, + "actions": { + "topSongs": "Најбоље песме", + "shuffle": "Измешај", + "radio": "Радио" } }, "user": { "name": "Корисник |||| Корисници", "fields": { - "changePassword": "Измени лозинку?", - "createdAt": "Креирана", - "currentPassword": "Текућа лозинка", + "userName": "Корисничко име", "isAdmin": "Да ли је Админ", - "lastAccessAt": "Последњи приступ", "lastLoginAt": "Последња пријава", - "name": "Назив", - "newPassword": "Нова лозинка", - "password": "Лозинка", - "token": "Жетон", + "lastAccessAt": "Последњи приступ", "updatedAt": "Ажурирано", - "userName": "Корисничко име" + "name": "Назив", + "password": "Лозинка", + "createdAt": "Креирана", + "changePassword": "Измени лозинку?", + "currentPassword": "Текућа лозинка", + "newPassword": "Нова лозинка", + "token": "Жетон", + "libraries": "Библиотеке" }, "helperTexts": { - "name": "Измене вашег имена ће постати видљиве након следеће пријаве" + "name": "Измене вашег имена ће постати видљиве након следеће пријаве", + "libraries": "Изаберите одређене библиотеке за овог корисника, или оставите празно да се користе подразумеване библиотеке" }, "notifications": { "created": "Корисник креиран", - "deleted": "Корисник обрисан", - "updated": "Корисник ажуриран" + "updated": "Корисник ажуриран", + "deleted": "Корисник обрисан" + }, + "validation": { + "librariesRequired": "Барем једна библиотека мора да буде изабрана за кориснике који нису администратори" }, "message": { + "listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон.", "clickHereForToken": "Кликните овде да преузмете свој жетон", - "listenBrainzToken": "Унесите свој ListenBrainz кориснички жетон." + "selectAllLibraries": "Изабери све библиотеке", + "adminAutoLibraries": "Администратори аутоматски имају приступ свим библиотекама" } }, "player": { "name": "Плејер |||| Плејери", "fields": { - "client": "Клијент", - "lastSeen": "Последњи пут виђен", - "maxBitRate": "Макс. битски проток", "name": "Назив", - "reportRealPath": "Пријављуј реалну путању", - "scrobbleEnabled": "Шаљи скроблове на спољне сервисе", "transcodingId": "Транскодирање", - "userName": "Корисничко име" + "maxBitRate": "Макс. битски проток", + "client": "Клијент", + "userName": "Корисничко име", + "lastSeen": "Последњи пут виђен", + "reportRealPath": "Пријављуј реалну путању", + "scrobbleEnabled": "Шаљи скроблове на спољне сервисе" } }, "transcoding": { "name": "Транскодирање |||| Транскодирања", "fields": { - "command": "Команда", - "defaultBitRate": "Подразумевани битски проток", "name": "Назив", - "targetFormat": "Циљни формат" + "targetFormat": "Циљни формат", + "defaultBitRate": "Подразумевани битски проток", + "command": "Команда" } }, "playlist": { "name": "Плејлиста |||| Плејлисте", "fields": { - "comment": "Коментар", - "createdAt": "Креирана", - "duration": "Трајање", "name": "Назив", + "duration": "Трајање", "ownerName": "Власник", - "path": "Увоз из", "public": "Јавна", + "updatedAt": "Ажурирано", + "createdAt": "Креирана", "songCount": "Песме", + "comment": "Коментар", "sync": "Ауто-увоз", - "updatedAt": "Ажурирано" + "path": "Увоз из" }, "actions": { + "selectPlaylist": "Изабери плејлисту", "addNewPlaylist": "Креирај „%{name}”", "export": "Извези", - "makePrivate": "Учини приватном", + "saveQueue": "Сачувај ред у плејлисту", "makePublic": "Учини јавном", - "selectPlaylist": "Изабери плејлисту" + "makePrivate": "Учини приватном", + "searchOrCreate": "Претражите плејлисте или унесите назив за нову…", + "pressEnterToCreate": "Притисните Ентер да креирате нову плејлисту", + "removeFromSelection": "Уклони из избора" }, "message": { "duplicate_song": "Додај дуплиране песме", - "song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?" + "song_exist": "У плејлисту се додају дупликати. Желите ли да се додају, или да се прескоче?", + "noPlaylistsFound": "Нема пронађених плејлиста", + "noPlaylists": "Нема доступних плејлиста" } }, "radio": { - "name": "Радио |||| Радији", + "name": "Радио |||| Радио-станице", "fields": { - "createdAt": "Креирана", - "homePageUrl": "URL почетне странице", "name": "Назив", "streamUrl": "URL тока", - "updatedAt": "Ажурирано" + "homePageUrl": "URL почетне странице", + "updatedAt": "Ажурирано", + "createdAt": "Креирана" }, "actions": { "playNow": "Пусти одмах" @@ -215,18 +246,18 @@ "share": { "name": "Дељење |||| Дељења", "fields": { - "contents": "Садржај", - "createdAt": "Креирано", + "username": "Поделио", + "url": "URL", "description": "Опис", "downloadable": "Допушта се преузимање?", + "contents": "Садржај", "expiresAt": "Истиче", - "format": "Формат", "lastVisitedAt": "Последњи пут посећено", + "visitCount": "Број посета", + "format": "Формат", "maxBitRate": "Макс. битски проток", "updatedAt": "Ажурирано", - "url": "URL", - "username": "Поделио", - "visitCount": "Број посета" + "createdAt": "Креирано" }, "notifications": {}, "actions": {} @@ -237,111 +268,246 @@ "fields": { "path": "Путања", "size": "Величина", + "libraryName": "Библиотека", "updatedAt": "Нестао дана" }, "actions": { - "remove": "Уклони" + "remove": "Уклони", + "remove_all": "Уклони све" }, "notifications": { "removed": "Фајл који недостаје, или више њих, је уклоњен" } + }, + "library": { + "name": "Библиотека |||| Библиотеке", + "fields": { + "name": "Назив", + "path": "Путања", + "remotePath": "Удаљена путања", + "lastScanAt": "Последње скенирање", + "songCount": "Песме", + "albumCount": "Албуми", + "artistCount": "Уметници", + "totalSongs": "Песме", + "totalAlbums": "Албуми", + "totalArtists": "Уметници", + "totalFolders": "Фасцикле", + "totalFiles": "Фајлови", + "totalMissingFiles": "Фајлови који недостају", + "totalSize": "Укупна величина", + "totalDuration": "Трајање", + "defaultNewUsers": "Подразумевано за нове кориснике", + "createdAt": "Креирана", + "updatedAt": "Ажурирана" + }, + "sections": { + "basic": "Основне информације", + "statistics": "Статистика" + }, + "actions": { + "scan": "Скенирај библиотеку", + "quickScan": "Брзо скенирање", + "fullScan": "Комплетно скенирање", + "manageUsers": "Управљај приступом корисника", + "viewDetails": "Прикажи детаље" + }, + "notifications": { + "created": "Библиотека је успешно креирана", + "updated": "Библиотека је успешно ажурирана", + "deleted": "Библиотека је успешно обрисана", + "scanStarted": "Скенирање библиотеке је покренуто", + "quickScanStarted": "Брзо скенирање је покренуто", + "fullScanStarted": "Комплетно скенирање је покренуто", + "scanError": "Грешка при покретању скенирања. Проверите дневнике.", + "scanCompleted": "Скенирање библиотеке је завршено" + }, + "validation": { + "nameRequired": "Назив библиотеке је обавезан", + "pathRequired": "Путања библиотеке је обавезна", + "pathNotDirectory": "Путања библиотеке мора да буде фасцикла", + "pathNotFound": "Путања библиотеке није пронађена", + "pathNotAccessible": "Путања библиотеке није доступна", + "pathInvalid": "Неисправна путања библиотеке" + }, + "messages": { + "deleteConfirm": "Да ли сте сигурни да желите да обришете ову библиотеку? Ово ће да уклони све повезане податке и приступ корисника.", + "scanInProgress": "Скенирање је у току…", + "noLibrariesAssigned": "Овом кориснику нема додељених библиотека" + } + }, + "plugin": { + "name": "Додатак |||| Додаци", + "fields": { + "id": "ИД", + "name": "Назив", + "description": "Опис", + "version": "Верзија", + "author": "Аутор", + "website": "Веб-сајт", + "permissions": "Дозволе", + "enabled": "Омогућено", + "status": "Статус", + "path": "Путања", + "lastError": "Грешка", + "hasError": "Грешка", + "updatedAt": "Ажурирано", + "createdAt": "Инсталирано", + "configKey": "Кључ", + "configValue": "Вредност", + "allUsers": "Дозволи свим корисницима", + "selectedUsers": "Изабрани корисници", + "allLibraries": "Дозволи све библиотеке", + "selectedLibraries": "Изабране библиотеке", + "allowWriteAccess": "Дозволи приступ за упис" + }, + "sections": { + "status": "Статус", + "info": "Информације о додатку", + "configuration": "Конфигурација", + "manifest": "Манифест", + "usersPermission": "Дозволе корисника", + "libraryPermission": "Дозволе библиотеке" + }, + "status": { + "enabled": "Омогућено", + "disabled": "Онемогућено" + }, + "actions": { + "enable": "Омогући", + "disable": "Онемогући", + "disabledDueToError": "Поправите грешку пре омогућавања", + "disabledUsersRequired": "Изаберите кориснике пре омогућавања", + "disabledLibrariesRequired": "Изаберите библиотеке пре омогућавања", + "addConfig": "Додај конфигурацију", + "rescan": "Поново скенирај" + }, + "notifications": { + "enabled": "Додатак је омогућен", + "disabled": "Додатак је онемогућен", + "updated": "Додатак је ажуриран", + "error": "Грешка при ажурирању додатка" + }, + "validation": { + "invalidJson": "Конфигурација мора да буде исправан JSON" + }, + "messages": { + "configHelp": "Конфигуришите додатак користећи парове кључ-вредност. Оставите празно ако додатак не захтева конфигурацију.", + "configValidationError": "Провера исправности конфигурације није успела:", + "schemaRenderError": "Не може да се прикаже образац за конфигурацију. Шема додатка можда није исправна.", + "clickPermissions": "Кликните на дозволу за детаље", + "noConfig": "Конфигурација није постављена", + "allUsersHelp": "Када је омогућено, додатак ће имати приступ свим корисницима, укључујући оне који буду креирани у будућности.", + "noUsers": "Нема изабраних корисника", + "permissionReason": "Разлог", + "usersRequired": "Овај додатак захтева приступ информацијама о корисницима. Изаберите којим корисницима додатак може да приступи, или омогућите „Дозволи свим корисницима”.", + "allLibrariesHelp": "Када је омогућено, додатак ће имати приступ свим библиотекама, укључујући оне које буду креиране у будућности.", + "noLibraries": "Нема изабраних библиотека", + "librariesRequired": "Овај додатак захтева приступ информацијама о библиотекама. Изаберите којим библиотекама додатак може да приступи, или омогућите „Дозволи све библиотеке”.", + "allowWriteAccessHelp": "Када је омогућено, додатак може да мења фајлове у фасциклама библиотеке. Подразумевано, додаци имају приступ само за читање.", + "requiredHosts": "Потребни хостови" + }, + "placeholders": { + "configKey": "кључ", + "configValue": "вредност" + } } }, "ra": { "auth": { - "auth_check_error": "Ако желите да наставите, молимо вас да се пријавите", - "buttonCreateAdmin": "Креирај админа", + "welcome1": "Хвала што сте инсталирали Navidrome!", + "welcome2": "За почетак, креирајте админ корисника", "confirmPassword": "Потврдите лозинку", - "insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите", - "logout": "Одјави се", + "buttonCreateAdmin": "Креирај админа", + "auth_check_error": "Ако желите да наставите, молимо вас да се пријавите", + "user_menu": "Профил", + "username": "Корисничко име", "password": "Лозинка", "sign_in": "Пријави се", "sign_in_error": "Потврда идентитета није успела, покушајте поново", - "user_menu": "Профил", - "username": "Корисничко име", - "welcome1": "Хвала што сте инсталирали Navidrome!", - "welcome2": "За почетак, креирајте админ корисника" + "logout": "Одјави се", + "insightsCollectionNote": "Navidrome прикупља анонимне податке о коришћењу\nшто олакшава унапређење пројекта. Кликните [овде] да\nсазнате више и да одустанете од прикупљања ако желите" }, "validation": { - "email": "Мора да буде исправна и-мејл адреса", "invalidChars": "Молимо вас да користите само слова и цифре", - "maxLength": "Мора да буде %{max} карактера или мање", - "maxValue": "Мора да буде %{max} или мање", - "minLength": "Мора да буде барем %{min} карактера", - "minValue": "Мора да буде барем %{min}", - "number": "Мора да буде број", - "oneOf": "Мора да буде једно од: %{options}", "passwordDoesNotMatch": "Лозинка се не подудара", - "regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}", "required": "Неопходно", + "minLength": "Мора да буде барем %{min} карактера", + "maxLength": "Мора да буде %{max} карактера или мање", + "minValue": "Мора да буде барем %{min}", + "maxValue": "Мора да буде %{max} или мање", + "number": "Мора да буде број", + "email": "Мора да буде исправна и-мејл адреса", + "oneOf": "Мора да буде једно од: %{options}", + "regex": "Мора да се подудара са одређеним форматом (регуларни израз): %{pattern}", "unique": "Мора да буде јединствено", "url": "Мора да буде исправна URL адреса" }, "action": { - "add": "Додај", "add_filter": "Додај филтер", + "add": "Додај", "back": "Иди назад", "bulk_actions": "изабрана је 1 ставка |||| изабрано је %{smart_count} ставки", "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "Откажи", "clear_input_value": "Обриши вредност", "clone": "Клонирај", - "close": "Затвори", - "close_menu": "Затвори мени", "confirm": "Потврди", "create": "Креирај", "delete": "Обриши", - "download": "Преузми", "edit": "Уреди", - "expand": "Развиј", "export": "Извези", "list": "Листа", - "open_menu": "Отвори мени", "refresh": "Освежи", - "remove": "Уклони", "remove_filter": "Уклони овај филтер", + "remove": "Уклони", "save": "Сачувај", "search": "Тражи", - "share": "Дели", "show": "Прикажи", - "skip": "Прескочи", "sort": "Сортирај", "undo": "Поништи", - "unselect": "Уклони избор" + "expand": "Развиј", + "close": "Затвори", + "open_menu": "Отвори мени", + "close_menu": "Затвори мени", + "unselect": "Уклони избор", + "skip": "Прескочи", + "share": "Дели", + "download": "Преузми" }, "boolean": { - "false": "Не", - "true": "Да" + "true": "Да", + "false": "Не" }, "page": { "create": "Креирај %{name}", "dashboard": "Контролна табла", "edit": "%{name} #%{id}", - "empty": "Још увек нема %{name}.", "error": "Нешто је пошло наопако", - "invite": "Желите ли да се дода?", "list": "%{name}", "loading": "Учитава се", "not_found": "Није пронађено", - "show": "%{name} #%{id}" + "show": "%{name} #%{id}", + "empty": "Још увек нема %{name}.", + "invite": "Желите ли да се дода?" }, "input": { "file": { - "upload_several": "Упустите фајлове да се отпреме, или кликните да их изаберете.", - "upload_single": "Упустите фајл да се отпреми, или кликните да га изаберете." + "upload_several": "Превуците фајлове да се отпреме, или кликните да их изаберете.", + "upload_single": "Превуците фајл да се отпреми, или кликните да га изаберете." }, "image": { - "upload_several": "Упустите слике да се отпреме, или кликните да их изаберете.", - "upload_single": "Упустите слику да се отпреми, или кликните да је изаберете." - }, - "password": { - "toggle_hidden": "Прикажи лозинку", - "toggle_visible": "Сакриј лозинку" + "upload_several": "Превуците слике да се отпреме, или кликните да их изаберете.", + "upload_single": "Превуците слику да се отпреми, или кликните да је изаберете." }, "references": { "all_missing": "Не могу да се нађу подаци референци.", "many_missing": "Изгледа да барем једна од придружених референци више није доступна.", "single_missing": "Изгледа да придружена референца више није доступна." + }, + "password": { + "toggle_visible": "Сакриј лозинку", + "toggle_hidden": "Прикажи лозинку" } }, "message": { @@ -357,161 +523,203 @@ "loading": "Страница се учитава, сачекајте мало", "no": "Не", "not_found": "Или сте откуцали погрешну URL адресу, или сте следили неисправан линк.", - "unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?", - "yes": "Да" + "yes": "Да", + "unsaved_changes": "Неке од ваших измена нису сачуване. Да ли заиста желите да их одбаците?" }, "navigation": { - "next": "Наредна", - "no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.", "no_results": "Није пронађен ниједан резултат", - "page_out_from_begin": "Не може да се иде испред странице 1", - "page_out_from_end": "Не може да се иде након последње странице", + "no_more_results": "Број странице %{page} је ван опсега. Покушајте претходну страницу.", "page_out_of_boundaries": "Број странице %{page} је ван опсега", + "page_out_from_end": "Не може да се иде након последње странице", + "page_out_from_begin": "Не може да се иде испред странице 1", "page_range_info": "%{offsetBegin}-%{offsetEnd} од %{total}", "page_rows_per_page": "Ставки по страници:", - "prev": "Претход", + "next": "Наредна", + "prev": "Претх.", "skip_nav": "Прескочи на садржај" }, "notification": { - "bad_item": "Неисправни елемент", - "canceled": "Акција је отказана", + "updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано", "created": "Елемент је креиран", - "data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.", "deleted": "Елемент је обрисан |||| %{smart_count} елемената је обрисано", - "http_error": "Грешка у комуникацији са сервером", - "i18n_error": "Не могу да се учитају преводи за наведени језик", + "bad_item": "Неисправни елемент", "item_doesnt_exist": "Елемент не постоји", + "http_error": "Грешка у комуникацији са сервером", + "data_provider_error": "dataProvider грешка. За више детаља погледајте конзолу.", + "i18n_error": "Не могу да се учитају преводи за наведени језик", + "canceled": "Акција је отказана", "logged_out": "Ваша сесија је завршена, молимо вас да се повежите поново.", - "new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор.", - "updated": "Елемент је ажуриран |||| %{smart_count} елемената је ажурирано" + "new_version": "Доступна је нова верзија! Молимо вас да освежите овај прозор." }, "toggleFieldsMenu": { "columnsToDisplay": "Колоне за приказ", - "grid": "Мрежа", "layout": "Распоред", + "grid": "Мрежа", "table": "Табела" } }, "message": { - "delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?", - "delete_user_title": "Брисање корисника ’%{name}’", - "downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})", - "downloadOriginalFormat": "Преузми у оригиналном формату", - "lastfmLink": "Прочитај још...", - "lastfmLinkFailure": "Last.fm није могао да се повеже", - "lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање", - "lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm", - "lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено", - "listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}", - "listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}", - "listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz", - "listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено", - "noPlaylistsAvailable": "Није доступна ниједна", + "uploadCover": "Отпреми омот", + "removeCover": "Уклони омот", + "coverUploaded": "Омот је ажуриран", + "coverRemoved": "Омот је уклоњен", + "coverUploadError": "Грешка при отпремању омота", + "coverRemoveError": "Грешка при уклањању омота", "note": "НАПОМЕНА", + "transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.", + "transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања.", + "songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама", + "noSimilarSongsFound": "Нису пронађене сличне песме", + "startingInstantMix": "Учитава се инстант микс…", + "noTopSongsFound": "Нису пронађене најбоље песме", + "noPlaylistsAvailable": "Није доступна ниједна", + "delete_user_title": "Брисање корисника ’%{name}’", + "delete_user_content": "Да ли заиста желите да обришете овог корисника, заједно са свим његовим подацима (плејлистама и подешавањима)?", + "remove_missing_title": "Уклони фајлове који недостају", + "remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", + "remove_all_missing_title": "Уклони све фајлове који недостају", + "remove_all_missing_content": "Да ли сте сигурни да желите да из базе података уклоните све фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", "notifications_blocked": "У подешавањима интернет прегледача за овај сајт, блокирали сте обавештења", "notifications_not_available": "Овај интернет прегледач не подржава десктоп обавештења, или Navidrome серверу не приступате преко https протокола", + "lastfmLinkSuccess": "Last.fm је успешно повезан и укључено је скробловање", + "lastfmLinkFailure": "Last.fm није могао да се повеже", + "lastfmUnlinkSuccess": "Last.fm више није повезан и скробловање је искључено", + "lastfmUnlinkFailure": "Није могла да се уклони веза са Last.fm", + "listenBrainzLinkSuccess": "ListenBrainz је успешно повезан и скробловање је укључено као корисник: %{user}", + "listenBrainzLinkFailure": "ListenBrainz није могао да се повеже: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz више није повезан и скробловање је искључено", + "listenBrainzUnlinkFailure": "Није могла да се уклони веза са ListenBrainz", "openIn": { "lastfm": "Отвори у Last.fm", "musicbrainz": "Отвори у MusicBrainz" }, - "remove_missing_content": "Да ли сте сигурни да из базе података желите да уклоните фајлове који недостају? Ово ће трајно да уклони све референце на њих, укључујући број пуштања и рангирања.", - "remove_missing_title": "Уклони фајлове који недостају", + "lastfmLink": "Прочитај још...", + "shareOriginalFormat": "Подели у оригиналном формату", + "shareDialogTitle": "Подели %{resource} ’%{name}’", "shareBatchDialogTitle": "Подели 1 %{resource} |||| Подели %{smart_count} %{resource}", "shareCopyToClipboard": "Копирај у клипборд: Ctrl+C, Ентер", - "shareDialogTitle": "Подели %{resource} ’%{name}’", - "shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд", - "shareOriginalFormat": "Подели у оригиналном формату", "shareSuccess": "URL је копиран у клипборд: %{url}", - "songsAddedToPlaylist": "У плејлисту је додата 1 песма |||| У плејлисту је додато %{smart_count} песама", - "transcodingDisabled": "Измена конфигурације транскодирања кроз веб интерфејс је искључена из разлога безбедности. Ако желите да измените (уредите или додате) опције транскодирања, поново покрените сервер са %{config} конфигурационом опцијом.", - "transcodingEnabled": "Navidrome се тренутно извршава са %{config}, чиме је омогућено извршавање системских команди из подешавања транскодирања коришћењем веб интерфејса. Из разлога безбедности, препоручујемо да то искључите, а да омогућите само када конфигуришете опције транскодирања." + "shareFailure": "Грешка приликом копирања URL адресе %{url} у клипборд", + "downloadDialogTitle": "Преузимање %{resource} ’%{name}’ (%{size})", + "downloadOriginalFormat": "Преузми у оригиналном формату" }, "menu": { - "about": "О", - "albumList": "Албуми", "library": "Библиотека", + "librarySelector": { + "allLibraries": "Све библиотеке (%{count})", + "multipleLibraries": "%{selected} од %{total} библиотека", + "selectLibraries": "Изабери библиотеке", + "none": "Ниједна" + }, + "settings": "Подешавања", + "version": "Верзија", + "theme": "Тема", "personal": { "name": "Лична", "options": { + "theme": "Тема", + "language": "Језик", "defaultView": "Подразумевани поглед", "desktop_notifications": "Десктоп обавештења", - "gain": { - "album": "Користи Album појачање", - "none": "Искључено", - "track": "Користи Track појачање" - }, - "language": "Језик", "lastfmNotConfigured": "Није подешен Last.fm API-кључ", "lastfmScrobbling": "Скроблуј на Last.fm", "listenBrainzScrobbling": "Скроблуј на ListenBrainz", - "preAmp": "ReplayGain претпојачање (dB)", "replaygain": "ReplayGain режим", - "theme": "Тема" + "preAmp": "ReplayGain претпојачање (dB)", + "gain": { + "none": "Искључено", + "album": "Користи Album појачање", + "track": "Користи Track појачање" + } } }, + "albumList": "Албуми", "playlists": "Плејлисте", - "settings": "Подешавања", "sharedPlaylists": "Дељене плејлисте", - "theme": "Тема", - "version": "Верзија" + "about": "О" }, "player": { - "clickToDeleteText": "Кликните да обришете %{name}", - "clickToPauseText": "Кликни за паузирање", - "clickToPlayText": "Кликни за пуштање", + "playListsText": "Ред за пуштање", + "openText": "Отвори", "closeText": "Затвори", + "notContentText": "Нема музике", + "clickToPlayText": "Кликните за пуштање", + "clickToPauseText": "Кликните за паузирање", + "nextTrackText": "Наредна нумера", + "previousTrackText": "Претходна нумера", + "reloadText": "Поново учитај", + "volumeText": "Јачина", + "toggleLyricText": "Укљ./Искљ. стихове", + "toggleMiniModeText": "Умањи", "destroyText": "Уништи", "downloadText": "Преузми", + "removeAudioListsText": "Обриши аудио листе", + "clickToDeleteText": "Кликните да обришете %{name}", "emptyLyricText": "Нема стихова", - "nextTrackText": "Наредна нумера", - "notContentText": "Нема музике", - "openText": "Отвори", - "playListsText": "Ред за пуштање", "playModeText": { "order": "По редоследу", "orderLoop": "Понови", - "shufflePlay": "Измешај", - "singleLoop": "Понови једну" - }, - "previousTrackText": "Претходна нумера", - "reloadText": "Поново учитај", - "removeAudioListsText": "Обриши аудио листе", - "toggleLyricText": "Укљ./Искљ. стихове", - "toggleMiniModeText": "Умањи", - "volumeText": "Јачина" + "singleLoop": "Понови једну", + "shufflePlay": "Измешај" + } }, "about": { "links": { - "featureRequests": "Захтеви за функцијама", "homepage": "Почетна страница", + "source": "Изворни кôд", + "featureRequests": "Захтеви за функције", + "lastInsightsCollection": "Последња колекција увида", "insights": { "disabled": "Искључено", "waiting": "Чека се" - }, - "lastInsightsCollection": "Последња колекција увида", - "source": "Изворни кôд" + } + }, + "tabs": { + "about": "О програму", + "config": "Конфигурација" + }, + "config": { + "configName": "Назив конфигурације", + "environmentVariable": "Променљива окружења", + "currentValue": "Тренутна вредност", + "configurationFile": "Конфигурациони фајл", + "exportToml": "Извези конфигурацију (TOML)", + "downloadToml": "Преузми конфигурацију (TOML)", + "exportSuccess": "Конфигурација је извезена у клипборд у TOML формату", + "exportFailed": "Копирање конфигурације није успело", + "devFlagsHeader": "Развојне заставице (подложне промени или уклањању)", + "devFlagsComment": "Ово су експерименталне поставке и могу бити уклоњене у будућим верзијама" } }, "activity": { - "fullScan": "Комплетно скенирање", - "quickScan": "Брзо скенирање", - "serverDown": "ВАН МРЕЖЕ", - "serverUptime": "Сервер се извршава", "title": "Активност", - "totalScanned": "Укупан број скенираних фолдера" + "totalScanned": "Укупан број скенираних фолдера", + "quickScan": "Брзо скенирање", + "fullScan": "Комплетно скенирање", + "selectiveScan": "Селективно", + "serverUptime": "Сервер се извршава", + "serverDown": "ВАН МРЕЖЕ", + "scanType": "Последње скенирање", + "status": "Грешка скенирања", + "elapsedTime": "Протекло време" + }, + "nowPlaying": { + "title": "Сада се пушта", + "empty": "Ништа се не пушта", + "minutesAgo": "Пре %{smart_count} минут |||| Пре %{smart_count} минута" }, "help": { "title": "Navidrome пречице", "hotkeys": { - "current_song": "Иди на текућу песму", - "next_song": "Наредна песма", - "prev_song": "Претходна песма", "show_help": "Прикажи ову помоћ", - "toggle_love": "Додај ову нумеру у омиљене", "toggle_menu": "Укљ./Искљ. бочну траку менија", "toggle_play": "Пусти / Паузирај", + "prev_song": "Претходна песма", + "next_song": "Наредна песма", + "current_song": "Иди на текућу песму", + "vol_up": "Појачај", "vol_down": "Утишај", - "vol_up": "Појачај" + "toggle_love": "Додај ову нумеру у омиљене" } } } From efe9291db0159b925a03e8daed5e5e9b1b55fe36 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 19 May 2026 18:02:29 -0300 Subject: [PATCH 04/32] refactor: multiple syntax updates for Go 1.26 Signed-off-by: Deluan --- .gitignore | 3 +- adapters/gotaglib/end_to_end_test.go | 10 ++-- core/external/provider.go | 4 +- core/external/provider_artistimage_test.go | 6 +-- .../external/provider_updatealbuminfo_test.go | 5 +- .../provider_updateartistinfo_test.go | 7 ++- core/ffmpeg/ffmpeg.go | 3 +- core/inspect.go | 3 +- core/lyrics/lyrics_test.go | 7 ++- core/lyrics/sources_test.go | 13 +++-- core/playback/mpv/mpv.go | 3 +- core/playlists/parse_nsp.go | 3 +- core/playlists/playlists_test.go | 15 ++---- core/playlists/rest_adapter_test.go | 3 +- core/scrobbler/play_tracker_test.go | 3 +- core/share.go | 4 +- model/artwork_id_test.go | 3 +- model/lyrics_test.go | 52 ++++++++----------- model/metadata/map_mediafile_test.go | 5 +- model/metadata/metadata_test.go | 15 +++--- persistence/artist_repository.go | 3 +- persistence/genre_repository.go | 3 +- persistence/persistence_suite_test.go | 14 ++--- persistence/playqueue_repository.go | 3 +- persistence/radio_repository_test.go | 3 +- plugins/host_kvstore_test.go | 9 ++-- plugins/host_library_test.go | 45 ++++++---------- plugins/host_websocket.go | 3 +- plugins/manifest_test.go | 3 +- plugins/package_test.go | 3 +- scanner/controller.go | 3 +- scanner/external.go | 3 +- server/auth.go | 3 +- server/nativeapi/artists.go | 6 +-- server/nativeapi/queue_test.go | 23 ++++---- server/public/handle_streams_test.go | 6 +-- server/subsonic/api_test.go | 3 +- server/subsonic/browsing.go | 3 +- server/subsonic/helpers.go | 7 ++- server/subsonic/media_retrieval_test.go | 3 +- server/subsonic/playlists.go | 3 +- server/subsonic/responses/responses_test.go | 23 ++++---- server/subsonic/sharing.go | 8 +-- server/subsonic/users.go | 3 +- tests/mock_user_repo.go | 5 +- utils/cache/cached_http_client.go | 3 +- utils/cache/simple_cache.go | 3 +- utils/chrono/meter.go | 4 +- utils/gg/gg.go | 5 -- utils/gg/gg_test.go | 15 +----- utils/req/req.go | 6 +-- 51 files changed, 143 insertions(+), 251 deletions(-) diff --git a/.gitignore b/.gitignore index 73475a53a..fc8eaac69 100644 --- a/.gitignore +++ b/.gitignore @@ -37,5 +37,6 @@ AGENTS.md *.wasm *.ndp openspec/ +.agents go.work* -.worktrees/ \ No newline at end of file +.worktrees/ diff --git a/adapters/gotaglib/end_to_end_test.go b/adapters/gotaglib/end_to_end_test.go index 4a93f5b83..e7dd18ac1 100644 --- a/adapters/gotaglib/end_to_end_test.go +++ b/adapters/gotaglib/end_to_end_test.go @@ -8,7 +8,6 @@ import ( "github.com/djherbis/times" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -91,8 +90,7 @@ var _ = Describe("Extractor", func() { info.FileInfo = testFileInfo{FileInfo: fileInfo} metadata := metadata.New(path, info) - mf := metadata.ToMediaFile(1, "folderID") - return &mf + return new(metadata.ToMediaFile(1, "folderID")) } BeforeEach(func() { @@ -109,7 +107,7 @@ var _ = Describe("Extractor", func() { Expect(mf.RGAlbumPeak).To(Equal(albumPeak)) }, Entry("mp3 with no replaygain", "no_replaygain.mp3", nil, nil, nil, nil), - Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", gg.P(0.0), gg.P(1.0), gg.P(0.0), gg.P(1.0)), + Entry("mp3 with no zero replaygain", "zero_replaygain.mp3", new(0.0), new(1.0), new(0.0), new(1.0)), ) }) @@ -120,8 +118,8 @@ var _ = Describe("Extractor", func() { DisplayTitle: "", Lang: code, Line: []model.Line{ - {Start: gg.P(int64(0)), Value: "This is"}, - {Start: gg.P(int64(2500)), Value: secondLine}, + {Start: new(int64(0)), Value: "This is"}, + {Start: new(int64(2500)), Value: secondLine}, }, Offset: nil, Synced: true, diff --git a/core/external/provider.go b/core/external/provider.go index 4f3295cc7..74dab4972 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -153,7 +153,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl return album, err } - album.ExternalInfoUpdatedAt = P(time.Now()) + album.ExternalInfoUpdatedAt = new(time.Now()) album.ExternalUrl = info.URL if info.Description != "" { @@ -269,7 +269,7 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au return artist, ctx.Err() } - artist.ExternalInfoUpdatedAt = P(time.Now()) + artist.ExternalInfoUpdatedAt = new(time.Now()) err := e.ds.Artist(ctx).UpdateExternalInfo(&artist.Artist) if err != nil { log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName, diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go index 37d3fd81a..79612d651 100644 --- a/core/external/provider_artistimage_test.go +++ b/core/external/provider_artistimage_test.go @@ -272,12 +272,11 @@ var _ = Describe("Provider - ArtistImage", func() { It("returns cached URL and does not call agent when info is not expired", func() { // Arrange: artist has a cached image URL with recent ExternalInfoUpdatedAt - recentTime := time.Now().Add(-1 * time.Minute) cachedArtist := &model.Artist{ ID: "artist-cached", Name: "Cached Artist", LargeImageUrl: "http://example.com/cached-large.jpg", - ExternalInfoUpdatedAt: &recentTime, + ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Minute)), } mockArtistRepo.On("Get", "artist-cached").Return(cachedArtist, nil).Maybe() expectedURL, _ := url.Parse("http://example.com/cached-large.jpg") @@ -304,12 +303,11 @@ var _ = Describe("Provider - ArtistImage", func() { It("returns stale URL and enqueues refresh when info is expired", func() { // Arrange conf.Server.DevArtistInfoTimeToLive = 1 * time.Nanosecond - expiredTime := time.Now().Add(-1 * time.Hour) staleArtist := &model.Artist{ ID: "artist-expired", Name: "Expired Artist", LargeImageUrl: "http://example.com/expired-large.jpg", - ExternalInfoUpdatedAt: &expiredTime, + ExternalInfoUpdatedAt: new(time.Now().Add(-1 * time.Hour)), } mockArtistRepo.On("Get", "artist-expired").Return(staleArtist, nil).Maybe() expectedURL, _ := url.Parse("http://example.com/expired-large.jpg") diff --git a/core/external/provider_updatealbuminfo_test.go b/core/external/provider_updatealbuminfo_test.go index 3dd8a587a..21824c93f 100644 --- a/core/external/provider_updatealbuminfo_test.go +++ b/core/external/provider_updatealbuminfo_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -90,7 +89,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ExternalUrl: "http://cached.com/album", Description: "Cached Desc", LargeImageUrl: "http://cached.com/large.jpg", - ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)), + ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevAlbumInfoTimeToLive / 2)), } mockAlbumRepo.SetData(model.Albums{*originalAlbum}) @@ -113,7 +112,7 @@ var _ = Describe("Provider - UpdateAlbumInfo", func() { ExternalUrl: "http://expired.com/album", Description: "Expired Desc", LargeImageUrl: "http://expired.com/large.jpg", - ExternalInfoUpdatedAt: gg.P(expiredTime), + ExternalInfoUpdatedAt: new(expiredTime), } mockAlbumRepo.SetData(model.Albums{*originalAlbum}) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go index cc9506d1f..d783128fb 100644 --- a/core/external/provider_updateartistinfo_test.go +++ b/core/external/provider_updateartistinfo_test.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/stretchr/testify/mock" @@ -137,7 +136,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ExternalUrl: "http://cached.url", Biography: "Cached Bio", LargeImageUrl: "http://cached_large.jpg", - ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), + ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), SimilarArtists: model.Artists{ {ID: "ar-similar-present", Name: "Similar Present"}, {ID: "ar-similar-absent", Name: "Similar Absent"}, @@ -174,7 +173,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { originalArtist := &model.Artist{ ID: "ar-expired", Name: "Expired Artist", - ExternalInfoUpdatedAt: gg.P(expiredTime), + ExternalInfoUpdatedAt: new(expiredTime), SimilarArtists: model.Artists{ {ID: "ar-exp-similar", Name: "Expired Similar"}, }, @@ -205,7 +204,7 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { originalArtist := &model.Artist{ ID: "ar-similar-test", Name: "Similar Test Artist", - ExternalInfoUpdatedAt: gg.P(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), + ExternalInfoUpdatedAt: new(now.Add(-conf.Server.DevArtistInfoTimeToLive / 2)), SimilarArtists: model.Artists{ {ID: "ar-sim-present", Name: "Similar Present"}, {ID: "", Name: "Similar Absent Raw"}, diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 3225ff150..a3f6cd7d2 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -326,8 +326,7 @@ func (j *ffCmd) start(ctx context.Context) error { func (j *ffCmd) wait() { if err := j.cmd.Wait(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { 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 diff --git a/core/inspect.go b/core/inspect.go index 751cf063f..01ec33760 100644 --- a/core/inspect.go +++ b/core/inspect.go @@ -7,7 +7,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" - . "github.com/navidrome/navidrome/utils/gg" ) type InspectOutput struct { @@ -44,7 +43,7 @@ func Inspect(filePath string, libraryId int, folderId string) (*InspectOutput, e result := &InspectOutput{ File: filePath, RawTags: tags[file].Tags, - MappedTags: P(md.ToMediaFile(libraryId, folderId)), + MappedTags: new(md.ToMediaFile(libraryId, folderId)), } return result, nil diff --git a/core/lyrics/lyrics_test.go b/core/lyrics/lyrics_test.go index 7e837782e..9ab732ad1 100644 --- a/core/lyrics/lyrics_test.go +++ b/core/lyrics/lyrics_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -32,15 +31,15 @@ var _ = Describe("sources", func() { Lang: "eng", Line: []model.Line{ { - Start: gg.P(int64(18800)), + Start: new(int64(18800)), Value: "We're no strangers to love", }, { - Start: gg.P(int64(22801)), + Start: new(int64(22801)), Value: "You know the rules and so do I", }, }, - Offset: gg.P(int64(-100)), + Offset: new(int64(-100)), Synced: true, }, } diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go index b3d502101..d1aefcb5d 100644 --- a/core/lyrics/sources_test.go +++ b/core/lyrics/sources_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -74,15 +73,15 @@ var _ = Describe("sources", func() { Lang: "eng", Line: []model.Line{ { - Start: gg.P(int64(18800)), + Start: new(int64(18800)), Value: "We're no strangers to love", }, { - Start: gg.P(int64(22801)), + Start: new(int64(22801)), Value: "You know the rules and so do I", }, }, - Offset: gg.P(int64(-100)), + Offset: new(int64(-100)), Synced: true, }, })) @@ -122,7 +121,7 @@ var _ = Describe("sources", func() { // The critical assertion: even with BOM, synced should be true Expect(lyrics[0].Synced).To(BeTrue(), "Lyrics with BOM marker should be recognized as synced") Expect(lyrics[0].Line).To(HaveLen(1)) - Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(0)))) + Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(0)))) Expect(lyrics[0].Line[0].Value).To(ContainSubstring("作曲")) }) @@ -137,9 +136,9 @@ var _ = Describe("sources", func() { // UTF-16 should be properly converted to UTF-8 Expect(lyrics[0].Synced).To(BeTrue(), "UTF-16 encoded lyrics should be recognized as synced") Expect(lyrics[0].Line).To(HaveLen(2)) - Expect(lyrics[0].Line[0].Start).To(Equal(gg.P(int64(18800)))) + Expect(lyrics[0].Line[0].Start).To(Equal(new(int64(18800)))) Expect(lyrics[0].Line[0].Value).To(Equal("We're no strangers to love")) - Expect(lyrics[0].Line[1].Start).To(Equal(gg.P(int64(22801)))) + Expect(lyrics[0].Line[1].Start).To(Equal(new(int64(22801)))) Expect(lyrics[0].Line[1].Value).To(Equal("You know the rules and so do I")) }) }) diff --git a/core/playback/mpv/mpv.go b/core/playback/mpv/mpv.go index 035e18dd5..6696eca2a 100644 --- a/core/playback/mpv/mpv.go +++ b/core/playback/mpv/mpv.go @@ -62,8 +62,7 @@ func (j *Executor) start(ctx context.Context) error { func (j *Executor) wait() { if err := j.cmd.Wait(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { _ = j.out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %d", j.args[0], exitErr.ExitCode())) } else { _ = j.out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", j.args[0], err)) diff --git a/core/playlists/parse_nsp.go b/core/playlists/parse_nsp.go index 56c80a950..a5b8b7c02 100644 --- a/core/playlists/parse_nsp.go +++ b/core/playlists/parse_nsp.go @@ -59,8 +59,7 @@ func (s *playlists) parseNSP(_ context.Context, pls *model.Playlist, reader io.R } err = json.Unmarshal(input, nsp) if err != nil { - var syntaxErr *json.SyntaxError - if errors.As(err, &syntaxErr) { + if syntaxErr, ok := errors.AsType[*json.SyntaxError](err); ok { line, col := getPositionFromOffset(input, syntaxErr.Offset) return fmt.Errorf("JSON syntax error in SmartPlaylist at line %d, column %d: %w", line, col, err) } diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index 95d7b3e6a..f849a0a21 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -144,29 +144,25 @@ var _ = Describe("Playlists", func() { It("allows owner to update their playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - newName := "Updated Name" - err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) It("allows admin to update any playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "admin-1", IsAdmin: true}) - newName := "Updated Name" - err := ps.Update(ctx, "pls-other", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "pls-other", new("Updated Name"), nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) It("denies non-owner, non-admin from updating", func() { ctx = request.WithUser(ctx, model.User{ID: "other-user", IsAdmin: false}) - newName := "Updated Name" - err := ps.Update(ctx, "pls-1", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "pls-1", new("Updated Name"), nil, nil, nil, nil) Expect(err).To(MatchError(model.ErrNotAuthorized)) }) It("returns error when playlist not found", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - newName := "Updated Name" - err := ps.Update(ctx, "nonexistent", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "nonexistent", new("Updated Name"), nil, nil, nil, nil) Expect(err).To(Equal(model.ErrNotFound)) }) @@ -184,8 +180,7 @@ var _ = Describe("Playlists", func() { It("allows metadata updates on a smart playlist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) - newName := "Updated Smart" - err := ps.Update(ctx, "pls-smart", &newName, nil, nil, nil, nil) + err := ps.Update(ctx, "pls-smart", new("Updated Smart"), nil, nil, nil, nil) Expect(err).ToNot(HaveOccurred()) }) }) diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 90d22327a..68461b259 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -63,7 +63,6 @@ var _ = Describe("REST Adapter", func() { It("clears server-managed fields to prevent injection via REST API", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) repo = ps.NewRepository(ctx).(rest.Persistable) - now := time.Now() pls := &model.Playlist{ Name: "Legit Playlist", Comment: "A comment", @@ -73,7 +72,7 @@ var _ = Describe("REST Adapter", func() { Sync: true, UploadedImage: "injected-image-path", ExternalImageURL: "http://evil.example.com/ssrf", - EvaluatedAt: &now, + EvaluatedAt: new(time.Now()), } _, err := repo.Save(pls) Expect(err).ToNot(HaveOccurred()) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 684b887fe..5383244cd 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -1133,8 +1133,7 @@ func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession if f.Error != nil { return f.Error } - uid := info.UserId - f.userID.Store(&uid) + f.userID.Store(new(info.UserId)) f.LastPlaybackReport.Store(&info) return nil } diff --git a/core/share.go b/core/share.go index a6d06a018..eb9b63ae9 100644 --- a/core/share.go +++ b/core/share.go @@ -41,7 +41,7 @@ func (s *shareService) Load(ctx context.Context, id string) (*model.Share, error if !expiresAt.IsZero() && expiresAt.Before(time.Now()) { return nil, model.ErrExpired } - share.LastVisitedAt = P(time.Now()) + share.LastVisitedAt = new(time.Now()) share.VisitCount++ err = repo.(rest.Persistable).Update(id, share, "last_visited_at", "visit_count") @@ -95,7 +95,7 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) { } s.ID = id if V(s.ExpiresAt).IsZero() { - s.ExpiresAt = P(time.Now().Add(conf.Server.DefaultShareExpiration)) + s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration)) } firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0] diff --git a/model/artwork_id_test.go b/model/artwork_id_test.go index b634e7cbc..ad66f7bb5 100644 --- a/model/artwork_id_test.go +++ b/model/artwork_id_test.go @@ -11,8 +11,7 @@ import ( var _ = Describe("ArtworkID", func() { Describe("NewArtworkID()", func() { It("creates a valid parseable ArtworkID", func() { - now := time.Now() - id := model.NewArtworkID(model.KindAlbumArtwork, "1234", &now) + id := model.NewArtworkID(model.KindAlbumArtwork, "1234", new(time.Now())) parsedId, err := model.ParseArtworkID(id.String()) Expect(err).ToNot(HaveOccurred()) Expect(parsedId.Kind).To(Equal(id.Kind)) diff --git a/model/lyrics_test.go b/model/lyrics_test.go index 382976872..644b85ad2 100644 --- a/model/lyrics_test.go +++ b/model/lyrics_test.go @@ -8,14 +8,13 @@ import ( var _ = Describe("ToLyrics", func() { It("should parse tags with spaces", func() { - num := int64(1551) lyrics, err := ToLyrics("xxx", "[lang: eng ]\n[offset: 1551 ]\n[ti: A title ]\n[ar: An artist ]\n[00:00.00]Hi there") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Lang).To(Equal("eng")) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.DisplayArtist).To(Equal("An artist")) Expect(lyrics.DisplayTitle).To(Equal("A title")) - Expect(lyrics.Offset).To(Equal(&num)) + Expect(lyrics.Offset).To(Equal(new(int64(1551)))) }) It("Should ignore bad offset", func() { @@ -25,39 +24,36 @@ var _ = Describe("ToLyrics", func() { }) It("should accept lines with no text and weird times", func() { - a, b, c, d := int64(0), int64(10040), int64(40000), int64(1000*60*60) lyrics, err := ToLyrics("xxx", "[00:00.00]Hi there\n\n\n[00:10.040]\n[00:40]Test\n[01:00:00]late") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "Hi there"}, - {Start: &b, Value: ""}, - {Start: &c, Value: "Test"}, - {Start: &d, Value: "late"}, + {Start: new(int64(0)), Value: "Hi there"}, + {Start: new(int64(10040)), Value: ""}, + {Start: new(int64(40000)), Value: "Test"}, + {Start: new(int64(1000 * 60 * 60)), Value: "late"}, })) }) It("Should support multiple timestamps per line", func() { - a, b, c, d := int64(0), int64(10000), int64(13*60*1000), int64(1000*60*60*51) lyrics, err := ToLyrics("xxx", "[00:00.00] [00:10.00]Repeated\n[13:00][51:00:00.00]") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "Repeated"}, - {Start: &b, Value: "Repeated"}, - {Start: &c, Value: ""}, - {Start: &d, Value: ""}, + {Start: new(int64(0)), Value: "Repeated"}, + {Start: new(int64(10000)), Value: "Repeated"}, + {Start: new(int64(13 * 60 * 1000)), Value: ""}, + {Start: new(int64(1000 * 60 * 60 * 51)), Value: ""}, })) }) It("Should support parsing multiline string", func() { - a, b := int64(0), int64(10*60*1000+1) lyrics, err := ToLyrics("xxx", "[00:00.00]This is\na multiline \n\n [:0] string\n[10:00.001]This is\nalso one") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "This is\na multiline\n\n[:0] string"}, - {Start: &b, Value: "This is\nalso one"}, + {Start: new(int64(0)), Value: "This is\na multiline\n\n[:0] string"}, + {Start: new(int64(10*60*1000 + 1)), Value: "This is\nalso one"}, })) }) @@ -71,49 +67,45 @@ var _ = Describe("ToLyrics", func() { }) It("Allows timestamp in middle of line if also at beginning", func() { - a, b := int64(0), int64(1000) lyrics, err := ToLyrics("xxx", " [00:00] This is [00:00:00] be a synced file\n [00:01]Line 2") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "This is [00:00:00] be a synced file"}, - {Start: &b, Value: "Line 2"}, + {Start: new(int64(0)), Value: "This is [00:00:00] be a synced file"}, + {Start: new(int64(1000)), Value: "Line 2"}, })) }) It("Ignores lines in synchronized lyric prior to first timestamp", func() { - a := int64(0) lyrics, err := ToLyrics("xxx", "This is some prelude\nThat doesn't\nmatter\n[00:00]Text") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "Text"}, + {Start: new(int64(0)), Value: "Text"}, })) }) It("Handles all possible ms cases", func() { - a, b, c := int64(1), int64(10), int64(100) lyrics, err := ToLyrics("xxx", "[00:00.001]a\n[00:00.01]b\n[00:00.1]c") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "a"}, - {Start: &b, Value: "b"}, - {Start: &c, Value: "c"}, + {Start: new(int64(1)), Value: "a"}, + {Start: new(int64(10)), Value: "b"}, + {Start: new(int64(100)), Value: "c"}, })) }) It("Properly sorts repeated lyrics out of order", func() { - a, b, c, d, e := int64(0), int64(10000), int64(40000), int64(13*60*1000), int64(1000*60*60*51) lyrics, err := ToLyrics("xxx", "[00:00.00] [13:00]Repeated\n[00:10.00][51:00:00.00]Test\n[00:40.00]Not repeated") Expect(err).ToNot(HaveOccurred()) Expect(lyrics.Synced).To(BeTrue()) Expect(lyrics.Line).To(Equal([]Line{ - {Start: &a, Value: "Repeated"}, - {Start: &b, Value: "Test"}, - {Start: &c, Value: "Not repeated"}, - {Start: &d, Value: "Repeated"}, - {Start: &e, Value: "Test"}, + {Start: new(int64(0)), Value: "Repeated"}, + {Start: new(int64(10000)), Value: "Test"}, + {Start: new(int64(40000)), Value: "Not repeated"}, + {Start: new(int64(13 * 60 * 1000)), Value: "Repeated"}, + {Start: new(int64(1000 * 60 * 60 * 51)), Value: "Test"}, })) }) }) diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go index e3adf3fae..16142f526 100644 --- a/model/metadata/map_mediafile_test.go +++ b/model/metadata/map_mediafile_test.go @@ -8,7 +8,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" - . "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -108,8 +107,8 @@ var _ = Describe("ToMediaFile", func() { expected := model.LyricList{ {Lang: "eng", Line: []model.Line{ - {Value: "This is", Start: P(int64(0))}, - {Value: "English SYLT", Start: P(int64(2500))}, + {Value: "This is", Start: new(int64(0))}, + {Value: "English SYLT", Start: new(int64(2500))}, }, Synced: true}, {Lang: "xxx", Line: []model.Line{{Value: "Lyrics"}}, Synced: false}, } diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index 663e306c4..c9acdbce9 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -8,7 +8,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/utils" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -274,8 +273,8 @@ var _ = Describe("Metadata", func() { mf := createMF("replaygain_track_gain", tagValue) Expect(mf.RGTrackGain).To(Equal(expected)) }, - Entry("0", "0", gg.P(0.0)), - Entry("1.2dB", "1.2dB", gg.P(1.2)), + Entry("0", "0", new(0.0)), + Entry("1.2dB", "1.2dB", new(1.2)), Entry("Infinity", "Infinity", nil), Entry("Invalid value", "INVALID VALUE", nil), Entry("NaN", "NaN", nil), @@ -285,9 +284,9 @@ var _ = Describe("Metadata", func() { mf := createMF("replaygain_track_peak", tagValue) Expect(mf.RGTrackPeak).To(Equal(expected)) }, - Entry("0", "0", gg.P(0.0)), - Entry("1.0", "1.0", gg.P(1.0)), - Entry("0.5", "0.5", gg.P(0.5)), + Entry("0", "0", new(0.0)), + Entry("1.0", "1.0", new(1.0)), + Entry("0.5", "0.5", new(0.5)), Entry("Invalid dB suffix", "0.7dB", nil), Entry("Infinity", "Infinity", nil), Entry("Invalid value", "INVALID VALUE", nil), @@ -299,8 +298,8 @@ var _ = Describe("Metadata", func() { Expect(mf.RGTrackGain).To(Equal(expected)) }, - Entry("0", "0", gg.P(5.0)), - Entry("-3776", "-3776", gg.P(-9.75)), + Entry("0", "0", new(5.0)), + Entry("-3776", "-3776", new(-9.75)), Entry("Infinity", "Infinity", nil), Entry("Invalid value", "INVALID VALUE", nil), ) diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go index e75a0e58c..cfdc499e0 100644 --- a/persistence/artist_repository.go +++ b/persistence/artist_repository.go @@ -18,7 +18,6 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils" - . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/slice" "github.com/pocketbase/dbx" ) @@ -219,7 +218,7 @@ func (r *artistRepository) Exists(id string) (bool, error) { func (r *artistRepository) Put(a *model.Artist, colsToUpdate ...string) error { dba := &dbArtist{Artist: a} - dba.CreatedAt = P(time.Now()) + dba.CreatedAt = new(time.Now()) dba.UpdatedAt = dba.CreatedAt _, err := r.put(dba.ID, dba, colsToUpdate...) return err diff --git a/persistence/genre_repository.go b/persistence/genre_repository.go index 53f324bf4..22443284f 100644 --- a/persistence/genre_repository.go +++ b/persistence/genre_repository.go @@ -14,9 +14,8 @@ type genreRepository struct { } func NewGenreRepository(ctx context.Context, db dbx.Builder) model.GenreRepository { - genreFilter := model.TagGenre return &genreRepository{ - baseTagRepository: newBaseTagRepository(ctx, db, &genreFilter), + baseTagRepository: newBaseTagRepository(ctx, db, new(model.TagGenre)), } } diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go index ebc247d77..abc5c4b6a 100644 --- a/persistence/persistence_suite_test.go +++ b/persistence/persistence_suite_test.go @@ -13,7 +13,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/pocketbase/dbx" @@ -103,7 +102,7 @@ var ( songAntenna = mf(model.MediaFile{ID: "1004", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103", Path: p("kraft/radio/antenna.mp3"), - RGAlbumGain: gg.P(1.0), RGAlbumPeak: gg.P(2.0), RGTrackGain: gg.P(3.0), RGTrackPeak: gg.P(4.0), + RGAlbumGain: new(1.0), RGAlbumPeak: new(2.0), RGTrackGain: new(3.0), RGTrackPeak: new(4.0), }) songAntennaWithLyrics = mf(model.MediaFile{ ID: "1005", @@ -162,8 +161,6 @@ func p(path string) string { return filepath.FromSlash(path) } -// Initialize test DB -// TODO Load this data setup from file(s) var _ = BeforeSuite(func() { conn := GetDBXBuilder() ctx := log.NewContext(context.TODO()) @@ -187,8 +184,7 @@ var _ = BeforeSuite(func() { alr := NewAlbumRepository(ctx, conn).(*albumRepository) for i := range testAlbums { - a := testAlbums[i] - err := alr.Put(&a) + err := alr.Put(new(testAlbums[i])) if err != nil { panic(err) } @@ -196,8 +192,7 @@ var _ = BeforeSuite(func() { arr := NewArtistRepository(ctx, conn) for i := range testArtists { - a := testArtists[i] - err := arr.Put(&a) + err := arr.Put(new(testArtists[i])) if err != nil { panic(err) } @@ -243,8 +238,7 @@ var _ = BeforeSuite(func() { rar := NewRadioRepository(ctx, conn) for i := range testRadios { - r := testRadios[i] - err := rar.Put(&r) + err := rar.Put(new(testRadios[i])) if err != nil { panic(err) } diff --git a/persistence/playqueue_repository.go b/persistence/playqueue_repository.go index c952b42b1..ba69ec746 100644 --- a/persistence/playqueue_repository.go +++ b/persistence/playqueue_repository.go @@ -89,8 +89,7 @@ func (r *playQueueRepository) Retrieve(userId string) (*model.PlayQueue, error) sel := r.newSelect().Columns("*").Where(Eq{"user_id": userId}) var res playQueue err := r.queryOne(sel, &res) - q := r.toModel(&res) - return &q, err + return new(r.toModel(&res)), err } func (r *playQueueRepository) fromModel(q *model.PlayQueue) playQueue { diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index 88a31ac49..828af8043 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -34,8 +34,7 @@ var _ = Describe("RadioRepository", func() { } for i := range testRadios { - r := testRadios[i] - err := repo.Put(&r) + err := repo.Put(new(testRadios[i])) if err != nil { panic(err) } diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go index 109ae8131..997409146 100644 --- a/plugins/host_kvstore_test.go +++ b/plugins/host_kvstore_test.go @@ -37,8 +37,7 @@ var _ = Describe("KVStoreService", func() { conf.Server.DataFolder = conf.NewDir(tmpDir) // Create service with 1KB limit for testing - maxSize := "1KB" - service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize}) + service, err = newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")}) Expect(err).ToNot(HaveOccurred()) }) @@ -253,8 +252,7 @@ var _ = Describe("KVStoreService", func() { // Close and reopen the service (simulating restart) Expect(service.Close()).To(Succeed()) - maxSize := "1KB" - service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: &maxSize}) + service2, err := newKVStoreService(ctx, "test_plugin", &KVStorePermission{MaxSize: new("1KB")}) Expect(err).ToNot(HaveOccurred()) defer service2.Close() @@ -452,8 +450,7 @@ var _ = Describe("KVStoreService", func() { closeCtx, closeCancel := context.WithCancel(ctx) defer closeCancel() - maxSize := "1KB" - svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: &maxSize}) + svc, err := newKVStoreService(closeCtx, "test_close_race", &KVStorePermission{MaxSize: new("1KB")}) Expect(err).ToNot(HaveOccurred()) // Insert an expired key so cleanup has work to do diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go index 67f5f9b0f..eb5b17a02 100644 --- a/plugins/host_library_test.go +++ b/plugins/host_library_test.go @@ -35,8 +35,7 @@ var _ = Describe("LibraryService", Ordered, func() { Describe("GetLibrary", func() { It("should return library metadata without filesystem permission", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, nil, true).(*libraryServiceImpl) lib := &model.Library{ ID: 1, @@ -67,8 +66,7 @@ var _ = Describe("LibraryService", Ordered, func() { }) It("should return library metadata with filesystem permission", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: true}, nil, true).(*libraryServiceImpl) lib := &model.Library{ ID: 2, @@ -93,8 +91,7 @@ var _ = Describe("LibraryService", Ordered, func() { }) It("should return error for non-existent library", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test")}, nil, true).(*libraryServiceImpl) mockLibRepo := ds.Library(ctx).(*tests.MockLibraryRepo) mockLibRepo.SetData(model.Libraries{}) @@ -107,8 +104,7 @@ var _ = Describe("LibraryService", Ordered, func() { Describe("GetAllLibraries", func() { It("should return all libraries without filesystem permission", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, nil, true).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -130,8 +126,7 @@ var _ = Describe("LibraryService", Ordered, func() { }) It("should return all libraries with filesystem permission", func() { - reason := "test" - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: true}, nil, true).(*libraryServiceImpl) + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: true}, nil, true).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -152,10 +147,8 @@ var _ = Describe("LibraryService", Ordered, func() { }) Describe("Library Access Filtering", func() { - It("should only return libraries in the allowed list", func() { - reason := "test" - // Only allow library ID 2 - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl) + It("should only return libraries in the allowed list", func() { // Only allow library ID 2 + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -173,10 +166,8 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(results[0].Name).To(Equal("Jazz")) }) - It("should return error when getting a library not in the allowed list", func() { - reason := "test" - // Only allow library ID 2 - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl) + It("should return error when getting a library not in the allowed list", func() { // Only allow library ID 2 + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -192,10 +183,8 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(err.Error()).To(ContainSubstring("not accessible")) }) - It("should allow access to a library in the allowed list", func() { - reason := "test" - // Only allow library ID 2 - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{2}, false).(*libraryServiceImpl) + It("should allow access to a library in the allowed list", func() { // Only allow library ID 2 + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{2}, false).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -211,10 +200,8 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(result.Name).To(Equal("Jazz")) }) - It("should return empty list when no libraries are allowed and allLibraries is false", func() { - reason := "test" - // No libraries allowed - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{}, false).(*libraryServiceImpl) + It("should return empty list when no libraries are allowed and allLibraries is false", func() { // No libraries allowed + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{}, false).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, @@ -229,10 +216,8 @@ var _ = Describe("LibraryService", Ordered, func() { Expect(results).To(HaveLen(0)) }) - It("should return all libraries when allLibraries is true regardless of allowed list", func() { - reason := "test" - // allLibraries=true should ignore the allowed list - service = newLibraryService(ds, &LibraryPermission{Reason: &reason, Filesystem: false}, []int{1}, true).(*libraryServiceImpl) + It("should return all libraries when allLibraries is true regardless of allowed list", func() { // allLibraries=true should ignore the allowed list + service = newLibraryService(ds, &LibraryPermission{Reason: new("test"), Filesystem: false}, []int{1}, true).(*libraryServiceImpl) libs := model.Libraries{ {ID: 1, Name: "Rock", Path: "/music/rock", TotalSongs: 100}, diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index 74238a422..eef1e6236 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -302,8 +302,7 @@ func (s *webSocketServiceImpl) readLoop(ctx context.Context, connectionID string if websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) { closeCode := websocket.CloseNoStatusReceived closeReason := "" - var ce *websocket.CloseError - if errors.As(err, &ce) { + if ce, ok := errors.AsType[*websocket.CloseError](err); ok { closeCode = ce.Code closeReason = ce.Text } diff --git a/plugins/manifest_test.go b/plugins/manifest_test.go index c45a480eb..0d371d14a 100644 --- a/plugins/manifest_test.go +++ b/plugins/manifest_test.go @@ -140,11 +140,10 @@ var _ = Describe("Manifest", func() { }) It("returns true when threads feature has a reason", func() { - reason := "Required for concurrent processing" m := &Manifest{ Experimental: &Experimental{ Threads: &ThreadsFeature{ - Reason: &reason, + Reason: new("Required for concurrent processing"), }, }, } diff --git a/plugins/package_test.go b/plugins/package_test.go index fa76ddd94..3d3fec022 100644 --- a/plugins/package_test.go +++ b/plugins/package_test.go @@ -135,12 +135,11 @@ var _ = Describe("ndpPackage", func() { Describe("readManifest", func() { It("should read only the manifest without loading wasm", func() { ndpPath := filepath.Join(tmpDir, "test.ndp") - desc := "A test plugin" manifest := &Manifest{ Name: "Test Plugin", Author: "Test Author", Version: "1.0.0", - Description: &desc, + Description: new("A test plugin"), } wasmBytes := make([]byte, 1024*1024) // 1MB of zeros diff --git a/scanner/controller.go b/scanner/controller.go index 94248ffd0..175b92e26 100644 --- a/scanner/controller.go +++ b/scanner/controller.go @@ -17,7 +17,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" - . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/pl" "golang.org/x/time/rate" ) @@ -38,7 +37,7 @@ func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, br devExternalScanner: conf.Server.DevExternalScanner, } if !c.devExternalScanner { - c.limiter = P(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate}) + c.limiter = new(rate.Sometimes{Interval: conf.Server.DevActivityPanelUpdateRate}) } return c } diff --git a/scanner/external.go b/scanner/external.go index 393a9278c..7f573fe3e 100644 --- a/scanner/external.go +++ b/scanner/external.go @@ -97,8 +97,7 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod func (s *scannerExternal) wait(cmd *exec.Cmd, out *io.PipeWriter) { if err := cmd.Wait(); err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { + if exitErr, ok := errors.AsType[*exec.ExitError](err); ok { _ = out.CloseWithError(fmt.Errorf("%s exited with non-zero status code: %w", cmd, exitErr)) } else { _ = out.CloseWithError(fmt.Errorf("waiting %s cmd: %w", cmd, err)) diff --git a/server/auth.go b/server/auth.go index a7edaab0a..6a25f1406 100644 --- a/server/auth.go +++ b/server/auth.go @@ -135,7 +135,6 @@ func createAdmin(ds model.DataStore) func(w http.ResponseWriter, r *http.Request func createAdminUser(ctx context.Context, ds model.DataStore, username, password string) error { log.Warn(ctx, "Creating initial user", "user", username) - now := time.Now() caser := cases.Title(language.Und) initialUser := model.User{ ID: id.NewRandom(), @@ -144,7 +143,7 @@ func createAdminUser(ctx context.Context, ds model.DataStore, username, password Email: "", NewPassword: password, IsAdmin: true, - LastLoginAt: &now, + LastLoginAt: new(time.Now()), } err := ds.User(ctx).Put(&initialUser) if err != nil { diff --git a/server/nativeapi/artists.go b/server/nativeapi/artists.go index 1b78bb93e..daa918d00 100644 --- a/server/nativeapi/artists.go +++ b/server/nativeapi/artists.go @@ -45,8 +45,7 @@ func (api *Router) uploadArtistImage() http.HandlerFunc { return err } ar.UploadedImage = filename - now := time.Now() - ar.UpdatedAt = &now + ar.UpdatedAt = new(time.Now()) return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") }) } @@ -65,8 +64,7 @@ func (api *Router) deleteArtistImage() http.HandlerFunc { return err } ar.UploadedImage = "" - now := time.Now() - ar.UpdatedAt = &now + ar.UpdatedAt = new(time.Now()) return api.ds.Artist(ctx).Put(ar, "uploaded_image", "updated_at") }) } diff --git a/server/nativeapi/queue_test.go b/server/nativeapi/queue_test.go index ef971ee68..0aad09718 100644 --- a/server/nativeapi/queue_test.go +++ b/server/nativeapi/queue_test.go @@ -9,7 +9,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -32,7 +31,7 @@ var _ = Describe("Queue Endpoints", func() { Describe("POST /queue", func() { It("saves the queue", func() { - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1), Position: gg.P(int64(10))} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(1), Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) ctx := request.WithUser(req.Context(), user) @@ -50,7 +49,7 @@ var _ = Describe("Queue Endpoints", func() { }) It("saves an empty queue", func() { - payload := updateQueuePayload{Ids: gg.P([]string{}), Current: gg.P(0), Position: gg.P(int64(0))} + payload := updateQueuePayload{Ids: new([]string{}), Current: new(0), Position: new(int64(0))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -63,7 +62,7 @@ var _ = Describe("Queue Endpoints", func() { }) It("returns bad request for invalid current index (negative)", func() { - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(-1), Position: gg.P(int64(10))} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(-1), Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -75,7 +74,7 @@ var _ = Describe("Queue Endpoints", func() { }) It("returns bad request for invalid current index (too large)", func() { - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(2), Position: gg.P(int64(10))} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(2), Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -97,7 +96,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns internal server error when store fails", func() { repo.Err = true - payload := updateQueuePayload{Ids: gg.P([]string{"s1"}), Current: gg.P(0), Position: gg.P(int64(10))} + payload := updateQueuePayload{Ids: new([]string{"s1"}), Current: new(0), Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("POST", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -166,7 +165,7 @@ var _ = Describe("Queue Endpoints", func() { Describe("PUT /queue", func() { It("updates the queue fields", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}, {ID: "s2"}, {ID: "s3"}}} - payload := updateQueuePayload{Current: gg.P(2), Position: gg.P(int64(20))} + payload := updateQueuePayload{Current: new(2), Position: new(int64(20))} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) ctx := request.WithUser(req.Context(), user) @@ -184,7 +183,7 @@ var _ = Describe("Queue Endpoints", func() { It("updates only ids", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 1} - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"})} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -198,7 +197,7 @@ var _ = Describe("Queue Endpoints", func() { It("updates ids and current", func() { repo.Queue = &model.PlayQueue{UserID: user.ID} - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"}), Current: gg.P(1)} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"}), Current: new(1)} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -213,7 +212,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns bad request when new ids invalidate current", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Current: 2} - payload := updateQueuePayload{Ids: gg.P([]string{"s1", "s2"})} + payload := updateQueuePayload{Ids: new([]string{"s1", "s2"})} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -225,7 +224,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns bad request when current out of bounds", func() { repo.Queue = &model.PlayQueue{UserID: user.ID, Items: model.MediaFiles{{ID: "s1"}}} - payload := updateQueuePayload{Current: gg.P(3)} + payload := updateQueuePayload{Current: new(3)} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) @@ -246,7 +245,7 @@ var _ = Describe("Queue Endpoints", func() { It("returns internal server error when store fails", func() { repo.Err = true - payload := updateQueuePayload{Position: gg.P(int64(10))} + payload := updateQueuePayload{Position: new(int64(10))} body, _ := json.Marshal(payload) req := httptest.NewRequest("PUT", "/queue", bytes.NewReader(body)) req = req.WithContext(request.WithUser(req.Context(), user)) diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go index 222d5ef1a..f43d75a26 100644 --- a/server/public/handle_streams_test.go +++ b/server/public/handle_streams_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/tests" - . "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -89,7 +88,7 @@ var _ = Describe("encodeMediafileShare", func() { }) It("includes the share ID in the token", func() { - exp := P(time.Now().Add(time.Hour)) + exp := new(time.Now().Add(time.Hour)) s := model.Share{ID: "shareABC", Format: "mp3", MaxBitRate: 320, ExpiresAt: exp} token := encodeMediafileShare(s, "mf-999") info, err := decodeStreamInfo(token) @@ -164,8 +163,7 @@ var _ = Describe("handleStream", func() { It("returns 410 when share has been set to expired", func() { shareRepo.ID = "share123" - expired := time.Now().Add(-time.Hour) - shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: &expired} + shareRepo.Entity = &model.Share{ID: "share123", ExpiresAt: new(time.Now().Add(-time.Hour))} claims := auth.Claims{ID: "mf-123", ShareID: "share123"} token, _ := auth.CreatePublicToken(claims) diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index f3053c8af..a2db4a0af 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -9,7 +9,6 @@ import ( "strings" "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "golang.org/x/net/context" @@ -136,7 +135,7 @@ var _ = Describe("sendResponse", func() { It("should return a fail response", func() { payload.Song = &responses.Child{OpenSubsonicChild: &responses.OpenSubsonicChild{}} // An +Inf value will cause an error when marshalling to JSON - payload.Song.ReplayGain = responses.ReplayGain{TrackGain: gg.P(math.Inf(1))} + payload.Song.ReplayGain = responses.ReplayGain{TrackGain: new(math.Inf(1))} q := r.URL.Query() q.Add("f", "json") r.URL.RawQuery = q.Encode() diff --git a/server/subsonic/browsing.go b/server/subsonic/browsing.go index 5b9c4f3c9..817238aaf 100644 --- a/server/subsonic/browsing.go +++ b/server/subsonic/browsing.go @@ -256,8 +256,7 @@ func (api *Router) GetSong(r *http.Request) (*responses.Subsonic, error) { } response := newResponse() - child := childFromMediaFile(ctx, *mf) - response.Song = &child + response.Song = new(childFromMediaFile(ctx, *mf)) return response, nil } diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 8a9eb7110..0b18b1bc7 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -18,7 +18,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/number" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" @@ -217,7 +216,7 @@ func childFromMediaFile(ctx context.Context, mf model.MediaFile) responses.Child child.Path = fakePath(mf) } child.DiscNumber = int32(mf.DiscNumber) - child.Created = P(mf.BirthTime) + child.Created = new(mf.BirthTime) child.AlbumId = mf.AlbumID child.ArtistId = mf.ArtistID child.Type = "music" @@ -346,7 +345,7 @@ func childFromAlbum(ctx context.Context, al model.Album) responses.Child { child.Year = int32(cmp.Or(al.MaxOriginalYear, al.MaxYear)) child.Genre = al.Genre child.CoverArt = al.CoverArtID().String() - child.Created = P(albumCreatedAt(al)) + child.Created = new(albumCreatedAt(al)) child.Parent = al.AlbumArtistID child.ArtistId = al.AlbumArtistID child.Duration = int32(al.Duration) @@ -442,7 +441,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir.PlayCount = album.PlayCount dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear)) dir.Genre = album.Genre - dir.Created = P(albumCreatedAt(album)) + dir.Created = new(albumCreatedAt(album)) if album.Starred { dir.Starred = album.StarredAt } diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 27d1edb84..12c0dff56 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -294,7 +294,6 @@ var _ = Describe("MediaRetrievalController", func() { response, err := router.GetLyricsBySongId(r) Expect(err).ToNot(HaveOccurred()) - offset := int64(-100) compareResponses(response.LyricsList, responses.LyricsList{ StructuredLyrics: responses.StructuredLyrics{ { @@ -312,7 +311,7 @@ var _ = Describe("MediaRetrievalController", func() { Value: "You know the rules and so do I", }, }, - Offset: &offset, + Offset: new(int64(-100)), }, }, }) diff --git a/server/subsonic/playlists.go b/server/subsonic/playlists.go index a8c3da68c..7101f9f15 100644 --- a/server/subsonic/playlists.go +++ b/server/subsonic/playlists.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/subsonic/responses" - . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/req" "github.com/navidrome/navidrome/utils/slice" ) @@ -169,7 +168,7 @@ func buildOSPlaylist(ctx context.Context, p model.Playlist) *responses.OpenSubso pls.Readonly = true if p.EvaluatedAt != nil { - pls.ValidUntil = P(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay)) + pls.ValidUntil = new(p.EvaluatedAt.Add(conf.Server.SmartPlaylistRefreshDelay)) } } else { user, ok := request.UserFrom(ctx) diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 0b72fab19..3166df875 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -8,7 +8,6 @@ import ( "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/navidrome/navidrome/server/subsonic/responses" - "github.com/navidrome/navidrome/utils/gg" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -94,11 +93,10 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { artists := make([]Artist, 1) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) artists[0] = Artist{ Id: "111", Name: "aaa", - Starred: &t, + Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), UserRating: 3, ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", } @@ -133,11 +131,10 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { artists := make([]ArtistID3, 1) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) artists[0] = ArtistID3{ Id: "111", Name: "aaa", - Starred: &t, + Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), UserRating: 3, AlbumCount: 2, ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", @@ -158,11 +155,10 @@ var _ = Describe("Responses", func() { Context("with OpenSubsonic data", func() { BeforeEach(func() { artists := make([]ArtistID3, 1) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) artists[0] = ArtistID3{ Id: "111", Name: "aaa", - Starred: &t, + Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), UserRating: 3, AlbumCount: 2, ArtistImageUrl: "https://lastfm.freetls.fastly.net/i/u/300x300/2a96cbd8b46e442fc41c2b86b821562f.png", @@ -211,12 +207,11 @@ var _ = Describe("Responses", func() { BeforeEach(func() { response.Directory = &Directory{Id: "1", Name: "N"} child := make([]Child, 2) - t := time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC) child[0] = Child{ Id: "1", IsDir: true, Title: "title", Album: "album", Artist: "artist", Track: 1, Year: 1985, Genre: "Rock", CoverArt: "1", Size: 8421341, ContentType: "audio/flac", Suffix: "flac", TranscodedContentType: "audio/mpeg", TranscodedSuffix: "mp3", - Duration: 146, BitRate: 320, Starred: &t, + Duration: 146, BitRate: 320, Starred: new(time.Date(2016, 03, 2, 20, 30, 0, 0, time.UTC)), } child[0].OpenSubsonicChild = &OpenSubsonicChild{ Genres: []ItemGenre{{Name: "rock"}, {Name: "progressive"}}, @@ -225,7 +220,7 @@ var _ = Describe("Responses", func() { BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, Moods: []string{"happy", "sad"}, Groupings: []string{"Soundtrack", "Live"}, - ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)}, + ReplayGain: ReplayGain{TrackGain: new(1.0), AlbumGain: new(2.0), TrackPeak: new(3.0), AlbumPeak: new(4.0), BaseGain: new(5.0), FallbackGain: new(6.0)}, DisplayArtist: "artist 1 & artist 2", Artists: []ArtistID3Ref{ {Id: "1", Name: "artist1"}, @@ -246,7 +241,7 @@ var _ = Describe("Responses", func() { ExplicitStatus: "clean", } child[1].OpenSubsonicChild = &OpenSubsonicChild{ - ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)}, + ReplayGain: ReplayGain{TrackGain: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)}, } response.Directory.Child = child }) @@ -322,7 +317,7 @@ var _ = Describe("Responses", func() { Isrc: []string{"ISRC-1"}, Moods: []string{"happy", "sad"}, Groupings: []string{"Soundtrack", "Live"}, - ReplayGain: ReplayGain{TrackGain: gg.P(1.0), AlbumGain: gg.P(2.0), TrackPeak: gg.P(3.0), AlbumPeak: gg.P(4.0), BaseGain: gg.P(5.0), FallbackGain: gg.P(6.0)}, + ReplayGain: ReplayGain{TrackGain: new(1.0), AlbumGain: new(2.0), TrackPeak: new(3.0), AlbumPeak: new(4.0), BaseGain: new(5.0), FallbackGain: new(6.0)}, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, DisplayArtist: "artist1 & artist2", Artists: []ArtistID3Ref{ @@ -342,7 +337,7 @@ var _ = Describe("Responses", func() { ExplicitStatus: "clean", } songs[1].OpenSubsonicChild = &OpenSubsonicChild{ - ReplayGain: ReplayGain{TrackGain: gg.P(0.0), AlbumGain: gg.P(0.0), TrackPeak: gg.P(0.0), AlbumPeak: gg.P(0.0), BaseGain: gg.P(0.0), FallbackGain: gg.P(0.0)}, + ReplayGain: ReplayGain{TrackGain: new(0.0), AlbumGain: new(0.0), TrackPeak: new(0.0), AlbumPeak: new(0.0), BaseGain: new(0.0), FallbackGain: new(0.0)}, } response.AlbumWithSongsID3.AlbumID3 = album response.AlbumWithSongsID3.Song = songs @@ -804,7 +799,7 @@ var _ = Describe("Responses", func() { Context("with data", func() { BeforeEach(func() { response.PlayQueueByIndex.Username = "user1" - response.PlayQueueByIndex.CurrentIndex = gg.P(0) + response.PlayQueueByIndex.CurrentIndex = new(0) response.PlayQueueByIndex.Position = 243 response.PlayQueueByIndex.Changed = time.Time{} response.PlayQueueByIndex.ChangedBy = "a_client" diff --git a/server/subsonic/sharing.go b/server/subsonic/sharing.go index 9cc8d7097..a9ccfdca4 100644 --- a/server/subsonic/sharing.go +++ b/server/subsonic/sharing.go @@ -58,12 +58,10 @@ func (api *Router) CreateShare(r *http.Request) (*responses.Subsonic, error) { } description, _ := p.String("description") - expires := p.TimeOr("expires", time.Time{}) - repo := api.share.NewRepository(r.Context()) share := &model.Share{ Description: description, - ExpiresAt: &expires, + ExpiresAt: new(p.TimeOr("expires", time.Time{})), ResourceIDs: strings.Join(ids, ","), } @@ -90,13 +88,11 @@ func (api *Router) UpdateShare(r *http.Request) (*responses.Subsonic, error) { } description, _ := p.String("description") - expires := p.TimeOr("expires", time.Time{}) - repo := api.share.NewRepository(r.Context()) share := &model.Share{ ID: id, Description: description, - ExpiresAt: &expires, + ExpiresAt: new(p.TimeOr("expires", time.Time{})), } err = repo.(rest.Persistable).Update(id, share) diff --git a/server/subsonic/users.go b/server/subsonic/users.go index 8b7406b60..acf8de3e8 100644 --- a/server/subsonic/users.go +++ b/server/subsonic/users.go @@ -46,8 +46,7 @@ func (api *Router) GetUser(r *http.Request) (*responses.Subsonic, error) { return nil, newError(responses.ErrorAuthorizationFail) } response := newResponse() - user := buildUserResponse(loggedUser) - response.User = &user + response.User = new(buildUserResponse(loggedUser)) return response, nil } diff --git a/tests/mock_user_repo.go b/tests/mock_user_repo.go index cc05829f6..7c7dadbc4 100644 --- a/tests/mock_user_repo.go +++ b/tests/mock_user_repo.go @@ -7,7 +7,6 @@ import ( "time" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/utils/gg" ) func CreateMockUserRepo() *MockedUserRepo { @@ -84,7 +83,7 @@ func (u *MockedUserRepo) GetAll(options ...model.QueryOptions) (model.Users, err func (u *MockedUserRepo) UpdateLastLoginAt(id string) error { for _, usr := range u.Data { if usr.ID == id { - usr.LastLoginAt = gg.P(time.Now()) + usr.LastLoginAt = new(time.Now()) return nil } } @@ -94,7 +93,7 @@ func (u *MockedUserRepo) UpdateLastLoginAt(id string) error { func (u *MockedUserRepo) UpdateLastAccessAt(id string) error { for _, usr := range u.Data { if usr.ID == id { - usr.LastAccessAt = gg.P(time.Now()) + usr.LastAccessAt = new(time.Now()) return nil } } diff --git a/utils/cache/cached_http_client.go b/utils/cache/cached_http_client.go index 94d33100b..4eed243dd 100644 --- a/utils/cache/cached_http_client.go +++ b/utils/cache/cached_http_client.go @@ -75,8 +75,7 @@ func (c *HTTPClient) serializeReq(req *http.Request) string { } if req.Body != nil { bodyData, _ := io.ReadAll(req.Body) - bodyStr := base64.StdEncoding.EncodeToString(bodyData) - data.Body = &bodyStr + data.Body = new(base64.StdEncoding.EncodeToString(bodyData)) } j, _ := json.Marshal(&data) return string(j) diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go index 2f0ff4764..eb3c99995 100644 --- a/utils/cache/simple_cache.go +++ b/utils/cache/simple_cache.go @@ -9,7 +9,6 @@ import ( "time" "github.com/jellydator/ttlcache/v3" - . "github.com/navidrome/navidrome/utils/gg" ) type SimpleCache[K comparable, V any] interface { @@ -119,7 +118,7 @@ func (c *simpleCache[K, V]) GetWithLoader(key K, loader func(key K) (V, time.Dur func (c *simpleCache[K, V]) evictExpired() { if c.evictionDeadline.Load() == nil || c.evictionDeadline.Load().Before(time.Now()) { c.data.DeleteExpired() - c.evictionDeadline.Store(P(time.Now().Add(evictionTimeout))) + c.evictionDeadline.Store(new(time.Now().Add(evictionTimeout))) } } diff --git a/utils/chrono/meter.go b/utils/chrono/meter.go index 7b4786ed5..2a249f455 100644 --- a/utils/chrono/meter.go +++ b/utils/chrono/meter.go @@ -2,8 +2,6 @@ package chrono import ( "time" - - . "github.com/navidrome/navidrome/utils/gg" ) // Meter is a simple stopwatch @@ -13,7 +11,7 @@ type Meter struct { } func (m *Meter) Start() { - m.mark = P(time.Now()) + m.mark = new(time.Now()) } func (m *Meter) Stop() time.Duration { diff --git a/utils/gg/gg.go b/utils/gg/gg.go index 208fe2952..674cacf20 100644 --- a/utils/gg/gg.go +++ b/utils/gg/gg.go @@ -1,11 +1,6 @@ // Package gg implements simple "extensions" to Go language. Based on https://github.com/icza/gog package gg -// P returns a pointer to the input value -func P[T any](v T) *T { - return &v -} - // V returns the value of the input pointer, or a zero value if the input pointer is nil. func V[T any](p *T) T { if p == nil { diff --git a/utils/gg/gg_test.go b/utils/gg/gg_test.go index 1d6dff484..a2dd8154f 100644 --- a/utils/gg/gg_test.go +++ b/utils/gg/gg_test.go @@ -16,22 +16,9 @@ func TestGG(t *testing.T) { } var _ = Describe("GG", func() { - Describe("P", func() { - It("returns a pointer to the input value", func() { - v := 123 - Expect(gg.P(123)).To(Equal(&v)) - }) - - It("returns nil if the input value is zero", func() { - v := 0 - Expect(gg.P(0)).To(Equal(&v)) - }) - }) - Describe("V", func() { It("returns the value of the input pointer", func() { - v := 123 - Expect(gg.V(&v)).To(Equal(123)) + Expect(gg.V(new(123))).To(Equal(123)) }) It("returns a zero value if the input pointer is nil", func() { diff --git a/utils/req/req.go b/utils/req/req.go index 6b6135e1a..2757fc3f5 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -38,8 +38,7 @@ func (r *Values) String(param string) (string, error) { func (r *Values) StringPtr(param string) *string { var v *string if _, exists := r.URL.Query()[param]; exists { - s := r.URL.Query().Get(param) - v = &s + v = new(r.URL.Query().Get(param)) } return v } @@ -48,8 +47,7 @@ func (r *Values) BoolPtr(param string) *bool { var v *bool if _, exists := r.URL.Query()[param]; exists { s := r.URL.Query().Get(param) - b := strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/") - v = &b + v = new(strings.Contains("/true/on/1/", "/"+strings.ToLower(s)+"/")) } return v } From 03ac02d9648adcccf81236241e18c93016343e51 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 19 May 2026 18:23:36 -0300 Subject: [PATCH 05/32] refactor: more warnings clean up Signed-off-by: Deluan --- adapters/deezer/client.go | 2 +- cmd/user.go | 4 ++-- conf/dir.go | 2 +- core/auth/auth_test.go | 3 +-- core/ffmpeg/ffmpeg_test.go | 2 +- core/playback/mpv/track.go | 2 +- model/criteria/export_test.go | 2 -- model/criteria/operators.go | 6 ------ persistence/radio_repository_test.go | 6 +----- plugins/manager_cache_test.go | 3 ++- scanner/metadata_old/metadata_internal_test.go | 2 +- utils/slice/slice_test.go | 2 +- 12 files changed, 12 insertions(+), 24 deletions(-) diff --git a/adapters/deezer/client.go b/adapters/deezer/client.go index 31150c673..d51f65dd9 100644 --- a/adapters/deezer/client.go +++ b/adapters/deezer/client.go @@ -1,7 +1,7 @@ package deezer import ( - bytes "bytes" + "bytes" "context" "encoding/json" "errors" diff --git a/cmd/user.go b/cmd/user.go index 1abf157b7..885edb53b 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -123,14 +123,14 @@ func promptPassword() string { for { fmt.Print("Enter new password (press enter with no password to cancel): ") // This cast is necessary for some platforms - password, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert + password, err := term.ReadPassword(syscall.Stdin) //nolint:unconvert if err != nil { log.Fatal("Error getting password", err) } fmt.Print("\nConfirm new password (press enter with no password to cancel): ") - confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert + confirmation, err := term.ReadPassword(syscall.Stdin) //nolint:unconvert if err != nil { log.Fatal("Error getting password confirmation", err) diff --git a/conf/dir.go b/conf/dir.go index 8ed43039b..abbe72b77 100644 --- a/conf/dir.go +++ b/conf/dir.go @@ -57,7 +57,7 @@ func (d *Dir) MustPath() string { // GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf) // prints the path string instead of the internal struct fields. -func (d Dir) GoString() string { //nolint:govet +func (d Dir) GoString() string { //nolint:govet // uses a value receiver so Dir values satisfy GoStringer return fmt.Sprintf("%q", d.path) } diff --git a/core/auth/auth_test.go b/core/auth/auth_test.go index 761dd205c..3a3585e53 100644 --- a/core/auth/auth_test.go +++ b/core/auth/auth_test.go @@ -21,8 +21,7 @@ func TestAuth(t *testing.T) { } const ( - testJWTSecret = "not so secret" - oneDay = 24 * time.Hour + oneDay = 24 * time.Hour ) var _ = BeforeSuite(func() { diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 562fd9100..2e2895738 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -7,7 +7,7 @@ import ( "path/filepath" "runtime" "strings" - sync "sync" + "sync" "testing" "time" diff --git a/core/playback/mpv/track.go b/core/playback/mpv/track.go index 14170efd4..1038b9190 100644 --- a/core/playback/mpv/track.go +++ b/core/playback/mpv/track.go @@ -206,7 +206,7 @@ func (t *MpvTrack) IsPlaying() bool { func waitForSocket(path string, timeout time.Duration, pause time.Duration) error { start := time.Now() end := start.Add(timeout) - var retries int = 0 + var retries = 0 for { fileInfo, err := os.Stat(path) diff --git a/model/criteria/export_test.go b/model/criteria/export_test.go index 9f3f3922b..e2109aa1a 100644 --- a/model/criteria/export_test.go +++ b/model/criteria/export_test.go @@ -1,5 +1,3 @@ package criteria -var StartOfPeriod = startOfPeriod - type UnmarshalConjunctionType = unmarshalConjunctionType diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 3ddd77f8b..14a02ff4b 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,7 +1,5 @@ package criteria -import "time" - // Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively type conjunction interface { ChildPlaylistIds() []string @@ -142,10 +140,6 @@ func (nitl NotInTheLast) MarshalJSON() ([]byte, error) { func (nitl NotInTheLast) fields() map[string]any { return nitl } -func startOfPeriod(numDays int64, from time.Time) string { - return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") -} - type InPlaylist map[string]any func (ipl InPlaylist) MarshalJSON() ([]byte, error) { diff --git a/persistence/radio_repository_test.go b/persistence/radio_repository_test.go index 828af8043..05628ca41 100644 --- a/persistence/radio_repository_test.go +++ b/persistence/radio_repository_test.go @@ -11,10 +11,6 @@ import ( . "github.com/onsi/gomega" ) -var ( - NewId string = "123-456-789" -) - var _ = Describe("RadioRepository", func() { var repo model.RadioRepository @@ -139,7 +135,7 @@ var _ = Describe("RadioRepository", func() { It("returns an existing item", func() { res, err := repo.Get(radioWithHomePage.ID) - Expect(err).To((BeNil())) + Expect(err).To(BeNil()) Expect(res.ID).To(Equal(radioWithHomePage.ID)) }) diff --git a/plugins/manager_cache_test.go b/plugins/manager_cache_test.go index f985fcd84..3dbfa45ee 100644 --- a/plugins/manager_cache_test.go +++ b/plugins/manager_cache_test.go @@ -4,6 +4,7 @@ import ( "context" "os" "path/filepath" + "strconv" "time" "github.com/dustin/go-humanize" @@ -143,7 +144,7 @@ var _ = Describe("purgeCacheBySize", func() { // Create 5 files, 1MiB each (total 5MiB) for i := range 5 { - path := filepath.Join(cacheDir, filepath.Join("dir", "file"+string(rune('0'+i))+".bin")) + path := filepath.Join(cacheDir, filepath.Join("dir", "file"+strconv.Itoa(i)+".bin")) createFileWithSize(path, 1*1024*1024, now.Add(-time.Duration(5-i)*time.Hour)) } diff --git a/scanner/metadata_old/metadata_internal_test.go b/scanner/metadata_old/metadata_internal_test.go index 2d21e07eb..aff1ede9c 100644 --- a/scanner/metadata_old/metadata_internal_test.go +++ b/scanner/metadata_old/metadata_internal_test.go @@ -93,7 +93,7 @@ var _ = Describe("Tags", func() { var t *Tags BeforeEach(func() { t = &Tags{Tags: map[string][]string{ - "fbpm": []string{"141.7"}, + "fbpm": {"141.7"}, }} }) diff --git a/utils/slice/slice_test.go b/utils/slice/slice_test.go index 65e5f0934..64cb89d53 100644 --- a/utils/slice/slice_test.go +++ b/utils/slice/slice_test.go @@ -134,7 +134,7 @@ var _ = Describe("Slice Utils", func() { count := 0 file, _ := os.Open(path) defer file.Close() - for _ = range slice.LinesFrom(file) { + for range slice.LinesFrom(file) { count++ } Expect(count).To(Equal(expected)) From e75ab3b0377d80691c86f547d60c778321a659aa Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 20 May 2026 19:33:42 -0300 Subject: [PATCH 06/32] fix(cli): restore int cast for syscall.Stdin on Windows On Windows, syscall.Stdin is syscall.Handle (uintptr), not int, so term.ReadPassword requires an explicit int() cast to compile. --- cmd/user.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/user.go b/cmd/user.go index 885edb53b..1abf157b7 100644 --- a/cmd/user.go +++ b/cmd/user.go @@ -123,14 +123,14 @@ func promptPassword() string { for { fmt.Print("Enter new password (press enter with no password to cancel): ") // This cast is necessary for some platforms - password, err := term.ReadPassword(syscall.Stdin) //nolint:unconvert + password, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert if err != nil { log.Fatal("Error getting password", err) } fmt.Print("\nConfirm new password (press enter with no password to cancel): ") - confirmation, err := term.ReadPassword(syscall.Stdin) //nolint:unconvert + confirmation, err := term.ReadPassword(int(syscall.Stdin)) //nolint:unconvert if err != nil { log.Fatal("Error getting password confirmation", err) From 74185dc6d1cf5676e90805929285302429aad009 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 22 May 2026 18:00:13 -0300 Subject: [PATCH 07/32] fix(smartplaylists): optimize smart playlist performance for role and tag criteria (#5515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): optimize smart playlist role queries for large criteria (#5511) Role-based smart playlist criteria (artist, composer, etc.) now query the indexed media_file_artists join table instead of parsing JSON via json_tree() on every row. Multiple conditions for the same role within an OR group are merged into a single EXISTS subquery (batched at 200 to stay under SQLite's expression tree depth limit). A composite index (media_file_id, role) replaces the now-redundant single-column (media_file_id) index on media_file_artists. Benchmark (40k tracks, 500 patterns, 3 artists/track): - Merged join-table: 15ms (9.3x faster) - Merged json_tree: 30ms (4.6x faster) - Unmerged baseline: 137ms * refactor: simplify role condition SQL generation and benchmark Extract shared roleCondSQL/roleExistsSQL helpers to deduplicate the EXISTS template between roleCond and roleCondGroup. Use slices.Chunk for batching per project convention. Extract runBenchQuery helper to eliminate triplicated benchmark execution loop. * chore: raise roleCondBatchSize to 350 The empirical SQLite limit is 496 conditions per merged EXISTS subquery. Raising from 200 to 350 reduces the number of batches (e.g. 500 patterns now splits into 2 batches instead of 3). * fix(server): apply OR-merge optimization to tag conditions too Generalize mergeRoleConds into mergeJsonConds to also collapse multiple tag conditions for the same tag (e.g. genre) within OR groups. This gives the same ~5x speedup for tag-heavy smart playlists as the role optimization gives for artist-heavy ones. * refactor: benchmark uses real criteria pipeline instead of hand-built SQL The "Current" sub-benchmark now builds criteria.Criteria expressions and runs them through the actual newSmartPlaylistCriteria → Where() → ToSql() pipeline, validating the real production code path. The baseline still uses hand-built SQL representing the old json_tree approach. * fix: stabilize merged group ordering and close rows before error check Sort group keys in mergeJsonConds so the merged additions have deterministic order across runs, improving SQLite statement cache reuse. Move rows.Close() before rows.Err() in benchmark helper. --- ...add_media_file_artists_composite_index.sql | 9 + persistence/criteria_sql.go | 170 ++++++++++++- persistence/criteria_sql_benchmark_test.go | 236 ++++++++++++++++++ persistence/criteria_sql_test.go | 156 +++++++++++- 4 files changed, 558 insertions(+), 13 deletions(-) create mode 100644 db/migrations/20260520211813_add_media_file_artists_composite_index.sql create mode 100644 persistence/criteria_sql_benchmark_test.go diff --git a/db/migrations/20260520211813_add_media_file_artists_composite_index.sql b/db/migrations/20260520211813_add_media_file_artists_composite_index.sql new file mode 100644 index 000000000..f65050d80 --- /dev/null +++ b/db/migrations/20260520211813_add_media_file_artists_composite_index.sql @@ -0,0 +1,9 @@ +-- +goose Up +CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id_role + ON media_file_artists (media_file_id, role); +DROP INDEX IF EXISTS media_file_artists_media_file_id; + +-- +goose Down +CREATE INDEX IF NOT EXISTS media_file_artists_media_file_id + ON media_file_artists (media_file_id); +DROP INDEX IF EXISTS media_file_artists_media_file_id_role; diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index a1bae3170..37e4ae340 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -3,7 +3,9 @@ package persistence import ( "errors" "fmt" + "maps" "reflect" + "slices" "strconv" "strings" "time" @@ -147,7 +149,7 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz } or = append(or, cond) } - return or, nil + return mergeJsonConds(or), nil case criteria.Is: return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { return squirrel.Eq(fields) @@ -381,17 +383,173 @@ type roleCond struct { func (e roleCond) ToSql() (string, []any, error) { var cond string var args []any - var err error if e.cond != nil { - cond, args, err = e.cond.ToSql() - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name' and %s)", e.role, cond) + innerSQL, innerArgs, err := roleCondSQL(e.cond) + if err != nil { + return "", nil, err + } + cond = roleExistsSQL(innerSQL) + args = append([]any{e.role}, innerArgs...) } else { - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name')", e.role) + cond = "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)" + args = []any{e.role} } if e.not { cond = "not " + cond } - return cond, args, err + return cond, args, nil +} + +// roleCondSQL extracts SQL from a squirrel condition and rewrites the placeholder column name. +func roleCondSQL(cond squirrel.Sqlizer) (string, []any, error) { + sql, args, err := cond.ToSql() + if err != nil { + return "", nil, err + } + return strings.ReplaceAll(sql, "value", "artist.name"), args, nil +} + +// roleExistsSQL wraps a condition fragment in the standard role EXISTS subquery. +func roleExistsSQL(innerCond string) string { + return fmt.Sprintf("exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id "+ + "where mfa.media_file_id = media_file.id and mfa.role = ? and %s)", innerCond) +} + +// jsonCondBatchSize limits how many conditions are ORed inside a single EXISTS subquery +// to stay within SQLite's expression tree depth limit (max 1000). The EXISTS wrapper +// consumes ~4 levels; each ORed condition adds 1 level. Empirically, 496 is the maximum. +const jsonCondBatchSize = 350 + +// mergeJsonConds collapses multiple non-negated roleCond or tagCond entries for the same +// field within an OR group into batched EXISTS subqueries with the conditions ORed inside. +// This turns N separate correlated subqueries into ceil(N/batchSize), dramatically +// improving performance for smart playlists with many patterns. +func mergeJsonConds(or squirrel.Or) squirrel.Sqlizer { + type condEntry struct { + index int + cond squirrel.Sqlizer + } + type group struct { + entries []condEntry + isRole bool + numeric bool + tag string + } + groups := make(map[string]*group) + for i, s := range or { + switch c := s.(type) { + case roleCond: + if c.not || c.cond == nil { + continue + } + g, exists := groups["role:"+c.role] + if !exists { + g = &group{isRole: true} + groups["role:"+c.role] = g + } + g.entries = append(g.entries, condEntry{index: i, cond: c.cond}) + case tagCond: + if c.not || c.cond == nil { + continue + } + g, exists := groups["tag:"+c.tag] + if !exists { + g = &group{tag: c.tag, numeric: c.numeric} + groups["tag:"+c.tag] = g + } + g.entries = append(g.entries, condEntry{index: i, cond: c.cond}) + } + } + + merged := false + remove := make(map[int]bool) + var additions []squirrel.Sqlizer + for _, key := range slices.Sorted(maps.Keys(groups)) { + g := groups[key] + if len(g.entries) < 2 { + continue + } + merged = true + for _, e := range g.entries { + remove[e.index] = true + } + conds := make([]squirrel.Sqlizer, len(g.entries)) + for i, e := range g.entries { + conds[i] = e.cond + } + if g.isRole { + role := key[len("role:"):] + for batch := range slices.Chunk(conds, jsonCondBatchSize) { + additions = append(additions, roleCondGroup{role: role, conds: batch}) + } + } else { + for batch := range slices.Chunk(conds, jsonCondBatchSize) { + additions = append(additions, tagCondGroup{tag: g.tag, numeric: g.numeric, conds: batch}) + } + } + } + + if !merged { + return or + } + + result := make(squirrel.Or, 0, len(or)-len(remove)+len(additions)) + for i, s := range or { + if !remove[i] { + result = append(result, s) + } + } + result = append(result, additions...) + return result +} + +// roleCondGroup represents multiple role conditions for the same role, merged into +// a single EXISTS subquery for performance. +type roleCondGroup struct { + role string + conds []squirrel.Sqlizer +} + +func (g roleCondGroup) ToSql() (string, []any, error) { + innerParts := make([]string, 0, len(g.conds)) + allArgs := []any{g.role} + for _, c := range g.conds { + part, args, err := roleCondSQL(c) + if err != nil { + return "", nil, err + } + innerParts = append(innerParts, part) + allArgs = append(allArgs, args...) + } + cond := roleExistsSQL("(" + strings.Join(innerParts, " OR ") + ")") + return cond, allArgs, nil +} + +// tagCondGroup represents multiple tag conditions for the same tag, merged into +// a single EXISTS subquery for performance. +type tagCondGroup struct { + tag string + numeric bool + conds []squirrel.Sqlizer +} + +func (g tagCondGroup) ToSql() (string, []any, error) { + innerParts := make([]string, 0, len(g.conds)) + var allArgs []any + for _, c := range g.conds { + part, args, err := c.ToSql() + if err != nil { + return "", nil, err + } + if g.numeric { + part = strings.ReplaceAll(part, "value", "CAST(value AS REAL)") + } + innerParts = append(innerParts, part) + allArgs = append(allArgs, args...) + } + cond := fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and (%s))", + g.tag, strings.Join(innerParts, " OR ")) + return cond, allArgs, nil } func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) { diff --git a/persistence/criteria_sql_benchmark_test.go b/persistence/criteria_sql_benchmark_test.go new file mode 100644 index 000000000..d901e9eda --- /dev/null +++ b/persistence/criteria_sql_benchmark_test.go @@ -0,0 +1,236 @@ +package persistence + +import ( + "context" + "encoding/json" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/request" + "github.com/pocketbase/dbx" +) + +const ( + benchNumArtists = 1_000 + benchNumTracks = 40_000 + benchNumPatterns = 500 + benchArtistsPerTrack = 3 +) + +// BenchmarkSmartPlaylistRole compares role-based smart playlist query performance +// between the current implementation (merged join-table via criteria pipeline) and +// the old baseline (unmerged json_tree subqueries). +func BenchmarkSmartPlaylistRole(b *testing.B) { + configtest.SetupConfig() + tmpDir := b.TempDir() + conf.Server.DbPath = filepath.Join(tmpDir, "bench-smartpl.db") + cleanup := db.Init(context.Background()) + defer cleanup() + log.SetLevel(log.LevelFatal) + + conn := dbx.NewFromDB(db.Db(), db.Dialect) + ctx := log.NewContext(context.Background()) + user := model.User{ID: "bench-user", UserName: "bench", Name: "Bench User", IsAdmin: true} + ctx = request.WithUser(ctx, user) + + setupBenchData(b, ctx, conn, user) + criteria.AddRoles([]string{"artist"}) + + // Build the criteria expression: 500 "contains artist" patterns in an OR group + anyExprs := make(criteria.Any, benchNumPatterns) + for i := range benchNumPatterns { + anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist %04d", i)} + } + expr := criteria.Criteria{Expression: anyExprs, Sort: "title", Limit: 500} + + b.Run("Current", func(b *testing.B) { + benchmarkCriteriaPipeline(b, ctx, expr) + }) + b.Run("Baseline_UnmergedJSONTree", func(b *testing.B) { + benchmarkUnmergedJSONTree(b, ctx) + }) +} + +// benchmarkCriteriaPipeline runs the criteria through the actual production code path: +// newSmartPlaylistCriteria → Where() → ToSql(), then executes the resulting query. +func benchmarkCriteriaPipeline(b *testing.B, ctx context.Context, expr criteria.Criteria) { + b.Helper() + + cSQL := newSmartPlaylistCriteria(expr) + + // Build the full query matching buildSmartPlaylistQuery + addCriteria + sq := squirrel.Select("media_file.id").From("media_file") + cond, err := cSQL.Where() + if err != nil { + b.Fatal(err) + } + sq = sq.Where(cond) + if expr.Limit > 0 { + sq = sq.Limit(uint64(expr.Limit)) + } + if order := cSQL.OrderBy(); order != "" { + sq = sq.OrderBy(order) + } + + query, args, err := sq.PlaceholderFormat(squirrel.Question).ToSql() + if err != nil { + b.Fatal(err) + } + + runBenchQuery(b, ctx, query, args) +} + +// benchmarkUnmergedJSONTree builds the old-style query with N separate json_tree EXISTS +// subqueries (the pre-optimization baseline). +func benchmarkUnmergedJSONTree(b *testing.B, ctx context.Context) { + b.Helper() + + var sb strings.Builder + sb.WriteString("SELECT media_file.id FROM media_file WHERE (") + args := make([]any, 0, benchNumPatterns) + for i := range benchNumPatterns { + if i > 0 { + sb.WriteString(" OR ") + } + sb.WriteString("exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)") + args = append(args, fmt.Sprintf("%%Artist %04d%%", i)) + } + sb.WriteString(") ORDER BY media_file.title LIMIT 500") + + runBenchQuery(b, ctx, sb.String(), args) +} + +func runBenchQuery(b *testing.B, ctx context.Context, query string, args []any) { + b.Helper() + sqlDB := db.Db() + b.ResetTimer() + for range b.N { + rows, err := sqlDB.QueryContext(ctx, query, args...) + if err != nil { + b.Fatal(err) + } + for rows.Next() { + var id string + _ = rows.Scan(&id) + } + rows.Close() + if err := rows.Err(); err != nil { + b.Fatal(err) + } + } +} + +func setupBenchData(b *testing.B, ctx context.Context, conn *dbx.DB, user model.User) { + b.Helper() + + sqlDB := db.Db() + + ur := NewUserRepository(ctx, conn) + if err := ur.Put(&user); err != nil { + b.Fatal(err) + } + if err := ur.SetUserLibraries(user.ID, []int{1}); err != nil { + b.Fatal(err) + } + + tx, err := sqlDB.Begin() + if err != nil { + b.Fatal(err) + } + + // Create artists + artistStmt, err := tx.Prepare("INSERT INTO artist (id, name) VALUES (?, ?)") + if err != nil { + b.Fatal(err) + } + for i := range benchNumArtists { + if _, err := artistStmt.Exec(fmt.Sprintf("artist-%04d", i), fmt.Sprintf("Artist %04d", i)); err != nil { + b.Fatal(err) + } + } + artistStmt.Close() + + // Ensure folder exists + folderID := "bench-folder" + if _, err := tx.Exec("INSERT OR IGNORE INTO folder (id, library_id, path, name, parent_id) VALUES (?, 1, '.', '.', '')", folderID); err != nil { + b.Fatal(err) + } + + // Create media files with participants JSON, cycling through artists + mfStmt, err := tx.Prepare(`INSERT INTO media_file (id, path, title, album, artist, artist_id, album_id, + duration, year, size, suffix, tags, participants, lyrics, library_id, folder_id, pid, codec) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + b.Fatal(err) + } + + // Populate media_file_artists join table + mfaStmt, err := tx.Prepare("INSERT INTO media_file_artists (media_file_id, artist_id, role, sub_role) VALUES (?, ?, ?, ?)") + if err != nil { + b.Fatal(err) + } + + for i := range benchNumTracks { + trackID := fmt.Sprintf("track-%05d", i) + + // Assign benchArtistsPerTrack artists to each track, cycling through the pool + artistEntries := make([]map[string]string, benchArtistsPerTrack) + for a := range benchArtistsPerTrack { + artistIdx := (i + a) % benchNumArtists + artistEntries[a] = map[string]string{ + "id": fmt.Sprintf("artist-%04d", artistIdx), + "name": fmt.Sprintf("Artist %04d", artistIdx), + } + } + primaryArtistIdx := i % benchNumArtists + primaryArtistID := fmt.Sprintf("artist-%04d", primaryArtistIdx) + primaryArtistName := fmt.Sprintf("Artist %04d", primaryArtistIdx) + + participants := map[string][]map[string]string{"artist": artistEntries} + participantsJSON, _ := json.Marshal(participants) + + if _, err := mfStmt.Exec( + trackID, + fmt.Sprintf("music/%s.mp3", trackID), + fmt.Sprintf("Track %05d", i), + "Bench Album", + primaryArtistName, + primaryArtistID, + "bench-album", + 180, 2024, 5000000, "mp3", + "{}", + string(participantsJSON), + "[]", + 1, folderID, trackID, "mp3", + ); err != nil { + b.Fatal(err) + } + + // Insert all artist associations into the join table + for a := range benchArtistsPerTrack { + artistIdx := (i + a) % benchNumArtists + artistID := fmt.Sprintf("artist-%04d", artistIdx) + if _, err := mfaStmt.Exec(trackID, artistID, "artist", ""); err != nil { + b.Fatal(err) + } + } + } + mfStmt.Close() + mfaStmt.Close() + + if err := tx.Commit(); err != nil { + b.Fatal(err) + } + + b.Logf("Setup complete: %d artists, %d tracks (%d artists/track), %d patterns", + benchNumArtists, benchNumTracks, benchArtistsPerTrack, benchNumPatterns) +} diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index ae2695a4d..5c8909e1c 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -1,6 +1,8 @@ package persistence import ( + "fmt" + "strings" "time" "github.com/navidrome/navidrome/model" @@ -56,9 +58,9 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("numeric tag", criteria.Lt{"rate": 6}, "exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)", 6), Entry("tag alias", criteria.Is{"albumtype": "album"}, "exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)", "album"), Entry("field alias via tag registration", criteria.Is{"recordingdate": "2024-01-01"}, "media_file.date = ?", "2024-01-01"), - Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), - Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon%"), - Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), + Entry("role is", criteria.Is{"artist": "u2"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name = ?)", "artist", "u2"), + Entry("role contains", criteria.Contains{"composer": "Lennon"}, "exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "composer", "%Lennon%"), + Entry("role not contains", criteria.NotContains{"artist": "u2"}, "not exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and artist.name LIKE ?)", "artist", "%u2%"), // ReplayGain fields Entry("rgAlbumGain is", criteria.Is{"rgAlbumGain": 0}, "media_file.rg_album_gain = ?", 0), Entry("rgAlbumGain gt", criteria.Gt{"rgAlbumGain": -6.0}, "media_file.rg_album_gain > ?", -6.0), @@ -70,9 +72,9 @@ var _ = Describe("Smart playlist criteria SQL", func() { "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), // isMissing — roles Entry("isMissing role [true]", criteria.IsMissing{"artist": true}, - "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"), Entry("isMissing role [false]", criteria.IsMissing{"artist": false}, - "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "artist"), // isPresent — tags Entry("isPresent tag [true]", criteria.IsPresent{"genre": true}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), @@ -80,9 +82,9 @@ var _ = Describe("Smart playlist criteria SQL", func() { "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), // isPresent — roles Entry("isPresent role [true]", criteria.IsPresent{"composer": true}, - "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), + "exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), Entry("isPresent role [false]", criteria.IsPresent{"composer": false}, - "not exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), + "not exists (select 1 from media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), ) Describe("playlist permissions", func() { @@ -204,6 +206,146 @@ var _ = Describe("Smart playlist criteria SQL", func() { } }) + Describe("JSON condition merging", func() { + It("merges multiple role conditions in an OR group into a single EXISTS", func() { + expr := criteria.Any{ + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"artist": "Kraftwerk"}, + criteria.Contains{"artist": "Pink Floyd"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("(exists (select 1 from media_file_artists mfa join artist on artist.id = mfa.artist_id where mfa.media_file_id = media_file.id and mfa.role = ? and (artist.name LIKE ? OR artist.name LIKE ? OR artist.name LIKE ?)))")) + Expect(args).To(HaveExactElements("artist", "%Beatles%", "%Kraftwerk%", "%Pink Floyd%")) + }) + + It("does not merge role conditions from different roles", func() { + expr := criteria.Any{ + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"composer": "Lennon"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("mfa.role = ?")) + // Two separate EXISTS since roles differ + Expect(strings.Count(sql, "exists")).To(Equal(2)) + }) + + It("does not merge negated role conditions", func() { + expr := criteria.Any{ + criteria.NotContains{"artist": "Beatles"}, + criteria.NotContains{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Two separate "not exists" since they are negated + Expect(strings.Count(sql, "not exists")).To(Equal(2)) + }) + + It("batches large groups to avoid SQLite expression tree depth limit", func() { + // Create jsonCondBatchSize + 1 conditions to trigger batching into 2 groups + anyExprs := make(criteria.Any, jsonCondBatchSize+1) + for i := range anyExprs { + anyExprs[i] = criteria.Contains{"artist": fmt.Sprintf("Artist%d", i)} + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: anyExprs}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Should produce 2 EXISTS subqueries (one batch of jsonCondBatchSize, one of 1) + Expect(strings.Count(sql, "exists")).To(Equal(2)) + // First batch has jsonCondBatchSize patterns, second has 1 => total args: + // 2 roles + (jsonCondBatchSize + 1) patterns + Expect(args).To(HaveLen(2 + jsonCondBatchSize + 1)) + }) + + It("merges role conditions while preserving non-role conditions", func() { + expr := criteria.Any{ + criteria.Contains{"title": "Love"}, + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"artist": "Kraftwerk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(ContainSubstring("media_file.title LIKE ?")) + Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?")) + Expect(args).To(HaveExactElements("%Love%", "artist", "%Beatles%", "%Kraftwerk%")) + }) + + It("merges multiple tag conditions in an OR group into a single EXISTS", func() { + expr := criteria.Any{ + criteria.Contains{"genre": "Rock"}, + criteria.Contains{"genre": "Metal"}, + criteria.Contains{"genre": "Punk"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("(exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and (value LIKE ? OR value LIKE ? OR value LIKE ?)))")) + Expect(args).To(HaveExactElements("%Rock%", "%Metal%", "%Punk%")) + }) + + It("does not merge tag conditions from different tags", func() { + expr := criteria.Any{ + criteria.Contains{"genre": "Rock"}, + criteria.Contains{"mood": "Happy"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "exists")).To(Equal(2)) + }) + + It("does not merge negated tag conditions", func() { + expr := criteria.Any{ + criteria.NotContains{"genre": "Rock"}, + criteria.NotContains{"genre": "Metal"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, _, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(strings.Count(sql, "not exists")).To(Equal(2)) + }) + + It("merges role and tag conditions independently", func() { + expr := criteria.Any{ + criteria.Contains{"artist": "Beatles"}, + criteria.Contains{"artist": "Kraftwerk"}, + criteria.Contains{"genre": "Rock"}, + criteria.Contains{"genre": "Metal"}, + } + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: expr}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + // Two merged EXISTS: one for roles, one for tags + Expect(strings.Count(sql, "exists")).To(Equal(2)) + Expect(sql).To(ContainSubstring("artist.name LIKE ? OR artist.name LIKE ?")) + Expect(sql).To(ContainSubstring("value LIKE ? OR value LIKE ?")) + Expect(args).To(HaveLen(2 + 2 + 1)) // 2 tag patterns + 2 role patterns + 1 role name + }) + }) + Describe("joins", func() { It("excludes sort-only joins from expression joins", func() { c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "albumRating"} From 8897ec918e3a3b540c95b1693367b40eaf1be280 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 22 May 2026 18:42:17 -0300 Subject: [PATCH 08/32] fix(subsonic): mark AlbumID3 songCount and created as required The Subsonic API spec defines songCount and created as required attributes on AlbumID3, but they were tagged with omitempty in our response struct, allowing them to be silently dropped from responses (e.g. when songCount was 0). Created was also a *time.Time, which compounded the omitempty behavior. Remove omitempty from both fields and change Created from *time.Time to time.Time so they are always serialized, matching the spec contract that clients rely on. The buildAlbumID3 helper and its tests are updated for the non-pointer Created, and the AlbumWithSongsID3 snapshots are regenerated to include the now-always-present fields. --- server/subsonic/helpers.go | 2 +- server/subsonic/helpers_test.go | 13 +++++-------- ...s AlbumWithSongsID3 with data should match .JSON | 2 ++ ...es AlbumWithSongsID3 with data should match .XML | 2 +- ...lbumWithSongsID3 without data should match .JSON | 4 +++- ...AlbumWithSongsID3 without data should match .XML | 2 +- ...ID3 without data should match OpenSubsonic .JSON | 2 ++ ...sID3 without data should match OpenSubsonic .XML | 2 +- server/subsonic/responses/responses.go | 4 ++-- 9 files changed, 18 insertions(+), 15 deletions(-) diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 0b18b1bc7..e4c39e373 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -441,7 +441,7 @@ func buildAlbumID3(ctx context.Context, album model.Album) responses.AlbumID3 { dir.PlayCount = album.PlayCount dir.Year = int32(cmp.Or(album.MaxOriginalYear, album.MaxYear)) dir.Genre = album.Genre - dir.Created = new(albumCreatedAt(album)) + dir.Created = albumCreatedAt(album) if album.Starred { dir.Starred = album.StarredAt } diff --git a/server/subsonic/helpers_test.go b/server/subsonic/helpers_test.go index abf6116f3..2ae6eb28e 100644 --- a/server/subsonic/helpers_test.go +++ b/server/subsonic/helpers_test.go @@ -576,30 +576,27 @@ var _ = Describe("helpers", func() { t := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) al := model.Album{ID: "a1", Name: "A", CreatedAt: t} dir := buildAlbumID3(ctx, al) - Expect(dir.Created).ToNot(BeNil()) - Expect(*dir.Created).To(Equal(t)) + Expect(dir.Created).To(Equal(t)) }) It("falls back to UpdatedAt when CreatedAt is zero", func() { updated := time.Date(2019, 5, 6, 7, 8, 9, 0, time.UTC) al := model.Album{ID: "a2", Name: "A", UpdatedAt: updated} dir := buildAlbumID3(ctx, al) - Expect(dir.Created).ToNot(BeNil()) - Expect(*dir.Created).To(Equal(updated)) + Expect(dir.Created).To(Equal(updated)) }) It("falls back to ImportedAt when CreatedAt and UpdatedAt are zero", func() { imported := time.Date(2021, 8, 9, 10, 11, 12, 0, time.UTC) al := model.Album{ID: "a3", Name: "A", ImportedAt: imported} dir := buildAlbumID3(ctx, al) - Expect(dir.Created).ToNot(BeNil()) - Expect(*dir.Created).To(Equal(imported)) + Expect(dir.Created).To(Equal(imported)) }) - It("never leaves Created nil even when all timestamps are zero", func() { + It("leaves Created as zero time when all timestamps are zero", func() { al := model.Album{ID: "a4", Name: "A"} dir := buildAlbumID3(ctx, al) - Expect(dir.Created).ToNot(BeNil()) + Expect(dir.Created.IsZero()).To(BeTrue()) }) }) 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 a07914344..bff0bd20c 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,7 +8,9 @@ "id": "1", "name": "album", "artist": "artist", + "songCount": 0, "duration": 292, + "created": "0001-01-01T00:00:00Z", "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 6c96391bc..16a7748aa 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 14e96939e..030502618 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 @@ -7,6 +7,8 @@ "album": { "id": "", "name": "", - "duration": 0 + "songCount": 0, + "duration": 0, + "created": "0001-01-01T00:00:00Z" } } 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 868265347..0d3882080 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 446368fa5..d3964663b 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,7 +7,9 @@ "album": { "id": "", "name": "", + "songCount": 0, "duration": 0, + "created": "0001-01-01T00:00:00Z", "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 868265347..0d3882080 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 4165669df..dcb458932 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -251,10 +251,10 @@ type AlbumID3 struct { Artist string `xml:"artist,attr,omitempty" json:"artist,omitempty"` 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"` + SongCount int32 `xml:"songCount,attr" json:"songCount"` 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"` + Created time.Time `xml:"created,attr" json:"created"` Starred *time.Time `xml:"starred,attr,omitempty" json:"starred,omitempty"` Year int32 `xml:"year,attr,omitempty" json:"year,omitempty"` Genre string `xml:"genre,attr,omitempty" json:"genre,omitempty"` From 0265ff3ad18b7a83c2ea20c62bad69ccb737aee8 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 22 May 2026 22:22:23 -0300 Subject: [PATCH 09/32] fix(ui): report playback when restarting current track via prev The navidrome-music-player library rewinds the current track by directly mutating audio.currentTime when the Previous button is pressed with restartCurrentOnPrev (and other programmatic seek paths like singleLoop reset and mediaSession seek). It does not invoke its onAudioSeeked callback for these, so the play tracker never learned about the new position until the next ~30s heartbeat. Replace the React onAudioSeeked prop with a native HTML5 'seeked' event listener on the audio element, which fires for every seek (programmatic or via slider release). The handler is debounced by 250ms so the burst of seeks emitted while dragging the progress bar coalesces into a single reportPlayback call at the final position. --- ui/src/audioplayer/Player.jsx | 42 ++++++++++++++++++++++++----------- 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index e2070deea..c3d795b26 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -272,18 +272,6 @@ const Player = () => { } }, []) - const onAudioSeeked = useCallback( - (info) => { - if (!info.isRadio && currentTrackId) { - const posMs = Math.floor(info.currentTime * 1000) - lastPositionMsRef.current = posMs - const state = audioInstance?.paused ? 'paused' : 'playing' - subsonic.reportPlayback(currentTrackId, posMs, state) - } - }, - [currentTrackId, audioInstance], - ) - const onAudioVolumeChange = useCallback( // sqrt to compensate for the logarithmic volume (volume) => dispatch(setVolume(Math.sqrt(volume))), @@ -436,6 +424,35 @@ const Player = () => { } }, [isMobilePlayer, audioInstance]) + // Report every seek (including programmatic ones the library does not surface + // via onAudioSeeked, e.g. restartCurrentOnPrev). Debounce coalesces drag + // bursts into one report at the final position. + useEffect(() => { + if (!audioInstance) return + let timer = null + const flush = () => { + timer = null + if ( + !currentTrackIdRef.current || + playerStateRef.current?.current?.isRadio + ) { + return + } + const posMs = Math.floor((audioInstance.currentTime || 0) * 1000) + const state = audioInstance.paused ? 'paused' : 'playing' + subsonic.reportPlayback(currentTrackIdRef.current, posMs, state) + } + const handleSeeked = () => { + if (timer) clearTimeout(timer) + timer = setTimeout(flush, 250) + } + audioInstance.addEventListener('seeked', handleSeeked) + return () => { + if (timer) clearTimeout(timer) + audioInstance.removeEventListener('seeked', handleSeeked) + } + }, [audioInstance]) + return ( { onAudioListsChange={onAudioListsChange} onAudioVolumeChange={onAudioVolumeChange} onAudioProgress={onAudioProgress} - onAudioSeeked={onAudioSeeked} onAudioPlay={onAudioPlay} onAudioPlayTrackChange={onAudioPlayTrackChange} onAudioPause={onAudioPause} From edffca24b11b5378876cbcfed62acbbed8d95d10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 23 May 2026 12:16:15 -0300 Subject: [PATCH 10/32] fix(lastfm): require signed state token on link callback (#5521) * fix(lastfm): require signed state token on link callback The Last.fm OAuth callback at /api/lastfm/link/callback trusted a raw \`uid\` query parameter and wrote the resulting Last.fm session key under that user with no ownership check. Any authenticated user who learned a victim's internal user ID (e.g. from playlist ownerId) could redirect the victim's scrobbles to an attacker-controlled Last.fm account by calling the callback directly with the victim's uid and a Last.fm token obtained for their own account. The callback cannot use the regular auth middleware because it is reached via a browser redirect from Last.fm, which cannot carry a JWT header. Instead, GET /api/lastfm/link (authenticated) now also returns a short- lived (5 min) HMAC-signed link token bound to the requesting user, with a dedicated "lastfm-link" scope claim. The callback verifies the signature, scope and expiry before deriving the user ID from the token; the \`uid\` query value is no longer trusted as a user identifier. The UI fetches this token at link-flow start and passes it in place of the raw user ID. Reuses the existing HS256 secret via auth.EncodeToken/DecodeAndVerifyToken so no new key management is introduced. * fix(ui): keep Last.fm popup tied to user gesture for Safari Opening the Last.fm OAuth tab after an awaited fetch causes the popup to be blocked on Safari and on Firefox with strict popup blocking enabled, because the browser's transient-activation window has already elapsed by the time window.open is reached. Linking became impossible on those browsers in the previous commit. Move the click handler up to the parent component and open a placeholder about:blank tab synchronously from the click; the linkToken fetch then runs in parallel and we redirect the existing tab to Last.fm's auth URL once it resolves. The user gesture stays attached to the window.open call, so popup blockers no longer fire. The polling/progress UI is unchanged; it now receives the openedTab ref from the parent instead of owning it. * fix(lastfm): require exp claim on link tokens jwtauth.VerifyToken treats a JWT without an exp claim as non-expiring, so verifyLinkToken used to delegate expiry handling entirely. A future regression in createLinkToken that dropped the exp field would silently turn link tokens into permanent bearer credentials. Assert presence of an exp claim explicitly and add a regression test covering the missing-exp case. Also tightens the wrong-scope test to use a freshly-minted token with all claims present except the scope, instead of relying on auth.CreatePublicToken which happens to also be missing exp. * style(lastfm): simplify comments in link token code Trim doc comments on createLinkToken/verifyLinkToken/callback/startLink to the load-bearing lines: keep the non-obvious 'jwtauth treats missing exp as non-expiring' note and the popup-blocker hint, drop the rest since the function names already describe behavior. Signed-off-by: Deluan * fix(lastfm): address review feedback on link token PR - Wrap openInNewTab in a try/catch in startLink: openInNewTab calls win.focus() unconditionally, so if the browser blocks the popup (window.open returns null) it throws a TypeError synchronously, before the catch() on the link-token fetch is attached. The throw used to escape the click handler, leaving the UI without a notification. Now the failure is surfaced as lastfmLinkFailure and the toggle stays usable. - Rename the link-token "subject" rejection message to "user ID" since the claim is uid, not the JWT sub field. --------- Signed-off-by: Deluan --- adapters/lastfm/auth_router.go | 15 +- adapters/lastfm/auth_router_test.go | 218 +++++++++++++++++++++++ adapters/lastfm/link_token.go | 50 ++++++ ui/src/personal/LastfmScrobbleToggle.jsx | 51 ++++-- 4 files changed, 319 insertions(+), 15 deletions(-) create mode 100644 adapters/lastfm/auth_router_test.go create mode 100644 adapters/lastfm/link_token.go diff --git a/adapters/lastfm/auth_router.go b/adapters/lastfm/auth_router.go index 162ae9037..499863e28 100644 --- a/adapters/lastfm/auth_router.go +++ b/adapters/lastfm/auth_router.go @@ -77,6 +77,13 @@ func (s *Router) getLinkStatus(w http.ResponseWriter, r *http.Request) { return } resp["status"] = key != "" + linkToken, err := createLinkToken(u.ID) + if err != nil { + log.Error(r.Context(), "Could not create LastFM link token", "userId", u.ID, err) + _ = rest.RespondWithError(w, http.StatusInternalServerError, err.Error()) + return + } + resp["linkToken"] = linkToken _ = rest.RespondWithJSON(w, http.StatusOK, resp) } @@ -97,11 +104,17 @@ func (s *Router) callback(w http.ResponseWriter, r *http.Request) { _ = rest.RespondWithError(w, http.StatusBadRequest, "token not received") return } - uid, err := p.String("uid") + linkToken, err := p.String("uid") if err != nil { _ = rest.RespondWithError(w, http.StatusBadRequest, "uid not received") return } + uid, err := verifyLinkToken(linkToken) + if err != nil { + log.Warn(r.Context(), "Rejected LastFM callback with invalid link token", "requestId", middleware.GetReqID(r.Context()), err) + _ = rest.RespondWithError(w, http.StatusBadRequest, "invalid link token") + return + } // Need to add user to context, as this is a non-authenticated endpoint, so it does not // automatically contain any user info diff --git a/adapters/lastfm/auth_router_test.go b/adapters/lastfm/auth_router_test.go new file mode 100644 index 000000000..4cbbd4298 --- /dev/null +++ b/adapters/lastfm/auth_router_test.go @@ -0,0 +1,218 @@ +package lastfm + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "time" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/auth" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("auth_router", func() { + var ( + ds *tests.MockDataStore + userProps *tests.MockedUserPropsRepo + httpClient *tests.FakeHttpClient + router *Router + ) + + const ( + victimID = "victim-user-id" + attackerID = "attacker-user-id" + ) + + BeforeEach(func() { + userProps = &tests.MockedUserPropsRepo{} + ds = &tests.MockDataStore{ + MockedProperty: &tests.MockedPropertyRepo{}, + MockedUserProps: userProps, + } + auth.Init(ds) + + httpClient = &tests.FakeHttpClient{} + router = &Router{ + ds: ds, + apiKey: "API_KEY", + secret: "SECRET", + sessionKeys: &agents.SessionKeys{DataStore: ds, KeyName: sessionKeyProperty}, + } + router.client = newClient(router.apiKey, router.secret, httpClient) + router.Handler = router.routes() + }) + + storedSessionKey := func(userID string) string { + key, _ := userProps.Get(userID, sessionKeyProperty) + return key + } + + stubGetSessionOK := func(sessionKey string) { + httpClient.Res = http.Response{ + Body: io.NopCloser(bytes.NewBufferString(`{"session":{"name":"Navidrome","key":"` + sessionKey + `","subscriber":0}}`)), + StatusCode: 200, + } + } + + Describe("getLinkStatus", func() { + It("includes a signed linkToken for the authenticated user", func() { + req := httptest.NewRequest(http.MethodGet, "/link", nil) + ctx := request.WithUser(req.Context(), model.User{ID: victimID}) + req = req.WithContext(ctx) + rec := httptest.NewRecorder() + + router.getLinkStatus(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + var body map[string]any + Expect(json.Unmarshal(rec.Body.Bytes(), &body)).To(Succeed()) + Expect(body["apiKey"]).To(Equal("API_KEY")) + Expect(body["status"]).To(Equal(false)) + token, ok := body["linkToken"].(string) + Expect(ok).To(BeTrue()) + Expect(token).ToNot(BeEmpty()) + + verified, err := verifyLinkToken(token) + Expect(err).ToNot(HaveOccurred()) + Expect(verified).To(Equal(victimID)) + }) + }) + + Describe("callback", func() { + It("stores the session key under the user encoded in the signed token", func() { + stubGetSessionOK("LEGIT_SESSION") + linkToken, err := createLinkToken(victimID) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken+"&token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(storedSessionKey(victimID)).To(Equal("LEGIT_SESSION")) + }) + + It("rejects a raw (unsigned) uid value", func() { + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+victimID+"&token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(storedSessionKey(victimID)).To(BeEmpty()) + Expect(httpClient.SavedRequest).To(BeNil()) + }) + + It("rejects an expired link token", func() { + expiredToken, err := auth.EncodeToken(map[string]any{ + "uid": victimID, + "scope": linkTokenScope, + "exp": time.Now().Add(-1 * time.Minute).UTC().Unix(), + }) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+expiredToken+"&token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(storedSessionKey(victimID)).To(BeEmpty()) + Expect(httpClient.SavedRequest).To(BeNil()) + }) + + It("rejects a token with the wrong scope (e.g. a regular session JWT)", func() { + sessionJWT, err := auth.CreateToken(&model.User{ID: attackerID, UserName: "attacker"}) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+sessionJWT+"&token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(storedSessionKey(attackerID)).To(BeEmpty()) + Expect(httpClient.SavedRequest).To(BeNil()) + }) + + It("writes only under the user encoded in the token, regardless of query manipulation", func() { + // An attacker holds a legitimate link token for their own account. + // They attempt to call the callback hoping to overwrite the victim's + // session key — but the handler must derive the user ID from the + // signed token, not from any other input. + stubGetSessionOK("ATTACKER_SESSION") + attackerToken, err := createLinkToken(attackerID) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+attackerToken+"&token=LASTFM_TOKEN&user="+victimID, nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(storedSessionKey(attackerID)).To(Equal("ATTACKER_SESSION")) + Expect(storedSessionKey(victimID)).To(BeEmpty()) + }) + + It("returns 400 when uid is missing", func() { + req := httptest.NewRequest(http.MethodGet, "/link/callback?token=LASTFM_TOKEN", nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 400 when token is missing", func() { + linkToken, err := createLinkToken(victimID) + Expect(err).ToNot(HaveOccurred()) + + req := httptest.NewRequest(http.MethodGet, "/link/callback?uid="+linkToken, nil) + rec := httptest.NewRecorder() + router.callback(rec, req) + + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + }) + }) + + Describe("link token helpers", func() { + It("round-trips a freshly issued token", func() { + token, err := createLinkToken(victimID) + Expect(err).ToNot(HaveOccurred()) + + uid, err := verifyLinkToken(token) + Expect(err).ToNot(HaveOccurred()) + Expect(uid).To(Equal(victimID)) + }) + + It("rejects garbage", func() { + _, err := verifyLinkToken("not-a-jwt") + Expect(err).To(HaveOccurred()) + }) + + It("rejects a token whose scope claim is wrong", func() { + wrongScopeToken, err := auth.EncodeToken(map[string]any{ + "uid": victimID, + "scope": "some-other-scope", + "exp": time.Now().Add(linkTokenTTL).UTC().Unix(), + }) + Expect(err).ToNot(HaveOccurred()) + + _, err = verifyLinkToken(wrongScopeToken) + Expect(err).To(MatchError("invalid link token scope")) + }) + + It("rejects a scoped token that has no expiration", func() { + nonExpiringToken, err := auth.EncodeToken(map[string]any{ + "uid": victimID, + "scope": linkTokenScope, + }) + Expect(err).ToNot(HaveOccurred()) + + _, err = verifyLinkToken(nonExpiringToken) + Expect(err).To(MatchError("link token missing expiration")) + }) + }) +}) diff --git a/adapters/lastfm/link_token.go b/adapters/lastfm/link_token.go new file mode 100644 index 000000000..fd8ceb3c9 --- /dev/null +++ b/adapters/lastfm/link_token.go @@ -0,0 +1,50 @@ +package lastfm + +import ( + "errors" + "time" + + "github.com/navidrome/navidrome/core/auth" +) + +const ( + linkTokenScope = "lastfm-link" + linkTokenTTL = 5 * time.Minute +) + +// createLinkToken issues a signed token binding the Last.fm callback to the +// user who initiated the OAuth flow. It travels back through Last.fm via the +// `cb` URL in place of the previously-trusted raw `uid` query parameter. +func createLinkToken(userID string) (string, error) { + claims := map[string]any{ + "uid": userID, + "scope": linkTokenScope, + "exp": time.Now().Add(linkTokenTTL).UTC().Unix(), + } + return auth.EncodeToken(claims) +} + +// verifyLinkToken validates a signed link token and returns the encoded user ID. +// It enforces both the signature/expiry (via the underlying JWT verifier) and a +// dedicated scope claim, preventing tokens minted for other purposes (e.g. a +// regular session JWT) from being accepted here. +func verifyLinkToken(tokenStr string) (string, error) { + token, err := auth.DecodeAndVerifyToken(tokenStr) + if err != nil { + return "", err + } + // jwtauth treats a token without `exp` as non-expiring; require it + // explicitly so an accidental regression cannot mint permanent tokens. + if exp, ok := token.Expiration(); !ok || exp.IsZero() { + return "", errors.New("link token missing expiration") + } + var scope string + if err := token.Get("scope", &scope); err != nil || scope != linkTokenScope { + return "", errors.New("invalid link token scope") + } + var uid string + if err := token.Get("uid", &uid); err != nil || uid == "" { + return "", errors.New("invalid link token user ID") + } + return uid, nil +} diff --git a/ui/src/personal/LastfmScrobbleToggle.jsx b/ui/src/personal/LastfmScrobbleToggle.jsx index 67018d2bb..c8e07328f 100644 --- a/ui/src/personal/LastfmScrobbleToggle.jsx +++ b/ui/src/personal/LastfmScrobbleToggle.jsx @@ -13,21 +13,10 @@ import { baseUrl, openInNewTab } from '../utils' import { httpClient } from '../dataProvider' const Progress = (props) => { - const { setLinked, setCheckingLink, apiKey } = props + const { setLinked, setCheckingLink, openedTab } = props const notify = useNotify() let linkCheckDelay = 2000 let linkChecks = 30 - const openedTab = useRef() - - useEffect(() => { - const callbackEndpoint = baseUrl( - `/api/lastfm/link/callback?uid=${localStorage.getItem('userId')}`, - ) - const callbackUrl = `${window.location.origin}${callbackEndpoint}` - openedTab.current = openInNewTab( - `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${callbackUrl}`, - ) - }, [apiKey]) const endChecking = (success) => { linkCheckDelay = null @@ -76,6 +65,7 @@ export const LastfmScrobbleToggle = (props) => { const [linked, setLinked] = useState(null) const [checkingLink, setCheckingLink] = useState(false) const [apiKey, setApiKey] = useState(false) + const openedTab = useRef() useEffect(() => { httpClient('/api/lastfm/link') @@ -88,9 +78,42 @@ export const LastfmScrobbleToggle = (props) => { }) }, [setLinked, setApiKey]) + const startLink = () => { + // Open the tab synchronously so popup blockers attribute it to the click. + let tab + try { + tab = openInNewTab('about:blank') + } catch { + notify('message.lastfmLinkFailure', 'warning') + return + } + openedTab.current = tab + setCheckingLink(true) + httpClient('/api/lastfm/link') + .then((response) => { + const linkToken = response.json.linkToken + if (!linkToken) { + tab?.close() + notify('message.lastfmLinkFailure', 'warning') + setCheckingLink(false) + return + } + const callbackEndpoint = baseUrl( + `/api/lastfm/link/callback?uid=${encodeURIComponent(linkToken)}`, + ) + const callbackUrl = `${window.location.origin}${callbackEndpoint}` + tab.location.href = `https://www.last.fm/api/auth/?api_key=${apiKey}&cb=${callbackUrl}` + }) + .catch(() => { + tab?.close() + notify('message.lastfmLinkFailure', 'warning') + setCheckingLink(false) + }) + } + const toggleScrobble = () => { if (!linked) { - setCheckingLink(true) + startLink() } else { httpClient('/api/lastfm/link', { method: 'DELETE' }) .then(() => { @@ -121,7 +144,7 @@ export const LastfmScrobbleToggle = (props) => { )} {!apiKey && ( From 55a31f30b3725a17b8a25b13c426dbc41d85b258 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 23 May 2026 18:20:18 -0400 Subject: [PATCH 11/32] fix(scanner): respect tag split config when multiple frames map to the same tag (#5193) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: split tag values from multiple sources individually When a file has multiple tag frames mapping to the same logical tag (e.g. both TXXX:MOOD and TMOO), TagLib merges them into one key with multiple values. SplitTagValue had a len(values) != 1 guard that skipped splitting entirely in this case, leaving comma-separated values unsplit. Change SplitTagValue to split each value individually regardless of input count. Empty values are filtered during splitting. Fixes #5065 * test: cover SplitTagValue with multi-frame regression cases Add tests pinning the behavior fixed by SplitTagValue iterating over each input value. The previous len(values) != 1 short-circuit silently skipped splitting whenever TagLib merged multiple ID3v2 frames into the same property (e.g. TMOO + TXXX:MOOD for mood, or duplicate TIPL entries for composer), as reported in #5065. Three layers of coverage: - model/tag_mappings_test.go: direct unit tests on TagConf.SplitTagValue covering single/multi-value input, case-insensitive separators, missing SplitRx, empty input, and the empty-strings-passed-through contract that the downstream metadata pipeline relies on. - model/metadata/metadata_test.go: end-to-end check that a "mood" tag surfaced as two raw values (the exact shape from the bug report) is split, trimmed, and deduplicated to the expected three moods. - model/metadata/map_participants_test.go: parallel multi-value case for the COMPOSER tag, ensuring the same fix also corrects multi-frame role parsing. All three new specs fail on the pre-fix code and pass on the patched SplitTagValue. --------- Co-authored-by: Deluan Quintão --- model/metadata/map_participants_test.go | 20 ++++++++ model/metadata/metadata_test.go | 15 ++++++ model/tag_mappings.go | 24 +++++----- model/tag_mappings_test.go | 64 +++++++++++++++++++++++++ 4 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 model/tag_mappings_test.go diff --git a/model/metadata/map_participants_test.go b/model/metadata/map_participants_test.go index 71cb9c1f2..5ee802ced 100644 --- a/model/metadata/map_participants_test.go +++ b/model/metadata/map_participants_test.go @@ -684,6 +684,26 @@ var _ = Describe("Participants", func() { Expect(composers[2].Name).To(Equal("The Album Artist")) }) }) + + // Sibling fix to https://github.com/navidrome/navidrome/issues/5065: when + // multiple frames map to the same role tag (e.g. TIPL producer entries), + // the configured split separator must still apply to each value. + When("the tag has multiple values", func() { + It("should split each value individually", func() { + mf = toMediaFile(model.RawTags{ + "COMPOSER": {"John Doe/Jane Doe", "Someone Else"}, + }) + + participants := mf.Participants + Expect(participants).To(HaveKeyWithValue(model.RoleComposer, HaveLen(3))) + composers := participants[model.RoleComposer] + Expect(composers).To(ConsistOf( + HaveField("Name", "John Doe"), + HaveField("Name", "Jane Doe"), + HaveField("Name", "Someone Else"), + )) + }) + }) }) Describe("MBID tags", func() { diff --git a/model/metadata/metadata_test.go b/model/metadata/metadata_test.go index c9acdbce9..350731b89 100644 --- a/model/metadata/metadata_test.go +++ b/model/metadata/metadata_test.go @@ -129,6 +129,21 @@ var _ = Describe("Metadata", func() { Expect(md.Strings(model.TagGenre)).To(Equal([]string{"Rock", "Pop", "Punk"})) }) + + // Regression test for https://github.com/navidrome/navidrome/issues/5065 + // + // MP3s with both an ID3v2 TMOO frame and a TXXX:MOOD frame are surfaced by + // TagLib's PropertyMap as a single "mood" key with multiple values. The split + // configuration must still apply to each value individually. + It("should split values from multiple frames mapping to the same tag", func() { + props.Tags = model.RawTags{ + // Same shape as the bug report: two frames, comma-separated content. + "mood": {"Love, Emotional, Ballad", "Love; Emotional; Ballad"}, + } + md = metadata.New(filePath, props) + + Expect(md.Strings(model.TagMood)).To(ConsistOf("Love", "Emotional", "Ballad")) + }) }) DescribeTable("Date", diff --git a/model/tag_mappings.go b/model/tag_mappings.go index bfe098f77..af76de741 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -34,23 +34,25 @@ type TagConf struct { SplitRx *regexp.Regexp `yaml:"-"` } -// SplitTagValue splits a tag value by the split separators, but only if it has a single value. +// SplitTagValue splits tag values by the configured split separators. +// Each value in the input slice is individually split and trimmed. func (c TagConf) SplitTagValue(values []string) []string { - // If there's not exactly one value or no separators, return early. - if len(values) != 1 || c.SplitRx == nil { + if c.SplitRx == nil || len(values) == 0 { return values } - tag := values[0] - // Replace all occurrences of any separator with the zero-width space. - tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp) + var result []string + for _, tag := range values { + // Replace all occurrences of any separator with the zero-width space. + tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp) - // Split by the zero-width space and trim each substring. - parts := strings.Split(tag, consts.Zwsp) - for i, part := range parts { - parts[i] = strings.TrimSpace(part) + // Split by the zero-width space and trim each substring. + parts := strings.Split(tag, consts.Zwsp) + for _, part := range parts { + result = append(result, strings.TrimSpace(part)) + } } - return parts + return result } type TagType string diff --git a/model/tag_mappings_test.go b/model/tag_mappings_test.go new file mode 100644 index 000000000..1665d557b --- /dev/null +++ b/model/tag_mappings_test.go @@ -0,0 +1,64 @@ +package model + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("TagConf", func() { + Describe("SplitTagValue", func() { + var conf TagConf + + BeforeEach(func() { + conf = TagConf{Split: []string{";", "/", ","}} + conf.SplitRx = compileSplitRegex("test", conf.Split) + }) + + It("splits a single value on configured separators", func() { + Expect(conf.SplitTagValue([]string{"Rock/Pop;Punk"})).To(Equal([]string{"Rock", "Pop", "Punk"})) + }) + + It("trims whitespace around split values", func() { + Expect(conf.SplitTagValue([]string{"Love, Emotional, Ballad"})).To(Equal([]string{"Love", "Emotional", "Ballad"})) + }) + + // Regression test for https://github.com/navidrome/navidrome/issues/5065 + // + // When multiple ID3v2 frames map to the same logical tag (e.g. TMOO + TXXX:MOOD), + // TagLib's PropertyMap merges them into a slice with several entries. Previously + // SplitTagValue had a `len(values) != 1` guard that skipped splitting in this case. + It("splits each value individually when given multiple inputs", func() { + input := []string{"Love, Emotional, Ballad", "Love; Emotional; Ballad"} + Expect(conf.SplitTagValue(input)).To(Equal([]string{ + "Love", "Emotional", "Ballad", + "Love", "Emotional", "Ballad", + })) + }) + + It("matches separators case-insensitively when the split pattern allows", func() { + c := TagConf{Split: []string{" AND "}} + c.SplitRx = compileSplitRegex("test", c.Split) + Expect(c.SplitTagValue([]string{"foo and bar AND baz"})).To(Equal([]string{"foo", "bar", "baz"})) + }) + + It("returns values unchanged when no separators are configured", func() { + c := TagConf{} + Expect(c.SplitTagValue([]string{"Foo, Bar"})).To(Equal([]string{"Foo, Bar"})) + Expect(c.SplitTagValue([]string{"a", "b"})).To(Equal([]string{"a", "b"})) + }) + + It("returns an empty slice for empty input", func() { + Expect(conf.SplitTagValue([]string{})).To(BeEmpty()) + }) + + It("handles a value with no separator as a single-element result", func() { + Expect(conf.SplitTagValue([]string{"JustOneMood"})).To(Equal([]string{"JustOneMood"})) + }) + + It("produces empty strings when separators are adjacent (dedup happens downstream)", func() { + // SplitTagValue itself does not filter empties; that is the job of + // filterDuplicatedOrEmptyValues in the metadata pipeline. + Expect(conf.SplitTagValue([]string{"Rock//Pop"})).To(Equal([]string{"Rock", "", "Pop"})) + }) + }) +}) From 945d0ba1e2ddfc0e8b6f63b47e2913e43310dd0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 24 May 2026 00:24:30 -0300 Subject: [PATCH 12/32] fix(transcoding): cap concurrent transcodes to prevent ffmpeg DoS (#5522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(transcoding): add MaxConcurrent and MaxConcurrentPerUser config Introduce Transcoding.MaxConcurrent (default NumCPU()*2) and Transcoding.MaxConcurrentPerUser (default 3) to support upcoming concurrency limits on the streaming pipeline. No behavior change yet. Refs #5246 * feat(transcoding): add TranscodeLimiter with global and per-user caps Introduce a non-blocking limiter that gates concurrent transcodes. Returns ErrTooManyTranscodes immediately when the cap is reached so callers can translate it into a 429 response, rather than queuing requests. The per-user reservation is taken first to avoid burning a global slot that would only be rolled back when the per-user cap rejects the caller. Release is idempotent so wrapping the transcoder reader's Close is safe. Refs #5246 * feat(transcoding): cap concurrent transcodes in media streamer Acquire a TranscodeLimiter slot before spawning ffmpeg in the transcoding cache's read function, and release it when the resulting reader is closed. Raw streams and cache hits bypass the limiter so a single saturating client cannot block ordinary playback. When the cap is reached, ErrTooManyTranscodes bubbles up through cache.Get, ready for the HTTP layer to translate into a 429 response. Refs #5246 * feat(transcoding): return HTTP 429 with Retry-After when transcode cap is hit Map stream.ErrTooManyTranscodes to HTTP 429 in both the Subsonic API (/stream, /download) and the public share endpoint, including a 5s Retry-After hint. The Subsonic response still carries a failed-status envelope so clients that ignore HTTP codes also see the failure. Refs #5246 * feat(transcoding): default MaxConcurrent to 0 (disabled) Ship the limiter opt-in so existing installations are not affected by a behavior change on upgrade. Users hitting the DoS reported in #5246 can enable it by setting Transcoding.MaxConcurrent to a positive value (NumCPU()*2 is a reasonable starting point). Refs #5246 * fix(transcoding): make global and per-user caps independent Previously the limiter short-circuited to a no-op whenever MaxConcurrent was zero, silently ignoring a configured MaxConcurrentPerUser. Treat each cap independently so an operator can throttle per-user without enforcing a global ceiling (or vice versa), and only fall back to the no-op limiter when both caps are disabled. * fix(archiver): abort archive download when the transcode limiter rejects The album/artist/playlist zip writers were silently producing zip entries with headers but no data when ms.NewStream returned ErrTooManyTranscodes, because the per-file error was discarded by `_ = a.addFileToZip(...)`. The client received HTTP 200 with a corrupt zip and no indication that the server was rate-limited. Now the zip loop bails out as soon as it sees ErrTooManyTranscodes, and the Download handler swallows the error (the response status and Content-Disposition are already flushed by the time the limit is hit, so no 429 can be sent). The truncated zip surfaces the problem to the client; operators see a clear "transcode cap reached" warning in the server logs. Refs #5246 * fix(transcoding): release limiter slot on client close, not ffmpeg EOF Previously the slot was wrapped around the ffmpeg source reader, so it was only released by the cache's background copyAndClose goroutine when ffmpeg finished producing the file — meaning a client that disconnected after a single byte still held the slot for the full transcode duration. Under MaxConcurrent=N this serialized fresh requests behind abandoned encodes for minutes. Hand the release function back from the cache producer via the streamJob struct and wire it into the consumer-side Stream.Close. The HTTP handler already runs `defer stream.Close()`, so disconnect now frees the slot immediately. Cache hits never enter the producer and still pay no slot, and singleflight waiters on the same key correctly inherit no release (only the original producer's job holds the slot). Refs #5246 * fix(transcoding): skip per-user cap for anonymous requests Public share viewers have no user in context, so userName(ctx) returned the literal string "UNKNOWN" and the limiter mapped every anonymous viewer to the same bucket. With MaxConcurrentPerUser=N, only N unrelated anonymous clients could stream a viral share at any time — the opposite of the fairness the per-user cap is meant to provide. Introduce a limiterKey(ctx) helper that returns "" for anonymous callers (userName(ctx) is unchanged for logs), and teach Acquire to skip the per-user reservation when the key is empty. The global cap is still enforced for anonymous traffic and remains the protection against runaway anonymous load. Refs #5246 * refactor(transcoding): tidy limiter struct and centralize Retry-After Per review feedback: - Drop the redundant maxConcurrent field on transcodeLimiter; the channel capacity already enforces the global cap and the field was only used inside the constructor. - Only allocate the perUser map when MaxConcurrentPerUser > 0. - Move the Retry-After value into core/stream as RetryAfterSeconds so the Subsonic API and public-share handlers cannot drift if the window is later tuned. * fix(transcoding): do not log limiter rejections as cache failures NewStream was emitting an error-level "Error accessing transcoding cache" log whenever cache.Get returned anything non-nil, including the limiter's ErrTooManyTranscodes — even though the producer had already logged the rejection at warn level. The result was double logging and a misleading "cache failure" classification that buries real cache problems. Skip the error log when the cause is ErrTooManyTranscodes; the warn line from the producer is the canonical signal. * fix(archiver): open stream before writing zip entry header Per review: addFileToZip previously called z.CreateHeader before NewStream, so when the limiter rejected a transcode the zip already contained a 0-byte entry for that track. Open the source first and only write the header once the read side is ready; rejections now skip the entry entirely. The truncation comment in handleArchiveErr was also misleading — z.Close finalises the central directory, so the client receives a well-formed zip containing only the tracks written before the rejection, not a "truncated" archive. Reword to match reality. * fix(transcoding): hold slot for ffmpeg lifetime, force cancellable ctx The previous release-on-consumer-close design let a client open many unique transcodes, disconnect immediately, and still spawn the configured cap's worth of ffmpeg processes — the cache writer goroutine continued draining ffmpeg to disk after the client disappeared, defeating the DoS protection the limiter is meant to provide. Move the release back onto the source reader so the slot is freed only when ffmpeg actually exits (either EOF or context cancellation). To keep disconnects from leaking slots for the full transcode duration, force the request context into ffmpeg whenever the limiter is enabled — so client disconnect cancels the process and frees the slot promptly. When the limiter is disabled, the legacy EnableTranscodingCancellation behavior is preserved unchanged. Reported by codex and Copilot reviewers on #5522. --- conf/configuration.go | 8 ++ core/archiver.go | 56 ++++++--- core/archiver_test.go | 26 ++++ core/stream/limiter.go | 135 +++++++++++++++++++++ core/stream/limiter_test.go | 186 +++++++++++++++++++++++++++++ core/stream/media_streamer.go | 63 ++++++++-- core/stream/media_streamer_test.go | 54 +++++++++ server/public/handle_streams.go | 9 +- server/subsonic/api.go | 26 +++- server/subsonic/api_test.go | 20 ++++ server/subsonic/stream.go | 23 +++- 11 files changed, 571 insertions(+), 35 deletions(-) create mode 100644 core/stream/limiter.go create mode 100644 core/stream/limiter_test.go diff --git a/conf/configuration.go b/conf/configuration.go index 6fff1641a..b4c43f135 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -113,6 +113,7 @@ type configOptions struct { PID pidOptions `json:",omitzero"` Inspect inspectOptions `json:",omitzero"` Subsonic subsonicOptions `json:",omitzero"` + Transcoding transcodingOptions `json:",omitzero"` LastFM lastfmOptions `json:",omitzero"` Deezer deezerOptions `json:",omitzero"` ListenBrainz listenBrainzOptions `json:",omitzero"` @@ -165,6 +166,11 @@ type scannerOptions struct { PurgeMissing string // Values: "never", "always", "full" } +type transcodingOptions struct { + MaxConcurrent int + MaxConcurrentPerUser int +} + type subsonicOptions struct { AppendSubtitle bool AppendAlbumVersion bool @@ -822,6 +828,8 @@ func setViperDefaults() { viper.SetDefault("subsonic.enableaveragerating", true) viper.SetDefault("subsonic.legacyclients", "DSub") viper.SetDefault("subsonic.minimalclients", "SubMusic") + viper.SetDefault("transcoding.maxconcurrent", 0) + viper.SetDefault("transcoding.maxconcurrentperuser", 0) viper.SetDefault("agents", "deezer,lastfm,listenbrainz") viper.SetDefault("lastfm.enabled", true) viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage) diff --git a/core/archiver.go b/core/archiver.go index 96cc2c31e..5d1c090cd 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -3,6 +3,7 @@ package core import ( "archive/zip" "context" + "errors" "fmt" "io" "os" @@ -60,7 +61,15 @@ func (a *archiver) zipAlbums(ctx context.Context, id string, format string, bitr "format", format, "bitrate", bitrate, "isMultiDisc", isMultiDisc, "numTracks", len(album)) for _, mf := range album { file := a.albumFilename(mf, format, isMultiDisc) - _ = a.addFileToZip(ctx, z, mf, format, bitrate, file) + if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { + // Stop iterating: continuing would just rack up more + // rejections from the limiter. Close finalises whatever + // tracks were already written; the rejected one is not + // present in the archive (addFileToZip aborts before + // writing its entry header). + _ = z.Close() + return addErr + } } } err = z.Close() @@ -120,7 +129,12 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st zippedMfs := make(model.MediaFiles, len(mfs)) for idx, mf := range mfs { file := a.playlistFilename(mf, format, idx) - _ = a.addFileToZip(ctx, z, mf, format, bitrate, file) + if addErr := a.addFileToZip(ctx, z, mf, format, bitrate, file); errors.Is(addErr, stream.ErrTooManyTranscodes) { + // Abort the whole archive: continuing would silently emit + // empty zip entries since the headers are already written. + _ = z.Close() + return addErr + } mf.Path = file zippedMfs[idx] = mf } @@ -162,6 +176,27 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error { path := mf.AbsolutePath() + + // Open the source before writing the zip entry header so a rejection + // (limiter, missing file, etc.) does not leave an empty entry in the + // archive. + var r io.ReadCloser + var err error + if format != "raw" && format != "" { + r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate}) + } else { + r, err = os.Open(path) + } + if err != nil { + log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err) + return err + } + defer func() { + if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { + log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err) + } + }() + w, err := z.CreateHeader(&zip.FileHeader{ Name: filename, Modified: mf.UpdatedAt, @@ -172,23 +207,6 @@ func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.Med return err } - var r io.ReadCloser - if format != "raw" && format != "" { - r, err = a.ms.NewStream(ctx, &mf, stream.Request{Format: format, BitRate: bitrate}) - } else { - r, err = os.Open(path) - } - if err != nil { - log.Error(ctx, "Error opening file for zipping", "file", path, "format", format, err) - return err - } - - defer func() { - if err := r.Close(); err != nil && log.IsGreaterOrEqualTo(log.LevelDebug) { - log.Error(ctx, "Error closing stream", "id", mf.ID, "file", path, err) - } - }() - _, err = io.Copy(w, r) if err != nil { log.Error(ctx, "Error zipping file", "file", path, err) diff --git a/core/archiver_test.go b/core/archiver_test.go index 4f7aed278..f432139d8 100644 --- a/core/archiver_test.go +++ b/core/archiver_test.go @@ -89,6 +89,32 @@ var _ = Describe("Archiver", func() { }) }) + Context("when the transcode limiter rejects a file", func() { + It("aborts the archive instead of continuing with empty entries", func() { + mfs := model.MediaFiles{ + {Path: "test_data/01 - track1.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1}, + {Path: "test_data/02 - track2.mp3", Suffix: "mp3", AlbumID: "1", Album: "Album", DiscNumber: 1}, + } + + mfRepo := &mockMediaFileRepository{} + mfRepo.On("GetAll", []model.QueryOptions{{ + Filters: squirrel.Eq{"album_id": "1"}, + Sort: "album", + }}).Return(mfs, nil) + ds.On("MediaFile", mock.Anything).Return(mfRepo) + + ms.On("NewStream", mock.Anything, mock.Anything, stream.Request{Format: "mp3", BitRate: 128}). + Return(nil, stream.ErrTooManyTranscodes).Once() + + out := new(bytes.Buffer) + err := arch.ZipAlbum(context.Background(), "1", "mp3", 128, out) + Expect(err).To(MatchError(stream.ErrTooManyTranscodes)) + // NewStream should only have been called once: the loop must bail + // out on the rejection instead of trying every remaining track. + ms.AssertNumberOfCalls(GinkgoT(), "NewStream", 1) + }) + }) + Context("ZipShare", func() { It("zips a share correctly", func() { mfs := model.MediaFiles{ diff --git a/core/stream/limiter.go b/core/stream/limiter.go new file mode 100644 index 000000000..622fe21cc --- /dev/null +++ b/core/stream/limiter.go @@ -0,0 +1,135 @@ +package stream + +import ( + "context" + "errors" + "io" + "sync" + "sync/atomic" +) + +// ErrTooManyTranscodes is returned by TranscodeLimiter.Acquire when the +// configured concurrency cap has been reached. Callers should translate this +// into an HTTP 429 response so well-behaved clients back off and retry. +var ErrTooManyTranscodes = errors.New("too many concurrent transcodes") + +// RetryAfterSeconds is the value returned in the HTTP Retry-After header when +// a request is rejected with ErrTooManyTranscodes. Most transcodes finish well +// within this window, so retrying after this delay typically succeeds. +const RetryAfterSeconds = 5 + +// TranscodeLimiter gates the number of concurrent ffmpeg transcodes. It enforces +// both a global cap (to protect the host from process exhaustion) and an optional +// per-user cap (to keep one client from starving the others). Acquire never +// blocks: it either reserves a slot or returns ErrTooManyTranscodes immediately. +type TranscodeLimiter interface { + // Acquire reserves a slot for the given user. On success it returns a release + // function that must be called exactly once when the transcode is done. + // Calling release more than once is safe and idempotent. + Acquire(ctx context.Context, user string) (release func(), err error) + + // Enabled reports whether the limiter actually enforces any cap. Callers + // can use it to decide whether to bind ffmpeg's lifetime to the request + // context so disconnects free slots quickly, rather than letting the + // process drain to completion in the background. + Enabled() bool +} + +// NewTranscodeLimiter returns a limiter enforcing the given caps. Each cap is +// independent: a value of zero or less disables that cap. When both caps are +// disabled the limiter is a no-op. +func NewTranscodeLimiter(maxConcurrent, maxPerUser int) TranscodeLimiter { + if maxConcurrent <= 0 && maxPerUser <= 0 { + return noopLimiter{} + } + l := &transcodeLimiter{maxPerUser: maxPerUser} + if maxConcurrent > 0 { + l.global = make(chan struct{}, maxConcurrent) + } + if maxPerUser > 0 { + l.perUser = make(map[string]int) + } + return l +} + +// releasingReadCloser wraps an io.ReadCloser so that closing it also releases +// the limiter slot exactly once. release must be the function returned by +// TranscodeLimiter.Acquire; its own idempotency makes double-Close safe too. +type releasingReadCloser struct { + io.ReadCloser + release func() +} + +func (r *releasingReadCloser) Close() error { + err := r.ReadCloser.Close() + r.release() + return err +} + +type noopLimiter struct{} + +func (noopLimiter) Acquire(context.Context, string) (func(), error) { + return func() {}, nil +} + +func (noopLimiter) Enabled() bool { return false } + +type transcodeLimiter struct { + maxPerUser int + global chan struct{} + + mu sync.Mutex + perUser map[string]int +} + +func (*transcodeLimiter) Enabled() bool { return true } + +func (l *transcodeLimiter) Acquire(_ context.Context, user string) (func(), error) { + // Reserve a per-user slot first so a noisy user can't burn through + // global slots only to be rejected later. An empty user key means + // "anonymous" (e.g. public share viewers); we skip the per-user cap + // entirely so unrelated anonymous clients do not share a bucket. + perUserActive := l.maxPerUser > 0 && user != "" + if perUserActive { + l.mu.Lock() + if l.perUser[user] >= l.maxPerUser { + l.mu.Unlock() + return nil, ErrTooManyTranscodes + } + l.perUser[user]++ + l.mu.Unlock() + } + + if l.global != nil { + select { + case l.global <- struct{}{}: + default: + if perUserActive { + l.releasePerUser(user) + } + return nil, ErrTooManyTranscodes + } + } + + var released atomic.Bool + return func() { + if !released.CompareAndSwap(false, true) { + return + } + if l.global != nil { + <-l.global + } + if perUserActive { + l.releasePerUser(user) + } + }, nil +} + +func (l *transcodeLimiter) releasePerUser(user string) { + l.mu.Lock() + defer l.mu.Unlock() + l.perUser[user]-- + if l.perUser[user] <= 0 { + delete(l.perUser, user) + } +} diff --git a/core/stream/limiter_test.go b/core/stream/limiter_test.go new file mode 100644 index 000000000..d278d47c8 --- /dev/null +++ b/core/stream/limiter_test.go @@ -0,0 +1,186 @@ +package stream_test + +import ( + "context" + "errors" + "sync" + + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("TranscodeLimiter", func() { + ctx := log.NewContext(context.TODO()) + + Describe("Disabled (both caps <= 0)", func() { + It("never blocks and never returns ErrTooManyTranscodes", func() { + lim := stream.NewTranscodeLimiter(0, 0) + for range 100 { + rel, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + Expect(rel).ToNot(BeNil()) + } + }) + }) + + Describe("Per-user cap only (no global cap)", func() { + It("still enforces the per-user limit when MaxConcurrent is disabled", func() { + lim := stream.NewTranscodeLimiter(0, 2) + + rel1, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + rel2, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + _, err = lim.Acquire(ctx, "alice") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + + // Other users have their own buckets. + rel3, err := lim.Acquire(ctx, "bob") + Expect(err).ToNot(HaveOccurred()) + + rel1() + _, err = lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + rel2() + rel3() + }) + }) + + Describe("Global cap", func() { + It("rejects requests beyond MaxConcurrent with ErrTooManyTranscodes", func() { + lim := stream.NewTranscodeLimiter(2, 0) + + rel1, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + rel2, err := lim.Acquire(ctx, "bob") + Expect(err).ToNot(HaveOccurred()) + + _, err = lim.Acquire(ctx, "carol") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + + rel1() + _, err = lim.Acquire(ctx, "carol") + Expect(err).ToNot(HaveOccurred()) + + rel2() + }) + + It("releases a slot only once even if release is called multiple times", func() { + lim := stream.NewTranscodeLimiter(1, 0) + + rel, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + rel() + rel() + rel() + + // After releases, exactly one slot should be available. + _, err = lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + _, err = lim.Acquire(ctx, "alice") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + }) + }) + + Describe("Per-user cap", func() { + It("rejects a user beyond MaxConcurrentPerUser even if global slots remain", func() { + lim := stream.NewTranscodeLimiter(10, 2) + + rel1, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + rel2, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + _, err = lim.Acquire(ctx, "alice") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + + // A different user is unaffected. + rel3, err := lim.Acquire(ctx, "bob") + Expect(err).ToNot(HaveOccurred()) + + rel1() + _, err = lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + + rel2() + rel3() + }) + + It("skips the per-user cap for anonymous users (empty key)", func() { + // Anonymous requests (e.g. public share viewers) deliberately + // bypass the per-user cap so unrelated anonymous clients are not + // collapsed into a single shared bucket. The global cap remains + // the only ceiling on anonymous traffic. + lim := stream.NewTranscodeLimiter(10, 1) + + rels := make([]func(), 0, 5) + for range 5 { + rel, err := lim.Acquire(ctx, "") + Expect(err).ToNot(HaveOccurred()) + rels = append(rels, rel) + } + for _, rel := range rels { + rel() + } + }) + + It("still applies the global cap to anonymous users", func() { + lim := stream.NewTranscodeLimiter(2, 1) + + rel1, err := lim.Acquire(ctx, "") + Expect(err).ToNot(HaveOccurred()) + rel2, err := lim.Acquire(ctx, "") + Expect(err).ToNot(HaveOccurred()) + + _, err = lim.Acquire(ctx, "") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + + rel1() + rel2() + }) + }) + + Describe("Concurrent safety", func() { + It("survives parallel Acquire/release with consistent counts", func() { + lim := stream.NewTranscodeLimiter(5, 0) + + var wg sync.WaitGroup + var acquired int64 + var rejected int64 + var mu sync.Mutex + + for i := range 50 { + wg.Add(1) + go func(i int) { + defer wg.Done() + rel, err := lim.Acquire(ctx, "alice") + mu.Lock() + if err == nil { + acquired++ + mu.Unlock() + rel() + } else { + rejected++ + mu.Unlock() + } + _ = i + }(i) + } + wg.Wait() + + Expect(acquired + rejected).To(Equal(int64(50))) + // After all releases, all 5 slots should be free again. + for range 5 { + _, err := lim.Acquire(ctx, "alice") + Expect(err).ToNot(HaveOccurred()) + } + _, err := lim.Acquire(ctx, "alice") + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + }) + }) +}) diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go index de03b4d2f..f898e066e 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -2,6 +2,7 @@ package stream import ( "context" + "errors" "fmt" "io" "mime" @@ -28,13 +29,19 @@ type MediaStreamer interface { type TranscodingCache cache.FileCache func NewMediaStreamer(ds model.DataStore, t ffmpeg.FFmpeg, cache TranscodingCache) MediaStreamer { - return &mediaStreamer{ds: ds, transcoder: t, cache: cache} + return &mediaStreamer{ + ds: ds, + transcoder: t, + cache: cache, + limiter: NewTranscodeLimiter(conf.Server.Transcoding.MaxConcurrent, conf.Server.Transcoding.MaxConcurrentPerUser), + } } type mediaStreamer struct { ds model.DataStore transcoder ffmpeg.FFmpeg cache cache.FileCache + limiter TranscodeLimiter } type streamJob struct { @@ -104,7 +111,12 @@ func (ms *mediaStreamer) NewStream(ctx context.Context, mf *model.MediaFile, req } r, err := ms.cache.Get(ctx, job) if err != nil { - log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err) + // Rate-limit rejections are already logged at warn level by the + // producer; treating them as cache failures here would both + // double-log and mask actual cache problems. + if !errors.Is(err, ErrTooManyTranscodes) { + log.Error(ctx, "Error accessing transcoding cache", "id", mf.ID, err) + } return nil, err } cached = r.Cached @@ -217,15 +229,31 @@ func NewTranscodingCache() TranscodingCache { return nil, os.ErrInvalid } - // Choose the appropriate context based on EnableTranscodingCancellation configuration. - // This is where we decide whether transcoding processes should be cancellable or not. + release, err := job.ms.limiter.Acquire(ctx, limiterKey(ctx)) + if err != nil { + log.Warn(ctx, "Refusing transcode: concurrent transcode limit reached", + "id", job.mf.ID, "user", userName(ctx), + "maxConcurrent", conf.Server.Transcoding.MaxConcurrent, + "maxPerUser", conf.Server.Transcoding.MaxConcurrentPerUser) + return nil, err + } + + // Choose the context that drives the ffmpeg process. + // + // When the limiter is enabled, force the request context so a + // client disconnect cancels ffmpeg and frees the slot promptly. + // Otherwise a client could open many transcodes, disconnect + // immediately, and still leave the configured cap's worth of + // ffmpeg processes draining in the background — which is exactly + // the DoS the limiter is meant to prevent. + // + // When the limiter is disabled, preserve the legacy behavior + // governed by EnableTranscodingCancellation so this PR does not + // change observable behavior for operators who have not opted in. var transcodingCtx context.Context - if conf.Server.EnableTranscodingCancellation { - // Use the request context directly, allowing cancellation when client disconnects + if job.ms.limiter.Enabled() || conf.Server.EnableTranscodingCancellation { transcodingCtx = ctx } else { - // Use background context with request values preserved. - // This prevents cancellation but maintains request metadata (user, client, etc.) transcodingCtx = request.AddValues(context.Background(), ctx) } @@ -240,10 +268,14 @@ func NewTranscodingCache() TranscodingCache { Offset: job.offset, }) if err != nil { + release() log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err) return nil, os.ErrInvalid } - return out, nil + // Tie the slot to the ffmpeg process: copyAndClose calls Close + // on this reader after io.Copy returns, which is exactly when + // ffmpeg has exited (either EOF or context cancellation). + return &releasingReadCloser{ReadCloser: out, release: release}, nil }) } @@ -255,3 +287,16 @@ func userName(ctx context.Context) string { return user.UserName } } + +// limiterKey returns the per-user bucket key used by the transcode limiter. +// For anonymous requests (e.g. public shares) it returns the empty string, +// which signals the limiter to skip the per-user cap entirely — otherwise +// every anonymous viewer of a public share would collide on the same key +// and starve each other within MaxConcurrentPerUser slots. The global cap +// still applies and remains the protection against runaway anonymous load. +func limiterKey(ctx context.Context) string { + if user, ok := request.UserFrom(ctx); ok { + return user.UserName + } + return "" +} diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go index 1bbf868fa..676e8d6f8 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -2,6 +2,7 @@ package stream_test import ( "context" + "errors" "io" "os" @@ -10,6 +11,7 @@ import ( "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -61,6 +63,58 @@ var _ = Describe("MediaStreamer", func() { Expect(s.Seekable()).To(BeFalse()) Expect(s.Duration()).To(Equal(float32(257.0))) }) + It("rejects transcode requests beyond MaxConcurrent with ErrTooManyTranscodes", func() { + // Rebuild the streamer with a tight cap. The first request will hold the + // ffmpeg reader open (we don't read/close it), saturating the single slot. + conf.Server.Transcoding.MaxConcurrent = 1 + conf.Server.Transcoding.MaxConcurrentPerUser = 0 + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + + userCtx := request.WithUsername(ctx, "alice") + s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) + Expect(err).ToNot(HaveOccurred()) + defer s1.Close() + + // Different cache key so it doesn't dedupe with the first request. + _, err = tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96}) + Expect(errors.Is(err, stream.ErrTooManyTranscodes)).To(BeTrue()) + }) + + It("releases the slot once the stream is closed", func() { + conf.Server.Transcoding.MaxConcurrent = 1 + conf.Server.Transcoding.MaxConcurrentPerUser = 0 + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + + userCtx := request.WithUsername(ctx, "alice") + s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) + Expect(err).ToNot(HaveOccurred()) + _, _ = io.ReadAll(s1) + _ = s1.Close() + Eventually(func() bool { return ffmpeg.IsClosed() }, "3s").Should(BeTrue()) + + // Slot should now be free for a different transcode. + s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 96}) + Expect(err).ToNot(HaveOccurred()) + defer s2.Close() + }) + + It("does not consume a slot for raw streams", func() { + conf.Server.Transcoding.MaxConcurrent = 1 + conf.Server.Transcoding.MaxConcurrentPerUser = 0 + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + + userCtx := request.WithUsername(ctx, "alice") + // First, saturate the single transcode slot. + s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) + Expect(err).ToNot(HaveOccurred()) + defer s1.Close() + + // Raw stream must still succeed. + s2, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "raw"}) + Expect(err).ToNot(HaveOccurred()) + defer s2.Close() + }) + It("returns a seekable stream if the file is complete in the cache", func() { s, err := streamer.NewStream(ctx, mf, stream.Request{Format: "mp3", BitRate: 32}) Expect(err).To(BeNil()) diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index 7d5a836b3..c7b8a4d4f 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -7,7 +7,7 @@ import ( "time" "github.com/navidrome/navidrome/core/auth" - "github.com/navidrome/navidrome/core/stream" + streampkg "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" . "github.com/navidrome/navidrome/utils/gg" @@ -48,10 +48,15 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } - stream, err := pub.streamer.NewStream(ctx, mf, stream.Request{ + stream, err := pub.streamer.NewStream(ctx, mf, streampkg.Request{ Format: info.format, BitRate: info.bitrate, }) if err != nil { + if errors.Is(err, streampkg.ErrTooManyTranscodes) { + w.Header().Set("Retry-After", strconv.Itoa(streampkg.RetryAfterSeconds)) + http.Error(w, "too many concurrent transcodes, please retry shortly", http.StatusTooManyRequests) + return + } log.Error(ctx, "Error starting shared stream", err) http.Error(w, "invalid request", http.StatusInternalServerError) return diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 7d15125b6..f39dec009 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -7,6 +7,7 @@ import ( "fmt" "net/http" "regexp" + "strconv" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" @@ -304,6 +305,8 @@ func mapToSubsonicError(err error) subError { err = newError(responses.ErrorDataNotFound, "data not found") case errors.Is(err, model.ErrNotAuthorized): err = newError(responses.ErrorAuthorizationFail) + case errors.Is(err, stream.ErrTooManyTranscodes): + err = newError(responses.ErrorGeneric, "too many concurrent transcodes, please retry shortly") default: err = newError(responses.ErrorGeneric, fmt.Sprintf("Internal Server Error: %s", err)) } @@ -313,15 +316,31 @@ func mapToSubsonicError(err error) subError { } func sendError(w http.ResponseWriter, r *http.Request, err error) { + if errors.Is(err, stream.ErrTooManyTranscodes) { + w.Header().Set("Retry-After", strconv.Itoa(stream.RetryAfterSeconds)) + sendResponseWithStatus(w, r, errorResponse(err), http.StatusTooManyRequests) + return + } + sendResponse(w, r, errorResponse(err)) +} + +func errorResponse(err error) *responses.Subsonic { subErr := mapToSubsonicError(err) response := newResponse() response.Status = responses.StatusFailed response.Error = &responses.Error{Code: subErr.code, Message: subErr.Error()} - - sendResponse(w, r, response) + return response } func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic) { + sendResponseWithStatus(w, r, payload, 0) +} + +// sendResponseWithStatus writes the response body in the format requested by +// the client. When status is non-zero, WriteHeader is called with that code +// before the body is written; callers that need to set additional headers +// (e.g. Retry-After) must set them before calling. +func sendResponseWithStatus(w http.ResponseWriter, r *http.Request, payload *responses.Subsonic, status int) { p := req.Params(r) f, _ := p.String("f") var response []byte @@ -356,6 +375,9 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub sendError(w, r, err) return } + if status != 0 { + w.WriteHeader(status) + } if payload.Status == responses.StatusOK { if log.IsGreaterOrEqualTo(log.LevelTrace) { diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index a2db4a0af..a1b66925a 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -3,11 +3,13 @@ package subsonic import ( "encoding/json" "encoding/xml" + "fmt" "math" "net/http" "net/http/httptest" "strings" + "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -152,6 +154,24 @@ var _ = Describe("sendResponse", func() { }) }) + It("responds with HTTP 429 and Retry-After when the transcode limiter rejects", func() { + w = httptest.NewRecorder() + r = httptest.NewRequest("GET", "/rest/stream", nil) + + sendError(w, r, fmt.Errorf("rejected: %w", stream.ErrTooManyTranscodes)) + + Expect(w.Code).To(Equal(http.StatusTooManyRequests)) + Expect(w.Header().Get("Retry-After")).ToNot(BeEmpty()) + + var subsonicResponse responses.Subsonic + err := xml.Unmarshal(w.Body.Bytes(), &subsonicResponse) + Expect(err).NotTo(HaveOccurred()) + Expect(subsonicResponse.Status).To(Equal(responses.StatusFailed)) + Expect(subsonicResponse.Error).ToNot(BeNil()) + Expect(subsonicResponse.Error.Code).To(Equal(responses.ErrorGeneric)) + Expect(subsonicResponse.Error.Message).To(ContainSubstring("transcode")) + }) + It("updates status pointer when an error occurs", func() { pointer := int32(0) diff --git a/server/subsonic/stream.go b/server/subsonic/stream.go index b49af2b24..28b4585f0 100644 --- a/server/subsonic/stream.go +++ b/server/subsonic/stream.go @@ -1,12 +1,15 @@ package subsonic import ( + "context" + "errors" "fmt" "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" @@ -119,14 +122,28 @@ func (api *Router) Download(w http.ResponseWriter, r *http.Request) (*responses. return nil, err case *model.Album: setHeaders(v.Name) - return nil, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w) + return nil, handleArchiveErr(ctx, id, api.archiver.ZipAlbum(ctx, id, format, maxBitRate, w)) case *model.Artist: setHeaders(v.Name) - return nil, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w) + return nil, handleArchiveErr(ctx, id, api.archiver.ZipArtist(ctx, id, format, maxBitRate, w)) case *model.Playlist: setHeaders(v.Name) - return nil, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w) + return nil, handleArchiveErr(ctx, id, api.archiver.ZipPlaylist(ctx, id, format, maxBitRate, w)) default: return nil, model.ErrNotFound } } + +// handleArchiveErr swallows ErrTooManyTranscodes from archive downloads so the +// outer error handler does not try to write a 429 onto a response whose status +// and Content-Disposition have already been flushed. The archive ends up with +// the tracks that were written before the rejection (the rejected track and +// any following ones are omitted); the server-side log is the unambiguous +// signal operators can act on. +func handleArchiveErr(ctx context.Context, id string, err error) error { + if errors.Is(err, stream.ErrTooManyTranscodes) { + log.Warn(ctx, "Archive download finalized early: transcode cap reached", "id", id, err) + return nil + } + return err +} From 823d851b75f72cea1ca1092aec0e30ced5aeef79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 24 May 2026 00:51:58 -0300 Subject: [PATCH 13/32] refactor(transcoding): rename EnableTranscodingCancellation to Transcoding.EnableCancellation (#5523) Move the option into the nested Transcoding config group alongside the limit knobs it interacts with, so all transcoding-related settings live together. The old top-level name is still honored via the existing mapDeprecatedOption / logDeprecatedOptions plumbing, which forwards the value to the new key and logs a deprecation warning at startup. The old struct field is removed (the new field is the single source of truth); the deprecated default is removed so viper.IsSet correctly distinguishes "user set the legacy option" from "no one set it." --- conf/configuration.go | 6 ++++-- core/stream/media_streamer.go | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index b4c43f135..08f12fc94 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -47,7 +47,6 @@ type configOptions struct { UIWelcomeMessage string MaxSidebarPlaylists int EnableTranscodingConfig bool - EnableTranscodingCancellation bool EnableDownloads bool EnableExternalServices bool EnableM3UExternalAlbumArt bool @@ -169,6 +168,7 @@ type scannerOptions struct { type transcodingOptions struct { MaxConcurrent int MaxConcurrentPerUser int + EnableCancellation bool } type subsonicOptions struct { @@ -330,6 +330,7 @@ func Load(noConfigDump bool) { mapDeprecatedOption("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality") mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") + mapDeprecatedOption("EnableTranscodingCancellation", "Transcoding.EnableCancellation") err := viper.Unmarshal(&Server, viper.DecodeHook( mapstructure.ComposeDecodeHookFunc( @@ -455,6 +456,7 @@ func Load(noConfigDump bool) { logDeprecatedOptions("HTTPSecurityHeaders.CustomFrameOptionsValue", "HTTPHeaders.FrameOptions") logDeprecatedOptions("CoverJpegQuality", "CoverArtQuality") logDeprecatedOptions("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") + logDeprecatedOptions("EnableTranscodingCancellation", "Transcoding.EnableCancellation") // Removed options logRemovedOptions("Spotify.ID", "Spotify.Secret") @@ -743,7 +745,6 @@ func setViperDefaults() { viper.SetDefault("uiwelcomemessage", "") viper.SetDefault("maxsidebarplaylists", consts.DefaultMaxSidebarPlaylists) viper.SetDefault("enabletranscodingconfig", false) - viper.SetDefault("enabletranscodingcancellation", false) viper.SetDefault("transcodingcachesize", "100MB") viper.SetDefault("imagecachesize", "100MB") viper.SetDefault("albumplaycountmode", consts.AlbumPlayCountModeAbsolute) @@ -830,6 +831,7 @@ func setViperDefaults() { viper.SetDefault("subsonic.minimalclients", "SubMusic") viper.SetDefault("transcoding.maxconcurrent", 0) viper.SetDefault("transcoding.maxconcurrentperuser", 0) + viper.SetDefault("transcoding.enablecancellation", false) viper.SetDefault("agents", "deezer,lastfm,listenbrainz") viper.SetDefault("lastfm.enabled", true) viper.SetDefault("lastfm.language", consts.DefaultInfoLanguage) diff --git a/core/stream/media_streamer.go b/core/stream/media_streamer.go index f898e066e..b09d9bab8 100644 --- a/core/stream/media_streamer.go +++ b/core/stream/media_streamer.go @@ -248,10 +248,10 @@ func NewTranscodingCache() TranscodingCache { // the DoS the limiter is meant to prevent. // // When the limiter is disabled, preserve the legacy behavior - // governed by EnableTranscodingCancellation so this PR does not - // change observable behavior for operators who have not opted in. + // governed by Transcoding.EnableCancellation so unchanged configs + // keep their previous observable behavior. var transcodingCtx context.Context - if job.ms.limiter.Enabled() || conf.Server.EnableTranscodingCancellation { + if job.ms.limiter.Enabled() || conf.Server.Transcoding.EnableCancellation { transcodingCtx = ctx } else { transcodingCtx = request.AddValues(context.Background(), ctx) From fc9cdf39c8098e2c0e3d315aee4aff2d7772caa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 27 May 2026 23:18:35 -0300 Subject: [PATCH 14/32] fix(conf): make Dir a plain value type to prevent sync.Once corruption (#5543) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dir embedded sync.Once directly and exposed a value-receiver GoString so that pretty.Sprintf("%# v", Server) could render the path. That meant every pretty-print copied the entire Dir along with its Once, and a goroutine concurrently using the original (or any copy) for Path() could hit a "sync: unlock of unlocked mutex" runtime fatal error. The failure was reproduced deterministically on Windows CI when test-suite shuffle ordering raced cache initialization (utils/cache/file_caches.go's NewFileCache.func1 -> conf.CacheFolder.MustPath) against the configuration-dump pretty.Sprintf in Load(). Drop the sync.Once entirely. Dir is now a plain {path, perm} value type, and Path() calls os.MkdirAll on every invocation. MkdirAll is idempotent, so repeated calls on an existing directory cost one stat syscall — negligible for the few config paths read at startup and during cache init. This removes the entire class of bug: - No Mutex, so copies (via reflection, pretty-print, etc.) are safe. - No state pointer, so no nil-state defensive checks scattered across methods, and no risk of two copies seeing different lifecycle state. - go vet is happy with the value receivers — the //nolint:govet suppression on GoString is gone. Adds two regression tests in conf/dir_test.go: - GoString renders Dir as a quoted path under pretty.Sprintf (and does not leak the internal struct fields). - Concurrent copy + Path() stress test, locking in the copy-safety property in case the type ever grows non-trivial state again. --- conf/dir.go | 47 ++++++++++++++++++++++++----------------------- conf/dir_test.go | 41 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 25 deletions(-) diff --git a/conf/dir.go b/conf/dir.go index abbe72b77..f7a14b933 100644 --- a/conf/dir.go +++ b/conf/dir.go @@ -1,20 +1,20 @@ package conf import ( + "cmp" "fmt" "os" - "sync" ) -// Dir wraps a directory path and lazily creates the directory on first use. -// The directory is created at most once; if creation fails, the error is -// permanently cached (sync.Once semantics). Dir is not safe for mutation -// after Path() has been called. +// Dir wraps a directory path and creates the directory on demand. Dir is a +// plain value type — safe to copy, compare, and print via reflection-based +// formatters (pretty.Sprintf("%# v", ...)) without any concurrency hazards. +// Directory creation is delegated to os.MkdirAll on every Path() call; +// MkdirAll is idempotent, so repeated calls cost one stat syscall when the +// directory already exists. type Dir struct { path string perm os.FileMode - once sync.Once - err error } // NewDir creates a new Dir with the given path and default permissions (os.ModePerm). @@ -23,31 +23,32 @@ func NewDir(path string) Dir { } // NewDirWithPerm creates a new Dir with the given path and permissions. +// A perm of 0 is treated as "default" and resolves to os.ModePerm at +// directory-creation time; pass an explicit non-zero mode to constrain the +// permissions. func NewDirWithPerm(path string, perm os.FileMode) Dir { return Dir{path: path, perm: perm} } // String returns the raw path without creating the directory. Satisfies fmt.Stringer. -func (d *Dir) String() string { +func (d Dir) String() string { return d.path } -// Path creates the directory on first call (via sync.Once) and returns the path. -func (d *Dir) Path() (string, error) { - d.once.Do(func() { - if d.path == "" { - return - } - d.err = os.MkdirAll(d.path, d.perm) - if d.err != nil { - d.err = fmt.Errorf("creating directory %q: %w", d.path, d.err) - } - }) - return d.path, d.err +// Path ensures the directory exists and returns its path. Safe to call +// repeatedly; an empty path is returned as-is with no error. +func (d Dir) Path() (string, error) { + if d.path == "" { + return "", nil + } + if err := os.MkdirAll(d.path, cmp.Or(d.perm, os.ModePerm)); err != nil { + return d.path, fmt.Errorf("creating directory %q: %w", d.path, err) + } + return d.path, nil } // MustPath calls Path() and calls logFatal on error. -func (d *Dir) MustPath() string { +func (d Dir) MustPath() string { path, err := d.Path() if err != nil { logFatal("creating directory:", err) @@ -57,12 +58,12 @@ func (d *Dir) MustPath() string { // GoString implements fmt.GoStringer so that %#v (used by pretty.Sprintf) // prints the path string instead of the internal struct fields. -func (d Dir) GoString() string { //nolint:govet // uses a value receiver so Dir values satisfy GoStringer +func (d Dir) GoString() string { return fmt.Sprintf("%q", d.path) } // MarshalText returns the raw path bytes. No side effects. -func (d *Dir) MarshalText() ([]byte, error) { +func (d Dir) MarshalText() ([]byte, error) { return []byte(d.path), nil } diff --git a/conf/dir_test.go b/conf/dir_test.go index 2dd4250bc..79db379d2 100644 --- a/conf/dir_test.go +++ b/conf/dir_test.go @@ -2,7 +2,9 @@ package conf_test import ( "os" + "sync" + "github.com/kr/pretty" "github.com/navidrome/navidrome/conf" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -35,9 +37,9 @@ var _ = Describe("Dir", func() { Expect(target).To(BeADirectory()) }) - It("returns the same result on subsequent calls (sync.Once)", func() { + It("is idempotent on subsequent calls", func() { dir := GinkgoT().TempDir() - target := dir + "/once" + target := dir + "/idempotent" d := conf.NewDir(target) path1, err1 := d.Path() @@ -45,6 +47,7 @@ var _ = Describe("Dir", func() { Expect(err1).ToNot(HaveOccurred()) Expect(err2).ToNot(HaveOccurred()) Expect(path1).To(Equal(path2)) + Expect(target).To(BeADirectory()) }) It("returns an error when directory cannot be created", func() { @@ -124,4 +127,38 @@ var _ = Describe("Dir", func() { Expect(d2.String()).To(Equal(d1.String())) }) }) + + Describe("GoString", func() { + // Regression: pretty.Sprintf("%# v", ...) is used by the + // configuration dump. It must render Dir as a quoted path via + // GoString, not dump the internal struct fields. + It("renders Dir as a quoted path under pretty.Sprintf", func() { + type host struct { + DataFolder conf.Dir + } + h := host{DataFolder: conf.NewDir("./data")} + out := pretty.Sprintf("%# v", h) + Expect(out).To(ContainSubstring(`DataFolder: "./data"`)) + Expect(out).ToNot(ContainSubstring("perm:")) + Expect(out).ToNot(ContainSubstring("path:")) + }) + + It("is safe to copy and use concurrently", func() { + // Regression for the Windows "sync: unlock of unlocked mutex" + // crash that was caused by copying a Dir embedding sync.Once. + // Dir is a plain value type now, but keep the concurrent stress + // test to lock in the property. + dir := GinkgoT().TempDir() + d := conf.NewDir(dir + "/race") + var wg sync.WaitGroup + for range 10 { + wg.Go(func() { + copy1 := d + _ = pretty.Sprintf("%# v", copy1) + _, _ = copy1.Path() + }) + } + wg.Wait() + }) + }) }) From 74a5c0c6d116c206fed3f2287af5ab46a3f62a60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 27 May 2026 23:29:17 -0300 Subject: [PATCH 15/32] fix(playlists): preserve unchanged fields on partial REST updates (#5542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(playlists): preserve unchanged fields on partial REST updates (#5541) The REST adapter for playlists was discarding the `cols` argument that rest.Put provides (the list of fields actually present in the JSON body). updatePlaylistEntity then compared the deserialized entity's zero-valued Name/Comment against the DB row, decided "content changed", and called updateMetadata with &entity.Name — overwriting the name with the empty string. This surfaced via the Playlists list view's bulk "Make Public" action, which sends N parallel `PUT /api/playlist/{id}` requests with body `{"public": true}`. Affected playlists ended up with their names wiped (UI showed "Loading..." indefinitely). The per-row Public toggle was unaffected because it spreads the full record into the payload. Honor the cols list: gate every field-change check and every pointer passed to updateMetadata by whether the field was actually in the request body. Empty cols falls back to the existing "treat as a full record" behavior so non-REST callers are unaffected. * test(playlists): cover rules-only PUT + case-variant owner-change guard Follow-ups from manual testing and code review of the prior commit: - Manual testing confirmed Feishin-style rules-only PUT works correctly on the fix; add ginkgo regression tests for rules-only update, name+ rules combined, idempotent rules PUT (no-op), and bulk Make-Public preserving rules on smart playlists. - Keep the non-admin owner-change permission check gated on the deserialized entity content (not on `sent("ownerId")`) so a case-variant JSON key like {"OwnerId":"x"} can't downgrade the 403 to a silent 200. Go's json decoder is case-insensitive on struct field matching but rest.Put's field-name extraction is case- sensitive; the entity-based guard catches both spellings. The apply-side gating on ownerChanged still prevents the actual mutation, so this was a behavioral (not security) regression, but worth fixing. Adds a regression test asserting the case-variant key still returns rest.ErrPermissionDenied. - Correct misleading doc on applyContentUpdate: the path does not rewrite the backing M3U file; it goes through updateMetadata which bumps updatedAt and invalidates cached cover-art URLs. * fix(playlists): match REST cols case-insensitively (PR #5542 review) Go's encoding/json populates struct fields from case-variant keys like {"Name":"x"} or {"OwnerId":"y"}, but rest.Put's getFieldNames extracts raw JSON keys verbatim. With case-sensitive matching, sentFields would ignore the field on the update side — a request with {"Name":"Renamed"} would parse into entity.Name but then sent("name") returns false and the rename silently no-ops. Normalize both sides to lowercase. The entity-based owner-permission guard added in the previous commit remains as belt-and-suspenders but is now redundant with this change. Also clarify the applyContentUpdate doc comment: namePtr/commentPtr are nil when the field is absent OR present-but-unchanged, while publicPtr only tracks presence (an idempotent public is still forwarded). * refactor(playlists): drop redundant entity-based owner-permission guard The case-insensitive sentFields predicate already prevents case-variant JSON keys like {"OwnerId":"x"} from bypassing the ownerChanged check, so the duplicated entity-content guard is no longer load-bearing. Strengthen the regression test into a DescribeTable covering canonical, PascalCase, all-upper, and all-lower spellings to lock in the case-insensitive contract. --- core/playlists/rest_adapter.go | 111 ++++++++++++++---- core/playlists/rest_adapter_test.go | 169 ++++++++++++++++++++++++++++ 2 files changed, 255 insertions(+), 25 deletions(-) diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index c9b7c4ea6..3f886aadd 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -4,11 +4,13 @@ import ( "context" "errors" "reflect" + "strings" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/slice" ) // --- REST adapter (follows Share/Library pattern) --- @@ -34,8 +36,8 @@ func (r *playlistRepositoryWrapper) Save(entity any) (string, error) { return r.service.savePlaylist(r.ctx, entity.(*model.Playlist)) } -func (r *playlistRepositoryWrapper) Update(id string, entity any, _ ...string) error { - return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist)) +func (r *playlistRepositoryWrapper) Update(id string, entity any, cols ...string) error { + return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...) } func (r *playlistRepositoryWrapper) Delete(id string) error { @@ -79,7 +81,15 @@ func (s *playlists) savePlaylist(ctx context.Context, pls *model.Playlist) (stri // updatePlaylistEntity updates playlist metadata with permission checks. // Used by the REST API wrapper. -func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist) error { +// +// cols names the fields the client actually sent in the JSON body (extracted by +// rest.Put). When non-empty, fields outside cols are not considered changed and +// are left untouched — this prevents partial requests like bulk "Make Public" +// (body: {"public": true}) from wiping fields that just happen to be zero in +// the deserialized entity (see issue #5541). An empty cols means "treat the +// entity as a complete record" — preserved for callers that don't use the REST +// wrapper. +func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist, cols ...string) error { current, err := s.checkWritable(ctx, id) if err != nil { switch { @@ -91,41 +101,92 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity return err } } + + sent := sentFields(cols) + usr, _ := request.UserFrom(ctx) - if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID { + ownerChanged := sent("ownerId") && entity.OwnerID != "" && entity.OwnerID != current.OwnerID + if !usr.IsAdmin && ownerChanged { return rest.ErrPermissionDenied } - contentChanged := entity.Name != current.Name || - entity.Comment != current.Comment || - (entity.OwnerID != "" && entity.OwnerID != current.OwnerID) || - !rulesEqual(current.Rules, entity.Rules) + nameChanged := sent("name") && entity.Name != current.Name + commentChanged := sent("comment") && entity.Comment != current.Comment + rulesChanged := sent("rules") && !rulesEqual(current.Rules, entity.Rules) - if contentChanged { - if entity.OwnerID != "" { - current.OwnerID = entity.OwnerID - } + if nameChanged || commentChanged || ownerChanged || rulesChanged { + return s.applyContentUpdate(ctx, current, entity, sent, + nameChanged, commentChanged, ownerChanged, rulesChanged) + } + return s.applyFlagsOnly(ctx, current, entity, sent) +} + +// applyContentUpdate handles updates that change at least one of name/comment/ +// owner/rules. It goes through updateMetadata, which always bumps updatedAt +// (invalidating cached cover-art URLs). namePtr/commentPtr are nil when the +// field is absent from the request OR present-but-unchanged (so updateMetadata +// skips them); publicPtr is nil only when public is absent from the request +// (an idempotent public value is still forwarded). +func (s *playlists) applyContentUpdate(ctx context.Context, current, entity *model.Playlist, + sent func(string) bool, nameChanged, commentChanged, ownerChanged, rulesChanged bool, +) error { + if ownerChanged { + current.OwnerID = entity.OwnerID + } + if rulesChanged { current.Rules = entity.Rules - if current.Path != "" && current.Sync != entity.Sync { - current.Sync = entity.Sync - } - return s.updateMetadata(ctx, s.ds, current, &entity.Name, &entity.Comment, &entity.Public) } - - // Only sync/public changed — skip updatedAt so cover art URLs stay stable - var cols []string - if current.Path != "" && current.Sync != entity.Sync { + if sent("sync") && current.Path != "" && current.Sync != entity.Sync { current.Sync = entity.Sync - cols = append(cols, "sync") } - if current.Public != entity.Public { + var namePtr, commentPtr *string + var publicPtr *bool + if nameChanged { + namePtr = &entity.Name + } + if commentChanged { + commentPtr = &entity.Comment + } + if sent("public") { + publicPtr = &entity.Public + } + return s.updateMetadata(ctx, s.ds, current, namePtr, commentPtr, publicPtr) +} + +// applyFlagsOnly handles updates that only toggle sync/public — skips +// updatedAt so cover art URLs stay stable. +func (s *playlists) applyFlagsOnly(ctx context.Context, current, entity *model.Playlist, + sent func(string) bool, +) error { + var updateCols []string + if sent("sync") && current.Path != "" && current.Sync != entity.Sync { + current.Sync = entity.Sync + updateCols = append(updateCols, "sync") + } + if sent("public") && current.Public != entity.Public { current.Public = entity.Public - cols = append(cols, "public") + updateCols = append(updateCols, "public") } - if len(cols) == 0 { + if len(updateCols) == 0 { return nil } - return s.ds.Playlist(ctx).Put(current, cols...) + return s.ds.Playlist(ctx).Put(current, updateCols...) +} + +// sentFields returns a predicate that reports whether a JSON field was present +// in the request body. Matching is case-insensitive to mirror Go's json +// decoder, which populates struct fields from case-variant keys like +// {"Name":"x"} or {"OWNERID":"y"}. An empty cols list means "treat the entity +// as a full record" — every field is considered sent. +func sentFields(cols []string) func(string) bool { + if len(cols) == 0 { + return func(string) bool { return true } + } + set := slice.ToMap(cols, func(c string) (string, struct{}) { return strings.ToLower(c), struct{}{} }) + return func(field string) bool { + _, ok := set[strings.ToLower(field)] + return ok + } } func rulesEqual(a, b *criteria.Criteria) bool { diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 68461b259..79d72d147 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -125,6 +125,25 @@ var _ = Describe("REST Adapter", func() { Expect(err).To(Equal(rest.ErrPermissionDenied)) }) + DescribeTable("denies regular user from changing ownership under any case-variant JSON key", + func(colName string) { + // rest.Put's field-name extraction is case-sensitive, but Go's + // json decoder is case-insensitive on struct fields, so any + // {"OwnerId":"x"} / {"OWNERID":"x"} / {"ownerid":"x"} populates + // entity.OwnerID. sentFields normalizes both sides so the + // permission gate fires regardless of casing. + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{OwnerID: "other-user"} + err := repo.Update("pls-1", pls, colName) + Expect(err).To(Equal(rest.ErrPermissionDenied)) + }, + Entry("canonical camelCase", "ownerId"), + Entry("PascalCase", "OwnerId"), + Entry("all upper", "OWNERID"), + Entry("all lower", "ownerid"), + ) + It("updates smart playlist rules", func() { mockPlsRepo.Data["smart-1"] = &model.Playlist{ ID: "smart-1", @@ -218,6 +237,156 @@ var _ = Describe("REST Adapter", func() { err := repo.Update("nonexistent", pls) Expect(err).To(Equal(rest.ErrNotFound)) }) + + // Regression tests for #5541: partial REST updates (e.g. bulk "Make Public") + // must only touch the fields the client actually sent. The cols list from + // rest.Put names those fields; fields outside it must be left alone, even + // when the deserialized entity has zero values for them. + Context("with partial updates (cols)", func() { + BeforeEach(func() { + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + mockPlsRepo.Data["partial"] = &model.Playlist{ + ID: "partial", + Name: "Original Name", + Comment: "Original comment", + OwnerID: "user-1", + Public: false, + } + }) + + It("preserves name and comment when only public is sent (bulk Make Public)", func() { + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Public: true}, "public") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Original Name")) + Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + }) + + It("preserves name when only sync is sent for a file-backed playlist", func() { + mockPlsRepo.Data["file-partial"] = &model.Playlist{ + ID: "file-partial", + Name: "Keep Me", + OwnerID: "user-1", + Path: "/music/p.m3u", + Sync: true, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("file-partial", &model.Playlist{Sync: false}, "sync") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Keep Me")) + Expect(mockPlsRepo.Last.Sync).To(BeFalse()) + }) + + It("renames the playlist when only name is sent", func() { + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "name") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Renamed")) + Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) + Expect(mockPlsRepo.Last.Public).To(BeFalse()) + }) + + It("clears the comment when an empty comment is sent explicitly", func() { + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Comment: ""}, "comment") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Comment).To(BeEmpty()) + Expect(mockPlsRepo.Last.Name).To(Equal("Original Name")) + }) + + It("updates rules-only on a smart playlist (Feishin-style edit)", func() { + mockPlsRepo.Data["smart-partial"] = &model.Playlist{ + ID: "smart-partial", + Name: "Smart Original", + Comment: "smart comment", + OwnerID: "user-1", + Public: true, + Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + newRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Jazz"}, Sort: "year DESC"} + err := repo.Update("smart-partial", &model.Playlist{Rules: newRules}, "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) + Expect(mockPlsRepo.Last.Name).To(Equal("Smart Original")) + Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment")) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + }) + + It("updates name and rules together (smart-playlist Edit form)", func() { + mockPlsRepo.Data["smart-edit"] = &model.Playlist{ + ID: "smart-edit", + Name: "Smart Original", + Comment: "smart comment", + OwnerID: "user-1", + Rules: &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}}, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + newRules := &criteria.Criteria{Expression: criteria.Is{"artist": "Miles Davis"}, Sort: "album"} + err := repo.Update("smart-edit", + &model.Playlist{Name: "Smart Renamed", Rules: newRules}, + "name", "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Smart Renamed")) + Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) + Expect(mockPlsRepo.Last.Comment).To(Equal("smart comment")) + }) + + It("does not bump the saved rules on an idempotent rules-only PUT", func() { + rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} + mockPlsRepo.Data["smart-idempotent"] = &model.Playlist{ + ID: "smart-idempotent", + Name: "Smart Idempotent", + OwnerID: "user-1", + Rules: rules, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + // Same rules sent back — rulesEqual should report no change and + // the request should no-op (no Put call). + sameRules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} + err := repo.Update("smart-idempotent", &model.Playlist{Rules: sameRules}, "rules") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).To(BeNil()) // no Put happened + }) + + It("preserves rules when only public is sent (smart playlist + bulk Make Public)", func() { + rules := &criteria.Criteria{Expression: criteria.Is{"genre": "Rock"}} + mockPlsRepo.Data["smart-public"] = &model.Playlist{ + ID: "smart-public", + Name: "Smart Public", + OwnerID: "user-1", + Public: false, + Rules: rules, + } + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("smart-public", &model.Playlist{Public: true}, "public") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + Expect(mockPlsRepo.Last.Rules).To(Equal(rules)) + Expect(mockPlsRepo.Last.Name).To(Equal("Smart Public")) + }) + + It("does not treat a missing ownerId as an ownership transfer attempt", func() { + // A non-admin user sending only {public:true} should not be blocked + // just because OwnerID is the zero value in the deserialized entity. + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Public: true}, "public") + Expect(err).ToNot(HaveOccurred()) + }) + + It("matches cols case-insensitively (mirrors json decoder behavior)", func() { + // Go's json decoder populates struct fields from case-variant keys + // like {"Name":"x"}, but rest.Put's field-name extraction is + // case-sensitive. sentFields normalizes both sides so a request + // with {"Name":"Renamed"} is honored, not silently ignored. + repo = ps.NewRepository(ctx).(rest.Persistable) + err := repo.Update("partial", &model.Playlist{Name: "Renamed"}, "Name") + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("Renamed")) + Expect(mockPlsRepo.Last.Comment).To(Equal("Original comment")) + }) + }) }) Describe("Delete", func() { From 833c50adc7d45dcc9f0f6dfb700e02be9a3706a1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 28 May 2026 00:07:49 -0300 Subject: [PATCH 16/32] test(stream): fix data race in MediaStreamer transcoding cap tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three It blocks that build a tight-cap streamer each spawned a fresh transcoding cache without waiting for its background initialization. The init goroutine reads conf.Server.CacheFolder, which races against SnapshotConfig's pointer-swap restore (Server = &restored) fired by DeferCleanup at the end of the spec. CI tripped the race under -shuffle=on -race; locally it reproduced about 10% of the time. Wait for tightCache.Available() before constructing the streamer, mirroring the outer BeforeEach. For the slot-saturation spec, swap in a blocking io.Pipe-backed mock ffmpeg so the cache's background copyAndClose can't drain the source and release the slot — the previous behavior happened to work only because the cache wasn't yet available and the no-cache path was exercised. --- core/stream/media_streamer_test.go | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go index 676e8d6f8..f5ca16d3f 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -64,11 +64,19 @@ var _ = Describe("MediaStreamer", func() { Expect(s.Duration()).To(Equal(float32(257.0))) }) It("rejects transcode requests beyond MaxConcurrent with ErrTooManyTranscodes", func() { - // Rebuild the streamer with a tight cap. The first request will hold the - // ffmpeg reader open (we don't read/close it), saturating the single slot. + // Use an ffmpeg whose Read blocks indefinitely so the cache's + // background copy can't drain the source and release the slot — + // keeping the single transcode slot pinned for this test. + pr, pw := io.Pipe() + DeferCleanup(func() { _ = pw.Close() }) + blockingFFmpeg := tests.NewMockFFmpeg("") + blockingFFmpeg.Reader = pr + conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 - tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + tightCache := stream.NewTranscodingCache() + Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + tightStreamer := stream.NewMediaStreamer(ds, blockingFFmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) @@ -83,7 +91,9 @@ var _ = Describe("MediaStreamer", func() { It("releases the slot once the stream is closed", func() { conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 - tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + tightCache := stream.NewTranscodingCache() + Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") s1, err := tightStreamer.NewStream(userCtx, mf, stream.Request{Format: "mp3", BitRate: 64}) @@ -101,7 +111,9 @@ var _ = Describe("MediaStreamer", func() { It("does not consume a slot for raw streams", func() { conf.Server.Transcoding.MaxConcurrent = 1 conf.Server.Transcoding.MaxConcurrentPerUser = 0 - tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, stream.NewTranscodingCache()) + tightCache := stream.NewTranscodingCache() + Eventually(func() bool { return tightCache.Available(context.TODO()) }).Should(BeTrue()) + tightStreamer := stream.NewMediaStreamer(ds, ffmpeg, tightCache) userCtx := request.WithUsername(ctx, "alice") // First, saturate the single transcode slot. From 59b6755014be0ec7e722ec94819723b28875e403 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 28 May 2026 19:25:26 -0300 Subject: [PATCH 17/32] chore(deps): update dependencies to latest versions in go.mod and go.sum Signed-off-by: Deluan --- go.mod | 18 +++++++++--------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 937cffbd5..29a415126 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/extism/go-sdk v1.7.1 github.com/fatih/structs v1.1.0 github.com/gen2brain/webp v0.5.5 - github.com/go-chi/chi/v5 v5.2.5 + github.com/go-chi/chi/v5 v5.3.0 github.com/go-chi/cors v1.2.2 github.com/go-chi/httprate v0.15.0 github.com/go-chi/jwtauth/v5 v5.4.0 @@ -39,8 +39,8 @@ require ( github.com/mattn/go-sqlite3 v1.14.44 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.28.3 - github.com/onsi/gomega v1.40.0 + github.com/onsi/ginkgo/v2 v2.29.0 + github.com/onsi/gomega v1.41.0 github.com/pelletier/go-toml/v2 v2.3.1 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 @@ -59,10 +59,10 @@ 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.40.0 - golang.org/x/net v0.54.0 + golang.org/x/image v0.41.0 + golang.org/x/net v0.55.0 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.44.0 + golang.org/x/sys v0.45.0 golang.org/x/term v0.43.0 golang.org/x/text v0.37.0 golang.org/x/time v0.15.0 @@ -81,7 +81,7 @@ 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.10.0 // indirect + github.com/ebitengine/purego v0.10.1 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect @@ -115,7 +115,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rogpeppe/go-internal v1.15.0 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sanity-io/litter v1.5.8 // indirect github.com/segmentio/asm v1.2.1 // indirect @@ -133,7 +133,7 @@ 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.51.0 // indirect + golang.org/x/crypto v0.52.0 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect golang.org/x/tools v0.45.0 // indirect diff --git a/go.sum b/go.sum index 0c550e47d..57289abfd 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,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.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= -github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY= +github.com/ebitengine/purego v0.10.1/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= @@ -73,8 +73,8 @@ github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZ github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= -github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= -github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-chi/chi/v5 v5.3.0 h1:halUjDxhshgXHMrao5bB8eNBXo/rnzwr8m5m36glehM= +github.com/go-chi/chi/v5 v5.3.0/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5g= @@ -193,10 +193,10 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ogier/pflag v0.0.1 h1:RW6JSWSu/RkSatfcLtogGfFgpim5p7ARQ10ECk5O750= github.com/ogier/pflag v0.0.1/go.mod h1:zkFki7tvTa0tafRvTBIZTvzYyAu6kQhPZFnshFFPE+g= -github.com/onsi/ginkgo/v2 v2.28.3 h1:4JvMdwtFU0imd8fHx25OJXoDMRexnf8v5NHKYSTTji4= -github.com/onsi/ginkgo/v2 v2.28.3/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= -github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= -github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= +github.com/onsi/ginkgo/v2 v2.29.0 h1:rfh+ZFjgJhYWRoIqVf3Uwx/W20yLrcrE2h2GmYVRaag= +github.com/onsi/ginkgo/v2 v2.29.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.41.0 h1:OwKp4pXNgVxf6sCplzYo794OFNuoL2q2SBMU5NSWOjA= +github.com/onsi/gomega v1.41.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= @@ -224,8 +224,8 @@ github.com/rjeczalik/notify v0.9.3/go.mod h1:gF3zSOrafR9DQEWSE8TjfI9NkooDxbyT4Ug github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= -github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc= +github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= @@ -316,10 +316,10 @@ 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.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= -golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= -golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= 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= @@ -338,8 +338,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.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= 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= @@ -364,8 +364,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.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-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE= golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE= From 2a43c4683ea41492a6ad8c6b22af9a1f7eacb1e9 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 28 May 2026 22:09:54 -0300 Subject: [PATCH 18/32] chore: go fix Signed-off-by: Deluan --- cmd/inspect.go | 6 +++--- core/artwork/benchmark_e2e_test.go | 2 +- core/artwork/benchmark_helpers_test.go | 4 ++-- core/ffmpeg/ffmpeg.go | 4 ++-- core/share.go | 2 +- log/journal.go | 2 +- model/tag_mappings.go | 4 ++-- persistence/sql_search_fts.go | 12 ++++++------ persistence/sql_search_like.go | 2 +- plugins/host_taskqueue.go | 5 ++--- plugins/host_taskqueue_test.go | 14 +++++++------- scheduler/crontab_schedule_test.go | 2 +- server/subsonic/api_test.go | 2 +- server/throttle_backlog.go | 5 ++--- utils/cache/benchmark_test.go | 4 ++-- 15 files changed, 34 insertions(+), 36 deletions(-) diff --git a/cmd/inspect.go b/cmd/inspect.go index 9f9270b1e..5e88793cc 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -32,17 +32,17 @@ var inspectCmd = &cobra.Command{ }, } -var marshalers = map[string]func(interface{}) ([]byte, error){ +var marshalers = map[string]func(any) ([]byte, error){ "pretty": prettyMarshal, "toml": toml.Marshal, "yaml": yaml.Marshal, "json": json.Marshal, - "jsonindent": func(v interface{}) ([]byte, error) { + "jsonindent": func(v any) ([]byte, error) { return json.MarshalIndent(v, "", " ") }, } -func prettyMarshal(v interface{}) ([]byte, error) { +func prettyMarshal(v any) ([]byte, error) { out := v.([]core.InspectOutput) var res strings.Builder for i := range out { diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go index 393cbb473..bf3d435a8 100644 --- a/core/artwork/benchmark_e2e_test.go +++ b/core/artwork/benchmark_e2e_test.go @@ -169,7 +169,7 @@ func BenchmarkArtworkGetE2EConcurrent(b *testing.B) { for i := 0; i < b.N; i++ { var wg sync.WaitGroup wg.Add(n) - for g := 0; g < n; g++ { + for range n { go func() { defer wg.Done() r, _, err := aw.Get(context.Background(), artID, 300, true) diff --git a/core/artwork/benchmark_helpers_test.go b/core/artwork/benchmark_helpers_test.go index 60990bb8b..0076506f3 100644 --- a/core/artwork/benchmark_helpers_test.go +++ b/core/artwork/benchmark_helpers_test.go @@ -35,8 +35,8 @@ func generatePNG(t testing.TB, width, height int) []byte { // generateGradientImage creates an RGBA image with a diagonal gradient pattern. func generateGradientImage(width, height int) *image.RGBA { img := image.NewRGBA(image.Rect(0, 0, width, height)) - for y := 0; y < height; y++ { - for x := 0; x < width; x++ { + for y := range height { + for x := range width { r := uint8((x * 255) / width) g := uint8((y * 255) / height) b := uint8(((x + y) * 255) / (width + height)) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index a3f6cd7d2..58e9fd152 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -496,8 +496,8 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string { // Pre-input seeking: ffmpeg seeks at the demuxer level (fast) // instead of decoding all frames up to the offset (slow). insertAt := len(args) - for i := len(args) - 1; i >= 0; i-- { - if args[i] == "-i" { + for i, arg := range slices.Backward(args) { + if arg == "-i" { insertAt = i break } diff --git a/core/share.go b/core/share.go index eb9b63ae9..5a611c7f0 100644 --- a/core/share.go +++ b/core/share.go @@ -98,7 +98,7 @@ func (r *shareRepositoryWrapper) Save(entity any) (string, error) { s.ExpiresAt = new(time.Now().Add(conf.Server.DefaultShareExpiration)) } - firstId := strings.SplitN(s.ResourceIDs, ",", 2)[0] + firstId, _, _ := strings.Cut(s.ResourceIDs, ",") v, err := model.GetEntityByID(r.ctx, r.ds, firstId) if err != nil { return "", err diff --git a/log/journal.go b/log/journal.go index f1c17d2e7..dd7cf5400 100644 --- a/log/journal.go +++ b/log/journal.go @@ -36,6 +36,6 @@ func (f *journalFormatter) Format(entry *logrus.Entry) ([]byte, error) { if !ok { priority = 6 // default to info for unknown levels } - prefix := []byte(fmt.Sprintf("<%d>", priority)) + prefix := fmt.Appendf(nil, "<%d>", priority) return append(prefix, formatted...), nil } diff --git a/model/tag_mappings.go b/model/tag_mappings.go index af76de741..dd19a157b 100644 --- a/model/tag_mappings.go +++ b/model/tag_mappings.go @@ -47,8 +47,8 @@ func (c TagConf) SplitTagValue(values []string) []string { tag = c.SplitRx.ReplaceAllString(tag, consts.Zwsp) // Split by the zero-width space and trim each substring. - parts := strings.Split(tag, consts.Zwsp) - for _, part := range parts { + parts := strings.SplitSeq(tag, consts.Zwsp) + for part := range parts { result = append(result, strings.TrimSpace(part)) } } diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index e9b961d91..b90dc937b 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -66,7 +66,7 @@ func normalizeForFTS(values ...string) string { result = append(result, variant) } for _, v := range values { - for _, word := range strings.Fields(v) { + for word := range strings.FieldsSeq(v) { transliterated := sanitize.Accents(word) // Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne. add(word, fts5PunctStrip.ReplaceAllString(transliterated, "")) @@ -279,9 +279,9 @@ type ftsSearch struct { } // ToSql returns a single-query fallback for the REST filter path (no two-phase split). -func (s *ftsSearch) ToSql() (string, []interface{}, error) { +func (s *ftsSearch) ToSql() (string, []any, error) { sql := s.tableName + ".rowid IN (SELECT rowid FROM " + s.ftsTable + " WHERE " + s.ftsTable + " MATCH ?)" - return sql, []interface{}{s.matchExpr}, nil + return sql, []any{s.matchExpr}, nil } // execute runs a two-phase FTS5 search: @@ -373,8 +373,8 @@ func ftsQueryDegraded(original, ftsQuery string) bool { // Check if all effective FTS tokens are very short (≤2 chars). // Short tokens with prefix matching are too broad when special chars were stripped. // For quoted phrases, extract the content and check the tokens inside. - tokens := strings.Fields(ftsQuery) - for _, t := range tokens { + tokens := strings.FieldsSeq(ftsQuery) + for t := range tokens { t = strings.TrimSuffix(t, "*") // Skip internal phrase placeholders if strings.HasPrefix(t, "\x00") { @@ -390,7 +390,7 @@ func ftsQueryDegraded(original, ftsQuery string) bool { // Extract content between quotes inner := strings.Trim(t, `"`) innerAlpha := fts5PunctStrip.ReplaceAllString(inner, " ") - for _, it := range strings.Fields(innerAlpha) { + for it := range strings.FieldsSeq(innerAlpha) { if len(it) > 2 { return false } diff --git a/persistence/sql_search_like.go b/persistence/sql_search_like.go index 769a911d5..972545ac5 100644 --- a/persistence/sql_search_like.go +++ b/persistence/sql_search_like.go @@ -16,7 +16,7 @@ type likeSearch struct { filter Sqlizer } -func (s *likeSearch) ToSql() (string, []interface{}, error) { +func (s *likeSearch) ToSql() (string, []any, error) { return s.filter.ToSql() } diff --git a/plugins/host_taskqueue.go b/plugins/host_taskqueue.go index eff73c822..a5db3344f 100644 --- a/plugins/host_taskqueue.go +++ b/plugins/host_taskqueue.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "maps" "os" "path/filepath" "sync" @@ -540,9 +541,7 @@ func (s *taskQueueServiceImpl) cleanupLoop() { func (s *taskQueueServiceImpl) runCleanup() { s.mu.Lock() queues := make(map[string]*queueState, len(s.queues)) - for k, v := range s.queues { - queues[k] = v - } + maps.Copy(queues, s.queues) s.mu.Unlock() now := time.Now().UnixMilli() diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go index 8a58f1eb4..faff79c8e 100644 --- a/plugins/host_taskqueue_test.go +++ b/plugins/host_taskqueue_test.go @@ -367,8 +367,8 @@ var _ = Describe("TaskQueueService", func() { // Enqueue several more tasks — they stay pending since the worker is busy var pendingIDs []string - for i := 0; i < 3; i++ { - taskID, err := service.Enqueue(ctx, "clear-test", []byte(fmt.Sprintf("task-%d", i))) + for i := range 3 { + taskID, err := service.Enqueue(ctx, "clear-test", fmt.Appendf(nil, "task-%d", i)) Expect(err).ToNot(HaveOccurred()) pendingIDs = append(pendingIDs, taskID) } @@ -674,8 +674,8 @@ var _ = Describe("TaskQueueService", func() { Expect(err).ToNot(HaveOccurred()) // Enqueue 5 tasks - for i := 0; i < 5; i++ { - _, err := service.Enqueue(ctx, "delay-concurrent", []byte(fmt.Sprintf("task-%d", i))) + for i := range 5 { + _, err := service.Enqueue(ctx, "delay-concurrent", fmt.Appendf(nil, "task-%d", i)) Expect(err).ToNot(HaveOccurred()) } @@ -1112,7 +1112,7 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() { // the second will be dequeued but block on the rate limiter (status=running), // the rest will stay pending. var taskIDs []string - for i := 0; i < 5; i++ { + for range 5 { output, err := callTestTaskQueue(ctx, testTaskQueueInput{ Operation: "enqueue", QueueName: "test-cancel", @@ -1186,11 +1186,11 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() { Expect(err).ToNot(HaveOccurred()) // Enqueue several tasks - for i := 0; i < 4; i++ { + for i := range 4 { _, err := callTestTaskQueue(ctx, testTaskQueueInput{ Operation: "enqueue", QueueName: "test-clear", - Payload: []byte(fmt.Sprintf("task-%d", i)), + Payload: fmt.Appendf(nil, "task-%d", i), }) Expect(err).ToNot(HaveOccurred()) } diff --git a/scheduler/crontab_schedule_test.go b/scheduler/crontab_schedule_test.go index b1e26f1de..b616f0884 100644 --- a/scheduler/crontab_schedule_test.go +++ b/scheduler/crontab_schedule_test.go @@ -185,7 +185,7 @@ var _ = Describe("ParseCrontab", func() { // findSetBit returns the lowest bit position set in v, ignoring the starBit (bit 63). func findSetBit(v uint64) int { v &^= 1 << 63 // clear starBit - for i := 0; i < 63; i++ { + for i := range 63 { if v&(1< 0 { w.WriteHeader(buf.code) } diff --git a/utils/cache/benchmark_test.go b/utils/cache/benchmark_test.go index e3fc08eda..9ab07cf18 100644 --- a/utils/cache/benchmark_test.go +++ b/utils/cache/benchmark_test.go @@ -116,7 +116,7 @@ func BenchmarkConcurrentCacheRead(b *testing.B) { for i := 0; i < b.N; i++ { var wg sync.WaitGroup wg.Add(n) - for g := 0; g < n; g++ { + for range n { go func() { defer wg.Done() s, err := fc.Get(context.Background(), item) @@ -152,7 +152,7 @@ func BenchmarkConcurrentCacheMiss(b *testing.B) { wg.Add(n) // All goroutines request the SAME key (not yet cached) item := &benchItem{key: fmt.Sprintf("miss-%d", i)} - for g := 0; g < n; g++ { + for range n { go func() { defer wg.Done() s, err := fc.Get(context.Background(), item) From dad4203f9a93225a17d5474318372ae0e4a9069f Mon Sep 17 00:00:00 2001 From: Tales Costa Date: Tue, 2 Jun 2026 09:38:57 -0300 Subject: [PATCH 19/32] fix(ui): Gruvbox Dark colors (#5553) * Add Gruvbox Dark theme Add Gruvbox Dark color theme including: - gruvboxDark.js with full palette and component overrides - gruvboxDark.css.js with custom player styles * Fix: move error state to MuiFormHelperText --- ui/src/themes/gruvboxDark.css.js | 9 ++++- ui/src/themes/gruvboxDark.js | 65 ++++++++++++++++++++++++-------- 2 files changed, 58 insertions(+), 16 deletions(-) diff --git a/ui/src/themes/gruvboxDark.css.js b/ui/src/themes/gruvboxDark.css.js index dc1f64041..f482451b2 100644 --- a/ui/src/themes/gruvboxDark.css.js +++ b/ui/src/themes/gruvboxDark.css.js @@ -5,7 +5,7 @@ const stylesheet = ` } .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { - background-color: #458588 + background-color: #ebdbb2 } .react-jinke-music-player-main ::-webkit-scrollbar-thumb { @@ -50,6 +50,13 @@ const stylesheet = ` .MuiCheckbox-colorSecondary.Mui-checked { color: #458588 !important } +.react-jinke-music-player-main .music-player-panel svg { + color: #ebdbb2; + fill: #ebdbb2; +} +.react-jinke-music-player-main .music-player-panel button { + color: #ebdbb2; +} ` export default stylesheet diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js index 20f5c732f..0f4cbd7c4 100644 --- a/ui/src/themes/gruvboxDark.js +++ b/ui/src/themes/gruvboxDark.js @@ -14,22 +14,34 @@ export default { background: { default: '#282828', }, + text: { + primary: '#ebdbb2', + secondary: '#a89984', + }, }, overrides: { MuiPaper: { root: { color: '#ebdbb2', backgroundColor: '#3c3836', - MuiSnackbarContent: { - root: { - color: '#ebdbb2', - backgroundColor: '#cc241d', - }, - message: { - color: '#ebdbb2', - backgroundColor: '#cc241d', - }, - }, + }, + }, + MuiSnackbarContent: { + root: { + color: '#3c3836', + backgroundColor: '#a89984', + }, + message: { + color: '#3c3836', + backgroundColor: '#a89984', + }, + }, + MuiTypography: { + root: { + color: '#ebdbb2', + }, + colorTextSecondary: { + color: '#a89984', }, }, MuiButton: { @@ -45,6 +57,19 @@ export default { color: '#ebdbb2', }, }, + MuiListItemIcon: { + root: { + color: '#ebdbb2', + }, + }, + MuiListItemText: { + primary: { + color: '#ebdbb2', + }, + secondary: { + color: '#a89984', + }, + }, MuiChip: { clickable: { background: '#49483e', @@ -57,11 +82,10 @@ export default { }, MuiFormHelperText: { root: { - Mui: { - error: { - color: '#cc241d', - }, - }, + color: '#ebdbb2', + }, + error: { + color: '#cc241d', }, }, MuiTableHead: { @@ -113,6 +137,17 @@ export default { 'linear-gradient(to bottom, rgba(52 52 52 / 72%), rgb(48 48 48))!important', }, }, + NDAlbumGridView: { + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + textTransform: 'none', + color: '#ebdbb2', + }, + albumSubtitle: { + color: '#a89984', + }, + }, }, player: { theme: 'dark', From bc107d1ceed30f78abc1c5fb3d1a601a4f099e30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 3 Jun 2026 20:03:08 -0400 Subject: [PATCH 20/32] fix(scrobbler): proxy NowPlaying even when ignoreScrobble is set (#5559) * fix(scrobbler): proxy NowPlaying even when ignoreScrobble is set When a client reports playback with ignoreScrobble=true, the reportPlayback handler suppressed both the scrobble submission and the NowPlaying update sent to external agents (Last.fm, ListenBrainz, plugins). These are independent concerns: ignoring the scrobble submission should not stop Navidrome from telling external services what is currently playing. The !params.IgnoreScrobble guard now applies only to the scrobble submission and play-count path; the NowPlaying dispatch is gated solely by the player's ScrobbleEnabled flag. This mirrors the legacy scrobble endpoint, where submission=false has always still set NowPlaying. * test(scrobbler): assert no scrobble dispatch when ignoreScrobble=true Address PR review feedback: explicitly verify that ignoreScrobble=true suppresses the scrobble submission (not just the play count) while NowPlaying is still dispatched, so the flag cannot regress into ignoring nothing. Also expand the NowPlaying gating comment to spell out the IgnoreScrobble vs ScrobbleEnabled rules and identify the external agents involved. --- core/scrobbler/play_tracker.go | 9 ++++++++- core/scrobbler/play_tracker_test.go | 6 ++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index bdb261ef2..860a80bce 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -371,7 +371,14 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } - if !params.IgnoreScrobble && player.ScrobbleEnabled && + // NowPlaying gating, by design distinct from scrobble submission: + // - IgnoreScrobble=true -> still send NowPlaying (suppresses only the + // scrobble submission/play-count above), mirroring the legacy scrobble + // endpoint's submission=false behavior. + // - player.ScrobbleEnabled=false -> never send NowPlaying. + // External agents here are the active scrobblers (Last.fm, ListenBrainz, and + // scrobbler plugins) returned by getActiveScrobblers; see dispatchNowPlaying. + if player.ScrobbleEnabled && (params.State == StateStarting || params.State == StatePlaying) { if info, err := p.playMap.Get(clientId); err == nil { p.enqueueNowPlaying(ctx, clientId, user.ID, &info.MediaFile, int(params.PositionMs/1000)) diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 5383244cd..b5a478c2a 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -521,6 +521,7 @@ var _ = Describe("PlayTracker", func() { }) It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() { + fake.ScrobbleCalled.Store(false) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, }) @@ -531,6 +532,7 @@ var _ = Describe("PlayTracker", func() { }) Expect(err).ToNot(HaveOccurred()) Expect(track.PlayCount).To(Equal(int64(0))) + Consistently(func() bool { return fake.ScrobbleCalled.Load() }).Should(BeFalse()) }) It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() { @@ -715,14 +717,14 @@ var _ = Describe("PlayTracker", func() { Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) }) - It("does NOT dispatch when ignoreScrobble=true", func() { + It("still dispatches NowPlaying when ignoreScrobble=true", func() { fake.nowPlayingCalled.Store(false) err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, IgnoreScrobble: true, }) Expect(err).ToNot(HaveOccurred()) - Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) }) It("does NOT dispatch when ScrobbleEnabled=false", func() { From 37908d3cead9d982a1075b5f3fdd3314d32a87e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 4 Jun 2026 19:43:13 -0400 Subject: [PATCH 21/32] fix: enforce ownership atomically on player and share updates (#5563) * fix(player): enforce ownership atomically on player update The native API PUT /api/player/{id} authorized writes using the userId in the request body via isPermitted, while the actual write targeted the row by the URL id. A non-admin user could set userId to their own id in the body to pass the check, then overwrite and reassign ownership of another user's player row identified by the URL id (cross-tenant takeover). Add updateOwned on the base repository: an atomic, ownership-restricted UPDATE that folds the owner predicate (user_id = caller) into the WHERE clause for non-admins, so a row owned by another user simply does not match and no write happens. It also never writes user_id, so ownership is immutable on update and no caller (admin included) can reassign a player to a different owner. Unlike put, it never falls through to an INSERT, so a non-matching id returns ErrNotFound instead of creating a row. playerRepository.Update now uses updateOwned. Extract filterUpdateValues, shared by put and updateOwned, so the update-column filtering lives in one place. The create path (Save) keeps the body-based isPermitted check, which is correct for new records. Add regression tests covering the spoofed-userId hijack, regular-user and admin ownership reassignment, legitimate owner updates, and the nonexistent-player case. * fix(share): enforce ownership atomically on share update shareRepository.Update authorized writes with a separate checkOwnership SELECT, then wrote the row via put(). The check and the write were two statements (a TOCTOU window), put() could fall through to an INSERT on a missing id, and put() would write user_id if present in the update columns, so ownership was mutable on update. Switch Update to updateOwned, which folds the owner predicate into the UPDATE's WHERE clause, never writes user_id, and never inserts. This makes the write atomic and ownership immutable, and drops the extra ownership SELECT on the happy path. To preserve the previous 403/404 distinction, updateOwned now classifies a non-matching id: it runs a follow-up existence check only on the failure path (count == 0, where no write happened, so no TOCTOU) and returns ErrPermissionDenied when the row exists but is owned by another user, ErrNotFound when the id is missing. The player path inherits this: its tests now expect ErrPermissionDenied for a non-owner targeting an existing row, and ErrNotFound only for a genuinely missing id. Add share regression tests for the nonexistent-id and ownership- reassignment cases. checkOwnership remains in use by Delete. * refactor(persistence): extract canonical ownerFilter predicate The non-admin owner-restriction predicate (user_id = me, exempting admins and headless contexts) was spelled out independently in updateOwned and in playerRepository.addRestriction. The two copies had drifted: addRestriction did not exempt the headless/invalid user, so a headless context restricted to user_id = "-1" (matching nothing) while updateOwned exempted it. Extract sqlRepository.ownerFilter as the single definition and route both call sites through it. addRestriction now exempts the headless user too; that path is only reachable from the authenticated native API, so there is no production behavior change, but the latent divergence is removed. playlistRepository.userFilter is intentionally left alone: it encodes a different policy (public OR owner_id = me, on the owner_id column). * fix(share): preserve all-columns update path in Update shareRepository.Update unconditionally appended "updated_at" to cols. filterUpdateValues treats an empty cols as "update every column", so when a caller passes no columns, appending "updated_at" turned an all-columns update into an updated_at-only one, silently dropping every other field. The REST controller always populates cols from the request-body field names, so this path is not reachable through the native API and the behavior was latent (and pre-existing). Guard the append so the all-columns path is preserved, and add a regression test that updates with no columns and asserts the other fields persist. Signed-off-by: Deluan --------- Signed-off-by: Deluan --- persistence/player_repository.go | 20 +++--- persistence/player_repository_test.go | 87 +++++++++++++++++++++++- persistence/share_repository.go | 15 ++--- persistence/share_repository_test.go | 38 +++++++++++ persistence/sql_base_repository.go | 95 ++++++++++++++++++++++----- 5 files changed, 216 insertions(+), 39 deletions(-) diff --git a/persistence/player_repository.go b/persistence/player_repository.go index 6c8339378..c9c7d3b4b 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -67,11 +67,10 @@ func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer { if len(sql) > 0 { s = append(s, sql[0]) } - u := loggedUser(r.ctx) - if u.IsAdmin { - return s + if owner := r.ownerFilter(); owner != nil { + s = append(s, owner) } - return append(s, Eq{"user_id": u.ID}) + return s } func (r *playerRepository) CountByClient(options ...model.QueryOptions) (map[string]int64, error) { @@ -125,6 +124,10 @@ func (r *playerRepository) NewInstance() any { return &model.Player{} } +// isPermitted authorizes creating a new record, based on the owner declared in the request body. +// This is only safe for inserts: there is no stored row yet, and a non-admin may only create a +// player they own. Updates must not use this (the body owner is attacker-controlled); they go +// through updateOwned, which authorizes against the persisted user_id in the WHERE clause. func (r *playerRepository) isPermitted(p *model.Player) bool { u := loggedUser(r.ctx) return u.IsAdmin || p.UserId == u.ID @@ -145,14 +148,7 @@ func (r *playerRepository) Save(entity any) (string, error) { func (r *playerRepository) Update(id string, entity any, cols ...string) error { t := entity.(*model.Player) t.ID = id - if !r.isPermitted(t) { - return rest.ErrPermissionDenied - } - _, err := r.put(id, t, cols...) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.updateOwned(id, t, cols...) } func (r *playerRepository) Delete(id string) error { diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go index f6c669493..f640cd9a4 100644 --- a/persistence/player_repository_test.go +++ b/persistence/player_repository_test.go @@ -215,9 +215,12 @@ var _ = Describe("PlayerRepository", func() { clone.MaxBitRate = 10000 err := repo.Update(clone.ID, &clone, "ip") - if clone.UserId == "" { + if player.UserId == "" { Expect(err).To(HaveOccurred()) } else if !admin && player.Username == adminPlayer1.Username { + // A non-admin cannot target another user's player: the ownership-restricted + // update matches no owned row, so it reports permission-denied rather than + // touching it. Expect(err).To(Equal(rest.ErrPermissionDenied)) clone.IP = player.IP } else { @@ -244,4 +247,86 @@ var _ = Describe("PlayerRepository", func() { Entry("admin context", true, players, adminPlayer1, regularPlayer), Entry("regular context", false, model.Players{regularPlayer}, regularPlayer, adminPlayer1), ) + + Describe("Ownership enforcement (cross-tenant write protection)", func() { + var regularRepo *playerRepository + + BeforeEach(func() { + ctx := log.NewContext(context.TODO()) + ctx = request.WithUser(ctx, regularUser) + regularRepo = NewPlayerRepository(ctx, database).(*playerRepository) + }) + + It("does not let a regular user hijack another user's player by spoofing userId in the body", func() { + // Attacker (regularUser) targets the victim's (adminUser) player by URL id, + // but sets userId in the body to their own id to try to pass the permission check. + spoofed := model.Player{ + ID: adminPlayer1.ID, + Name: "HIJACKED", + UserId: regularUser.ID, // attacker's own id, spoofed in the body + MaxBitRate: 1, + } + + // The ownership-restricted update matches no row owned by the attacker, so the write + // targets nothing and reports permission-denied rather than overwriting the victim's row. + err := regularRepo.Update(adminPlayer1.ID, &spoofed, "name", "user_id", "max_bit_rate") + Expect(err).To(Equal(rest.ErrPermissionDenied)) + + // The victim's player must remain untouched. + stored, err := adminRepo.Get(adminPlayer1.ID) + Expect(err).To(BeNil()) + Expect(*stored).To(Equal(adminPlayer1)) + }) + + It("does not let a regular user reassign their own player to another user", func() { + // Owner updates their own player but tries to give it away to the admin. The update + // succeeds for the other fields, but user_id is never written, so ownership stays put. + reassign := regularPlayer + reassign.UserId = adminUser.ID + reassign.Name = "given-away" + + err := regularRepo.Update(regularPlayer.ID, &reassign, "name", "user_id") + Expect(err).To(BeNil()) + + // Ownership must not have changed. + stored, err := adminRepo.Get(regularPlayer.ID) + Expect(err).To(BeNil()) + Expect(stored.UserId).To(Equal(regularUser.ID)) + }) + + It("does not let an admin reassign a player to another user", func() { + // Even an admin cannot change a player's owner via update. + reassign := regularPlayer + reassign.UserId = adminUser.ID + reassign.Name = "admin-renamed" + + err := adminRepo.Update(regularPlayer.ID, &reassign, "name", "user_id") + Expect(err).To(BeNil()) + + // The name change applies, but ownership must not have moved. + stored, err := adminRepo.Get(regularPlayer.ID) + Expect(err).To(BeNil()) + Expect(stored.Name).To(Equal("admin-renamed")) + Expect(stored.UserId).To(Equal(regularUser.ID)) + }) + + It("lets the owner update their own player", func() { + update := regularPlayer + update.Name = "renamed-by-owner" + + err := regularRepo.Update(regularPlayer.ID, &update, "name") + Expect(err).To(BeNil()) + + stored, err := adminRepo.Get(regularPlayer.ID) + Expect(err).To(BeNil()) + Expect(stored.Name).To(Equal("renamed-by-owner")) + Expect(stored.UserId).To(Equal(regularUser.ID)) + }) + + It("returns not found when updating a nonexistent player", func() { + ghost := model.Player{ID: "does-not-exist", Name: "ghost", UserId: regularUser.ID} + err := regularRepo.Update("does-not-exist", &ghost, "name") + Expect(err).To(Equal(rest.ErrNotFound)) + }) + }) }) diff --git a/persistence/share_repository.go b/persistence/share_repository.go index 415109640..bcd13ff3e 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -53,6 +53,10 @@ func (r *shareRepository) checkOwnership(id string) error { return nil } +// TODO: this still uses the legacy checkOwnership SELECT-then-delete pattern (a TOCTOU window), +// the same shape removed from Update. Once a base-repo deleteOwned exists (built on ownerFilter, +// mirroring updateOwned), route Delete through it and drop checkOwnership entirely. playerRepository +// .Delete (which restricts via addRestriction) should adopt the same primitive. func (r *shareRepository) Delete(id string) error { if err := r.checkOwnership(id); err != nil { return err @@ -166,17 +170,12 @@ func sortByIdPosition(mfs model.MediaFiles, ids []string) model.MediaFiles { func (r *shareRepository) Update(id string, entity any, cols ...string) error { s := entity.(*model.Share) - if err := r.checkOwnership(id); err != nil { - return err - } s.ID = id s.UpdatedAt = time.Now() - cols = append(cols, "updated_at") - _, err := r.put(id, s, cols...) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound + if len(cols) > 0 { + cols = append(cols, "updated_at") } - return err + return r.updateOwned(id, s, cols...) } func (r *shareRepository) Save(entity any) (string, error) { diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index 6988f323f..dcc84d66f 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -216,6 +216,44 @@ var _ = Describe("ShareRepository", func() { err := repo.(rest.Persistable).Update("headless-upd-share", &model.Share{Description: "Headless"}, "description") Expect(err).ToNot(HaveOccurred()) }) + + It("returns not found when updating a nonexistent share", func() { + ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Update("does-not-exist", &model.Share{Description: "Ghost"}, "description") + Expect(err).To(Equal(rest.ErrNotFound)) + }) + + It("updates all columns when no specific columns are given", func() { + insertShare("all-cols-share", ownerUser.ID) + ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + // No cols: the update must write every column, not just updated_at. + err := repo.(rest.Persistable).Update("all-cols-share", + &model.Share{Description: "All Updated", MaxBitRate: 192, ResourceType: "album", ResourceIDs: "2002"}) + Expect(err).ToNot(HaveOccurred()) + + got, err := repo.(rest.Repository).Read("all-cols-share") + Expect(err).ToNot(HaveOccurred()) + share := got.(*model.Share) + Expect(share.Description).To(Equal("All Updated")) + Expect(share.MaxBitRate).To(Equal(192)) + Expect(share.ResourceType).To(Equal("album")) + }) + + It("does not let an owner reassign their share to another user", func() { + insertShare("reassign-share", ownerUser.ID) + ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + repo := NewShareRepository(ctx, GetDBXBuilder()) + err := repo.(rest.Persistable).Update("reassign-share", + &model.Share{UserID: otherUser.ID, Description: "Given away"}, "user_id", "description") + Expect(err).ToNot(HaveOccurred()) + + // Ownership must not have moved, even though user_id was passed in the body and cols. + got, err := repo.(rest.Repository).Read("reassign-share") + Expect(err).ToNot(HaveOccurred()) + Expect(got.(*model.Share).UserID).To(Equal(ownerUser.ID)) + }) }) }) }) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index fd263d37b..55e83d544 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -13,6 +13,7 @@ import ( "time" . "github.com/Masterminds/squirrel" + "github.com/deluan/rest" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -57,6 +58,16 @@ func loggedUser(ctx context.Context) *model.User { } } +// ownerFilter returns the predicate restricting access to rows owned by the logged-in user, for +// tables with a user_id column. It returns nil for admins and for headless/system contexts (invalid +// user), meaning "no ownership restriction". Callers should skip the WHERE clause when it is nil. +func (r sqlRepository) ownerFilter() Sqlizer { + if usr := loggedUser(r.ctx); !usr.IsAdmin && usr.ID != invalidUserId { + return Eq{"user_id": usr.ID} + } + return nil +} + func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) { if r.tableName == "" { r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.") @@ -382,6 +393,47 @@ func (r sqlRepository) exists(cond Sqlizer) (bool, error) { return res.Exist > 0, err } +// updateOwned performs an atomic, ownership-restricted update of the row identified by id, for +// repositories whose table has a user_id column. Non-admins can only update rows they own: the +// ownership predicate is part of the UPDATE's WHERE clause, so a row owned by another user simply +// does not match and no write happens. Ownership itself is immutable here: user_id is never written, +// so no caller (admin included) can reassign a row to a different owner via an update. Unlike put, +// it never falls through to an INSERT, so a non-matching id never creates a row. +// +// When the update matches no row it classifies the failure: if the row exists but is owned by +// another user it returns rest.ErrPermissionDenied, otherwise rest.ErrNotFound. The write itself is +// still atomic; the extra lookup happens only on the failure path (count == 0), where no write +// occurred, so there is no TOCTOU on the update. +func (r sqlRepository) updateOwned(id string, m any, colsToUpdate ...string) error { + values, err := toSQLArgs(m) + if err != nil { + return fmt.Errorf("error preparing values to write to DB: %w", err) + } + updateValues := filterUpdateValues(values, id, colsToUpdate...) + delete(updateValues, "user_id") // ownership is immutable on update + update := Update(r.tableName).Where(Eq{"id": id}).SetMap(updateValues) + if owner := r.ownerFilter(); owner != nil { + update = update.Where(owner) + } + count, err := r.executeSQL(update) + if err != nil { + return err + } + if count == 0 { + // The update matched no row: either the id is missing, or it exists but is owned by + // someone else. Disambiguate to return the more accurate error. + exists, err := r.exists(Eq{"id": id}) + if err != nil { + return err + } + if exists { + return rest.ErrPermissionDenied + } + return rest.ErrNotFound + } + return nil +} + func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) { countQuery = countQuery. RemoveColumns().Columns("count(distinct " + r.tableName + ".id) as count"). @@ -408,6 +460,30 @@ func (r sqlRepository) putByMatch(filter Sqlizer, id string, m any, colsToUpdate return r.put(res.ID, m, colsToUpdate...) } +// filterUpdateValues selects, from a marshaled column map, the values to write in an UPDATE on the +// row identified by id: only the requested colsToUpdate (or all columns when none are specified), +// dropping columns that must never be overwritten on update (created_at, birth_time). +func filterUpdateValues(values map[string]any, id string, colsToUpdate ...string) map[string]any { + updateValues := map[string]any{} + + // This is a map of the columns that need to be updated, if specified + c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) { + return toSnakeCase(s), struct{}{} + }) + for k, v := range values { + if _, found := c2upd[k]; len(c2upd) == 0 || found { + updateValues[k] = v + } + } + + updateValues["id"] = id + delete(updateValues, "created_at") + // To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now + // TODO move to mediafile_repository when each repo has its own upsert method + delete(updateValues, "birth_time") + return updateValues +} + func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId string, err error) { values, err := toSQLArgs(m) if err != nil { @@ -415,24 +491,7 @@ func (r sqlRepository) put(id string, m any, colsToUpdate ...string) (newId stri } // If there's an ID, try to update first if id != "" { - updateValues := map[string]any{} - - // This is a map of the columns that need to be updated, if specified - c2upd := slice.ToMap(colsToUpdate, func(s string) (string, struct{}) { - return toSnakeCase(s), struct{}{} - }) - for k, v := range values { - if _, found := c2upd[k]; len(c2upd) == 0 || found { - updateValues[k] = v - } - } - - updateValues["id"] = id - delete(updateValues, "created_at") - // To avoid updating the media_file birth_time on each scan. Not the best solution, but it works for now - // TODO move to mediafile_repository when each repo has its own upsert method - delete(updateValues, "birth_time") - update := Update(r.tableName).Where(Eq{"id": id}).SetMap(updateValues) + update := Update(r.tableName).Where(Eq{"id": id}).SetMap(filterUpdateValues(values, id, colsToUpdate...)) count, err := r.executeSQL(update) if err != nil { return "", err From 11640f2e4d2e807bce3b473474a35af96dd4e9cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 4 Jun 2026 23:07:13 -0400 Subject: [PATCH 22/32] fix: restrict transcoding config reads to admins (#5564) * fix(security): restrict transcoding config reads to admins Authenticated non-admin users could read transcoding configs through the native API (GET /api/transcoding and /api/transcoding/{id}) when EnableTranscodingConfig was enabled. The responses included the full command templates, disclosing admin-configured ffmpeg invocations and local command paths. Write operations were already admin-only. The /transcoding route was registered in the general authenticated group, and only the repository's write methods checked IsAdmin. This applies the boundary at two layers: - Move the route under adminOnlyMiddleware, alongside the other admin-only resources (/library, /config, /inspect). - Add an IsAdmin guard to the repository's rest.Repository read methods (Read, ReadAll, Count) as defense-in-depth. The guard is scoped to the REST methods only. The streaming pipeline resolves profiles via Get/FindByFormat (model.TranscodingRepository), which stay open so transcoding keeps working for non-admin users. Adds regression tests covering non-admin read denial and confirming non-admin streaming lookups (Get/FindByFormat) still succeed. * fix(security): redact transcoding Command for non-admins instead of blocking reads Reworks the previous approach after review (Codex P2): moving /transcoding under adminOnlyMiddleware and denying non-admin reads broke legitimate non-admin UI flows. The web UI reads the transcoding resource as a regular user in several places that need only the profile name and target format: the player edit dropdown (ReferenceInput), the player list (ReferenceField), and the share/download format pickers (useGetList -> {targetFormat, name}). The only sensitive field is Command (the admin-owned ffmpeg template). So: - Revert the route move; /transcoding stays in the authenticated group. - Read/ReadAll now return the profiles to any authenticated user but blank the Command field for non-admins (mirrors user_repository's field-level redaction). Count is no longer denied (the UI needs list pagination). - Writes remain admin-only (Save/Update/Delete/Put). - Streaming is unaffected: it resolves profiles via Get/FindByFormat, which are not redacted, so on-the-fly transcoding keeps working for non-admins. Tests updated: non-admin reads succeed with Command blank, admin reads keep Command, non-admin Get/FindByFormat keep Command, writes still denied. --- persistence/transcoding_repository.go | 19 ++++++- persistence/transcoding_repository_test.go | 60 ++++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/persistence/transcoding_repository.go b/persistence/transcoding_repository.go index 870da61c8..96fd3efdb 100644 --- a/persistence/transcoding_repository.go +++ b/persistence/transcoding_repository.go @@ -53,14 +53,29 @@ func (r *transcodingRepository) Count(options ...rest.QueryOptions) (int64, erro } func (r *transcodingRepository) Read(id string) (any, error) { - return r.Get(id) + res, err := r.Get(id) + if err != nil { + return nil, err + } + if !loggedUser(r.ctx).IsAdmin { + res.Command = "" + } + return res, nil } func (r *transcodingRepository) ReadAll(options ...rest.QueryOptions) (any, error) { sel := r.newSelect(r.parseRestOptions(r.ctx, options...)).Columns("*") res := model.Transcodings{} err := r.queryAll(sel, &res) - return res, err + if err != nil { + return nil, err + } + if !loggedUser(r.ctx).IsAdmin { + for i := range res { + res[i].Command = "" + } + } + return res, nil } func (r *transcodingRepository) EntityName() string { diff --git a/persistence/transcoding_repository_test.go b/persistence/transcoding_repository_test.go index eddc5047a..73250163c 100644 --- a/persistence/transcoding_repository_test.go +++ b/persistence/transcoding_repository_test.go @@ -64,9 +64,69 @@ var _ = Describe("TranscodingRepository", func() { _, err = adminRepo.Get("to-delete") Expect(err).To(MatchError(model.ErrNotFound)) }) + + It("reads the Command field via the REST Read method", func() { + tr := &model.Transcoding{ID: "adminread", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := adminRepo.(*transcodingRepository).Read("adminread") + Expect(err).ToNot(HaveOccurred()) + Expect(res.(*model.Transcoding).Command).To(Equal("ffmpeg -secret")) + }) }) Describe("Regular User", func() { + It("reads a transcoding but with the Command field redacted", func() { + tr := &model.Transcoding{ID: "readreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := repo.(*transcodingRepository).Read("readreg") + Expect(err).ToNot(HaveOccurred()) + t := res.(*model.Transcoding) + Expect(t.Name).To(Equal("temp")) + Expect(t.TargetFormat).To(Equal("test_format")) + Expect(t.Command).To(BeEmpty()) + }) + + It("lists transcodings but with the Command field redacted", func() { + tr := &model.Transcoding{ID: "listreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := repo.(*transcodingRepository).ReadAll() + Expect(err).ToNot(HaveOccurred()) + list := res.(model.Transcodings) + Expect(list).ToNot(BeEmpty()) + for _, t := range list { + Expect(t.Command).To(BeEmpty()) + } + }) + + It("counts transcodings", func() { + count, err := repo.(*transcodingRepository).Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically(">=", 0)) + }) + + It("can still resolve a transcoding for streaming via Get (Command not redacted)", func() { + tr := &model.Transcoding{ID: "streamreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := repo.Get("streamreg") + Expect(err).ToNot(HaveOccurred()) + Expect(res.ID).To(Equal("streamreg")) + Expect(res.Command).To(Equal("ffmpeg -secret")) + }) + + It("can still resolve a transcoding for streaming via FindByFormat (Command not redacted)", func() { + tr := &model.Transcoding{ID: "fmtreg", Name: "temp", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg -secret"} + Expect(adminRepo.Put(tr)).To(Succeed()) + + res, err := repo.FindByFormat("test_format") + Expect(err).ToNot(HaveOccurred()) + Expect(res.ID).To(Equal("fmtreg")) + Expect(res.Command).To(Equal("ffmpeg -secret")) + }) + It("fails to create", func() { err := repo.Put(&model.Transcoding{ID: "bad", Name: "bad", TargetFormat: "test_format", DefaultBitRate: 64, Command: "ffmpeg"}) Expect(err).To(Equal(rest.ErrPermissionDenied)) From cf1f190bb57d1e8137f805553e9479031c2103f5 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 5 Jun 2026 08:14:00 -0400 Subject: [PATCH 23/32] fix(subsonic): use SQLite RANDOM() sorting in getRandomSongs, for faster results Related to #5558 Signed-off-by: Deluan --- server/subsonic/filter/filters.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/subsonic/filter/filters.go b/server/subsonic/filter/filters.go index 8ba4f0ff9..856870a6c 100644 --- a/server/subsonic/filter/filters.go +++ b/server/subsonic/filter/filters.go @@ -92,7 +92,7 @@ func SongsByAlbum(albumId string) Options { func SongsByRandom(genre string, fromYear, toYear int) Options { options := Options{ - Sort: "random", + Sort: "random()", } ff := And{} if genre != "" { From 174621f2595ac9d2567de5296353acfab5182448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 5 Jun 2026 13:54:55 -0400 Subject: [PATCH 24/32] fix(nativeapi): make /api/song path filter work and use startsWith (#5566) The native API exposes a `path` query param on /api/song, but it was not registered in the media file filter map. Unmapped real columns fall through to a default LIKE predicate that emits an unqualified `path LIKE ?`. Since the song query joins the library table (which also has a `path` column), SQLite returned "ambiguous column name: path" and the request failed with HTTP 500. Register a dedicated path filter qualified to media_file.path, resolving the ambiguity. The value is matched with startsWith semantics (LIKE arg || '%') against the library-relative path stored in media_file.path. To register it inline (without a one-off wrapper), startsWithFilter now takes a bound field and returns a filterFunc, mirroring containsFilter. The two existing callers are updated accordingly, and the now-unused withTableName helper is removed. The user 'name' filter, which previously relied on withTableName, is now qualified directly as user.name; tests are added to guard that filter against the same column-ambiguity class (the user query also joins the library table, which has a name column). Signed-off-by: Deluan --- persistence/mediafile_repository.go | 1 + persistence/mediafile_repository_test.go | 28 ++++++++++++++ persistence/sql_base_repository.go | 9 ----- persistence/sql_restful.go | 8 ++-- persistence/user_repository.go | 2 +- persistence/user_repository_test.go | 47 ++++++++++++++++++++++++ 6 files changed, 82 insertions(+), 13 deletions(-) diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go index 264778ea0..559378262 100644 --- a/persistence/mediafile_repository.go +++ b/persistence/mediafile_repository.go @@ -104,6 +104,7 @@ var mediaFileFilter = sync.OnceValue(func() map[string]filterFunc { "missing": booleanFilter, "artists_id": artistFilter, "library_id": libraryIdFilter, + "path": startsWithFilter("media_file.path"), } // Add all album tags as filters for tag := range model.TagMappings() { diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go index 464d88288..2bc9d0267 100644 --- a/persistence/mediafile_repository_test.go +++ b/persistence/mediafile_repository_test.go @@ -524,6 +524,34 @@ var _ = Describe("MediaRepository", func() { } }) }) + + Describe("path", func() { + It("matches files whose path starts with the given prefix", func() { + res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"path": "test/"}, + }) + Expect(err).ToNot(HaveOccurred()) + files := res.(model.MediaFiles) + + var found bool + for _, f := range files { + Expect(f.Path).To(HavePrefix("test/")) + if f.ID == mfWithoutAnnotation.ID { + found = true + } + } + Expect(found).To(BeTrue(), "MediaFile with matching path prefix should be included") + }) + + It("excludes files whose path does not start with the given prefix", func() { + res, err := mr.(model.ResourceRepository).ReadAll(rest.QueryOptions{ + Filters: map[string]any{"path": "no-such-prefix/"}, + }) + Expect(err).ToNot(HaveOccurred()) + files := res.(model.MediaFiles) + Expect(files).To(BeEmpty()) + }) + }) }) Describe("Search", func() { diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index 55e83d544..c2ba4e073 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -197,15 +197,6 @@ func (r sqlRepository) applyFilters(sq SelectBuilder, options ...model.QueryOpti return sq } -func (r *sqlRepository) withTableName(filter filterFunc) filterFunc { - return func(field string, value any) Sqlizer { - if r.tableName != "" { - field = r.tableName + "." + field - } - return filter(field, value) - } -} - // libraryIdFilter is a filter function to be added to resources that have a library_id column. func libraryIdFilter(_ string, value any) Sqlizer { return Eq{"library_id": value} diff --git a/persistence/sql_restful.go b/persistence/sql_restful.go index 02162387c..1dcabcec6 100644 --- a/persistence/sql_restful.go +++ b/persistence/sql_restful.go @@ -46,7 +46,7 @@ func (r *sqlRepository) parseRestFilters(ctx context.Context, options rest.Query continue } // Default to a "starts with" filter - filters = append(filters, startsWithFilter(f, v)) + filters = append(filters, Like{f: fmt.Sprintf("%s%%", v)}) } return filters } @@ -91,8 +91,10 @@ func eqFilter(field string, value any) Sqlizer { return Eq{field: value} } -func startsWithFilter(field string, value any) Sqlizer { - return Like{field: fmt.Sprintf("%s%%", value)} +func startsWithFilter(field string) func(string, any) Sqlizer { + return func(_ string, value any) Sqlizer { + return Like{field: fmt.Sprintf("%s%%", value)} + } } func containsFilter(field string) func(string, any) Sqlizer { diff --git a/persistence/user_repository.go b/persistence/user_repository.go index dc149e8ba..9decff4e5 100644 --- a/persistence/user_repository.go +++ b/persistence/user_repository.go @@ -59,7 +59,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository r.registerModel(&model.User{}, map[string]filterFunc{ "id": idFilter(r.tableName), "password": invalidFilter(ctx), - "name": r.withTableName(startsWithFilter), + "name": startsWithFilter(r.tableName + ".name"), }) once.Do(func() { _ = r.initPasswordEncryptionKey() diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go index 8abbf76a9..6f8ab9161 100644 --- a/persistence/user_repository_test.go +++ b/persistence/user_repository_test.go @@ -11,6 +11,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/id" + "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -207,6 +208,52 @@ var _ = Describe("UserRepository", func() { }) }) + Describe("ReadAll name filter", func() { + var adminRepo model.ResourceRepository + + BeforeEach(func() { + adminCtx := request.WithUser(GinkgoT().Context(), model.User{ID: "admin-id", UserName: "admin", IsAdmin: true}) + adminRepo = NewUserRepository(adminCtx, GetDBXBuilder()).(model.ResourceRepository) + + for _, u := range []model.User{ + {ID: "filter-alice", UserName: "alice_filter", Name: "Alice Filter", NewPassword: "x"}, + {ID: "filter-bob", UserName: "bob_filter", Name: "Bob Filter", NewPassword: "x"}, + } { + Expect(adminRepo.(model.UserRepository).Put(&u)).To(Succeed()) + } + }) + + AfterEach(func() { + ur := adminRepo.(model.UserRepository) + _ = ur.Delete("filter-alice") + _ = ur.Delete("filter-bob") + }) + + It("matches users whose name starts with the given prefix", func() { + res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Alice"}}) + Expect(err).ToNot(HaveOccurred()) + users := res.(model.Users) + + var names []string + for _, u := range users { + names = append(names, u.Name) + } + Expect(names).To(ContainElement("Alice Filter")) + Expect(names).ToNot(ContainElement("Bob Filter")) + }) + + It("does not match names by mid-string substring (startsWith, not contains)", func() { + res, err := adminRepo.ReadAll(rest.QueryOptions{Filters: map[string]any{"name": "Filter"}}) + Expect(err).ToNot(HaveOccurred()) + users := res.(model.Users) + + for _, u := range users { + Expect(u.ID).ToNot(Or(Equal("filter-alice"), Equal("filter-bob")), + "a mid-string substring should not match a startsWith filter") + } + }) + }) + Describe("validateUsernameUnique", func() { var repo *tests.MockedUserRepo var existingUser *model.User From 1e7996f5d708b0b2768688b62dae9354a22b427a Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 5 Jun 2026 15:50:59 -0400 Subject: [PATCH 25/32] fix(share): enforce per-user ownership on share reads Share repository read methods (Get, GetAll, Read, ReadAll, Exists, Count, CountAll) did not apply an owner filter, so non-admin users saw shares belonging to other users. The write paths already enforced per-user ownership; this brings reads in line with them. Add an addRestriction()/ownerFilter() based scope to share reads, keeping admins and the headless public-share resolution path unrestricted. Route share and player Delete through a new base-repo deleteOwned() primitive that applies the ownership predicate in the DELETE's WHERE clause (atomic, no select-then- delete window) and classifies a zero-row result as permission-denied vs not-found, mirroring updateOwned. The addRestriction helper and the write-miss classifier are hoisted onto the base repository so player and share share one implementation. Also map rest.ErrPermissionDenied and rest.ErrNotFound in the Subsonic error handler so ownership/not-found failures from the rest-backed repositories return the proper Subsonic codes (50 / 70) instead of a generic error. Covered by unit tests (persistence, subsonic error mapping) and an end-to-end cross-user sharing isolation test. --- persistence/player_repository.go | 18 +-- persistence/player_repository_test.go | 46 +++++--- persistence/share_repository.go | 41 +------ persistence/share_repository_test.go | 160 ++++++++++++++++++++++++-- persistence/sql_base_repository.go | 63 +++++++--- server/e2e/subsonic_sharing_test.go | 79 +++++++++++++ server/subsonic/api.go | 5 +- server/subsonic/api_test.go | 24 ++++ 8 files changed, 336 insertions(+), 100 deletions(-) diff --git a/persistence/player_repository.go b/persistence/player_repository.go index c9c7d3b4b..353b0444f 100644 --- a/persistence/player_repository.go +++ b/persistence/player_repository.go @@ -62,17 +62,6 @@ func (r *playerRepository) newRestSelect(options ...model.QueryOptions) SelectBu return s.Where(r.addRestriction()) } -func (r *playerRepository) addRestriction(sql ...Sqlizer) Sqlizer { - s := And{} - if len(sql) > 0 { - s = append(s, sql[0]) - } - if owner := r.ownerFilter(); owner != nil { - s = append(s, owner) - } - return s -} - func (r *playerRepository) CountByClient(options ...model.QueryOptions) (map[string]int64, error) { sel := r.newSelect(options...). Columns( @@ -152,12 +141,7 @@ func (r *playerRepository) Update(id string, entity any, cols ...string) error { } func (r *playerRepository) Delete(id string) error { - filter := r.addRestriction(And{Eq{"player.id": id}}) - err := r.delete(filter) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.deleteOwned(id) } var _ model.PlayerRepository = (*playerRepository)(nil) diff --git a/persistence/player_repository_test.go b/persistence/player_repository_test.go index f640cd9a4..b7085a1fb 100644 --- a/persistence/player_repository_test.go +++ b/persistence/player_repository_test.go @@ -110,33 +110,43 @@ var _ = Describe("PlayerRepository", func() { }) Describe("Delete", func() { - DescribeTable("item type", func(player model.Player) { - err := repo.Delete(player.ID) + It("deletes a player owned by the current user", func() { + err := repo.Delete(userPlayer.ID) Expect(err).To(BeNil()) - isReal := player.UserId != "" - canDelete := admin || player.UserId == userPlayer.UserId - count, err := repo.Count() Expect(err).To(BeNil()) + Expect(count).To(Equal(baseCount - 1)) - if isReal && canDelete { - Expect(count).To(Equal(baseCount - 1)) - } else { - Expect(count).To(Equal(baseCount)) - } + _, err = repo.Get(userPlayer.ID) + Expect(err).To(Equal(model.ErrNotFound)) + }) - item, err := repo.Get(player.ID) - if !isReal || canDelete { + It("does not delete another user's player when not admin", func() { + err := repo.Delete(otherPlayer.ID) + + if admin { + // Admins may delete any player. + Expect(err).To(BeNil()) + Expect(repo.Count()).To(Equal(baseCount - 1)) + _, err = repo.Get(otherPlayer.ID) Expect(err).To(Equal(model.ErrNotFound)) } else { - Expect(*item).To(Equal(player)) + // The ownership-restricted delete matches no owned row, so it reports + // permission-denied and leaves the other user's player untouched. + Expect(err).To(Equal(rest.ErrPermissionDenied)) + Expect(repo.Count()).To(Equal(baseCount)) + item, err := repo.Get(otherPlayer.ID) + Expect(err).To(BeNil()) + Expect(*item).To(Equal(otherPlayer)) } - }, - Entry("same user", userPlayer), - Entry("other item", otherPlayer), - Entry("fake item", model.Player{}), - ) + }) + + It("returns not-found for a nonexistent player", func() { + err := repo.Delete("i don't exist") + Expect(err).To(Equal(rest.ErrNotFound)) + Expect(repo.Count()).To(Equal(baseCount)) + }) }) Describe("Read", func() { diff --git a/persistence/share_repository.go b/persistence/share_repository.go index bcd13ff3e..89dc19e19 100644 --- a/persistence/share_repository.go +++ b/persistence/share_repository.go @@ -30,51 +30,18 @@ func NewShareRepository(ctx context.Context, db dbx.Builder) model.ShareReposito return r } -// TODO: Ownership checks should be moved to the service layer (core/share.go) -func (r *shareRepository) checkOwnership(id string) error { - usr := loggedUser(r.ctx) - if usr.IsAdmin || usr.ID == invalidUserId { - return nil - } - sel := r.newSelect().Columns("user_id").Where(Eq{"id": id}) - var share struct { - UserID string `db:"user_id"` - } - err := r.queryOne(sel, &share) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err - } - if share.UserID != usr.ID { - return rest.ErrPermissionDenied - } - return nil -} - -// TODO: this still uses the legacy checkOwnership SELECT-then-delete pattern (a TOCTOU window), -// the same shape removed from Update. Once a base-repo deleteOwned exists (built on ownerFilter, -// mirroring updateOwned), route Delete through it and drop checkOwnership entirely. playerRepository -// .Delete (which restricts via addRestriction) should adopt the same primitive. func (r *shareRepository) Delete(id string) error { - if err := r.checkOwnership(id); err != nil { - return err - } - err := r.delete(Eq{"id": id}) - if errors.Is(err, model.ErrNotFound) { - return rest.ErrNotFound - } - return err + return r.deleteOwned(id) } func (r *shareRepository) selectShare(options ...model.QueryOptions) SelectBuilder { return r.newSelect(options...).Join("user u on u.id = share.user_id"). - Columns("share.*", "user_name as username") + Columns("share.*", "user_name as username"). + Where(r.addRestriction()) } func (r *shareRepository) Exists(id string) (bool, error) { - return r.exists(Eq{"id": id}) + return r.exists(r.addRestriction(And{Eq{"id": id}})) } func (r *shareRepository) Get(id string) (*model.Share, error) { diff --git a/persistence/share_repository_test.go b/persistence/share_repository_test.go index dcc84d66f..0b3ece598 100644 --- a/persistence/share_repository_test.go +++ b/persistence/share_repository_test.go @@ -20,7 +20,7 @@ var _ = Describe("ShareRepository", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - ctx = request.WithUser(log.NewContext(context.TODO()), adminUser) + ctx = request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) repo = NewShareRepository(ctx, GetDBXBuilder()) // Insert the admin user into the database (required for foreign key constraint) @@ -38,7 +38,7 @@ var _ = Describe("ShareRepository", func() { Context("Repository creation and basic operations", func() { It("should create repository successfully with no user context", func() { // Create repository with no user context (headless) - headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) Expect(headlessRepo).ToNot(BeNil()) }) @@ -60,7 +60,7 @@ var _ = Describe("ShareRepository", func() { Expect(err).ToNot(HaveOccurred()) // Headless process should see all shares - headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) shares, err := headlessRepo.GetAll() Expect(err).ToNot(HaveOccurred()) @@ -92,7 +92,7 @@ var _ = Describe("ShareRepository", func() { Expect(err).ToNot(HaveOccurred()) // Headless process should be able to get the share - headlessRepo := NewShareRepository(context.Background(), GetDBXBuilder()) + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) share, err := headlessRepo.Get(shareID) Expect(err).ToNot(HaveOccurred()) Expect(share.ID).To(Equal(shareID)) @@ -155,7 +155,7 @@ var _ = Describe("ShareRepository", func() { Describe("Delete", func() { It("allows a non-admin user to delete their own share", func() { insertShare("own-share-del", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Delete("own-share-del") Expect(err).ToNot(HaveOccurred()) @@ -163,15 +163,21 @@ var _ = Describe("ShareRepository", func() { It("denies a non-admin user from deleting another user's share", func() { insertShare("other-share-del", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), otherUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Delete("other-share-del") Expect(err).To(Equal(rest.ErrPermissionDenied)) + + // The share was not deleted: the owner can still read it. + ownerCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) + ownerRepo := NewShareRepository(ownerCtx, GetDBXBuilder()) + _, err = ownerRepo.(rest.Repository).Read("other-share-del") + Expect(err).ToNot(HaveOccurred()) }) It("allows an admin to delete any user's share", func() { insertShare("admin-del-share", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), adminUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Delete("admin-del-share") Expect(err).ToNot(HaveOccurred()) @@ -179,7 +185,7 @@ var _ = Describe("ShareRepository", func() { It("allows headless context (no user) to delete a share", func() { insertShare("headless-del-share", ownerUser.ID) - repo := NewShareRepository(context.Background(), GetDBXBuilder()) + repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) err := repo.(rest.Persistable).Delete("headless-del-share") Expect(err).ToNot(HaveOccurred()) }) @@ -188,7 +194,7 @@ var _ = Describe("ShareRepository", func() { Describe("Update", func() { It("allows a non-admin user to update their own share", func() { insertShare("own-share-upd", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), ownerUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Update("own-share-upd", &model.Share{Description: "Updated"}, "description") Expect(err).ToNot(HaveOccurred()) @@ -196,7 +202,7 @@ var _ = Describe("ShareRepository", func() { It("denies a non-admin user from updating another user's share", func() { insertShare("other-share-upd", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), otherUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), otherUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Update("other-share-upd", &model.Share{Description: "Hacked"}, "description") Expect(err).To(Equal(rest.ErrPermissionDenied)) @@ -204,7 +210,7 @@ var _ = Describe("ShareRepository", func() { It("allows an admin to update any user's share", func() { insertShare("admin-upd-share", ownerUser.ID) - ctx := request.WithUser(log.NewContext(context.TODO()), adminUser) + ctx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) repo := NewShareRepository(ctx, GetDBXBuilder()) err := repo.(rest.Persistable).Update("admin-upd-share", &model.Share{Description: "Admin Updated"}, "description") Expect(err).ToNot(HaveOccurred()) @@ -212,7 +218,7 @@ var _ = Describe("ShareRepository", func() { It("allows headless context (no user) to update a share", func() { insertShare("headless-upd-share", ownerUser.ID) - repo := NewShareRepository(context.Background(), GetDBXBuilder()) + repo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) err := repo.(rest.Persistable).Update("headless-upd-share", &model.Share{Description: "Headless"}, "description") Expect(err).ToNot(HaveOccurred()) }) @@ -255,5 +261,135 @@ var _ = Describe("ShareRepository", func() { Expect(got.(*model.Share).UserID).To(Equal(ownerUser.ID)) }) }) + + Describe("Read scoping", func() { + BeforeEach(func() { + // Persist owner/other users so the JOIN in selectShare resolves. + ur := NewUserRepository(ctx, GetDBXBuilder()) + Expect(ur.Put(&ownerUser)).To(Succeed()) + Expect(ur.Put(&otherUser)).To(Succeed()) + + insertShare("share-owner-1", ownerUser.ID) + insertShare("share-owner-2", ownerUser.ID) + insertShare("share-other-1", otherUser.ID) + }) + + Context("non-admin user", func() { + var nonAdminRepo model.ShareRepository + var nonAdminRest rest.Repository + + BeforeEach(func() { + nonAdminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), ownerUser) + nonAdminRepo = NewShareRepository(nonAdminCtx, GetDBXBuilder()) + nonAdminRest = nonAdminRepo.(rest.Repository) + }) + + It("GetAll returns only own shares", func() { + shares, err := nonAdminRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + ids := make([]string, len(shares)) + for i, s := range shares { + ids[i] = s.ID + } + Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2")) + }) + + It("ReadAll returns only own shares", func() { + res, err := nonAdminRest.ReadAll() + Expect(err).ToNot(HaveOccurred()) + shares := res.(model.Shares) + ids := make([]string, len(shares)) + for i, s := range shares { + ids[i] = s.ID + } + Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2")) + }) + + It("Get returns own share", func() { + s, err := nonAdminRepo.Get("share-owner-1") + Expect(err).ToNot(HaveOccurred()) + Expect(s.ID).To(Equal("share-owner-1")) + }) + + It("Get returns ErrNotFound for another user's share", func() { + _, err := nonAdminRepo.Get("share-other-1") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("Read returns ErrNotFound for another user's share", func() { + _, err := nonAdminRest.Read("share-other-1") + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("Exists returns true for own share", func() { + exists, err := nonAdminRepo.Exists("share-owner-1") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + + It("Exists returns false for another user's share", func() { + exists, err := nonAdminRepo.Exists("share-other-1") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("CountAll counts only own shares", func() { + count, err := nonAdminRepo.CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically("==", 2)) + }) + + It("Count (rest) counts only own shares", func() { + count, err := nonAdminRest.Count() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically("==", 2)) + }) + }) + + Context("admin user", func() { + It("GetAll returns all shares", func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + adminRepo := NewShareRepository(adminCtx, GetDBXBuilder()) + shares, err := adminRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + ids := make([]string, len(shares)) + for i, s := range shares { + ids[i] = s.ID + } + Expect(ids).To(ConsistOf("share-owner-1", "share-owner-2", "share-other-1")) + }) + + It("CountAll counts all shares", func() { + adminCtx := request.WithUser(log.NewContext(GinkgoT().Context()), adminUser) + adminRepo := NewShareRepository(adminCtx, GetDBXBuilder()) + count, err := adminRepo.CountAll() + Expect(err).ToNot(HaveOccurred()) + Expect(count).To(BeNumerically("==", 3)) + }) + }) + + Context("headless context (public share route)", func() { + It("GetAll returns all shares", func() { + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) + shares, err := headlessRepo.GetAll() + Expect(err).ToNot(HaveOccurred()) + Expect(shares).To(HaveLen(3)) + }) + + It("Get returns another user's share", func() { + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) + s, err := headlessRepo.Get("share-other-1") + Expect(err).ToNot(HaveOccurred()) + Expect(s.ID).To(Equal("share-other-1")) + }) + + It("Exists returns true for any share", func() { + headlessRepo := NewShareRepository(GinkgoT().Context(), GetDBXBuilder()) + exists, err := headlessRepo.Exists("share-other-1") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + }) + }) }) }) diff --git a/persistence/sql_base_repository.go b/persistence/sql_base_repository.go index c2ba4e073..321e790db 100644 --- a/persistence/sql_base_repository.go +++ b/persistence/sql_base_repository.go @@ -61,6 +61,9 @@ func loggedUser(ctx context.Context) *model.User { // ownerFilter returns the predicate restricting access to rows owned by the logged-in user, for // tables with a user_id column. It returns nil for admins and for headless/system contexts (invalid // user), meaning "no ownership restriction". Callers should skip the WHERE clause when it is nil. +// +// The predicate uses an unqualified user_id, so it only works on queries where that column is +// unambiguous (no join introducing a second user_id). func (r sqlRepository) ownerFilter() Sqlizer { if usr := loggedUser(r.ctx); !usr.IsAdmin && usr.ID != invalidUserId { return Eq{"user_id": usr.ID} @@ -68,6 +71,20 @@ func (r sqlRepository) ownerFilter() Sqlizer { return nil } +// addRestriction combines an optional caller predicate with the ownership filter, producing the +// WHERE clause for owner-scoped reads. For admins and headless contexts ownerFilter() is nil and +// only the caller's predicate (if any) remains. +func (r sqlRepository) addRestriction(sql ...Sqlizer) Sqlizer { + s := And{} + if len(sql) > 0 { + s = append(s, sql[0]) + } + if owner := r.ownerFilter(); owner != nil { + s = append(s, owner) + } + return s +} + func (r *sqlRepository) registerModel(instance any, filters map[string]filterFunc) { if r.tableName == "" { r.tableName = strings.TrimPrefix(reflect.TypeOf(instance).String(), "*model.") @@ -402,29 +419,47 @@ func (r sqlRepository) updateOwned(id string, m any, colsToUpdate ...string) err } updateValues := filterUpdateValues(values, id, colsToUpdate...) delete(updateValues, "user_id") // ownership is immutable on update - update := Update(r.tableName).Where(Eq{"id": id}).SetMap(updateValues) - if owner := r.ownerFilter(); owner != nil { - update = update.Where(owner) - } + update := Update(r.tableName).Where(r.addRestriction(Eq{"id": id})).SetMap(updateValues) count, err := r.executeSQL(update) if err != nil { return err } if count == 0 { - // The update matched no row: either the id is missing, or it exists but is owned by - // someone else. Disambiguate to return the more accurate error. - exists, err := r.exists(Eq{"id": id}) - if err != nil { - return err - } - if exists { - return rest.ErrPermissionDenied - } - return rest.ErrNotFound + return r.classifyOwnedWriteMiss(id) } return nil } +// deleteOwned performs an atomic, ownership-restricted delete of the row identified by id, for +// repositories whose table has a user_id column. Non-admins can only delete rows they own: the +// ownership predicate is part of the DELETE's WHERE clause, so a row owned by another user simply +// does not match and is left untouched. The failure path mirrors updateOwned (see +// classifyOwnedWriteMiss), so there is no TOCTOU on the delete. +func (r sqlRepository) deleteOwned(id string) error { + count, err := r.executeSQL(Delete(r.tableName).Where(r.addRestriction(Eq{"id": id}))) + if err != nil { + return err + } + if count == 0 { + return r.classifyOwnedWriteMiss(id) + } + return nil +} + +// classifyOwnedWriteMiss explains why an ownership-filtered write (updateOwned/deleteOwned) matched +// no row: rest.ErrPermissionDenied if the row exists but is owned by another user, otherwise +// rest.ErrNotFound. It runs only on the failure path (count == 0), where no write occurred. +func (r sqlRepository) classifyOwnedWriteMiss(id string) error { + exists, err := r.exists(Eq{"id": id}) + if err != nil { + return err + } + if exists { + return rest.ErrPermissionDenied + } + return rest.ErrNotFound +} + func (r sqlRepository) count(countQuery SelectBuilder, options ...model.QueryOptions) (int64, error) { countQuery = countQuery. RemoveColumns().Columns("count(distinct " + r.tableName + ".id) as count"). diff --git a/server/e2e/subsonic_sharing_test.go b/server/e2e/subsonic_sharing_test.go index 1a082ba0f..03bf1f80f 100644 --- a/server/e2e/subsonic_sharing_test.go +++ b/server/e2e/subsonic_sharing_test.go @@ -125,3 +125,82 @@ var _ = Describe("Sharing Endpoints", Ordered, func() { Expect(resp.Error).ToNot(BeNil()) }) }) + +var _ = Describe("Sharing Cross-User Isolation", Ordered, func() { + var userA, userB model.User + var shareID string + var albumID string + + BeforeAll(func() { + conf.Server.EnableSharing = true + setupTestDB() + + userA = createUser("share-user-a", "share-user-a", "Share User A", false) + userB = createUser("share-user-b", "share-user-b", "Share User B", false) + + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"album.name": "Abbey Road"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).ToNot(BeEmpty()) + albumID = albums[0].ID + + resp := doReqWithUser(userA, "createShare", "id", albumID, "description", "User A's share") + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares.Share).To(HaveLen(1)) + shareID = resp.Shares.Share[0].ID + Expect(resp.Shares.Share[0].Username).To(Equal(userA.UserName)) + }) + + It("userB's getShares does not leak userA's share", func() { + resp := doReqWithUser(userB, "getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares).ToNot(BeNil()) + Expect(resp.Shares.Share).To(BeEmpty()) + }) + + It("userA still sees own share", func() { + resp := doReqWithUser(userA, "getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Shares.Share).To(HaveLen(1)) + Expect(resp.Shares.Share[0].ID).To(Equal(shareID)) + Expect(resp.Shares.Share[0].Description).To(Equal("User A's share")) + }) + + It("admin sees userA's share", func() { + resp := doReqWithUser(adminUser, "getShares") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + ids := make([]string, len(resp.Shares.Share)) + for i, s := range resp.Shares.Share { + ids[i] = s.ID + } + Expect(ids).To(ContainElement(shareID)) + }) + + It("userB cannot updateShare on userA's share", func() { + resp := doReqWithUser(userB, "updateShare", "id", shareID, "description", "hijacked") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + + // Confirm description unchanged for userA. + check := doReqWithUser(userA, "getShares") + Expect(check.Shares.Share).To(HaveLen(1)) + Expect(check.Shares.Share[0].Description).To(Equal("User A's share")) + }) + + It("userB cannot deleteShare on userA's share", func() { + resp := doReqWithUser(userB, "deleteShare", "id", shareID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + + // Confirm share still present for userA. + check := doReqWithUser(userA, "getShares") + Expect(check.Shares.Share).To(HaveLen(1)) + Expect(check.Shares.Share[0].ID).To(Equal(shareID)) + }) +}) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index f39dec009..82e404228 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -9,6 +9,7 @@ import ( "regexp" "strconv" + "github.com/deluan/rest" "github.com/go-chi/chi/v5" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" @@ -301,9 +302,9 @@ func mapToSubsonicError(err error) subError { err = newError(responses.ErrorMissingParameter, err.Error()) case errors.Is(err, req.ErrInvalidParam): err = newError(responses.ErrorGeneric, err.Error()) - case errors.Is(err, model.ErrNotFound): + case errors.Is(err, model.ErrNotFound), errors.Is(err, rest.ErrNotFound): err = newError(responses.ErrorDataNotFound, "data not found") - case errors.Is(err, model.ErrNotAuthorized): + case errors.Is(err, model.ErrNotAuthorized), errors.Is(err, rest.ErrPermissionDenied): err = newError(responses.ErrorAuthorizationFail) case errors.Is(err, stream.ErrTooManyTranscodes): err = newError(responses.ErrorGeneric, "too many concurrent transcodes, please retry shortly") diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index b565109a5..f8d5b6642 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -4,13 +4,16 @@ import ( "context" "encoding/json" "encoding/xml" + "errors" "fmt" "math" "net/http" "net/http/httptest" "strings" + "github.com/deluan/rest" "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -187,3 +190,24 @@ var _ = Describe("sendResponse", func() { Expect(pointer).To(Equal(responses.ErrorDataNotFound)) }) }) + +var _ = Describe("mapToSubsonicError", func() { + DescribeTable("maps repository errors to the correct Subsonic error code", + func(err error, expectedCode int32) { + subErr := mapToSubsonicError(err) + Expect(subErr.code).To(Equal(expectedCode)) + }, + Entry("rest.ErrPermissionDenied -> not authorized (50)", + rest.ErrPermissionDenied, responses.ErrorAuthorizationFail), + Entry("rest.ErrNotFound -> data not found (70)", + rest.ErrNotFound, responses.ErrorDataNotFound), + Entry("model.ErrNotAuthorized -> not authorized (50)", + model.ErrNotAuthorized, responses.ErrorAuthorizationFail), + Entry("model.ErrNotFound -> data not found (70)", + model.ErrNotFound, responses.ErrorDataNotFound), + Entry("wrapped rest.ErrPermissionDenied is still mapped", + fmt.Errorf("update share: %w", rest.ErrPermissionDenied), responses.ErrorAuthorizationFail), + Entry("unknown error -> generic (0)", + errors.New("boom"), responses.ErrorGeneric), + ) +}) From fb61827ab6c23ccbcb2096a581b07496ee3820c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 5 Jun 2026 18:06:52 -0400 Subject: [PATCH 26/32] test: fix flaky tests in utils/cache (#5567) * test: fix flaky tests in utils/cache Two tests in the utils/cache suite were timing- and ordering-dependent and failed intermittently on CI (notably on the Windows runner). The FileHaunter tests raced the asynchronous cache-cleanup goroutine with a fixed 400ms sleep, then asserted the directory state once. On slow runners the haunter had not finished scrubbing, so the assertion saw the original files and failed. Replace the fixed sleep with Eventually polling so the assertions wait for the haunter to converge. While doing so, the exact set and count of reaped files proved nondeterministic (the empty file is double-counted in the size loop and LRU survivors depend on OS access-time ordering), so the assertions now check the haunter's actual guarantees: the empty file is always scrubbed and the cache stays within the configured maxSize/maxItems bound. This also lets the previously-disabled maxItems context and its commented-out assertions be re-enabled. The HTTPClient 'caches repeated requests' test relied on a shared requestsReceived counter that was never reset in BeforeEach. Under randomized spec order another spec could run first and leave the counter non-zero, breaking the first assertion. Reset the counter and header in BeforeEach to make the spec independent of execution order. Verified with: ginkgo -race -repeat=80 --randomize-all ./utils/cache/ * test: surface errors in dirSize and align Eventually with house style Address code review feedback on the cache flaky-test fix: - dirSize now returns (uint64, error) and the maxSize spec asserts the error is nil. Previously a ReadDir/Info failure silently returned 0, which always satisfies '<= maxSize' and would mask a real filesystem error as a passing test. - dirSize skips non-regular entries (info.Mode().IsRegular()) to match its doc comment and avoid counting directories or symlinks. - The Eventually blocks now use .WithTimeout()/.WithPolling() with time.Duration values instead of string-literal durations, matching the prevailing pattern in the test suite. --- utils/cache/cached_http_client_test.go | 2 + utils/cache/file_haunter_test.go | 61 ++++++++++++++++++++------ 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/utils/cache/cached_http_client_test.go b/utils/cache/cached_http_client_test.go index 1ec1a3a27..5f8b0029c 100644 --- a/utils/cache/cached_http_client_test.go +++ b/utils/cache/cached_http_client_test.go @@ -20,6 +20,8 @@ var _ = Describe("HTTPClient", func() { var header string BeforeEach(func() { + requestsReceived = 0 + header = "" ts = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requestsReceived++ header = r.Header.Get("head") diff --git a/utils/cache/file_haunter_test.go b/utils/cache/file_haunter_test.go index 47440cc22..6c5151abb 100644 --- a/utils/cache/file_haunter_test.go +++ b/utils/cache/file_haunter_test.go @@ -29,15 +29,15 @@ var _ = Describe("FileHaunter", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(func() { _ = os.RemoveAll(tempDir) }) + // Use a short haunter period so cleanup runs promptly; the assertions + // below poll with Eventually instead of racing a fixed sleep. fsCache, err = fscache.NewCacheWithHaunter(fs, fscache.NewLRUHaunterStrategy( - cache.NewFileHaunter("", maxItems, maxSize, 300*time.Millisecond), + cache.NewFileHaunter("", maxItems, maxSize, 100*time.Millisecond), )) Expect(err).ToNot(HaveOccurred()) DeferCleanup(fsCache.Clean) Expect(createTestFiles(fsCache)).To(Succeed()) - - <-time.After(400 * time.Millisecond) }) Context("When maxSize is defined", func() { @@ -46,24 +46,39 @@ var _ = Describe("FileHaunter", func() { }) It("removes files", func() { - Expect(os.ReadDir(cacheDir)).To(HaveLen(4)) - Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") - // TODO Fix flaky tests - //Expect(fsCache.Exists("stream-0")).To(BeFalse(), "stream-0 should have been scrubbed") + // stream-0..4 hold "hello" (5 bytes each) and stream-5 is empty. + // With maxSize=20, the haunter scrubs the empty file plus enough of + // the oldest files to bring the total size down to <= 20 bytes. + // Which files survive (and therefore the exact count) depends on + // access-time ordering, so we only assert the haunter's guarantees: + // the empty file is always scrubbed and the total size stays within + // the configured limit. + Eventually(func(g Gomega) { + g.Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") + size, err := dirSize(cacheDir) + g.Expect(err).ToNot(HaveOccurred()) + g.Expect(size).To(BeNumerically("<=", maxSize)) + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Succeed()) }) }) - XContext("When maxItems is defined", func() { + Context("When maxItems is defined", func() { BeforeEach(func() { maxItems = 3 }) It("removes files", func() { - Expect(os.ReadDir(cacheDir)).To(HaveLen(maxItems)) - Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") - // TODO Fix flaky tests - //Expect(fsCache.Exists("stream-0")).To(BeFalse(), "stream-0 should have been scrubbed") - //Expect(fsCache.Exists("stream-1")).To(BeFalse(), "stream-1 should have been scrubbed") + // With maxItems=3, the haunter scrubs the empty file plus enough of + // the oldest files to bring the count within the limit. As above, the + // exact survivors depend on access-time ordering, so we assert the + // guaranteed invariants: the empty file is gone and the item count + // stays within the configured limit. + Eventually(func(g Gomega) { + g.Expect(fsCache.Exists("stream-5")).To(BeFalse(), "stream-5 (empty file) should have been scrubbed") + entries, readErr := os.ReadDir(cacheDir) + g.Expect(readErr).ToNot(HaveOccurred()) + g.Expect(len(entries)).To(BeNumerically("<=", maxItems)) + }).WithTimeout(5 * time.Second).WithPolling(50 * time.Millisecond).Should(Succeed()) }) }) }) @@ -93,6 +108,26 @@ func createTestFiles(c *fscache.FSCache) error { return nil } +// dirSize returns the total size in bytes of all regular files in dir. +func dirSize(dir string) (uint64, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return 0, err + } + var total uint64 + for _, e := range entries { + info, err := e.Info() + if err != nil { + return 0, err + } + if !info.Mode().IsRegular() { + continue + } + total += uint64(info.Size()) + } + return total, nil +} + func createCachedStream(c *fscache.FSCache, name string, contents string) fscache.ReadAtCloser { r, w, _ := c.Get(name) _, _ = w.Write([]byte(contents)) From 03841ffe965d637fbcd6a003870bd6b40c94ee8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 5 Jun 2026 18:16:21 -0400 Subject: [PATCH 27/32] fix(ui): update German, Finnish, Galician, Dutch, Slovak, Thai, Chinese (traditional) translations from POEditor (#5351) Co-authored-by: navidrome-bot --- resources/i18n/de.json | 4 +- resources/i18n/fi.json | 4 +- resources/i18n/gl.json | 4 +- resources/i18n/nl.json | 4 +- resources/i18n/sk.json | 170 ++++++++++++++++++------------------ resources/i18n/th.json | 18 ++-- resources/i18n/zh-Hant.json | 6 +- 7 files changed, 114 insertions(+), 96 deletions(-) diff --git a/resources/i18n/de.json b/resources/i18n/de.json index c540dee05..1a516d393 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -38,7 +38,9 @@ "missing": "Fehlend", "libraryName": "Bibliothek", "composer": "Komponist", - "disc": "Disc %{discNumber}" + "disc": "Disc %{discNumber}", + "albumGain": "Album Gain", + "trackGain": "Titel Gain" }, "actions": { "addToQueue": "Später abspielen", diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index bbad47bd6..0e6149f87 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -38,7 +38,9 @@ "missing": "Puuttuva", "libraryName": "Kirjasto", "composer": "Säveltäjä", - "disc": "Levy %{discNumber}" + "disc": "Levy %{discNumber}", + "albumGain": "Albumin äänenvoimakkuus", + "trackGain": "Kappaleen äänenvoimakkuus" }, "actions": { "addToQueue": "Lisää jonoon", diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json index d62ca2ab2..444998d03 100644 --- a/resources/i18n/gl.json +++ b/resources/i18n/gl.json @@ -38,7 +38,9 @@ "missing": "Falta", "libraryName": "Biblioteca", "composer": "Composición", - "disc": "Disco %{discNumber}" + "disc": "Disco %{discNumber}", + "albumGain": "Gañancia de Album", + "trackGain": "Gañancia de Canción" }, "actions": { "addToQueue": "Ao final da cola", diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json index 3f638c13c..46c3df9de 100644 --- a/resources/i18n/nl.json +++ b/resources/i18n/nl.json @@ -38,7 +38,9 @@ "missing": "Ontbrekend", "libraryName": "Bibliotheek", "composer": "Componist", - "disc": "Schijf %{discNumber}" + "disc": "Schijf %{discNumber}", + "albumGain": "Album gain", + "trackGain": "Nummer gain" }, "actions": { "addToQueue": "Voeg toe aan wachtrij", diff --git a/resources/i18n/sk.json b/resources/i18n/sk.json index af5afade7..f294d1602 100644 --- a/resources/i18n/sk.json +++ b/resources/i18n/sk.json @@ -2,7 +2,7 @@ "languageName": "Slovenčina", "resources": { "song": { - "name": "Skladba |||| Skladieb", + "name": "Skladba |||| Skladby", "fields": { "albumArtist": "Interpret albumu", "duration": "Dĺžka", @@ -10,20 +10,14 @@ "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", @@ -31,6 +25,7 @@ "quality": "Kvalita", "bpm": "BPM", "playDate": "Naposledy prehraná skladba", + "channels": "Kanály", "createdAt": "Pridané", "grouping": "Zoskupovanie", "mood": "Nálada", @@ -38,17 +33,24 @@ "tags": "Ďalšie značky", "mappedTags": "Mapované značky", "rawTags": "Nespracované značky", - "missing": "Chýbajúce" + "bitDepth": "Bitová hĺbka", + "sampleRate": "Vzorkovacia frekvencia", + "missing": "Chýbajúce", + "libraryName": "Knižnica", + "composer": "Skladateľ", + "disc": "Disk %{discNumber}", + "albumGain": "Zosilnenie albumu", + "trackGain": "Zosilnenie stopy" }, "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", + "showInPlaylist": "Zobraziť v zozname skladieb", "instantMix": "Okamžitý mix" } }, @@ -60,38 +62,38 @@ "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é", + "size": "Veľkosť", + "originalDate": "Pôvodné", + "releaseDate": "Vydané", + "releases": "Vydanie |||| Vydania", + "released": "Vydané", "recordLabel": "Štítok", "catalogNum": "Katalógové číslo", "releaseType": "Typ vydania", "grouping": "Zoskupovanie", "media": "Médiá", "mood": "Nálada", - "missing": "Chýbajúce" + "date": "Dátum záznamu", + "missing": "Chýbajúce", + "libraryName": "Knižnica" }, "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" + "info": "Získať informácie", + "share": "Zdieľať" }, "lists": { "all": "Všetko", @@ -109,10 +111,10 @@ "name": "Názov", "albumCount": "Počet albumov", "songCount": "Počet skladieb", - "size": "Veľkosť", "playCount": "Prehrania", "rating": "Hodnotenie", "genre": "Žáner", + "size": "Veľkosť", "role": "Rola", "missing": "Chýbajúci" }, @@ -133,9 +135,9 @@ "maincredit": "Interpret albumu alebo interpret |||| Interpreti albumov alebo interpreti" }, "actions": { - "topSongs": "Najpopulárnejšie skladby", "shuffle": "Zamiešať", - "radio": "Rádio" + "radio": "Rádio", + "topSongs": "Najpopulárnejšie skladby" } }, "user": { @@ -144,7 +146,6 @@ "userName": "Používateľské meno", "isAdmin": "Správca", "lastLoginAt": "Naposledy prihlásený", - "lastAccessAt": "Posledný Prístup", "updatedAt": "Upravený", "name": "Meno", "password": "Heslo", @@ -153,6 +154,7 @@ "currentPassword": "Súčastné heslo", "newPassword": "Nové heslo", "token": "Token", + "lastAccessAt": "Posledný Prístup", "libraries": "Knižnice" }, "helperTexts": { @@ -164,14 +166,14 @@ "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" + }, + "validation": { + "librariesRequired": "Pre používateľov bez administrátorských práv musí byť vybratá aspoň jedna knižnica" } }, "player": { @@ -214,9 +216,9 @@ "selectPlaylist": "Vybrať zoznam skladieb:", "addNewPlaylist": "Vytvoriť \"%{name}\"", "export": "Export", - "saveQueue": "Uložiť rad do zoznamu skladieb", "makePublic": "Zverejniť", "makePrivate": "Nastaviť ako súkromné", + "saveQueue": "Uložiť rad do zoznamu skladieb", "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" @@ -247,7 +249,6 @@ "username": "Zdieľané", "url": "URL", "description": "Popis", - "downloadable": "Povoliť sťahovanie?", "contents": "Obsah", "expiresAt": "Vyprší", "lastVisitedAt": "Naposledy navštívené", @@ -255,19 +256,17 @@ "format": "Formát", "maxBitRate": "Max. Bit Rate", "updatedAt": "Nahrané", - "createdAt": "Vytvorené" - }, - "notifications": {}, - "actions": {} + "createdAt": "Vytvorené", + "downloadable": "Povoliť sťahovanie?" + } }, "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" + "updatedAt": "Zmizol dňa", + "libraryName": "Knižnica" }, "actions": { "remove": "Odstrániť", @@ -275,7 +274,8 @@ }, "notifications": { "removed": "Chýbajúce súbory odstránené" - } + }, + "empty": "Žiadne chýbajúce súbory" }, "library": { "name": "Knižnica |||| Knižnice", @@ -305,20 +305,20 @@ }, "actions": { "scan": "Skenovať knižnicu", - "quickScan": "Rýchly sken", - "fullScan": "Úplný sken", "manageUsers": "Spravovať prístup používateľov", - "viewDetails": "Zobraziť detaily" + "viewDetails": "Zobraziť detaily", + "quickScan": "Rýchly sken", + "fullScan": "Úplný sken" }, "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é", + "scanCompleted": "Skenovanie knižnice dokončené", "quickScanStarted": "Rýchly sken spustený", "fullScanStarted": "Úplný sken spustený", - "scanError": "Chyba pri spustení skenu. Skontrolujte logy", - "scanCompleted": "Skenovanie knižnice dokončené" + "scanError": "Chyba pri spustení skenu. Skontrolujte logy" }, "validation": { "nameRequired": "Názov knižnice je povinný", @@ -391,8 +391,6 @@ }, "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.", @@ -402,8 +400,10 @@ "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" + "requiredHosts": "Požadovaní hostitelia", + "configValidationError": "Overenie konfigurácie zlyhalo:", + "schemaRenderError": "Nie je možné zobraziť konfiguračný formulár. Schéma pluginu môže byť neplatná.", + "allowWriteAccessHelp": "Keď je povolené, plugin môže upravovať súbory v adresároch knižníc. Predvolene majú pluginy prístup iba na čítanie." }, "placeholders": { "configKey": "kľúč", @@ -446,7 +446,6 @@ "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ť", @@ -470,6 +469,7 @@ "close_menu": "Zavrieť ponuku", "unselect": "Zrušiť výber", "skip": "Preskočiť", + "bulk_actions_mobile": "1 |||| %{smart_count}", "share": "Zdieľať", "download": "Stiahnuť" }, @@ -557,58 +557,52 @@ } }, "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...", + "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ť", + "downloadOriginalFormat": "Stiahnuť v pôvodnom formáte", "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" + "shareCopyToClipboard": "Skopírovať do schránky: Ctrl+C, Enter", + "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í.", + "noSimilarSongsFound": "Nenašli sa žiadne podobné skladby", + "noTopSongsFound": "Nenašli sa žiadne top skladby", + "startingInstantMix": "Načítava sa Instant Mix...", + "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" }, "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", @@ -619,7 +613,6 @@ "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", @@ -628,13 +621,20 @@ "none": "Vypnuté", "album": "Použiť Album Gain", "track": "Použiť Track Gain" - } + }, + "lastfmNotConfigured": "Kľúč API Last.fm nie je nakonfigurovaný" } }, "albumList": "Albumy", + "about": "O Navidrome", "playlists": "Zoznamy skladieb", "sharedPlaylists": "Zdieľané zoznamy skladieb", - "about": "O Navidrome" + "librarySelector": { + "allLibraries": "Všetky knižnice (%{count})", + "multipleLibraries": "%{selected} z %{total} knižníc", + "selectLibraries": "Vyberte knižnice", + "none": "Žiadne" + } }, "player": { "playListsText": "Rad", @@ -682,11 +682,11 @@ "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" + "devFlagsComment": "Toto sú experimentálne nastavenia a môžu byť odstránené v budúcich verziách", + "downloadToml": "Stiahnuť konfiguráciu (TOML)" } }, "activity": { @@ -694,17 +694,12 @@ "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" + "elapsedTime": "Uplynutý čas", + "selectiveScan": "Selektívne" }, "help": { "title": "Klávesové skratky Navidrome", @@ -714,10 +709,15 @@ "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" + "toggle_love": "Pridať túto skladbu do obľúbených", + "current_song": "Prejsť na aktuálnu skladbu" } + }, + "nowPlaying": { + "title": "Práve hrá", + "empty": "Nič sa neprehráva", + "minutesAgo": "pred %{smart_count} minútou |||| pred %{smart_count} minútami" } } \ No newline at end of file diff --git a/resources/i18n/th.json b/resources/i18n/th.json index b445d7464..fde89494e 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -38,7 +38,9 @@ "missing": "หายไป", "libraryName": "ห้องสมุด", "composer": "ผู้แต่ง", - "disc": "" + "disc": "พื้นที่ %{discNumber}", + "albumGain": "เนื้อหาในอัลบั้ม", + "trackGain": "เนื้อหาในเพลง" }, "actions": { "addToQueue": "เพิ่มในคิว", @@ -355,7 +357,7 @@ "selectedUsers": "ผู้ใช้ถูกเลือก", "allLibraries": "อนุญาติห้องสมุดเพลงทั้งหมด", "selectedLibraries": "ห้องสมุดเพลงถูกเลือก", - "allowWriteAccess": "" + "allowWriteAccess": "อนุญาตให้เขียน" }, "sections": { "status": "สถานะ", @@ -401,7 +403,7 @@ "requiredHosts": "ต้องการ Host", "configValidationError": "การตั้งค่าเกิดความผิดพลาด", "schemaRenderError": "ไม่สามารถแสดงหน้าจอการตั้งค่า อาจเกิดจากความผิดพลาดจากปลั๊กอิน", - "allowWriteAccessHelp": "" + "allowWriteAccessHelp": "เมื่อเปิดใช้งาน ปลั๊กอินสามารถแก้ไขไฟล์ในห้องสมุด ปลั๊กอินอยู่ในโหมดอ่านอย่างเดียวเป็นค่าเริ่มต้น" }, "placeholders": { "configKey": "คีย์", @@ -591,7 +593,13 @@ "remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร", "noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน", "noTopSongsFound": "ไม่พบเพลงยอดนิยม", - "startingInstantMix": "กำลังโหลดอินสแตนท์ มิก..." + "startingInstantMix": "กำลังโหลดอินสแตนท์ มิก...", + "uploadCover": "อัพโหลดภาพหน้าปก", + "removeCover": "ลบถาพหน้าปก", + "coverUploaded": "ภาพหน้าปกถูกอัพเดทแล้ว", + "coverRemoved": "ภาพหน้าปกถูกลบแล้ว", + "coverUploadError": "อัพโหลดภาพหน้าปกผิดพลาด", + "coverRemoveError": "ลบภาพหน้าปกผิดพลาด" }, "menu": { "library": "ห้องสมุดเพลง", @@ -712,4 +720,4 @@ "empty": "ไม่มีเพลงเล่น", "minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว" } -} +} \ No newline at end of file diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index 92b4af3d0..d00ae2ac3 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -38,7 +38,9 @@ "missing": "遺失", "libraryName": "媒體庫", "composer": "作曲者", - "disc": "光碟 %{discNumber}" + "disc": "光碟 %{discNumber}", + "albumGain": "專輯增益", + "trackGain": "曲目增益" }, "actions": { "addToQueue": "加入至播放佇列", @@ -718,4 +720,4 @@ "empty": "無播放內容", "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" } -} +} \ No newline at end of file From a6451f75d6bfab01a612ed05e20d7345bddc7544 Mon Sep 17 00:00:00 2001 From: Xabi <888924+xabirequejo@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:32:10 +0200 Subject: [PATCH 28/32] fix(ui): update Basque localisation (#5364) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added two strings (gain), imrpoved some, fixed a typo Co-authored-by: Deluan Quintão --- resources/i18n/eu.json | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/resources/i18n/eu.json b/resources/i18n/eu.json index 6bfd09d0e..30db91cde 100644 --- a/resources/i18n/eu.json +++ b/resources/i18n/eu.json @@ -2,7 +2,7 @@ "languageName": "Euskara", "resources": { "song": { - "name": "Abestia |||| Abesti", + "name": "Abestia |||| Abestiak", "fields": { "albumArtist": "Albumaren artista", "duration": "Iraupena", @@ -22,6 +22,8 @@ "bitRate": "Bit-tasa", "bitDepth": "Bit-sakonera", "sampleRate": "Lagin-tasa", + "albumGain": "Album-irabazia", + "trackGain": "Pista-irabazia", "channels": "Kanalak", "disc": "%{discNumber}. diskoa", "discSubtitle": "Diskoaren azpititulua", @@ -53,7 +55,7 @@ } }, "album": { - "name": "Albuma |||| Album", + "name": "Albuma |||| Albumak", "fields": { "albumArtist": "Albumaren artista", "artist": "Artista", @@ -104,7 +106,7 @@ } }, "artist": { - "name": "Artista |||| Artista", + "name": "Artista |||| Artistak", "fields": { "name": "Izena", "albumCount": "Album kopurua", @@ -117,7 +119,7 @@ "missing": "Ez da aurkitu" }, "roles": { - "albumartist": "Albumeko egilea |||| Albumeko artistak", + "albumartist": "Albumeko artista |||| Albumeko artistak", "artist": "Artista |||| Artistak", "composer": "Konpositorea |||| Konpositoreak", "conductor": "Orkestra zuzendaria |||| Orkestra zuzendariak", @@ -335,7 +337,7 @@ } }, "plugin": { - "name": "Plugina |||| Plugin", + "name": "Plugina |||| Pluginak", "fields": { "id": "IDa", "name": "Izena", @@ -492,7 +494,7 @@ "input": { "file": { "upload_several": "Jaregin edo hautatu igo nahi dituzun fitxategiak.", - "upload_single": "AJaregin edo hautatu igo nahi duzun fitxategia." + "upload_single": "Jaregin edo hautatu igo nahi duzun fitxategia." }, "image": { "upload_several": "Jaregin edo hautatu igo nahi dituzun irudiak.", @@ -537,9 +539,9 @@ "skip_nav": "Joan edukira" }, "notification": { - "updated": "Elementu bat eguneratu da |||| %{smart_count} elementu eguneratu dira", + "updated": "Elementua eguneratu da |||| %{smart_count} elementu eguneratu dira", "created": "Elementua sortu da", - "deleted": "Elementu bat ezabatu da |||| %{smart_count} elementu ezabatu dira.", + "deleted": "Elementua ezabatu da |||| %{smart_count} elementu ezabatu dira.", "bad_item": "Elementu okerra", "item_doesnt_exist": "Elementua ez dago", "http_error": "Errorea zerbitzariarekin komunikatzerakoan", @@ -588,7 +590,7 @@ "listenBrainzUnlinkSuccess": "ListenBrainz deskonektatu da eta erabiltzailearen ohiturak hirugarrenen zerbitzuekin partekatzea desaktibatu da", "listenBrainzUnlinkFailure": "Ezin izan da ListenBrainz deskonektatu", "openIn": { - "lastfm": "Ikusi Last.fm-n", + "lastfm": "Ikusi Last.fm-en", "musicbrainz": "Ikusi MusicBrainz-en" }, "lastfmLink": "Irakurri gehiago…", From 1709ce37f694ecab5b21c3d0e17c5a5dc23254cf Mon Sep 17 00:00:00 2001 From: Daniel Barrientos Anariba <69573860+danielbanariba@users.noreply.github.com> Date: Fri, 5 Jun 2026 16:32:57 -0600 Subject: [PATCH 29/32] fix(ui): update Spanish translations and add missing gain keys (#5433) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): update Spanish translations and add missing gain keys - Add missing 'albumGain' and 'trackGain' keys (matches recent additions in pt-br, ru) - Translate 'Playlists' and 'Shared Playlists' to 'Listas de reproducción' / '...compartidas' - Translate 'OFFLINE' (server down indicator) to 'DESCONECTADO' Spanish translation now covers 553/553 keys (was 551/553). Signed-off-by: Daniel Banariba * fix(ui): address review feedback on Spanish translations - Use 'Ganancia del álbum' (with article 'del') for consistency with the existing pattern in line 624 ('album': 'Ganancia del álbum') and 'Artista del álbum'. Thanks @gemini-code-assist for the catch. - Revert 'playlists' and 'sharedPlaylists' to keep the loanword 'Playlist(s)' which is the form actually used by Spanish-speaking music app users (Spotify ES, etc.) and matches existing usage elsewhere in this same file (e.g. line 48 'Agregar a la playlist'). Signed-off-by: Daniel Banariba --------- Signed-off-by: Daniel Banariba Co-authored-by: Deluan Quintão --- resources/i18n/es.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/resources/i18n/es.json b/resources/i18n/es.json index a018eda3d..555c165d6 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -35,6 +35,8 @@ "rawTags": "Etiquetas sin procesar", "bitDepth": "Profundidad de bits", "sampleRate": "Frecuencia de muestreo", + "albumGain": "Ganancia del álbum", + "trackGain": "Ganancia de pista", "missing": "Faltante", "libraryName": "Biblioteca", "composer": "Compositor", @@ -693,7 +695,7 @@ "quickScan": "Escaneo rápido", "fullScan": "Escaneo completo", "serverUptime": "Uptime del servidor", - "serverDown": "OFFLINE", + "serverDown": "DESCONECTADO", "scanType": "Tipo", "status": "Error de escaneo", "elapsedTime": "Tiempo transcurrido", From 318ad164df36373f79f9d444f26382095e841fab Mon Sep 17 00:00:00 2001 From: Buck DeFore Date: Fri, 5 Jun 2026 18:35:00 -0400 Subject: [PATCH 30/32] fix(ui): suppress capitalization and correction for login on mobile keyboards (#3783) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * suppress capitalization and correction for login on mobile keyboards * prettier pass --------- Co-authored-by: Deluan Quintão --- ui/src/layout/Login.jsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/src/layout/Login.jsx b/ui/src/layout/Login.jsx index 91f56b273..a84e01f4d 100644 --- a/ui/src/layout/Login.jsx +++ b/ui/src/layout/Login.jsx @@ -101,8 +101,13 @@ const renderInput = ({ }) => ( From 29c123854cdb3f17adb11c2a7950ef634b93143a Mon Sep 17 00:00:00 2001 From: craiglush <75083395+craiglush@users.noreply.github.com> Date: Sat, 6 Jun 2026 01:35:56 +0100 Subject: [PATCH 31/32] feat(ui): Add Moonbase themes (Alpha light + Bravo dark) (#5243) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add Moonbase theme A warm dark theme with gold (#d4a039) accents on deep charcoal backgrounds (#0a0a09/#141413). Features muted cream text (#e5ddd3), copper error states (#c45c3c), and subtle earthy secondary tones. Co-Authored-By: Claude Opus 4.6 (1M context) * Fix review comments on Moonbase theme - Fix CSS selector: use :not(.player-delete) instead of :not([class=".player-delete"]) - Fix MuiFormHelperText override structure: target error key directly - Remove empty icon: {} and avatar: {} from NDLogin overrides - Use comma-separated rgba syntax and hex for linear-gradient Co-Authored-By: Claude Opus 4.6 (1M context) * Add Moonbase Alpha (light) and rename dark to Moonbase Bravo Split the Moonbase theme into a complementary pair: - Moonbase Alpha: warm cream/stone light theme with deep gold accents - Moonbase Bravo: the original deep charcoal dark theme Both share the same gold (#d4a039) brand accent, copper error states, and earthy neutral palette. Alpha uses darkened gold (#9a7420) for better contrast on light backgrounds. Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: Deluan Quintão --- ui/src/themes/index.js | 4 ++ ui/src/themes/moonbaseAlpha.css.js | 63 +++++++++++++++++++++ ui/src/themes/moonbaseAlpha.js | 90 ++++++++++++++++++++++++++++++ ui/src/themes/moonbaseBravo.css.js | 63 +++++++++++++++++++++ ui/src/themes/moonbaseBravo.js | 90 ++++++++++++++++++++++++++++++ 5 files changed, 310 insertions(+) create mode 100644 ui/src/themes/moonbaseAlpha.css.js create mode 100644 ui/src/themes/moonbaseAlpha.js create mode 100644 ui/src/themes/moonbaseBravo.css.js create mode 100644 ui/src/themes/moonbaseBravo.js diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index f65948438..e6cd4e0ff 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -15,6 +15,8 @@ import NutballTheme from './nutball' import AmusicTheme from './amusic' import SquiddiesGlassTheme from './SquiddiesGlass' import NautilineTheme from './nautiline' +import MoonbaseAlphaTheme from './moonbaseAlpha' +import MoonbaseBravoTheme from './moonbaseBravo' export default { // Classic default themes @@ -31,6 +33,8 @@ export default { GruvboxDarkTheme, LigeraTheme, MonokaiTheme, + MoonbaseAlphaTheme, + MoonbaseBravoTheme, NautilineTheme, NordTheme, NuclearTheme, diff --git a/ui/src/themes/moonbaseAlpha.css.js b/ui/src/themes/moonbaseAlpha.css.js new file mode 100644 index 000000000..757cfc03c --- /dev/null +++ b/ui/src/themes/moonbaseAlpha.css.js @@ -0,0 +1,63 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #9a7420 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #b8862e; + border-color: #9a7420 +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #c9b896; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #b8862e +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #9a7420 +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #9a7420 !important +} + +.react-jinke-music-player-main .rc-slider-rail { + background-color: #ddd7cc !important +} + +.react-jinke-music-player-main .lyric-btn { + color: #1a1917 !important +} + +.react-jinke-music-player-main .music-player-panel { + color: #1a1917 !important +} + +.react-jinke-music-player-main .lyric-btn-active svg { + color: #9a7420 !important +} + +.music-player-lyric { + color: #9a7420 !important +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #9a7420 +} +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #9a7420 +} + +.progress-bar-content .audio-title a { + color: #1a1917 +} + +.MuiCheckbox-colorSecondary.Mui-checked { + color: #b8862e !important +} +` +export default stylesheet diff --git a/ui/src/themes/moonbaseAlpha.js b/ui/src/themes/moonbaseAlpha.js new file mode 100644 index 000000000..51d8a2696 --- /dev/null +++ b/ui/src/themes/moonbaseAlpha.js @@ -0,0 +1,90 @@ +import stylesheet from './moonbaseAlpha.css.js' + +export default { + themeName: 'Moonbase - Alpha', + palette: { + primary: { + main: '#9a7420', + }, + secondary: { + main: '#ede8df', + contrastText: '#1a1917', + }, + type: 'light', + background: { + default: '#f5f0e8', + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#1a1917', + backgroundColor: '#faf8f4', + }, + }, + MuiButton: { + textPrimary: { + color: '#9a7420', + }, + textSecondary: { + color: '#1a1917', + }, + }, + MuiChip: { + clickable: { + background: '#ede8df', + }, + }, + MuiFormGroup: { + root: { + color: '#1a1917', + }, + }, + MuiFormHelperText: { + error: { + color: '#b04a2e', + }, + }, + MuiTableHead: { + root: { + color: '#6b635a', + background: '#f5f0e8 !important', + }, + }, + MuiTableCell: { + root: { + color: '#1a1917', + background: '#faf8f4 !important', + }, + head: { + color: '#6b635a', + background: '#f5f0e8 !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#9a7420', + }, + welcome: { + color: '#1a1917', + }, + card: { + minWidth: 300, + background: '#faf8f4', + }, + button: { + boxShadow: '3px 3px 5px rgba(0, 0, 0, 0.12)', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(245, 240, 232, 0.72), #faf8f4)!important', + }, + }, + }, + player: { + theme: 'light', + stylesheet, + }, +} diff --git a/ui/src/themes/moonbaseBravo.css.js b/ui/src/themes/moonbaseBravo.css.js new file mode 100644 index 000000000..580b054cc --- /dev/null +++ b/ui/src/themes/moonbaseBravo.css.js @@ -0,0 +1,63 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #d4a039 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #d4a039; + border-color: #b8862e +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #d4a039; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #d4a039 +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #d4a039 +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #d4a039 !important +} + +.react-jinke-music-player-main .rc-slider-rail { + background-color: #2a2a27 !important +} + +.react-jinke-music-player-main .lyric-btn { + color: #e5ddd3 !important +} + +.react-jinke-music-player-main .music-player-panel { + color: #e5ddd3 !important +} + +.react-jinke-music-player-main .lyric-btn-active svg { + color: #d4a039 !important +} + +.music-player-lyric { + color: #d4a039 !important +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #d4a039 +} +.audio-lists-panel-content .audio-item:active .group:not(.player-delete) svg, .audio-lists-panel-content .audio-item:hover .group:not(.player-delete) svg { + color: #d4a039 +} + +.progress-bar-content .audio-title a { + color: #e5ddd3 +} + +.MuiCheckbox-colorSecondary.Mui-checked { + color: #d4a039 !important +} +` +export default stylesheet diff --git a/ui/src/themes/moonbaseBravo.js b/ui/src/themes/moonbaseBravo.js new file mode 100644 index 000000000..87585df29 --- /dev/null +++ b/ui/src/themes/moonbaseBravo.js @@ -0,0 +1,90 @@ +import stylesheet from './moonbaseBravo.css.js' + +export default { + themeName: 'Moonbase - Bravo', + palette: { + primary: { + main: '#d4a039', + }, + secondary: { + main: '#1e1e1c', + contrastText: '#e5ddd3', + }, + type: 'dark', + background: { + default: '#0a0a09', + }, + }, + overrides: { + MuiPaper: { + root: { + color: '#e5ddd3', + backgroundColor: '#141413', + }, + }, + MuiButton: { + textPrimary: { + color: '#d4a039', + }, + textSecondary: { + color: '#e5ddd3', + }, + }, + MuiChip: { + clickable: { + background: '#1e1e1c', + }, + }, + MuiFormGroup: { + root: { + color: '#e5ddd3', + }, + }, + MuiFormHelperText: { + error: { + color: '#c45c3c', + }, + }, + MuiTableHead: { + root: { + color: '#8a8278', + background: '#0a0a09 !important', + }, + }, + MuiTableCell: { + root: { + color: '#e5ddd3', + background: '#141413 !important', + }, + head: { + color: '#8a8278', + background: '#0a0a09 !important', + }, + }, + NDLogin: { + systemNameLink: { + color: '#d4a039', + }, + welcome: { + color: '#e5ddd3', + }, + card: { + minWidth: 300, + background: '#1e1e1c', + }, + button: { + boxShadow: '3px 3px 5px #0a0a09', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(10, 10, 9, 0.72), #141413)!important', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} From e15896bf3260da57dc4e5331cc7d8271ba22b5bd Mon Sep 17 00:00:00 2001 From: Love <51426041+lov3b@users.noreply.github.com> Date: Sat, 6 Jun 2026 02:42:27 +0200 Subject: [PATCH 32/32] feat(ui): Add Catppuccin Latte (#5250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Catppuccin Latte (the light version) theme based on the existing Catppuccin Macchiato theme. The palette and player styling are adapted for light mode while staying as close as practical to the existing Macchiato theme behavior. I've opted to use gray for the color for controls. The dark version appears to mix a few control/accent colors, so for Latte I standardized those choices. This might be worth looking into in a separate PR. It uses gray and blue. Signed-off-by: Love Billenius Co-authored-by: Deluan Quintão Signed-off-by: Deluan --- ui/src/themes/catppuccinLatte.css.js | 203 +++++++++++++++++++++++++++ ui/src/themes/catppuccinLatte.js | 104 ++++++++++++++ ui/src/themes/index.js | 2 + 3 files changed, 309 insertions(+) create mode 100644 ui/src/themes/catppuccinLatte.css.js create mode 100644 ui/src/themes/catppuccinLatte.js diff --git a/ui/src/themes/catppuccinLatte.css.js b/ui/src/themes/catppuccinLatte.css.js new file mode 100644 index 000000000..84c8d2d7f --- /dev/null +++ b/ui/src/themes/catppuccinLatte.css.js @@ -0,0 +1,203 @@ +const stylesheet = ` + .react-jinke-music-player-main.light-theme svg, + .react-jinke-music-player .music-player-controller, + .react-jinke-music-player .audio-circle-process-bar circle[class='stroke'] { + color: #6c6f85; + stroke: #6c6f85; + } + + .react-jinke-music-player-main svg:active, + .react-jinke-music-player-main svg:hover { + color: #7c7f93; + } + + .react-jinke-music-player-main.light-theme svg:active, + .react-jinke-music-player-main.light-theme svg:hover { + color: #7c7f93; + } + + .react-jinke-music-player-mobile-play-model-tip, + .react-jinke-music-player-main.light-theme .play-mode-title { + background-color: #6c6f85; + color: #eff1f5; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle, + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track { + background-color: #6c6f85; + } + + .react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #6c6f85; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #6c6f85; + } + + .react-jinke-music-player-main .audio-item.playing svg { + color: #6c6f85; + } + + .react-jinke-music-player-main .audio-item.playing .player-singer { + color: #6c6f85 !important; + } + + .react-jinke-music-player-main .loading svg { + color: #6c6f85 !important; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: hidden; + box-shadow: + rgba(76, 79, 105, 0.12) 0px 4px 6px, + rgba(76, 79, 105, 0.08) 0px 5px 7px; + } + + .rc-slider-rail, + .rc-slider-track { + height: 6px; + } + + .rc-slider { + padding: 3px 0; + } + + .react-jinke-music-player-main.light-theme .rc-switch-checked { + background-color: #6c6f85 !important; + border: 1px solid #6c6f85; + } + + .sound-operation > div:nth-child(4) { + transform: translateX(-50%) translateY(5%) !important; + } + + .sound-operation { + padding: 4px 0; + } + + .react-jinke-music-player-main .music-player-panel { + background-color: #e6e9ef; + color: #4c4f69; + box-shadow: 0 0 8px rgba(76, 79, 105, 0.15); + } + + .react-jinke-music-player-main.light-theme .music-player-panel { + color: #4c4f69; + } + + .audio-lists-panel { + background-color: #e6e9ef; + bottom: 6.25rem; + box-shadow: + rgba(76, 79, 105, 0.12) 0px 4px 6px, + rgba(76, 79, 105, 0.08) 0px 5px 7px; + } + + .audio-lists-panel-content .audio-item.playing { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-content .audio-item:nth-child(2n+1) { + background-color: rgba(0, 0, 0, 0); + } + + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color: rgba(76, 79, 105, 0.08); + } + + .audio-lists-panel-header { + border-bottom: 1px solid #ccd0da; + box-shadow: none; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn { + background-color: rgba(0, 0, 0, 0); + box-shadow: 0 0 0 0; + } + + .react-jinke-music-player-main.light-theme .audio-lists-panel-header { + background-color: #e6e9ef; + color: #4c4f69; + } + + .audio-lists-panel-content .audio-item { + line-height: 32px; + color: #4c4f69; + } + + .react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow: + rgba(76, 79, 105, 0.12) 0px 4px 6px, + rgba(76, 79, 105, 0.08) 0px 5px 7px; + } + + .react-jinke-music-player-main .music-player-lyric { + color: #6c6f85; /* subtext0 */ + -webkit-text-stroke: 0.35px #eff1f5; + font-weight: bolder; + } + + .react-jinke-music-player-main .lyric-btn-active, + .react-jinke-music-player-main .lyric-btn-active svg { + color: #6c6f85 !important; + } + + .audio-lists-panel-content .audio-item.playing, + .audio-lists-panel-content .audio-item.playing svg { + color: #6c6f85; + } + + .audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg, + .audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg { + color: #6c6f85; + } + + .audio-lists-panel-content .audio-item .player-icons { + scale: 75%; + } + + .audio-lists-panel-content .audio-item:active, + .audio-lists-panel-content .audio-item:hover { + background-color: #dce0e8; /* surface1 */ + } + + /* Mobile */ + .react-jinke-music-player-mobile-cover { + border: none; + box-shadow: + rgba(76, 79, 105, 0.12) 0px 4px 6px, + rgba(76, 79, 105, 0.08) 0px 5px 7px; + } + + .react-jinke-music-player .music-player-controller { + border: none; + background-color: #e6e9ef; + border-color: #e6e9ef; + box-shadow: + rgba(76, 79, 105, 0.12) 0px 4px 6px, + rgba(76, 79, 105, 0.08) 0px 5px 7px; + color: #6c6f85; + } + + .react-jinke-music-player .music-player-controller.music-player-playing:before { + border: 1px solid rgba(76, 79, 105, 0.18); + } + + .react-jinke-music-player .music-player-controller .music-player-controller-setting { + background: rgba(108, 111, 133, 0.2); + color: #eff1f5; + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle, + .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #6c6f85; + } + + .react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; + } +` + +export default stylesheet diff --git a/ui/src/themes/catppuccinLatte.js b/ui/src/themes/catppuccinLatte.js new file mode 100644 index 000000000..3624cd853 --- /dev/null +++ b/ui/src/themes/catppuccinLatte.js @@ -0,0 +1,104 @@ +import stylesheet from './catppuccinLatte.css.js' + +export default { + themeName: 'Catppuccin Latte', + palette: { + primary: { main: '#8839ef' }, // mauve + secondary: { + main: '#ccd0da', // surface0 + contrastText: '#4c4f69', // text + }, + type: 'light', + background: { + default: '#eff1f5', // base + }, + }, + + overrides: { + MuiPaper: { + root: { + color: '#4c4f69', // text + backgroundColor: '#e6e9ef', // mantle + }, + }, + + MuiButton: { + textPrimary: { + color: '#1e66f5', // blue + }, + textSecondary: { + color: '#4c4f69', // text + }, + }, + + MuiChip: { + clickable: { + background: '#ccd0da', // surface0 + }, + }, + + MuiFormGroup: { + root: { + color: '#4c4f69', + }, + }, + + MuiFormHelperText: { + root: { + Mui: { + error: { + color: '#d20f39', // red + }, + }, + }, + }, + + MuiTableHead: { + root: { + color: '#4c4f69', + background: '#e6e9ef', + }, + }, + + MuiTableCell: { + root: { + color: '#4c4f69', + background: '#e6e9ef !important', + }, + head: { + color: '#4c4f69', + background: '#e6e9ef !important', + }, + }, + + NDLogin: { + systemNameLink: { + color: '#8839ef', // mauve + }, + icon: {}, + welcome: { + color: '#4c4f69', + }, + card: { + minWidth: 300, + background: '#eff1f5', + }, + avatar: {}, + button: { + boxShadow: '3px 3px 5px #ccd0da', + }, + }, + + NDMobileArtistDetails: { + bgContainer: { + background: + 'linear-gradient(to bottom, rgba(255 255 255 / 72%), rgb(239 241 245))!important', + }, + }, + }, + + player: { + theme: 'light', + stylesheet, + }, +} diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index e6cd4e0ff..f4886fd31 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -9,6 +9,7 @@ import ElectricPurpleTheme from './electricPurple' import NordTheme from './nord' import GruvboxDarkTheme from './gruvboxDark' import CatppuccinMacchiatoTheme from './catppuccinMacchiato' +import CatppuccinLatteTheme from './catppuccinLatte' import DraculaTheme from './dracula' import NuclearTheme from './nuclear' import NutballTheme from './nutball' @@ -26,6 +27,7 @@ export default { // New themes should be added here, in alphabetic order AmusicTheme, CatppuccinMacchiatoTheme, + CatppuccinLatteTheme, DraculaTheme, ElectricPurpleTheme, ExtraDarkTheme,