From 24c97293249d44fab4ade7d63643d13f858c37ff Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Thu, 9 Apr 2026 14:16:36 +0100 Subject: [PATCH 001/117] Implementing remember property pattern for these properties, this should fix #5287 Previously, on an upgrade, this will default back to the default values set in the wxs; now it will store those away in the registry and lookup their value before continuing. This won't fix the upgrade for existing installs that have changed the values, only new ones. The "best" option for existing installs imo is probably to backup the navidrome database and config file. Uninstall the old version, install the new version (with this fix) with the paths you wanted, stop navidrome, restore the database and config file over the top and start navidrome. --- release/wix/navidrome.wxs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 6d94bab9d..e9d3fcd45 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -23,9 +23,15 @@ - - - + + + + + + + + + @@ -46,6 +52,10 @@ + + + + From 9f72fa41683d74bc0e040bcb4eea55704d0ca383 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Thu, 9 Apr 2026 14:24:19 +0100 Subject: [PATCH 002/117] This should be SOFTWARE\Company\Application format --- release/wix/navidrome.wxs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index e9d3fcd45..97d5d9985 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -24,13 +24,13 @@ - + - + - + @@ -53,9 +53,9 @@ - - - + + + From 5e076b1ae2847a27ad0bdb999156357e07a30b7e Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Thu, 9 Apr 2026 16:07:16 +0100 Subject: [PATCH 003/117] Also need to do it on INSTALLDIR --- release/wix/navidrome.wxs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 97d5d9985..4ac8f4c4e 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -23,6 +23,9 @@ + + + @@ -53,6 +56,7 @@ + From 269248bb9dde46d041cb70e36726880f045c0f68 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Thu, 9 Apr 2026 16:27:48 +0100 Subject: [PATCH 004/117] This needs to be a property as well --- release/wix/navidrome.wxs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 4ac8f4c4e..df0401629 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -23,7 +23,7 @@ - + From 7f6a589421a60c0279dca39678dbc6464f78f1b7 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Thu, 9 Apr 2026 16:59:00 +0100 Subject: [PATCH 005/117] This needs to be expanded --- release/wix/navidrome.wxs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index df0401629..917deb7a7 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -23,7 +23,7 @@ - + From 195423b6a9a6fb470875056f3ae9eadc8c41feff Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Thu, 9 Apr 2026 18:03:46 +0100 Subject: [PATCH 006/117] Might be able to workaround the double-expansion by using the WIX 4 builtin ProgramFiles3264Folder --- release/wix/navidrome.wxs | 102 +++++++++++++++++++------------------- 1 file changed, 50 insertions(+), 52 deletions(-) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 917deb7a7..3e1d9d3a0 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -23,7 +23,7 @@ - + @@ -39,62 +39,60 @@ - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Not Installed AND NOT WIX_UPGRADE_DETECTED From 0734e285151b5582a338d223c9bfcb8ff8d06734 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Thu, 9 Apr 2026 18:07:19 +0100 Subject: [PATCH 007/117] We need TARGETDIR setting --- release/wix/navidrome.wxs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 3e1d9d3a0..c28731c44 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -39,7 +39,7 @@ - + From fc8b68173fbaf83b851b4752e13170779542209e Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Thu, 9 Apr 2026 18:20:43 +0100 Subject: [PATCH 008/117] Tidying up and simplifying hopefully --- release/wix/navidrome.wxs | 78 +++++++++++++++++++-------------------- 1 file changed, 37 insertions(+), 41 deletions(-) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index c28731c44..01f2bc22a 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -2,12 +2,10 @@ - - @@ -23,7 +21,7 @@ - + @@ -39,52 +37,50 @@ - - - - + + + - - - + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - + + + - - - + + + - From 0a68c84f772aa8889b3929308f71f50d5b17cf3b Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 11 Apr 2026 09:18:06 +0100 Subject: [PATCH 009/117] From memory, INSTALLDIR is special in some way, if I use a different property this should work --- release/wix/SettingsDlg.wxs | 2 +- release/wix/navidrome.wxs | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/release/wix/SettingsDlg.wxs b/release/wix/SettingsDlg.wxs index 4a83f91da..e47d6d0fe 100644 --- a/release/wix/SettingsDlg.wxs +++ b/release/wix/SettingsDlg.wxs @@ -16,7 +16,7 @@ - + diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 01f2bc22a..e21eaa718 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -21,8 +21,8 @@ - - + + @@ -47,12 +47,12 @@ - - - - + + + + - + @@ -68,7 +68,7 @@ Start='auto' Type='ownProcess' Vital='yes' - Arguments='service execute --configfile "[INSTALLDIR]navidrome.ini" --logfile "[ND_DATAFOLDER]\navidrome.log"' + Arguments='service execute --configfile "[INSTALLATIONDIRECTORY]navidrome.ini" --logfile "[ND_DATAFOLDER]\navidrome.log"' /> From 6a1c7557b8f69fde74656952602be905a3445f8f Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 11 Apr 2026 09:47:41 +0100 Subject: [PATCH 010/117] Wixl compilation is complaining about the INSTALLDIR for some reason so I'm going to restore the original pattern, even if it isn't used --- release/wix/navidrome.wxs | 76 ++++++++++++++++++++------------------- 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index e21eaa718..f8608b24c 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -37,49 +37,53 @@ + + - - - + + + - - - + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - + + + - - - + + + + + From dcd545ec33fa7b32f566366ba2a6afc6671539b6 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 11 Apr 2026 09:52:27 +0100 Subject: [PATCH 011/117] Need to restore this variable in order for this to compile --- release/wix/navidrome.wxs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index f8608b24c..8e7b79a0c 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -2,10 +2,12 @@ + + From b863b7be720876c6e4009ce527934cf7a430562c Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 11 Apr 2026 10:03:36 +0100 Subject: [PATCH 012/117] Doh, the DATADIR needs to be in the installdir, that's what it was erroring about. --- release/wix/navidrome.wxs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 8e7b79a0c..b0e919d45 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -23,9 +23,6 @@ - - - @@ -36,6 +33,10 @@ + + + + @@ -87,12 +88,11 @@ - - - - - - + + + + + From 2868be4fb91878e86d09464dedd3b0ed4ba385ed Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 11 Apr 2026 10:22:28 +0100 Subject: [PATCH 013/117] ProgramFiles6432Folder --- release/wix/navidrome.wxs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index b0e919d45..08806dd5f 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -33,7 +33,7 @@ - + From 98ab8f57c8f205ee0fa9d76d5917d788be39403d Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 11 Apr 2026 10:47:02 +0100 Subject: [PATCH 014/117] OK there doesn't seem to be anyway to get this to expand out that I can see, but we should be able to hijack the value from INSTALLDIR through the UI basically? --- release/wix/SettingsDlg.wxs | 2 +- release/wix/navidrome.wxs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/release/wix/SettingsDlg.wxs b/release/wix/SettingsDlg.wxs index e47d6d0fe..289e759c2 100644 --- a/release/wix/SettingsDlg.wxs +++ b/release/wix/SettingsDlg.wxs @@ -16,7 +16,7 @@ - + diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 08806dd5f..923a21cd2 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -32,8 +32,7 @@ - - + From bd60a6344f91067f954a42eefec2fb3e1e890abe Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 11 Apr 2026 10:52:30 +0100 Subject: [PATCH 015/117] OK that blows up with unable to access network location UNSET I didn't think it would check for existance on the first pass but presumably it does. Trying leaving it blank --- release/wix/navidrome.wxs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 923a21cd2..6d15f7582 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -32,7 +32,7 @@ - + From 9ac890069eb3fbee341894c893c38cc3ed18eaf0 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 11 Apr 2026 11:01:09 +0100 Subject: [PATCH 016/117] Just realised I'm chasing a poisson-rouge, I think I canjust use INSTALLDIR and it'll be fine? --- release/wix/SettingsDlg.wxs | 2 +- release/wix/navidrome.wxs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/release/wix/SettingsDlg.wxs b/release/wix/SettingsDlg.wxs index 289e759c2..4a83f91da 100644 --- a/release/wix/SettingsDlg.wxs +++ b/release/wix/SettingsDlg.wxs @@ -16,7 +16,7 @@ - + diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 6d15f7582..9ea1ef65a 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -32,8 +32,8 @@ - - + + From 8bc5a8762ebd61373d6209b4df1e53ce17e0d0f1 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 11 Apr 2026 11:02:09 +0100 Subject: [PATCH 017/117] INSTALLATIONDIRECTORY -> INSTALLDIR --- release/wix/navidrome.wxs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 9ea1ef65a..6793889c1 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -51,12 +51,12 @@ - - - - + + + + - + @@ -72,7 +72,7 @@ Start='auto' Type='ownProcess' Vital='yes' - Arguments='service execute --configfile "[INSTALLATIONDIRECTORY]navidrome.ini" --logfile "[ND_DATAFOLDER]\navidrome.log"' + Arguments='service execute --configfile "[INSTALLDIR]navidrome.ini" --logfile "[ND_DATAFOLDER]\navidrome.log"' /> From 4c7a0657f95803cdc5a3aa6a695ca193a26d3f59 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Sat, 11 Apr 2026 19:20:19 +0100 Subject: [PATCH 018/117] OK this was half working, but on upgrade; the INSTALLDIR is set from the Directory tags and the dynamic pathing, so we need to use a different thing on upgrade. I think our only option is to be able to copy the values between the two when we need with custom actions. --- release/wix/navidrome.wxs | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 6793889c1..ca013d5ad 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -32,8 +32,8 @@ - - + + @@ -51,12 +51,12 @@ - - - - + + + + - + @@ -72,7 +72,7 @@ Start='auto' Type='ownProcess' Vital='yes' - Arguments='service execute --configfile "[INSTALLDIR]navidrome.ini" --logfile "[ND_DATAFOLDER]\navidrome.log"' + Arguments='service execute --configfile "[INSTALLATIONDIRECTORY]navidrome.ini" --logfile "[ND_DATAFOLDER]\navidrome.log"' /> @@ -98,6 +98,11 @@ Not Installed AND NOT WIX_UPGRADE_DETECTED + + + Not Installed AND NOT WIX_UPGRADE_DETECTED + + From 36380715cdde80d5347ba1a178f2027a46b8884e Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Mon, 13 Apr 2026 17:05:56 +0100 Subject: [PATCH 019/117] Can we get away with default sequencing here? --- release/wix/navidrome.wxs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index ca013d5ad..2227ed9b4 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -98,7 +98,7 @@ Not Installed AND NOT WIX_UPGRADE_DETECTED - + Not Installed AND NOT WIX_UPGRADE_DETECTED From 9c72f8acefb3975417842de7d8970c63e976fc3b Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Mon, 13 Apr 2026 18:02:49 +0100 Subject: [PATCH 020/117] OK this blows up on upgrade, but the install works fine. Presumably this is because INSTALLDIR isn't being set on upgrade at any point so can we switch this directory so we only use INSTALLDIR in the UI? --- release/wix/navidrome.wxs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index 2227ed9b4..e5d92a3cd 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -40,7 +40,7 @@ - + @@ -100,7 +100,7 @@ - Not Installed AND NOT WIX_UPGRADE_DETECTED + Not Installed AND NOT WIX_UPGRADE_DETECTED From d90fa8158cbc11814ee6a536a3490481ee7a3c34 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Mon, 13 Apr 2026 18:22:20 +0100 Subject: [PATCH 021/117] So on fresh install, the UI was empty because INSTALLDIR wasn't pre populated by the Directory tags, also on upgrade it default back... --- release/wix/navidrome.wxs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index e5d92a3cd..faf0216a6 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -40,8 +40,9 @@ - - + + + @@ -100,7 +101,7 @@ - Not Installed AND NOT WIX_UPGRADE_DETECTED + Not Installed AND NOT WIX_UPGRADE_DETECTED From 8d5e909c132f3ccb839f662a31292e648aeac150 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Mon, 13 Apr 2026 18:37:18 +0100 Subject: [PATCH 022/117] Missed extra --- release/wix/build_msi.sh | 2 +- release/wix/navidrome.wxs | 76 +++++++++++++++++++-------------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/release/wix/build_msi.sh b/release/wix/build_msi.sh index a8781a965..af934fb85 100755 --- a/release/wix/build_msi.sh +++ b/release/wix/build_msi.sh @@ -40,7 +40,7 @@ fi # Download static compiled ffmpeg for Windows FFMPEG_FILE="ffmpeg-n${FFMPEG_VERSION}-latest-${WIN_ARCH}-gpl-${FFMPEG_VERSION}" -wget --quiet --output-document="${DOWNLOAD_FOLDER}/ffmpeg.zip" \ +[[ ! -e "${DOWNLOAD_FOLDER}/ffmpeg.zip" ]] && wget --quiet --output-document="${DOWNLOAD_FOLDER}/ffmpeg.zip" \ "https://github.com/${FFMPEG_REPOSITORY}/releases/download/latest/${FFMPEG_FILE}.zip" rm -rf "${DOWNLOAD_FOLDER}/extracted_ffmpeg" unzip -d "${DOWNLOAD_FOLDER}/extracted_ffmpeg" "${DOWNLOAD_FOLDER}/ffmpeg.zip" "*/ffmpeg.exe" "*/ffprobe.exe" diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs index faf0216a6..a5120a446 100644 --- a/release/wix/navidrome.wxs +++ b/release/wix/navidrome.wxs @@ -40,52 +40,52 @@ - + + - - - + + + - - - + + + - - - - - + + + + + - - - - - + + + + + - - - - - + + + + + - - - + + + - - - - + + + From 1f309efb1808ba1898dc58800be92e44ed151fa7 Mon Sep 17 00:00:00 2001 From: Rob Emery Date: Mon, 13 Apr 2026 18:38:31 +0100 Subject: [PATCH 023/117] Need to work with sh not bash --- release/wix/build_msi.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release/wix/build_msi.sh b/release/wix/build_msi.sh index af934fb85..119abc8c5 100755 --- a/release/wix/build_msi.sh +++ b/release/wix/build_msi.sh @@ -40,7 +40,7 @@ fi # Download static compiled ffmpeg for Windows FFMPEG_FILE="ffmpeg-n${FFMPEG_VERSION}-latest-${WIN_ARCH}-gpl-${FFMPEG_VERSION}" -[[ ! -e "${DOWNLOAD_FOLDER}/ffmpeg.zip" ]] && wget --quiet --output-document="${DOWNLOAD_FOLDER}/ffmpeg.zip" \ +[ ! -e "${DOWNLOAD_FOLDER}/ffmpeg.zip" ] && wget --quiet --output-document="${DOWNLOAD_FOLDER}/ffmpeg.zip" \ "https://github.com/${FFMPEG_REPOSITORY}/releases/download/latest/${FFMPEG_FILE}.zip" rm -rf "${DOWNLOAD_FOLDER}/extracted_ffmpeg" unzip -d "${DOWNLOAD_FOLDER}/extracted_ffmpeg" "${DOWNLOAD_FOLDER}/ffmpeg.zip" "*/ffmpeg.exe" "*/ffprobe.exe" From 987b16079aaf64c6965d01f595d9f41bd43d37cf Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 22 Apr 2026 20:11:17 -0400 Subject: [PATCH 024/117] fix(makefile): adjust PATH order for golangci-lint installation and linting Signed-off-by: Deluan --- Makefile | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Makefile b/Makefile index 4efbc4db6..ad96afd31 100644 --- a/Makefile +++ b/Makefile @@ -75,8 +75,8 @@ test-i18n: ##@Development Validate all translations files install-golangci-lint: ##@Development Install golangci-lint if not present @INSTALL=false; \ - if PATH=$$PATH:./bin which golangci-lint > /dev/null 2>&1; then \ - CURRENT_VERSION=$$(PATH=$$PATH:./bin golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \ + if PATH=./bin:$$PATH which golangci-lint > /dev/null 2>&1; then \ + CURRENT_VERSION=$$(PATH=./bin:$$PATH golangci-lint version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1); \ REQUIRED_VERSION=$$(echo "$(GOLANGCI_LINT_VERSION)" | sed 's/^v//'); \ if [ "$$CURRENT_VERSION" != "$$REQUIRED_VERSION" ]; then \ echo "Found golangci-lint $$CURRENT_VERSION, but $$REQUIRED_VERSION is required. Reinstalling..."; \ @@ -93,7 +93,7 @@ install-golangci-lint: ##@Development Install golangci-lint if not present .PHONY: install-golangci-lint lint: install-golangci-lint ##@Development Lint Go code - PATH=$$PATH:./bin golangci-lint run --timeout 5m + PATH=./bin:$$PATH golangci-lint run --timeout 5m .PHONY: lint lintall: lint ##@Development Lint Go and JS code From f784224aefab7368a6738a243a535818b9e52122 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 23 Apr 2026 17:53:28 -0400 Subject: [PATCH 025/117] fix: split html sanitization from plaintext handling (#5403) * fix: split html sanitization from plaintext handling Add a dedicated SanitizeHTML helper for HTML-rendered values so entity-encoded markup is decoded before bluemonday sanitization. Use the new helper for the login welcome message and artist biographies while preserving SanitizeText semantics for lyrics and other plaintext callers. Add regression coverage for both helpers and the serveIndex welcomeMessage path. * docs: add SanitizeText and SanitizeHTML godoc Signed-off-by: Deluan * fix: preserve plain text in artist biographies Revert artist biography storage to SanitizeText so entity-encoded plain text remains decoded for Subsonic consumers. This avoids double-escaping values like R&B in XML responses while keeping the new welcomeMessage HTML sanitization in place, and adds a regression test covering the biography storage behavior. --------- Signed-off-by: Deluan --- .../provider_updateartistinfo_test.go | 23 +++++++++++++++ server/serve_index.go | 2 +- server/serve_index_test.go | 12 ++++++++ utils/str/sanitize_strings.go | 9 ++++++ utils/str/sanitize_strings_test.go | 29 +++++++++++++++++++ 5 files changed, 74 insertions(+), 1 deletion(-) diff --git a/core/external/provider_updateartistinfo_test.go b/core/external/provider_updateartistinfo_test.go index e309ece6e..cc9506d1f 100644 --- a/core/external/provider_updateartistinfo_test.go +++ b/core/external/provider_updateartistinfo_test.go @@ -105,6 +105,29 @@ var _ = Describe("Provider - UpdateArtistInfo", func() { ag.AssertExpectations(GinkgoT()) }) + It("preserves decoded plain text in biography storage", func() { + originalArtist := &model.Artist{ + ID: "ar-encoded-bio", + Name: "Encoded Bio Artist", + } + mockArtistRepo.SetData(model.Artists{*originalArtist}) + + expectedMBID := "mbid-encoded-bio" + expectedBio := "R&B" + + ag.On("GetArtistMBID", ctx, "ar-encoded-bio", "Encoded Bio Artist").Return(expectedMBID, nil).Once() + ag.On("GetArtistImages", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(nil, nil).Maybe() + ag.On("GetArtistBiography", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return(expectedBio, nil).Once() + ag.On("GetArtistURL", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID).Return("", nil).Maybe() + ag.On("GetSimilarArtists", ctx, "ar-encoded-bio", "Encoded Bio Artist", expectedMBID, 100).Return(nil, nil).Maybe() + + updatedArtist, err := p.UpdateArtistInfo(ctx, "ar-encoded-bio", 10, false) + + Expect(err).NotTo(HaveOccurred()) + Expect(updatedArtist).NotTo(BeNil()) + Expect(updatedArtist.Biography).To(Equal("R&B")) + }) + It("returns cached info when artist exists and info is not expired", func() { now := time.Now() originalArtist := &model.Artist{ diff --git a/server/serve_index.go b/server/serve_index.go index bd5be44f5..734aabc70 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -45,7 +45,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "variousArtistsId": consts.VariousArtistsID, "baseURL": str.SanitizeText(strings.TrimSuffix(conf.Server.BasePath, "/")), "loginBackgroundURL": str.SanitizeText(conf.Server.UILoginBackgroundURL), - "welcomeMessage": str.SanitizeText(conf.Server.UIWelcomeMessage), + "welcomeMessage": str.SanitizeHTML(conf.Server.UIWelcomeMessage), "maxSidebarPlaylists": conf.Server.MaxSidebarPlaylists, "enableTranscodingConfig": conf.Server.EnableTranscodingConfig, "enableDownloads": conf.Server.EnableDownloads, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 7515e7276..31bca02cf 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -108,6 +108,18 @@ var _ = Describe("serveIndex", func() { Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"), ) + It("sanitizes entity-encoded welcomeMessage as html", func() { + conf.Server.UIWelcomeMessage = `<img src=x onerror=alert(1)><b>Hello</b>` + r := httptest.NewRequest("GET", "/index.html", nil) + w := httptest.NewRecorder() + + serveIndex(ds, fs, nil)(w, r) + + config := extractAppConfig(w.Body.String()) + Expect(config).To(HaveKey("welcomeMessage")) + Expect(config["welcomeMessage"]).To(Equal(`Hello`)) + }) + DescribeTable("sets other UI configuration values", func(configKey string, expectedValueFunc func() any) { r := httptest.NewRequest("GET", "/index.html", nil) diff --git a/utils/str/sanitize_strings.go b/utils/str/sanitize_strings.go index 73608112e..c121aefe7 100644 --- a/utils/str/sanitize_strings.go +++ b/utils/str/sanitize_strings.go @@ -38,11 +38,20 @@ func SanitizeStrings(text ...string) string { var policy = bluemonday.UGCPolicy() +// SanitizeText unescapes the input string before sanitizing it as text. +// This should be used for fields rendered as plain text in the UI (e.g. lyrics, song titles, artist names) func SanitizeText(text string) string { s := policy.Sanitize(text) return html.UnescapeString(s) } +// SanitizeHTML unescapes the input string before sanitizing it as HTML. +// This should be used for fields rendered as HTML by clients (e.g. biographies, welcome messages) +// to prevent XSS bypasses via entity-encoded tags. +func SanitizeHTML(text string) string { + return policy.Sanitize(html.UnescapeString(text)) +} + func SanitizeFieldForSorting(originalValue string) string { v := strings.TrimSpace(sanitize.Accents(originalValue)) return Clear(strings.ToLower(v)) diff --git a/utils/str/sanitize_strings_test.go b/utils/str/sanitize_strings_test.go index ac28fe435..6527f326b 100644 --- a/utils/str/sanitize_strings_test.go +++ b/utils/str/sanitize_strings_test.go @@ -64,6 +64,35 @@ var _ = Describe("Sanitize Strings", func() { }) }) + Describe("SanitizeText", func() { + It("preserves decoded plaintext", func() { + Expect(str.SanitizeText("Tom & Jerry")).To(Equal("Tom & Jerry")) + Expect(str.SanitizeText("Tom & Jerry")).To(Equal("Tom & Jerry")) + }) + + It("keeps entity-encoded html readable", func() { + Expect(str.SanitizeText(`<b>ok</b>`)).To(Equal("ok")) + }) + }) + + Describe("SanitizeHTML", func() { + It("removes dangerous content from raw html", func() { + sanitized := str.SanitizeHTML(`ok`) + + Expect(sanitized).To(ContainSubstring("ok")) + Expect(sanitized).ToNot(ContainSubstring("onerror")) + Expect(sanitized).ToNot(ContainSubstring("ok")) + Expect(sanitized).ToNot(ContainSubstring("onerror")) + Expect(sanitized).ToNot(ContainSubstring(" Date: Fri, 24 Apr 2026 23:03:10 -0400 Subject: [PATCH 026/117] test(smartplaylists): add smart playlist e2e test suite (#5409) * test: add smart playlist e2e suite infrastructure * test: add string field smart playlist e2e tests * test: add numeric, boolean, tag, participant, annotation, logic, sorting smart playlist e2e tests * test: add playlist operator smart playlist e2e tests * test: add isNot and endsWith string field e2e tests * test: add date/time field smart playlist e2e tests * fix: add gosec nolint directives for safe SQL concatenation in e2e restore * refactor: address code review feedback for smart playlist e2e tests - Deduplicate evaluateRule by delegating to evaluateRuleOrdered - Cache table list in BeforeSuite instead of querying sqlite_master per test - Wrap restoreDB in a transaction with defer cleanup for DETACH/foreign_keys - Use JSON numbers for numeric criteria values to match canonical JSON shape * refactor: simplify e2e test infrastructure - Remove unused return value from buildTestFS - Add deferred ROLLBACK as safety net in restoreDB transaction - Cache Come Together ID to avoid repeated lookups in BeforeSuite - Use range-over-int for play count loop * test: add missing operator coverage to smart playlist e2e tests Add 4 tests for operators/paths that had no e2e coverage: - notContains on string fields (LIKE negation path) - before on date fields (Lt for dates, only after was tested) - startsWith on tag fields (json_tree + LIKE subquery) - endsWith on participant/role fields (json_tree + LIKE subquery) --- core/playlists/e2e/e2e_suite_test.go | 326 +++++++++++++++++++++++ core/playlists/e2e/smartplaylist_test.go | 290 ++++++++++++++++++++ 2 files changed, 616 insertions(+) create mode 100644 core/playlists/e2e/e2e_suite_test.go create mode 100644 core/playlists/e2e/smartplaylist_test.go diff --git a/core/playlists/e2e/e2e_suite_test.go b/core/playlists/e2e/e2e_suite_test.go new file mode 100644 index 000000000..e9a717d0a --- /dev/null +++ b/core/playlists/e2e/e2e_suite_test.go @@ -0,0 +1,326 @@ +package e2e + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sort" + "testing" + "testing/fstest" + "time" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/storage/storagetest" + "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/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSmartPlaylistE2E(t *testing.T) { + tests.Init(t, false) + defer db.Close(t.Context()) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Smart Playlist E2E Suite") +} + +type _t = map[string]any + +var template = storagetest.Template +var track = storagetest.Track + +var ( + ctx context.Context + ds *tests.MockDataStore + lib model.Library + + dbFilePath string + snapshotPath string + snapshotTables []string + + testUser = model.User{ + ID: "sp-test-user-1", + UserName: "sptestuser", + Name: "SP Test User", + IsAdmin: true, + } + + otherUser = model.User{ + ID: "sp-test-user-2", + UserName: "spotheruser", + Name: "SP Other User", + IsAdmin: false, + } +) + +func buildTestFS() { + abbeyRoad := template(_t{ + "albumartist": "The Beatles", + "artist": "The Beatles", + "album": "Abbey Road", + "year": 1969, + "genre": "Rock;Blues", + }) + ledZepIV := template(_t{ + "albumartist": "Led Zeppelin", + "artist": "Led Zeppelin", + "album": "IV", + "year": 1971, + }) + kindOfBlue := template(_t{ + "albumartist": "Miles Davis", + "artist": "Miles Davis", + "album": "Kind of Blue", + "year": 1959, + "genre": "Jazz", + "composer": "Miles Davis", + }) + nightAtOpera := template(_t{ + "albumartist": "Queen", + "artist": "Queen", + "album": "A Night at the Opera", + "year": 1975, + "genre": "Rock", + }) + electricLadyland := template(_t{ + "albumartist": "Jimi Hendrix", + "artist": "Jimi Hendrix", + "album": "Electric Ladyland", + "year": 1968, + "genre": "Rock;Blues", + }) + newsOfWorld := template(_t{ + "albumartist": "Queen", + "artist": "Queen", + "album": "News of the World", + "year": 1977, + "genre": "Rock;Pop", + "compilation": "1", + }) + + fs := storagetest.FakeFS{} + fs.SetFiles(fstest.MapFS{ + "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together", + _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120})), + "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", + _t{"genre": "Rock", "composer": "Harrison", "bpm": 100})), + "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.flac": ledZepIV(track(1, "Stairway To Heaven", + _t{"genre": "Rock;Folk", "composer": "Page/Plant", "bpm": 82, "suffix": "flac", + "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), + "Rock/Led Zeppelin/IV/02 - Black Dog.flac": ledZepIV(track(2, "Black Dog", + _t{"genre": "Rock;Blues", "composer": "Page/Plant/Jones", "bpm": 150, "suffix": "flac", + "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), + "Jazz/Miles Davis/Kind of Blue/01 - So What.mp3": kindOfBlue(track(1, "So What", + _t{"bpm": 136})), + "Rock/Queen/A Night at the Opera/01 - Bohemian Rhapsody.mp3": nightAtOpera(track(1, "Bohemian Rhapsody", + _t{"composer": "Freddie Mercury", "bpm": 72})), + "Rock/Jimi Hendrix/Electric Ladyland/01 - All Along the Watchtower.mp3": electricLadyland(track(1, "All Along the Watchtower", + _t{"composer": "Bob Dylan", "bpm": 112})), + "Rock/Queen/News of the World/01 - We Are the Champions.mp3": newsOfWorld(track(1, "We Are the Champions", + _t{"composer": "Freddie Mercury", "bpm": 64})), + }) + storagetest.Register("fake", &fs) +} + +func findMediaFileByTitle(title string) string { + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"media_file.title": title}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(mfs).To(HaveLen(1), "expected exactly one media file with title %q", title) + return mfs[0].ID +} + +func evaluateRule(jsonRule string) []string { + titles := evaluateRuleOrdered(jsonRule) + sort.Strings(titles) + return titles +} + +func evaluateRuleOrdered(jsonRule string) []string { + var rules criteria.Criteria + err := json.Unmarshal([]byte(jsonRule), &rules) + Expect(err).ToNot(HaveOccurred(), "invalid criteria JSON: %s", jsonRule) + + pls := &model.Playlist{ + Name: "test-smart-playlist", + OwnerID: testUser.ID, + Rules: &rules, + } + err = ds.Playlist(ctx).Put(pls) + Expect(err).ToNot(HaveOccurred()) + + loaded, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + titles := make([]string, len(loaded.Tracks)) + for i, t := range loaded.Tracks { + titles[i] = t.Title + } + return titles +} + +func createPlaylist(owner model.User, public bool, titles ...string) string { + pls := &model.Playlist{ + Name: "ref-playlist", + OwnerID: owner.ID, + Public: public, + } + for _, title := range titles { + mfID := findMediaFileByTitle(title) + pls.AddMediaFilesByID([]string{mfID}) + } + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + return pls.ID +} + +func createPublicPlaylist(owner model.User, titles ...string) string { + return createPlaylist(owner, true, titles...) +} + +func createPrivatePlaylist(owner model.User, titles ...string) string { + return createPlaylist(owner, false, titles...) +} + +func createPublicSmartPlaylist(owner model.User, jsonRule string) string { + var rules criteria.Criteria + Expect(json.Unmarshal([]byte(jsonRule), &rules)).To(Succeed()) + pls := &model.Playlist{ + Name: "ref-smart-playlist", + OwnerID: owner.ID, + Public: true, + Rules: &rules, + } + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + return pls.ID +} + +var _ = BeforeSuite(func() { + ctx = request.WithUser(GinkgoT().Context(), testUser) + tmpDir := GinkgoT().TempDir() + dbFilePath = filepath.Join(tmpDir, "smartplaylist-e2e.db") + snapshotPath = filepath.Join(tmpDir, "smartplaylist-e2e.db.snapshot") + conf.Server.DbPath = dbFilePath + "?_journal_mode=WAL" + db.Db().SetMaxOpenConns(1) + + conf.Server.MusicFolder = "fake:///music" + conf.Server.DevExternalScanner = false + conf.Server.SmartPlaylistRefreshDelay = 0 + + db.Init(ctx) + + initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + userWithPass := testUser + userWithPass.NewPassword = "password" + Expect(initDS.User(ctx).Put(&userWithPass)).To(Succeed()) + + otherUserWithPass := otherUser + otherUserWithPass.NewPassword = "password" + Expect(initDS.User(ctx).Put(&otherUserWithPass)).To(Succeed()) + + lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} + Expect(initDS.Library(ctx).Put(&lib)).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(testUser.ID, []int{lib.ID})).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(otherUser.ID, []int{lib.ID})).To(Succeed()) + + loadedUser, err := initDS.User(ctx).FindByUsername(testUser.UserName) + Expect(err).ToNot(HaveOccurred()) + testUser.Libraries = loadedUser.Libraries + + loadedOther, err := initDS.User(ctx).FindByUsername(otherUser.UserName) + Expect(err).ToNot(HaveOccurred()) + otherUser.Libraries = loadedOther.Libraries + + ctx = request.WithUser(GinkgoT().Context(), testUser) + + buildTestFS() + s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(initDS, core.NewImageUploadService()), metrics.NewNoopInstance()) + _, err = s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + comeTogetherID := findMediaFileByTitle("Come Together") + Expect(ds.MediaFile(ctx).SetStar(true, comeTogetherID)).To(Succeed()) + Expect(ds.MediaFile(ctx).SetStar(true, findMediaFileByTitle("So What"))).To(Succeed()) + Expect(ds.MediaFile(ctx).SetRating(3, findMediaFileByTitle("Stairway To Heaven"))).To(Succeed()) + Expect(ds.MediaFile(ctx).SetRating(5, findMediaFileByTitle("Bohemian Rhapsody"))).To(Succeed()) + for range 10 { + Expect(ds.MediaFile(ctx).IncPlayCount(comeTogetherID, time.Now())).To(Succeed()) + } + Expect(ds.MediaFile(ctx).IncPlayCount(findMediaFileByTitle("Black Dog"), time.Now())).To(Succeed()) + + rows, err := db.Db().Query("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '%_fts' AND name NOT LIKE '%_fts_%'") + Expect(err).ToNot(HaveOccurred()) + defer rows.Close() + for rows.Next() { + var name string + Expect(rows.Scan(&name)).To(Succeed()) + snapshotTables = append(snapshotTables, name) + } + Expect(rows.Err()).ToNot(HaveOccurred()) + + _, err = db.Db().Exec("PRAGMA wal_checkpoint(TRUNCATE)") + Expect(err).ToNot(HaveOccurred()) + data, err := os.ReadFile(dbFilePath) + Expect(err).ToNot(HaveOccurred()) + Expect(os.WriteFile(snapshotPath, data, 0600)).To(Succeed()) +}) + +var _ = AfterSuite(func() { + db.Close(ctx) +}) + +func restoreDB() { + sqlDB := db.Db() + + _, err := sqlDB.Exec("PRAGMA foreign_keys = OFF") + Expect(err).ToNot(HaveOccurred()) + defer func() { _, _ = sqlDB.Exec("PRAGMA foreign_keys = ON") }() + + _, err = sqlDB.Exec("ATTACH DATABASE ? AS snapshot", snapshotPath) + Expect(err).ToNot(HaveOccurred()) + defer func() { _, _ = sqlDB.Exec("DETACH DATABASE snapshot") }() + + _, err = sqlDB.Exec("BEGIN TRANSACTION") + Expect(err).ToNot(HaveOccurred()) + defer func() { _, _ = sqlDB.Exec("ROLLBACK") }() + + for _, table := range snapshotTables { + _, err = sqlDB.Exec(`DELETE FROM main."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + _, err = sqlDB.Exec(`INSERT INTO main."` + table + `" SELECT * FROM snapshot."` + table + `"`) //nolint:gosec + Expect(err).ToNot(HaveOccurred()) + } + + _, err = sqlDB.Exec("COMMIT") + Expect(err).ToNot(HaveOccurred()) +} + +func setupTestDB() { + ctx = request.WithUser(GinkgoT().Context(), testUser) + DeferCleanup(configtest.SetupConfig()) + conf.Server.MusicFolder = "fake:///music" + conf.Server.DevExternalScanner = false + conf.Server.SmartPlaylistRefreshDelay = 0 + + restoreDB() + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} +} diff --git a/core/playlists/e2e/smartplaylist_test.go b/core/playlists/e2e/smartplaylist_test.go new file mode 100644 index 000000000..6e31c6787 --- /dev/null +++ b/core/playlists/e2e/smartplaylist_test.go @@ -0,0 +1,290 @@ +package e2e + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Smart Playlists", func() { + BeforeEach(func() { + setupTestDB() + }) + + Describe("String fields", func() { + It("matches by exact title", func() { + results := evaluateRule(`{"all":[{"is":{"title":"Something"}}]}`) + Expect(results).To(ConsistOf("Something")) + }) + + It("matches by title contains", func() { + results := evaluateRule(`{"all":[{"contains":{"title":"the"}}]}`) + Expect(results).To(ConsistOf("Come Together", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches by artist startsWith", func() { + results := evaluateRule(`{"all":[{"startsWith":{"artist":"Led"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog")) + }) + + It("matches by title isNot", func() { + results := evaluateRule(`{"all":[{"isNot":{"title":"Something"}},{"is":{"artist":"The Beatles"}}]}`) + Expect(results).To(ConsistOf("Come Together")) + }) + + It("matches by artist endsWith", func() { + results := evaluateRule(`{"all":[{"endsWith":{"artist":"Davis"}}]}`) + Expect(results).To(ConsistOf("So What")) + }) + }) + + Describe("Numeric fields", func() { + It("matches by year greater than", func() { + results := evaluateRule(`{"all":[{"gt":{"year":1970}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "We Are the Champions")) + }) + + It("matches by year less than", func() { + results := evaluateRule(`{"all":[{"lt":{"year":1969}}]}`) + Expect(results).To(ConsistOf("So What", "All Along the Watchtower")) + }) + + It("matches by BPM in range", func() { + results := evaluateRule(`{"all":[{"inTheRange":{"bpm":[100,130]}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "All Along the Watchtower")) + }) + }) + + Describe("Boolean fields", func() { + It("matches compilations", func() { + results := evaluateRule(`{"all":[{"is":{"compilation":true}}]}`) + Expect(results).To(ConsistOf("We Are the Champions")) + }) + + It("matches non-compilations", func() { + results := evaluateRule(`{"all":[{"is":{"compilation":false}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody", "All Along the Watchtower")) + }) + }) + + Describe("File type fields", func() { + It("matches by filetype", func() { + results := evaluateRule(`{"all":[{"is":{"filetype":"flac"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog")) + }) + }) + + Describe("Multi-valued tags", func() { + It("matches tracks with Blues genre", func() { + results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Black Dog", "All Along the Watchtower")) + }) + + It("excludes tracks with Rock genre", func() { + results := evaluateRule(`{"all":[{"isNot":{"genre":"Rock"}}]}`) + Expect(results).To(ConsistOf("So What")) + }) + + It("matches genre contains", func() { + results := evaluateRule(`{"all":[{"contains":{"genre":"ol"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven")) + }) + + It("matches tracks with Pop genre", func() { + results := evaluateRule(`{"all":[{"is":{"genre":"Pop"}}]}`) + Expect(results).To(ConsistOf("We Are the Champions")) + }) + + It("matches genre startsWith", func() { + results := evaluateRule(`{"all":[{"startsWith":{"genre":"Ro"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + }) + + Describe("Participants", func() { + It("matches by exact composer", func() { + results := evaluateRule(`{"all":[{"is":{"composer":"Harrison"}}]}`) + Expect(results).To(ConsistOf("Something")) + }) + + It("matches by composer contains", func() { + results := evaluateRule(`{"all":[{"contains":{"composer":"Plant"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog")) + }) + + It("matches by composer isNot", func() { + results := evaluateRule(`{"all":[{"isNot":{"composer":"Freddie Mercury"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "All Along the Watchtower")) + }) + + It("matches by composer endsWith", func() { + results := evaluateRule(`{"all":[{"endsWith":{"composer":"Mercury"}}]}`) + Expect(results).To(ConsistOf("Bohemian Rhapsody", "We Are the Champions")) + }) + }) + + Describe("Annotations", func() { + It("matches starred tracks", func() { + results := evaluateRule(`{"all":[{"is":{"loved":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "So What")) + }) + + It("matches unstarred tracks", func() { + results := evaluateRule(`{"all":[{"is":{"loved":false}}]}`) + Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches by rating greater than", func() { + results := evaluateRule(`{"all":[{"gt":{"rating":3}}]}`) + Expect(results).To(ConsistOf("Bohemian Rhapsody")) + }) + + It("matches by rating greater than or equal via inTheRange", func() { + results := evaluateRule(`{"all":[{"inTheRange":{"rating":[3,5]}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Bohemian Rhapsody")) + }) + + It("matches by play count greater than", func() { + results := evaluateRule(`{"all":[{"gt":{"playcount":5}}]}`) + Expect(results).To(ConsistOf("Come Together")) + }) + + It("matches by play count greater than zero", func() { + results := evaluateRule(`{"all":[{"gt":{"playcount":0}}]}`) + Expect(results).To(ConsistOf("Come Together", "Black Dog")) + }) + }) + + Describe("Negated string operators", func() { + It("matches by title notContains", func() { + results := evaluateRule(`{"all":[{"notContains":{"title":"the"}}]}`) + Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody")) + }) + }) + + Describe("Date/time fields", func() { + It("matches dateAdded before a far-future date", func() { + results := evaluateRule(`{"all":[{"before":{"dateadded":"2099-01-01"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches lastPlayed inTheLast 1 day", func() { + results := evaluateRule(`{"all":[{"inTheLast":{"lastplayed":1}}]}`) + Expect(results).To(ConsistOf("Come Together", "Black Dog")) + }) + + It("matches lastPlayed notInTheLast (far future)", func() { + results := evaluateRule(`{"all":[{"notInTheLast":{"lastplayed":99999}}]}`) + Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("matches dateLoved after a past date", func() { + results := evaluateRule(`{"all":[{"after":{"dateloved":"2020-01-01"}}]}`) + Expect(results).To(ConsistOf("Come Together", "So What")) + }) + + It("matches dateRated after a past date", func() { + results := evaluateRule(`{"all":[{"after":{"daterated":"2020-01-01"}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Bohemian Rhapsody")) + }) + + It("matches dateAdded inTheLast 1 day", func() { + results := evaluateRule(`{"all":[{"inTheLast":{"dateadded":1}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + }) + + Describe("Logic operators", func() { + It("matches with ALL (AND)", func() { + results := evaluateRule(`{"all":[{"is":{"genre":"Blues"}},{"gt":{"bpm":130}}]}`) + Expect(results).To(ConsistOf("Black Dog")) + }) + + It("matches with ANY (OR)", func() { + results := evaluateRule(`{"any":[{"is":{"genre":"Jazz"}},{"is":{"compilation":true}}]}`) + Expect(results).To(ConsistOf("So What", "We Are the Champions")) + }) + + It("matches nested all/any", func() { + results := evaluateRule(`{"all":[{"any":[{"is":{"genre":"Blues"}},{"is":{"genre":"Jazz"}}]},{"gt":{"year":1960}}]}`) + Expect(results).To(ConsistOf("Come Together", "Black Dog", "All Along the Watchtower")) + }) + }) + + Describe("Sorting and limits", func() { + It("returns tracks sorted by year descending with limit", func() { + results := evaluateRuleOrdered(`{"all":[{"gt":{"year":0}}],"sort":"year","order":"desc","limit":2}`) + Expect(results).To(Equal([]string{"We Are the Champions", "Bohemian Rhapsody"})) + }) + + It("returns tracks sorted by title ascending", func() { + results := evaluateRuleOrdered(`{"all":[{"is":{"genre":"Blues"}}],"sort":"title","order":"asc"}`) + Expect(results).To(Equal([]string{"All Along the Watchtower", "Black Dog", "Come Together"})) + }) + }) + + Describe("Combined real-world patterns", func() { + It("matches genre filter with exclusion and year range", func() { + results := evaluateRuleOrdered(`{ + "all":[ + {"any":[ + {"is":{"genre":"Blues"}}, + {"is":{"genre":"Folk"}} + ]}, + {"isNot":{"genre":"Jazz"}}, + {"gt":{"year":1965}} + ], + "sort":"-year,title" + }`) + Expect(results).To(Equal([]string{"Black Dog", "Stairway To Heaven", "Come Together", "All Along the Watchtower"})) + }) + }) + + Describe("Playlist operators", func() { + It("matches tracks in a public regular playlist", func() { + refID := createPublicPlaylist(testUser, "Come Together", "So What") + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + Expect(results).To(ConsistOf("Come Together", "So What")) + }) + + It("matches tracks not in a public regular playlist", func() { + refID := createPublicPlaylist(testUser, "Come Together", "So What") + results := evaluateRule(`{"all":[{"notInPlaylist":{"id":"` + refID + `"}}]}`) + Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("recursively refreshes a referenced smart playlist owned by the same user", func() { + smartBID := createPublicSmartPlaylist(testUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + smartBID + `"}}]}`) + Expect(results).To(ConsistOf("So What")) + }) + + It("does not refresh a referenced smart playlist owned by another user", func() { + smartBID := createPublicSmartPlaylist(otherUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + smartBID + `"}}]}`) + Expect(results).To(BeEmpty()) + }) + + It("does not match tracks from a private playlist", func() { + refID := createPrivatePlaylist(testUser, "Come Together", "So What") + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + Expect(results).To(BeEmpty()) + }) + + It("matches tracks in a public playlist owned by another user", func() { + refID := createPublicPlaylist(otherUser, "Bohemian Rhapsody") + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + Expect(results).To(ConsistOf("Bohemian Rhapsody")) + }) + + It("does not match tracks from a private playlist owned by another user", func() { + refID := createPrivatePlaylist(otherUser, "Bohemian Rhapsody") + results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + Expect(results).To(BeEmpty()) + }) + }) +}) From 989cf3f43e1bf5fa70e1a659e32a0928c2164b8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 24 Apr 2026 23:18:20 -0400 Subject: [PATCH 027/117] refactor: move smart playlist criteria SQL to persistence (#5408) * refactor: move criteria SQL generation to persistence Keep model/criteria as a domain DSL with JSON parsing, field metadata, expression traversal, and child playlist extraction only. Move smart playlist SQL translation, sort SQL, and join planning into persistence behind smartPlaylistCriteria so repository code uses a small query-building API. * refactor: simplify criteria translator metadata Use generic helper functions for criteria operator maps so the SQL translator can pass named criteria map types directly. Remove unused pseudo-field metadata from the criteria field API while preserving special field name lookup. * test: add coverage check for criteria-to-SQL field mappings Add a test that iterates all fields registered in the criteria package and verifies that every non-tag/non-role field has a corresponding entry in the persistence layer's smartPlaylistFields map. This prevents silent drift between the domain field registry and the SQL translation layer. Also adds an AllFieldNames() function to the criteria package to support field enumeration from outside the package. --- model/criteria/criteria.go | 114 +------ model/criteria/criteria_test.go | 209 +------------ model/criteria/fields.go | 368 +++++++--------------- model/criteria/fields_test.go | 57 +++- model/criteria/operators.go | 219 ++----------- model/criteria/operators_test.go | 177 ----------- model/criteria/walk.go | 72 +++++ model/criteria/walk_test.go | 64 ++++ persistence/criteria_sql.go | 487 +++++++++++++++++++++++++++++ persistence/criteria_sql_test.go | 170 ++++++++++ persistence/playlist_repository.go | 40 ++- 11 files changed, 1017 insertions(+), 960 deletions(-) create mode 100644 model/criteria/walk.go create mode 100644 model/criteria/walk_test.go create mode 100644 persistence/criteria_sql.go create mode 100644 persistence/criteria_sql_test.go diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 278acf34c..1e161c9f2 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -1,17 +1,16 @@ -// Package criteria implements a Criteria API based on Masterminds/squirrel +// Package criteria implements the smart playlist criteria DSL. package criteria import ( "encoding/json" "errors" - "fmt" - "strings" - "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" ) -type Expression = squirrel.Sqlizer +type Expression interface { + criteriaExpression() +} type Criteria struct { Expression @@ -49,115 +48,12 @@ func (c Criteria) IsPercentageLimit() bool { return c.Limit == 0 && c.LimitPercent > 0 && c.LimitPercent <= 100 } -func (c Criteria) OrderBy() string { - if c.Sort == "" { - c.Sort = "title" - } - - order := strings.ToLower(strings.TrimSpace(c.Order)) - if order != "" && order != "asc" && order != "desc" { - log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order) - order = "" - } - - parts := strings.Split(c.Sort, ",") - fields := make([]string, 0, len(parts)) - - for _, p := range parts { - p = strings.TrimSpace(p) - if p == "" { - continue - } - - dir := "asc" - if strings.HasPrefix(p, "+") || strings.HasPrefix(p, "-") { - if strings.HasPrefix(p, "-") { - dir = "desc" - } - p = strings.TrimSpace(p[1:]) - } - - sortField := strings.ToLower(p) - f := fieldMap[sortField] - if f == nil { - log.Error("Invalid field in 'sort' field", "sort", sortField) - continue - } - - var mapped string - - if f.order != "" { - mapped = f.order - } else if f.isTag { - // Use the actual field name (handles aliases like albumtype -> releasetype) - tagName := sortField - if f.field != "" { - tagName = f.field - } - mapped = "COALESCE(json_extract(media_file.tags, '$." + tagName + "[0].value'), '')" - } else if f.isRole { - mapped = "COALESCE(json_extract(media_file.participants, '$." + sortField + "[0].name'), '')" - } else { - mapped = f.field - } - if f.numeric { - mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped) - } - // If the global 'order' field is set to 'desc', reverse the default or field-specific sort direction. - // This ensures that the global order applies consistently across all fields. - if order == "desc" { - if dir == "asc" { - dir = "desc" - } else { - dir = "asc" - } - } - - fields = append(fields, mapped+" "+dir) - } - - return strings.Join(fields, ", ") -} - -func (c Criteria) ToSql() (sql string, args []any, err error) { - return c.Expression.ToSql() -} - -// ExpressionJoins returns only the JOINs needed by the WHERE-clause expression, -// excluding any JOINs required solely for sorting. This is useful for COUNT -// queries where sort order is irrelevant. -func (c Criteria) ExpressionJoins() JoinType { - if c.Expression == nil { - return JoinNone - } - return extractJoinTypes(c.Expression) -} - -// RequiredJoins inspects the expression tree and Sort field to determine which -// additional JOINs are needed when evaluating this criteria. -func (c Criteria) RequiredJoins() JoinType { - result := JoinNone - if c.Expression != nil { - result |= extractJoinTypes(c.Expression) - } - // Also check Sort fields - if c.Sort != "" { - for _, p := range strings.Split(c.Sort, ",") { - p = strings.TrimSpace(p) - p = strings.TrimLeft(p, "+-") - p = strings.TrimSpace(p) - result |= fieldJoinType(p) - } - } - return result -} - func (c Criteria) ChildPlaylistIds() []string { if c.Expression == nil { return nil } - if parent := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); parent != nil { + if parent, ok := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); ok { return parent.ChildPlaylistIds() } diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index a76b3fc1f..e0940a509 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -65,16 +65,6 @@ var _ = Describe("Criteria", func() { } jsonObj = b.String() }) - It("generates valid SQL", func() { - sql, args, err := goObj.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal( - `(media_file.title LIKE ? AND media_file.title NOT LIKE ? ` + - `AND (not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) ` + - `OR media_file.album = ?) AND (media_file.comment LIKE ? AND (media_file.year >= ? AND media_file.year <= ?) ` + - `AND not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?) AND COALESCE(album_annotation.rating, 0) > ?))`)) - gomega.Expect(args).To(gomega.HaveExactElements("%love%", "%hate%", "u2", "best of", "this%", 1980, 1990, "Rock", 3)) - }) It("marshals to JSON", func() { j, err := json.Marshal(goObj) gomega.Expect(err).ToNot(gomega.HaveOccurred()) @@ -88,201 +78,6 @@ var _ = Describe("Criteria", func() { gomega.Expect(err).ToNot(gomega.HaveOccurred()) gomega.Expect(string(j)).To(gomega.Equal(jsonObj)) }) - Describe("OrderBy", func() { - It("sorts by regular fields", func() { - gomega.Expect(goObj.OrderBy()).To(gomega.Equal("media_file.title asc")) - }) - - It("sorts by tag fields", func() { - goObj.Sort = "genre" - gomega.Expect(goObj.OrderBy()).To( - gomega.Equal( - "COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc", - ), - ) - }) - - It("sorts by role fields", func() { - goObj.Sort = "artist" - gomega.Expect(goObj.OrderBy()).To( - gomega.Equal( - "COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc", - ), - ) - }) - - It("casts numeric tags when sorting", func() { - AddTagNames([]string{"rate"}) - AddNumericTags([]string{"rate"}) - goObj.Sort = "rate" - gomega.Expect(goObj.OrderBy()).To( - gomega.Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc"), - ) - }) - - It("sorts by albumtype alias (resolves to releasetype)", func() { - AddTagNames([]string{"releasetype"}) - goObj.Sort = "albumtype" - gomega.Expect(goObj.OrderBy()).To( - gomega.Equal( - "COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc", - ), - ) - }) - - It("sorts by random", func() { - newObj := goObj - newObj.Sort = "random" - gomega.Expect(newObj.OrderBy()).To(gomega.Equal("random() asc")) - }) - - It("sorts by multiple fields", func() { - goObj.Sort = "title,-rating" - gomega.Expect(goObj.OrderBy()).To(gomega.Equal( - "media_file.title asc, COALESCE(annotation.rating, 0) desc", - )) - }) - - It("reverts order when order is desc", func() { - goObj.Sort = "-date,artist" - goObj.Order = "desc" - gomega.Expect(goObj.OrderBy()).To(gomega.Equal( - "media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc", - )) - }) - - It("ignores invalid sort fields", func() { - goObj.Sort = "bogus,title" - gomega.Expect(goObj.OrderBy()).To(gomega.Equal( - "media_file.title asc", - )) - }) - }) - }) - - Context("with artist roles", func() { - BeforeEach(func() { - goObj = Criteria{ - Expression: All{ - Is{"artist": "The Beatles"}, - Contains{"composer": "Lennon"}, - }, - } - }) - - It("generates valid SQL", func() { - sql, args, err := goObj.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal( - `(exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?) AND ` + - `exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?))`, - )) - gomega.Expect(args).To(gomega.HaveExactElements("The Beatles", "%Lennon%")) - }) - }) - - Describe("ExpressionJoins", func() { - It("excludes sort-only joins", func() { - c := Criteria{ - Expression: All{ - Contains{"title": "love"}, - }, - Sort: "albumRating", - } - gomega.Expect(c.ExpressionJoins()).To(gomega.Equal(JoinNone)) - gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - }) - - It("includes expression-based joins", func() { - c := Criteria{ - Expression: All{ - Gt{"albumRating": 3}, - }, - } - gomega.Expect(c.ExpressionJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - }) - }) - - Describe("RequiredJoins", func() { - It("returns JoinNone when no annotation fields are used", func() { - c := Criteria{ - Expression: All{ - Contains{"title": "love"}, - }, - } - gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone)) - }) - It("returns JoinNone for media_file annotation fields", func() { - c := Criteria{ - Expression: All{ - Is{"loved": true}, - Gt{"playCount": 5}, - }, - } - gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinNone)) - }) - It("returns JoinAlbumAnnotation for album annotation fields", func() { - c := Criteria{ - Expression: All{ - Gt{"albumRating": 3}, - }, - } - gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinAlbumAnnotation)) - }) - It("returns JoinArtistAnnotation for artist annotation fields", func() { - c := Criteria{ - Expression: All{ - Is{"artistLoved": true}, - }, - } - gomega.Expect(c.RequiredJoins()).To(gomega.Equal(JoinArtistAnnotation)) - }) - It("returns both join types when both are used", func() { - c := Criteria{ - Expression: All{ - Gt{"albumRating": 3}, - Is{"artistLoved": true}, - }, - } - j := c.RequiredJoins() - gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue()) - }) - It("detects join types in nested expressions", func() { - c := Criteria{ - Expression: All{ - Any{ - All{ - Is{"albumLoved": true}, - }, - }, - Any{ - Gt{"artistPlayCount": 10}, - }, - }, - } - j := c.RequiredJoins() - gomega.Expect(j.Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - gomega.Expect(j.Has(JoinArtistAnnotation)).To(gomega.BeTrue()) - }) - It("detects join types from Sort field", func() { - c := Criteria{ - Expression: All{ - Contains{"title": "love"}, - }, - Sort: "albumRating", - } - gomega.Expect(c.RequiredJoins().Has(JoinAlbumAnnotation)).To(gomega.BeTrue()) - }) - It("detects join types from Sort field with direction prefix", func() { - c := Criteria{ - Expression: All{ - Contains{"title": "love"}, - }, - Sort: "-artistRating", - } - gomega.Expect(c.RequiredJoins().Has(JoinArtistAnnotation)).To(gomega.BeTrue()) - }) }) Describe("LimitPercent", func() { @@ -470,5 +265,9 @@ var _ = Describe("Criteria", func() { ids := Criteria{}.ChildPlaylistIds() gomega.Expect(ids).To(gomega.BeEmpty()) }) + It("returns empty list for leaf expressions", func() { + ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds() + gomega.Expect(ids).To(gomega.BeEmpty()) + }) }) }) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index bc3c7a3d3..30541a945 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -1,280 +1,133 @@ package criteria -import ( - "fmt" - "reflect" - "strings" +import "strings" - "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" -) - -// JoinType is a bitmask indicating which additional JOINs are needed by a smart playlist expression. -type JoinType int - -const ( - JoinNone JoinType = 0 - JoinAlbumAnnotation JoinType = 1 << iota - JoinArtistAnnotation -) - -// Has returns true if j contains all bits in other. -func (j JoinType) Has(other JoinType) bool { return j&other != 0 } - -var fieldMap = map[string]*mappedField{ - "title": {field: "media_file.title"}, - "album": {field: "media_file.album"}, - "hascoverart": {field: "media_file.has_cover_art"}, - "tracknumber": {field: "media_file.track_number"}, - "discnumber": {field: "media_file.disc_number"}, - "year": {field: "media_file.year"}, - "date": {field: "media_file.date", alias: "recordingdate"}, - "originalyear": {field: "media_file.original_year"}, - "originaldate": {field: "media_file.original_date"}, - "releaseyear": {field: "media_file.release_year"}, - "releasedate": {field: "media_file.release_date"}, - "size": {field: "media_file.size"}, - "compilation": {field: "media_file.compilation"}, - "missing": {field: "media_file.missing"}, - "explicitstatus": {field: "media_file.explicit_status"}, - "dateadded": {field: "media_file.created_at"}, - "datemodified": {field: "media_file.updated_at"}, - "discsubtitle": {field: "media_file.disc_subtitle"}, - "comment": {field: "media_file.comment"}, - "lyrics": {field: "media_file.lyrics"}, - "sorttitle": {field: "media_file.sort_title"}, - "sortalbum": {field: "media_file.sort_album_name"}, - "sortartist": {field: "media_file.sort_artist_name"}, - "sortalbumartist": {field: "media_file.sort_album_artist_name"}, - "albumcomment": {field: "media_file.mbz_album_comment"}, - "catalognumber": {field: "media_file.catalog_num"}, - "filepath": {field: "media_file.path"}, - "filetype": {field: "media_file.suffix"}, - "codec": {field: "media_file.codec"}, - "duration": {field: "media_file.duration"}, - "bitrate": {field: "media_file.bit_rate"}, - "bitdepth": {field: "media_file.bit_depth"}, - "samplerate": {field: "media_file.sample_rate"}, - "bpm": {field: "media_file.bpm"}, - "channels": {field: "media_file.channels"}, - "loved": {field: "COALESCE(annotation.starred, false)"}, - "dateloved": {field: "annotation.starred_at"}, - "lastplayed": {field: "annotation.play_date"}, - "daterated": {field: "annotation.rated_at"}, - "playcount": {field: "COALESCE(annotation.play_count, 0)"}, - "rating": {field: "COALESCE(annotation.rating, 0)"}, - "averagerating": {field: "media_file.average_rating", numeric: true}, - "albumrating": {field: "COALESCE(album_annotation.rating, 0)", joinType: JoinAlbumAnnotation}, - "albumloved": {field: "COALESCE(album_annotation.starred, false)", joinType: JoinAlbumAnnotation}, - "albumplaycount": {field: "COALESCE(album_annotation.play_count, 0)", joinType: JoinAlbumAnnotation}, - "albumlastplayed": {field: "album_annotation.play_date", joinType: JoinAlbumAnnotation}, - "albumdateloved": {field: "album_annotation.starred_at", joinType: JoinAlbumAnnotation}, - "albumdaterated": {field: "album_annotation.rated_at", joinType: JoinAlbumAnnotation}, - - "artistrating": {field: "COALESCE(artist_annotation.rating, 0)", joinType: JoinArtistAnnotation}, - "artistloved": {field: "COALESCE(artist_annotation.starred, false)", joinType: JoinArtistAnnotation}, - "artistplaycount": {field: "COALESCE(artist_annotation.play_count, 0)", joinType: JoinArtistAnnotation}, - "artistlastplayed": {field: "artist_annotation.play_date", joinType: JoinArtistAnnotation}, - "artistdateloved": {field: "artist_annotation.starred_at", joinType: JoinArtistAnnotation}, - "artistdaterated": {field: "artist_annotation.rated_at", joinType: JoinArtistAnnotation}, - - "mbz_album_id": {field: "media_file.mbz_album_id"}, - "mbz_album_artist_id": {field: "media_file.mbz_album_artist_id"}, - "mbz_artist_id": {field: "media_file.mbz_artist_id"}, - "mbz_recording_id": {field: "media_file.mbz_recording_id"}, - "mbz_release_track_id": {field: "media_file.mbz_release_track_id"}, - "mbz_release_group_id": {field: "media_file.mbz_release_group_id"}, - "library_id": {field: "media_file.library_id", numeric: true}, - - // Backward compatibility: albumtype is an alias for releasetype tag - "albumtype": {field: "releasetype", isTag: true}, - - // special fields - "random": {field: "", order: "random()"}, // pseudo-field for random sorting - "value": {field: "value"}, // pseudo-field for tag and roles values +// FieldInfo describes a criteria field without tying it to persistence details. +type FieldInfo struct { + Name string + IsTag bool + IsRole bool + Numeric bool } -type mappedField struct { - field string - order string - isRole bool // true if the field is a role (e.g. "artist", "composer", "conductor", etc.) - isTag bool // true if the field is a tag imported from the file metadata - alias string // name from `mappings.yml` that may differ from the name used in the smart playlist - numeric bool // true if the field/tag should be treated as numeric - joinType JoinType // which additional JOINs this field requires +var fieldMap = map[string]*fieldMetadata{ + "title": {name: "title"}, + "album": {name: "album"}, + "hascoverart": {name: "hascoverart"}, + "tracknumber": {name: "tracknumber"}, + "discnumber": {name: "discnumber"}, + "year": {name: "year"}, + "date": {name: "date", alias: "recordingdate"}, + "originalyear": {name: "originalyear"}, + "originaldate": {name: "originaldate"}, + "releaseyear": {name: "releaseyear"}, + "releasedate": {name: "releasedate"}, + "size": {name: "size"}, + "compilation": {name: "compilation"}, + "missing": {name: "missing"}, + "explicitstatus": {name: "explicitstatus"}, + "dateadded": {name: "dateadded"}, + "datemodified": {name: "datemodified"}, + "discsubtitle": {name: "discsubtitle"}, + "comment": {name: "comment"}, + "lyrics": {name: "lyrics"}, + "sorttitle": {name: "sorttitle"}, + "sortalbum": {name: "sortalbum"}, + "sortartist": {name: "sortartist"}, + "sortalbumartist": {name: "sortalbumartist"}, + "albumcomment": {name: "albumcomment"}, + "catalognumber": {name: "catalognumber"}, + "filepath": {name: "filepath"}, + "filetype": {name: "filetype"}, + "codec": {name: "codec"}, + "duration": {name: "duration"}, + "bitrate": {name: "bitrate"}, + "bitdepth": {name: "bitdepth"}, + "samplerate": {name: "samplerate"}, + "bpm": {name: "bpm"}, + "channels": {name: "channels"}, + "loved": {name: "loved"}, + "dateloved": {name: "dateloved"}, + "lastplayed": {name: "lastplayed"}, + "daterated": {name: "daterated"}, + "playcount": {name: "playcount"}, + "rating": {name: "rating"}, + "averagerating": {name: "averagerating", numeric: true}, + "albumrating": {name: "albumrating"}, + "albumloved": {name: "albumloved"}, + "albumplaycount": {name: "albumplaycount"}, + "albumlastplayed": {name: "albumlastplayed"}, + "albumdateloved": {name: "albumdateloved"}, + "albumdaterated": {name: "albumdaterated"}, + "artistrating": {name: "artistrating"}, + "artistloved": {name: "artistloved"}, + "artistplaycount": {name: "artistplaycount"}, + "artistlastplayed": {name: "artistlastplayed"}, + "artistdateloved": {name: "artistdateloved"}, + "artistdaterated": {name: "artistdaterated"}, + "mbz_album_id": {name: "mbz_album_id"}, + "mbz_album_artist_id": {name: "mbz_album_artist_id"}, + "mbz_artist_id": {name: "mbz_artist_id"}, + "mbz_recording_id": {name: "mbz_recording_id"}, + "mbz_release_track_id": {name: "mbz_release_track_id"}, + "mbz_release_group_id": {name: "mbz_release_group_id"}, + "library_id": {name: "library_id", numeric: true}, + + // Backward compatibility: albumtype is an alias for the releasetype tag. + "albumtype": {name: "releasetype", isTag: true}, + + "random": {name: "random"}, + "value": {name: "value"}, } -func mapFields(expr map[string]any) map[string]any { - m := make(map[string]any) - for f, v := range expr { - if dbf := fieldMap[strings.ToLower(f)]; dbf != nil && dbf.field != "" { - m[dbf.field] = v - } else { - log.Error("Invalid field in criteria", "field", f) - } +type fieldMetadata struct { + name string + isRole bool + isTag bool + alias string + numeric bool +} + +// AllFieldNames returns the names of all registered criteria fields. +func AllFieldNames() []string { + names := make([]string, 0, len(fieldMap)) + for name := range fieldMap { + names = append(names, name) } - return m + return names } -// mapExpr maps a normal field expression to a specific type of expression (tag or role). -// This is required because tags are handled differently than other fields, -// as they are stored as a JSON column in the database. -func mapExpr(expr squirrel.Sqlizer, negate bool, exprFunc func(string, squirrel.Sqlizer, bool) squirrel.Sqlizer) squirrel.Sqlizer { - rv := reflect.ValueOf(expr) - if rv.Kind() != reflect.Map || rv.Type().Key().Kind() != reflect.String { - log.Fatal(fmt.Sprintf("expr is not a map-based operator: %T", expr)) +// LookupField returns semantic metadata for a criteria field name. +func LookupField(name string) (FieldInfo, bool) { + f, ok := fieldMap[strings.ToLower(name)] + if !ok { + return FieldInfo{}, false } - - // Extract the field name and value, then build a new map keyed by "value" - // for the inner condition. The original map is left untouched so that - // ToSql can be called multiple times without corruption. - var k string - var v any - for _, key := range rv.MapKeys() { - k = key.String() - v = rv.MapIndex(key).Interface() - break // only one key is expected (and supported) - } - - // Create a new map-based expression with "value" as the key, matching the - // column name inside json_tree subqueries. - newMap := reflect.MakeMap(rv.Type()) - newMap.SetMapIndex(reflect.ValueOf("value"), reflect.ValueOf(v)) - newExpr := newMap.Interface().(squirrel.Sqlizer) - - return exprFunc(k, newExpr, negate) -} - -// mapTagExpr maps a normal field expression to a tag expression. -func mapTagExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - return mapExpr(expr, negate, tagExpr) -} - -// mapRoleExpr maps a normal field expression to an artist role expression. -func mapRoleExpr(expr squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - return mapExpr(expr, negate, roleExpr) -} - -func isTagExpr(expr map[string]any) bool { - for f := range expr { - if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isTag { - return true - } - } - return false -} - -func isRoleExpr(expr map[string]any) bool { - for f := range expr { - if f2, ok := fieldMap[strings.ToLower(f)]; ok && f2.isRole { - return true - } - } - return false -} - -func tagExpr(tag string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - return tagCond{tag: tag, cond: cond, not: negate} -} - -type tagCond struct { - tag string - cond squirrel.Sqlizer - not bool -} - -func (e tagCond) ToSql() (string, []any, error) { - cond, args, err := e.cond.ToSql() - - // Resolve the actual tag name (handles aliases like albumtype -> releasetype) - tagName := e.tag - if fm, ok := fieldMap[e.tag]; ok { - if fm.field != "" { - tagName = fm.field - } - if fm.numeric { - cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") - } - } - - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", - tagName, cond) - if e.not { - cond = "not " + cond - } - return cond, args, err -} - -func roleExpr(role string, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { - return roleCond{role: role, cond: cond, not: negate} -} - -type roleCond struct { - role string - cond squirrel.Sqlizer - not bool -} - -func (e roleCond) ToSql() (string, []any, error) { - 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) - if e.not { - cond = "not " + cond - } - return cond, args, err -} - -// fieldJoinType returns the JoinType for a given field name (case-insensitive). -func fieldJoinType(name string) JoinType { - if f, ok := fieldMap[strings.ToLower(name)]; ok { - return f.joinType - } - return JoinNone -} - -// extractJoinTypes walks an expression tree and collects all required JoinType flags. -func extractJoinTypes(expr any) JoinType { - result := JoinNone - switch e := expr.(type) { - case All: - for _, sub := range e { - result |= extractJoinTypes(sub) - } - case Any: - for _, sub := range e { - result |= extractJoinTypes(sub) - } - default: - // Leaf expression: use reflection to check if it's a map with field names - rv := reflect.ValueOf(expr) - if rv.Kind() == reflect.Map && rv.Type().Key().Kind() == reflect.String { - for _, key := range rv.MapKeys() { - result |= fieldJoinType(key.String()) - } - } - } - return result + return FieldInfo{ + Name: f.name, + IsTag: f.isTag, + IsRole: f.isRole, + Numeric: f.numeric, + }, true } // AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in -// smart playlists. If a role already exists in the field map, it is ignored, so calls to this function are idempotent. +// smart playlists. func AddRoles(roles []string) { for _, role := range roles { name := strings.ToLower(role) if _, ok := fieldMap[name]; ok { continue } - fieldMap[name] = &mappedField{field: name, isRole: true} + fieldMap[name] = &fieldMetadata{name: name, isRole: true} } } // AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml` -// file to the field map, so they can be used in smart playlists. -// If a tag name already exists in the field map, it is ignored, so calls to this function are idempotent. +// configuration file. func AddTagNames(tagNames []string) { - for _, name := range tagNames { - name := strings.ToLower(name) + for _, tagName := range tagNames { + name := strings.ToLower(tagName) if _, ok := fieldMap[name]; ok { continue } @@ -285,20 +138,19 @@ func AddTagNames(tagNames []string) { } } if _, ok := fieldMap[name]; !ok { - fieldMap[name] = &mappedField{field: name, isTag: true} + fieldMap[name] = &fieldMetadata{name: name, isTag: true} } } } -// AddNumericTags marks the given tag names as numeric so they can be cast -// when used in comparisons or sorting. +// AddNumericTags adds tags that should be treated as numbers. func AddNumericTags(tagNames []string) { - for _, name := range tagNames { - name := strings.ToLower(name) + for _, tagName := range tagNames { + name := strings.ToLower(tagName) if fm, ok := fieldMap[name]; ok { fm.numeric = true } else { - fieldMap[name] = &mappedField{field: name, isTag: true, numeric: true} + fieldMap[name] = &fieldMetadata{name: name, isTag: true, numeric: true} } } } diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index accdebd3d..ecbeb5857 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -6,11 +6,58 @@ import ( ) var _ = Describe("fields", func() { - Describe("mapFields", func() { - It("ignores random fields", func() { - m := map[string]any{"random": "123"} - m = mapFields(m) - gomega.Expect(m).To(gomega.BeEmpty()) + Describe("LookupField", func() { + It("finds built-in fields case-insensitively", func() { + field, ok := LookupField("Title") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field).To(gomega.Equal(FieldInfo{Name: "title"})) + }) + + It("resolves aliases to their semantic field name", func() { + field, ok := LookupField("albumtype") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name).To(gomega.Equal("releasetype")) + gomega.Expect(field.IsTag).To(gomega.BeTrue()) + }) + + It("finds special fields", func() { + field, ok := LookupField("value") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name).To(gomega.Equal("value")) + }) + + It("finds registered tag names", func() { + AddTagNames([]string{"task3_mood"}) + + field, ok := LookupField("task3_mood") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name).To(gomega.Equal("task3_mood")) + gomega.Expect(field.IsTag).To(gomega.BeTrue()) + }) + + It("marks registered numeric tags", func() { + AddTagNames([]string{"task3_score"}) + AddNumericTags([]string{"task3_score"}) + + field, ok := LookupField("task3_score") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.IsTag).To(gomega.BeTrue()) + gomega.Expect(field.Numeric).To(gomega.BeTrue()) + }) + + It("finds registered roles", func() { + AddRoles([]string{"task3_producer"}) + + field, ok := LookupField("task3_producer") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name).To(gomega.Equal("task3_producer")) + gomega.Expect(field.IsRole).To(gomega.BeTrue()) }) }) }) diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 336f914de..6f911b12f 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,23 +1,13 @@ package criteria -import ( - "errors" - "fmt" - "reflect" - "strconv" - "time" - - "github.com/Masterminds/squirrel" -) +import "time" type ( - All squirrel.And + All []Expression And = All ) -func (all All) ToSql() (sql string, args []any, err error) { - return squirrel.And(all).ToSql() -} +func (All) criteriaExpression() {} func (all All) MarshalJSON() ([]byte, error) { return marshalConjunction("all", all) @@ -28,13 +18,11 @@ func (all All) ChildPlaylistIds() (ids []string) { } type ( - Any squirrel.Or + Any []Expression Or = Any ) -func (any Any) ToSql() (sql string, args []any, err error) { - return squirrel.Or(any).ToSql() -} +func (Any) criteriaExpression() {} func (any Any) MarshalJSON() ([]byte, error) { return marshalConjunction("any", any) @@ -44,70 +32,42 @@ func (any Any) ChildPlaylistIds() (ids []string) { return extractPlaylistIds(any) } -type Is squirrel.Eq +type Is map[string]any type Eq = Is -func (is Is) ToSql() (sql string, args []any, err error) { - if isRoleExpr(is) { - return mapRoleExpr(is, false).ToSql() - } - if isTagExpr(is) { - return mapTagExpr(is, false).ToSql() - } - return squirrel.Eq(mapFields(is)).ToSql() -} +func (Is) criteriaExpression() {} func (is Is) MarshalJSON() ([]byte, error) { return marshalExpression("is", is) } -type IsNot squirrel.NotEq +type IsNot map[string]any -func (in IsNot) ToSql() (sql string, args []any, err error) { - if isRoleExpr(in) { - return mapRoleExpr(squirrel.Eq(in), true).ToSql() - } - if isTagExpr(in) { - return mapTagExpr(squirrel.Eq(in), true).ToSql() - } - return squirrel.NotEq(mapFields(in)).ToSql() -} +func (IsNot) criteriaExpression() {} func (in IsNot) MarshalJSON() ([]byte, error) { return marshalExpression("isNot", in) } -type Gt squirrel.Gt +type Gt map[string]any -func (gt Gt) ToSql() (sql string, args []any, err error) { - if isTagExpr(gt) { - return mapTagExpr(gt, false).ToSql() - } - return squirrel.Gt(mapFields(gt)).ToSql() -} +func (Gt) criteriaExpression() {} func (gt Gt) MarshalJSON() ([]byte, error) { return marshalExpression("gt", gt) } -type Lt squirrel.Lt +type Lt map[string]any -func (lt Lt) ToSql() (sql string, args []any, err error) { - if isTagExpr(lt) { - return mapTagExpr(squirrel.Lt(lt), false).ToSql() - } - return squirrel.Lt(mapFields(lt)).ToSql() -} +func (Lt) criteriaExpression() {} func (lt Lt) MarshalJSON() ([]byte, error) { return marshalExpression("lt", lt) } -type Before squirrel.Lt +type Before map[string]any -func (bf Before) ToSql() (sql string, args []any, err error) { - return Lt(bf).ToSql() -} +func (Before) criteriaExpression() {} func (bf Before) MarshalJSON() ([]byte, error) { return marshalExpression("before", bf) @@ -115,9 +75,7 @@ func (bf Before) MarshalJSON() ([]byte, error) { type After Gt -func (af After) ToSql() (sql string, args []any, err error) { - return Gt(af).ToSql() -} +func (After) criteriaExpression() {} func (af After) MarshalJSON() ([]byte, error) { return marshalExpression("after", af) @@ -125,19 +83,7 @@ func (af After) MarshalJSON() ([]byte, error) { type Contains map[string]any -func (ct Contains) ToSql() (sql string, args []any, err error) { - lk := squirrel.Like{} - for f, v := range mapFields(ct) { - lk[f] = fmt.Sprintf("%%%s%%", v) - } - if isRoleExpr(ct) { - return mapRoleExpr(lk, false).ToSql() - } - if isTagExpr(ct) { - return mapTagExpr(lk, false).ToSql() - } - return lk.ToSql() -} +func (Contains) criteriaExpression() {} func (ct Contains) MarshalJSON() ([]byte, error) { return marshalExpression("contains", ct) @@ -145,19 +91,7 @@ func (ct Contains) MarshalJSON() ([]byte, error) { type NotContains map[string]any -func (nct NotContains) ToSql() (sql string, args []any, err error) { - lk := squirrel.NotLike{} - for f, v := range mapFields(nct) { - lk[f] = fmt.Sprintf("%%%s%%", v) - } - if isRoleExpr(nct) { - return mapRoleExpr(squirrel.Like(lk), true).ToSql() - } - if isTagExpr(nct) { - return mapTagExpr(squirrel.Like(lk), true).ToSql() - } - return lk.ToSql() -} +func (NotContains) criteriaExpression() {} func (nct NotContains) MarshalJSON() ([]byte, error) { return marshalExpression("notContains", nct) @@ -165,19 +99,7 @@ func (nct NotContains) MarshalJSON() ([]byte, error) { type StartsWith map[string]any -func (sw StartsWith) ToSql() (sql string, args []any, err error) { - lk := squirrel.Like{} - for f, v := range mapFields(sw) { - lk[f] = fmt.Sprintf("%s%%", v) - } - if isRoleExpr(sw) { - return mapRoleExpr(lk, false).ToSql() - } - if isTagExpr(sw) { - return mapTagExpr(lk, false).ToSql() - } - return lk.ToSql() -} +func (StartsWith) criteriaExpression() {} func (sw StartsWith) MarshalJSON() ([]byte, error) { return marshalExpression("startsWith", sw) @@ -185,19 +107,7 @@ func (sw StartsWith) MarshalJSON() ([]byte, error) { type EndsWith map[string]any -func (sw EndsWith) ToSql() (sql string, args []any, err error) { - lk := squirrel.Like{} - for f, v := range mapFields(sw) { - lk[f] = fmt.Sprintf("%%%s", v) - } - if isRoleExpr(sw) { - return mapRoleExpr(lk, false).ToSql() - } - if isTagExpr(sw) { - return mapTagExpr(lk, false).ToSql() - } - return lk.ToSql() -} +func (EndsWith) criteriaExpression() {} func (sw EndsWith) MarshalJSON() ([]byte, error) { return marshalExpression("endsWith", sw) @@ -205,20 +115,7 @@ func (sw EndsWith) MarshalJSON() ([]byte, error) { type InTheRange map[string]any -func (itr InTheRange) ToSql() (sql string, args []any, err error) { - and := squirrel.And{} - for f, v := range mapFields(itr) { - s := reflect.ValueOf(v) - if s.Kind() != reflect.Slice || s.Len() != 2 { - return "", nil, fmt.Errorf("invalid range for 'in' operator: %s", v) - } - and = append(and, - squirrel.GtOrEq{f: s.Index(0).Interface()}, - squirrel.LtOrEq{f: s.Index(1).Interface()}, - ) - } - return and.ToSql() -} +func (InTheRange) criteriaExpression() {} func (itr InTheRange) MarshalJSON() ([]byte, error) { return marshalExpression("inTheRange", itr) @@ -226,13 +123,7 @@ func (itr InTheRange) MarshalJSON() ([]byte, error) { type InTheLast map[string]any -func (itl InTheLast) ToSql() (sql string, args []any, err error) { - exp, err := inPeriod(itl, false) - if err != nil { - return "", nil, err - } - return exp.ToSql() -} +func (InTheLast) criteriaExpression() {} func (itl InTheLast) MarshalJSON() ([]byte, error) { return marshalExpression("inTheLast", itl) @@ -240,50 +131,19 @@ func (itl InTheLast) MarshalJSON() ([]byte, error) { type NotInTheLast map[string]any -func (nitl NotInTheLast) ToSql() (sql string, args []any, err error) { - exp, err := inPeriod(nitl, true) - if err != nil { - return "", nil, err - } - return exp.ToSql() -} +func (NotInTheLast) criteriaExpression() {} func (nitl NotInTheLast) MarshalJSON() ([]byte, error) { return marshalExpression("notInTheLast", nitl) } -func inPeriod(m map[string]any, negate bool) (Expression, error) { - var field string - var value any - for f, v := range mapFields(m) { - field, value = f, v - break - } - str := fmt.Sprintf("%v", value) - v, err := strconv.ParseInt(str, 10, 64) - if err != nil { - return nil, err - } - firstDate := startOfPeriod(v, time.Now()) - - if negate { - return Or{ - squirrel.Lt{field: firstDate}, - squirrel.Eq{field: nil}, - }, nil - } - return squirrel.Gt{field: firstDate}, nil -} - 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) ToSql() (sql string, args []any, err error) { - return inList(ipl, false) -} +func (InPlaylist) criteriaExpression() {} func (ipl InPlaylist) MarshalJSON() ([]byte, error) { return marshalExpression("inPlaylist", ipl) @@ -291,41 +151,12 @@ func (ipl InPlaylist) MarshalJSON() ([]byte, error) { type NotInPlaylist map[string]any -func (ipl NotInPlaylist) ToSql() (sql string, args []any, err error) { - return inList(ipl, true) -} +func (NotInPlaylist) criteriaExpression() {} func (ipl NotInPlaylist) MarshalJSON() ([]byte, error) { return marshalExpression("notInPlaylist", ipl) } -func inList(m map[string]any, negate bool) (sql string, args []any, err error) { - var playlistid string - var ok bool - if playlistid, ok = m["id"].(string); !ok { - return "", nil, errors.New("playlist id not given") - } - - // Subquery to fetch all media files that are contained in given playlist - // Only evaluate playlist if it is public - subQuery := squirrel.Select("media_file_id"). - From("playlist_tracks pl"). - LeftJoin("playlist on pl.playlist_id = playlist.id"). - Where(squirrel.And{ - squirrel.Eq{"pl.playlist_id": playlistid}, - squirrel.Eq{"playlist.public": 1}}) - subQText, subQArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql() - - if err != nil { - return "", nil, err - } - if negate { - return "media_file.id NOT IN (" + subQText + ")", subQArgs, nil - } else { - return "media_file.id IN (" + subQText + ")", subQArgs, nil - } -} - func extractPlaylistIds(inputRule any) (ids []string) { var id string var ok bool diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index 5f756f97d..c93e8f2b2 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -3,7 +3,6 @@ package criteria_test import ( "encoding/json" "fmt" - "time" . "github.com/navidrome/navidrome/model/criteria" . "github.com/onsi/ginkgo/v2" @@ -17,182 +16,6 @@ var _ = BeforeSuite(func() { }) var _ = Describe("Operators", func() { - rangeStart := time.Date(2021, 10, 01, 0, 0, 0, 0, time.Local) - rangeEnd := time.Date(2021, 11, 01, 0, 0, 0, 0, time.Local) - - DescribeTable("ToSQL", - func(op Expression, expectedSql string, expectedArgs ...any) { - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal(expectedSql)) - gomega.Expect(args).To(gomega.HaveExactElements(expectedArgs...)) - }, - Entry("is [string]", Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"), - Entry("is [bool]", Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true), - Entry("is [numeric]", Is{"library_id": 1}, "media_file.library_id = ?", 1), - Entry("is [numeric list]", Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2), - Entry("isNot", IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"), - Entry("isNot [numeric]", IsNot{"library_id": 1}, "media_file.library_id <> ?", 1), - Entry("isNot [numeric list]", IsNot{"library_id": []int{1, 2}}, "media_file.library_id NOT IN (?,?)", 1, 2), - Entry("gt", Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10), - Entry("lt", Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10), - Entry("contains", Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"), - Entry("notContains", NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"), - Entry("startsWith", StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"), - Entry("endsWith", EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"), - Entry("inTheRange [number]", InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990), - Entry("inTheRange [date]", InTheRange{"lastPlayed": []time.Time{rangeStart, rangeEnd}}, "(annotation.play_date >= ? AND annotation.play_date <= ?)", rangeStart, rangeEnd), - Entry("before", Before{"lastPlayed": rangeStart}, "annotation.play_date < ?", rangeStart), - Entry("after", After{"lastPlayed": rangeStart}, "annotation.play_date > ?", rangeStart), - - // InPlaylist and NotInPlaylist are special cases - Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN "+ - "(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), - Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN "+ - "(SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), - - Entry("inTheLast", InTheLast{"lastPlayed": 30}, "annotation.play_date > ?", StartOfPeriod(30, time.Now())), - Entry("notInTheLast", NotInTheLast{"lastPlayed": 30}, "(annotation.play_date < ? OR annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())), - - // Album annotation fields - Entry("albumRating", Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3), - Entry("albumLoved", Is{"albumLoved": true}, "COALESCE(album_annotation.starred, false) = ?", true), - Entry("albumPlayCount", Gt{"albumPlayCount": 5}, "COALESCE(album_annotation.play_count, 0) > ?", 5), - Entry("albumLastPlayed", After{"albumLastPlayed": rangeStart}, "album_annotation.play_date > ?", rangeStart), - Entry("albumDateLoved", Before{"albumDateLoved": rangeStart}, "album_annotation.starred_at < ?", rangeStart), - Entry("albumDateRated", After{"albumDateRated": rangeStart}, "album_annotation.rated_at > ?", rangeStart), - Entry("albumLastPlayed inTheLast", InTheLast{"albumLastPlayed": 30}, "album_annotation.play_date > ?", StartOfPeriod(30, time.Now())), - Entry("albumLastPlayed notInTheLast", NotInTheLast{"albumLastPlayed": 30}, "(album_annotation.play_date < ? OR album_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())), - - // Artist annotation fields - Entry("artistRating", Gt{"artistRating": 3}, "COALESCE(artist_annotation.rating, 0) > ?", 3), - Entry("artistLoved", Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true), - Entry("artistPlayCount", Gt{"artistPlayCount": 5}, "COALESCE(artist_annotation.play_count, 0) > ?", 5), - Entry("artistLastPlayed", After{"artistLastPlayed": rangeStart}, "artist_annotation.play_date > ?", rangeStart), - Entry("artistDateLoved", Before{"artistDateLoved": rangeStart}, "artist_annotation.starred_at < ?", rangeStart), - Entry("artistDateRated", After{"artistDateRated": rangeStart}, "artist_annotation.rated_at > ?", rangeStart), - Entry("artistLastPlayed inTheLast", InTheLast{"artistLastPlayed": 30}, "artist_annotation.play_date > ?", StartOfPeriod(30, time.Now())), - Entry("artistLastPlayed notInTheLast", NotInTheLast{"artistLastPlayed": 30}, "(artist_annotation.play_date < ? OR artist_annotation.play_date IS NULL)", StartOfPeriod(30, time.Now())), - - // Tag tests - Entry("tag is [string]", Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), - Entry("tag isNot [string]", IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), - Entry("tag gt", Gt{"genre": "A"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value > ?)", "A"), - Entry("tag lt", Lt{"genre": "Z"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value < ?)", "Z"), - Entry("tag contains", Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), - Entry("tag not contains", NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), - Entry("tag startsWith", StartsWith{"genre": "Soft"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "Soft%"), - Entry("tag endsWith", EndsWith{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock"), - - // Artist roles tests - Entry("role is [string]", Is{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), - Entry("role isNot [string]", IsNot{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value = ?)", "u2"), - Entry("role contains [string]", Contains{"artist": "u2"}, "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), - Entry("role not contains [string]", NotContains{"artist": "u2"}, "not exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name' and value LIKE ?)", "%u2%"), - Entry("role startsWith [string]", StartsWith{"composer": "John"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "John%"), - Entry("role endsWith [string]", EndsWith{"composer": "Lennon"}, "exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name' and value LIKE ?)", "%Lennon"), - ) - - // TODO Validate operators that are not valid for each field type. - XDescribeTable("ToSQL - Invalid Operators", - func(op Expression, expectedError string) { - _, _, err := op.ToSql() - gomega.Expect(err).To(gomega.MatchError(expectedError)) - }, - Entry("numeric tag contains", Contains{"rate": 5}, "numeric tag 'rate' cannot be used with Contains operator"), - ) - - Describe("Custom Tags", func() { - It("generates valid SQL", func() { - AddTagNames([]string{"mood"}) - op := EndsWith{"mood": "Soft"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.mood') where key='value' and value LIKE ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("%Soft")) - }) - It("casts numeric comparisons", func() { - AddNumericTags([]string{"rate"}) - op := Lt{"rate": 6} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.rate') where key='value' and CAST(value AS REAL) < ?)")) - gomega.Expect(args).To(gomega.HaveExactElements(6)) - }) - It("skips unknown tag names", func() { - op := EndsWith{"unknown": "value"} - sql, args, _ := op.ToSql() - gomega.Expect(sql).To(gomega.BeEmpty()) - gomega.Expect(args).To(gomega.BeEmpty()) - }) - It("supports releasetype as multi-valued tag", func() { - AddTagNames([]string{"releasetype"}) - op := Contains{"releasetype": "soundtrack"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("%soundtrack%")) - }) - It("supports albumtype as alias for releasetype", func() { - AddTagNames([]string{"releasetype"}) - op := Contains{"albumtype": "live"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value LIKE ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("%live%")) - }) - It("supports albumtype alias with Is operator", func() { - AddTagNames([]string{"releasetype"}) - op := Is{"albumtype": "album"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - // Should query $.releasetype, not $.albumtype - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("album")) - }) - It("supports albumtype alias with IsNot operator", func() { - AddTagNames([]string{"releasetype"}) - op := IsNot{"albumtype": "compilation"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - // Should query $.releasetype, not $.albumtype - gomega.Expect(sql).To(gomega.Equal("not exists (select 1 from json_tree(media_file.tags, '$.releasetype') where key='value' and value = ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("compilation")) - }) - }) - - Describe("Custom Roles", func() { - It("generates valid SQL", func() { - AddRoles([]string{"producer"}) - op := EndsWith{"producer": "Eno"} - sql, args, err := op.ToSql() - gomega.Expect(err).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql).To(gomega.Equal("exists (select 1 from json_tree(media_file.participants, '$.producer') where key='name' and value LIKE ?)")) - gomega.Expect(args).To(gomega.HaveExactElements("%Eno")) - }) - It("skips unknown roles", func() { - op := Contains{"groupie": "Penny Lane"} - sql, args, _ := op.ToSql() - gomega.Expect(sql).To(gomega.BeEmpty()) - gomega.Expect(args).To(gomega.BeEmpty()) - }) - }) - - DescribeTable("ToSql idempotency", - func(expr Expression) { - sql1, args1, err1 := expr.ToSql() - sql2, args2, err2 := expr.ToSql() - - gomega.Expect(err1).ToNot(gomega.HaveOccurred()) - gomega.Expect(err2).ToNot(gomega.HaveOccurred()) - gomega.Expect(sql2).To(gomega.Equal(sql1)) - gomega.Expect(args2).To(gomega.Equal(args1)) - }, - Entry("tag expression", Is{"genre": "Rock"}), - Entry("role expression", Contains{"artist": "Beatles"}), - Entry("nested criteria", Criteria{Expression: All{Is{"genre": "Rock"}, Contains{"artist": "Beatles"}}}), - ) - DescribeTable("JSON Marshaling", func(op Expression, jsonString string) { obj := And{op} diff --git a/model/criteria/walk.go b/model/criteria/walk.go new file mode 100644 index 000000000..62aaf97f8 --- /dev/null +++ b/model/criteria/walk.go @@ -0,0 +1,72 @@ +package criteria + +import "fmt" + +type Visitor func(Expression) error + +func Walk(expr Expression, visit Visitor) error { + if expr == nil { + return nil + } + if err := visit(expr); err != nil { + return err + } + switch e := expr.(type) { + case All: + for _, child := range e { + if err := Walk(child, visit); err != nil { + return err + } + } + case Any: + for _, child := range e { + if err := Walk(child, visit); err != nil { + return err + } + } + case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist: + return nil + default: + return fmt.Errorf("unknown criteria expression type %T", expr) + } + return nil +} + +// Fields returns field values for leaf expressions only. +// Use Walk to traverse All and Any expressions before calling Fields. +func Fields(expr Expression) map[string]any { + switch e := expr.(type) { + case Is: + return map[string]any(e) + case IsNot: + return map[string]any(e) + case Gt: + return map[string]any(e) + case Lt: + return map[string]any(e) + case Before: + return map[string]any(e) + case After: + return map[string]any(Gt(e)) + case Contains: + return map[string]any(e) + case NotContains: + return map[string]any(e) + case StartsWith: + return map[string]any(e) + case EndsWith: + return map[string]any(e) + case InTheRange: + return map[string]any(e) + case InTheLast: + return map[string]any(e) + case NotInTheLast: + return map[string]any(e) + case InPlaylist: + return map[string]any(e) + case NotInPlaylist: + return map[string]any(e) + default: + return nil + } +} diff --git a/model/criteria/walk_test.go b/model/criteria/walk_test.go new file mode 100644 index 000000000..91438f095 --- /dev/null +++ b/model/criteria/walk_test.go @@ -0,0 +1,64 @@ +package criteria + +import ( + "fmt" + + . "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +type unknownExpression struct{} + +func (unknownExpression) criteriaExpression() {} + +var _ = Describe("Walk", func() { + It("visits the expression tree depth-first", func() { + expr := All{ + Contains{"title": "love"}, + Any{ + Is{"album": "best of"}, + Gt{"rating": 3}, + }, + } + + var visited []string + err := Walk(expr, func(expr Expression) error { + visited = append(visited, fmt.Sprintf("%T", expr)) + return nil + }) + + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(visited).To(gomega.Equal([]string{ + "criteria.All", + "criteria.Contains", + "criteria.Any", + "criteria.Is", + "criteria.Gt", + })) + }) + + It("stops when the visitor returns an error", func() { + expectedErr := fmt.Errorf("stop") + + err := Walk(All{Contains{"title": "love"}}, func(Expression) error { + return expectedErr + }) + + gomega.Expect(err).To(gomega.MatchError(expectedErr)) + }) + + It("returns fields for leaf expressions", func() { + gomega.Expect(Fields(Contains{"title": "love"})).To(gomega.Equal(map[string]any{"title": "love"})) + gomega.Expect(Fields(After{"date": "2020-01-01"})).To(gomega.Equal(map[string]any{"date": "2020-01-01"})) + }) + + It("returns nil fields for group expressions", func() { + gomega.Expect(Fields(All{Contains{"title": "love"}})).To(gomega.BeNil()) + }) + + It("returns an error for unknown expression types", func() { + err := Walk(unknownExpression{}, func(Expression) error { return nil }) + + gomega.Expect(err).To(gomega.MatchError("unknown criteria expression type criteria.unknownExpression")) + }) +}) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go new file mode 100644 index 000000000..2f7b73885 --- /dev/null +++ b/persistence/criteria_sql.go @@ -0,0 +1,487 @@ +package persistence + +import ( + "errors" + "fmt" + "reflect" + "strconv" + "strings" + "time" + + squirrel "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/criteria" +) + +type smartPlaylistJoinType int + +const ( + smartPlaylistJoinNone smartPlaylistJoinType = 0 + smartPlaylistJoinAlbumAnnotation smartPlaylistJoinType = 1 << iota + smartPlaylistJoinArtistAnnotation +) + +func (j smartPlaylistJoinType) has(other smartPlaylistJoinType) bool { + return j&other != 0 +} + +type smartPlaylistField struct { + expr string + order string + joinType smartPlaylistJoinType +} + +type smartPlaylistCriteria struct { + criteria criteria.Criteria +} + +func newSmartPlaylistCriteria(c criteria.Criteria) smartPlaylistCriteria { + return smartPlaylistCriteria{criteria: c} +} + +var smartPlaylistFields = map[string]smartPlaylistField{ + "title": {expr: "media_file.title"}, + "album": {expr: "media_file.album"}, + "hascoverart": {expr: "media_file.has_cover_art"}, + "tracknumber": {expr: "media_file.track_number"}, + "discnumber": {expr: "media_file.disc_number"}, + "year": {expr: "media_file.year"}, + "date": {expr: "media_file.date"}, + "originalyear": {expr: "media_file.original_year"}, + "originaldate": {expr: "media_file.original_date"}, + "releaseyear": {expr: "media_file.release_year"}, + "releasedate": {expr: "media_file.release_date"}, + "size": {expr: "media_file.size"}, + "compilation": {expr: "media_file.compilation"}, + "missing": {expr: "media_file.missing"}, + "explicitstatus": {expr: "media_file.explicit_status"}, + "dateadded": {expr: "media_file.created_at"}, + "datemodified": {expr: "media_file.updated_at"}, + "discsubtitle": {expr: "media_file.disc_subtitle"}, + "comment": {expr: "media_file.comment"}, + "lyrics": {expr: "media_file.lyrics"}, + "sorttitle": {expr: "media_file.sort_title"}, + "sortalbum": {expr: "media_file.sort_album_name"}, + "sortartist": {expr: "media_file.sort_artist_name"}, + "sortalbumartist": {expr: "media_file.sort_album_artist_name"}, + "albumcomment": {expr: "media_file.mbz_album_comment"}, + "catalognumber": {expr: "media_file.catalog_num"}, + "filepath": {expr: "media_file.path"}, + "filetype": {expr: "media_file.suffix"}, + "codec": {expr: "media_file.codec"}, + "duration": {expr: "media_file.duration"}, + "bitrate": {expr: "media_file.bit_rate"}, + "bitdepth": {expr: "media_file.bit_depth"}, + "samplerate": {expr: "media_file.sample_rate"}, + "bpm": {expr: "media_file.bpm"}, + "channels": {expr: "media_file.channels"}, + "loved": {expr: "COALESCE(annotation.starred, false)"}, + "dateloved": {expr: "annotation.starred_at"}, + "lastplayed": {expr: "annotation.play_date"}, + "daterated": {expr: "annotation.rated_at"}, + "playcount": {expr: "COALESCE(annotation.play_count, 0)"}, + "rating": {expr: "COALESCE(annotation.rating, 0)"}, + "averagerating": {expr: "media_file.average_rating"}, + "albumrating": {expr: "COALESCE(album_annotation.rating, 0)", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumloved": {expr: "COALESCE(album_annotation.starred, false)", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumplaycount": {expr: "COALESCE(album_annotation.play_count, 0)", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumlastplayed": {expr: "album_annotation.play_date", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumdateloved": {expr: "album_annotation.starred_at", joinType: smartPlaylistJoinAlbumAnnotation}, + "albumdaterated": {expr: "album_annotation.rated_at", joinType: smartPlaylistJoinAlbumAnnotation}, + "artistrating": {expr: "COALESCE(artist_annotation.rating, 0)", joinType: smartPlaylistJoinArtistAnnotation}, + "artistloved": {expr: "COALESCE(artist_annotation.starred, false)", joinType: smartPlaylistJoinArtistAnnotation}, + "artistplaycount": {expr: "COALESCE(artist_annotation.play_count, 0)", joinType: smartPlaylistJoinArtistAnnotation}, + "artistlastplayed": {expr: "artist_annotation.play_date", joinType: smartPlaylistJoinArtistAnnotation}, + "artistdateloved": {expr: "artist_annotation.starred_at", joinType: smartPlaylistJoinArtistAnnotation}, + "artistdaterated": {expr: "artist_annotation.rated_at", joinType: smartPlaylistJoinArtistAnnotation}, + "mbz_album_id": {expr: "media_file.mbz_album_id"}, + "mbz_album_artist_id": {expr: "media_file.mbz_album_artist_id"}, + "mbz_artist_id": {expr: "media_file.mbz_artist_id"}, + "mbz_recording_id": {expr: "media_file.mbz_recording_id"}, + "mbz_release_track_id": {expr: "media_file.mbz_release_track_id"}, + "mbz_release_group_id": {expr: "media_file.mbz_release_group_id"}, + "library_id": {expr: "media_file.library_id"}, + "random": {order: "random()"}, + "value": {expr: "value"}, +} + +func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { + if c.criteria.Expression == nil { + return squirrel.Expr("1 = 1"), nil + } + return exprSQL(c.criteria.Expression) +} + +func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { + switch e := expr.(type) { + case criteria.All: + and := squirrel.And{} + for _, child := range e { + cond, err := exprSQL(child) + if err != nil { + return nil, err + } + and = append(and, cond) + } + return and, nil + case criteria.Any: + or := squirrel.Or{} + for _, child := range e { + cond, err := exprSQL(child) + if err != nil { + return nil, err + } + or = append(or, cond) + } + return or, nil + case criteria.Is: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Eq(fields) + }, false) + case criteria.IsNot: + return isNotExpr(e) + case criteria.Gt: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Gt(fields) + }, false) + case criteria.Lt: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Lt(fields) + }, false) + case criteria.Before: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Lt(fields) + }, false) + case criteria.After: + return mapExpr(e, func(fields map[string]any) squirrel.Sqlizer { + return squirrel.Gt(fields) + }, false) + case criteria.Contains: + return likeExpr(e, "%%%v%%", false) + case criteria.NotContains: + return likeExpr(e, "%%%v%%", true) + case criteria.StartsWith: + return likeExpr(e, "%v%%", false) + case criteria.EndsWith: + return likeExpr(e, "%%%v", false) + case criteria.InTheRange: + return rangeExpr(e) + case criteria.InTheLast: + return periodExpr(e, false) + case criteria.NotInTheLast: + return periodExpr(e, true) + case criteria.InPlaylist: + return inList(e, false) + case criteria.NotInPlaylist: + return inList(e, true) + default: + return nil, fmt.Errorf("unknown criteria expression type %T", expr) + } +} + +func isNotExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { + if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { + return jsonExpr(info, squirrel.Eq{"value": value}, true), nil + } + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + return squirrel.NotEq(fields), nil +} + +func mapExpr[T ~map[string]any](values T, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { + if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { + return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil + } + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + return makeCond(fields), nil +} + +func likeExpr[T ~map[string]any](values T, pattern string, negate bool) (squirrel.Sqlizer, error) { + if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { + return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil + } + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + if negate { + lk := squirrel.NotLike{} + for field, value := range fields { + lk[field] = fmt.Sprintf(pattern, value) + } + return lk, nil + } + lk := squirrel.Like{} + for field, value := range fields { + lk[field] = fmt.Sprintf(pattern, value) + } + return lk, nil +} + +func rangeExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + and := squirrel.And{} + for field, value := range fields { + s := reflect.ValueOf(value) + if s.Kind() != reflect.Slice || s.Len() != 2 { + return nil, fmt.Errorf("invalid range for 'in' operator: %s", value) + } + and = append(and, + squirrel.GtOrEq{field: s.Index(0).Interface()}, + squirrel.LtOrEq{field: s.Index(1).Interface()}, + ) + } + return and, nil +} + +func periodExpr[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) { + fields, err := sqlFields(values) + if err != nil { + return nil, err + } + var field string + var value any + for f, v := range fields { + field, value = f, v + break + } + days, err := strconv.ParseInt(fmt.Sprintf("%v", value), 10, 64) + if err != nil { + return nil, err + } + firstDate := startOfPeriod(days, time.Now()) + if negate { + return squirrel.Or{ + squirrel.Lt{field: firstDate}, + squirrel.Eq{field: nil}, + }, nil + } + return squirrel.Gt{field: firstDate}, nil +} + +func startOfPeriod(numDays int64, from time.Time) string { + return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") +} + +func inList[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) { + playlistID, ok := values["id"].(string) + if !ok { + return nil, errors.New("playlist id not given") + } + subQuery := squirrel.Select("media_file_id"). + From("playlist_tracks pl"). + LeftJoin("playlist on pl.playlist_id = playlist.id"). + Where(squirrel.And{ + squirrel.Eq{"pl.playlist_id": playlistID}, + squirrel.Eq{"playlist.public": 1}, + }) + subSQL, subArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql() + if err != nil { + return nil, err + } + if negate { + return squirrel.Expr("media_file.id NOT IN ("+subSQL+")", subArgs...), nil + } + return squirrel.Expr("media_file.id IN ("+subSQL+")", subArgs...), nil +} + +func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { + if info.IsRole { + return roleCond{role: info.Name, cond: cond, not: negate} + } + return tagCond{tag: info.Name, numeric: info.Numeric, cond: cond, not: negate} +} + +type tagCond struct { + tag string + numeric bool + cond squirrel.Sqlizer + not bool +} + +func (e tagCond) ToSql() (string, []any, error) { + cond, args, err := e.cond.ToSql() + if e.numeric { + cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + } + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond) + if e.not { + cond = "not " + cond + } + return cond, args, err +} + +type roleCond struct { + role string + cond squirrel.Sqlizer + not bool +} + +func (e roleCond) ToSql() (string, []any, error) { + 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) + if e.not { + cond = "not " + cond + } + return cond, args, err +} + +func singleField[T ~map[string]any](values T) (string, any, criteria.FieldInfo, bool) { + if len(values) != 1 { + return "", nil, criteria.FieldInfo{}, false + } + for field, value := range values { + info, ok := criteria.LookupField(field) + return field, value, info, ok + } + return "", nil, criteria.FieldInfo{}, false +} + +func sqlFields[T ~map[string]any](values T) (map[string]any, error) { + fields := make(map[string]any, len(values)) + for field, value := range values { + info, ok := criteria.LookupField(field) + if !ok { + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + if info.IsTag || info.IsRole { + return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field) + } + sqlField, ok := fieldExpr(info.Name) + if !ok || sqlField == "" { + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + fields[sqlField] = value + } + return fields, nil +} + +func fieldExpr(name string) (string, bool) { + field, ok := smartPlaylistFields[strings.ToLower(name)] + return field.expr, ok +} + +func fieldJoinType(name string) smartPlaylistJoinType { + info, ok := criteria.LookupField(name) + if !ok { + return smartPlaylistJoinNone + } + field, ok := smartPlaylistFields[info.Name] + if !ok { + return smartPlaylistJoinNone + } + return field.joinType +} + +func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType { + var joins smartPlaylistJoinType + _ = criteria.Walk(c.criteria.Expression, func(expr criteria.Expression) error { + for field := range criteria.Fields(expr) { + joins |= fieldJoinType(field) + } + return nil + }) + return joins +} + +func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType { + joins := c.ExpressionJoins() + for _, sortField := range sortFields(c.criteria.Sort) { + joins |= fieldJoinType(sortField) + } + return joins +} + +func (c smartPlaylistCriteria) OrderBy() string { + sortValue := c.criteria.Sort + if sortValue == "" { + sortValue = "title" + } + + order := strings.ToLower(strings.TrimSpace(c.criteria.Order)) + if order != "" && order != "asc" && order != "desc" { + log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.criteria.Order) + order = "" + } + + parts := strings.Split(sortValue, ",") + fields := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + dir := "asc" + if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") { + if strings.HasPrefix(part, "-") { + dir = "desc" + } + part = strings.TrimSpace(part[1:]) + } + sortField := strings.ToLower(part) + mapped, ok := sortExpr(sortField) + if !ok { + log.Error("Invalid field in 'sort' field", "sort", sortField) + continue + } + if order == "desc" { + if dir == "asc" { + dir = "desc" + } else { + dir = "asc" + } + } + fields = append(fields, mapped+" "+dir) + } + return strings.Join(fields, ", ") +} + +func sortFields(sortValue string) []string { + if sortValue == "" { + sortValue = "title" + } + parts := strings.Split(sortValue, ",") + fields := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(part), "+-")) + if part != "" { + fields = append(fields, part) + } + } + return fields +} + +func sortExpr(sortField string) (string, bool) { + info, ok := criteria.LookupField(sortField) + if !ok { + return "", false + } + if field, ok := smartPlaylistFields[info.Name]; ok && field.order != "" { + return field.order, true + } + var mapped string + switch { + case info.IsTag: + mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name + "[0].value'), '')" + case info.IsRole: + mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name + "[0].name'), '')" + default: + field, ok := smartPlaylistFields[info.Name] + if !ok || field.expr == "" { + return "", false + } + mapped = field.expr + } + if info.Numeric { + mapped = fmt.Sprintf("CAST(%s AS REAL)", mapped) + } + return mapped, true +} diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go new file mode 100644 index 000000000..9fa5ff104 --- /dev/null +++ b/persistence/criteria_sql_test.go @@ -0,0 +1,170 @@ +package persistence + +import ( + "time" + + "github.com/navidrome/navidrome/model/criteria" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Smart playlist criteria SQL", func() { + BeforeEach(func() { + criteria.AddRoles([]string{"artist", "composer", "producer"}) + criteria.AddTagNames([]string{"genre", "mood", "releasetype"}) + criteria.AddNumericTags([]string{"rate"}) + }) + + DescribeTable("expressions", + func(expr criteria.Expression, expectedSQL string, expectedArgs ...any) { + 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(expectedSQL)) + Expect(args).To(HaveExactElements(expectedArgs...)) + }, + Entry("all group", + criteria.All{criteria.Contains{"title": "love"}, criteria.Gt{"rating": 3}}, + "(media_file.title LIKE ? AND COALESCE(annotation.rating, 0) > ?)", "%love%", 3), + Entry("any group", + criteria.Any{criteria.Is{"title": "Low Rider"}, criteria.Is{"album": "Best Of"}}, + "(media_file.title = ? OR media_file.album = ?)", "Low Rider", "Best Of"), + Entry("is string", criteria.Is{"title": "Low Rider"}, "media_file.title = ?", "Low Rider"), + Entry("is bool", criteria.Is{"loved": true}, "COALESCE(annotation.starred, false) = ?", true), + Entry("is numeric list", criteria.Is{"library_id": []int{1, 2}}, "media_file.library_id IN (?,?)", 1, 2), + Entry("is not", criteria.IsNot{"title": "Low Rider"}, "media_file.title <> ?", "Low Rider"), + Entry("gt", criteria.Gt{"playCount": 10}, "COALESCE(annotation.play_count, 0) > ?", 10), + Entry("lt", criteria.Lt{"playCount": 10}, "COALESCE(annotation.play_count, 0) < ?", 10), + Entry("contains", criteria.Contains{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider%"), + Entry("not contains", criteria.NotContains{"title": "Low Rider"}, "media_file.title NOT LIKE ?", "%Low Rider%"), + Entry("starts with", criteria.StartsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "Low Rider%"), + Entry("ends with", criteria.EndsWith{"title": "Low Rider"}, "media_file.title LIKE ?", "%Low Rider"), + Entry("in range", criteria.InTheRange{"year": []int{1980, 1990}}, "(media_file.year >= ? AND media_file.year <= ?)", 1980, 1990), + Entry("before", criteria.Before{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date < ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), + Entry("after", criteria.After{"lastPlayed": time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)}, "annotation.play_date > ?", time.Date(2021, 10, 1, 0, 0, 0, 0, time.Local)), + Entry("in playlist", criteria.InPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), + Entry("not in playlist", criteria.NotInPlaylist{"id": "deadbeef-dead-beef"}, "media_file.id NOT IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND playlist.public = ?))", "deadbeef-dead-beef", 1), + Entry("album annotation", criteria.Gt{"albumRating": 3}, "COALESCE(album_annotation.rating, 0) > ?", 3), + Entry("artist annotation", criteria.Is{"artistLoved": true}, "COALESCE(artist_annotation.starred, false) = ?", true), + Entry("tag is", criteria.Is{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), + Entry("tag is not", criteria.IsNot{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value = ?)", "Rock"), + Entry("tag contains", criteria.Contains{"genre": "Rock"}, "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), + Entry("tag not contains", criteria.NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), + 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("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%"), + ) + + It("builds relative date expressions", func() { + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("annotation.play_date > ?")) + Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now()))) + }) + + It("builds negated relative date expressions", func() { + sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.NotInTheLast{"lastPlayed": 30}}).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("(annotation.play_date < ? OR annotation.play_date IS NULL)")) + Expect(args).To(HaveExactElements(startOfPeriod(30, time.Now()))) + }) + + It("returns an error for unknown fields", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.EndsWith{"unknown": "value"}}).Where() + + Expect(err).To(MatchError("invalid field in criteria: unknown")) + }) + + Describe("sort", func() { + It("sorts by regular fields", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc")) + }) + + It("sorts by tag fields", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "genre"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.genre[0].value'), '') asc")) + }) + + It("sorts by role fields", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "artist"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') asc")) + }) + + It("casts numeric tags when sorting", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "rate"}).OrderBy()).To(Equal("CAST(COALESCE(json_extract(media_file.tags, '$.rate[0].value'), '') AS REAL) asc")) + }) + + It("sorts by albumtype alias", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "albumtype"}).OrderBy()).To(Equal("COALESCE(json_extract(media_file.tags, '$.releasetype[0].value'), '') asc")) + }) + + It("sorts by random", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "random"}).OrderBy()).To(Equal("random() asc")) + }) + + It("sorts by multiple fields", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title,-rating"}).OrderBy()).To(Equal("media_file.title asc, COALESCE(annotation.rating, 0) desc")) + }) + + It("reverts order when order is desc", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "-date,artist", Order: "desc"}).OrderBy()).To(Equal("media_file.date asc, COALESCE(json_extract(media_file.participants, '$.artist[0].name'), '') desc")) + }) + + It("ignores invalid sort fields", func() { + Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "bogus,title"}).OrderBy()).To(Equal("media_file.title asc")) + }) + }) + + It("has SQL mappings for all non-tag/non-role criteria fields", func() { + for _, name := range criteria.AllFieldNames() { + info, ok := criteria.LookupField(name) + Expect(ok).To(BeTrue(), "field %q registered but LookupField fails", name) + if info.IsTag || info.IsRole { + continue + } + _, hasSQLField := smartPlaylistFields[info.Name] + Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.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"} + cSQL := newSmartPlaylistCriteria(c) + + Expect(cSQL.ExpressionJoins()).To(Equal(smartPlaylistJoinNone)) + Expect(cSQL.RequiredJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + }) + + It("includes expression-based joins", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Gt{"albumRating": 3}}} + + Expect(newSmartPlaylistCriteria(c).ExpressionJoins().has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + }) + + It("detects nested album and artist joins", func() { + c := criteria.Criteria{Expression: criteria.All{ + criteria.Any{criteria.All{criteria.Is{"albumLoved": true}}}, + criteria.Any{criteria.Gt{"artistPlayCount": 10}}, + }} + + joins := newSmartPlaylistCriteria(c).RequiredJoins() + Expect(joins.has(smartPlaylistJoinAlbumAnnotation)).To(BeTrue()) + Expect(joins.has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) + }) + + It("detects join types from sort fields with direction prefixes", func() { + c := criteria.Criteria{Expression: criteria.All{criteria.Contains{"title": "love"}}, Sort: "-artistRating"} + + Expect(newSmartPlaylistCriteria(c).RequiredJoins().has(smartPlaylistJoinArtistAnnotation)).To(BeTrue()) + }) + }) +}) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 8d1bbe0f8..6bf3ded30 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -228,6 +228,7 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { // Re-populate playlist based on Smart Playlist criteria rules := *pls.Rules + rulesSQL := newSmartPlaylistCriteria(rules) // If the playlist depends on other playlists, recursively refresh them first childPlaylistIds := rules.ChildPlaylistIds() @@ -240,14 +241,15 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { r.refreshSmartPlaylist(childPls) } - sq := Select("row_number() over (order by "+rules.OrderBy()+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). + orderBy := rulesSQL.OrderBy() + sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). From("media_file").LeftJoin("annotation on ("+ "annotation.item_id = media_file.id"+ " AND annotation.item_type = 'media_file'"+ " AND annotation.user_id = ?)", usr.ID) // Conditionally join album/artist annotation tables only when referenced by criteria or sort - requiredJoins := rules.RequiredJoins() + requiredJoins := rulesSQL.RequiredJoins() sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, usr.ID) // Only include media files from libraries the user has access to @@ -256,7 +258,7 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { // Resolve percentage-based limit to an absolute number before applying criteria if rules.IsPercentageLimit() { // Use only expression-based joins for the COUNT query (sort joins are unnecessary) - exprJoins := rules.ExpressionJoins() + exprJoins := rulesSQL.ExpressionJoins() countSq := Select("count(*) as count").From("media_file"). LeftJoin("annotation on ("+ "annotation.item_id = media_file.id"+ @@ -264,7 +266,12 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { " AND annotation.user_id = ?)", usr.ID) countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, usr.ID) countSq = r.applyLibraryFilter(countSq, "media_file") - countSq = countSq.Where(rules) + cond, err := rulesSQL.Where() + if err != nil { + log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + return false + } + countSq = countSq.Where(cond) var res struct{ Count int64 } err = r.queryOne(countSq, &res) @@ -279,7 +286,11 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { } // Apply the criteria rules - sq = r.addCriteria(sq, rules) + sq, err = r.addCriteria(sq, rules) + if err != nil { + log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + return false + } insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) _, err = r.executeSQL(insSql) if err != nil { @@ -310,14 +321,14 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { return true } -func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins criteria.JoinType, userID string) SelectBuilder { - if joins.Has(criteria.JoinAlbumAnnotation) { +func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder { + if joins.has(smartPlaylistJoinAlbumAnnotation) { sq = sq.LeftJoin("annotation AS album_annotation ON ("+ "album_annotation.item_id = media_file.album_id"+ " AND album_annotation.item_type = 'album'"+ " AND album_annotation.user_id = ?)", userID) } - if joins.Has(criteria.JoinArtistAnnotation) { + if joins.has(smartPlaylistJoinArtistAnnotation) { sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ "artist_annotation.item_id = media_file.artist_id"+ " AND artist_annotation.item_type = 'artist'"+ @@ -326,15 +337,20 @@ func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, j return sq } -func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) SelectBuilder { - sql = sql.Where(c) +func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) (SelectBuilder, error) { + cSQL := newSmartPlaylistCriteria(c) + cond, err := cSQL.Where() + if err != nil { + return sql, err + } + sql = sql.Where(cond) if c.Limit > 0 { sql = sql.Limit(uint64(c.Limit)).Offset(uint64(c.Offset)) } - if order := c.OrderBy(); order != "" { + if order := cSQL.OrderBy(); order != "" { sql = sql.OrderBy(order) } - return sql + return sql, nil } func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error { From 84de06bfdf52acf9877722d8e6d3d4bd8552dc81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 25 Apr 2026 14:59:06 -0400 Subject: [PATCH 028/117] feat(smartplaylists): relax playlist visibility in inPlaylist/notInPlaylist rules (#5411) * test(e2e): add end-to-end tests for smart playlists functionality Signed-off-by: Deluan * fix: enforce playlist visibility in smart playlist InPlaylist/NotInPlaylist rules Previously, the InPlaylist/NotInPlaylist smart playlist criteria only allowed referencing public playlists, regardless of who owned the smart playlist. This was too restrictive for owners referencing their own private playlists and for admins who should have unrestricted access. The fix passes the smart playlist owner's identity and admin status into the criteria SQL builder, so that: admins can reference any playlist, regular users can reference public playlists plus their own private ones, and inaccessible referenced playlists produce a warning instead of a hard error. Also prevents recursive refresh of child playlists the owner cannot access. * test(e2e): clarify user roles and fix playlist visibility tests Renamed testUser/otherUser to adminUser/regularUser to make the admin vs regular user distinction explicit in test code. Fixed three playlist visibility tests that were evaluating as admin (bypassing all access checks) instead of as a regular user, so the public playlist path is now actually exercised. All playlist operator tests now use explicit evaluateRuleAs calls with the appropriate user role. * fix: sync rulesSQL criteria after limitPercent resolution The rulesSQL struct captures a copy of rules at creation time. When limitPercent is resolved later, rules.Limit is updated but rulesSQL still holds the stale value. This caused percentage-based smart playlist limits to be silently ignored. Fix by updating rulesSQL.criteria after the resolution. * refactor: convert inList to a method on smartPlaylistCriteria The inList function already receives ownerID and ownerIsAdmin from the smartPlaylistCriteria caller. Making it a method lets it access those fields directly from the receiver, simplifying the signature and staying consistent with exprSQL which was already converted to a method. * refactor: simplify function signatures by removing type parameters in criteria_sql.go Signed-off-by: Deluan --------- Signed-off-by: Deluan --- persistence/criteria_sql.go | 65 ++++++++++------ persistence/criteria_sql_test.go | 28 +++++++ .../e2e/e2e_suite_test.go | 59 ++++++++++----- .../e2e/smartplaylist_test.go | 74 ++++++++++++++----- persistence/playlist_repository.go | 17 +++-- 5 files changed, 176 insertions(+), 67 deletions(-) rename {core/playlists => persistence}/e2e/e2e_suite_test.go (85%) rename {core/playlists => persistence}/e2e/smartplaylist_test.go (76%) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 2f7b73885..c75f6467b 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -8,7 +8,7 @@ import ( "strings" "time" - squirrel "github.com/Masterminds/squirrel" + "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model/criteria" ) @@ -32,11 +32,24 @@ type smartPlaylistField struct { } type smartPlaylistCriteria struct { - criteria criteria.Criteria + criteria criteria.Criteria + ownerID string + ownerIsAdmin bool } -func newSmartPlaylistCriteria(c criteria.Criteria) smartPlaylistCriteria { - return smartPlaylistCriteria{criteria: c} +func newSmartPlaylistCriteria(c criteria.Criteria, opts ...func(*smartPlaylistCriteria)) smartPlaylistCriteria { + cSQL := smartPlaylistCriteria{criteria: c} + for _, opt := range opts { + opt(&cSQL) + } + return cSQL +} + +func withSmartPlaylistOwner(ownerID string, ownerIsAdmin bool) func(*smartPlaylistCriteria) { + return func(c *smartPlaylistCriteria) { + c.ownerID = ownerID + c.ownerIsAdmin = ownerIsAdmin + } } var smartPlaylistFields = map[string]smartPlaylistField{ @@ -109,15 +122,15 @@ func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { if c.criteria.Expression == nil { return squirrel.Expr("1 = 1"), nil } - return exprSQL(c.criteria.Expression) + return c.exprSQL(c.criteria.Expression) } -func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { +func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { switch e := expr.(type) { case criteria.All: and := squirrel.And{} for _, child := range e { - cond, err := exprSQL(child) + cond, err := c.exprSQL(child) if err != nil { return nil, err } @@ -127,7 +140,7 @@ func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { case criteria.Any: or := squirrel.Or{} for _, child := range e { - cond, err := exprSQL(child) + cond, err := c.exprSQL(child) if err != nil { return nil, err } @@ -171,15 +184,15 @@ func exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { case criteria.NotInTheLast: return periodExpr(e, true) case criteria.InPlaylist: - return inList(e, false) + return c.inList(e, false) case criteria.NotInPlaylist: - return inList(e, true) + return c.inList(e, true) default: return nil, fmt.Errorf("unknown criteria expression type %T", expr) } } -func isNotExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { +func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) { if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { return jsonExpr(info, squirrel.Eq{"value": value}, true), nil } @@ -190,7 +203,7 @@ func isNotExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { return squirrel.NotEq(fields), nil } -func mapExpr[T ~map[string]any](values T, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { +func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil } @@ -201,7 +214,7 @@ func mapExpr[T ~map[string]any](values T, makeCond func(map[string]any) squirrel return makeCond(fields), nil } -func likeExpr[T ~map[string]any](values T, pattern string, negate bool) (squirrel.Sqlizer, error) { +func likeExpr(values map[string]any, pattern string, negate bool) (squirrel.Sqlizer, error) { if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { return jsonExpr(info, squirrel.Like{"value": fmt.Sprintf(pattern, value)}, negate), nil } @@ -223,7 +236,7 @@ func likeExpr[T ~map[string]any](values T, pattern string, negate bool) (squirre return lk, nil } -func rangeExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { +func rangeExpr(values map[string]any) (squirrel.Sqlizer, error) { fields, err := sqlFields(values) if err != nil { return nil, err @@ -242,7 +255,7 @@ func rangeExpr[T ~map[string]any](values T) (squirrel.Sqlizer, error) { return and, nil } -func periodExpr[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) { +func periodExpr(values map[string]any, negate bool) (squirrel.Sqlizer, error) { fields, err := sqlFields(values) if err != nil { return nil, err @@ -271,18 +284,26 @@ func startOfPeriod(numDays int64, from time.Time) string { return from.Add(time.Duration(-24*numDays) * time.Hour).Format("2006-01-02") } -func inList[T ~map[string]any](values T, negate bool) (squirrel.Sqlizer, error) { +func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squirrel.Sqlizer, error) { playlistID, ok := values["id"].(string) if !ok { return nil, errors.New("playlist id not given") } + filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}} + if !c.ownerIsAdmin { + if c.ownerID == "" { + filters = append(filters, squirrel.Eq{"playlist.public": 1}) + } else { + filters = append(filters, squirrel.Or{ + squirrel.Eq{"playlist.public": 1}, + squirrel.Eq{"playlist.owner_id": c.ownerID}, + }) + } + } subQuery := squirrel.Select("media_file_id"). From("playlist_tracks pl"). LeftJoin("playlist on pl.playlist_id = playlist.id"). - Where(squirrel.And{ - squirrel.Eq{"pl.playlist_id": playlistID}, - squirrel.Eq{"playlist.public": 1}, - }) + Where(filters) subSQL, subArgs, err := subQuery.PlaceholderFormat(squirrel.Question).ToSql() if err != nil { return nil, err @@ -334,7 +355,7 @@ func (e roleCond) ToSql() (string, []any, error) { return cond, args, err } -func singleField[T ~map[string]any](values T) (string, any, criteria.FieldInfo, bool) { +func singleField(values map[string]any) (string, any, criteria.FieldInfo, bool) { if len(values) != 1 { return "", nil, criteria.FieldInfo{}, false } @@ -345,7 +366,7 @@ func singleField[T ~map[string]any](values T) (string, any, criteria.FieldInfo, return "", nil, criteria.FieldInfo{}, false } -func sqlFields[T ~map[string]any](values T) (map[string]any, error) { +func sqlFields(values map[string]any) (map[string]any, error) { fields := make(map[string]any, len(values)) for field, value := range values { info, ok := criteria.LookupField(field) diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 9fa5ff104..e06a4eccb 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -59,6 +59,34 @@ var _ = Describe("Smart playlist criteria SQL", func() { 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%"), ) + Describe("playlist permissions", func() { + It("allows public or same-owner playlist references for regular users", func() { + sqlizer, err := newSmartPlaylistCriteria( + criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, + withSmartPlaylistOwner("owner-id", false), + ).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ? AND (playlist.public = ? OR playlist.owner_id = ?)))")) + Expect(args).To(HaveExactElements("deadbeef-dead-beef", 1, "owner-id")) + }) + + It("allows all playlist references for admins", func() { + sqlizer, err := newSmartPlaylistCriteria( + criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, + withSmartPlaylistOwner("admin-id", true), + ).Where() + Expect(err).ToNot(HaveOccurred()) + + sql, args, err := sqlizer.ToSql() + Expect(err).ToNot(HaveOccurred()) + Expect(sql).To(Equal("media_file.id IN (SELECT media_file_id FROM playlist_tracks pl LEFT JOIN playlist on pl.playlist_id = playlist.id WHERE (pl.playlist_id = ?))")) + Expect(args).To(HaveExactElements("deadbeef-dead-beef")) + }) + }) + It("builds relative date expressions", func() { sqlizer, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.InTheLast{"lastPlayed": 30}}).Where() Expect(err).ToNot(HaveOccurred()) diff --git a/core/playlists/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go similarity index 85% rename from core/playlists/e2e/e2e_suite_test.go rename to persistence/e2e/e2e_suite_test.go index e9a717d0a..ea0d0fea8 100644 --- a/core/playlists/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -53,14 +53,14 @@ var ( snapshotPath string snapshotTables []string - testUser = model.User{ + adminUser = model.User{ ID: "sp-test-user-1", UserName: "sptestuser", Name: "SP Test User", IsAdmin: true, } - otherUser = model.User{ + regularUser = model.User{ ID: "sp-test-user-2", UserName: "spotheruser", Name: "SP Other User", @@ -147,25 +147,36 @@ func findMediaFileByTitle(title string) string { } func evaluateRule(jsonRule string) []string { - titles := evaluateRuleOrdered(jsonRule) + titles := evaluateRuleOrderedAs(adminUser, jsonRule) sort.Strings(titles) return titles } func evaluateRuleOrdered(jsonRule string) []string { + return evaluateRuleOrderedAs(adminUser, jsonRule) +} + +func evaluateRuleAs(owner model.User, jsonRule string) []string { + titles := evaluateRuleOrderedAs(owner, jsonRule) + sort.Strings(titles) + return titles +} + +func evaluateRuleOrderedAs(owner model.User, jsonRule string) []string { + userCtx := request.WithUser(GinkgoT().Context(), owner) var rules criteria.Criteria err := json.Unmarshal([]byte(jsonRule), &rules) Expect(err).ToNot(HaveOccurred(), "invalid criteria JSON: %s", jsonRule) pls := &model.Playlist{ Name: "test-smart-playlist", - OwnerID: testUser.ID, + OwnerID: owner.ID, Rules: &rules, } - err = ds.Playlist(ctx).Put(pls) + err = ds.Playlist(userCtx).Put(pls) Expect(err).ToNot(HaveOccurred()) - loaded, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false) + loaded, err := ds.Playlist(userCtx).GetWithTracks(pls.ID, true, false) Expect(err).ToNot(HaveOccurred()) titles := make([]string, len(loaded.Tracks)) @@ -198,12 +209,20 @@ func createPrivatePlaylist(owner model.User, titles ...string) string { } func createPublicSmartPlaylist(owner model.User, jsonRule string) string { + return createSmartPlaylist(owner, true, jsonRule) +} + +func createPrivateSmartPlaylist(owner model.User, jsonRule string) string { + return createSmartPlaylist(owner, false, jsonRule) +} + +func createSmartPlaylist(owner model.User, public bool, jsonRule string) string { var rules criteria.Criteria Expect(json.Unmarshal([]byte(jsonRule), &rules)).To(Succeed()) pls := &model.Playlist{ Name: "ref-smart-playlist", OwnerID: owner.ID, - Public: true, + Public: public, Rules: &rules, } Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) @@ -211,7 +230,7 @@ func createPublicSmartPlaylist(owner model.User, jsonRule string) string { } var _ = BeforeSuite(func() { - ctx = request.WithUser(GinkgoT().Context(), testUser) + ctx = request.WithUser(GinkgoT().Context(), adminUser) tmpDir := GinkgoT().TempDir() dbFilePath = filepath.Join(tmpDir, "smartplaylist-e2e.db") snapshotPath = filepath.Join(tmpDir, "smartplaylist-e2e.db.snapshot") @@ -226,28 +245,28 @@ var _ = BeforeSuite(func() { initDS := &tests.MockDataStore{RealDS: persistence.New(db.Db())} - userWithPass := testUser + userWithPass := adminUser userWithPass.NewPassword = "password" Expect(initDS.User(ctx).Put(&userWithPass)).To(Succeed()) - otherUserWithPass := otherUser - otherUserWithPass.NewPassword = "password" - Expect(initDS.User(ctx).Put(&otherUserWithPass)).To(Succeed()) + regularUserWithPass := regularUser + regularUserWithPass.NewPassword = "password" + Expect(initDS.User(ctx).Put(®ularUserWithPass)).To(Succeed()) lib = model.Library{ID: 1, Name: "Music Library", Path: "fake:///music"} Expect(initDS.Library(ctx).Put(&lib)).To(Succeed()) - Expect(initDS.User(ctx).SetUserLibraries(testUser.ID, []int{lib.ID})).To(Succeed()) - Expect(initDS.User(ctx).SetUserLibraries(otherUser.ID, []int{lib.ID})).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + Expect(initDS.User(ctx).SetUserLibraries(regularUser.ID, []int{lib.ID})).To(Succeed()) - loadedUser, err := initDS.User(ctx).FindByUsername(testUser.UserName) + loadedUser, err := initDS.User(ctx).FindByUsername(adminUser.UserName) Expect(err).ToNot(HaveOccurred()) - testUser.Libraries = loadedUser.Libraries + adminUser.Libraries = loadedUser.Libraries - loadedOther, err := initDS.User(ctx).FindByUsername(otherUser.UserName) + loadedOther, err := initDS.User(ctx).FindByUsername(regularUser.UserName) Expect(err).ToNot(HaveOccurred()) - otherUser.Libraries = loadedOther.Libraries + regularUser.Libraries = loadedOther.Libraries - ctx = request.WithUser(GinkgoT().Context(), testUser) + ctx = request.WithUser(GinkgoT().Context(), adminUser) buildTestFS() s := scanner.New(ctx, initDS, artwork.NoopCacheWarmer(), events.NoopBroker(), @@ -315,7 +334,7 @@ func restoreDB() { } func setupTestDB() { - ctx = request.WithUser(GinkgoT().Context(), testUser) + ctx = request.WithUser(GinkgoT().Context(), adminUser) DeferCleanup(configtest.SetupConfig()) conf.Server.MusicFolder = "fake:///music" conf.Server.DevExternalScanner = false diff --git a/core/playlists/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go similarity index 76% rename from core/playlists/e2e/smartplaylist_test.go rename to persistence/e2e/smartplaylist_test.go index 6e31c6787..658030de5 100644 --- a/core/playlists/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -1,8 +1,10 @@ package e2e import ( + "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + "github.com/sirupsen/logrus" ) var _ = Describe("Smart Playlists", func() { @@ -245,46 +247,82 @@ var _ = Describe("Smart Playlists", func() { Describe("Playlist operators", func() { It("matches tracks in a public regular playlist", func() { - refID := createPublicPlaylist(testUser, "Come Together", "So What") - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + refID := createPublicPlaylist(adminUser, "Come Together", "So What") + results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) Expect(results).To(ConsistOf("Come Together", "So What")) }) It("matches tracks not in a public regular playlist", func() { - refID := createPublicPlaylist(testUser, "Come Together", "So What") - results := evaluateRule(`{"all":[{"notInPlaylist":{"id":"` + refID + `"}}]}`) + refID := createPublicPlaylist(adminUser, "Come Together", "So What") + results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`) Expect(results).To(ConsistOf("Something", "Stairway To Heaven", "Black Dog", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) }) It("recursively refreshes a referenced smart playlist owned by the same user", func() { - smartBID := createPublicSmartPlaylist(testUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + smartBID + `"}}]}`) + smartBID := createPublicSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`) Expect(results).To(ConsistOf("So What")) }) It("does not refresh a referenced smart playlist owned by another user", func() { - smartBID := createPublicSmartPlaylist(otherUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + smartBID + `"}}]}`) + smartBID := createPublicSmartPlaylist(regularUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`) Expect(results).To(BeEmpty()) }) - It("does not match tracks from a private playlist", func() { - refID := createPrivatePlaylist(testUser, "Come Together", "So What") - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) - Expect(results).To(BeEmpty()) + It("does not refresh a playlist or its children when an admin views another user's smart playlist", func() { + smartBID := createPrivateSmartPlaylist(adminUser, `{"all":[{"is":{"genre":"Jazz"}}]}`) + smartAID := createPublicSmartPlaylist(regularUser, `{"all":[{"inPlaylist":{"id":"`+smartBID+`"}}]}`) + + loadedA, err := ds.Playlist(ctx).GetWithTracks(smartAID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(loadedA.Tracks).To(BeEmpty()) + Expect(loadedA.EvaluatedAt).To(BeNil()) + + loadedB, err := ds.Playlist(ctx).Get(smartBID) + Expect(err).ToNot(HaveOccurred()) + Expect(loadedB.EvaluatedAt).To(BeNil()) }) - It("matches tracks in a public playlist owned by another user", func() { - refID := createPublicPlaylist(otherUser, "Bohemian Rhapsody") - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + It("matches tracks from a private playlist owned by the same user", func() { + refID := createPrivatePlaylist(regularUser, "Come Together", "So What") + results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) + Expect(results).To(ConsistOf("Come Together", "So What")) + }) + + It("allows admin-owned smart playlists to reference private playlists owned by other users", func() { + refID := createPrivatePlaylist(regularUser, "Bohemian Rhapsody") + results := evaluateRuleAs(adminUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) Expect(results).To(ConsistOf("Bohemian Rhapsody")) }) - It("does not match tracks from a private playlist owned by another user", func() { - refID := createPrivatePlaylist(otherUser, "Bohemian Rhapsody") - results := evaluateRule(`{"all":[{"inPlaylist":{"id":"` + refID + `"}}]}`) + It("does not match tracks from a private playlist owned by another regular user", func() { + refID := createPrivatePlaylist(adminUser, "Come Together", "So What") + results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) Expect(results).To(BeEmpty()) }) + + It("warns when a referenced playlist is inaccessible to the smart playlist owner", func() { + hook, cleanup := tests.LogHook() + defer cleanup() + + refID := createPrivatePlaylist(adminUser, "Come Together") + results := evaluateRuleAs(regularUser, `{"all":[{"notInPlaylist":{"id":"`+refID+`"}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + + Expect(hook.LastEntry()).ToNot(BeNil()) + Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel)) + Expect(hook.LastEntry().Message).To(Equal("Referenced playlist is not accessible to smart playlist owner")) + Expect(hook.LastEntry().Data).To(HaveKeyWithValue("childId", refID)) + }) + + It("matches tracks in a public playlist owned by another user", func() { + refID := createPublicPlaylist(adminUser, "Bohemian Rhapsody") + results := evaluateRuleAs(regularUser, `{"all":[{"inPlaylist":{"id":"`+refID+`"}}]}`) + Expect(results).To(ConsistOf("Bohemian Rhapsody")) + }) + }) }) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 6bf3ded30..11e76fa8b 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -14,7 +14,6 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/pocketbase/dbx" ) @@ -228,13 +227,17 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { // Re-populate playlist based on Smart Playlist criteria rules := *pls.Rules - rulesSQL := newSmartPlaylistCriteria(rules) + rulesSQL := newSmartPlaylistCriteria(rules, withSmartPlaylistOwner(pls.OwnerID, usr.IsAdmin)) // If the playlist depends on other playlists, recursively refresh them first childPlaylistIds := rules.ChildPlaylistIds() for _, id := range childPlaylistIds { childPls, err := r.Get(id) if err != nil { + if errors.Is(err, model.ErrNotFound) { + log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) + continue + } log.Error(r.ctx, "Error loading child playlist", "id", pls.ID, "childId", id, err) return false } @@ -283,10 +286,11 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rules.LimitPercent, "totalMatching", res.Count, "resolvedLimit", resolvedLimit) rules.Limit = resolvedLimit rules.LimitPercent = 0 + rulesSQL.criteria = rules } // Apply the criteria rules - sq, err = r.addCriteria(sq, rules) + sq, err = r.addCriteria(sq, rulesSQL) if err != nil { log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) return false @@ -337,15 +341,14 @@ func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, j return sq } -func (r *playlistRepository) addCriteria(sql SelectBuilder, c criteria.Criteria) (SelectBuilder, error) { - cSQL := newSmartPlaylistCriteria(c) +func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) { cond, err := cSQL.Where() if err != nil { return sql, err } sql = sql.Where(cond) - if c.Limit > 0 { - sql = sql.Limit(uint64(c.Limit)).Offset(uint64(c.Offset)) + if cSQL.criteria.Limit > 0 { + sql = sql.Limit(uint64(cSQL.criteria.Limit)).Offset(uint64(cSQL.criteria.Offset)) } if order := cSQL.OrderBy(); order != "" { sql = sql.OrderBy(order) From f2be88dd16868b36867d2c16a6230ae251ec7a9f Mon Sep 17 00:00:00 2001 From: Daniele Massa <90097496+DanieleMassa@users.noreply.github.com> Date: Sat, 25 Apr 2026 22:18:51 +0200 Subject: [PATCH 029/117] fix(ui): completed Italian translation (#5407) Co-authored-by: Daniele Massa --- resources/i18n/it.json | 459 ++++++++++++++++++++++++++++++++--------- 1 file changed, 362 insertions(+), 97 deletions(-) diff --git a/resources/i18n/it.json b/resources/i18n/it.json index 11fadb46b..b91c04064 100644 --- a/resources/i18n/it.json +++ b/resources/i18n/it.json @@ -10,32 +10,48 @@ "playCount": "Riproduzioni", "title": "Titolo", "artist": "Artista", + "composer": "Compositore", "album": "Album", "path": "Percorso", + "libraryName": "Libreria", "genre": "Genere", "compilation": "Compilation", "year": "Anno", "size": "Dimensioni", "updatedAt": "Ultimo aggiornamento", "bitRate": "Bitrate", - "discSubtitle": "Sottotitoli disco", + "bitDepth": "Profondità di bit", + "sampleRate": "Frequenza di campionamento", + "albumGain": "Guadagno album", + "trackGain": "Guadagno traccia", + "channels": "Canali", + "disc": "Disco %{discNumber}", + "discSubtitle": "Sottotitolo disco", "starred": "Preferita", "comment": "Commento", "rating": "Valutazione", "quality": "Qualità", "bpm": "BPM", "playDate": "Ultima riproduzione", - "channels": "Canali", - "createdAt": "" + "createdAt": "Data di aggiunta", + "grouping": "Raggruppamento", + "mood": "Umore", + "participants": "Partecipanti aggiuntivi", + "tags": "Tag aggiuntivi", + "mappedTags": "Tag mappati", + "rawTags": "Tag grezzi", + "missing": "Mancante" }, "actions": { "addToQueue": "Aggiungi alla coda", "playNow": "Riproduci adesso", "addToPlaylist": "Aggiungi alla playlist", + "showInPlaylist": "Mostra nella playlist", "shuffleAll": "Riproduci casualmente", "download": "Scarica", "playNext": "Riproduci come successivo", - "info": "Informazioni" + "info": "Informazioni", + "instantMix": "Mix istantaneo" } }, "album": { @@ -46,29 +62,38 @@ "duration": "Durata", "songCount": "Tracce", "playCount": "Riproduzioni", + "size": "Dimensione", "name": "Nome", + "libraryName": "Libreria", "genre": "Genere", "compilation": "Compilation", "year": "Anno", + "date": "Data di registrazione", + "originalDate": "Originale", + "releaseDate": "Data di pubblicazione", + "releases": "Pubblicazione |||| Pubblicazioni", + "released": "Pubblicato", "updatedAt": "Ultimo aggiornamento", "comment": "Commento", "rating": "Valutazione", - "createdAt": "Data di creazione", - "size": "Dimensione", - "originalDate": "", - "releaseDate": "Data di pubblicazione", - "releases": "Pubblicazione |||| Pubblicazioni", - "released": "Pubblicato" + "createdAt": "Data di aggiunta", + "recordLabel": "Etichetta", + "catalogNum": "Numero di catalogo", + "releaseType": "Tipo", + "grouping": "Raggruppamento", + "media": "Media", + "mood": "Umore", + "missing": "Mancante" }, "actions": { "playAll": "Riproduci", "playNext": "Riproduci come successivo", "addToQueue": "Aggiungi alla coda", + "share": "Condividi", "shuffle": "Riproduci casualmente", - "addToPlaylist": "Aggiungi alla Playlist", + "addToPlaylist": "Aggiungi alla playlist", "download": "Scarica", - "info": "Informazioni", - "share": "Condividi" + "info": "Informazioni" }, "lists": { "all": "Tutti", @@ -86,10 +111,33 @@ "name": "Nome", "albumCount": "Album", "songCount": "Numero tracce", + "size": "Dimensione", "playCount": "Riproduzioni", "rating": "Valutazione", "genre": "Genere", - "size": "Dimensione" + "role": "Ruolo", + "missing": "Mancante" + }, + "roles": { + "albumartist": "Artista Album |||| Artisti Album", + "artist": "Artista |||| Artisti", + "composer": "Compositore |||| Compositori", + "conductor": "Direttore d'orchestra |||| Direttori d'orchestra", + "lyricist": "Paroliere |||| Parolieri", + "arranger": "Arrangiatore |||| Arrangiatori", + "producer": "Produttore |||| Produttori", + "director": "Direttore |||| Direttori", + "engineer": "Ingegnere del suono |||| Ingegneri del suono", + "mixer": "Mixer |||| Mixer", + "remixer": "Remixer |||| Remixer", + "djmixer": "DJ Mixer |||| DJ Mixer", + "performer": "Esecutore |||| Esecutori", + "maincredit": "Artista Album o Artista |||| Artisti Album o Artisti" + }, + "actions": { + "topSongs": "Brani più ascoltati", + "shuffle": "Riproduci casualmente", + "radio": "Radio" } }, "user": { @@ -97,31 +145,39 @@ "fields": { "userName": "Nome utente", "isAdmin": "Amministratore", - "lastLoginAt": "Ultimo accesso", + "lastLoginAt": "Ultimo login", + "lastAccessAt": "Ultimo accesso", "updatedAt": "Ultimo aggiornamento", "name": "Nome", "password": "Password", - "createdAt": "Creato a", + "createdAt": "Creato il", "changePassword": "Cambiare la password?", "currentPassword": "Password Attuale", "newPassword": "Nuova Password", - "token": "Token" + "token": "Token", + "libraries": "Librerie" }, "helperTexts": { - "name": "Le modifiche effettuate al tuo nome verrano mostrate al prossimo accesso" + "name": "Le modifiche effettuate al tuo nome verranno mostrate al prossimo accesso", + "libraries": "Seleziona librerie specifiche per questo utente, o lascia vuoto per usare le librerie predefinite" }, "notifications": { "created": "Utente creato", "updated": "Utente aggiornato", "deleted": "Utente eliminato" }, + "validation": { + "librariesRequired": "Almeno una libreria deve essere selezionata per gli utenti non amministratori" + }, "message": { - "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz.", - "clickHereForToken": "Clicca qui per ottenere il tuo token" + "listenBrainzToken": "Inserisci il tuo token utente ListenBrainz", + "clickHereForToken": "Clicca qui per ottenere il tuo token", + "selectAllLibraries": "Seleziona tutte le librerie", + "adminAutoLibraries": "Gli utenti amministratori hanno automaticamente accesso a tutte le librerie" } }, "player": { - "name": "Client |||| Client", + "name": "Lettore |||| Lettori", "fields": { "name": "Nome", "transcodingId": "Transcodifica", @@ -130,7 +186,7 @@ "userName": "Nome utente", "lastSeen": "Ultimo accesso", "reportRealPath": "Mostra percorso reale", - "scrobbleEnabled": "" + "scrobbleEnabled": "Invia scrobble ai servizi esterni" } }, "transcoding": { @@ -157,45 +213,203 @@ "path": "Importa da" }, "actions": { - "selectPlaylist": "Aggiungi tracce alla playlist:", - "addNewPlaylist": "Aggiungi \"%{name}\"", + "selectPlaylist": "Seleziona una playlist:", + "addNewPlaylist": "Crea \"%{name}\"", "export": "Esporta", + "saveQueue": "Salva la coda nella playlist", "makePublic": "Rendi Pubblica", - "makePrivate": "Rendi Privata" + "makePrivate": "Rendi Privata", + "searchOrCreate": "Cerca playlist o digita per crearne una nuova...", + "pressEnterToCreate": "Premi Invio per creare una nuova playlist", + "removeFromSelection": "Rimuovi dalla selezione" }, "message": { "duplicate_song": "Aggiungere i duplicati", - "song_exist": "Stanno essendo aggiunti dei duplicati nella playlist. Vuoi aggiungerli o saltarli?" + "song_exist": "Si stanno aggiungendo dei duplicati nella playlist. Vuoi aggiungerli o saltarli?", + "noPlaylistsFound": "Nessuna playlist trovata", + "noPlaylists": "Nessuna playlist disponibile" } }, "radio": { "name": "Radio |||| Radio", "fields": { "name": "Nome", - "streamUrl": "", - "homePageUrl": "", - "updatedAt": "", - "createdAt": "" + "streamUrl": "URL dello stream", + "homePageUrl": "URL della pagina web", + "updatedAt": "Ultimo aggiornamento", + "createdAt": "Data di creazione" }, "actions": { - "playNow": "" + "playNow": "Riproduci adesso" } }, "share": { - "name": "", + "name": "Condivisione |||| Condivisioni", "fields": { - "username": "", - "url": "", - "description": "", - "contents": "", - "expiresAt": "", - "lastVisitedAt": "", - "visitCount": "", - "format": "", - "maxBitRate": "", - "updatedAt": "", - "createdAt": "", - "downloadable": "" + "username": "Condiviso da", + "url": "URL", + "description": "Descrizione", + "downloadable": "Consenti i download?", + "contents": "Contenuti", + "expiresAt": "Scade il", + "lastVisitedAt": "Ultima visita", + "visitCount": "Visite", + "format": "Formato", + "maxBitRate": "Bitrate massimo", + "updatedAt": "Ultimo aggiornamento", + "createdAt": "Data di creazione" + }, + "notifications": {}, + "actions": {} + }, + "missing": { + "name": "File mancante |||| File mancanti", + "empty": "Nessun file mancante", + "fields": { + "path": "Percorso", + "size": "Dimensione", + "libraryName": "Libreria", + "updatedAt": "Scomparso il" + }, + "actions": { + "remove": "Rimuovi", + "remove_all": "Rimuovi tutti" + }, + "notifications": { + "removed": "File mancanti rimossi" + } + }, + "library": { + "name": "Libreria |||| Librerie", + "fields": { + "name": "Nome", + "path": "Percorso", + "remotePath": "Percorso remoto", + "lastScanAt": "Ultima scansione", + "songCount": "Tracce", + "albumCount": "Album", + "artistCount": "Artisti", + "totalSongs": "Tracce", + "totalAlbums": "Album", + "totalArtists": "Artisti", + "totalFolders": "Cartelle", + "totalFiles": "File", + "totalMissingFiles": "File mancanti", + "totalSize": "Dimensione totale", + "totalDuration": "Durata", + "defaultNewUsers": "Predefinita per i nuovi utenti", + "createdAt": "Creata il", + "updatedAt": "Aggiornata il" + }, + "sections": { + "basic": "Informazioni di base", + "statistics": "Statistiche" + }, + "actions": { + "scan": "Scansiona la libreria", + "quickScan": "Scansione rapida", + "fullScan": "Scansione completa", + "manageUsers": "Gestisci accesso utenti", + "viewDetails": "Visualizza dettagli" + }, + "notifications": { + "created": "Libreria creata con successo", + "updated": "Libreria aggiornata con successo", + "deleted": "Libreria eliminata con successo", + "scanStarted": "Scansione della libreria avviata", + "quickScanStarted": "Scansione rapida avviata", + "fullScanStarted": "Scansione completa avviata", + "scanError": "Errore durante l'avvio della scansione. Controlla i log", + "scanCompleted": "Scansione della libreria completata" + }, + "validation": { + "nameRequired": "Il nome della libreria è obbligatorio", + "pathRequired": "Il percorso della libreria è obbligatorio", + "pathNotDirectory": "Il percorso della libreria deve essere una directory", + "pathNotFound": "Percorso della libreria non trovato", + "pathNotAccessible": "Il percorso della libreria non è accessibile", + "pathInvalid": "Percorso della libreria non valido" + }, + "messages": { + "deleteConfirm": "Sei sicuro di voler eliminare questa libreria? Verranno rimossi tutti i dati associati e gli accessi degli utenti.", + "scanInProgress": "Scansione in corso...", + "noLibrariesAssigned": "Nessuna libreria assegnata a questo utente" + } + }, + "plugin": { + "name": "Plugin |||| Plugin", + "fields": { + "id": "ID", + "name": "Nome", + "description": "Descrizione", + "version": "Versione", + "author": "Autore", + "website": "Sito web", + "permissions": "Permessi", + "enabled": "Abilitato", + "status": "Stato", + "path": "Percorso", + "lastError": "Errore", + "hasError": "Errore", + "updatedAt": "Aggiornato il", + "createdAt": "Installato il", + "configKey": "Chiave", + "configValue": "Valore", + "allUsers": "Consenti tutti gli utenti", + "selectedUsers": "Utenti selezionati", + "allLibraries": "Consenti tutte le librerie", + "selectedLibraries": "Librerie selezionate", + "allowWriteAccess": "Consenti accesso in scrittura" + }, + "sections": { + "status": "Stato", + "info": "Informazioni sul plugin", + "configuration": "Configurazione", + "manifest": "Manifest", + "usersPermission": "Permessi utenti", + "libraryPermission": "Permesso libreria" + }, + "status": { + "enabled": "Abilitato", + "disabled": "Disabilitato" + }, + "actions": { + "enable": "Abilita", + "disable": "Disabilita", + "disabledDueToError": "Correggi l'errore prima di abilitare", + "disabledUsersRequired": "Seleziona gli utenti prima di abilitare", + "disabledLibrariesRequired": "Seleziona le librerie prima di abilitare", + "addConfig": "Aggiungi configurazione", + "rescan": "Riscansiona" + }, + "notifications": { + "enabled": "Plugin abilitato", + "disabled": "Plugin disabilitato", + "updated": "Plugin aggiornato", + "error": "Errore durante l'aggiornamento del plugin" + }, + "validation": { + "invalidJson": "La configurazione deve essere un JSON valido" + }, + "messages": { + "configHelp": "Configura il plugin usando coppie chiave-valore. Lascia vuoto se il plugin non richiede configurazione.", + "configValidationError": "Validazione della configurazione fallita:", + "schemaRenderError": "Impossibile visualizzare il modulo di configurazione. Lo schema del plugin potrebbe non essere valido.", + "clickPermissions": "Clicca su un permesso per i dettagli", + "noConfig": "Nessuna configurazione impostata", + "allUsersHelp": "Se abilitato, il plugin avrà accesso a tutti gli utenti, inclusi quelli creati in futuro.", + "noUsers": "Nessun utente selezionato", + "permissionReason": "Motivo", + "usersRequired": "Questo plugin richiede accesso alle informazioni degli utenti. Seleziona quali utenti il plugin può accedere, oppure abilita 'Consenti tutti gli utenti'.", + "allLibrariesHelp": "Se abilitato, il plugin avrà accesso a tutte le librerie, incluse quelle create in futuro.", + "noLibraries": "Nessuna libreria selezionata", + "librariesRequired": "Questo plugin richiede accesso alle informazioni delle librerie. Seleziona quali librerie il plugin può accedere, oppure abilita 'Consenti tutte le librerie'.", + "allowWriteAccessHelp": "Se abilitato, il plugin può modificare i file nelle directory della libreria. Per impostazione predefinita, i plugin hanno accesso in sola lettura.", + "requiredHosts": "Host richiesti" + }, + "placeholders": { + "configKey": "chiave", + "configValue": "valore" } } }, @@ -206,12 +420,13 @@ "confirmPassword": "Conferma la password", "buttonCreateAdmin": "Crea amministratore", "auth_check_error": "Per favore accedi per continuare", - "user_menu": "Profile", + "user_menu": "Profilo", "username": "Nome utente", "password": "Password", "sign_in": "Accedi", "sign_in_error": "Autenticazione fallita, per favore riprova", - "logout": "Disconnetti" + "logout": "Disconnetti", + "insightsCollectionNote": "Navidrome raccoglie dati di utilizzo anonimi per\nmigliorare il progetto. Clicca [qui] per saperne di più\ne per disattivarlo se lo desideri" }, "validation": { "invalidChars": "Per favore usa solo lettere e numeri", @@ -226,13 +441,14 @@ "oneOf": "Deve essere uno di: %{options}", "regex": "Deve rispettare il formato (espressione regolare): %{pattern}", "unique": "Deve essere unico", - "url": "" + "url": "Deve essere un URL valido" }, "action": { "add_filter": "Aggiungi un filtro", "add": "Aggiungi", "back": "Indietro", "bulk_actions": "Un elemento selezionato |||| %{smart_count} elementi selezionati", + "bulk_actions_mobile": "1 |||| %{smart_count}", "cancel": "Annulla", "clear_input_value": "Cancella", "clone": "Duplica", @@ -244,7 +460,7 @@ "list": "Elenco", "refresh": "Aggiorna", "remove_filter": "Rimuovi questo filtro", - "remove": "Remove", + "remove": "Rimuovi", "save": "Salva", "search": "Cerca", "show": "Mostra", @@ -255,17 +471,16 @@ "open_menu": "Apri menù", "close_menu": "Chiudi menù", "unselect": "Deseleziona", - "skip": "Saltare i duplicati", - "bulk_actions_mobile": "", - "share": "", - "download": "" + "skip": "Salta", + "share": "Condividi", + "download": "Scarica" }, "boolean": { - "true": "Si", + "true": "Sì", "false": "No" }, "page": { - "create": "Aggiungi %{name}", + "create": "Crea %{name}", "dashboard": "Pannello di controllo", "edit": "%{name} #%{id}", "error": "Qualcosa è andato storto", @@ -274,7 +489,7 @@ "not_found": "Non trovato", "show": "%{name} #%{id}", "empty": "Nessun %{name} per adesso.", - "invite": "Vuoi invitare un amico?" + "invite": "Vuoi aggiungerne uno?" }, "input": { "file": { @@ -308,17 +523,17 @@ "loading": "La pagina si sta caricando, solo un momento per favore", "no": "No", "not_found": "Hai inserito un URL inesistente, oppure hai cliccato un link errato.", - "yes": "Si", - "unsaved_changes": "Alcune modifiche non sono state salvate. Vuoi ripristinarle?" + "yes": "Sì", + "unsaved_changes": "Alcune modifiche non sono state salvate. Sei sicuro di volerle ignorare?" }, "navigation": { "no_results": "Nessun risultato trovato", "no_more_results": "La pagina numero %{page} è fuori dall'intervallo. Prova la pagina precedente.", - "page_out_of_boundaries": "Il numero di pagina %{page} è fuori dall’intervallo", - "page_out_from_end": "Non è possibile andare oltre l’ultima pagina", + "page_out_of_boundaries": "Il numero di pagina %{page} è fuori dall'intervallo", + "page_out_from_end": "Non è possibile andare oltre l'ultima pagina", "page_out_from_begin": "Non è possibile andare prima della prima pagina", "page_range_info": "%{offsetBegin}-%{offsetEnd} di %{total}", - "page_rows_per_page": "Righe per pagina:", + "page_rows_per_page": "Elementi per pagina:", "next": "Successivo", "prev": "Precedente", "skip_nav": "Passa al contenuto" @@ -334,7 +549,7 @@ "i18n_error": "Impossibile caricare la traduzione per la lingua selezionata", "canceled": "Azione annullata", "logged_out": "La sessione è scaduta, per favore accedi di nuovo.", - "new_version": "Una nuova versione è disponibile! Ricarica la pagina" + "new_version": "Una nuova versione è disponibile! Ricarica la pagina." }, "toggleFieldsMenu": { "columnsToDisplay": "Colonne da mostrare", @@ -344,39 +559,58 @@ } }, "message": { - "note": "Note", - "transcodingDisabled": "La possibilità di modificare le opzioni di transcodifica attraverso l’interfaccia web è disabilitata per ragioni di sicurezza. Se desideri cambiare (modificare o aggiungere) opzioni di transcodifica, riavvia il server con l’opzione %{config}.", - "transcodingEnabled": "Navidrome è al momento attivo con %{config}, rendendo possibile eseguire comandi remoti attraverso l’interfaccia web. Si raccomanda di disabilitare questa opzione per ragioni di sicurezza e di abilitarla solo per configurare le opzioni di transcodifica.", + "uploadCover": "Carica copertina", + "removeCover": "Rimuovi copertina", + "coverUploaded": "Copertina aggiornata", + "coverRemoved": "Copertina rimossa", + "coverUploadError": "Errore durante il caricamento della copertina", + "coverRemoveError": "Errore durante la rimozione della copertina", + "note": "NOTA", + "transcodingDisabled": "La possibilità di modificare le opzioni di transcodifica attraverso l'interfaccia web è disabilitata per ragioni di sicurezza. Se desideri cambiare (modificare o aggiungere) opzioni di transcodifica, riavvia il server con l'opzione %{config}.", + "transcodingEnabled": "Navidrome è al momento attivo con %{config}, rendendo possibile eseguire comandi di sistema dalle impostazioni di transcodifica tramite l'interfaccia web. Si raccomanda di disabilitare questa opzione per ragioni di sicurezza e di abilitarla solo per configurare le opzioni di transcodifica.", "songsAddedToPlaylist": "Aggiunta una traccia alla playlist |||| Aggiunte %{smart_count} tracce alla playlist", - "noPlaylistsAvailable": "Nessuna playlist", + "noSimilarSongsFound": "Nessuna traccia simile trovata", + "startingInstantMix": "Caricamento del Mix istantaneo...", + "noTopSongsFound": "Nessun brano più ascoltato trovato", + "noPlaylistsAvailable": "Nessuna disponibile", "delete_user_title": "Rimuovi utente '%{name}'", - "delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati, incluse playlist e impostazioni?", + "delete_user_content": "Sei sicuro di voler rimuovere questo utente e tutti i suoi dati (incluse playlist e impostazioni)?", + "remove_missing_title": "Rimuovi i file mancanti", + "remove_missing_content": "Sei sicuro di voler rimuovere i file mancanti selezionati dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", + "remove_all_missing_title": "Rimuovi tutti i file mancanti", + "remove_all_missing_content": "Sei sicuro di voler rimuovere tutti i file mancanti dal database? Verranno eliminati permanentemente tutti i riferimenti ad essi, inclusi i conteggi delle riproduzioni e le valutazioni.", "notifications_blocked": "Hai bloccato le notifiche per questo sito nelle tue impostazioni del browser", "notifications_not_available": "Questo browser non supporta le notifiche desktop o non stai accedendo a Navidrome tramite HTTPS", "lastfmLinkSuccess": "Collegamento a Last.fm riuscito e scrobbling abilitato", - "lastfmLinkFailure": "Non è stato possible collegare Last.fm", + "lastfmLinkFailure": "Non è stato possibile collegare Last.fm", "lastfmUnlinkSuccess": "Lo scrobbling è stato disabilitato e Last.fm è stato disconnesso", "lastfmUnlinkFailure": "Non è stato possibile scollegare Last.fm", + "listenBrainzLinkSuccess": "ListenBrainz collegato con successo, abilitato lo scrobbling per l'utente: %{user}", + "listenBrainzLinkFailure": "Non è stato possibile collegare ListenBrainz: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainz disconnesso e scrobbling disabilitato", + "listenBrainzUnlinkFailure": "Non è stato possibile disconnettere ListenBrainz", "openIn": { "lastfm": "Apri in Last.fm", "musicbrainz": "Apri in MusicBrainz" }, "lastfmLink": "Per saperne di più...", - "listenBrainzLinkSuccess": "ListenBrainz collegato con successo, abilitato lo scrobbling per l'utente %{user}", - "listenBrainzLinkFailure": "Non è stato possibile collegare ListenBrainz: %{error}", - "listenBrainzUnlinkSuccess": "", - "listenBrainzUnlinkFailure": "", - "downloadOriginalFormat": "", - "shareOriginalFormat": "", - "shareDialogTitle": "", - "shareBatchDialogTitle": "", - "shareSuccess": "", - "shareFailure": "", - "downloadDialogTitle": "", - "shareCopyToClipboard": "" + "shareOriginalFormat": "Condividi nel formato originale", + "shareDialogTitle": "Condividi %{resource} '%{name}'", + "shareBatchDialogTitle": "Condividi 1 %{resource} |||| Condividi %{smart_count} %{resource}", + "shareCopyToClipboard": "Copia negli appunti: Ctrl+C, Invio", + "shareSuccess": "URL copiato negli appunti: %{url}", + "shareFailure": "Errore durante la copia dell'URL %{url} negli appunti", + "downloadDialogTitle": "Scarica %{resource} '%{name}' (%{size})", + "downloadOriginalFormat": "Scarica nel formato originale" }, "menu": { "library": "Libreria", + "librarySelector": { + "allLibraries": "Tutte le librerie (%{count})", + "multipleLibraries": "%{selected} di %{total} librerie", + "selectLibraries": "Seleziona librerie", + "none": "Nessuna" + }, "settings": "Impostazioni", "version": "Versione", "theme": "Tema", @@ -387,21 +621,22 @@ "language": "Lingua", "defaultView": "Vista Predefinita", "desktop_notifications": "Notifiche desktop", + "lastfmNotConfigured": "La chiave API di Last.fm non è configurata", "lastfmScrobbling": "Esegui lo scrobbling tramite Last.fm", - "listenBrainzScrobbling": "", - "replaygain": "", - "preAmp": "", + "listenBrainzScrobbling": "Esegui lo scrobbling tramite ListenBrainz", + "replaygain": "Modalità ReplayGain", + "preAmp": "ReplayGain PreAmp (dB)", "gain": { - "none": "", - "album": "", - "track": "" + "none": "Disabilitato", + "album": "Usa guadagno album", + "track": "Usa guadagno traccia" } } }, "albumList": "Album", - "about": "Info", "playlists": "Playlist", - "sharedPlaylists": "Playlist Condivise" + "sharedPlaylists": "Playlist Condivise", + "about": "Info" }, "player": { "playListsText": "Coda", @@ -432,29 +667,59 @@ "links": { "homepage": "Sito web", "source": "Codice sorgente", - "featureRequests": "Richieste" + "featureRequests": "Richieste", + "lastInsightsCollection": "Ultima raccolta dati", + "insights": { + "disabled": "Disabilitato", + "waiting": "In attesa" + } + }, + "tabs": { + "about": "Info", + "config": "Configurazione" + }, + "config": { + "configName": "Nome configurazione", + "environmentVariable": "Variabile d'ambiente", + "currentValue": "Valore attuale", + "configurationFile": "File di configurazione", + "exportToml": "Esporta configurazione (TOML)", + "downloadToml": "Scarica configurazione (TOML)", + "exportSuccess": "Configurazione esportata negli appunti in formato TOML", + "exportFailed": "Impossibile copiare la configurazione", + "devFlagsHeader": "Flag di sviluppo (soggetti a modifiche/rimozione)", + "devFlagsComment": "Queste sono impostazioni sperimentali e potrebbero essere rimosse in versioni future" } }, "activity": { "title": "Attività", - "totalScanned": "Cartelle scansionate", - "quickScan": "Scansione veloce", - "fullScan": "Scansione completa", - "serverUptime": "Periodo di attività", - "serverDown": "OFFLINE" + "totalScanned": "Cartelle scansionate totali", + "quickScan": "Rapida", + "fullScan": "Completa", + "selectiveScan": "Selettiva", + "serverUptime": "Periodo di attività del server", + "serverDown": "OFFLINE", + "scanType": "Ultima scansione", + "status": "Errore di scansione", + "elapsedTime": "Tempo trascorso" + }, + "nowPlaying": { + "title": "In riproduzione", + "empty": "Nessuna riproduzione in corso", + "minutesAgo": "%{smart_count} minuto fa |||| %{smart_count} minuti fa" }, "help": { - "title": "Scorciatoie da Tastiera", + "title": "Scorciatoie da Tastiera di Navidrome", "hotkeys": { "show_help": "Mostra questa schermata", "toggle_menu": "Mostra/Nascondi la barra laterale", "toggle_play": "Riproduzione/Pausa", "prev_song": "Traccia Precedente", "next_song": "Traccia Successiva", + "current_song": "Vai alla traccia corrente", "vol_up": "Alza il Volume", "vol_down": "Abbassa il Volume", - "toggle_love": "Aggiungi questa traccia ai preferiti", - "current_song": "" + "toggle_love": "Aggiungi questa traccia ai preferiti" } } -} +} \ No newline at end of file From b5ceab344323b73229174986d94ac7746dba1ee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 25 Apr 2026 20:27:38 -0400 Subject: [PATCH 030/117] =?UTF-8?q?fix(search):=20normalization=20for=20no?= =?UTF-8?q?n-NFKD=20Unicode=20letters=20(=C3=B8,=20=C3=A6,=20=C5=93,=20?= =?UTF-8?q?=C3=9F)=20(#5413)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(search): transliterate non-ASCII letters symmetrically in FTS5 path Songs and artists with letters like ø, æ, œ, ß were unsearchable. The query path in server/subsonic/searching.go transliterates with sanitize.Accents (Øystein → Oystein), but the FTS5 tokenizer's remove_diacritics 2 only strips NFKD-decomposable marks — atomic letters with built-in strokes/ligatures survive tokenization, so the query side and index side disagreed. Apply sanitize.Accents on both sides: - normalizeForFTS now also emits an ASCII-transliterated form for each word, so search_normalized contains the variant the query produces. - buildFTS5Query transliterates the unquoted portion of the input so every caller (Subsonic, REST fullTextFilter) gets the same handling. Quoted phrases stay as typed, preserving phrase matches against the original title/artist columns. Existing libraries pick up the fix as records are re-scanned; users can trigger a manual full rescan to refresh older entries. * fix(search): cache transliteration and add ß/quoted-phrase test coverage Address review feedback: call sanitize.Accents once per word and reuse the result for both the punct-stripped and accent-only paths. Add missing test entries for ß→ss transliteration and quoted Unicode phrase preservation. --------- Co-authored-by: Claude --- persistence/sql_search_fts.go | 43 +++++++++++++++++++++--------- persistence/sql_search_fts_test.go | 18 +++++++++++-- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/persistence/sql_search_fts.go b/persistence/sql_search_fts.go index 1d4116b5d..e9b961d91 100644 --- a/persistence/sql_search_fts.go +++ b/persistence/sql_search_fts.go @@ -8,6 +8,7 @@ import ( "unicode/utf8" . "github.com/Masterminds/squirrel" + "github.com/deluan/sanitize" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" ) @@ -44,24 +45,33 @@ var fts5Operators = regexp.MustCompile(`(?i)\b(AND|OR|NOT|NEAR)\b`) // fts5LeadingStar matches a * at the start of a token. FTS5 only supports * at the end (prefix queries). var fts5LeadingStar = regexp.MustCompile(`(^|[\s])\*+`) -// normalizeForFTS takes multiple strings, strips non-letter/non-number characters from each word, -// and returns a space-separated string of words that changed after stripping (deduplicated). -// This is used at index time to create concatenated forms: "R.E.M." → "REM", "AC/DC" → "ACDC". +// normalizeForFTS takes multiple strings and returns a space-separated, deduplicated list of +// alternative searchable forms for each word: punctuation-stripped (R.E.M. → REM, AC/DC → ACDC) +// and ASCII-transliterated (Bjørk → Bjork, œuvre → oeuvre). The transliterated form is needed +// because FTS5's `unicode61 remove_diacritics 2` only handles NFKD-decomposable diacritics — +// atomic letters like ø/æ/œ/ß survive tokenization, so the query side and index side disagree +// without an explicit transliterated entry here. func normalizeForFTS(values ...string) string { seen := make(map[string]struct{}) var result []string + add := func(orig, variant string) { + if variant == "" || variant == orig { + return + } + lower := strings.ToLower(variant) + if _, ok := seen[lower]; ok { + return + } + seen[lower] = struct{}{} + result = append(result, variant) + } for _, v := range values { for _, word := range strings.Fields(v) { - stripped := fts5PunctStrip.ReplaceAllString(word, "") - if stripped == "" || stripped == word { - continue - } - lower := strings.ToLower(stripped) - if _, ok := seen[lower]; ok { - continue - } - seen[lower] = struct{}{} - result = append(result, stripped) + transliterated := sanitize.Accents(word) + // Concatenated ASCII form: R.E.M. → REM, AC/DC → ACDC, St-Étienne → StEtienne. + add(word, fts5PunctStrip.ReplaceAllString(transliterated, "")) + // Accent-only transliteration for words without name-punctuation (Bjørk → Bjork). + add(word, transliterated) } } return strings.Join(result, " ") @@ -158,6 +168,13 @@ func buildFTS5Query(userInput string) string { result = result[:start] + fmt.Sprintf("\x00PHRASE%d\x00", len(phrases)-1) + result[end+1:] } + // Transliterate non-ASCII letters in the unquoted portion (ø→o, æ→ae, œ→oe, ß→ss, …) + // so the query matches the ASCII variants emitted by normalizeForFTS at index time. + // FTS5's own `remove_diacritics 2` only strips NFKD-decomposable marks, so without + // this step queries for words containing these letters can miss. Quoted phrases are + // left untouched so they continue to match the original text in title/artist columns. + result = sanitize.Accents(result) + // Neutralize FTS5 operators by lowercasing them (FTS5 operators are case-sensitive: // AND, OR, NOT, NEAR are operators, but and, or, not, near are plain tokens) result = fts5Operators.ReplaceAllStringFunc(result, strings.ToLower) diff --git a/persistence/sql_search_fts_test.go b/persistence/sql_search_fts_test.go index d0e26c8e3..b54e5856a 100644 --- a/persistence/sql_search_fts_test.go +++ b/persistence/sql_search_fts_test.go @@ -37,7 +37,13 @@ var _ = DescribeTable("buildFTS5Query", Entry("handles punctuated word mixed with plain words", "best of a-ha", `best* AND of* AND ("a ha" OR aha*)`), Entry("handles contraction followed by plain words", "you've got", `("you ve" OR youve*) AND got*`), Entry("strips miscellaneous punctuation", "rock & roll, vol. 2", "rock* AND roll* AND vol* AND 2*"), - Entry("preserves unicode characters with diacritics", "Björk début", "Björk* AND début*"), + Entry("transliterates NFKD-decomposable diacritics", "Björk début", "Bjork* AND debut*"), + Entry("transliterates ø to o", "Øystein", "Oystein*"), + Entry("transliterates œ ligature to oe", "œuvre", "oeuvre*"), + Entry("transliterates æ ligature to ae", "Brennæ", "Brennae*"), + Entry("transliterates mixed unicode words", "Mø Sigur Rós", "Mo* AND Sigur* AND Ros*"), + Entry("transliterates ß to ss", "Straße", "Strasse*"), + Entry("preserves quoted unicode phrase verbatim", `"Björk"`, `"Björk"`), Entry("collapses dotted abbreviation into phrase", "R.E.M.", `"R E M"`), Entry("collapses abbreviation without trailing dot", "R.E.M", `"R E M"`), Entry("collapses abbreviation mixed with words", "best of R.E.M.", `best* AND of* AND "R E M"`), @@ -75,11 +81,19 @@ var _ = DescribeTable("normalizeForFTS", Entry("strips dots and concatenates", "REM", "R.E.M."), Entry("strips slash", "ACDC", "AC/DC"), Entry("strips hyphen", "Aha", "A-ha"), - Entry("skips unchanged words", "", "The Beatles"), + Entry("skips unchanged ASCII words", "", "The Beatles"), Entry("handles mixed input", "REM", "R.E.M.", "Automatic for the People"), Entry("deduplicates", "REM", "R.E.M.", "R.E.M."), Entry("strips apostrophe from word", "N", "Guns N' Roses"), Entry("handles multiple values with punctuation", "REM ACDC", "R.E.M.", "AC/DC"), + Entry("transliterates ø to o", "Bjork", "Bjørk"), + Entry("transliterates Ø to O", "Oystein", "Øystein"), + Entry("transliterates œ ligature to oe", "oeuvre", "œuvre"), + Entry("transliterates Latin diacritics", "cafe", "café"), + Entry("transliterates only the non-ASCII words", "Mo Ros", "Mø Rós"), + Entry("combines punctuation strip and transliteration", "StEtienne St-Etienne", "St-Étienne"), + Entry("deduplicates against punctuation form", "Cafe", "Café", "Cafe"), + Entry("transliterates ß to ss", "Strasse", "Straße"), ) var _ = DescribeTable("containsCJK", From 8ad6db793e5b4c3ab3a26b21c8900bc52f1cb486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 25 Apr 2026 20:54:02 -0400 Subject: [PATCH 031/117] feat(cli): add pls export/import subcommands for bulk playlist management (#5412) * refactor: rename ImportFile to ImportFromFolder in playlists service * feat: add ImportFile method with library/folder resolution * feat: allow sync flag upgrade on re-import of non-synced playlists * feat: add pls export subcommand with bulk and single export Add `navidrome pls export` command that supports: - Single playlist export to stdout (-p flag only) - Single playlist export to directory (-p and -o flags) - Bulk export of all playlists to a directory (-o flag only) - Filtering by user (-u flag) - Automatic filename sanitization and collision detection Also extracts findPlaylist helper from runExporter for reuse. * feat: add pls import subcommand with sync flag support * fix: improve error message for export without output directory * test: add tests for ImportFile sync flag and sync upgrade behavior * refactor: streamline export and import logic by removing redundant comments and improving library path matching Signed-off-by: Deluan * feat: update ImportFile method to include sync flag for playlist imports Signed-off-by: Deluan * feat: implement fetchPlaylists function to streamline playlist retrieval Signed-off-by: Deluan * feat: replace inline filename sanitization with centralized utility function Signed-off-by: Deluan * feat: refactor playlist import logic to consolidate sync handling and improve method signatures Signed-off-by: Deluan * fix: address code review feedback on playlist import/export - Fix duplicate playlist creation on non-sync re-import: only reconcile sync flag when the playlist was actually persisted (has an ID) - Distinguish "not in any library" from real errors in resolveFolder using a sentinel error, so DB/folder errors propagate instead of falling back to ImportM3U - Use bufio.Scanner in countM3UTrackLines instead of reading entire file * feat: replace bufio.Scanner with UTF8Reader and LinesFrom utility for improved file reading Signed-off-by: Deluan * fix: record path for outside-library imports to prevent duplicates Files outside all libraries now go through updatePlaylist with the absolute path recorded, so re-importing the same file updates the existing playlist instead of creating a duplicate. * refactor: name guard condition in updatePlaylist for readability Extracted the compound boolean expression into a named local variable `alreadyImportedAndNotSynced` to make the intent of the early-return guard clearer at a glance. * add godocs Signed-off-by: Deluan --------- Signed-off-by: Deluan --- cmd/pls.go | 224 ++++++++++++++++++++++++++---- core/archiver.go | 11 +- core/playlists/import.go | 110 +++++++++++++-- core/playlists/import_test.go | 214 ++++++++++++++++++++++++---- core/playlists/parse_m3u.go | 13 +- core/playlists/playlists.go | 5 +- scanner/phase_4_playlists.go | 2 +- scanner/phase_4_playlists_test.go | 6 +- utils/str/sanitize_strings.go | 16 +++ 9 files changed, 516 insertions(+), 85 deletions(-) diff --git a/cmd/pls.go b/cmd/pls.go index 9b94c9e8f..95cbe4eec 100644 --- a/cmd/pls.go +++ b/cmd/pls.go @@ -7,11 +7,19 @@ import ( "errors" "fmt" "os" + "path/filepath" "strconv" + "strings" "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/utils/ioutils" + "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" "github.com/spf13/cobra" ) @@ -20,6 +28,7 @@ var ( outputFile string userID string outputFormat string + syncFlag bool ) type displayPlaylist struct { @@ -41,6 +50,15 @@ func init() { listCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID") listCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]") plsCmd.AddCommand(listCommand) + + exportCommand.Flags().StringVarP(&playlistID, "playlist", "p", "", "playlist name or ID") + exportCommand.Flags().StringVarP(&outputFile, "output", "o", "", "output directory") + exportCommand.Flags().StringVarP(&userID, "user", "u", "", "username or ID") + plsCmd.AddCommand(exportCommand) + + importCommand.Flags().StringVarP(&userID, "user", "u", "", "owner username or ID (default: first admin)") + importCommand.Flags().BoolVar(&syncFlag, "sync", false, "mark imported playlists as synced") + plsCmd.AddCommand(importCommand) } var ( @@ -60,72 +78,165 @@ var ( runList(cmd.Context()) }, } + + exportCommand = &cobra.Command{ + Use: "export", + Short: "Export playlists to M3U files", + Long: "Export one or more Navidrome playlists to M3U files", + Run: func(cmd *cobra.Command, args []string) { + runExport(cmd.Context()) + }, + } + + importCommand = &cobra.Command{ + Use: "import [files...]", + Short: "Import M3U playlists", + Long: "Import one or more M3U files as Navidrome playlists", + Args: cobra.MinimumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + runImport(cmd.Context(), args) + }, + } ) -func runExporter(ctx context.Context) { - ds, ctx := getAdminContext(ctx) - playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false) +func fetchPlaylists(ctx context.Context, ds model.DataStore, sort string) model.Playlists { + options := model.QueryOptions{Sort: sort} + if userID != "" { + user, err := getUser(ctx, userID, ds) + if err != nil { + log.Fatal(ctx, "Error retrieving user", "username or id", userID) + } + options.Filters = squirrel.Eq{"owner_id": user.ID} + } + pls, err := ds.Playlist(ctx).GetAll(options) + if err != nil { + log.Fatal(ctx, "Failed to retrieve playlists", err) + } + return pls +} + +func findPlaylist(ctx context.Context, ds model.DataStore, nameOrID string) *model.Playlist { + playlist, err := ds.Playlist(ctx).GetWithTracks(nameOrID, true, false) if err != nil && !errors.Is(err, model.ErrNotFound) { - log.Fatal("Error retrieving playlist", "name", playlistID, err) + log.Fatal("Error retrieving playlist", "name", nameOrID, err) } if errors.Is(err, model.ErrNotFound) { - playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": playlistID}}) + playlists, err := ds.Playlist(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"playlist.name": nameOrID}}) if err != nil { - log.Fatal("Error retrieving playlist", "name", playlistID, err) + log.Fatal("Error retrieving playlist", "name", nameOrID, err) } if len(playlists) > 0 { playlist, err = ds.Playlist(ctx).GetWithTracks(playlists[0].ID, true, false) if err != nil { - log.Fatal("Error retrieving playlist", "name", playlistID, err) + log.Fatal("Error retrieving playlist", "name", nameOrID, err) } } } if playlist == nil { - log.Fatal("Playlist not found", "name", playlistID) + log.Fatal("Playlist not found", "name", nameOrID) } + return playlist +} + +func runExporter(ctx context.Context) { + ds, ctx := getAdminContext(ctx) + playlist := findPlaylist(ctx, ds, playlistID) pls := playlist.ToM3U8() if outputFile == "-" || outputFile == "" { println(pls) return } - - err = os.WriteFile(outputFile, []byte(pls), 0600) + err := os.WriteFile(outputFile, []byte(pls), 0600) if err != nil { log.Fatal("Error writing to the output file", "file", outputFile, err) } } +func runExport(ctx context.Context) { + ds, ctx := getAdminContext(ctx) + + if playlistID != "" && outputFile == "" { + playlist := findPlaylist(ctx, ds, playlistID) + println(playlist.ToM3U8()) + return + } + + if outputFile == "" { + log.Fatal("Output directory (-o) is required for bulk export or when filtering by user") + } + + info, err := os.Stat(outputFile) + if err != nil || !info.IsDir() { + log.Fatal("Output path must be an existing directory", "path", outputFile) + } + + if playlistID != "" { + pls := findPlaylist(ctx, ds, playlistID) + filename := str.SanitizeFilename(pls.Name) + ".m3u" + path := filepath.Join(outputFile, filename) + err := os.WriteFile(path, []byte(pls.ToM3U8()), 0600) + if err != nil { + log.Fatal("Error writing playlist", "file", path, err) + } + fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path) + return + } + + allPls := fetchPlaylists(ctx, ds, "name") + + nameCounts := make(map[string]int) + for _, pls := range allPls { + nameCounts[str.SanitizeFilename(pls.Name)]++ + } + + exported := 0 + for _, pls := range allPls { + plsWithTracks, err := ds.Playlist(ctx).GetWithTracks(pls.ID, true, false) + if err != nil { + log.Error("Error loading playlist tracks", "playlist", pls.Name, err) + continue + } + + sanitized := str.SanitizeFilename(pls.Name) + filename := sanitized + ".m3u" + if nameCounts[sanitized] > 1 { + shortID := pls.ID + if len(shortID) > 6 { + shortID = shortID[:6] + } + filename = sanitized + "_" + shortID + ".m3u" + } + + path := filepath.Join(outputFile, filename) + err = os.WriteFile(path, []byte(plsWithTracks.ToM3U8()), 0600) + if err != nil { + log.Error("Error writing playlist", "file", path, err) + continue + } + fmt.Printf("Exported \"%s\" to %s\n", pls.Name, path) + exported++ + } + fmt.Printf("\nExported %d playlists to %s\n", exported, outputFile) +} + func runList(ctx context.Context) { if outputFormat != "csv" && outputFormat != "json" { log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat) } ds, ctx := getAdminContext(ctx) - options := model.QueryOptions{Sort: "owner_name"} - - if userID != "" { - user, err := getUser(ctx, userID, ds) - if err != nil { - log.Fatal(ctx, "Error retrieving user", "username or id", userID) - } - options.Filters = squirrel.Eq{"owner_id": user.ID} - } - - playlists, err := ds.Playlist(ctx).GetAll(options) - if err != nil { - log.Fatal(ctx, "Failed to retrieve playlists", err) - } + allPls := fetchPlaylists(ctx, ds, "owner_name") if outputFormat == "csv" { w := csv.NewWriter(os.Stdout) _ = w.Write([]string{"playlist id", "playlist name", "owner id", "owner name", "public"}) - for _, playlist := range playlists { + for _, playlist := range allPls { _ = w.Write([]string{playlist.ID, playlist.Name, playlist.OwnerID, playlist.OwnerName, strconv.FormatBool(playlist.Public)}) } w.Flush() } else { - display := make(displayPlaylists, len(playlists)) - for idx, playlist := range playlists { + display := make(displayPlaylists, len(allPls)) + for idx, playlist := range allPls { display[idx].Id = playlist.ID display[idx].Name = playlist.Name display[idx].OwnerId = playlist.OwnerID @@ -137,3 +248,62 @@ func runList(ctx context.Context) { fmt.Printf("%s\n", j) } } + +func runImport(ctx context.Context, files []string) { + ds, ctx := getAdminContext(ctx) + + if userID != "" { + user, err := getUser(ctx, userID, ds) + if err != nil { + log.Fatal(ctx, "Error retrieving user", "username or id", userID) + } + ctx = request.WithUser(ctx, *user) + } + + pls := playlists.NewPlaylists(ds, core.NewImageUploadService()) + + for _, file := range files { + absPath, err := filepath.Abs(file) + if err != nil { + log.Error("Error resolving path", "file", file, err) + fmt.Fprintf(os.Stderr, "Error: could not resolve path %s: %v\n", file, err) + continue + } + + totalLines := countM3UTrackLines(absPath) + + imported, err := pls.ImportFile(ctx, absPath, syncFlag) + if err != nil { + log.Error("Error importing playlist", "file", absPath, err) + fmt.Fprintf(os.Stderr, "Error importing %s: %v\n", file, err) + continue + } + + matched := len(imported.Tracks) + if totalLines > 0 { + notFound := totalLines - matched + fmt.Printf("Imported \"%s\" — %d/%d tracks matched (%d not found)\n", imported.Name, matched, totalLines, notFound) + } else { + fmt.Printf("Imported \"%s\" — %d tracks\n", imported.Name, matched) + } + } +} + +func countM3UTrackLines(path string) int { + file, err := os.Open(path) + if err != nil { + return 0 + } + defer file.Close() + + count := 0 + reader := ioutils.UTF8Reader(file) + for line := range slice.LinesFrom(reader) { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + count++ + } + return count +} diff --git a/core/archiver.go b/core/archiver.go index 8305c4f6c..96cc2c31e 100644 --- a/core/archiver.go +++ b/core/archiver.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/utils/slice" + "github.com/navidrome/navidrome/utils/str" ) type Archiver interface { @@ -87,7 +88,7 @@ func (a *archiver) albumFilename(mf model.MediaFile, format string, isMultiDisc if isMultiDisc { file = fmt.Sprintf("Disc %02d/%s", mf.DiscNumber, file) } - return fmt.Sprintf("%s/%s", sanitizeName(mf.Album), file) + return fmt.Sprintf("%s/%s", str.SanitizeFilename(mf.Album), file) } func (a *archiver) ZipShare(ctx context.Context, id string, out io.Writer) error { @@ -126,7 +127,7 @@ func (a *archiver) zipMediaFiles(ctx context.Context, id, name string, format st // Add M3U file if requested if addM3U && len(zippedMfs) > 0 { - plsName := sanitizeName(name) + plsName := str.SanitizeFilename(name) w, err := z.CreateHeader(&zip.FileHeader{ Name: plsName + ".m3u", Modified: mfs[0].UpdatedAt, @@ -156,11 +157,7 @@ func (a *archiver) playlistFilename(mf model.MediaFile, format string, idx int) if format != "" && format != "raw" { ext = format } - return fmt.Sprintf("%02d - %s - %s.%s", idx+1, sanitizeName(mf.Artist), sanitizeName(mf.Title), ext) -} - -func sanitizeName(target string) string { - return strings.ReplaceAll(target, "/", "_") + return fmt.Sprintf("%02d - %s - %s.%s", idx+1, str.SanitizeFilename(mf.Artist), str.SanitizeFilename(mf.Title), ext) } func (a *archiver) addFileToZip(ctx context.Context, z *zip.Writer, mf model.MediaFile, format string, bitrate int, filename string) error { diff --git a/core/playlists/import.go b/core/playlists/import.go index 4462554c7..9d3ecabc5 100644 --- a/core/playlists/import.go +++ b/core/playlists/import.go @@ -3,6 +3,7 @@ package playlists import ( "context" "errors" + "fmt" "io" "os" "path/filepath" @@ -17,14 +18,89 @@ import ( "golang.org/x/text/unicode/norm" ) -func (s *playlists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { +func (s *playlists) ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error) { + absPath, err := filepath.Abs(absolutePath) + if err != nil { + return nil, fmt.Errorf("resolving absolute path: %w", err) + } + + dir := filepath.Dir(absPath) + filename := filepath.Base(absPath) + + folder, err := s.resolveFolder(ctx, dir) + if err != nil && !errors.Is(err, errNotInLibrary) { + return nil, err + } + if err == nil { + pls, err := s.importFromFolder(ctx, folder, filename, sync) + if err != nil { + return nil, err + } + if pls.ID != "" && pls.Sync != sync { + pls.Sync = sync + if putErr := s.ds.Playlist(ctx).Put(pls); putErr != nil { + return nil, putErr + } + } + return pls, nil + } + + log.Debug(ctx, "Playlist file is outside all libraries, using path-based import", "path", absPath) + pls, err := s.newSyncedPlaylist(dir, filename) + if err != nil { + return nil, fmt.Errorf("reading playlist file: %w", err) + } + pls.Sync = sync + + file, err := os.Open(absPath) + if err != nil { + return nil, fmt.Errorf("opening playlist file: %w", err) + } + defer file.Close() + + reader := ioutils.UTF8Reader(file) + if err := s.parseM3U(ctx, pls, nil, reader); err != nil { + return nil, err + } + if err := s.updatePlaylist(ctx, pls, sync); err != nil { + return nil, err + } + return pls, nil +} + +var errNotInLibrary = fmt.Errorf("path not in any library") + +func (s *playlists) resolveFolder(ctx context.Context, dir string) (*model.Folder, error) { + libs, err := s.ds.Library(ctx).GetAll() + if err != nil { + return nil, err + } + matcher := newLibraryMatcher(libs) + lib, ok := matcher.findLibrary(dir) + if !ok { + return nil, fmt.Errorf("%w: %s", errNotInLibrary, dir) + } + + folder, err := s.ds.Folder(ctx).GetByPath(lib, dir) + if err != nil { + return nil, fmt.Errorf("resolving folder for path %s: %w", dir, err) + } + folder.LibraryPath = lib.Path + return folder, nil +} + +func (s *playlists) ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { + return s.importFromFolder(ctx, folder, filename, false) +} + +func (s *playlists) importFromFolder(ctx context.Context, folder *model.Folder, filename string, forceSync bool) (*model.Playlist, error) { pls, err := s.parsePlaylist(ctx, filename, folder) if err != nil { log.Error(ctx, "Error parsing playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err) return nil, err } log.Debug(ctx, "Found playlist", "name", pls.Name, "lastUpdated", pls.UpdatedAt, "path", pls.Path, "numTracks", len(pls.Tracks)) - err = s.updatePlaylist(ctx, pls) + err = s.updatePlaylist(ctx, pls, forceSync) if err != nil { log.Error(ctx, "Error updating playlist", "path", filepath.Join(folder.AbsolutePath(), filename), err) } @@ -74,27 +150,31 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold return pls, err } -func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error { - owner, _ := request.UserFrom(ctx) - - // Try to find existing playlist by path. Since filesystem normalization differs across - // platforms (macOS uses NFD, Linux/Windows use NFC), we try both forms to match - // playlists that may have been imported on a different platform. - pls, err := s.ds.Playlist(ctx).FindByPath(newPls.Path) +// findByPathNormalized looks up a playlist by path, trying both NFC and NFD Unicode +// normalization forms to handle cross-platform filesystem differences. +func (s *playlists) findByPathNormalized(ctx context.Context, path string) (*model.Playlist, error) { + pls, err := s.ds.Playlist(ctx).FindByPath(path) if errors.Is(err, model.ErrNotFound) { - // Try alternate normalization form - altPath := norm.NFD.String(newPls.Path) - if altPath == newPls.Path { - altPath = norm.NFC.String(newPls.Path) + altPath := norm.NFD.String(path) + if altPath == path { + altPath = norm.NFC.String(path) } - if altPath != newPls.Path { + if altPath != path { pls, err = s.ds.Playlist(ctx).FindByPath(altPath) } } + return pls, err +} + +func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist, forceSync bool) error { + owner, _ := request.UserFrom(ctx) + + pls, err := s.findByPathNormalized(ctx, newPls.Path) if err != nil && !errors.Is(err, model.ErrNotFound) { return err } - if err == nil && !pls.Sync { + alreadyImportedAndNotSynced := err == nil && !pls.Sync && !forceSync + if alreadyImportedAndNotSynced { log.Debug(ctx, "Playlist already imported and not synced", "playlist", pls.Name, "path", pls.Path) return nil } diff --git a/core/playlists/import_test.go b/core/playlists/import_test.go index 53855d781..f2866fb60 100644 --- a/core/playlists/import_test.go +++ b/core/playlists/import_test.go @@ -39,7 +39,7 @@ var _ = Describe("Playlists - Import", func() { ctx = request.WithUser(ctx, model.User{ID: "123"}) }) - Describe("ImportFile", func() { + Describe("ImportFromFolder", func() { var folder *model.Folder BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) @@ -59,7 +59,7 @@ var _ = Describe("Playlists - Import", func() { Describe("M3U", func() { It("parses well-formed playlists", func() { - pls, err := ps.ImportFile(ctx, folder, "pls1.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "pls1.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.OwnerID).To(Equal("123")) Expect(pls.Tracks).To(HaveLen(2)) @@ -69,19 +69,19 @@ var _ = Describe("Playlists - Import", func() { }) It("parses playlists using LF ending", func() { - pls, err := ps.ImportFile(ctx, folder, "lf-ended.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "lf-ended.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) }) It("parses playlists using CR ending (old Mac format)", func() { - pls, err := ps.ImportFile(ctx, folder, "cr-ended.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "cr-ended.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) }) It("parses playlists with UTF-8 BOM marker", func() { - pls, err := ps.ImportFile(ctx, folder, "bom-test.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "bom-test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.OwnerID).To(Equal("123")) Expect(pls.Name).To(Equal("Test Playlist")) @@ -90,7 +90,7 @@ var _ = Describe("Playlists - Import", func() { }) It("parses UTF-16 LE encoded playlists with BOM and converts to UTF-8", func() { - pls, err := ps.ImportFile(ctx, folder, "bom-test-utf16.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "bom-test-utf16.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.OwnerID).To(Equal("123")) Expect(pls.Name).To(Equal("UTF-16 Test Playlist")) @@ -101,7 +101,7 @@ var _ = Describe("Playlists - Import", func() { It("parses #EXTALBUMARTURL with HTTP URL", func() { conf.Server.EnableM3UExternalAlbumArt = true - pls, err := ps.ImportFile(ctx, folder, "pls-with-art-url.m3u") + pls, err := ps.ImportFromFolder(ctx, folder, "pls-with-art-url.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal("https://example.com/cover.jpg")) Expect(pls.Tracks).To(HaveLen(2)) @@ -121,7 +121,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal(imgPath)) }) @@ -139,7 +139,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal(filepath.Join(tmpDir, "cover.jpg"))) }) @@ -158,7 +158,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal(imgPath)) }) @@ -177,7 +177,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(Equal(imgPath)) }) @@ -195,7 +195,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -212,7 +212,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -229,7 +229,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -247,7 +247,7 @@ var _ = Describe("Playlists - Import", func() { ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -275,12 +275,38 @@ var _ = Describe("Playlists - Import", func() { mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.UploadedImage).To(Equal("existing-id.jpg")) Expect(pls.ExternalImageURL).To(Equal("https://example.com/new-cover.jpg")) }) + It("skips non-synced playlist on re-import (respects user's choice)", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3"}} + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "existing-id", + Name: "Existing Playlist", + Path: plsFile, + Sync: false, + OwnerID: "123", + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") + Expect(err).ToNot(HaveOccurred()) + // updatePlaylist skips the non-synced playlist, so the returned + // playlist has no ID (was never persisted/updated). + Expect(pls.ID).To(BeEmpty()) + }) + It("clears ExternalImageURL on re-scan when directive is removed", func() { tmpDir := GinkgoT().TempDir() mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) @@ -301,7 +327,7 @@ var _ = Describe("Playlists - Import", func() { mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} plsFolder := &model.Folder{ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: ""} - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.ExternalImageURL).To(BeEmpty()) }) @@ -309,7 +335,7 @@ var _ = Describe("Playlists - Import", func() { Describe("NSP", func() { It("parses well-formed playlists", func() { - pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp") + pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp") Expect(err).ToNot(HaveOccurred()) Expect(mockPlsRepo.Last).To(Equal(pls)) Expect(pls.OwnerID).To(Equal("123")) @@ -322,17 +348,17 @@ var _ = Describe("Playlists - Import", func() { }) It("returns an error if the playlist is not well-formed", func() { tests.SkipOnWindows("line-ending differences affect JSON error offset") - _, err := ps.ImportFile(ctx, folder, "invalid_json.nsp") + _, err := ps.ImportFromFolder(ctx, folder, "invalid_json.nsp") Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'")) }) It("parses NSP with public: true and creates public playlist", func() { - pls, err := ps.ImportFile(ctx, folder, "public_playlist.nsp") + pls, err := ps.ImportFromFolder(ctx, folder, "public_playlist.nsp") Expect(err).ToNot(HaveOccurred()) Expect(pls.Name).To(Equal("Public Playlist")) Expect(pls.Public).To(BeTrue()) }) It("parses NSP with public: false and creates private playlist", func() { - pls, err := ps.ImportFile(ctx, folder, "private_playlist.nsp") + pls, err := ps.ImportFromFolder(ctx, folder, "private_playlist.nsp") Expect(err).ToNot(HaveOccurred()) Expect(pls.Name).To(Equal("Private Playlist")) Expect(pls.Public).To(BeFalse()) @@ -340,7 +366,7 @@ var _ = Describe("Playlists - Import", func() { It("uses server default when public field is absent", func() { conf.Server.DefaultPlaylistPublicVisibility = true - pls, err := ps.ImportFile(ctx, folder, "recently_played.nsp") + pls, err := ps.ImportFromFolder(ctx, folder, "recently_played.nsp") Expect(err).ToNot(HaveOccurred()) Expect(pls.Name).To(Equal("Recently Played")) Expect(pls.Public).To(BeTrue()) // Should be true since server default is true @@ -386,7 +412,7 @@ var _ = Describe("Playlists - Import", func() { Path: "", Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, filesystemName+".m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, filesystemName+".m3u") Expect(err).ToNot(HaveOccurred()) // Should update existing playlist, not create new one @@ -441,7 +467,7 @@ var _ = Describe("Playlists - Import", func() { Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library @@ -462,7 +488,7 @@ var _ = Describe("Playlists - Import", func() { Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) // Should only find abc.mp3, not outside.mp3 Expect(pls.Tracks).To(HaveLen(1)) @@ -499,7 +525,7 @@ var _ = Describe("Playlists - Import", func() { Name: "subfolder", // The folder name } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].Path).To(Equal("abc.mp3")) // From songsDir library @@ -542,7 +568,7 @@ var _ = Describe("Playlists - Import", func() { Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) Expect(pls.Tracks).To(HaveLen(2)) Expect(pls.Tracks[0].Path).To(Equal("rock.mp3")) // From music library @@ -593,7 +619,7 @@ var _ = Describe("Playlists - Import", func() { Name: "", } - pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u") + pls, err := ps.ImportFromFolder(ctx, plsFolder, "test.m3u") Expect(err).ToNot(HaveOccurred()) // Should have BOTH tracks, not just one @@ -616,6 +642,126 @@ var _ = Describe("Playlists - Import", func() { }) }) + Describe("ImportFile", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + ds.MockedMediaFile = &mockedMediaFileFromListRepo{data: []string{"test.mp3", "test.ogg"}} + }) + + It("resolves file inside a library and imports it", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + + mockFolderRepo := &mockFolderRepoForImport{ + folder: &model.Folder{ + ID: "1", + LibraryID: 1, + LibraryPath: tmpDir, + Path: "", + Name: "", + }, + } + ds.MockedFolder = mockFolderRepo + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsContent := "#PLAYLIST:My Playlist\ntest.mp3\ntest.ogg\n" + plsFile := filepath.Join(tmpDir, "my-playlist.m3u") + Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed()) + + pls, err := ps.ImportFile(ctx, plsFile, true) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Name).To(Equal("My Playlist")) + Expect(pls.Tracks).To(HaveLen(2)) + Expect(pls.Path).To(Equal(plsFile)) + Expect(pls.Sync).To(BeTrue()) + }) + + It("records path for files outside all libraries", func() { + tmpDir := GinkgoT().TempDir() + libDir := filepath.Join(tmpDir, "music") + Expect(os.Mkdir(libDir, 0755)).To(Succeed()) + mockLibRepo.SetData([]model.Library{{ID: 1, Path: libDir}}) + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsContent := "#PLAYLIST:External Playlist\n" + libDir + "/test.mp3\n" + plsFile := filepath.Join(tmpDir, "external.m3u") + Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed()) + + pls, err := ps.ImportFile(ctx, plsFile, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Name).To(Equal("External Playlist")) + Expect(pls.Path).To(Equal(plsFile)) + Expect(pls.Sync).To(BeFalse()) + }) + + It("imports with Sync=false", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + + mockFolderRepo := &mockFolderRepoForImport{ + folder: &model.Folder{ + ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "", + }, + } + ds.MockedFolder = mockFolderRepo + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + + pls, err := ps.ImportFile(ctx, plsFile, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Sync).To(BeFalse()) + }) + + It("imports with Sync=true", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + + mockFolderRepo := &mockFolderRepoForImport{ + folder: &model.Folder{ + ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "", + }, + } + ds.MockedFolder = mockFolderRepo + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + + pls, err := ps.ImportFile(ctx, plsFile, true) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.Sync).To(BeTrue()) + }) + + It("upgrades non-synced playlist to synced on re-import with sync=true", func() { + tmpDir := GinkgoT().TempDir() + mockLibRepo.SetData([]model.Library{{ID: 1, Path: tmpDir}}) + + mockFolderRepo := &mockFolderRepoForImport{ + folder: &model.Folder{ + ID: "1", LibraryID: 1, LibraryPath: tmpDir, Path: "", Name: "", + }, + } + ds.MockedFolder = mockFolderRepo + ps = playlists.NewPlaylists(ds, core.NewImageUploadService()) + + plsFile := filepath.Join(tmpDir, "test.m3u") + Expect(os.WriteFile(plsFile, []byte("test.mp3\n"), 0600)).To(Succeed()) + + existingPls := &model.Playlist{ + ID: "existing-id", Name: "Existing", Path: plsFile, + Sync: false, OwnerID: "123", + } + mockPlsRepo.PathMap = map[string]*model.Playlist{plsFile: existingPls} + + pls, err := ps.ImportFile(ctx, plsFile, true) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.ID).To(Equal("existing-id")) + Expect(pls.Sync).To(BeTrue()) + }) + }) + Describe("ImportM3U", func() { var repo *mockedMediaFileFromListRepo BeforeEach(func() { @@ -925,3 +1071,15 @@ func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFi } return mfs, nil } + +type mockFolderRepoForImport struct { + model.FolderRepository + folder *model.Folder +} + +func (m *mockFolderRepoForImport) GetByPath(_ model.Library, _ string) (*model.Folder, error) { + if m.folder != nil { + return m.folder, nil + } + return nil, model.ErrNotFound +} diff --git a/core/playlists/parse_m3u.go b/core/playlists/parse_m3u.go index b9f5c92a2..a64c337c9 100644 --- a/core/playlists/parse_m3u.go +++ b/core/playlists/parse_m3u.go @@ -163,17 +163,26 @@ type libraryMatcher struct { // findLibraryForPath finds which library contains the given absolute path. // Returns library ID and path, or 0 and empty string if not found. func (lm *libraryMatcher) findLibraryForPath(absolutePath string) (int, string) { + lib, ok := lm.findLibrary(absolutePath) + if !ok { + return 0, "" + } + return lib.ID, filepath.Clean(lib.Path) +} + +// findLibrary checks if the absolute path is under any of the library paths. +func (lm *libraryMatcher) findLibrary(absolutePath string) (model.Library, bool) { // Check sorted libraries (longest path first) to find the best match for i, cleanLibPath := range lm.cleanedPaths { // Check if absolutePath is under this library path if strings.HasPrefix(absolutePath, cleanLibPath) { // Ensure it's a proper path boundary (not just a prefix) if len(absolutePath) == len(cleanLibPath) || absolutePath[len(cleanLibPath)] == filepath.Separator { - return lm.libraries[i].ID, cleanLibPath + return lm.libraries[i], true } } } - return 0, "" + return model.Library{}, false } // newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first). diff --git a/core/playlists/playlists.go b/core/playlists/playlists.go index a0086cd2d..3da24706c 100644 --- a/core/playlists/playlists.go +++ b/core/playlists/playlists.go @@ -42,10 +42,11 @@ type Playlists interface { RemoveImage(ctx context.Context, playlistID string) error // Import - ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) + ImportFile(ctx context.Context, absolutePath string, sync bool) (*model.Playlist, error) + ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) ImportM3U(ctx context.Context, reader io.Reader) (*model.Playlist, error) - // REST adapters (follows Share/Library pattern) + // REST adapters NewRepository(ctx context.Context) rest.Repository TracksRepository(ctx context.Context, playlistId string, refreshSmartPlaylist bool) rest.Repository } diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index ab5f77ae0..f726343f2 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -100,7 +100,7 @@ func (p *phasePlaylists) processPlaylistsInFolder(folder *model.Folder) (*model. continue } // BFR: Check if playlist needs to be refreshed (timestamp, sync flag, etc) - pls, err := p.pls.ImportFile(p.ctx, folder, f.Name()) + pls, err := p.pls.ImportFromFolder(p.ctx, folder, f.Name()) if err != nil { continue } diff --git a/scanner/phase_4_playlists_test.go b/scanner/phase_4_playlists_test.go index 06e6fa686..0e01a7549 100644 --- a/scanner/phase_4_playlists_test.go +++ b/scanner/phase_4_playlists_test.go @@ -97,9 +97,9 @@ var _ = Describe("phasePlaylists", func() { _ = os.WriteFile(file1, []byte{}, 0600) _ = os.WriteFile(file2, []byte{}, 0600) - pls.On("ImportFile", mock.Anything, folder, "playlist1.m3u"). + pls.On("ImportFromFolder", mock.Anything, folder, "playlist1.m3u"). Return(&model.Playlist{}, nil) - pls.On("ImportFile", mock.Anything, folder, "playlist2.m3u"). + pls.On("ImportFromFolder", mock.Anything, folder, "playlist2.m3u"). Return(&model.Playlist{}, nil) _, err := phase.processPlaylistsInFolder(folder) @@ -134,7 +134,7 @@ type mockPlaylists struct { playlists.Playlists } -func (p *mockPlaylists) ImportFile(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { +func (p *mockPlaylists) ImportFromFolder(ctx context.Context, folder *model.Folder, filename string) (*model.Playlist, error) { args := p.Called(ctx, folder, filename) return args.Get(0).(*model.Playlist), args.Error(1) } diff --git a/utils/str/sanitize_strings.go b/utils/str/sanitize_strings.go index c121aefe7..11f828270 100644 --- a/utils/str/sanitize_strings.go +++ b/utils/str/sanitize_strings.go @@ -52,6 +52,22 @@ func SanitizeHTML(text string) string { return policy.Sanitize(html.UnescapeString(text)) } +var filenameReplacer = strings.NewReplacer( + "/", "_", + "\\", "_", + ":", "_", + "*", "_", + "?", "_", + "\"", "_", + "<", "_", + ">", "_", + "|", "_", +) + +func SanitizeFilename(name string) string { + return filenameReplacer.Replace(name) +} + func SanitizeFieldForSorting(originalValue string) string { v := strings.TrimSpace(sanitize.Accents(originalValue)) return Clear(strings.ToLower(v)) From b7f544e7c17ec31ed4e788d7a0200a8b0e6034cc Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 26 Apr 2026 10:43:54 -0400 Subject: [PATCH 032/117] refactor: simplify criteria Expression interface Replaced the Fields() type switch with a fields() method on the Expression interface, eliminating the need to update a central switch when adding new expression types. Removed the now-redundant criteriaExpression() marker method since fields() alone suffices to restrict the interface. Extracted a conjunction interface for the ChildPlaylistIds() lookup used by All and Any. --- model/criteria/criteria.go | 4 +- model/criteria/operators.go | 81 ++++++++++++++++++++----------------- model/criteria/walk.go | 37 +---------------- model/criteria/walk_test.go | 2 +- 4 files changed, 47 insertions(+), 77 deletions(-) diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index 1e161c9f2..e204e6972 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -9,7 +9,7 @@ import ( ) type Expression interface { - criteriaExpression() + fields() map[string]any } type Criteria struct { @@ -53,7 +53,7 @@ func (c Criteria) ChildPlaylistIds() []string { return nil } - if parent, ok := c.Expression.(interface{ ChildPlaylistIds() (ids []string) }); ok { + if parent, ok := c.Expression.(conjunction); ok { return parent.ChildPlaylistIds() } diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 6f911b12f..983c6aa1a 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -2,12 +2,17 @@ package criteria import "time" +// Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively +type conjunction interface { + ChildPlaylistIds() []string +} + type ( All []Expression And = All ) -func (All) criteriaExpression() {} +func (All) fields() map[string]any { return nil } func (all All) MarshalJSON() ([]byte, error) { return marshalConjunction("all", all) @@ -22,7 +27,7 @@ type ( Or = Any ) -func (Any) criteriaExpression() {} +func (Any) fields() map[string]any { return nil } func (any Any) MarshalJSON() ([]byte, error) { return marshalConjunction("any", any) @@ -35,128 +40,128 @@ func (any Any) ChildPlaylistIds() (ids []string) { type Is map[string]any type Eq = Is -func (Is) criteriaExpression() {} - func (is Is) MarshalJSON() ([]byte, error) { return marshalExpression("is", is) } +func (is Is) fields() map[string]any { return is } + type IsNot map[string]any -func (IsNot) criteriaExpression() {} - -func (in IsNot) MarshalJSON() ([]byte, error) { - return marshalExpression("isNot", in) +func (isn IsNot) MarshalJSON() ([]byte, error) { + return marshalExpression("isNot", isn) } -type Gt map[string]any +func (isn IsNot) fields() map[string]any { return isn } -func (Gt) criteriaExpression() {} +type Gt map[string]any func (gt Gt) MarshalJSON() ([]byte, error) { return marshalExpression("gt", gt) } -type Lt map[string]any +func (gt Gt) fields() map[string]any { return gt } -func (Lt) criteriaExpression() {} +type Lt map[string]any func (lt Lt) MarshalJSON() ([]byte, error) { return marshalExpression("lt", lt) } -type Before map[string]any +func (lt Lt) fields() map[string]any { return lt } -func (Before) criteriaExpression() {} +type Before map[string]any func (bf Before) MarshalJSON() ([]byte, error) { return marshalExpression("before", bf) } -type After Gt +func (bf Before) fields() map[string]any { return bf } -func (After) criteriaExpression() {} +type After Gt func (af After) MarshalJSON() ([]byte, error) { return marshalExpression("after", af) } -type Contains map[string]any +func (af After) fields() map[string]any { return af } -func (Contains) criteriaExpression() {} +type Contains map[string]any func (ct Contains) MarshalJSON() ([]byte, error) { return marshalExpression("contains", ct) } -type NotContains map[string]any +func (ct Contains) fields() map[string]any { return ct } -func (NotContains) criteriaExpression() {} +type NotContains map[string]any func (nct NotContains) MarshalJSON() ([]byte, error) { return marshalExpression("notContains", nct) } -type StartsWith map[string]any +func (nct NotContains) fields() map[string]any { return nct } -func (StartsWith) criteriaExpression() {} +type StartsWith map[string]any func (sw StartsWith) MarshalJSON() ([]byte, error) { return marshalExpression("startsWith", sw) } +func (sw StartsWith) fields() map[string]any { return sw } + type EndsWith map[string]any -func (EndsWith) criteriaExpression() {} - -func (sw EndsWith) MarshalJSON() ([]byte, error) { - return marshalExpression("endsWith", sw) +func (ew EndsWith) MarshalJSON() ([]byte, error) { + return marshalExpression("endsWith", ew) } -type InTheRange map[string]any +func (ew EndsWith) fields() map[string]any { return ew } -func (InTheRange) criteriaExpression() {} +type InTheRange map[string]any func (itr InTheRange) MarshalJSON() ([]byte, error) { return marshalExpression("inTheRange", itr) } -type InTheLast map[string]any +func (itr InTheRange) fields() map[string]any { return itr } -func (InTheLast) criteriaExpression() {} +type InTheLast map[string]any func (itl InTheLast) MarshalJSON() ([]byte, error) { return marshalExpression("inTheLast", itl) } -type NotInTheLast map[string]any +func (itl InTheLast) fields() map[string]any { return itl } -func (NotInTheLast) criteriaExpression() {} +type NotInTheLast map[string]any func (nitl NotInTheLast) MarshalJSON() ([]byte, error) { return marshalExpression("notInTheLast", nitl) } +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 (InPlaylist) criteriaExpression() {} - func (ipl InPlaylist) MarshalJSON() ([]byte, error) { return marshalExpression("inPlaylist", ipl) } +func (ipl InPlaylist) fields() map[string]any { return ipl } + type NotInPlaylist map[string]any -func (NotInPlaylist) criteriaExpression() {} - -func (ipl NotInPlaylist) MarshalJSON() ([]byte, error) { - return marshalExpression("notInPlaylist", ipl) +func (nipl NotInPlaylist) MarshalJSON() ([]byte, error) { + return marshalExpression("notInPlaylist", nipl) } +func (nipl NotInPlaylist) fields() map[string]any { return nipl } + func extractPlaylistIds(inputRule any) (ids []string) { var id string var ok bool diff --git a/model/criteria/walk.go b/model/criteria/walk.go index 62aaf97f8..acaf48289 100644 --- a/model/criteria/walk.go +++ b/model/criteria/walk.go @@ -32,41 +32,6 @@ func Walk(expr Expression, visit Visitor) error { return nil } -// Fields returns field values for leaf expressions only. -// Use Walk to traverse All and Any expressions before calling Fields. func Fields(expr Expression) map[string]any { - switch e := expr.(type) { - case Is: - return map[string]any(e) - case IsNot: - return map[string]any(e) - case Gt: - return map[string]any(e) - case Lt: - return map[string]any(e) - case Before: - return map[string]any(e) - case After: - return map[string]any(Gt(e)) - case Contains: - return map[string]any(e) - case NotContains: - return map[string]any(e) - case StartsWith: - return map[string]any(e) - case EndsWith: - return map[string]any(e) - case InTheRange: - return map[string]any(e) - case InTheLast: - return map[string]any(e) - case NotInTheLast: - return map[string]any(e) - case InPlaylist: - return map[string]any(e) - case NotInPlaylist: - return map[string]any(e) - default: - return nil - } + return expr.fields() } diff --git a/model/criteria/walk_test.go b/model/criteria/walk_test.go index 91438f095..2e0f12f8d 100644 --- a/model/criteria/walk_test.go +++ b/model/criteria/walk_test.go @@ -9,7 +9,7 @@ import ( type unknownExpression struct{} -func (unknownExpression) criteriaExpression() {} +func (unknownExpression) fields() map[string]any { return nil } var _ = Describe("Walk", func() { It("visits the expression tree depth-first", func() { From a40edb892e732f453100a76bf309eb99c542b9ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 26 Apr 2026 14:49:59 -0400 Subject: [PATCH 033/117] refactor: centralize criteria sort parsing and extract smart playlist logic (#5415) * test: add tests for recordingdate alias resolution in smart playlists Signed-off-by: Deluan * refactor: update FieldInfo structure and simplify fieldMap initialization Signed-off-by: Deluan * refactor: move sort parsing logic from persistence to criteria package Extracted sort field parsing, validation, and direction handling from persistence/criteria_sql.go into model/criteria/sort.go. The new OrderByFields method on Criteria parses the Sort/Order strings into validated SortField structs (field name + direction), resolving aliases and handling +/- prefixes and order inversion. The persistence layer now consumes these parsed fields and only handles SQL expression mapping. This centralizes sort parsing to enforce consistent implementations. * refactor: standardize field access in smartPlaylistCriteria structure Signed-off-by: Deluan * refactor: add ResolveLimit method to Criteria Moved the percentage-limit resolution logic from playlist_repository into Criteria.ResolveLimit, replacing the 3-line mutate-after-query pattern with a single method call. The method preserves LimitPercent rather than zeroing it, since IsPercentageLimit already returns false once Limit is set, making the clear redundant and lossy. * refactor: improve child playlist loading and error handling in refresh logic Signed-off-by: Deluan * refactor: extract smart playlist logic to dedicated files Moved refreshSmartPlaylist, addSmartPlaylistAnnotationJoins, and addCriteria methods from playlist_repository.go to a new smart_playlist_repository.go file. Extracted all smart playlist tests to smart_playlist_repository_test.go. Added DeferCleanup to the "valid rules" test to fix ordering flakiness when Ginkgo randomizes test execution across files. * refactor: break refreshSmartPlaylist into smaller focused methods Split the monolithic refreshSmartPlaylist method into discrete helpers for readability: shouldRefreshSmartPlaylist for guard checks, refreshChildPlaylists for recursive dependency refresh, resolvePercentageLimit for count-based limit resolution, buildSmartPlaylistQuery for assembling the SELECT with joins, and addMediaFileAnnotationJoin to DRY up the repeated annotation join clause. * refactor: deduplicate child playlist IDs in Criteria Signed-off-by: Deluan * refactor: simplify withSmartPlaylistOwner to accept model.User Replaced separate ownerID string and ownerIsAdmin bool parameters with a single model.User struct, reducing the field count in smartPlaylistCriteria and making the option function signature clearer. Updated all call sites and tests accordingly. * fix: handle empty sort fields and propagate child playlist load errors OrderByFields now falls back to [{title, asc}] when all user-supplied sort fields are invalid, preventing empty ORDER BY clauses that would produce invalid SQL in row_number() window functions. Also restored the original behavior where a DB error loading child playlists aborts the parent smart playlist refresh, by making refreshChildPlaylists return a bool. * refactor: log warning when no valid sort fields are found Signed-off-by: Deluan --------- Signed-off-by: Deluan --- model/criteria/criteria.go | 20 +- model/criteria/criteria_test.go | 47 ++ model/criteria/fields.go | 160 +++--- model/criteria/sort.go | 62 ++ model/criteria/sort_test.go | 103 ++++ persistence/criteria_sql.go | 90 +-- persistence/criteria_sql_test.go | 8 +- persistence/e2e/smartplaylist_test.go | 5 + persistence/playlist_repository.go | 156 ----- persistence/playlist_repository_test.go | 511 ----------------- persistence/smart_playlist_repository.go | 203 +++++++ persistence/smart_playlist_repository_test.go | 531 ++++++++++++++++++ 12 files changed, 1069 insertions(+), 827 deletions(-) create mode 100644 model/criteria/sort.go create mode 100644 model/criteria/sort_test.go create mode 100644 persistence/smart_playlist_repository.go create mode 100644 persistence/smart_playlist_repository_test.go diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go index e204e6972..31d208d08 100644 --- a/model/criteria/criteria.go +++ b/model/criteria/criteria.go @@ -4,6 +4,7 @@ package criteria import ( "encoding/json" "errors" + "slices" "github.com/navidrome/navidrome/log" ) @@ -42,6 +43,16 @@ func (c Criteria) EffectiveLimit(totalCount int64) int { return 0 } +// ResolveLimit converts a percentage-based limit into an absolute Limit using +// the given totalCount. It is a no-op when a fixed Limit is already set or when +// no percentage limit is configured. +func (c *Criteria) ResolveLimit(totalCount int64) { + if !c.IsPercentageLimit() { + return + } + c.Limit = c.EffectiveLimit(totalCount) +} + // IsPercentageLimit returns true when the criteria uses a valid percentage-based // limit (i.e. LimitPercent is in [1, 100] and no fixed Limit overrides it). func (c Criteria) IsPercentageLimit() bool { @@ -53,11 +64,14 @@ func (c Criteria) ChildPlaylistIds() []string { return nil } - if parent, ok := c.Expression.(conjunction); ok { - return parent.ChildPlaylistIds() + parent, ok := c.Expression.(conjunction) + if !ok { + return nil } - return nil + ids := parent.ChildPlaylistIds() + slices.Sort(ids) + return slices.Compact(ids) } func (c Criteria) MarshalJSON() ([]byte, error) { diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go index e0940a509..092cfd36a 100644 --- a/model/criteria/criteria_test.go +++ b/model/criteria/criteria_test.go @@ -177,6 +177,39 @@ var _ = Describe("Criteria", func() { }) }) + Describe("ResolveLimit", func() { + It("resolves percentage to absolute limit preserving LimitPercent", func() { + c := Criteria{LimitPercent: 10} + c.ResolveLimit(450) + gomega.Expect(c.Limit).To(gomega.Equal(45)) + }) + + It("does nothing when Limit is already set", func() { + c := Criteria{Limit: 50, LimitPercent: 10} + c.ResolveLimit(1000) + gomega.Expect(c.Limit).To(gomega.Equal(50)) + }) + + It("does nothing when no limit is configured", func() { + c := Criteria{} + c.ResolveLimit(1000) + gomega.Expect(c.Limit).To(gomega.Equal(0)) + }) + + It("sets minimum 1 when percentage rounds to 0 and totalCount > 0", func() { + c := Criteria{LimitPercent: 1} + c.ResolveLimit(5) + gomega.Expect(c.Limit).To(gomega.Equal(1)) + }) + + It("is idempotent when called twice", func() { + c := Criteria{LimitPercent: 10} + c.ResolveLimit(450) + c.ResolveLimit(450) + gomega.Expect(c.Limit).To(gomega.Equal(45)) + }) + }) + Describe("IsPercentageLimit", func() { It("returns true when LimitPercent is set and Limit is 0", func() { c := Criteria{LimitPercent: 10} @@ -269,5 +302,19 @@ var _ = Describe("Criteria", func() { ids := Criteria{Expression: Is{"title": "Low Rider"}}.ChildPlaylistIds() gomega.Expect(ids).To(gomega.BeEmpty()) }) + It("deduplicates repeated playlist IDs", func() { + sharedID := uuid.NewString() + goObj = Criteria{ + Expression: All{ + InPlaylist{"id": sharedID}, + Any{ + InPlaylist{"id": sharedID}, + NotInPlaylist{"id": sharedID}, + }, + }, + } + ids := goObj.ChildPlaylistIds() + gomega.Expect(ids).To(gomega.Equal([]string{sharedID})) + }) }) }) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 30541a945..20c0048b3 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -2,90 +2,83 @@ package criteria import "strings" -// FieldInfo describes a criteria field without tying it to persistence details. +// FieldInfo contains semantic metadata about a criteria field type FieldInfo struct { Name string IsTag bool IsRole bool Numeric bool + alias string } -var fieldMap = map[string]*fieldMetadata{ - "title": {name: "title"}, - "album": {name: "album"}, - "hascoverart": {name: "hascoverart"}, - "tracknumber": {name: "tracknumber"}, - "discnumber": {name: "discnumber"}, - "year": {name: "year"}, - "date": {name: "date", alias: "recordingdate"}, - "originalyear": {name: "originalyear"}, - "originaldate": {name: "originaldate"}, - "releaseyear": {name: "releaseyear"}, - "releasedate": {name: "releasedate"}, - "size": {name: "size"}, - "compilation": {name: "compilation"}, - "missing": {name: "missing"}, - "explicitstatus": {name: "explicitstatus"}, - "dateadded": {name: "dateadded"}, - "datemodified": {name: "datemodified"}, - "discsubtitle": {name: "discsubtitle"}, - "comment": {name: "comment"}, - "lyrics": {name: "lyrics"}, - "sorttitle": {name: "sorttitle"}, - "sortalbum": {name: "sortalbum"}, - "sortartist": {name: "sortartist"}, - "sortalbumartist": {name: "sortalbumartist"}, - "albumcomment": {name: "albumcomment"}, - "catalognumber": {name: "catalognumber"}, - "filepath": {name: "filepath"}, - "filetype": {name: "filetype"}, - "codec": {name: "codec"}, - "duration": {name: "duration"}, - "bitrate": {name: "bitrate"}, - "bitdepth": {name: "bitdepth"}, - "samplerate": {name: "samplerate"}, - "bpm": {name: "bpm"}, - "channels": {name: "channels"}, - "loved": {name: "loved"}, - "dateloved": {name: "dateloved"}, - "lastplayed": {name: "lastplayed"}, - "daterated": {name: "daterated"}, - "playcount": {name: "playcount"}, - "rating": {name: "rating"}, - "averagerating": {name: "averagerating", numeric: true}, - "albumrating": {name: "albumrating"}, - "albumloved": {name: "albumloved"}, - "albumplaycount": {name: "albumplaycount"}, - "albumlastplayed": {name: "albumlastplayed"}, - "albumdateloved": {name: "albumdateloved"}, - "albumdaterated": {name: "albumdaterated"}, - "artistrating": {name: "artistrating"}, - "artistloved": {name: "artistloved"}, - "artistplaycount": {name: "artistplaycount"}, - "artistlastplayed": {name: "artistlastplayed"}, - "artistdateloved": {name: "artistdateloved"}, - "artistdaterated": {name: "artistdaterated"}, - "mbz_album_id": {name: "mbz_album_id"}, - "mbz_album_artist_id": {name: "mbz_album_artist_id"}, - "mbz_artist_id": {name: "mbz_artist_id"}, - "mbz_recording_id": {name: "mbz_recording_id"}, - "mbz_release_track_id": {name: "mbz_release_track_id"}, - "mbz_release_group_id": {name: "mbz_release_group_id"}, - "library_id": {name: "library_id", numeric: true}, +var fieldMap = map[string]FieldInfo{ + "title": {Name: "title"}, + "album": {Name: "album"}, + "hascoverart": {Name: "hascoverart"}, + "tracknumber": {Name: "tracknumber"}, + "discnumber": {Name: "discnumber"}, + "year": {Name: "year"}, + "date": {Name: "date", alias: "recordingdate"}, + "originalyear": {Name: "originalyear"}, + "originaldate": {Name: "originaldate"}, + "releaseyear": {Name: "releaseyear"}, + "releasedate": {Name: "releasedate"}, + "size": {Name: "size"}, + "compilation": {Name: "compilation"}, + "missing": {Name: "missing"}, + "explicitstatus": {Name: "explicitstatus"}, + "dateadded": {Name: "dateadded"}, + "datemodified": {Name: "datemodified"}, + "discsubtitle": {Name: "discsubtitle"}, + "comment": {Name: "comment"}, + "lyrics": {Name: "lyrics"}, + "sorttitle": {Name: "sorttitle"}, + "sortalbum": {Name: "sortalbum"}, + "sortartist": {Name: "sortartist"}, + "sortalbumartist": {Name: "sortalbumartist"}, + "albumcomment": {Name: "albumcomment"}, + "catalognumber": {Name: "catalognumber"}, + "filepath": {Name: "filepath"}, + "filetype": {Name: "filetype"}, + "codec": {Name: "codec"}, + "duration": {Name: "duration"}, + "bitrate": {Name: "bitrate"}, + "bitdepth": {Name: "bitdepth"}, + "samplerate": {Name: "samplerate"}, + "bpm": {Name: "bpm"}, + "channels": {Name: "channels"}, + "loved": {Name: "loved"}, + "dateloved": {Name: "dateloved"}, + "lastplayed": {Name: "lastplayed"}, + "daterated": {Name: "daterated"}, + "playcount": {Name: "playcount"}, + "rating": {Name: "rating"}, + "averagerating": {Name: "averagerating", Numeric: true}, + "albumrating": {Name: "albumrating"}, + "albumloved": {Name: "albumloved"}, + "albumplaycount": {Name: "albumplaycount"}, + "albumlastplayed": {Name: "albumlastplayed"}, + "albumdateloved": {Name: "albumdateloved"}, + "albumdaterated": {Name: "albumdaterated"}, + "artistrating": {Name: "artistrating"}, + "artistloved": {Name: "artistloved"}, + "artistplaycount": {Name: "artistplaycount"}, + "artistlastplayed": {Name: "artistlastplayed"}, + "artistdateloved": {Name: "artistdateloved"}, + "artistdaterated": {Name: "artistdaterated"}, + "mbz_album_id": {Name: "mbz_album_id"}, + "mbz_album_artist_id": {Name: "mbz_album_artist_id"}, + "mbz_artist_id": {Name: "mbz_artist_id"}, + "mbz_recording_id": {Name: "mbz_recording_id"}, + "mbz_release_track_id": {Name: "mbz_release_track_id"}, + "mbz_release_group_id": {Name: "mbz_release_group_id"}, + "library_id": {Name: "library_id", Numeric: true}, // Backward compatibility: albumtype is an alias for the releasetype tag. - "albumtype": {name: "releasetype", isTag: true}, + "albumtype": {Name: "releasetype", IsTag: true}, - "random": {name: "random"}, - "value": {name: "value"}, -} - -type fieldMetadata struct { - name string - isRole bool - isTag bool - alias string - numeric bool + "random": {Name: "random"}, + "value": {Name: "value"}, } // AllFieldNames returns the names of all registered criteria fields. @@ -100,15 +93,7 @@ func AllFieldNames() []string { // LookupField returns semantic metadata for a criteria field name. func LookupField(name string) (FieldInfo, bool) { f, ok := fieldMap[strings.ToLower(name)] - if !ok { - return FieldInfo{}, false - } - return FieldInfo{ - Name: f.name, - IsTag: f.isTag, - IsRole: f.isRole, - Numeric: f.numeric, - }, true + return f, ok } // AddRoles adds roles to the field map. This is used to add all artist roles to the field map, so they can be used in @@ -119,7 +104,7 @@ func AddRoles(roles []string) { if _, ok := fieldMap[name]; ok { continue } - fieldMap[name] = &fieldMetadata{name: name, isRole: true} + fieldMap[name] = FieldInfo{Name: name, IsRole: true} } } @@ -138,7 +123,7 @@ func AddTagNames(tagNames []string) { } } if _, ok := fieldMap[name]; !ok { - fieldMap[name] = &fieldMetadata{name: name, isTag: true} + fieldMap[name] = FieldInfo{Name: name, IsTag: true} } } } @@ -148,9 +133,10 @@ func AddNumericTags(tagNames []string) { for _, tagName := range tagNames { name := strings.ToLower(tagName) if fm, ok := fieldMap[name]; ok { - fm.numeric = true + fm.Numeric = true + fieldMap[name] = fm } else { - fieldMap[name] = &fieldMetadata{name: name, isTag: true, numeric: true} + fieldMap[name] = FieldInfo{Name: name, IsTag: true, Numeric: true} } } } diff --git a/model/criteria/sort.go b/model/criteria/sort.go new file mode 100644 index 000000000..05b108cf9 --- /dev/null +++ b/model/criteria/sort.go @@ -0,0 +1,62 @@ +package criteria + +import ( + "strings" + + "github.com/navidrome/navidrome/log" +) + +type SortField struct { + Field string + Desc bool +} + +func (c Criteria) OrderByFields() []SortField { + sortValue := c.Sort + if sortValue == "" { + sortValue = "title" + } + + order := strings.ToLower(strings.TrimSpace(c.Order)) + if order != "" && order != "asc" && order != "desc" { + log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.Order) + order = "" + } + + parts := strings.Split(sortValue, ",") + fields := make([]SortField, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + desc := false + if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") { + desc = strings.HasPrefix(part, "-") + part = strings.TrimSpace(part[1:]) + } + info, ok := LookupField(part) + if !ok { + log.Error("Invalid field in 'sort' field", "sort", part) + continue + } + if order == "desc" { + desc = !desc + } + fields = append(fields, SortField{Field: info.Name, Desc: desc}) + } + if len(fields) == 0 { + log.Warn("No valid sort fields found in 'sort', falling back to 'title'", "sort", sortValue) + return []SortField{{Field: "title", Desc: false}} + } + return fields +} + +func (c Criteria) SortFieldNames() []string { + sortFields := c.OrderByFields() + names := make([]string, len(sortFields)) + for i, sf := range sortFields { + names[i] = sf.Field + } + return names +} diff --git a/model/criteria/sort_test.go b/model/criteria/sort_test.go new file mode 100644 index 000000000..35db88549 --- /dev/null +++ b/model/criteria/sort_test.go @@ -0,0 +1,103 @@ +package criteria + +import ( + . "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +var _ = Describe("OrderByFields", func() { + It("defaults to title ascending when Sort is empty", func() { + c := Criteria{} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("parses a single field", func() { + c := Criteria{Sort: "title"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("parses descending prefix", func() { + c := Criteria{Sort: "-rating"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "rating", Desc: true}})) + }) + + It("parses ascending prefix", func() { + c := Criteria{Sort: "+title"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("parses multiple comma-separated fields", func() { + c := Criteria{Sort: "title,-rating"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{ + {Field: "title", Desc: false}, + {Field: "rating", Desc: true}, + })) + }) + + It("inverts directions when Order is desc", func() { + c := Criteria{Sort: "-date,title", Order: "desc"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{ + {Field: "date", Desc: false}, + {Field: "title", Desc: true}, + })) + }) + + It("skips invalid fields", func() { + c := Criteria{Sort: "bogus,title"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("falls back to title when all fields are invalid", func() { + c := Criteria{Sort: "bogus,invalid"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: false}})) + }) + + It("resolves tag aliases (albumtype -> releasetype)", func() { + c := Criteria{Sort: "albumtype"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "releasetype", Desc: false}})) + }) + + It("resolves field aliases (recordingdate -> date)", func() { + AddTagNames([]string{"recordingdate"}) + c := Criteria{Sort: "recordingdate"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "date", Desc: false}})) + }) + + It("handles the random field", func() { + c := Criteria{Sort: "random"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "random", Desc: false}})) + }) + + It("ignores invalid Order value", func() { + c := Criteria{Sort: "-title", Order: "invalid"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{{Field: "title", Desc: true}})) + }) + + It("handles whitespace in fields", func() { + c := Criteria{Sort: " title , -rating "} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{ + {Field: "title", Desc: false}, + {Field: "rating", Desc: true}, + })) + }) + + It("skips empty parts from trailing commas", func() { + c := Criteria{Sort: "title,,rating,"} + gomega.Expect(c.OrderByFields()).To(gomega.Equal([]SortField{ + {Field: "title", Desc: false}, + {Field: "rating", Desc: false}, + })) + }) +}) + +var _ = Describe("SortFieldNames", func() { + It("returns canonical field names", func() { + c := Criteria{Sort: "title,-rating,albumtype"} + gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title", "rating", "releasetype"})) + }) + + It("defaults to title when Sort is empty", func() { + c := Criteria{} + gomega.Expect(c.SortFieldNames()).To(gomega.Equal([]string{"title"})) + }) +}) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index c75f6467b..1431f0d0e 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -9,7 +9,7 @@ import ( "time" "github.com/Masterminds/squirrel" - "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" ) @@ -32,23 +32,21 @@ type smartPlaylistField struct { } type smartPlaylistCriteria struct { - criteria criteria.Criteria - ownerID string - ownerIsAdmin bool + criteria.Criteria + owner model.User } func newSmartPlaylistCriteria(c criteria.Criteria, opts ...func(*smartPlaylistCriteria)) smartPlaylistCriteria { - cSQL := smartPlaylistCriteria{criteria: c} + cSQL := smartPlaylistCriteria{Criteria: c} for _, opt := range opts { opt(&cSQL) } return cSQL } -func withSmartPlaylistOwner(ownerID string, ownerIsAdmin bool) func(*smartPlaylistCriteria) { +func withSmartPlaylistOwner(owner model.User) func(*smartPlaylistCriteria) { return func(c *smartPlaylistCriteria) { - c.ownerID = ownerID - c.ownerIsAdmin = ownerIsAdmin + c.owner = owner } } @@ -119,10 +117,10 @@ var smartPlaylistFields = map[string]smartPlaylistField{ } func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { - if c.criteria.Expression == nil { + if c.Criteria.Expression == nil { return squirrel.Expr("1 = 1"), nil } - return c.exprSQL(c.criteria.Expression) + return c.exprSQL(c.Criteria.Expression) } func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqlizer, error) { @@ -290,13 +288,13 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir return nil, errors.New("playlist id not given") } filters := squirrel.And{squirrel.Eq{"pl.playlist_id": playlistID}} - if !c.ownerIsAdmin { - if c.ownerID == "" { + if !c.owner.IsAdmin { + if c.owner.ID == "" { filters = append(filters, squirrel.Eq{"playlist.public": 1}) } else { filters = append(filters, squirrel.Or{ squirrel.Eq{"playlist.public": 1}, - squirrel.Eq{"playlist.owner_id": c.ownerID}, + squirrel.Eq{"playlist.owner_id": c.owner.ID}, }) } } @@ -404,7 +402,7 @@ func fieldJoinType(name string) smartPlaylistJoinType { func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType { var joins smartPlaylistJoinType - _ = criteria.Walk(c.criteria.Expression, func(expr criteria.Expression) error { + _ = criteria.Walk(c.Criteria.Expression, func(expr criteria.Expression) error { for field := range criteria.Fields(expr) { joins |= fieldJoinType(field) } @@ -415,69 +413,27 @@ func (c smartPlaylistCriteria) ExpressionJoins() smartPlaylistJoinType { func (c smartPlaylistCriteria) RequiredJoins() smartPlaylistJoinType { joins := c.ExpressionJoins() - for _, sortField := range sortFields(c.criteria.Sort) { - joins |= fieldJoinType(sortField) + for _, name := range c.Criteria.SortFieldNames() { + joins |= fieldJoinType(name) } return joins } func (c smartPlaylistCriteria) OrderBy() string { - sortValue := c.criteria.Sort - if sortValue == "" { - sortValue = "title" - } - - order := strings.ToLower(strings.TrimSpace(c.criteria.Order)) - if order != "" && order != "asc" && order != "desc" { - log.Error("Invalid value in 'order' field. Valid values: 'asc', 'desc'", "order", c.criteria.Order) - order = "" - } - - parts := strings.Split(sortValue, ",") - fields := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(part) - if part == "" { + sortFields := c.Criteria.OrderByFields() + parts := make([]string, 0, len(sortFields)) + for _, sf := range sortFields { + mapped, ok := sortExpr(sf.Field) + if !ok { continue } dir := "asc" - if strings.HasPrefix(part, "+") || strings.HasPrefix(part, "-") { - if strings.HasPrefix(part, "-") { - dir = "desc" - } - part = strings.TrimSpace(part[1:]) + if sf.Desc { + dir = "desc" } - sortField := strings.ToLower(part) - mapped, ok := sortExpr(sortField) - if !ok { - log.Error("Invalid field in 'sort' field", "sort", sortField) - continue - } - if order == "desc" { - if dir == "asc" { - dir = "desc" - } else { - dir = "asc" - } - } - fields = append(fields, mapped+" "+dir) + parts = append(parts, mapped+" "+dir) } - return strings.Join(fields, ", ") -} - -func sortFields(sortValue string) []string { - if sortValue == "" { - sortValue = "title" - } - parts := strings.Split(sortValue, ",") - fields := make([]string, 0, len(parts)) - for _, part := range parts { - part = strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(part), "+-")) - if part != "" { - fields = append(fields, part) - } - } - return fields + return strings.Join(parts, ", ") } func sortExpr(sortField string) (string, bool) { diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index e06a4eccb..e02032d9a 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -3,6 +3,7 @@ package persistence import ( "time" + "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/criteria" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -11,7 +12,7 @@ import ( var _ = Describe("Smart playlist criteria SQL", func() { BeforeEach(func() { criteria.AddRoles([]string{"artist", "composer", "producer"}) - criteria.AddTagNames([]string{"genre", "mood", "releasetype"}) + criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate"}) criteria.AddNumericTags([]string{"rate"}) }) @@ -54,6 +55,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { Entry("tag not contains", criteria.NotContains{"genre": "Rock"}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value' and value LIKE ?)", "%Rock%"), 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%"), @@ -63,7 +65,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { It("allows public or same-owner playlist references for regular users", func() { sqlizer, err := newSmartPlaylistCriteria( criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, - withSmartPlaylistOwner("owner-id", false), + withSmartPlaylistOwner(model.User{ID: "owner-id", IsAdmin: false}), ).Where() Expect(err).ToNot(HaveOccurred()) @@ -76,7 +78,7 @@ var _ = Describe("Smart playlist criteria SQL", func() { It("allows all playlist references for admins", func() { sqlizer, err := newSmartPlaylistCriteria( criteria.Criteria{Expression: criteria.InPlaylist{"id": "deadbeef-dead-beef"}}, - withSmartPlaylistOwner("admin-id", true), + withSmartPlaylistOwner(model.User{ID: "admin-id", IsAdmin: true}), ).Where() Expect(err).ToNot(HaveOccurred()) diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go index 658030de5..086e73703 100644 --- a/persistence/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -197,6 +197,11 @@ var _ = Describe("Smart Playlists", func() { Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) }) + + It("resolves recordingdate alias to the date column", func() { + results := evaluateRule(`{"all":[{"is":{"recordingdate":"1959"}}]}`) + Expect(results).To(ConsistOf("So What")) + }) }) Describe("Logic operators", func() { diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 11e76fa8b..9bbc41c5c 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -11,7 +11,6 @@ import ( . "github.com/Masterminds/squirrel" "github.com/deluan/rest" - "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/pocketbase/dbx" @@ -201,161 +200,6 @@ func (r *playlistRepository) selectPlaylist(options ...model.QueryOptions) Selec Columns(r.tableName+".*", "user.user_name as owner_name") } -func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { - // Only refresh if it is a smart playlist and was not refreshed within the interval provided by the refresh delay config - if !pls.IsSmartPlaylist() || (pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay) { - return false - } - - // Never refresh other users' playlists - usr := loggedUser(r.ctx) - if pls.OwnerID != usr.ID { - log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID) - return false - } - - log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID) - start := time.Now() - - // Remove old tracks - del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID}) - _, err := r.executeSQL(del) - if err != nil { - log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - // Re-populate playlist based on Smart Playlist criteria - rules := *pls.Rules - rulesSQL := newSmartPlaylistCriteria(rules, withSmartPlaylistOwner(pls.OwnerID, usr.IsAdmin)) - - // If the playlist depends on other playlists, recursively refresh them first - childPlaylistIds := rules.ChildPlaylistIds() - for _, id := range childPlaylistIds { - childPls, err := r.Get(id) - if err != nil { - if errors.Is(err, model.ErrNotFound) { - log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) - continue - } - log.Error(r.ctx, "Error loading child playlist", "id", pls.ID, "childId", id, err) - return false - } - r.refreshSmartPlaylist(childPls) - } - - orderBy := rulesSQL.OrderBy() - sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). - From("media_file").LeftJoin("annotation on ("+ - "annotation.item_id = media_file.id"+ - " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = ?)", usr.ID) - - // Conditionally join album/artist annotation tables only when referenced by criteria or sort - requiredJoins := rulesSQL.RequiredJoins() - sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, usr.ID) - - // Only include media files from libraries the user has access to - sq = r.applyLibraryFilter(sq, "media_file") - - // Resolve percentage-based limit to an absolute number before applying criteria - if rules.IsPercentageLimit() { - // Use only expression-based joins for the COUNT query (sort joins are unnecessary) - exprJoins := rulesSQL.ExpressionJoins() - countSq := Select("count(*) as count").From("media_file"). - LeftJoin("annotation on ("+ - "annotation.item_id = media_file.id"+ - " AND annotation.item_type = 'media_file'"+ - " AND annotation.user_id = ?)", usr.ID) - countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, usr.ID) - countSq = r.applyLibraryFilter(countSq, "media_file") - cond, err := rulesSQL.Where() - if err != nil { - log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) - return false - } - countSq = countSq.Where(cond) - - var res struct{ Count int64 } - err = r.queryOne(countSq, &res) - if err != nil { - log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err) - return false - } - resolvedLimit := rules.EffectiveLimit(res.Count) - log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rules.LimitPercent, "totalMatching", res.Count, "resolvedLimit", resolvedLimit) - rules.Limit = resolvedLimit - rules.LimitPercent = 0 - rulesSQL.criteria = rules - } - - // Apply the criteria rules - sq, err = r.addCriteria(sq, rulesSQL) - if err != nil { - log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) - return false - } - insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) - _, err = r.executeSQL(insSql) - if err != nil { - log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - // Update playlist stats - err = r.refreshCounters(pls) - if err != nil { - log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - // Update when the playlist was last refreshed (for cache purposes) - now := time.Now() - updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID}) - _, err = r.executeSQL(updSql) - if err != nil { - log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) - return false - } - - pls.EvaluatedAt = &now - - log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start)) - - return true -} - -func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder { - if joins.has(smartPlaylistJoinAlbumAnnotation) { - sq = sq.LeftJoin("annotation AS album_annotation ON ("+ - "album_annotation.item_id = media_file.album_id"+ - " AND album_annotation.item_type = 'album'"+ - " AND album_annotation.user_id = ?)", userID) - } - if joins.has(smartPlaylistJoinArtistAnnotation) { - sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ - "artist_annotation.item_id = media_file.artist_id"+ - " AND artist_annotation.item_type = 'artist'"+ - " AND artist_annotation.user_id = ?)", userID) - } - return sq -} - -func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) { - cond, err := cSQL.Where() - if err != nil { - return sql, err - } - sql = sql.Where(cond) - if cSQL.criteria.Limit > 0 { - sql = sql.Limit(uint64(cSQL.criteria.Limit)).Offset(uint64(cSQL.criteria.Offset)) - } - if order := cSQL.OrderBy(); order != "" { - sql = sql.OrderBy(order) - } - return sql, nil -} - func (r *playlistRepository) updateTracks(id string, tracks model.MediaFiles) error { ids := make([]string, len(tracks)) for i := range tracks { diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go index 88cb5f697..cfabd0983 100644 --- a/persistence/playlist_repository_test.go +++ b/persistence/playlist_repository_test.go @@ -1,17 +1,11 @@ package persistence import ( - "time" - - "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "github.com/pocketbase/dbx" ) var _ = Describe("PlaylistRepository", func() { @@ -128,379 +122,6 @@ var _ = Describe("PlaylistRepository", func() { }) }) - Context("Smart Playlists", func() { - var rules *criteria.Criteria - BeforeEach(func() { - rules = &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"title": "love"}, - }, - } - }) - Context("valid rules", func() { - Specify("Put/Get", func() { - newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - - savedPls, err := repo.Get(newPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(savedPls.Rules).To(Equal(rules)) - }) - }) - - Context("invalid rules", func() { - It("fails to Put it in the DB", func() { - rules = &criteria.Criteria{ - // This is invalid because "contains" cannot have multiple fields - Expression: criteria.All{ - criteria.Contains{"genre": "Hardcore", "filetype": "mp3"}, - }, - } - newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression"))) - }) - }) - - Context("child smart playlists", func() { - BeforeEach(func() { - DeferCleanup(configtest.SetupConfig()) - }) - - When("refresh delay has expired", func() { - It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - - childRules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"title": "Day"}, - }, - } - nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules} - Expect(repo.Put(&nestedPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) - - parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ - Expression: criteria.All{ - criteria.InPlaylist{"id": nestedPls.ID}, - }, - }} - Expect(repo.Put(&parentPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) - - // Nested playlist has not been evaluated yet - nestedPlsRead, err := repo.Get(nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(nestedPlsRead.EvaluatedAt).To(BeNil()) - - // Getting parent with refresh should recursively refresh the nested playlist - pls, err := repo.GetWithTracks(parentPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - Expect(pls.EvaluatedAt).ToNot(BeNil()) - Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - - // Parent should have tracks from the nested playlist - Expect(pls.Tracks).To(HaveLen(1)) - Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) - - // Nested playlist should now have been refreshed (EvaluatedAt set) - nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - }) - }) - - When("refresh delay has not expired", func() { - It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { - conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour - childEvaluatedAt := time.Now().Add(-30 * time.Minute) - - childRules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"title": "Day"}, - }, - } - nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt} - Expect(repo.Put(&nestedPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) - - // Parent has no EvaluatedAt, so it WILL refresh, but the child should not - parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ - Expression: criteria.All{ - criteria.InPlaylist{"id": nestedPls.ID}, - }, - }} - Expect(repo.Put(&parentPls)).To(Succeed()) - DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) - - nestedPlsRead, err := repo.Get(nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - - // Getting parent with refresh should NOT recursively refresh the nested playlist - parent, err := repo.GetWithTracks(parentPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - // Parent should have been refreshed (its EvaluatedAt was nil) - Expect(parent.EvaluatedAt).ToNot(BeNil()) - Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) - - // Nested playlist should NOT have been refreshed (still within delay window) - nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) - Expect(err).ToNot(HaveOccurred()) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second)) - Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) - }) - }) - }) - }) - - Describe("Playlist Track Sorting", func() { - var testPlaylistID string - - AfterEach(func() { - if testPlaylistID != "" { - Expect(repo.Delete(testPlaylistID)).To(BeNil()) - testPlaylistID = "" - } - }) - - It("sorts tracks correctly by album (disc and track number)", func() { - By("creating a playlist with multi-disc album tracks in arbitrary order") - newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"} - // Add tracks in intentionally scrambled order - newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"}) - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("retrieving tracks sorted by album") - tracksRepo := repo.Tracks(newPls.ID, false) - tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"}) - Expect(err).ToNot(HaveOccurred()) - - By("verifying tracks are sorted by disc number then track number") - Expect(tracks).To(HaveLen(4)) - // Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11 - Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1 - Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2 - Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1 - Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11 - }) - }) - - Describe("Smart Playlists with Album/Artist Annotation Criteria", func() { - var testPlaylistID string - - AfterEach(func() { - if testPlaylistID != "" { - _ = repo.Delete(testPlaylistID) - testPlaylistID = "" - } - }) - - It("matches tracks from starred albums using albumLoved", func() { - // albumRadioactivity (ID "103") is starred in test fixtures - // Songs in album 103: 1003, 1004, 1005, 1006 - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Is{"albumLoved": true}, - }, - } - newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - trackIDs := make([]string, len(pls.Tracks)) - for i, t := range pls.Tracks { - trackIDs[i] = t.MediaFileID - } - Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006")) - }) - - It("matches tracks from starred artists using artistLoved", func() { - // artistBeatles (ID "3") is starred in test fixtures - // Songs with ArtistID "3": 1001, 1002, 3002 - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Is{"artistLoved": true}, - }, - } - newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - trackIDs := make([]string, len(pls.Tracks)) - for i, t := range pls.Tracks { - trackIDs[i] = t.MediaFileID - } - Expect(trackIDs).To(ConsistOf("1001", "1002", "3002")) - }) - - It("matches tracks with combined album and artist criteria", func() { - // albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006) - // artistLoved=true → songs with artist 3 (1001, 1002) - // Using Any: union of both sets - rules := &criteria.Criteria{ - Expression: criteria.Any{ - criteria.Is{"albumLoved": true}, - criteria.Is{"artistLoved": true}, - }, - } - newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - trackIDs := make([]string, len(pls.Tracks)) - for i, t := range pls.Tracks { - trackIDs[i] = t.MediaFileID - } - Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002")) - }) - - It("returns no tracks when no albums/artists match", func() { - // No album has rating 5 in fixtures - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Is{"albumRating": 5}, - }, - } - newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - Expect(pls.Tracks).To(BeEmpty()) - }) - }) - - Describe("Smart Playlists with Tag Criteria", func() { - var mfRepo model.MediaFileRepository - var testPlaylistID string - var songWithGrouping, songWithoutGrouping model.MediaFile - - BeforeEach(func() { - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) - - // Register 'grouping' as a valid tag for smart playlists - criteria.AddTagNames([]string{"grouping"}) - - // Create a song with the grouping tag - songWithGrouping = model.MediaFile{ - ID: "test-grouping-1", - Title: "Song With Grouping", - Artist: "Test Artist", - ArtistID: "1", - Album: "Test Album", - AlbumID: "101", - Path: "test/grouping/song1.mp3", - Tags: model.Tags{ - "grouping": []string{"My Crate"}, - }, - Participants: model.Participants{}, - LibraryID: 1, - Lyrics: "[]", - } - Expect(mfRepo.Put(&songWithGrouping)).To(Succeed()) - - // Create a song without the grouping tag - songWithoutGrouping = model.MediaFile{ - ID: "test-grouping-2", - Title: "Song Without Grouping", - Artist: "Test Artist", - ArtistID: "1", - Album: "Test Album", - AlbumID: "101", - Path: "test/grouping/song2.mp3", - Tags: model.Tags{}, - Participants: model.Participants{}, - LibraryID: 1, - Lyrics: "[]", - } - Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed()) - }) - - AfterEach(func() { - if testPlaylistID != "" { - _ = repo.Delete(testPlaylistID) - testPlaylistID = "" - } - // Clean up test media files - _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute() - _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute() - }) - - It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() { - By("creating a smart playlist that checks if grouping tag has any value") - // This is the workaround for issue #4728: using 'contains' with empty string - // generates SQL: value LIKE '%%' which matches any non-empty string - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Contains{"grouping": ""}, - }, - } - newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("refreshing the smart playlist") - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - By("verifying only the track with grouping tag is matched") - Expect(pls.Tracks).To(HaveLen(1)) - Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID)) - }) - - It("excludes tracks with a tag value using 'notContains' with empty string", func() { - By("creating a smart playlist that checks if grouping tag is NOT set") - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.NotContains{"grouping": ""}, - }, - } - newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules} - Expect(repo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("refreshing the smart playlist") - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := repo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - By("verifying the track with grouping is NOT in the playlist") - for _, track := range pls.Tracks { - Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID)) - } - - By("verifying the track without grouping IS in the playlist") - var foundWithoutGrouping bool - for _, track := range pls.Tracks { - if track.MediaFileID == songWithoutGrouping.ID { - foundWithoutGrouping = true - break - } - } - Expect(foundWithoutGrouping).To(BeTrue()) - }) - }) - Describe("Track Deletion and Renumbering", func() { var testPlaylistID string @@ -573,136 +194,4 @@ var _ = Describe("PlaylistRepository", func() { Expect(mediaFileIDs).To(Equal([]string{"1001", "1002"})) }) }) - - Describe("Smart Playlists Library Filtering", func() { - var mfRepo model.MediaFileRepository - var testPlaylistID string - var lib2ID int - var restrictedUserID string - var uniqueLibPath string - - BeforeEach(func() { - db := GetDBXBuilder() - - // Generate unique IDs for this test run - uniqueSuffix := time.Now().Format("20060102150405.000") - restrictedUserID = "restricted-user-" + uniqueSuffix - uniqueLibPath = "/music/lib2-" + uniqueSuffix - - // Create a second library with unique name and path to avoid conflicts with other tests - _, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath) - Expect(err).ToNot(HaveOccurred()) - err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID) - Expect(err).ToNot(HaveOccurred()) - - // Create a restricted user with access only to library 1 - _, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID) - Expect(err).ToNot(HaveOccurred()) - _, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID) - Expect(err).ToNot(HaveOccurred()) - - // Create test media files in each library - ctx := log.NewContext(GinkgoT().Context()) - ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) - mfRepo = NewMediaFileRepository(ctx, db) - - // Song in library 1 (accessible by restricted user) - songLib1 := model.MediaFile{ - ID: "lib1-song", - Title: "Song in Lib1", - Artist: "Test Artist", - ArtistID: "1", - Album: "Test Album", - AlbumID: "101", - Path: "lib1/song.mp3", - LibraryID: 1, - Participants: model.Participants{}, - Tags: model.Tags{}, - Lyrics: "[]", - } - Expect(mfRepo.Put(&songLib1)).To(Succeed()) - - // Song in library 2 (NOT accessible by restricted user) - songLib2 := model.MediaFile{ - ID: "lib2-song", - Title: "Song in Lib2", - Artist: "Test Artist", - ArtistID: "1", - Album: "Test Album", - AlbumID: "101", - Path: "lib2/song.mp3", - LibraryID: lib2ID, - Participants: model.Participants{}, - Tags: model.Tags{}, - Lyrics: "[]", - } - Expect(mfRepo.Put(&songLib2)).To(Succeed()) - }) - - AfterEach(func() { - db := GetDBXBuilder() - if testPlaylistID != "" { - _ = repo.Delete(testPlaylistID) - testPlaylistID = "" - } - // Clean up test data - _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute() - _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute() - _, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute() - _, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute() - _, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID) - }) - - It("should only include tracks from libraries the user has access to (issue #4738)", func() { - db := GetDBXBuilder() - ctx := log.NewContext(GinkgoT().Context()) - - // Create the smart playlist as the restricted user - restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false} - ctx = request.WithUser(ctx, restrictedUser) - restrictedRepo := NewPlaylistRepository(ctx, db) - - // Create a smart playlist that matches all songs - rules := &criteria.Criteria{ - Expression: criteria.All{ - criteria.Gt{"playCount": -1}, // Matches everything - }, - } - newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules} - Expect(restrictedRepo.Put(&newPls)).To(Succeed()) - testPlaylistID = newPls.ID - - By("refreshing the smart playlist") - conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh - pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false) - Expect(err).ToNot(HaveOccurred()) - - By("verifying only the track from library 1 is in the playlist") - var foundLib1Song, foundLib2Song bool - for _, track := range pls.Tracks { - if track.MediaFileID == "lib1-song" { - foundLib1Song = true - } - if track.MediaFileID == "lib2-song" { - foundLib2Song = true - } - } - Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist") - Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist") - - By("verifying playlist_tracks table only contains the accessible track") - var playlistTracksCount int - err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount) - Expect(err).ToNot(HaveOccurred()) - // Count should only include tracks visible to the user (lib1-song) - // The count may include other test songs from library 1, but NOT lib2-song - var lib2TrackCount int - err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount) - Expect(err).ToNot(HaveOccurred()) - Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks") - - By("verifying SongCount matches visible tracks") - Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks") - }) - }) }) diff --git a/persistence/smart_playlist_repository.go b/persistence/smart_playlist_repository.go new file mode 100644 index 000000000..54f316152 --- /dev/null +++ b/persistence/smart_playlist_repository.go @@ -0,0 +1,203 @@ +package persistence + +import ( + "time" + + . "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +// PlaylistRepository methods to handle smart playlists, which are defined by criteria and automatically populated +// based on their rules. The main method is refreshSmartPlaylist, which evaluates the criteria and updates the playlist +// tracks accordingly. It also handles refreshing dependent playlists when a smart playlist references other playlists +// in its criteria. To optimize performance, it only refreshes when necessary based on the last evaluated time and +// configured refresh delay. + +// refreshSmartPlaylist evaluates the criteria of a smart playlist and updates its tracks accordingly. +func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool { + usr := loggedUser(r.ctx) + if !r.shouldRefreshSmartPlaylist(pls, usr) { + return false + } + + log.Debug(r.ctx, "Refreshing smart playlist", "playlist", pls.Name, "id", pls.ID) + start := time.Now() + + del := Delete("playlist_tracks").Where(Eq{"playlist_id": pls.ID}) + if _, err := r.executeSQL(del); err != nil { + log.Error(r.ctx, "Error deleting old smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + rulesSQL := newSmartPlaylistCriteria(*pls.Rules, withSmartPlaylistOwner(*usr)) + + if !r.refreshChildPlaylists(pls, rulesSQL) { + return false + } + + if err := r.resolvePercentageLimit(pls, &rulesSQL, usr.ID); err != nil { + return false + } + + sq := r.buildSmartPlaylistQuery(pls, rulesSQL, usr.ID) + sq, err := r.addCriteria(sq, rulesSQL) + if err != nil { + log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq) + if _, err = r.executeSQL(insSql); err != nil { + log.Error(r.ctx, "Error refreshing smart playlist tracks", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + if err = r.refreshCounters(pls); err != nil { + log.Error(r.ctx, "Error updating smart playlist stats", "playlist", pls.Name, "id", pls.ID, err) + return false + } + + now := time.Now() + updSql := Update(r.tableName).Set("evaluated_at", now).Where(Eq{"id": pls.ID}) + if _, err = r.executeSQL(updSql); err != nil { + log.Error(r.ctx, "Error updating smart playlist", "playlist", pls.Name, "id", pls.ID, err) + return false + } + pls.EvaluatedAt = &now + + log.Debug(r.ctx, "Refreshed playlist", "playlist", pls.Name, "id", pls.ID, "numTracks", pls.SongCount, "elapsed", time.Since(start)) + return true +} + +// shouldRefreshSmartPlaylist determines if a smart playlist needs to be refreshed based on its type, last evaluated +// time, and ownership. +func (r *playlistRepository) shouldRefreshSmartPlaylist(pls *model.Playlist, usr *model.User) bool { + if !pls.IsSmartPlaylist() { + return false + } + if pls.EvaluatedAt != nil && time.Since(*pls.EvaluatedAt) < conf.Server.SmartPlaylistRefreshDelay { + return false + } + if pls.OwnerID != usr.ID { + log.Trace(r.ctx, "Not refreshing smart playlist from other user", "playlist", pls.Name, "id", pls.ID) + return false + } + return true +} + +// refreshChildPlaylists handles refreshing any child playlists that are referenced in the smart playlist criteria. +// Returns false if child playlists could not be loaded (DB error), signaling the parent refresh should abort. +func (r *playlistRepository) refreshChildPlaylists(pls *model.Playlist, rulesSQL smartPlaylistCriteria) bool { + childPlaylistIds := rulesSQL.ChildPlaylistIds() + if len(childPlaylistIds) == 0 { + return true + } + + childPlaylists, err := r.GetAll(model.QueryOptions{Filters: Eq{"playlist.id": childPlaylistIds}}) + if err != nil { + log.Error(r.ctx, "Error loading child playlists for smart playlist refresh", "playlist", pls.Name, "id", pls.ID, "childIds", childPlaylistIds, err) + return false + } + + found := make(map[string]struct{}, len(childPlaylists)) + for i := range childPlaylists { + found[childPlaylists[i].ID] = struct{}{} + r.refreshSmartPlaylist(&childPlaylists[i]) + } + for _, id := range childPlaylistIds { + if _, ok := found[id]; !ok { + log.Warn(r.ctx, "Referenced playlist is not accessible to smart playlist owner", "playlist", pls.Name, "id", pls.ID, "childId", id, "ownerId", pls.OwnerID) + } + } + return true +} + +// resolvePercentageLimit calculates the actual limit for a smart playlist criteria that uses a percentage-based limit. +func (r *playlistRepository) resolvePercentageLimit(pls *model.Playlist, rulesSQL *smartPlaylistCriteria, userID string) error { + if !rulesSQL.IsPercentageLimit() { + return nil + } + + exprJoins := rulesSQL.ExpressionJoins() + countSq := Select("count(*) as count").From("media_file") + countSq = r.addMediaFileAnnotationJoin(countSq, userID) + countSq = r.addSmartPlaylistAnnotationJoins(countSq, exprJoins, userID) + countSq = r.applyLibraryFilter(countSq, "media_file") + + cond, err := rulesSQL.Where() + if err != nil { + log.Error(r.ctx, "Error building smart playlist criteria", "playlist", pls.Name, "id", pls.ID, err) + return err + } + countSq = countSq.Where(cond) + + var res struct{ Count int64 } + if err = r.queryOne(countSq, &res); err != nil { + log.Error(r.ctx, "Error counting matching tracks for percentage limit", "playlist", pls.Name, "id", pls.ID, err) + return err + } + + rulesSQL.ResolveLimit(res.Count) + log.Debug(r.ctx, "Resolved percentage limit", "playlist", pls.Name, "percent", rulesSQL.LimitPercent, "totalMatching", res.Count, "resolvedLimit", rulesSQL.Limit) + return nil +} + +// buildSmartPlaylistQuery constructs the SQL query to select media files matching the smart playlist criteria, +// including necessary joins for annotations and library filtering. +func (r *playlistRepository) buildSmartPlaylistQuery(pls *model.Playlist, rulesSQL smartPlaylistCriteria, userID string) SelectBuilder { + orderBy := rulesSQL.OrderBy() + sq := Select("row_number() over (order by "+orderBy+") as id", "'"+pls.ID+"' as playlist_id", "media_file.id as media_file_id"). + From("media_file") + sq = r.addMediaFileAnnotationJoin(sq, userID) + + requiredJoins := rulesSQL.RequiredJoins() + sq = r.addSmartPlaylistAnnotationJoins(sq, requiredJoins, userID) + sq = r.applyLibraryFilter(sq, "media_file") + return sq +} + +// addMediaFileAnnotationJoin adds a left join to the annotation table for media files, filtering by user ID to include +// user-specific annotations in the smart playlist criteria evaluation. +func (r *playlistRepository) addMediaFileAnnotationJoin(sq SelectBuilder, userID string) SelectBuilder { + return sq.LeftJoin("annotation on ("+ + "annotation.item_id = media_file.id"+ + " AND annotation.item_type = 'media_file'"+ + " AND annotation.user_id = ?)", userID) +} + +// addSmartPlaylistAnnotationJoins adds left joins to the annotation table for albums and artists as needed based on +// the smart playlist criteria, filtering by user ID to include user-specific annotations in the evaluation. +func (r *playlistRepository) addSmartPlaylistAnnotationJoins(sq SelectBuilder, joins smartPlaylistJoinType, userID string) SelectBuilder { + if joins.has(smartPlaylistJoinAlbumAnnotation) { + sq = sq.LeftJoin("annotation AS album_annotation ON ("+ + "album_annotation.item_id = media_file.album_id"+ + " AND album_annotation.item_type = 'album'"+ + " AND album_annotation.user_id = ?)", userID) + } + if joins.has(smartPlaylistJoinArtistAnnotation) { + sq = sq.LeftJoin("annotation AS artist_annotation ON ("+ + "artist_annotation.item_id = media_file.artist_id"+ + " AND artist_annotation.item_type = 'artist'"+ + " AND artist_annotation.user_id = ?)", userID) + } + return sq +} + +// addCriteria applies the where conditions, limit, offset, and order by clauses to the SQL query based on the +// smart playlist criteria. +func (r *playlistRepository) addCriteria(sql SelectBuilder, cSQL smartPlaylistCriteria) (SelectBuilder, error) { + cond, err := cSQL.Where() + if err != nil { + return sql, err + } + sql = sql.Where(cond) + if cSQL.Criteria.Limit > 0 { + sql = sql.Limit(uint64(cSQL.Criteria.Limit)).Offset(uint64(cSQL.Criteria.Offset)) + } + if order := cSQL.OrderBy(); order != "" { + sql = sql.OrderBy(order) + } + return sql, nil +} diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go new file mode 100644 index 000000000..207fe0c36 --- /dev/null +++ b/persistence/smart_playlist_repository_test.go @@ -0,0 +1,531 @@ +package persistence + +import ( + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" + "github.com/navidrome/navidrome/model/request" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/pocketbase/dbx" +) + +var _ = Describe("PlaylistRepository - Smart Playlists", func() { + var repo model.PlaylistRepository + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + repo = NewPlaylistRepository(ctx, GetDBXBuilder()) + }) + + Context("Smart Playlists", func() { + var rules *criteria.Criteria + BeforeEach(func() { + rules = &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "love"}, + }, + } + }) + Context("valid rules", func() { + Specify("Put/Get", func() { + newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(newPls.ID) }) + + savedPls, err := repo.Get(newPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(savedPls.Rules).To(Equal(rules)) + }) + }) + + Context("invalid rules", func() { + It("fails to Put it in the DB", func() { + rules = &criteria.Criteria{ + // This is invalid because "contains" cannot have multiple fields + Expression: criteria.All{ + criteria.Contains{"genre": "Hardcore", "filetype": "mp3"}, + }, + } + newPls := model.Playlist{Name: "Great!", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(MatchError(ContainSubstring("invalid criteria expression"))) + }) + }) + + Context("child smart playlists", func() { + BeforeEach(func() { + DeferCleanup(configtest.SetupConfig()) + }) + + When("refresh delay has expired", func() { + It("should refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + childRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "Day"}, + }, + } + nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules} + Expect(repo.Put(&nestedPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) + + parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.All{ + criteria.InPlaylist{"id": nestedPls.ID}, + }, + }} + Expect(repo.Put(&parentPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) + + // Nested playlist has not been evaluated yet + nestedPlsRead, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(nestedPlsRead.EvaluatedAt).To(BeNil()) + + // Getting parent with refresh should recursively refresh the nested playlist + pls, err := repo.GetWithTracks(parentPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + Expect(pls.EvaluatedAt).ToNot(BeNil()) + Expect(*pls.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + + // Parent should have tracks from the nested playlist + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].MediaFileID).To(Equal(songDayInALife.ID)) + + // Nested playlist should now have been refreshed (EvaluatedAt set) + nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(nestedPlsAfterParentGet.EvaluatedAt).ToNot(BeNil()) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + }) + }) + + When("refresh delay has not expired", func() { + It("should NOT refresh tracks for smart playlist referenced in parent smart playlist criteria", func() { + conf.Server.SmartPlaylistRefreshDelay = 1 * time.Hour + childEvaluatedAt := time.Now().Add(-30 * time.Minute) + + childRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"title": "Day"}, + }, + } + nestedPls := model.Playlist{Name: "Nested", OwnerID: "userid", Public: true, Rules: childRules, EvaluatedAt: &childEvaluatedAt} + Expect(repo.Put(&nestedPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(nestedPls.ID) }) + + // Parent has no EvaluatedAt, so it WILL refresh, but the child should not + parentPls := model.Playlist{Name: "Parent", OwnerID: "userid", Rules: &criteria.Criteria{ + Expression: criteria.All{ + criteria.InPlaylist{"id": nestedPls.ID}, + }, + }} + Expect(repo.Put(&parentPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(parentPls.ID) }) + + nestedPlsRead, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + + // Getting parent with refresh should NOT recursively refresh the nested playlist + parent, err := repo.GetWithTracks(parentPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + // Parent should have been refreshed (its EvaluatedAt was nil) + Expect(parent.EvaluatedAt).ToNot(BeNil()) + Expect(*parent.EvaluatedAt).To(BeTemporally("~", time.Now(), 2*time.Second)) + + // Nested playlist should NOT have been refreshed (still within delay window) + nestedPlsAfterParentGet, err := repo.Get(nestedPls.ID) + Expect(err).ToNot(HaveOccurred()) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(BeTemporally("~", childEvaluatedAt, time.Second)) + Expect(*nestedPlsAfterParentGet.EvaluatedAt).To(Equal(*nestedPlsRead.EvaluatedAt)) + }) + }) + }) + }) + + Describe("Playlist Track Sorting", func() { + var testPlaylistID string + + AfterEach(func() { + if testPlaylistID != "" { + Expect(repo.Delete(testPlaylistID)).To(BeNil()) + testPlaylistID = "" + } + }) + + It("sorts tracks correctly by album (disc and track number)", func() { + By("creating a playlist with multi-disc album tracks in arbitrary order") + newPls := model.Playlist{Name: "Multi-Disc Test", OwnerID: "userid"} + // Add tracks in intentionally scrambled order + newPls.AddMediaFilesByID([]string{"2001", "2002", "2003", "2004"}) + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("retrieving tracks sorted by album") + tracksRepo := repo.Tracks(newPls.ID, false) + tracks, err := tracksRepo.GetAll(model.QueryOptions{Sort: "album", Order: "asc"}) + Expect(err).ToNot(HaveOccurred()) + + By("verifying tracks are sorted by disc number then track number") + Expect(tracks).To(HaveLen(4)) + // Expected order: Disc 1 Track 1, Disc 1 Track 2, Disc 2 Track 1, Disc 2 Track 11 + Expect(tracks[0].MediaFileID).To(Equal("2002")) // Disc 1, Track 1 + Expect(tracks[1].MediaFileID).To(Equal("2004")) // Disc 1, Track 2 + Expect(tracks[2].MediaFileID).To(Equal("2003")) // Disc 2, Track 1 + Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11 + }) + }) + + Describe("Smart Playlists with Album/Artist Annotation Criteria", func() { + var testPlaylistID string + + AfterEach(func() { + if testPlaylistID != "" { + _ = repo.Delete(testPlaylistID) + testPlaylistID = "" + } + }) + + It("matches tracks from starred albums using albumLoved", func() { + // albumRadioactivity (ID "103") is starred in test fixtures + // Songs in album 103: 1003, 1004, 1005, 1006 + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Is{"albumLoved": true}, + }, + } + newPls := model.Playlist{Name: "Starred Album Songs", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ConsistOf("1003", "1004", "1005", "1006")) + }) + + It("matches tracks from starred artists using artistLoved", func() { + // artistBeatles (ID "3") is starred in test fixtures + // Songs with ArtistID "3": 1001, 1002, 3002 + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Is{"artistLoved": true}, + }, + } + newPls := model.Playlist{Name: "Starred Artist Songs", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ConsistOf("1001", "1002", "3002")) + }) + + It("matches tracks with combined album and artist criteria", func() { + // albumLoved=true → songs from album 103 (1003, 1004, 1005, 1006) + // artistLoved=true → songs with artist 3 (1001, 1002) + // Using Any: union of both sets + rules := &criteria.Criteria{ + Expression: criteria.Any{ + criteria.Is{"albumLoved": true}, + criteria.Is{"artistLoved": true}, + }, + } + newPls := model.Playlist{Name: "Combined Album+Artist", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ConsistOf("1001", "1002", "1003", "1004", "1005", "1006", "3002")) + }) + + It("returns no tracks when no albums/artists match", func() { + // No album has rating 5 in fixtures + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Is{"albumRating": 5}, + }, + } + newPls := model.Playlist{Name: "No Match", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + Expect(pls.Tracks).To(BeEmpty()) + }) + }) + + Describe("Smart Playlists with Tag Criteria", func() { + var mfRepo model.MediaFileRepository + var testPlaylistID string + var songWithGrouping, songWithoutGrouping model.MediaFile + + BeforeEach(func() { + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + mfRepo = NewMediaFileRepository(ctx, GetDBXBuilder()) + + // Register 'grouping' as a valid tag for smart playlists + criteria.AddTagNames([]string{"grouping"}) + + // Create a song with the grouping tag + songWithGrouping = model.MediaFile{ + ID: "test-grouping-1", + Title: "Song With Grouping", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "test/grouping/song1.mp3", + Tags: model.Tags{ + "grouping": []string{"My Crate"}, + }, + Participants: model.Participants{}, + LibraryID: 1, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songWithGrouping)).To(Succeed()) + + // Create a song without the grouping tag + songWithoutGrouping = model.MediaFile{ + ID: "test-grouping-2", + Title: "Song Without Grouping", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "test/grouping/song2.mp3", + Tags: model.Tags{}, + Participants: model.Participants{}, + LibraryID: 1, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songWithoutGrouping)).To(Succeed()) + }) + + AfterEach(func() { + if testPlaylistID != "" { + _ = repo.Delete(testPlaylistID) + testPlaylistID = "" + } + // Clean up test media files + _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-1"}).Execute() + _, _ = GetDBXBuilder().Delete("media_file", dbx.HashExp{"id": "test-grouping-2"}).Execute() + }) + + It("matches tracks with a tag value using 'contains' with empty string (issue #4728 workaround)", func() { + By("creating a smart playlist that checks if grouping tag has any value") + // This is the workaround for issue #4728: using 'contains' with empty string + // generates SQL: value LIKE '%%' which matches any non-empty string + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Contains{"grouping": ""}, + }, + } + newPls := model.Playlist{Name: "Tracks with Grouping", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying only the track with grouping tag is matched") + Expect(pls.Tracks).To(HaveLen(1)) + Expect(pls.Tracks[0].MediaFileID).To(Equal(songWithGrouping.ID)) + }) + + It("excludes tracks with a tag value using 'notContains' with empty string", func() { + By("creating a smart playlist that checks if grouping tag is NOT set") + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.NotContains{"grouping": ""}, + }, + } + newPls := model.Playlist{Name: "Tracks without Grouping", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying the track with grouping is NOT in the playlist") + for _, track := range pls.Tracks { + Expect(track.MediaFileID).ToNot(Equal(songWithGrouping.ID)) + } + + By("verifying the track without grouping IS in the playlist") + var foundWithoutGrouping bool + for _, track := range pls.Tracks { + if track.MediaFileID == songWithoutGrouping.ID { + foundWithoutGrouping = true + break + } + } + Expect(foundWithoutGrouping).To(BeTrue()) + }) + }) + + Describe("Smart Playlists Library Filtering", func() { + var mfRepo model.MediaFileRepository + var testPlaylistID string + var lib2ID int + var restrictedUserID string + var uniqueLibPath string + + BeforeEach(func() { + db := GetDBXBuilder() + + // Generate unique IDs for this test run + uniqueSuffix := time.Now().Format("20060102150405.000") + restrictedUserID = "restricted-user-" + uniqueSuffix + uniqueLibPath = "/music/lib2-" + uniqueSuffix + + // Create a second library with unique name and path to avoid conflicts with other tests + _, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES (?, ?, datetime('now'), datetime('now'))", "Library 2-"+uniqueSuffix, uniqueLibPath) + Expect(err).ToNot(HaveOccurred()) + err = db.DB().QueryRow("SELECT last_insert_rowid()").Scan(&lib2ID) + Expect(err).ToNot(HaveOccurred()) + + // Create a restricted user with access only to library 1 + _, err = db.DB().Exec("INSERT INTO user (id, user_name, name, is_admin, password, created_at, updated_at) VALUES (?, ?, 'Restricted User', false, 'pass', datetime('now'), datetime('now'))", restrictedUserID, restrictedUserID) + Expect(err).ToNot(HaveOccurred()) + _, err = db.DB().Exec("INSERT INTO user_library (user_id, library_id) VALUES (?, 1)", restrictedUserID) + Expect(err).ToNot(HaveOccurred()) + + // Create test media files in each library + ctx := log.NewContext(GinkgoT().Context()) + ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true}) + mfRepo = NewMediaFileRepository(ctx, db) + + // Song in library 1 (accessible by restricted user) + songLib1 := model.MediaFile{ + ID: "lib1-song", + Title: "Song in Lib1", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "lib1/song.mp3", + LibraryID: 1, + Participants: model.Participants{}, + Tags: model.Tags{}, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songLib1)).To(Succeed()) + + // Song in library 2 (NOT accessible by restricted user) + songLib2 := model.MediaFile{ + ID: "lib2-song", + Title: "Song in Lib2", + Artist: "Test Artist", + ArtistID: "1", + Album: "Test Album", + AlbumID: "101", + Path: "lib2/song.mp3", + LibraryID: lib2ID, + Participants: model.Participants{}, + Tags: model.Tags{}, + Lyrics: "[]", + } + Expect(mfRepo.Put(&songLib2)).To(Succeed()) + }) + + AfterEach(func() { + db := GetDBXBuilder() + if testPlaylistID != "" { + _ = repo.Delete(testPlaylistID) + testPlaylistID = "" + } + // Clean up test data + _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib1-song"}).Execute() + _, _ = db.Delete("media_file", dbx.HashExp{"id": "lib2-song"}).Execute() + _, _ = db.Delete("user_library", dbx.HashExp{"user_id": restrictedUserID}).Execute() + _, _ = db.Delete("user", dbx.HashExp{"id": restrictedUserID}).Execute() + _, _ = db.DB().Exec("DELETE FROM library WHERE id = ?", lib2ID) + }) + + It("should only include tracks from libraries the user has access to (issue #4738)", func() { + db := GetDBXBuilder() + ctx := log.NewContext(GinkgoT().Context()) + + // Create the smart playlist as the restricted user + restrictedUser := model.User{ID: restrictedUserID, UserName: restrictedUserID, IsAdmin: false} + ctx = request.WithUser(ctx, restrictedUser) + restrictedRepo := NewPlaylistRepository(ctx, db) + + // Create a smart playlist that matches all songs + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Gt{"playCount": -1}, // Matches everything + }, + } + newPls := model.Playlist{Name: "All Songs", OwnerID: restrictedUserID, Rules: rules} + Expect(restrictedRepo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + By("refreshing the smart playlist") + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second // Force refresh + pls, err := restrictedRepo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + By("verifying only the track from library 1 is in the playlist") + var foundLib1Song, foundLib2Song bool + for _, track := range pls.Tracks { + if track.MediaFileID == "lib1-song" { + foundLib1Song = true + } + if track.MediaFileID == "lib2-song" { + foundLib2Song = true + } + } + Expect(foundLib1Song).To(BeTrue(), "Song from library 1 should be in the playlist") + Expect(foundLib2Song).To(BeFalse(), "Song from library 2 should NOT be in the playlist") + + By("verifying playlist_tracks table only contains the accessible track") + var playlistTracksCount int + err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ?", newPls.ID).Scan(&playlistTracksCount) + Expect(err).ToNot(HaveOccurred()) + // Count should only include tracks visible to the user (lib1-song) + // The count may include other test songs from library 1, but NOT lib2-song + var lib2TrackCount int + err = db.DB().QueryRow("SELECT count(*) FROM playlist_tracks WHERE playlist_id = ? AND media_file_id = 'lib2-song'", newPls.ID).Scan(&lib2TrackCount) + Expect(err).ToNot(HaveOccurred()) + Expect(lib2TrackCount).To(Equal(0), "lib2-song should not be in playlist_tracks") + + By("verifying SongCount matches visible tracks") + Expect(pls.SongCount).To(Equal(len(pls.Tracks)), "SongCount should match the number of visible tracks") + }) + }) +}) From c7c494abcc4612b91cc0fc12042c1de4010d5cee Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 26 Apr 2026 16:36:57 -0400 Subject: [PATCH 034/117] feat(plugins): add LibraryID to TrackInfo Add LibraryID field to TrackInfo so plugins with library filesystem access can determine which library a track belongs to. This lets plugins resolve the full filesystem path by combining the library's root path with the track's relative path. LibraryID is gated behind the same filesystem access permission check as Path. --- plugins/capabilities/scrobbler.go | 3 +++ plugins/scrobbler_adapter.go | 1 + plugins/scrobbler_adapter_test.go | 18 ++++++++++++------ 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go index 34cf60015..ed8a4fb6c 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -68,6 +68,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` // Path is the full path to the track file, relative to the library root. // Only included if the plugin has library permission with filesystem access for the track's library. Path string `json:"path,omitempty"` diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 4f7cd4661..02c2b2889 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -130,6 +130,7 @@ func mediaFileToTrackInfo(p *plugin, mf *model.MediaFile) capabilities.TrackInfo MBZReleaseTrackID: mf.MbzReleaseTrackID, } if p.hasLibraryFilesystemAccess(mf.LibraryID) { + ti.LibraryID = int32(mf.LibraryID) ti.Path = mf.Path } return ti diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index 0ee229022..c56d8a900 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -259,19 +259,25 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { }, } - It("includes Path when the plugin has filesystem access to the track's library", func() { + It("includes LibraryID and Path when the plugin has filesystem access to the track's library", func() { p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{1}, false)} - Expect(mediaFileToTrackInfo(p, track).Path).To(Equal("/music/test.flac")) + ti := mediaFileToTrackInfo(p, track) + Expect(ti.LibraryID).To(Equal(int32(1))) + Expect(ti.Path).To(Equal("/music/test.flac")) }) - It("omits Path when the plugin lacks filesystem permission", func() { + It("omits LibraryID and Path when the plugin lacks filesystem permission", func() { p := &plugin{manifest: &Manifest{}, libraries: newLibraryAccess([]int{1}, false)} - Expect(mediaFileToTrackInfo(p, track).Path).To(BeEmpty()) + ti := mediaFileToTrackInfo(p, track) + Expect(ti.LibraryID).To(BeZero()) + Expect(ti.Path).To(BeEmpty()) }) - It("omits Path when the track's library is not in the allowed set", func() { + It("omits LibraryID and Path when the track's library is not in the allowed set", func() { p := &plugin{manifest: fsManifest, libraries: newLibraryAccess([]int{2}, false)} - Expect(mediaFileToTrackInfo(p, track).Path).To(BeEmpty()) + ti := mediaFileToTrackInfo(p, track) + Expect(ti.LibraryID).To(BeZero()) + Expect(ti.Path).To(BeEmpty()) }) }) }) From e040985de2dd1d8b253e9defd3231b27e3fada49 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 26 Apr 2026 17:34:39 -0400 Subject: [PATCH 035/117] test: enable artwork tests on Windows (#5416) * fix(test): enable artwork tests on Windows by using OS-aware path assertions Replace hardcoded forward-slash path expectations with filepath.FromSlash() so assertions match OS-native separators on Windows. Removes all 8 SkipOnWindows("#TBD-path-sep-artwork") guards from artwork unit tests. * test: add comment explaining forward-slash paths in test fixtures --- core/artwork/artwork_internal_test.go | 19 +++++++------------ core/artwork/reader_artist_test.go | 7 ++----- 2 files changed, 9 insertions(+), 17 deletions(-) diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index 12a7085e8..7a48fa620 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -41,6 +41,7 @@ var _ = Describe("Artwork", func() { MockedTranscoding: &tests.MockTranscodingRepo{}, MockedFolder: folderRepo, } + // Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB. alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}} alEmbedNotFound = model.Album{ID: "333", Name: "Embed not found", EmbedArtPath: "tests/fixtures/NON_EXISTENT.mp3", FolderIDs: []string{"f1"}} alOnlyExternal = model.Album{ID: "444", Name: "Only external", FolderIDs: []string{"f1"}, Discs: model.Discs{1: "", 2: ""}} @@ -80,12 +81,11 @@ var _ = Describe("Artwork", func() { }) }) It("returns embed cover", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") aw, err := newAlbumArtworkReader(ctx, aw, alOnlyEmbed.CoverArtID(), nil) Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3")) + Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/artist/an-album/test.mp3"))) }) It("returns ErrUnavailable if embed path is not available", func() { ffmpeg.Error = errors.New("not available") @@ -104,7 +104,6 @@ var _ = Describe("Artwork", func() { }) }) It("returns external cover", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") folderRepo.result = []model.Folder{{ Path: "tests/fixtures/artist/an-album", ImageFiles: []string{"front.png"}, @@ -113,7 +112,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png")) + Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/artist/an-album/front.png"))) }) It("returns ErrUnavailable if external file is not available", func() { folderRepo.result = []model.Folder{} @@ -135,13 +134,12 @@ var _ = Describe("Artwork", func() { }) DescribeTable("CoverArtPriority", func(priority string, expected string) { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") conf.Server.CoverArtPriority = priority aw, err := newAlbumArtworkReader(ctx, aw, alMultipleCovers.CoverArtID(), nil) Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(expected)) + Expect(path).To(Equal(filepath.FromSlash(expected))) }, Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"), Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"), @@ -213,13 +211,12 @@ var _ = Describe("Artwork", func() { }) DescribeTable("ArtistArtPriority", func(priority string, expected string) { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") conf.Server.ArtistArtPriority = priority aw, err := newArtistArtworkReader(ctx, aw, arMultipleCovers.CoverArtID(), nil) Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(expected)) + Expect(path).To(Equal(filepath.FromSlash(expected))) }, Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"), Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"), @@ -251,22 +248,20 @@ var _ = Describe("Artwork", func() { }) }) It("returns embed cover", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") aw, err := newMediafileArtworkReader(ctx, aw, mfWithEmbed.CoverArtID()) Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal("tests/fixtures/test.mp3")) + Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/test.mp3"))) }) It("returns embed cover if successfully extracted by ffmpeg", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) Expect(err).ToNot(HaveOccurred()) r, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) data, _ := io.ReadAll(r) Expect(data).ToNot(BeEmpty()) - Expect(path).To(Equal("tests/fixtures/test.ogg")) + Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/test.ogg"))) }) It("returns album cover if cannot read embed artwork", func() { // Force fromTag to fail diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 220c7554f..33dc6ed57 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -12,7 +12,6 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/model" - "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) @@ -62,13 +61,12 @@ var _ = Describe("artistArtworkReader", func() { When("artist has only one album", func() { It("returns the parent folder", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") paths = []string{ filepath.FromSlash("/music/artist/album1"), } folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal("/music/artist")) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) Expect(upd).To(Equal(expectedUpdTime)) }) }) @@ -88,14 +86,13 @@ var _ = Describe("artistArtworkReader", func() { When("the album paths contain same prefix", func() { It("returns the common prefix", func() { - tests.SkipOnWindows("artwork path handling (#TBD-path-sep-artwork)") paths = []string{ filepath.FromSlash("/music/artist/album1"), filepath.FromSlash("/music/artist/album2"), } folder, upd, err := loadArtistFolder(ctx, fds, albums, paths) Expect(err).ToNot(HaveOccurred()) - Expect(folder).To(Equal("/music/artist")) + Expect(folder).To(Equal(filepath.FromSlash("/music/artist"))) Expect(upd).To(Equal(expectedUpdTime)) }) }) From 7c6e273c6ace76bc36a31ac01d7f03e0a32b8ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 26 Apr 2026 18:16:14 -0400 Subject: [PATCH 036/117] refactor(artwork): migrate readers to storage.MusicFS and add e2e suite (#5379) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(artwork): add e2e suite documenting album/disc resolution Adds core/artwork/e2e/ with a real-tempdir + scanner harness that exercises artwork resolution end-to-end. Covers album and disc kinds; pending (PIt) cases document two known bugs in reader_album.go for regression-guard flipping once they are fixed. * refactor(artwork): add libraryFS helper to resolve MusicFS for a library * test(artwork): tighten libraryFS test isolation and add scheme-error case * test(artwork): update libraryFS test description to match implementation * refactor(artwork): convert fromExternalFile to use fs.FS Add a temporary fromExternalFileAbs shim so existing absolute-path callers still compile; the shim is removed once all readers are migrated. * refactor(artwork): make fromExternalFileAbs a thin delegator Introduce a minimal osDirectFS adapter so the shim no longer duplicates the matching loop. Both will be removed in Task 9. * refactor(artwork): convert fromTag to taglib.OpenStream over fs.FS Add a temporary fromTagAbs shim so existing absolute-path callers still compile; removed in Task 9. Reuses the osDirectFS adapter from Task 2. * refactor(artwork): defer fs.File close until after taglib reads finish Mirror the lifetime pattern used by adapters/gotaglib/gotaglib.go: keep the underlying fs.File open until taglib.File is closed, and pass WithFilename so format detection doesn't rely on content sniffing. * docs(artwork): note ffmpeg's path-based API limitation * refactor(artwork): migrate album reader to MusicFS - Add libFS (storage.MusicFS) field to albumArtworkReader; resolved once at construction time via libraryFS() - Switch fromCoverArtPriority from abs-path shims to FS-based fromTag/fromExternalFile; only fromFFmpegTag retains absolute path - Build imgFiles as library-relative forward-slash paths in loadAlbumFoldersPaths using path.Join(f.Path, f.Name, img) - Guard embedAbs so that an empty EmbedArtPath never produces a non-empty absolute path (prevents accidental ffmpeg invocation) - Register testfile:// storage scheme in artwork test suite to provide an os.DirFS-backed MusicFS without requiring the taglib extractor - Update test assertions from filepath.FromSlash(abs) to bare forward-slash relative strings * fix(artwork): use path package in compareImageFiles for forward-slash relative paths * refactor(artwork): migrate disc reader to MusicFS Replace os.Open absolute-path access with libFS.Open on library-relative forward-slash paths. Rename discFolders→discFoldersRel, split firstTrackPath into firstTrackRelPath (for fromTag) and firstTrackAbsPath (for fromFFmpegTag), and switch path.Dir/Base/Ext for forward-slash safety. * refactor(artwork): build discFoldersRel directly and guard empty first track * refactor(artwork): migrate mediafile reader to MusicFS * refactor(artwork): migrate artist album-art lookup to MusicFS * refactor(artwork): remove temporary path-based shims All readers now use the FS-based fromTag and fromExternalFile directly, so the absolute-path adapters and the osDirectFS helper that backed them can go away. * test(artwork): rewrite e2e suite to use storagetest.FakeFS Switches from real-tempdir + local storage to FakeFS via the storage registry. Adds a proper multi-disc scenario using the disc tag, which previously required curated MP3 fixtures we did not have. * test(artwork): use maps.Copy in trackFile tag merge Lint cleanup: replace the manual map-copy loop flagged by mapsloop. * test(artwork): reuse tests.MockFFmpeg in e2e harness Replace the hand-rolled noopFFmpeg stub with tests.NewMockFFmpeg, which already satisfies the full ffmpeg.FFmpeg interface and won't drift when new methods are added. Also tie imageBytes to imageFile so they cannot silently disagree on the on-disk encoding. * test(artwork): add e2e scenarios from artwork documentation Covers the behaviors documented at https://www.navidrome.org/docs/usage/library/artwork/: - Album: folder.*/front.* fallbacks and priority order with cover.*. - Disc: cd*.* match, cover.* inside disc folder, DiscArtPriority="" skip path, the documented multi-disc layout, and the discsubtitle keyword. - MediaFile: disc-level fallback for multi-disc tracks and album-level fallback for single-disc tracks (doc section "MediaFiles" items 2-3). - Artist: album/artist.* lookup via libFS (passes). The artist-folder branch is XIt-marked because fromArtistFolder still calls os.DirFS directly on an absolute path and can't read from a FakeFS-backed library — migrating that to storage.MusicFS is a follow-up. Signed-off-by: Deluan * refactor(artwork): scope artist folder traversal to library root Route fromArtistFolder reads through storage.MusicFS and bound the parent-directory walk at the library root. This keeps artwork resolution scoped to the configured library and unblocks FakeFS-backed e2e scenarios that depend on the artist folder. Also consolidate the libraryFS + core.AbsolutePath pairing (used by three readers) into a single libraryFSAndRoot helper. * test(artwork): add ASCII file-tree diagrams to e2e scenarios Each It/PIt block now shows the on-disk layout it exercises, with arrows indicating which file wins (or should win, for the known-bug PIt cases). Makes scenarios readable at a glance without having to parse the MapFS map. * test(artwork): add e2e tests for playlist and radio artwork resolution Signed-off-by: Deluan * test(artwork): enhance e2e tests with real MP3 fixtures for embedded artwork Signed-off-by: Deluan * test(ffmpeg): add support for animated WebP encoder detection and fallback handling Signed-off-by: Deluan * test(artwork): cover additional edge cases in e2e suite Add high-value scenarios uncovered by the existing specs: - Album: three-way basename tie (unsuffixed wins), unknown pattern in CoverArtPriority is skipped, embedded-first with no embedded art falls through. - Disc: discsubtitle with no matching image falls through. - Artist: ArtistArtPriority can reach images via album/. - Playlist: generates a 2x2 tiled cover from album art when the playlist has no uploaded/sidecar/external image. New helper realPNG() produces real taglib/image-decodable bytes so the tiled-cover test can exercise the generator's decode + compose path. * test(artwork): refactor image upload logic in e2e tests for consistency Signed-off-by: Deluan * test(ffmpeg): simplify animated WebP encoder check by removing context parameter Signed-off-by: Deluan * fix(artwork): normalize rel path for fs.Glob on Windows filepath.Rel returns backslash-separated paths on Windows, but fs.Glob and path.Join require forward slashes. Convert with filepath.ToSlash after computing the relative path and use path.Dir for the parent walk so the artist-folder lookup works cross-platform. * fix(ffmpeg): retry animated WebP probe on transient failure The probe previously used the caller's request context inside sync.Once, so a single cancelled first request would permanently disable animated WebP for the rest of the process. Switch to a mutex + probed flag, use a fresh background context with its own timeout, and only cache the result when the probe actually succeeds. * test(ffmpeg): reset ffOnce so ConvertAnimatedImage test is order-independent The ConvertAnimatedImage stand-in test sets ffmpegPath directly but does not reset ffOnce. If ffmpegCmd() has not been called earlier in the test process, the next call inside hasAnimatedWebPEncoder runs ffOnce.Do and re-resolves the real ffmpeg binary, overwriting the stand-in and breaking the test. Reset ffOnce and conf.Server.FFmpegPath alongside the other globals to pin resolution to the stand-in. * test(artwork): unblock Windows CI — forward-slash fs paths and suite-level DB lifetime The internal artwork test planted a Windows absolute path (backslashes) into Folder.Path and then fed it through libFS.Open, which fs.ValidPath rejects. Rooting the testfile library at the temp dir directly and using filepath.ToSlash keeps the path model library-relative and forward-slash, matching production. The e2e suite opened a per-spec DB in a per-spec TempDir, but the go-sqlite3 singleton kept the file open across specs. Ginkgo's per-spec TempDir cleanup then tried to unlink a file still held by that handle — fine on POSIX, fails on Windows. Moving the DB to a suite-level tempdir and closing it in AfterSuite avoids the race. * test(artwork): keep Windows drive letters intact in testfile library URLs url.Parse on `testfile://C:/path` reads `C` as the host and the path loses the drive letter, so Windows libFS lookups go to `/path` and fail. testFileLibPath now prepends a `/` when the OS path has no leading slash, and the testfile constructor strips that extra slash back off before handing the path to os.Stat / os.DirFS. * refactor(artwork): consolidate libFS + root into libraryView helper Collapses the per-reader libFS/libPath/rootFolder/firstTrackAbsPath fields into a single libraryView{FS, absRoot} with an Abs(rel) method. Also folds the two library lookups (ds.Library.Get + core.AbsolutePath) into one, and uses mf.Path directly instead of stripping libRoot off an absolute path. * refactor(ffmpeg): replace hasAnimatedWebPEncoder with encoderProbe for state management Signed-off-by: Deluan * fix: escape artist folder names in artwork glob Escape glob metacharacters in the library-relative artist folder path before composing the fs.Glob pattern for artist image lookup. This preserves literal folder names such as Artist [Live] while keeping the configured filename pattern behavior unchanged, and adds a regression test for bracketed artist folders. Signed-off-by: Deluan * fix(artwork): correct test path assertions after MusicFS migration Source functions (fromTag, fromExternalFile) now return forward-slash fs.FS-relative paths, so test assertions should compare against plain forward-slash strings, not filepath.FromSlash(). The artistArtPriority test needs filepath.FromSlash() on the suffix because findImageInFolder returns OS-native absolute paths via filepath.Join. * fix(artwork): normalize path separators in artistArtPriority assertion The two table entries exercise different code paths: entry 1 goes through fromArtistFolder (returns OS-native paths via filepath.Join), while entry 2 goes through fromExternalFile (returns forward-slash fs.FS paths). Using filepath.FromSlash on the expected value only works for entry 1. Normalize the actual path to forward slashes with filepath.ToSlash so a single HaveSuffix assertion works for both code paths on all platforms. --------- Signed-off-by: Deluan --- core/artwork/artwork_internal_test.go | 33 +- core/artwork/artwork_suite_test.go | 54 ++++ core/artwork/e2e/album_test.go | 354 +++++++++++++++++++++ core/artwork/e2e/artist_test.go | 167 ++++++++++ core/artwork/e2e/disc_test.go | 276 ++++++++++++++++ core/artwork/e2e/helpers_test.go | 184 +++++++++++ core/artwork/e2e/mediafile_test.go | 110 +++++++ core/artwork/e2e/playlist_test.go | 158 +++++++++ core/artwork/e2e/radio_test.go | 42 +++ core/artwork/e2e/suite_test.go | 106 ++++++ core/artwork/e2e/testdata/embedded_art.mp3 | Bin 0 -> 64223 bytes core/artwork/library_fs.go | 44 +++ core/artwork/library_fs_test.go | 45 +++ core/artwork/reader_album.go | 50 +-- core/artwork/reader_album_test.go | 35 +- core/artwork/reader_artist.go | 90 ++++-- core/artwork/reader_artist_test.go | 49 ++- core/artwork/reader_disc.go | 55 ++-- core/artwork/reader_disc_test.go | 137 ++++---- core/artwork/reader_mediafile.go | 11 +- core/artwork/sources.go | 48 ++- core/artwork/sources_internal_test.go | 92 ++++++ core/ffmpeg/ffmpeg.go | 66 ++++ core/ffmpeg/ffmpeg_test.go | 55 ++++ 24 files changed, 2084 insertions(+), 177 deletions(-) create mode 100644 core/artwork/e2e/album_test.go create mode 100644 core/artwork/e2e/artist_test.go create mode 100644 core/artwork/e2e/disc_test.go create mode 100644 core/artwork/e2e/helpers_test.go create mode 100644 core/artwork/e2e/mediafile_test.go create mode 100644 core/artwork/e2e/playlist_test.go create mode 100644 core/artwork/e2e/radio_test.go create mode 100644 core/artwork/e2e/suite_test.go create mode 100644 core/artwork/e2e/testdata/embedded_art.mp3 create mode 100644 core/artwork/library_fs.go create mode 100644 core/artwork/library_fs_test.go create mode 100644 core/artwork/sources_internal_test.go diff --git a/core/artwork/artwork_internal_test.go b/core/artwork/artwork_internal_test.go index 7a48fa620..c95371959 100644 --- a/core/artwork/artwork_internal_test.go +++ b/core/artwork/artwork_internal_test.go @@ -37,9 +37,13 @@ var _ = Describe("Artwork", func() { conf.Server.CoverArtPriority = "folder.*, cover.*, embedded , front.*" folderRepo = &fakeFolderRepo{} + libRepo := &tests.MockLibraryRepo{} + repoRoot, _ := os.Getwd() + libRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(repoRoot)}}) ds = &tests.MockDataStore{ MockedTranscoding: &tests.MockTranscodingRepo{}, MockedFolder: folderRepo, + MockedLibrary: libRepo, } // Paths use forward slashes because the scanner stores fs.FS-relative paths in the DB. alOnlyEmbed = model.Album{ID: "222", Name: "Only embed", EmbedArtPath: "tests/fixtures/artist/an-album/test.mp3", FolderIDs: []string{"f1"}} @@ -85,7 +89,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/artist/an-album/test.mp3"))) + Expect(path).To(Equal("tests/fixtures/artist/an-album/test.mp3")) }) It("returns ErrUnavailable if embed path is not available", func() { ffmpeg.Error = errors.New("not available") @@ -112,7 +116,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/artist/an-album/front.png"))) + Expect(path).To(Equal("tests/fixtures/artist/an-album/front.png")) }) It("returns ErrUnavailable if external file is not available", func() { folderRepo.result = []model.Folder{} @@ -139,7 +143,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(filepath.FromSlash(expected))) + Expect(path).To(Equal(expected)) }, Entry(nil, " folder.* , cover.*,embedded,front.*", "tests/fixtures/artist/an-album/cover.jpg"), Entry(nil, "front.* , cover.*, embedded ,folder.*", "tests/fixtures/artist/an-album/front.png"), @@ -195,9 +199,12 @@ var _ = Describe("Artwork", func() { Describe("artistArtworkReader", func() { Context("Multiple covers", func() { BeforeEach(func() { + repoRoot, err := os.Getwd() + Expect(err).ToNot(HaveOccurred()) folderRepo.result = []model.Folder{{ - Path: "tests/fixtures/artist/an-album", - ImageFiles: []string{"artist.png"}, + LibraryPath: testFileLibPath(repoRoot), + Path: "tests/fixtures/artist/an-album", + ImageFiles: []string{"artist.png"}, }} ds.Artist(ctx).(*tests.MockArtistRepo).SetData(model.Artists{ arMultipleCovers, @@ -216,7 +223,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(filepath.FromSlash(expected))) + Expect(filepath.ToSlash(path)).To(HaveSuffix(expected)) }, Entry(nil, " folder.* , artist.*,album/artist.*", "tests/fixtures/artist/artist.jpg"), Entry(nil, "album/artist.*, folder.*,artist.*", "tests/fixtures/artist/an-album/artist.png"), @@ -252,7 +259,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) _, path, err := aw.Reader(ctx) Expect(err).ToNot(HaveOccurred()) - Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/test.mp3"))) + Expect(path).To(Equal("tests/fixtures/test.mp3")) }) It("returns embed cover if successfully extracted by ffmpeg", func() { aw, err := newMediafileArtworkReader(ctx, aw, mfCorruptedCover.CoverArtID()) @@ -261,7 +268,7 @@ var _ = Describe("Artwork", func() { Expect(err).ToNot(HaveOccurred()) data, _ := io.ReadAll(r) Expect(data).ToNot(BeEmpty()) - Expect(path).To(Equal(filepath.FromSlash("tests/fixtures/test.ogg"))) + Expect(path).To(Equal("tests/fixtures/test.ogg")) }) It("returns album cover if cannot read embed artwork", func() { // Force fromTag to fail @@ -460,7 +467,10 @@ var _ = Describe("Artwork", func() { Name: "Only external", FolderIDs: []string{"tmp"}, } - folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{coverFileName}}} + folderRepo.result = []model.Folder{{ImageFiles: []string{coverFileName}}} + rootLibRepo := &tests.MockLibraryRepo{} + rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}}) + ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{ alCover, }) @@ -548,7 +558,10 @@ var _ = Describe("Artwork", func() { Name: "Only external", FolderIDs: []string{"tmp"}, } - folderRepo.result = []model.Folder{{Path: dirName, ImageFiles: []string{"cover.png"}}} + folderRepo.result = []model.Folder{{ImageFiles: []string{"cover.png"}}} + rootLibRepo := &tests.MockLibraryRepo{} + rootLibRepo.SetData(model.Libraries{{ID: 0, Path: testFileLibPath(dirName)}}) + ds.(*tests.MockDataStore).MockedLibrary = rootLibRepo ds.Album(ctx).(*tests.MockAlbumRepo).SetData(model.Albums{alCover}) conf.Server.CoverArtPriority = "cover.png" diff --git a/core/artwork/artwork_suite_test.go b/core/artwork/artwork_suite_test.go index dfd66e5e5..d42d7f3e4 100644 --- a/core/artwork/artwork_suite_test.go +++ b/core/artwork/artwork_suite_test.go @@ -1,9 +1,17 @@ package artwork import ( + "io/fs" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" "testing" + "github.com/navidrome/navidrome/core/storage" "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model/metadata" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -15,3 +23,49 @@ func TestArtwork(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Artwork Suite") } + +// osDirFS wraps os.DirFS as a storage.MusicFS for integration tests. +// ReadTags is not used by albumArtworkReader, so it is left as a stub. +type osDirFS struct{ fs.FS } + +func (o osDirFS) ReadTags(...string) (map[string]metadata.Info, error) { return nil, nil } + +// testFileScheme is the URL scheme registered to expose a tempdir as a +// storage.MusicFS for artwork integration tests. +const testFileScheme = "testfile" + +// testFileLibPath builds a `testfile://` library URL for the given absolute +// filesystem path. On Windows, the native path (e.g. `C:\foo`) has no leading +// slash after ToSlash, which makes url.Parse treat the drive letter as a +// host. We prepend a `/` so parsing yields `u.Path == /C:/foo`, and the +// registered constructor below strips that leading slash back off. +func testFileLibPath(absPath string) string { + p := filepath.ToSlash(absPath) + if !strings.HasPrefix(p, "/") { + p = "/" + p + } + return testFileScheme + "://" + p +} + +func init() { + // Register the testfile storage scheme (os.DirFS-backed MusicFS). Used by + // integration tests that need real files but not the taglib extractor. + storage.Register(testFileScheme, func(u url.URL) storage.Storage { + root := u.Path + // Undo the leading slash added by testFileLibPath on Windows so that + // os.Stat / os.DirFS receive a native path like `C:\foo`. + if runtime.GOOS == "windows" && len(root) >= 3 && root[0] == '/' && root[2] == ':' { + root = root[1:] + } + return &osDirStorage{root: filepath.FromSlash(root)} + }) +} + +type osDirStorage struct{ root string } + +func (s *osDirStorage) FS() (storage.MusicFS, error) { + if _, err := os.Stat(s.root); err != nil { + return nil, err + } + return osDirFS{os.DirFS(s.root)}, nil +} diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go new file mode 100644 index 000000000..3d5523afd --- /dev/null +++ b/core/artwork/e2e/album_test.go @@ -0,0 +1,354 @@ +package artworke2e_test + +import ( + "testing/fstest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +const ( + defaultCoverPriority = "cover.*, folder.*, front.*, embedded, external" + defaultDiscPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded" +) + +var _ = Describe("Album artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("an album has a single folder with cover.jpg at the album root", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── cover.jpg ← matched by cover.* + It("returns the album-root cover", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + // Bug 2 variant: cover.* basenames tie across album-root and per-disc folders; + // compareImageFiles' lexicographic full-path tiebreaker ranks disc-subfolder + // files first. Flip from PIt to It once it prefers shorter/parent paths. + When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── cover.jpg ← currently wins (bug) + // ├── CD2/ + // │ ├── 01 - Track.mp3 + // │ └── cover.jpg + // └── cover.jpg ← should win (album-root fallback) + PIt("uses the album-root cover (currently picks a disc subfolder image — bug)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), + "Artist/Album/cover.jpg": imageFile("album-root"), + "Artist/Album/CD1/cover.jpg": imageFile("disc1"), + "Artist/Album/CD2/cover.jpg": imageFile("disc2"), + }) + scan() + + al := firstAlbum() + Expect(al.FolderIDs).To(HaveLen(2), + "sanity check: scanner should treat the two disc subfolders as one multi-disc album") + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + // Bug 2: folder.jpg basenames tie across album-root and per-disc folders; + // the lexicographic full-path tiebreaker in compareImageFiles ranks + // "Artist/Album/CD1/folder.jpg" ahead of "Artist/Album/folder.jpg". + // Flip from PIt to It once compareImageFiles prefers shorter/parent paths. + When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg ← currently wins (bug) + // ├── CD2/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // └── folder.jpg ← should win (album-root fallback) + PIt("uses the album-root folder.jpg (currently picks a disc subfolder image — bug)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), + "Artist/Album/folder.jpg": imageFile("album-root"), + "Artist/Album/CD1/folder.jpg": imageFile("disc1"), + "Artist/Album/CD2/folder.jpg": imageFile("disc2"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + // Bug 1: commonParentFolder's `len(folders) < 2` guard skips the parent-folder + // lookup whenever an album lives entirely under a single subfolder, so an + // album-root cover is never considered. Flip from PIt to It once the guard + // accepts single-folder albums whose parent isn't already in the folder set. + When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() { + // Artist/ + // └── Album/ + // ├── disc1/ + // │ └── 01 - Track.mp3 + // └── cover.jpg ← should win (parent-folder fallback, currently ignored — bug) + PIt("uses the parent-folder cover (currently ignored — bug)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + + When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 ← has embedded picture (wins via "embedded") + // └── cover.jpg + It("returns the embedded image", func() { + conf.Server.CoverArtPriority = "embedded, cover.*, folder.*, front.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), + "Artist/Album/cover.jpg": imageFile("external"), + }) + scan() + // Swap in real MP3 bytes so libFS.Open returns a taglib-readable stream. + replaceWithRealMP3("Artist/Album/01 - Track.mp3") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) + }) + }) + + When("CoverArtPriority lists external first but no external file is present", func() { + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 ← has embedded picture (falls through to "embedded") + It("falls through to embedded artwork", func() { + conf.Server.CoverArtPriority = "external, embedded" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"has_picture": "true"}), + }) + scan() + replaceWithRealMP3("Artist/Album/01 - Track.mp3") + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(embeddedArtBytes)) + }) + }) + + When("the only cover file uses uppercase extension and a different case in its name", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── Cover.JPG ← matched case-insensitively by cover.* + It("matches case-insensitively against cover.*", func() { + conf.Server.CoverArtPriority = "cover.*, folder.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/Cover.JPG": imageFile("case-insensitive"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("case-insensitive"))) + }) + }) + + When("two cover files have basenames that tie under the natural-sort tiebreaker", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── cover.jpg ← wins (no numeric suffix) + // └── cover.1.jpg + It("prefers the file without a numeric suffix", func() { + conf.Server.CoverArtPriority = "cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("primary"), + "Artist/Album/cover.1.jpg": imageFile("secondary"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) + }) + }) + + When("the album has no cover and CoverArtPriority lists only file patterns", func() { + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 (no image files — returns ErrUnavailable) + It("returns ErrUnavailable", func() { + conf.Server.CoverArtPriority = "cover.*, folder.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + }) + scan() + + al := firstAlbum() + _, err := readArtworkOrErr(model.NewArtworkID(model.KindAlbumArtwork, al.ID, &al.UpdatedAt)) + Expect(err).To(HaveOccurred()) + }) + }) + + // Doc scenarios from: + // https://www.navidrome.org/docs/usage/library/artwork/#albums + // Default CoverArtPriority is "cover.*, folder.*, front.*, embedded, external". + When("only folder.jpg is present (cover.* and front.* missing)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── folder.jpg ← matched by folder.* + It("falls through to folder.jpg", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/folder.jpg": imageFile("folder"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) + }) + }) + + When("only front.jpg is present (cover.* and folder.* missing)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── front.jpg ← matched by front.* + It("falls through to front.jpg", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/front.jpg": imageFile("front"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("front"))) + }) + }) + + When("cover.*, folder.*, and front.* all exist in the same folder", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── cover.jpg ← wins (cover.* is first in priority) + // ├── folder.jpg + // └── front.jpg + It("prefers cover.* (first in CoverArtPriority)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("cover"), + "Artist/Album/folder.jpg": imageFile("folder"), + "Artist/Album/front.jpg": imageFile("front"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + }) + }) + + When("only folder.* and front.* exist (priority order check)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── folder.jpg ← wins (folder.* comes before front.*) + // └── front.jpg + It("prefers folder.* over front.*", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/folder.jpg": imageFile("folder"), + "Artist/Album/front.jpg": imageFile("front"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("folder"))) + }) + }) + + When("three cover files tie by basename and differ only by numeric suffix", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── cover.jpg ← wins (no numeric suffix) + // ├── cover.1.jpg + // └── cover.2.jpg + It("selects the unsuffixed file first regardless of numeric-suffix order", func() { + conf.Server.CoverArtPriority = "cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.2.jpg": imageFile("second"), + "Artist/Album/cover.jpg": imageFile("primary"), + "Artist/Album/cover.1.jpg": imageFile("first"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("primary"))) + }) + }) + + When("CoverArtPriority contains an unknown pattern before a matching one", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── cover.jpg ← wins (unknown "bogus.*" is skipped) + It("skips the unknown pattern and falls through to the matching one", func() { + conf.Server.CoverArtPriority = "bogus.*, cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("cover"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + }) + }) + + When("embedded is first in CoverArtPriority but the track has no embedded art", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 (no embedded picture) + // └── cover.jpg ← wins (embedded skipped, falls through) + It("falls through to the next priority entry", func() { + conf.Server.CoverArtPriority = "embedded, cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("cover"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("cover"))) + }) + }) +}) diff --git a/core/artwork/e2e/artist_test.go b/core/artwork/e2e/artist_test.go new file mode 100644 index 000000000..d959b1d60 --- /dev/null +++ b/core/artwork/e2e/artist_test.go @@ -0,0 +1,167 @@ +package artworke2e_test + +import ( + "os" + "path/filepath" + "testing/fstest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Doc reference: +// https://www.navidrome.org/docs/usage/library/artwork/#artists +// Default ArtistArtPriority is "artist.*, album/artist.*, external". +var _ = Describe("Artist artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("the artist folder contains an artist.jpg", func() { + // Artist/ + // ├── artist.jpg ← matched by artist.* + // └── Album/ + // └── 01 - Track.mp3 + It("returns the artist.* image from the artist folder", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/artist.jpg": imageFile("artist-folder"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + }) + }) + + When("artist.* only exists inside an album folder", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── artist.jpg ← matched by album/artist.* + It("falls through to album/artist.* and returns that image", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/Album/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) + }) + }) + + When("both the artist folder and an album folder have an artist.* image", func() { + // Artist/ + // ├── artist.jpg ← wins (artist.* before album/artist.*) + // └── Album/ + // ├── 01 - Track.mp3 + // └── artist.jpg + It("prefers the artist-folder image (artist.* comes before album/artist.*)", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/artist.jpg": imageFile("artist-folder"), + "Artist/Album/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-folder"))) + }) + }) + + When("an artist has an uploaded image and a matching artist.* file", func() { + // / + // └── artwork/ + // └── artist/ + // └── _upload.jpg ← wins (uploaded image beats the priority chain) + // Library: + // Artist/ + // ├── artist.jpg (ignored — uploaded image comes first) + // └── Album/ + // └── 01 - Track.mp3 + It("prefers the uploaded image over any priority-chain match", func() { + conf.Server.ArtistArtPriority = "artist.*, album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/artist.jpg": imageFile("artist-folder"), + }) + scan() + ar := soleArtist() + + uploaded := ar.ID + "_upload.jpg" + writeUploadedImage(consts.EntityArtist, uploaded, imageBytes("artist-uploaded")) + ar.UploadedImage = uploaded + Expect(ds.Artist(ctx).Put(&ar)).To(Succeed()) + + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("artist-uploaded"))) + }) + }) + + When("ArtistArtPriority uses album/ (not just album/artist.*)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── artist.jpg ← matched by album/artist.* + It("resolves the pattern against the artist's album image files", func() { + conf.Server.ArtistArtPriority = "album/artist.*, external" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + "Artist/Album/artist.jpg": imageFile("album-artist"), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("album-artist"))) + }) + }) + + When("ArtistArtPriority starts with image-folder and ArtistImageFolder has a name-matching image", func() { + // / + // └── Artist.jpg ← matched by artist name (image-folder source) + // Library: + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 (no artist.* present in library) + It("returns the image from the configured artist image folder", func() { + imgFolder := GinkgoT().TempDir() + Expect(os.WriteFile(filepath.Join(imgFolder, "Artist.jpg"), imageBytes("image-folder"), 0600)).To(Succeed()) + conf.Server.ArtistImageFolder = imgFolder + conf.Server.ArtistArtPriority = "image-folder, artist.*, album/artist.*" + + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track", map[string]any{"albumartist": "Artist"}), + }) + scan() + + ar := soleArtist() + artID := model.NewArtworkID(model.KindArtistArtwork, ar.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("image-folder"))) + }) + }) +}) + +func soleArtist() model.Artist { + GinkgoHelper() + artists, err := ds.Artist(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"artist.name": "Artist"}, + }) + Expect(err).ToNot(HaveOccurred()) + if len(artists) == 0 { + Fail("sole artist not found") + return model.Artist{} + } + return artists[0] +} diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go new file mode 100644 index 000000000..7569cbc32 --- /dev/null +++ b/core/artwork/e2e/disc_test.go @@ -0,0 +1,276 @@ +package artworke2e_test + +import ( + "testing/fstest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Disc artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("the album is single-disc with a disc1.jpg in the only folder", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── disc1.jpg ← matched by disc*.* + It("returns the disc1.jpg image (matched as disc*.*)", func() { + conf.Server.DiscArtPriority = "disc*.*, cd*.*, cover.*, folder.*, front.*, embedded" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/disc1.jpg": imageFile("disc1-image"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-image"))) + }) + }) + + When("the album has no per-disc image and no album cover", func() { + // Artist/ + // └── Album/ + // └── 01 - Track.mp3 (no disc or album art — returns ErrUnavailable) + It("returns ErrUnavailable for the disc lookup", func() { + conf.Server.DiscArtPriority = "disc*.*, cd*.*" + conf.Server.CoverArtPriority = "cover.*, folder.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + _, err := readArtworkOrErr(discID) + Expect(err).To(HaveOccurred()) + }) + }) + + When("the album has no per-disc image but has an album cover", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // └── cover.jpg ← album-level fallback (no disc art present) + It("falls back to the album cover", func() { + conf.Server.DiscArtPriority = "disc*.*, cd*.*" + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("album-cover"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover"))) + }) + }) + + When("multiple disc images exist in the same folder (disc1 vs disc10)", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 + // ├── disc1.jpg ← matches request for disc 1 + // └── disc10.jpg + It("matches the requested disc number, not a higher-numbered one", func() { + conf.Server.DiscArtPriority = "disc*.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/disc1.jpg": imageFile("disc-one"), + "Artist/Album/disc10.jpg": imageFile("disc-ten"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc-one"))) + }) + }) + + When("a multi-disc album has per-disc covers", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── disc1.jpg ← matches request for disc 1 + // └── CD2/ + // ├── 01 - Track.mp3 + // └── disc2.jpg ← matches request for disc 2 + It("returns the requested disc's image", func() { + conf.Server.DiscArtPriority = "disc*.*" + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/disc2.jpg": imageFile("disc-2"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc-2"))) + }) + }) + + // Doc scenarios from: + // https://www.navidrome.org/docs/usage/library/artwork/#disc-cover-art + // Default DiscArtPriority is "disc*.*, cd*.*, cover.*, folder.*, front.*, discsubtitle, embedded". + When("a disc subfolder has a cd2.png image", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── disc1.jpg + // └── CD2/ + // ├── 01 - Track.mp3 + // └── cd2.png ← matched by cd*.* for disc 2 + It("matches via the cd*.* pattern", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/cd2.png": imageFile("cd-2"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("cd-2"))) + }) + }) + + When("a disc subfolder has cover.jpg but no disc*.*/cd*.* image", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── cover.jpg ← matched by cover.* inside disc folder + // └── CD2/ + // ├── 01 - Track.mp3 + // └── cover.jpg + It("falls through to cover.* inside the disc folder", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/cover.jpg": imageFile("disc1-cover"), + "Artist/Album/CD2/cover.jpg": imageFile("disc2-cover"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("disc1-cover"))) + }) + }) + + When("DiscArtPriority is the empty string", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── disc1.jpg (ignored — DiscArtPriority is empty) + // ├── CD2/ + // │ ├── 01 - Track.mp3 + // │ └── cd2.png (ignored — DiscArtPriority is empty) + // └── cover.jpg ← used for every disc (album-level fallback) + It("skips every disc-level source and returns the album cover", func() { + conf.Server.DiscArtPriority = "" + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/cd2.png": imageFile("cd-2"), + "Artist/Album/cover.jpg": imageFile("album-cover"), + }) + scan() + + al := firstAlbum() + for _, n := range []int{1, 2} { + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, n), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("album-cover")), + "disc %d should use the album cover when DiscArtPriority is empty", n) + } + }) + }) + + When("the documented multi-disc layout is used (disc1.jpg + cd2.png + album-root cover.jpg)", func() { + // Artist/ + // └── Album/ + // ├── disc1/ + // │ ├── disc1.jpg ← matched by disc*.* for disc 1 + // │ ├── 01 - Track.mp3 + // │ └── 02 - Track.mp3 + // ├── disc2/ + // │ ├── cd2.png ← matched by cd*.* for disc 2 + // │ ├── 01 - Track.mp3 + // │ └── 02 - Track.mp3 + // └── cover.jpg (album-level fallback, unused here) + It("matches the per-disc image for each disc", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/disc1/02 - Track.mp3": trackFile(2, "T2", map[string]any{"disc": "1"}), + "Artist/Album/disc2/01 - Track.mp3": trackFile(1, "T3", map[string]any{"disc": "2"}), + "Artist/Album/disc2/02 - Track.mp3": trackFile(2, "T4", map[string]any{"disc": "2"}), + "Artist/Album/disc1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/disc2/cd2.png": imageFile("cd-2"), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + al := firstAlbum() + disc1ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + disc2ID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 2), &al.UpdatedAt) + Expect(readArtwork(disc1ID)).To(Equal(imageBytes("disc-1"))) + Expect(readArtwork(disc2ID)).To(Equal(imageBytes("cd-2"))) + }) + }) + + When("discsubtitle keyword matches an image whose stem equals the disc's subtitle", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks") + // └── Bonus Tracks.jpg ← matched by "discsubtitle" keyword + It("selects the subtitle-named image", func() { + conf.Server.DiscArtPriority = "discsubtitle" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}), + "Artist/Album/Bonus Tracks.jpg": imageFile("bonus-tracks"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("bonus-tracks"))) + }) + }) + + When("discsubtitle is set but no image filename matches the subtitle", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 (discsubtitle="Bonus Tracks") + // └── cover.jpg ← wins (discsubtitle has no match, falls through) + It("falls through to the next priority entry", func() { + conf.Server.DiscArtPriority = "discsubtitle, cover.*" + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1", "discsubtitle": "Bonus Tracks"}), + "Artist/Album/cover.jpg": imageFile("cover"), + }) + scan() + + al := firstAlbum() + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, 1), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes("cover"))) + }) + }) +}) diff --git a/core/artwork/e2e/helpers_test.go b/core/artwork/e2e/helpers_test.go new file mode 100644 index 000000000..e3abca097 --- /dev/null +++ b/core/artwork/e2e/helpers_test.go @@ -0,0 +1,184 @@ +package artworke2e_test + +import ( + "bytes" + "context" + _ "embed" + "errors" + "hash/fnv" + "image" + "image/color" + "image/png" + "io" + "maps" + "net/url" + "os" + "path/filepath" + "testing/fstest" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/core/external" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/resources" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.senan.xyz/taglib" +) + +// realMP3WithEmbeddedArt is the bytes of the canonical test fixture that +// contains a valid MP3 stream with an embedded picture. Used in the +// embedded-art e2e scenarios where FakeFS's JSON-encoded tag data isn't +// readable by taglib. Swap this into fakeFS.MapFS *after* scanning so the +// scanner still populates EmbedArtPath via the JSON-tagged track, and the +// artwork reader gets real bytes when it calls libFS.Open. +// +//go:embed testdata/embedded_art.mp3 +var realMP3WithEmbeddedArt []byte + +// embeddedArtBytes is the exact image payload that the artwork reader will +// extract from realMP3WithEmbeddedArt. Computed once via taglib so tests can +// assert byte-for-byte equality — if this ever differs it means the reader +// pulled from a different source. +var embeddedArtBytes = extractEmbeddedArt(realMP3WithEmbeddedArt) + +func extractEmbeddedArt(mp3 []byte) []byte { + tf, err := taglib.OpenStream(bytes.NewReader(mp3)) + if err != nil { + panic("embedded-art fixture: taglib.OpenStream failed: " + err.Error()) + } + defer tf.Close() + images := tf.Properties().Images + if len(images) == 0 { + panic("embedded-art fixture has no embedded images") + } + data, err := tf.Image(0) + if err != nil || len(data) == 0 { + panic("embedded-art fixture: could not read image 0") + } + return data +} + +// replaceWithRealMP3 swaps the FakeFS entry at the given library-relative +// path so libFS.Open returns an MP3 stream taglib can parse. +func replaceWithRealMP3(relPath string) { + GinkgoHelper() + fakeFS.MapFS[relPath] = &fstest.MapFile{Data: realMP3WithEmbeddedArt} +} + +// placeholderBytes returns the bundled album-placeholder image bytes — the +// same stream the artwork reader emits when every source falls through. +func placeholderBytes() []byte { + GinkgoHelper() + r, err := resources.FS().Open(consts.PlaceholderAlbumArt) + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + data, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + return data +} + +// writeUploadedImage drops `filename` into /artwork// with +// the given bytes, matching the on-disk layout expected by +// model.UploadedImagePath. +func writeUploadedImage(entity, filename string, data []byte) { + GinkgoHelper() + dir := filepath.Dir(model.UploadedImagePath(entity, filename)) + Expect(os.MkdirAll(dir, 0755)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, filename), data, 0600)).To(Succeed()) +} + +func newNoopFFmpeg() *tests.MockFFmpeg { + ff := tests.NewMockFFmpeg("") + ff.Error = errors.New("noop") + return ff +} + +// trackFile builds a FakeFS MP3 entry with optional tag overrides. +func trackFile(num int, title string, extra ...map[string]any) *fstest.MapFile { + tags := storagetest.Track(num, title) + for _, e := range extra { + maps.Copy(tags, e) + } + return storagetest.MP3(tags) +} + +// imageFile builds a label-keyed image entry. The bytes are deterministic +// per-label so tests can assert which file won. +func imageFile(label string) *fstest.MapFile { + return &fstest.MapFile{Data: []byte("image:" + label)} +} + +// realPNG builds a minimal 2x2 PNG with a color derived from label. Needed by +// tests that feed the bytes into image.Decode (e.g. playlist tiled covers). +func realPNG(label string) *fstest.MapFile { + img := image.NewRGBA(image.Rect(0, 0, 2, 2)) + // Derive a deterministic color per label. + h := fnv.New32a() + _, _ = h.Write([]byte(label)) + sum := h.Sum32() + c := color.RGBA{R: byte(sum), G: byte(sum >> 8), B: byte(sum >> 16), A: 255} + for y := range 2 { + for x := range 2 { + img.Set(x, y, c) + } + } + var buf bytes.Buffer + Expect(png.Encode(&buf, img)).To(Succeed()) + return &fstest.MapFile{Data: buf.Bytes()} +} + +// imageBytes returns the bytes that imageFile(label) writes. +func imageBytes(label string) []byte { return imageFile(label).Data } + +// setLayout populates fakeFS with the given map. Call after setupHarness. +// All paths must be forward-slash and relative (no leading "/"). +func setLayout(files fstest.MapFS) { + GinkgoHelper() + fakeFS.SetFiles(files) +} + +func readArtwork(artID model.ArtworkID) []byte { + GinkgoHelper() + r, _, err := aw.Get(ctx, artID, 0, false) + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + b, err := io.ReadAll(r) + Expect(err).ToNot(HaveOccurred()) + return b +} + +func readArtworkOrErr(artID model.ArtworkID) ([]byte, error) { + r, _, err := aw.Get(ctx, artID, 0, false) + if err != nil { + return nil, err + } + defer r.Close() + return io.ReadAll(r) +} + +// noopProvider implements external.Provider with not-found returns so the +// "external" priority entry never produces a result. +type noopProvider struct{} + +func (n *noopProvider) UpdateAlbumInfo(context.Context, string) (*model.Album, error) { + return nil, model.ErrNotFound +} +func (n *noopProvider) UpdateArtistInfo(context.Context, string, int, bool) (*model.Artist, error) { + return nil, model.ErrNotFound +} +func (n *noopProvider) SimilarSongs(context.Context, string, int) (model.MediaFiles, error) { + return nil, nil +} +func (n *noopProvider) TopSongs(context.Context, string, int) (model.MediaFiles, error) { + return nil, nil +} +func (n *noopProvider) ArtistImage(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} +func (n *noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { + return nil, model.ErrNotFound +} + +var _ external.Provider = (*noopProvider)(nil) diff --git a/core/artwork/e2e/mediafile_test.go b/core/artwork/e2e/mediafile_test.go new file mode 100644 index 000000000..1f43a3827 --- /dev/null +++ b/core/artwork/e2e/mediafile_test.go @@ -0,0 +1,110 @@ +package artworke2e_test + +import ( + "testing/fstest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Doc reference: +// https://www.navidrome.org/docs/usage/library/artwork/#mediafiles +// Navidrome resolves mediafile artwork in this order: +// 1. Embedded image from the mediafile itself +// 2. For multi-disc albums, disc-level artwork +// 3. Album cover art +// +// FakeFS cannot synthesize taglib-readable embedded JPEGs, so scenario (1) +// is covered by the existing embedded-art album tests (which currently +// Skip under FakeFS). The tests below cover (2) and (3): the fallback +// chain for tracks without embedded art. +var _ = Describe("MediaFile artwork fallback", func() { + BeforeEach(func() { + setupHarness() + }) + + When("a multi-disc album track has no embedded art", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── disc1.jpg + // ├── CD2/ + // │ ├── 01 - Track.mp3 ← track requested + // │ └── disc2.jpg ← wins (disc-level before album-level) + // └── cover.jpg + It("falls back to the disc-level artwork (not the album cover)", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/CD1/disc1.jpg": imageFile("disc-1"), + "Artist/Album/CD2/disc2.jpg": imageFile("disc-2"), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") + Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("disc-2"))) + }) + }) + + When("a single-disc album track has no embedded art", func() { + // Artist/ + // └── Album/ + // ├── 01 - Track.mp3 ← track requested + // └── cover.jpg ← wins (album-level fallback, no disc subfolder) + It("falls back to the album cover", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/01 - Track.mp3": trackFile(1, "Track"), + "Artist/Album/cover.jpg": imageFile("album-cover"), + }) + scan() + + mf := mediafileOn("Artist/Album/01 - Track.mp3") + Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-cover"))) + }) + }) + + When("a multi-disc album track has no embedded art and the disc has no disc-level image", func() { + // Artist/ + // └── Album/ + // ├── CD1/ + // │ └── 01 - Track.mp3 + // ├── CD2/ + // │ └── 01 - Track.mp3 ← track requested + // └── cover.jpg ← wins (no disc image → album-level fallback) + It("falls through from disc to album cover", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + conf.Server.DiscArtPriority = defaultDiscPriority + setLayout(fstest.MapFS{ + "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "T1", map[string]any{"disc": "1"}), + "Artist/Album/CD2/01 - Track.mp3": trackFile(1, "T2", map[string]any{"disc": "2"}), + "Artist/Album/cover.jpg": imageFile("album-root"), + }) + scan() + + mf := mediafileOn("Artist/Album/CD2/01 - Track.mp3") + Expect(readArtwork(mf.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) +}) + +func mediafileOn(relPath string) model.MediaFile { + GinkgoHelper() + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Like{"media_file.path": relPath}, + }) + Expect(err).ToNot(HaveOccurred()) + if len(mfs) == 0 { + Fail("mediafile not found: " + relPath) + return model.MediaFile{} + } + return mfs[0] +} diff --git a/core/artwork/e2e/playlist_test.go b/core/artwork/e2e/playlist_test.go new file mode 100644 index 000000000..d28efca8e --- /dev/null +++ b/core/artwork/e2e/playlist_test.go @@ -0,0 +1,158 @@ +package artworke2e_test + +import ( + "os" + "path/filepath" + "testing/fstest" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Playlist artwork resolves in this priority order: +// 1. Uploaded image (/artwork/playlist/) +// 2. Sidecar image next to the .m3u file (same basename, any image ext) +// 3. ExternalImageURL (http/https requires EnableM3UExternalAlbumArt; local path always allowed) +// 4. Generated 2x2 tiled cover from the playlist's albums +// 5. Album placeholder image +// +// The library FS is FakeFS, but uploaded/sidecar/local-external images are +// real files on disk — the reader reads them via os.Open, so the tests +// place them in a real tempdir under DataFolder. +var _ = Describe("Playlist artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("a playlist has an uploaded image", func() { + // / + // └── artwork/ + // └── playlist/ + // └── pl-1_upload.jpg ← matched by UploadedImagePath() (highest priority) + It("returns the uploaded image bytes", func() { + writeUploadedImage(consts.EntityPlaylist, "pl-1_upload.jpg", imageBytes("playlist-upload")) + + pl := putPlaylist(model.Playlist{ID: "pl-1", Name: "Test", UploadedImage: "pl-1_upload.jpg"}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("playlist-upload"))) + }) + }) + + When("a playlist has no uploaded image but a sidecar image beside its .m3u file", func() { + // / + // ├── MyList.m3u + // └── MyList.jpg ← matched by sidecar (same basename, case-insensitive) + It("returns the sidecar image", func() { + dir := GinkgoT().TempDir() + m3uPath := filepath.Join(dir, "MyList.m3u") + Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "MyList.jpg"), imageBytes("sidecar"), 0600)).To(Succeed()) + + pl := putPlaylist(model.Playlist{ID: "pl-2", Name: "MyList", Path: m3uPath}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar"))) + }) + }) + + When("a playlist's sidecar uses a different extension case", func() { + // / + // ├── MyList.m3u + // └── MyList.PNG ← matched case-insensitively + It("matches case-insensitively", func() { + dir := GinkgoT().TempDir() + m3uPath := filepath.Join(dir, "MyList.m3u") + Expect(os.WriteFile(m3uPath, []byte("#EXTM3U\n"), 0600)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(dir, "MyList.PNG"), imageBytes("sidecar-png"), 0600)).To(Succeed()) + + pl := putPlaylist(model.Playlist{ID: "pl-3", Name: "MyList", Path: m3uPath}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("sidecar-png"))) + }) + }) + + When("a playlist has an ExternalImageURL pointing to a local file", func() { + // / + // └── cover.jpg ← absolute path stored in ExternalImageURL + It("returns the local file regardless of EnableM3UExternalAlbumArt", func() { + conf.Server.EnableM3UExternalAlbumArt = false // local paths bypass the toggle + dir := GinkgoT().TempDir() + imgPath := filepath.Join(dir, "cover.jpg") + Expect(os.WriteFile(imgPath, imageBytes("external-local"), 0600)).To(Succeed()) + + pl := putPlaylist(model.Playlist{ID: "pl-4", Name: "WithExt", ExternalImageURL: imgPath}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(imageBytes("external-local"))) + }) + }) + + When("a playlist has an http(s) ExternalImageURL and EnableM3UExternalAlbumArt is false", func() { + // (no local files — http source is gated off, reader falls through to placeholder) + It("skips the URL and falls through to the bundled placeholder", func() { + conf.Server.EnableM3UExternalAlbumArt = false + + pl := putPlaylist(model.Playlist{ID: "pl-5", Name: "HttpGated", ExternalImageURL: "https://example.com/cover.jpg"}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) + }) + }) + + When("a playlist has no images and no tracks", func() { + // (reader falls all the way through to the bundled album placeholder) + It("returns the album placeholder", func() { + pl := putPlaylist(model.Playlist{ID: "pl-6", Name: "Empty"}) + + Expect(readArtwork(pl.CoverArtID())).To(Equal(placeholderBytes())) + }) + }) + + When("a playlist has no uploaded/sidecar/external image but has tracks with album covers", func() { + // Library: + // Artist/ + // ├── AlbumA/ + // │ ├── 01 - Track.mp3 + // │ └── cover.png (real PNG — wins as tile 1 source) + // └── AlbumB/ + // ├── 01 - Track.mp3 + // └── cover.png (real PNG — wins as tile 2 source) + // Playlist "pl-7" references tracks from both albums, so the reader + // generates a 2x2 tiled cover from 2 distinct album art tiles (the + // tiled generator mirrors when it has fewer than 4 unique tiles). + It("generates a tiled cover from album art", func() { + conf.Server.CoverArtPriority = "cover.*" + setLayout(fstest.MapFS{ + "Artist/AlbumA/01 - Track.mp3": trackFile(1, "TA", map[string]any{"album": "AlbumA"}), + "Artist/AlbumA/cover.png": realPNG("albumA"), + "Artist/AlbumB/01 - Track.mp3": trackFile(1, "TB", map[string]any{"album": "AlbumB"}), + "Artist/AlbumB/cover.png": realPNG("albumB"), + }) + scan() + + // Pull the scanned mediafile IDs so we can attach them to the playlist. + mfs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(mfs).To(HaveLen(2)) + + pl := model.Playlist{ID: "pl-7", Name: "Mix", OwnerID: "admin-1"} + pl.AddMediaFilesByID([]string{mfs[0].ID, mfs[1].ID}) + Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) + + data := readArtwork(pl.CoverArtID()) + // The tiled cover is a PNG-encoded 600x600 image (tileSize const). + // Exact bytes vary (random album order), so assert format + non-trivial size. + Expect(data[:8]).To(Equal([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})) + Expect(len(data)).To(BeNumerically(">", 1000)) + }) + }) +}) + +func putPlaylist(pl model.Playlist) model.Playlist { + GinkgoHelper() + if pl.OwnerID == "" { + pl.OwnerID = "admin-1" + } + Expect(ds.Playlist(ctx).Put(&pl)).To(Succeed()) + return pl +} diff --git a/core/artwork/e2e/radio_test.go b/core/artwork/e2e/radio_test.go new file mode 100644 index 000000000..73ee5f377 --- /dev/null +++ b/core/artwork/e2e/radio_test.go @@ -0,0 +1,42 @@ +package artworke2e_test + +import ( + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Radio artwork resolution", func() { + BeforeEach(func() { + setupHarness() + }) + + When("a radio has an uploaded image", func() { + // / + // └── artwork/ + // └── radio/ + // └── rd-1_logo.jpg ← matched by UploadedImagePath() + It("returns the uploaded image bytes", func() { + writeUploadedImage(consts.EntityRadio, "rd-1_logo.jpg", imageBytes("radio-logo")) + + rd := model.Radio{ID: "rd-1", Name: "Test Radio", StreamUrl: "https://example.com/stream", UploadedImage: "rd-1_logo.jpg"} + Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) + + artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) + Expect(readArtwork(artID)).To(Equal(imageBytes("radio-logo"))) + }) + }) + + When("a radio has no uploaded image", func() { + // (no files on disk — reader has no sources to fall back to) + It("returns ErrUnavailable", func() { + rd := model.Radio{ID: "rd-2", Name: "Bare Radio", StreamUrl: "https://example.com/stream"} + Expect(ds.Radio(ctx).Put(&rd)).To(Succeed()) + + artID := model.NewArtworkID(model.KindRadioArtwork, rd.ID, nil) + _, err := readArtworkOrErr(artID) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go new file mode 100644 index 000000000..9ce0edb8b --- /dev/null +++ b/core/artwork/e2e/suite_test.go @@ -0,0 +1,106 @@ +package artworke2e_test + +import ( + "context" + "path/filepath" + "testing" + + _ "github.com/navidrome/navidrome/adapters/gotaglib" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/artwork" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/db" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" + "github.com/navidrome/navidrome/persistence" + "github.com/navidrome/navidrome/scanner" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestArtworkE2E(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Artwork E2E Suite") +} + +const fakeLibScheme = "artworkfake" +const fakeLibPath = fakeLibScheme + ":///music" + +var ( + ctx context.Context + ds *tests.MockDataStore + aw artwork.Artwork + fakeFS *storagetest.FakeFS +) + +// The DB file lives in a suite-level tempdir: the go-sqlite3 singleton keeps +// the file open for the whole suite, and Ginkgo's per-spec TempDir cleanup +// can't unlink a file with a live handle on Windows. A suite-level tempdir +// combined with an AfterSuite close avoids the lock conflict. +var suiteDBTempDir string + +var _ = BeforeSuite(func() { + suiteDBTempDir = GinkgoT().TempDir() +}) + +var _ = AfterSuite(func() { + db.Close(GinkgoT().Context()) +}) + +func setupHarness() { + DeferCleanup(configtest.SetupConfig()) + + tempDir := GinkgoT().TempDir() + // Reuse the suite-level DB path so the singleton connection keeps working + // across specs (see suiteDBTempDir comment). + conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL" + conf.Server.DataFolder = tempDir + conf.Server.MusicFolder = fakeLibPath + conf.Server.DevExternalScanner = false + conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call + conf.Server.EnableExternalServices = false + + db.Db().SetMaxOpenConns(1) + ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "admin-1", UserName: "admin", IsAdmin: true}) + db.Init(ctx) + DeferCleanup(func() { Expect(tests.ClearDB()).To(Succeed()) }) + + ds = &tests.MockDataStore{RealDS: persistence.New(db.Db())} + + adminUser := model.User{ID: "admin-1", UserName: "admin", Name: "Admin", IsAdmin: true, NewPassword: "password"} + Expect(ds.User(ctx).Put(&adminUser)).To(Succeed()) + + lib := model.Library{ID: 1, Name: "Music", Path: fakeLibPath} + Expect(ds.Library(ctx).Put(&lib)).To(Succeed()) + Expect(ds.User(ctx).SetUserLibraries(adminUser.ID, []int{lib.ID})).To(Succeed()) + + fakeFS = &storagetest.FakeFS{} + storagetest.Register(fakeLibScheme, fakeFS) + + aw = artwork.NewArtwork(ds, artwork.GetImageCache(), newNoopFFmpeg(), &noopProvider{}) +} + +func scan() { + GinkgoHelper() + s := scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), metrics.NewNoopInstance()) + _, err := s.ScanAll(ctx, true) + Expect(err).ToNot(HaveOccurred()) +} + +func firstAlbum() model.Album { + GinkgoHelper() + albums, err := ds.Album(ctx).GetAll(model.QueryOptions{}) + Expect(err).ToNot(HaveOccurred()) + Expect(albums).To(HaveLen(1), "expected exactly one album, got %d", len(albums)) + return albums[0] +} diff --git a/core/artwork/e2e/testdata/embedded_art.mp3 b/core/artwork/e2e/testdata/embedded_art.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..18cb906749e047afda734b62c1ffcdd89ca72ead GIT binary patch literal 64223 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 z zLa|e2*E8IY%8hb0d-W}^T}0X@swcrwwE)V!=4oR=SPotxM#HfcQI}{qxDN{Op+%ux z9m?7;aSqM2Z|R2L^`!&~XZT)ko-OfLG|xOOw2pI(Q&v^>nQ>XjKX%?-e^^UBsiAt4 zt|DMzGclng7VOvQ`>cc{_Fm$>_fYfEdS^lE?3wR4UeNZ{hj*s&oF#=H96M zntPksekSA~QG#PkAyg_5s#`A*2r2>9h$AIIs|aEks2&86BH;iw+M7Ou9YEf>V?^A0 zyB9Iv;%Ta-RqTf-=~r(Vz3j{$n;_jj%!s{<4W*0r>zQ2O{%RM=d~uMdS453HO6w(} zB6)W|Na-<)43W_B$PMpMaO-rEMmNoDr)wO}yt=`_@X*rb4fN?|%7lwuoASLe$#)`| z9M^3&e57BI_jOPBnZxiPPB9&LRM?>&p*1ja0C307f&fTyEQ%fhY6mnS{(3({(3fPt zl&J}OAHvU@an*QYO6Rn}1fv;rgPyKKh`MfV>rgQIrD{fRrRsT9F{iNLyQ=2o+I-0; zhY>x_$20R^12Sc}9~343iz^omK&E_E!Ut$QGlsc<0PrINf}aQ?gj<&pW9eVwMs)

9+GI1 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 literal 0 HcmV?d00001 diff --git a/core/artwork/library_fs.go b/core/artwork/library_fs.go new file mode 100644 index 000000000..ff557294e --- /dev/null +++ b/core/artwork/library_fs.go @@ -0,0 +1,44 @@ +package artwork + +import ( + "context" + "path/filepath" + + "github.com/navidrome/navidrome/core/storage" + "github.com/navidrome/navidrome/model" +) + +// libraryView bundles the MusicFS for a library with its absolute root path, +// so readers can open library-relative paths through FS and compose absolute +// paths (for ffmpeg, which is path-based) via Abs. +type libraryView struct { + FS storage.MusicFS + absRoot string +} + +// Abs returns the absolute path for a library-relative path. Returns "" for an +// empty rel so callers (fromFFmpegTag) can treat it as "no path available". +func (v libraryView) Abs(rel string) string { + if rel == "" { + return "" + } + return filepath.Join(v.absRoot, rel) +} + +// loadLibraryView resolves the MusicFS and absolute root path in a single +// library lookup. +func loadLibraryView(ctx context.Context, ds model.DataStore, libID int) (libraryView, error) { + lib, err := ds.Library(ctx).Get(libID) + if err != nil { + return libraryView{}, err + } + s, err := storage.For(lib.Path) + if err != nil { + return libraryView{}, err + } + fs, err := s.FS() + if err != nil { + return libraryView{}, err + } + return libraryView{FS: fs, absRoot: lib.Path}, nil +} diff --git a/core/artwork/library_fs_test.go b/core/artwork/library_fs_test.go new file mode 100644 index 000000000..acf08fda3 --- /dev/null +++ b/core/artwork/library_fs_test.go @@ -0,0 +1,45 @@ +package artwork + +import ( + "context" + + "github.com/navidrome/navidrome/core/storage/storagetest" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("loadLibraryView", Ordered, func() { + var ctx context.Context + var ds *tests.MockDataStore + + BeforeAll(func() { + storagetest.Register("fake", &storagetest.FakeFS{}) + }) + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds = &tests.MockDataStore{MockedLibrary: &tests.MockLibraryRepo{}} + }) + + It("returns a view for a library backed by registered storage", func() { + Expect(ds.Library(ctx).Put(&model.Library{ID: 1, Path: "fake:///music"})).To(Succeed()) + + lib, err := loadLibraryView(ctx, ds, 1) + Expect(err).ToNot(HaveOccurred()) + Expect(lib.FS).ToNot(BeNil()) + Expect(lib.absRoot).To(Equal("fake:///music")) + }) + + It("returns an error when the library does not exist", func() { + _, err := loadLibraryView(ctx, ds, 999) + Expect(err).To(HaveOccurred()) + }) + + It("returns an error when the library path uses an unregistered scheme", func() { + Expect(ds.Library(ctx).Put(&model.Library{ID: 2, Path: "unsupported:///music"})).To(Succeed()) + _, err := loadLibraryView(ctx, ds, 2) + Expect(err).To(HaveOccurred()) + }) +}) diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 35d489b6c..8d7e14fd0 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -7,14 +7,13 @@ import ( "errors" "fmt" "io" - "path/filepath" + "path" "slices" "strings" "time" "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/external" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" @@ -24,12 +23,12 @@ import ( type albumArtworkReader struct { cacheKey - a *artwork - provider external.Provider - album model.Album - updatedAt *time.Time - imgFiles []string - rootFolder string + a *artwork + provider external.Provider + album model.Album + updatedAt *time.Time + imgFiles []string // library-relative, forward-slash, no leading slash + lib libraryView } func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*albumArtworkReader, error) { @@ -41,13 +40,17 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar if err != nil { return nil, err } + lib, err := loadLibraryView(ctx, artwork.ds, al.LibraryID) + if err != nil { + return nil, err + } a := &albumArtworkReader{ - a: artwork, - provider: provider, - album: *al, - updatedAt: imagesUpdateAt, - imgFiles: imgFiles, - rootFolder: core.AbsolutePath(ctx, artwork.ds, al.LibraryID, ""), + a: artwork, + provider: provider, + album: *al, + updatedAt: imagesUpdateAt, + imgFiles: imgFiles, + lib: lib, } a.cacheKey.artID = artID if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) { @@ -86,12 +89,15 @@ func (a *albumArtworkReader) fromCoverArtPriority(ctx context.Context, ffmpeg ff pattern = strings.TrimSpace(pattern) switch { case pattern == "embedded": - embedArtPath := filepath.Join(a.rootFolder, a.album.EmbedArtPath) - ff = append(ff, fromTag(ctx, embedArtPath), fromFFmpegTag(ctx, ffmpeg, embedArtPath)) + embedRel := a.album.EmbedArtPath + ff = append(ff, + fromTag(ctx, a.lib.FS, embedRel), + fromFFmpegTag(ctx, ffmpeg, a.lib.Abs(embedRel)), + ) case pattern == "external": ff = append(ff, fromAlbumExternalSource(ctx, a.album, a.provider)) case len(a.imgFiles) > 0: - ff = append(ff, fromExternalFile(ctx, a.imgFiles, pattern)) + ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, pattern)) } } return ff @@ -132,13 +138,13 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo var imgFiles []string var updatedAt time.Time for _, f := range folders { - path := f.AbsolutePath() - paths = append(paths, path) + paths = append(paths, f.AbsolutePath()) if f.ImagesUpdatedAt.After(updatedAt) { updatedAt = f.ImagesUpdatedAt } + rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/") for _, img := range f.ImageFiles { - imgFiles = append(imgFiles, filepath.Join(path, img)) + imgFiles = append(imgFiles, path.Join(rel, img)) } } @@ -179,8 +185,8 @@ func compareImageFiles(a, b string) int { b = strings.ToLower(b) // Extract base filenames without extensions - baseA := strings.TrimSuffix(filepath.Base(a), filepath.Ext(a)) - baseB := strings.TrimSuffix(filepath.Base(b), filepath.Ext(b)) + baseA := strings.TrimSuffix(path.Base(a), path.Ext(a)) + baseB := strings.TrimSuffix(path.Base(b), path.Ext(b)) // Compare base names first, then full paths if equal return cmp.Or( diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index a8a0eae3e..03412b6d9 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -3,7 +3,6 @@ package artwork import ( "context" "errors" - "path/filepath" "time" "github.com/navidrome/navidrome/model" @@ -69,11 +68,11 @@ var _ = Describe("Album Artwork Reader", func() { // Files should be sorted by base filename without extension, then by full path // "back" < "cover", so back.jpg comes first // Then all cover.jpg files, sorted by path - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/back.jpg"))) - Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.jpg"))) - Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/cover.jpg"))) - Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc10/cover.jpg"))) - Expect(imgFiles[4]).To(Equal(filepath.FromSlash("Artist/Album/Disc1/cover.1.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/Disc1/back.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/Disc1/cover.jpg")) + Expect(imgFiles[2]).To(Equal("Artist/Album/Disc2/cover.jpg")) + Expect(imgFiles[3]).To(Equal("Artist/Album/Disc10/cover.jpg")) + Expect(imgFiles[4]).To(Equal("Artist/Album/Disc1/cover.1.jpg")) }) It("prioritizes files without numeric suffixes", func() { @@ -92,9 +91,9 @@ var _ = Describe("Album Artwork Reader", func() { Expect(imgFiles).To(HaveLen(3)) // cover.jpg should come first because "cover" < "cover.1" < "cover.2" - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) - Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.1.jpg"))) - Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/cover.2.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/cover.1.jpg")) + Expect(imgFiles[2]).To(Equal("Artist/Album/cover.2.jpg")) }) It("handles case-insensitive sorting", func() { @@ -113,9 +112,9 @@ var _ = Describe("Album Artwork Reader", func() { Expect(imgFiles).To(HaveLen(3)) // Files should be sorted case-insensitively: BACK, cover, Folder - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/BACK.jpg"))) - Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) - Expect(imgFiles[2]).To(Equal(filepath.FromSlash("Artist/Album/Folder.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/BACK.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg")) + Expect(imgFiles[2]).To(Equal("Artist/Album/Folder.jpg")) }) It("includes images from parent folder for multi-disc albums", func() { @@ -151,8 +150,8 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(*imagesUpdatedAt).To(Equal(expectedAt)) Expect(imgFiles).To(HaveLen(2)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/back.jpg"))) - Expect(imgFiles[1]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/back.jpg")) + Expect(imgFiles[1]).To(Equal("Artist/Album/cover.jpg")) }) It("does not query parent when parent ID is already in album folders", func() { @@ -179,7 +178,7 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) // Get should not have been called (parent already in folder set) Expect(repo.getCallCount).To(Equal(0)) }) @@ -209,7 +208,7 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist1/Album/part1/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist1/Album/part1/cover.jpg")) // Get should not have been called (different parents) Expect(repo.getCallCount).To(Equal(0)) }) @@ -232,7 +231,7 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) // Get should not have been called (single folder, no parent lookup) Expect(repo.getCallCount).To(Equal(0)) }) @@ -290,7 +289,7 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) - Expect(imgFiles[0]).To(Equal(filepath.FromSlash("Artist/Album/CD1/cover.jpg"))) + Expect(imgFiles[0]).To(Equal("Artist/Album/CD1/cover.jpg")) Expect(repo.getCallCount).To(Equal(1)) }) }) diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go index 96ba08b8f..37b7b6dee 100644 --- a/core/artwork/reader_artist.go +++ b/core/artwork/reader_artist.go @@ -7,6 +7,7 @@ import ( "io" "io/fs" "os" + "path" "path/filepath" "slices" "strings" @@ -35,6 +36,7 @@ type artistReader struct { artistFolder string imgFiles []string imgFolderImgPath string // cached path from ArtistImageFolder lookup + lib libraryView } func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID, provider external.Provider) (*artistReader, error) { @@ -60,12 +62,20 @@ func newArtistArtworkReader(ctx context.Context, artwork *artwork, artID model.A if err != nil { return nil, err } + var lib libraryView + if len(als) > 0 { + lib, err = loadLibraryView(ctx, artwork.ds, als[0].LibraryID) + if err != nil { + return nil, err + } + } a := &artistReader{ a: artwork, provider: provider, artist: *ar, artistFolder: artistFolder, imgFiles: imgFiles, + lib: lib, } // TODO Find a way to factor in the ExternalUpdateInfoAt in the cache key. Problem is that it can // change _after_ retrieving from external sources, making the key invalid @@ -124,38 +134,62 @@ func (a *artistReader) fromArtistArtPriority(ctx context.Context, priority strin case pattern == "image-folder": ff = append(ff, a.fromArtistImageFolder(ctx)) case strings.HasPrefix(pattern, "album/"): - ff = append(ff, fromExternalFile(ctx, a.imgFiles, strings.TrimPrefix(pattern, "album/"))) + if a.lib.FS != nil { + ff = append(ff, fromExternalFile(ctx, a.lib.FS, a.imgFiles, strings.TrimPrefix(pattern, "album/"))) + } default: - ff = append(ff, fromArtistFolder(ctx, a.artistFolder, pattern)) + ff = append(ff, fromArtistFolder(ctx, a.lib.FS, a.lib.absRoot, a.artistFolder, pattern)) } } return ff } -func fromArtistFolder(ctx context.Context, artistFolder string, pattern string) sourceFunc { +// fromArtistFolder walks up from artistFolder toward libPath looking for a +// file matching pattern. Traversal is bounded by both maxArtistFolderTraversalDepth +// and the library root: once we reach libPath (or if artistFolder is outside +// libPath), the walk stops. All reads go through libFS, which keeps artwork +// resolution scoped to the configured library. +func fromArtistFolder(ctx context.Context, libFS fs.FS, libPath, artistFolder, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { + if libFS == nil { + return nil, "", fmt.Errorf("artist folder lookup unavailable") + } + rel, err := filepath.Rel(libPath, artistFolder) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return nil, "", fmt.Errorf(`artist folder '%s' is outside library '%s'`, artistFolder, libPath) + } + // fs.Glob / path.Join below expect forward-slash paths; filepath.Rel may + // return backslash separators on Windows. + rel = filepath.ToSlash(rel) current := artistFolder for range maxArtistFolderTraversalDepth { - if reader, path, err := findImageInFolder(ctx, current, pattern); err == nil { - return reader, path, nil + reader, hit, err := findImageInFolder(ctx, libFS, rel, current, pattern) + if err == nil { + return reader, hit, nil } - - parent := filepath.Dir(current) - if parent == current { - break + if rel == "." { + break // reached library root; don't traverse above it } - current = parent + rel = path.Dir(rel) + current = filepath.Dir(current) } - return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories`, pattern, artistFolder) + return nil, "", fmt.Errorf(`no matches for '%s' in '%s' or its parent directories (within library)`, pattern, artistFolder) } } -func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadCloser, string, error) { - log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", folder) - fsys := os.DirFS(folder) - matches, err := fs.Glob(fsys, pattern) +// findImageInFolder globs libFS at relFolder for pattern and returns the first +// matching image. absFolder is used only for the returned display path and log +// messages so callers see absolute-looking paths consistent with the rest of +// the artwork pipeline. +func findImageInFolder(ctx context.Context, libFS fs.FS, relFolder, absFolder, pattern string) (io.ReadCloser, string, error) { + log.Trace(ctx, "looking for artist image", "pattern", pattern, "folder", absFolder) + globPattern := pattern + if relFolder != "." { + globPattern = path.Join(escapeGlobLiteral(relFolder), pattern) + } + matches, err := fs.Glob(libFS, globPattern) if err != nil { - log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", folder, err) + log.Warn(ctx, "Error matching artist image pattern", "pattern", pattern, "folder", absFolder, err) return nil, "", err } @@ -172,18 +206,30 @@ func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadClos // suffixes (e.g., artist.jpg before artist.1.jpg) slices.SortFunc(imagePaths, compareImageFiles) - // Try to open files in sorted order for _, p := range imagePaths { - filePath := filepath.Join(folder, p) - f, err := os.Open(filePath) + f, err := libFS.Open(p) if err != nil { - log.Warn(ctx, "Could not open cover art file", "file", filePath, err) + log.Warn(ctx, "Could not open cover art file", "file", p, err) continue } - return f, filePath, nil + _, name := path.Split(p) + return f, filepath.Join(absFolder, name), nil } - return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, folder) + return nil, "", fmt.Errorf(`no matches for '%s' in '%s'`, pattern, absFolder) +} + +func escapeGlobLiteral(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch r { + case '\\', '*', '?', '[', ']': + b.WriteByte('\\') + } + b.WriteRune(r) + } + return b.String() } func loadArtistFolder(ctx context.Context, ds model.DataStore, albums model.Albums, paths []string) (string, time.Time, error) { diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index 33dc6ed57..e2a1f2094 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "io" + "io/fs" "os" "path/filepath" "time" @@ -117,12 +118,14 @@ var _ = Describe("artistArtworkReader", func() { var ( ctx context.Context tempDir string + libFS fs.FS testFunc sourceFunc ) BeforeEach(func() { ctx = context.Background() tempDir = GinkgoT().TempDir() + libFS = os.DirFS(tempDir) }) When("artist folder contains matching image", func() { @@ -134,7 +137,7 @@ var _ = Describe("artistArtworkReader", func() { artistImagePath := filepath.Join(artistDir, "artist.jpg") Expect(os.WriteFile(artistImagePath, []byte("fake image data"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("finds and returns the image", func() { @@ -151,6 +154,30 @@ var _ = Describe("artistArtworkReader", func() { }) }) + When("artist folder name contains glob metacharacters", func() { + BeforeEach(func() { + artistDir := filepath.Join(tempDir, "Artist [Live]") + Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) + + artistImagePath := filepath.Join(artistDir, "artist.jpg") + Expect(os.WriteFile(artistImagePath, []byte("bracketed artist image"), 0600)).To(Succeed()) + + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") + }) + + It("treats the folder path literally when globbing through the library fs", func() { + reader, path, err := testFunc() + Expect(err).ToNot(HaveOccurred()) + Expect(reader).ToNot(BeNil()) + Expect(path).To(ContainSubstring("Artist [Live]" + string(filepath.Separator) + "artist.jpg")) + + data, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("bracketed artist image")) + reader.Close() + }) + }) + When("artist folder is empty but parent contains image", func() { BeforeEach(func() { // Create test structure: /temp/parent/artist.jpg and /temp/parent/artist/album/ @@ -163,7 +190,7 @@ var _ = Describe("artistArtworkReader", func() { artistImagePath := filepath.Join(parentDir, "artist.jpg") Expect(os.WriteFile(artistImagePath, []byte("parent image"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("finds image in parent directory", func() { @@ -191,7 +218,7 @@ var _ = Describe("artistArtworkReader", func() { artistImagePath := filepath.Join(grandparentDir, "artist.jpg") Expect(os.WriteFile(artistImagePath, []byte("grandparent image"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("finds image in grandparent directory", func() { @@ -220,7 +247,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(filepath.Join(parentDir, "artist.jpg"), []byte("parent level"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(grandparentDir, "artist.jpg"), []byte("grandparent level"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("prioritizes the closest (artist folder) image", func() { @@ -246,7 +273,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("returns the first valid image file in sorted order", func() { @@ -273,7 +300,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist main"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(artistDir, "artist.2.jpg"), []byte("artist 2"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() { @@ -301,7 +328,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("artist"), 0600)).To(Succeed()) Expect(os.WriteFile(filepath.Join(artistDir, "BACK.jpg"), []byte("back"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "*.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "*.*") }) It("sorts case-insensitively", func() { @@ -327,7 +354,7 @@ var _ = Describe("artistArtworkReader", func() { // Create non-matching files Expect(os.WriteFile(filepath.Join(artistDir, "cover.jpg"), []byte("cover image"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("returns an error", func() { @@ -346,7 +373,7 @@ var _ = Describe("artistArtworkReader", func() { artistDir := filepath.Join(tempDir, "artist") Expect(os.MkdirAll(artistDir, 0755)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("handles root boundary gracefully", func() { @@ -367,7 +394,7 @@ var _ = Describe("artistArtworkReader", func() { restrictedFile := filepath.Join(artistDir, "artist.jpg") Expect(os.WriteFile(restrictedFile, []byte("restricted"), 0600)).To(Succeed()) - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("logs warning and continues searching", func() { @@ -397,7 +424,7 @@ var _ = Describe("artistArtworkReader", func() { Expect(os.WriteFile(artistImagePath, []byte("single album artist image"), 0600)).To(Succeed()) // The fromArtistFolder is called with the artist folder path - testFunc = fromArtistFolder(ctx, artistDir, "artist.*") + testFunc = fromArtistFolder(ctx, libFS, tempDir, artistDir, "artist.*") }) It("finds artist.jpg in artist folder for single album artist", func() { diff --git a/core/artwork/reader_disc.go b/core/artwork/reader_disc.go index 30d4968e1..de0a765f0 100644 --- a/core/artwork/reader_disc.go +++ b/core/artwork/reader_disc.go @@ -5,7 +5,7 @@ import ( "crypto/md5" "fmt" "io" - "os" + "path" "path/filepath" "strconv" "strings" @@ -13,7 +13,6 @@ import ( "github.com/Masterminds/squirrel" "github.com/navidrome/navidrome/conf" - "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -24,10 +23,11 @@ type discArtworkReader struct { a *artwork album model.Album discNumber int - imgFiles []string - discFolders map[string]bool + imgFiles []string // library-relative, forward-slash, no leading slash + discFoldersRel map[string]bool // library-relative folder paths isMultiFolder bool - firstTrackPath string + firstTrackRel string // library-relative; for fromTag / ffmpeg via lib.Abs + lib libraryView updatedAt *time.Time } @@ -57,18 +57,23 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID return nil, err } - // Build disc folder set and find first track - discFolders := make(map[string]bool) - var firstTrackPath string + lib, err := loadLibraryView(ctx, a.ds, al.LibraryID) + if err != nil { + return nil, err + } + + // Build disc folder set and find first track. mf.Path is already library-relative. + var firstTrackRel string allFolderIDs := make(map[string]bool) for _, mf := range mfs { allFolderIDs[mf.FolderID] = true - if firstTrackPath == "" { - firstTrackPath = mf.Path + if firstTrackRel == "" { + firstTrackRel = filepath.ToSlash(mf.Path) } } - // Resolve folder IDs to absolute paths + // Resolve folder IDs to library-relative paths + discFoldersRel := make(map[string]bool) if len(allFolderIDs) > 0 { folderIDs := make([]string, 0, len(allFolderIDs)) for id := range allFolderIDs { @@ -81,7 +86,8 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID return nil, err } for _, f := range folders { - discFolders[f.AbsolutePath()] = true + rel := strings.TrimPrefix(path.Join(f.Path, f.Name), "/") + discFoldersRel[rel] = true } } @@ -92,9 +98,10 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID album: *al, discNumber: discNumber, imgFiles: imgFiles, - discFolders: discFolders, + discFoldersRel: discFoldersRel, isMultiFolder: isMultiFolder, - firstTrackPath: core.AbsolutePath(ctx, a.ds, al.LibraryID, firstTrackPath), + firstTrackRel: firstTrackRel, + lib: lib, updatedAt: imagesUpdatedAt, } r.cacheKey.artID = artID @@ -133,7 +140,10 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp pattern = strings.TrimSpace(pattern) switch { case pattern == "embedded": - ff = append(ff, fromTag(ctx, d.firstTrackPath), fromFFmpegTag(ctx, ffmpeg, d.firstTrackPath)) + ff = append(ff, + fromTag(ctx, d.lib.FS, d.firstTrackRel), + fromFFmpegTag(ctx, ffmpeg, d.lib.Abs(d.firstTrackRel)), + ) case pattern == "external": // Not supported for disc art, silently ignore case pattern == "discsubtitle": @@ -152,12 +162,12 @@ func (d *discArtworkReader) fromDiscArtPriority(ctx context.Context, ffmpeg ffmp func (d *discArtworkReader) fromDiscSubtitle(ctx context.Context, subtitle string) sourceFunc { return func() (io.ReadCloser, string, error) { for _, file := range d.imgFiles { - _, name := filepath.Split(file) - stem := strings.TrimSuffix(name, filepath.Ext(name)) + name := path.Base(file) + stem := strings.TrimSuffix(name, path.Ext(name)) if !strings.EqualFold(stem, subtitle) { continue } - f, err := os.Open(file) + f, err := d.lib.FS.Open(file) if err != nil { log.Warn(ctx, "Could not open disc art file", "file", file, err) continue @@ -214,8 +224,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string return func() (io.ReadCloser, string, error) { var fallbacks []string for _, file := range d.imgFiles { - _, name := filepath.Split(file) - name = strings.ToLower(name) + name := strings.ToLower(path.Base(file)) match, err := filepath.Match(pattern, name) if err != nil { log.Warn(ctx, "Error matching disc art file to pattern", "pattern", pattern, "file", file) @@ -230,7 +239,7 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string if num != d.discNumber { continue } - f, err := os.Open(file) + f, err := d.lib.FS.Open(file) if err != nil { log.Warn(ctx, "Could not open disc art file", "file", file, err) continue @@ -239,14 +248,14 @@ func (d *discArtworkReader) fromExternalFile(ctx context.Context, pattern string } } - if d.isMultiFolder && !d.discFolders[filepath.Dir(file)] { + if d.isMultiFolder && !d.discFoldersRel[path.Dir(file)] { continue } fallbacks = append(fallbacks, file) } for _, file := range fallbacks { - f, err := os.Open(file) + f, err := d.lib.FS.Open(file) if err != nil { log.Warn(ctx, "Could not open disc art file", "file", file, err) continue diff --git a/core/artwork/reader_disc_test.go b/core/artwork/reader_disc_test.go index 7b633342f..8264ee27b 100644 --- a/core/artwork/reader_disc_test.go +++ b/core/artwork/reader_disc_test.go @@ -74,20 +74,27 @@ var _ = Describe("Disc Artwork Reader", func() { tmpDir = GinkgoT().TempDir() }) - createFile := func(path string) string { - fullPath := filepath.Join(tmpDir, filepath.FromSlash(path)) + // createFile creates the file on disk and returns its library-relative forward-slash path. + createFile := func(relPath string) string { + fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath)) Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed()) Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed()) - return fullPath + return relPath + } + + // removeFile removes a library-relative file from disk. + removeFile := func(relPath string) { + Expect(os.Remove(filepath.Join(tmpDir, filepath.FromSlash(relPath)))).To(Succeed()) } It("matches file with disc number in single-folder album", func() { f1 := createFile("album/disc1.jpg") f2 := createFile("album/disc2.jpg") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1, f2}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -101,9 +108,10 @@ var _ = Describe("Disc Artwork Reader", func() { It("matches file without number in single-folder album (shared disc art)", func() { f1 := createFile("album/cover.png") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "cover.*") @@ -118,9 +126,10 @@ var _ = Describe("Disc Artwork Reader", func() { f1 := createFile("album/shellac.png") makeReader := func(discNum int) *discArtworkReader { return &discArtworkReader{ - discNumber: discNum, - imgFiles: []string{f1}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: discNum, + imgFiles: []string{f1}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } } @@ -139,9 +148,10 @@ var _ = Describe("Disc Artwork Reader", func() { f2 := createFile("album/disc1.jpg") f3 := createFile("album/disc2.jpg") reader := &discArtworkReader{ - discNumber: 2, - imgFiles: []string{f1, f2, f3}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 2, + imgFiles: []string{f1, f2, f3}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -163,9 +173,10 @@ var _ = Describe("Disc Artwork Reader", func() { f1 := createFile("album/cover.png") f2 := createFile("album/disc1.jpg") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1, f2}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } ff := reader.fromDiscArtPriority(ctx, nil, "disc*.*, cover.*") @@ -191,9 +202,10 @@ var _ = Describe("Disc Artwork Reader", func() { createFile("album/disc2.jpg"), } reader := &discArtworkReader{ - discNumber: discNumber, - imgFiles: files, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: discNumber, + imgFiles: files, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -210,12 +222,13 @@ var _ = Describe("Disc Artwork Reader", func() { It("tries the next fallback candidate when the first one cannot be opened", func() { f1 := createFile("album/cover.jpg") f2 := createFile("album/cover.png") - // Remove f1 so os.Open will fail on it; f2 should still win. - Expect(os.Remove(f1)).To(Succeed()) + // Remove f1 so Open will fail on it; f2 should still win. + removeFile(f1) reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1, f2}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "cover.*") @@ -234,15 +247,16 @@ var _ = Describe("Disc Artwork Reader", func() { // that first file is unreadable. f1 := createFile("album/stale/cover.png") f2 := createFile("album/cover.png") - Expect(os.Remove(f1)).To(Succeed()) + removeFile(f1) reader := &discArtworkReader{ discNumber: 1, imgFiles: []string{f1, f2}, - discFolders: map[string]bool{ - filepath.Join(tmpDir, "album"): true, - filepath.Join(tmpDir, "album/stale"): true, + discFoldersRel: map[string]bool{ + "album": true, + "album/stale": true, }, isMultiFolder: true, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "cover.png") @@ -260,9 +274,10 @@ var _ = Describe("Disc Artwork Reader", func() { createFile("album/disc2.jpg"), } reader := &discArtworkReader{ - discNumber: discNumber, - imgFiles: files, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: discNumber, + imgFiles: files, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, pattern) @@ -282,10 +297,11 @@ var _ = Describe("Disc Artwork Reader", func() { f1 := createFile("album/cd1/disc.jpg") f2 := createFile("album/cd2/disc.jpg") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1, f2}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true}, - isMultiFolder: true, + discNumber: 1, + imgFiles: []string{f1, f2}, + discFoldersRel: map[string]bool{"album/cd1": true}, + isMultiFolder: true, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -300,10 +316,11 @@ var _ = Describe("Disc Artwork Reader", func() { // disc2.jpg in cd1 folder should match disc 2, not disc 1 f1 := createFile("album/cd1/disc2.jpg") reader := &discArtworkReader{ - discNumber: 2, - imgFiles: []string{f1}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album", "cd1"): true}, - isMultiFolder: true, + discNumber: 2, + imgFiles: []string{f1}, + discFoldersRel: map[string]bool{"album/cd1": true}, + isMultiFolder: true, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -317,9 +334,10 @@ var _ = Describe("Disc Artwork Reader", func() { It("does not match disc2.jpg when looking for disc 1", func() { f1 := createFile("album/disc2.jpg") reader := &discArtworkReader{ - discNumber: 1, - imgFiles: []string{f1}, - discFolders: map[string]bool{filepath.Join(tmpDir, "album"): true}, + discNumber: 1, + imgFiles: []string{f1}, + discFoldersRel: map[string]bool{"album": true}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromExternalFile(ctx, "disc*.*") @@ -339,11 +357,11 @@ var _ = Describe("Disc Artwork Reader", func() { tmpDir = GinkgoT().TempDir() }) - createFile := func(path string) string { - fullPath := filepath.Join(tmpDir, filepath.FromSlash(path)) + createFile := func(relPath string) string { + fullPath := filepath.Join(tmpDir, filepath.FromSlash(relPath)) Expect(os.MkdirAll(filepath.Dir(fullPath), 0755)).To(Succeed()) Expect(os.WriteFile(fullPath, []byte("image data"), 0600)).To(Succeed()) - return fullPath + return relPath } It("matches image file whose stem equals the disc subtitle (case-insensitive)", func() { @@ -351,6 +369,7 @@ var _ = Describe("Disc Artwork Reader", func() { reader := &discArtworkReader{ discNumber: 1, imgFiles: []string{f1}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") @@ -366,6 +385,7 @@ var _ = Describe("Disc Artwork Reader", func() { reader := &discArtworkReader{ discNumber: 2, imgFiles: []string{f1}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromDiscSubtitle(ctx, "Bonus Tracks") @@ -381,6 +401,7 @@ var _ = Describe("Disc Artwork Reader", func() { reader := &discArtworkReader{ discNumber: 1, imgFiles: []string{f1}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") @@ -394,6 +415,7 @@ var _ = Describe("Disc Artwork Reader", func() { reader := &discArtworkReader{ discNumber: 1, imgFiles: []string{f1, f2}, + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } sf := reader.fromDiscSubtitle(ctx, "The Blue Disc") @@ -407,19 +429,24 @@ var _ = Describe("Disc Artwork Reader", func() { Describe("discArtworkReader", func() { Describe("fromDiscArtPriority", func() { - var reader *discArtworkReader + var ( + reader *discArtworkReader + tmpDir string + ) BeforeEach(func() { + tmpDir = GinkgoT().TempDir() reader = &discArtworkReader{ - discNumber: 2, - isMultiFolder: true, - discFolders: map[string]bool{"/music/album/cd2": true}, + discNumber: 2, + isMultiFolder: true, + discFoldersRel: map[string]bool{"music/album/cd2": true}, imgFiles: []string{ - "/music/album/cd1/disc.jpg", - "/music/album/cd2/disc.jpg", - "/music/album/cd2/disc2.jpg", + "music/album/cd1/disc.jpg", + "music/album/cd2/disc.jpg", + "music/album/cd2/disc2.jpg", }, - firstTrackPath: "/music/album/cd2/track1.flac", + firstTrackRel: "music/album/cd2/track1.flac", + lib: libraryView{FS: osDirFS{os.DirFS(tmpDir)}, absRoot: tmpDir}, } }) diff --git a/core/artwork/reader_mediafile.go b/core/artwork/reader_mediafile.go index cf25c8f5d..eac3c5e70 100644 --- a/core/artwork/reader_mediafile.go +++ b/core/artwork/reader_mediafile.go @@ -15,6 +15,7 @@ type mediafileArtworkReader struct { a *artwork mediafile model.MediaFile album model.Album + lib libraryView } func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID model.ArtworkID) (*mediafileArtworkReader, error) { @@ -30,10 +31,15 @@ func newMediafileArtworkReader(ctx context.Context, artwork *artwork, artID mode if err != nil { return nil, err } + lib, err := loadLibraryView(ctx, artwork.ds, mf.LibraryID) + if err != nil { + return nil, err + } a := &mediafileArtworkReader{ a: artwork, mediafile: *mf, album: *al, + lib: lib, } a.cacheKey.artID = artID a.cacheKey.lastUpdate = mf.UpdatedAt @@ -60,10 +66,9 @@ func (a *mediafileArtworkReader) LastUpdated() time.Time { func (a *mediafileArtworkReader) Reader(ctx context.Context) (io.ReadCloser, string, error) { var ff []sourceFunc if a.mediafile.CoverArtID().Kind == model.KindMediaFileArtwork { - path := a.mediafile.AbsolutePath() ff = []sourceFunc{ - fromTag(ctx, path), - fromFFmpegTag(ctx, a.a.ffmpeg, path), + fromTag(ctx, a.lib.FS, a.mediafile.Path), + fromFFmpegTag(ctx, a.a.ffmpeg, a.lib.Abs(a.mediafile.Path)), } } // For multi-disc albums, fall back to disc artwork first; for single-disc albums, diff --git a/core/artwork/sources.go b/core/artwork/sources.go index d830593fc..04a9257fb 100644 --- a/core/artwork/sources.go +++ b/core/artwork/sources.go @@ -5,9 +5,9 @@ import ( "context" "fmt" "io" + "io/fs" "net/http" "net/url" - "os" "path/filepath" "reflect" "regexp" @@ -53,7 +53,7 @@ func (f sourceFunc) String() string { return name } -func fromExternalFile(ctx context.Context, files []string, pattern string) sourceFunc { +func fromExternalFile(ctx context.Context, libFS fs.FS, files []string, pattern string) sourceFunc { return func() (io.ReadCloser, string, error) { for _, file := range files { _, name := filepath.Split(file) @@ -65,12 +65,12 @@ func fromExternalFile(ctx context.Context, files []string, pattern string) sourc if !match { continue } - f, err := os.Open(file) + f, err := libFS.Open(file) if err != nil { log.Warn(ctx, "Could not open cover art file", "file", file, err) continue } - return f, file, err + return f, file, nil } return nil, "", fmt.Errorf("pattern '%s' not matched by files %v", pattern, files) } @@ -83,28 +83,43 @@ var picTypeRegexes = []*regexp.Regexp{ regexp.MustCompile(`(?i).*cover.*`), } -func fromTag(ctx context.Context, path string) sourceFunc { +func fromTag(ctx context.Context, libFS fs.FS, relPath string) sourceFunc { return func() (io.ReadCloser, string, error) { - if path == "" { + if relPath == "" { return nil, "", nil } - f, err := taglib.OpenReadOnly(path, taglib.WithReadStyle(taglib.ReadStyleFast)) + f, err := libFS.Open(relPath) if err != nil { return nil, "", err } + rs, ok := f.(io.ReadSeeker) + if !ok { + f.Close() + return nil, "", fmt.Errorf("FS file %s is not seekable; cannot read tags", relPath) + } + tf, err := taglib.OpenStream(rs, + taglib.WithReadStyle(taglib.ReadStyleFast), + taglib.WithFilename(relPath), + ) + if err != nil { + f.Close() + return nil, "", err + } + // Close in LIFO order: tf first (it holds rs internally), then f. defer f.Close() + defer tf.Close() - images := f.Properties().Images + images := tf.Properties().Images if len(images) == 0 { - return nil, "", fmt.Errorf("no embedded image found in %s", path) + return nil, "", fmt.Errorf("no embedded image found in %s", relPath) } - imageIndex := findBestImageIndex(ctx, images, path) - data, err := f.Image(imageIndex) + imageIndex := findBestImageIndex(ctx, images, relPath) + data, err := tf.Image(imageIndex) if err != nil || len(data) == 0 { - return nil, "", fmt.Errorf("could not load embedded image from %s", path) + return nil, "", fmt.Errorf("could not load embedded image from %s", relPath) } - return io.NopCloser(bytes.NewReader(data)), path, nil + return io.NopCloser(bytes.NewReader(data)), relPath, nil } } @@ -121,6 +136,13 @@ func findBestImageIndex(ctx context.Context, images []taglib.ImageDesc, path str return 0 } +// fromFFmpegTag is intentionally absolute-path-based. ffmpeg is a subprocess +// and cannot read from arbitrary fs.FS implementations; piping via stdin is a +// non-trivial refactor with stream/seek implications. +// +// TODO(artwork-musicfs): when the storage backing the library is not local +// (e.g. a future S3 backend, or FakeFS in tests), short-circuit this source +// func to return (nil, "", nil) so callers fall through cleanly. func fromFFmpegTag(ctx context.Context, ffmpeg ffmpeg.FFmpeg, path string) sourceFunc { return func() (io.ReadCloser, string, error) { if path == "" { diff --git a/core/artwork/sources_internal_test.go b/core/artwork/sources_internal_test.go new file mode 100644 index 000000000..4282575a5 --- /dev/null +++ b/core/artwork/sources_internal_test.go @@ -0,0 +1,92 @@ +package artwork + +import ( + "bytes" + "errors" + "io" + "io/fs" + "os" + "testing/fstest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("fromExternalFile", func() { + It("opens a matching file via the library FS", func() { + fsys := fstest.MapFS{ + "Artist/Album/cover.jpg": &fstest.MapFile{Data: []byte("cover-bytes")}, + } + f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/cover.jpg"}, "cover.*") + r, path, err := f() + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + b, _ := io.ReadAll(r) + Expect(b).To(Equal([]byte("cover-bytes"))) + Expect(path).To(Equal("Artist/Album/cover.jpg")) + }) + + It("returns an error when no file matches", func() { + fsys := fstest.MapFS{ + "Artist/Album/something.txt": &fstest.MapFile{Data: []byte("x")}, + } + f := fromExternalFile(GinkgoT().Context(), fsys, []string{"Artist/Album/something.txt"}, "cover.*") + _, _, err := f() + Expect(err).To(HaveOccurred()) + }) + + It("skips files that fail to open and tries the next match", func() { + fsys := fstest.MapFS{ + "a/cover.jpg": &fstest.MapFile{Data: []byte("a")}, + } + // "missing/cover.jpg" is in candidates but not in the FS — should be skipped. + f := fromExternalFile(GinkgoT().Context(), fsys, []string{"missing/cover.jpg", "a/cover.jpg"}, "cover.*") + r, path, err := f() + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + b, _ := io.ReadAll(r) + Expect(b).To(Equal([]byte("a"))) + Expect(path).To(Equal("a/cover.jpg")) + }) +}) + +var _ = Describe("fromTag", func() { + It("opens an embedded image via fs.FS", func() { + fsys := os.DirFS("tests/fixtures/artist/an-album") + f := fromTag(GinkgoT().Context(), fsys, "test.mp3") + r, path, err := f() + Expect(err).ToNot(HaveOccurred()) + defer r.Close() + Expect(path).To(Equal("test.mp3")) + b, _ := io.ReadAll(r) + Expect(b).ToNot(BeEmpty()) + }) + + It("returns nil reader when the relative path is empty", func() { + f := fromTag(GinkgoT().Context(), os.DirFS("."), "") + r, _, err := f() + Expect(err).ToNot(HaveOccurred()) + Expect(r).To(BeNil()) + }) + + It("errors when the FS file is not seekable", func() { + fsys := nonSeekableFS{data: []byte("garbage")} + f := fromTag(GinkgoT().Context(), fsys, "x.mp3") + _, _, err := f() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not seekable")) + }) +}) + +// nonSeekableFS is a single-file fs.FS whose Open returns a non-seekable file. +type nonSeekableFS struct{ data []byte } + +func (n nonSeekableFS) Open(name string) (fs.File, error) { + return &nonSeekableFile{r: bytes.NewReader(n.data)}, nil +} + +type nonSeekableFile struct{ r *bytes.Reader } + +func (n *nonSeekableFile) Read(p []byte) (int, error) { return n.r.Read(p) } +func (n *nonSeekableFile) Close() error { return nil } +func (n *nonSeekableFile) Stat() (fs.FileInfo, error) { return nil, errors.New("not implemented") } diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index abeda5c9e..80790c8d6 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" @@ -57,6 +58,11 @@ func New() FFmpeg { return &ffmpeg{} } +// ErrAnimatedWebPUnsupported is returned by ConvertAnimatedImage when the +// ffmpeg binary lacks the libwebp_anim encoder. Callers can use errors.Is to +// detect this specific case and fall back to static resize. +var ErrAnimatedWebPUnsupported = errors.New("ffmpeg lacks libwebp_anim encoder — install an ffmpeg build with libwebp") + const ( extractImageCmd = "ffmpeg -i %s -map 0:v -map -0:V -vcodec copy -f image2pipe -" probeCmd = "ffmpeg %s -f ffmetadata" @@ -86,6 +92,9 @@ func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, max if err != nil { return nil, err } + if !animWebP.has(cmdPath, "libwebp_anim") { + return nil, ErrAnimatedWebPUnsupported + } args := []string{cmdPath, "-i", "pipe:0"} if maxSize > 0 { @@ -98,6 +107,19 @@ func (e *ffmpeg) ConvertAnimatedImage(ctx context.Context, reader io.Reader, max return e.start(ctx, args, reader) } +// parseEncodersOutput scans the stdout of `ffmpeg -encoders` for a whole-word +// match of encoder name. The output has rows like " V....D libwebp_anim ..." +// where the name is the 2nd whitespace-separated field. +func parseEncodersOutput(out []byte, name string) bool { + for line := range strings.SplitSeq(string(out), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 && fields[1] == name { + return true + } + } + return false +} + func (e *ffmpeg) ExtractImage(ctx context.Context, path string) (io.ReadCloser, error) { if _, err := ffmpegCmd(); err != nil { return nil, err @@ -538,6 +560,49 @@ func ffmpegCmd() (string, error) { return ffmpegPath, ffmpegErr } +type encoderProbeState uint8 + +const ( + encoderProbeUnknown encoderProbeState = iota + encoderProbeAvailable + encoderProbeUnavailable +) + +type encoderProbe struct { + mu sync.Mutex + state encoderProbeState +} + +func (p *encoderProbe) has(cmdPath, encoder string) bool { + p.mu.Lock() + defer p.mu.Unlock() + + switch p.state { + case encoderProbeAvailable: + return true + case encoderProbeUnavailable: + return false + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, cmdPath, "-hide_banner", "-encoders").Output() // #nosec + if err != nil { + log.Warn(ctx, "Could not probe ffmpeg encoders; will retry on next animated cover", err) + return false + } + + if parseEncodersOutput(out, encoder) { + p.state = encoderProbeAvailable + return true + } + + p.state = encoderProbeUnavailable + log.Warn(ctx, "ffmpeg has no libwebp_anim encoder; animated covers will be served as static images", + "path", cmdPath, "hint", "install ffmpeg built with libwebp (e.g. `brew install ffmpeg@7`)") + return false +} + // These variables are accessible here for tests. Do not use them directly in production code. Use ffmpegCmd() instead. var ( ffOnce sync.Once @@ -545,4 +610,5 @@ var ( ffmpegErr error probeOnce sync.Once probeAvail bool + animWebP encoderProbe ) diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 01b284172..1649015d9 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -3,8 +3,10 @@ package ffmpeg import ( "context" "os" + "os/exec" "path/filepath" "runtime" + "strings" sync "sync" "testing" "time" @@ -693,4 +695,57 @@ var _ = Describe("ffmpeg", func() { }) }) }) + + Describe("parseEncodersOutput", func() { + const sample = `Encoders: + V..... = Video + ------ + V....D apng APNG (Animated Portable Network Graphics) image + V....D libwebp_anim libwebp WebP image (codec webp) + V....D libwebp libwebp WebP image (codec webp) + A....D aac AAC (Advanced Audio Coding) +` + It("returns true when the encoder is present", func() { + Expect(parseEncodersOutput([]byte(sample), "libwebp_anim")).To(BeTrue()) + Expect(parseEncodersOutput([]byte(sample), "libwebp")).To(BeTrue()) + Expect(parseEncodersOutput([]byte(sample), "aac")).To(BeTrue()) + }) + It("returns false when the encoder is absent", func() { + Expect(parseEncodersOutput([]byte(sample), "libwebp_missing")).To(BeFalse()) + Expect(parseEncodersOutput([]byte(sample), "")).To(BeFalse()) + }) + It("does not match partial names", func() { + // libwebp is a prefix of libwebp_anim; the parser must treat names as whole-word. + stripped := `Encoders: + V....D libwebp libwebp WebP image (codec webp) +` + Expect(parseEncodersOutput([]byte(stripped), "libwebp_anim")).To(BeFalse()) + }) + It("handles empty output", func() { + Expect(parseEncodersOutput(nil, "libwebp_anim")).To(BeFalse()) + Expect(parseEncodersOutput([]byte(""), "libwebp_anim")).To(BeFalse()) + }) + }) + + Describe("ConvertAnimatedImage", func() { + // Point ffmpegCmd at a stand-in binary that produces empty `-encoders` + // output so hasAnimatedWebPEncoder returns false. /usr/bin/true is + // portable across POSIX systems. + It("returns ErrAnimatedWebPUnsupported when the binary lacks libwebp_anim", func() { + truePath, err := exec.LookPath("true") + if err != nil { + Skip("true(1) not available") + } + origPath, origErr := ffmpegPath, ffmpegErr + ffmpegPath = truePath + ffmpegErr = nil + defer func() { + ffmpegPath, ffmpegErr = origPath, origErr + }() + + ff := &ffmpeg{} + _, err = ff.ConvertAnimatedImage(GinkgoT().Context(), strings.NewReader("x"), 100, 75) + Expect(err).To(MatchError(ErrAnimatedWebPUnsupported)) + }) + }) }) From 17adf63fca7b69e42e8e44374a4cf8c9028b1e6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 27 Apr 2026 12:20:27 -0400 Subject: [PATCH 037/117] fix(playlists): allow toggling auto-import and avoid unnecessary artwork reloads (#5421) * fix(playlists): allow toggling auto-import (sync) via REST API The updatePlaylistEntity handler was not applying the sync field from incoming requests, causing the auto-import toggle in the UI to have no effect. Apply the sync value for file-backed playlists only. * fix(playlists): enhance update logic for playlist metadata and sync toggle Signed-off-by: Deluan * fix(playlists): address code review feedback - Add pointer equality short-circuit in rulesEqual before reflect.DeepEqual - Guard against empty ID in Put's partial-update path - Only apply Sync when it actually differs from current value, preventing zero-value overwrites from partial payloads * fix(playlists): remove unused parameters from Update method Signed-off-by: Deluan --------- Signed-off-by: Deluan --- core/playlists/rest_adapter.go | 54 ++++++++++++++++++---- core/playlists/rest_adapter_test.go | 70 +++++++++++++++++++++++++++++ model/playlist.go | 2 +- persistence/playlist_repository.go | 9 +++- tests/mock_playlist_repo.go | 2 +- 5 files changed, 125 insertions(+), 12 deletions(-) diff --git a/core/playlists/rest_adapter.go b/core/playlists/rest_adapter.go index 3fecda0d5..c9b7c4ea6 100644 --- a/core/playlists/rest_adapter.go +++ b/core/playlists/rest_adapter.go @@ -3,9 +3,11 @@ package playlists import ( "context" "errors" + "reflect" "github.com/deluan/rest" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/criteria" "github.com/navidrome/navidrome/model/request" ) @@ -32,8 +34,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, cols ...string) error { - return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist), cols...) +func (r *playlistRepositoryWrapper) Update(id string, entity any, _ ...string) error { + return r.service.updatePlaylistEntity(r.ctx, id, entity.(*model.Playlist)) } func (r *playlistRepositoryWrapper) Delete(id string) error { @@ -77,7 +79,7 @@ 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, cols ...string) error { +func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity *model.Playlist) error { current, err := s.checkWritable(ctx, id) if err != nil { switch { @@ -93,11 +95,45 @@ func (s *playlists) updatePlaylistEntity(ctx context.Context, id string, entity if !usr.IsAdmin && entity.OwnerID != "" && entity.OwnerID != current.OwnerID { return rest.ErrPermissionDenied } - // Apply ownership change (admin only) - if entity.OwnerID != "" { - current.OwnerID = entity.OwnerID + + contentChanged := entity.Name != current.Name || + entity.Comment != current.Comment || + (entity.OwnerID != "" && entity.OwnerID != current.OwnerID) || + !rulesEqual(current.Rules, entity.Rules) + + if contentChanged { + if entity.OwnerID != "" { + current.OwnerID = entity.OwnerID + } + 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) } - // Apply smart playlist rules update - current.Rules = entity.Rules - 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 { + current.Sync = entity.Sync + cols = append(cols, "sync") + } + if current.Public != entity.Public { + current.Public = entity.Public + cols = append(cols, "public") + } + if len(cols) == 0 { + return nil + } + return s.ds.Playlist(ctx).Put(current, cols...) +} + +func rulesEqual(a, b *criteria.Criteria) bool { + if a == b { + return true + } + if a == nil || b == nil { + return false + } + return reflect.DeepEqual(a, b) } diff --git a/core/playlists/rest_adapter_test.go b/core/playlists/rest_adapter_test.go index 097bc6310..90d22327a 100644 --- a/core/playlists/rest_adapter_test.go +++ b/core/playlists/rest_adapter_test.go @@ -142,6 +142,76 @@ var _ = Describe("REST Adapter", func() { Expect(mockPlsRepo.Last.Rules).To(Equal(newRules)) }) + It("allows toggling sync for file-backed playlists", func() { + originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + mockPlsRepo.Data["file-pls"] = &model.Playlist{ + ID: "file-pls", + Name: "File Playlist", + OwnerID: "user-1", + Path: "/music/playlist.m3u", + Sync: true, + UpdatedAt: originalTime, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "File Playlist", Sync: false} + err := repo.Update("file-pls", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Sync).To(BeFalse()) + Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime)) + }) + + It("does not allow setting sync on non-file-backed playlists", func() { + mockPlsRepo.Data["manual-pls"] = &model.Playlist{ + ID: "manual-pls", + Name: "Manual Playlist", + OwnerID: "user-1", + Path: "", + Sync: false, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "Manual Playlist", Sync: true} + err := repo.Update("manual-pls", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last).To(BeNil()) + }) + + It("does not bump updatedAt when only public changes", func() { + originalTime := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + mockPlsRepo.Data["pls-pub"] = &model.Playlist{ + ID: "pls-pub", + Name: "My Playlist", + OwnerID: "user-1", + Public: false, + UpdatedAt: originalTime, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "My Playlist", Public: true} + err := repo.Update("pls-pub", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Public).To(BeTrue()) + Expect(mockPlsRepo.Last.UpdatedAt).To(Equal(originalTime)) + }) + + It("bumps updatedAt when name changes along with sync", func() { + mockPlsRepo.Data["file-pls2"] = &model.Playlist{ + ID: "file-pls2", + Name: "Old Name", + OwnerID: "user-1", + Path: "/music/playlist.m3u", + Sync: true, + } + ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) + repo = ps.NewRepository(ctx).(rest.Persistable) + pls := &model.Playlist{Name: "New Name", Sync: false} + err := repo.Update("file-pls2", pls) + Expect(err).ToNot(HaveOccurred()) + Expect(mockPlsRepo.Last.Name).To(Equal("New Name")) + Expect(mockPlsRepo.Last.Sync).To(BeFalse()) + }) + It("returns rest.ErrNotFound when playlist doesn't exist", func() { ctx = request.WithUser(ctx, model.User{ID: "user-1", IsAdmin: false}) repo = ps.NewRepository(ctx).(rest.Persistable) diff --git a/model/playlist.go b/model/playlist.go index e2f93993d..dc549f039 100644 --- a/model/playlist.go +++ b/model/playlist.go @@ -123,7 +123,7 @@ type PlaylistRepository interface { ResourceRepository CountAll(options ...QueryOptions) (int64, error) Exists(id string) (bool, error) - Put(pls *Playlist) error + Put(pls *Playlist, cols ...string) error Get(id string) (*Playlist, error) GetWithTracks(id string, refreshSmartPlaylist, includeMissing bool) (*Playlist, error) GetAll(options ...QueryOptions) (Playlists, error) diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go index 9bbc41c5c..4152505d2 100644 --- a/persistence/playlist_repository.go +++ b/persistence/playlist_repository.go @@ -97,8 +97,15 @@ func (r *playlistRepository) Delete(id string) error { return r.delete(And{Eq{"id": id}, r.userFilter()}) } -func (r *playlistRepository) Put(p *model.Playlist) error { +func (r *playlistRepository) Put(p *model.Playlist, cols ...string) error { pls := dbPlaylist{Playlist: *p} + if len(cols) > 0 { + if pls.ID == "" { + return errors.New("playlist id is required for partial update") + } + _, err := r.put(pls.ID, pls, cols...) + return err + } if pls.ID == "" { pls.CreatedAt = time.Now() } diff --git a/tests/mock_playlist_repo.go b/tests/mock_playlist_repo.go index 9bdc52152..9b38ea5b5 100644 --- a/tests/mock_playlist_repo.go +++ b/tests/mock_playlist_repo.go @@ -45,7 +45,7 @@ func (m *MockPlaylistRepo) GetWithTracks(id string, _, _ bool) (*model.Playlist, return m.Get(id) } -func (m *MockPlaylistRepo) Put(pls *model.Playlist) error { +func (m *MockPlaylistRepo) Put(pls *model.Playlist, _ ...string) error { if m.Err { return errors.New("error") } From 0ad2f020bd0b11243e67c8447bac02d9f557d37d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 27 Apr 2026 17:50:09 -0400 Subject: [PATCH 038/117] feat(subsonic): add sonicSimilarity extension as plugin capability (#5419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(plugins): add sonicSimilarity capability types Defines the SonicSimilarity plugin capability interface with GetSonicSimilarTracks and FindSonicPath methods, along with their request/response types. * feat(sonic): add core sonic similarity service Implements the Sonic service with HasProvider, GetSonicSimilarTracks, and FindSonicPath, delegating to the PluginLoader and using the Matcher for index-preserving library resolution. * test(sonic): add sonic service unit tests Covers HasProvider, GetSonicSimilarTracks, and FindSonicPath with mock plugin loader and provider, verifying error propagation and successful match resolution via the library matcher. * feat(matcher): add MatchSongsToLibraryMap for index-preserving matching Adds a new method alongside MatchSongsToLibrary that returns a map[int]MediaFile keyed by input song index rather than a flat slice, enabling callers to correlate similarity scores back to the original position in the results. * fix(sonic): check provider availability before MediaFile lookup Avoids unnecessary DB call when no plugin is available, and ensures the correct error path is tested. * feat(plugins): add sonic similarity adapter Adds SonicSimilarityAdapter implementing sonic.Provider, bridging the plugin system to the core sonic service via Extism plugin functions. Reuses existing songRefsToAgentSongs helper for SongRef conversion. * feat(plugins): add LoadSonicSimilarity to plugin manager Adds Manager.LoadSonicSimilarity method following the pattern of LoadLyricsProvider, enabling the core sonic service to load a SonicSimilarityAdapter from a named plugin. * feat(subsonic): add sonicMatch response type Add SonicMatch struct with Entry and Similarity fields, and a SonicMatches slice to the Subsonic response struct. These types support the OpenSubsonic sonicSimilarity extension for returning similarity-scored track results. * feat(subsonic): add getSonicSimilarTracks and findSonicPath handlers Add two new Subsonic API handlers for the sonicSimilarity OpenSubsonic extension: GetSonicSimilarTracks returns similarity-scored tracks similar to a given song, and FindSonicPath returns a path of tracks connecting two songs. Both handlers delegate to the sonic core service and map results to SonicMatch response types. * feat(subsonic): advertise sonicSimilarity extension when plugin available Update GetOpenSubsonicExtensions to conditionally include the sonicSimilarity extension only when a sonic similarity plugin provider is available. The nil guard ensures backward compatibility with tests that pass nil for the sonic field. Also update the existing test to pass the new nil parameter. * feat(subsonic): wire sonic similarity service into router Add the sonic.Sonic service to the Router struct and New() constructor, register the getSonicSimilarTracks and findSonicPath routes, and wire sonic.New and its PluginLoader binding into the Wire dependency injection graph. Update all existing test call sites to pass the new nil parameter. Regenerate wire_gen.go. * fix(e2e): add sonic parameter to subsonic.New call in e2e tests * test(subsonic): add sonicSimilarity extension advertisement tests Restructures the GetOpenSubsonicExtensions test into two contexts: one verifying the baseline 5 extensions are returned when no sonic similarity plugin is configured, and one verifying that the sonicSimilarity extension is advertised (making 6 total) when a plugin loader reports an available provider. Adds a mockSonicPluginLoader to satisfy the sonic.PluginLoader interface without requiring a real plugin. * feat(subsonic): add nil guard and e2e tests for sonic similarity endpoints Handlers return ErrorDataNotFound when no sonic service is configured, preventing nil panics. E2e tests verify both endpoints return proper error responses when no plugin is available. * fix(subsonic): return HTTP 404 when no sonic similarity plugin available Endpoints are always registered but return 404 when no provider is available, rather than a subsonic error code 70. * refactor: clean up sonic similarity code after review Extract shared helpers to reduce duplication across the sonic similarity implementation: loadAllMatches in matcher consolidates the 4-phase matching pipeline, songRefToAgentSong eliminates per-iteration slice allocation in the adapter, sonicMatchResponse deduplicates response building in handlers, and a package-level constant replaces raw capability name strings in core/sonic. * fix empty response shapes Signed-off-by: Deluan * test(plugins): add testdata plugin and e2e tests for sonic similarity Add a test-sonic-similarity WASM plugin that implements both GetSonicSimilarTracks and FindSonicPath via the generated sonicsimilarity PDK. The plugin returns deterministic test data with decreasing similarity scores and supports error injection via config. Adapter tests verify the full round-trip through the WASM plugin including error handling. Also includes regenerated PDK code from make gen. * docs: update README to include new capabilities and usage examples for plugins Signed-off-by: Deluan * test(e2e): enhance sonic similarity tests with additional scenarios and mock provider Signed-off-by: Deluan * fix: address PR review feedback for sonic similarity Fix incorrect field names in README documentation ({from, to} → {startSong, endSong}) and remove unnecessary XML serialization test from e2e suite since OpenSubsonic endpoints only use JSON. * refactor: rename Matcher methods for conciseness Rename MatchSongsToLibrary to MatchSongs and MatchSongsToLibraryMap to MatchSongsIndexed. The Matcher receiver already establishes the "to library" context, making that suffix redundant, and "Indexed" better describes the intent (preserving input ordering) than "Map" which describes the data structure. * refactor: standardize variable naming for media files in sonic path methods Signed-off-by: Deluan * refactor: simplify plugin loading by introducing adapter constructors Signed-off-by: Deluan --------- Signed-off-by: Deluan --- cmd/wire_gen.go | 6 +- cmd/wire_injectors.go | 3 + core/external/provider.go | 4 +- core/matcher/matcher.go | 69 +- core/matcher/matcher_test.go | 121 +++- core/sonic/sonic.go | 130 ++++ core/sonic/sonic_suite_test.go | 17 + core/sonic/sonic_test.go | 146 ++++ plugins/README.md | 651 +++++++++--------- plugins/capabilities/lyrics.yaml | 6 + plugins/capabilities/scrobbler.yaml | 6 + plugins/capabilities/sonic_similarity.go | 32 + plugins/capabilities/sonic_similarity.yaml | 92 +++ plugins/lyrics_adapter.go | 4 + plugins/manager.go | 64 +- plugins/metadata_agent.go | 36 +- plugins/pdk/go/lyrics/lyrics.go | 3 + plugins/pdk/go/lyrics/lyrics_stub.go | 3 + plugins/pdk/go/scrobbler/scrobbler.go | 3 + plugins/pdk/go/scrobbler/scrobbler_stub.go | 3 + .../pdk/go/sonicsimilarity/sonicsimilarity.go | 136 ++++ .../sonicsimilarity/sonicsimilarity_stub.go | 71 ++ .../pdk/rust/nd-pdk-capabilities/src/lib.rs | 1 + .../rust/nd-pdk-capabilities/src/lyrics.rs | 4 + .../rust/nd-pdk-capabilities/src/scrobbler.rs | 4 + .../src/sonicsimilarity.rs | 141 ++++ plugins/scrobbler_adapter.go | 14 + plugins/sonic_similarity_adapter.go | 92 +++ plugins/sonic_similarity_adapter_test.go | 110 +++ plugins/testdata/test-sonic-similarity/go.mod | 16 + plugins/testdata/test-sonic-similarity/go.sum | 14 + .../testdata/test-sonic-similarity/main.go | 71 ++ .../test-sonic-similarity/manifest.json | 7 + server/e2e/e2e_suite_test.go | 1 + server/e2e/subsonic_sonic_similarity_test.go | 272 ++++++++ server/subsonic/album_lists_test.go | 2 +- server/subsonic/api.go | 6 + server/subsonic/media_annotation_test.go | 2 +- server/subsonic/media_retrieval_test.go | 2 +- server/subsonic/opensubsonic.go | 8 +- server/subsonic/opensubsonic_test.go | 87 ++- server/subsonic/playlists_test.go | 4 +- ... SonicMatches with data should match .JSON | 25 + ...s SonicMatches with data should match .XML | 10 + ...nicMatches without data should match .JSON | 8 + ...onicMatches without data should match .XML | 1 + server/subsonic/responses/responses.go | 6 + server/subsonic/responses/responses_test.go | 29 + server/subsonic/searching_test.go | 2 +- server/subsonic/sonic_similarity.go | 69 ++ server/subsonic/transcode_test.go | 6 +- 51 files changed, 2159 insertions(+), 461 deletions(-) create mode 100644 core/sonic/sonic.go create mode 100644 core/sonic/sonic_suite_test.go create mode 100644 core/sonic/sonic_test.go create mode 100644 plugins/capabilities/sonic_similarity.go create mode 100644 plugins/capabilities/sonic_similarity.yaml create mode 100644 plugins/pdk/go/sonicsimilarity/sonicsimilarity.go create mode 100644 plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go create mode 100644 plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs create mode 100644 plugins/sonic_similarity_adapter.go create mode 100644 plugins/sonic_similarity_adapter_test.go create mode 100644 plugins/testdata/test-sonic-similarity/go.mod create mode 100644 plugins/testdata/test-sonic-similarity/go.sum create mode 100644 plugins/testdata/test-sonic-similarity/main.go create mode 100644 plugins/testdata/test-sonic-similarity/manifest.json create mode 100644 server/e2e/subsonic_sonic_similarity_test.go create mode 100644 server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML create mode 100644 server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML create mode 100644 server/subsonic/sonic_similarity.go diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index f66df2e75..0939eef4d 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -22,6 +22,7 @@ import ( "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" @@ -110,7 +111,8 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router { playbackServer := playback.GetInstance(dataStore) lyricsLyrics := lyrics.NewLyrics(manager) transcodeDecider := stream.NewTranscodeDecider(dataStore, fFmpeg) - router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider) + sonicSonic := sonic.New(dataStore, manager, matcherMatcher) + router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlistsPlaylists, playTracker, share, playbackServer, metricsMetrics, lyricsLyrics, transcodeDecider, sonicSonic) return router } @@ -219,7 +221,7 @@ func getPluginManager() *plugins.Manager { // wire_injectors.go: -var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) +var allProviders = wire.NewSet(core.Set, artwork.Set, server.New, subsonic.New, nativeapi.New, public.New, persistence.New, lastfm.NewRouter, listenbrainz.NewRouter, events.GetBroker, scanner.New, scanner.GetWatcher, metrics.GetPrometheusInstance, db.Db, plugins.GetManager, sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), wire.Bind(new(core.Watcher), new(scanner.Watcher))) func GetPluginManager(ctx context.Context) *plugins.Manager { manager := getPluginManager() diff --git a/cmd/wire_injectors.go b/cmd/wire_injectors.go index d87a8d6d3..bb5c5b5f3 100644 --- a/cmd/wire_injectors.go +++ b/cmd/wire_injectors.go @@ -15,6 +15,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/db" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/persistence" @@ -43,9 +44,11 @@ var allProviders = wire.NewSet( metrics.GetPrometheusInstance, db.Db, plugins.GetManager, + sonic.New, wire.Bind(new(agents.PluginLoader), new(*plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(*plugins.Manager)), wire.Bind(new(lyrics.PluginLoader), new(*plugins.Manager)), + wire.Bind(new(sonic.PluginLoader), new(*plugins.Manager)), wire.Bind(new(nativeapi.PluginManager), new(*plugins.Manager)), wire.Bind(new(core.PluginUnloader), new(*plugins.Manager)), wire.Bind(new(plugins.PluginMetricsRecorder), new(metrics.Metrics)), diff --git a/core/external/provider.go b/core/external/provider.go index 7e8aaba1c..4f3295cc7 100644 --- a/core/external/provider.go +++ b/core/external/provider.go @@ -302,7 +302,7 @@ func (e *provider) SimilarSongs(ctx context.Context, id string, count int) (mode } if err == nil && len(songs) > 0 { - return e.matcher.MatchSongsToLibrary(ctx, songs, count) + return e.matcher.MatchSongs(ctx, songs, count) } // Fallback to existing similar artists + top songs algorithm @@ -481,7 +481,7 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT } } - mfs, err := e.matcher.MatchSongsToLibrary(ctx, songs, count) + mfs, err := e.matcher.MatchSongs(ctx, songs, count) if err != nil { return nil, err } diff --git a/core/matcher/matcher.go b/core/matcher/matcher.go index cf6c99e28..54c2a368e 100644 --- a/core/matcher/matcher.go +++ b/core/matcher/matcher.go @@ -23,7 +23,7 @@ func New(ds model.DataStore) *Matcher { return &Matcher{ds: ds} } -// MatchSongsToLibrary matches agent song results to local library tracks using a multi-phase +// MatchSongs matches agent song results to local library tracks using a multi-phase // matching algorithm that prioritizes accuracy over recall. // // # Algorithm Overview @@ -107,25 +107,58 @@ func New(ds model.DataStore) *Matcher { // // Returns up to 'count' MediaFiles from the library that best match the input songs, // preserving the original order from the agent. Songs that cannot be matched are skipped. -func (m *Matcher) MatchSongsToLibrary(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { - idMatches, err := m.loadTracksByID(ctx, songs) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by ID: %w", err) - } - mbidMatches, err := m.loadTracksByMBID(ctx, songs, idMatches) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by MBID: %w", err) - } - isrcMatches, err := m.loadTracksByISRC(ctx, songs, idMatches, mbidMatches) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by ISRC: %w", err) - } - titleMatches, err := m.loadTracksByTitleAndArtist(ctx, songs, idMatches, mbidMatches, isrcMatches) - if err != nil { - return nil, fmt.Errorf("failed to load tracks by title: %w", err) +func (m *Matcher) MatchSongs(ctx context.Context, songs []agents.Song, count int) (model.MediaFiles, error) { + if len(songs) == 0 { + return nil, nil } - return m.selectBestMatchingSongs(songs, idMatches, mbidMatches, isrcMatches, titleMatches, count), nil + byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) + if err != nil { + return nil, err + } + return m.selectBestMatchingSongs(songs, byID, byMBID, byISRC, byTitle, count), nil +} + +// MatchSongsIndexed matches agent song results to local library tracks and returns a map +// from input song index to matched MediaFile. Songs that cannot be matched are omitted from the map. +// This preserves original indices, allowing callers to correlate results back to the input slice. +func (m *Matcher) MatchSongsIndexed(ctx context.Context, songs []agents.Song) (map[int]model.MediaFile, error) { + if len(songs) == 0 { + return nil, nil + } + + byID, byMBID, byISRC, byTitle, err := m.loadAllMatches(ctx, songs) + if err != nil { + return nil, err + } + + result := make(map[int]model.MediaFile, len(songs)) + for i, t := range songs { + if mf, found := findMatchingTrack(t, byID, byMBID, byISRC, byTitle); found { + result[i] = mf + } + } + return result, nil +} + +func (m *Matcher) loadAllMatches(ctx context.Context, songs []agents.Song) (byID, byMBID, byISRC, byTitle map[string]model.MediaFile, err error) { + byID, err = m.loadTracksByID(ctx, songs) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ID: %w", err) + } + byMBID, err = m.loadTracksByMBID(ctx, songs, byID) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by MBID: %w", err) + } + byISRC, err = m.loadTracksByISRC(ctx, songs, byID, byMBID) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by ISRC: %w", err) + } + byTitle, err = m.loadTracksByTitleAndArtist(ctx, songs, byID, byMBID, byISRC) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("failed to load tracks by title: %w", err) + } + return byID, byMBID, byISRC, byTitle, nil } // songMatchedIn checks if a song has already been matched in any of the provided match maps. diff --git a/core/matcher/matcher_test.go b/core/matcher/matcher_test.go index 8996cf71d..42e3ec88d 100644 --- a/core/matcher/matcher_test.go +++ b/core/matcher/matcher_test.go @@ -75,7 +75,7 @@ var _ = Describe("Matcher", func() { Return(artistTracks, nil).Maybe() } - Describe("MatchSongsToLibrary", func() { + Describe("MatchSongs", func() { Context("matching by direct ID", func() { It("matches songs with an ID field to MediaFiles by ID", func() { conf.Server.Matcher.FuzzyThreshold = 100 @@ -87,7 +87,7 @@ var _ = Describe("Matcher", func() { } expectIDPhase(model.MediaFiles{idMatch}) allowOtherPhases() - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-1")) @@ -106,7 +106,7 @@ var _ = Describe("Matcher", func() { } expectMBIDPhase(model.MediaFiles{mbidMatch}) allowOtherPhases() - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-mbid")) @@ -125,7 +125,7 @@ var _ = Describe("Matcher", func() { } expectISRCPhase(model.MediaFiles{isrcMatch}) allowOtherPhases() - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-isrc")) @@ -142,7 +142,7 @@ var _ = Describe("Matcher", func() { ID: "track-title", Title: "Enjoy the Silence", Artist: "Depeche Mode", } setupTitleOnlyExpectations(model.MediaFiles{titleMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-title")) @@ -157,7 +157,7 @@ var _ = Describe("Matcher", func() { ID: "track-fuzzy", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", } setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-fuzzy")) @@ -172,7 +172,7 @@ var _ = Describe("Matcher", func() { {ID: "different", Title: "Tomorrow Never Knows", Artist: "The Beatles"}, } setupTitleOnlyExpectations(differentTracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) }) @@ -189,7 +189,7 @@ var _ = Describe("Matcher", func() { ID: "br-live", Title: "Bohemian Rhapsody (Live)", Artist: "Queen", } setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("br-live")) @@ -205,7 +205,7 @@ var _ = Describe("Matcher", func() { ID: "br", Title: "Bohemian Rhapsody", Artist: "Queen", Album: "A Night at the Opera", } setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) Expect(result[0].ID).To(Equal("br")) @@ -227,7 +227,7 @@ var _ = Describe("Matcher", func() { } expectIDPhase(model.MediaFiles{idMatch}) allowOtherPhases() - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) Expect(result[0].ID).To(Equal("track-id")) @@ -248,7 +248,7 @@ var _ = Describe("Matcher", func() { {ID: "c", Title: "Song C", Artist: "Artist"}, } setupTitleOnlyExpectations(tracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 2) + result, err := m.MatchSongs(ctx, songs, 2) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) }) @@ -256,13 +256,60 @@ var _ = Describe("Matcher", func() { Context("empty input", func() { It("returns empty results for no songs", func() { - result, err := m.MatchSongsToLibrary(ctx, []agents.Song{}, 5) + result, err := m.MatchSongs(ctx, []agents.Song{}, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) }) }) }) + Describe("MatchSongsIndexed", func() { + It("returns index-keyed map of matched songs", func() { + songs := []agents.Song{ + {ID: "track-1", Name: "Song One", Artist: "Artist A"}, + {ID: "track-2", Name: "Song Two", Artist: "Artist B"}, + {ID: "track-3", Name: "Song Three", Artist: "Artist C"}, + } + mf1 := model.MediaFile{ID: "track-1", Title: "Song One", Artist: "Artist A"} + mf2 := model.MediaFile{ID: "track-2", Title: "Song Two", Artist: "Artist B"} + + expectIDPhase(model.MediaFiles{mf1, mf2}) + allowOtherPhases() + + result, err := m.MatchSongsIndexed(ctx, songs) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(2)) + Expect(result[0].ID).To(Equal("track-1")) + Expect(result[1].ID).To(Equal("track-2")) + _, exists := result[2] + Expect(exists).To(BeFalse()) + }) + + It("preserves original indices when some songs don't match", func() { + songs := []agents.Song{ + {Name: "Unknown Song", Artist: "Unknown Artist"}, + {ID: "track-1", Name: "Known Song", Artist: "Known Artist"}, + } + mf1 := model.MediaFile{ID: "track-1", Title: "Known Song", Artist: "Known Artist"} + + expectIDPhase(model.MediaFiles{mf1}) + allowOtherPhases() + + result, err := m.MatchSongsIndexed(ctx, songs) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(HaveLen(1)) + _, exists := result[0] + Expect(exists).To(BeFalse()) + Expect(result[1].ID).To(Equal("track-1")) + }) + + It("returns empty map for empty input", func() { + result, err := m.MatchSongsIndexed(ctx, nil) + Expect(err).ToNot(HaveOccurred()) + Expect(result).To(BeEmpty()) + }) + }) + Describe("specificity level matching", func() { BeforeEach(func() { conf.Server.Matcher.FuzzyThreshold = 100 @@ -283,7 +330,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -303,7 +350,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -323,7 +370,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -337,7 +384,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) @@ -356,7 +403,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{cover1, cover2, cover3}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(3)) @@ -384,7 +431,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{lessAccurateMatch, preciseMatch, artistTwoMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -407,7 +454,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(artistTracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -426,7 +473,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(artistTracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -447,7 +494,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(artistTracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(BeEmpty()) @@ -467,7 +514,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(artistTracks) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -495,7 +542,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -515,7 +562,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongMatch, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -535,7 +582,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{fuzzyMatch, exactMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -556,7 +603,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{albumMatch, starredTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -577,7 +624,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{albumMatch, ratedTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -603,7 +650,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{wrongDuration, correctMatch}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -620,7 +667,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{closeDuration}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -640,7 +687,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{farDuration, closeDuration}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -657,7 +704,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{differentDuration}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -677,7 +724,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{differentTitle, correctTitle}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -694,7 +741,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{anyTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -711,7 +758,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{shortTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(1)) @@ -737,7 +784,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{libraryTrack}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) @@ -757,7 +804,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB, trackC}) - result, err := m.MatchSongsToLibrary(ctx, songs, 5) + result, err := m.MatchSongs(ctx, songs, 5) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(3)) @@ -778,7 +825,7 @@ var _ = Describe("Matcher", func() { setupTitleOnlyExpectations(model.MediaFiles{trackA, trackB}) - result, err := m.MatchSongsToLibrary(ctx, songs, 2) + result, err := m.MatchSongs(ctx, songs, 2) Expect(err).ToNot(HaveOccurred()) Expect(result).To(HaveLen(2)) diff --git a/core/sonic/sonic.go b/core/sonic/sonic.go new file mode 100644 index 000000000..19eb69c65 --- /dev/null +++ b/core/sonic/sonic.go @@ -0,0 +1,130 @@ +package sonic + +import ( + "context" + "fmt" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +const capabilitySonicSimilarity = "SonicSimilarity" + +type SimilarResult struct { + Song agents.Song + Similarity float64 +} + +type SimilarMatch struct { + MediaFile model.MediaFile + Similarity float64 +} + +type Provider interface { + GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]SimilarResult, error) + FindSonicPath(ctx context.Context, startMF, endMF *model.MediaFile, count int) ([]SimilarResult, error) +} + +type PluginLoader interface { + PluginNames(capability string) []string + LoadSonicSimilarity(name string) (Provider, bool) +} + +type Sonic struct { + ds model.DataStore + pluginLoader PluginLoader + matcher *matcher.Matcher +} + +func New(ds model.DataStore, pluginLoader PluginLoader, matcher *matcher.Matcher) *Sonic { + return &Sonic{ + ds: ds, + pluginLoader: pluginLoader, + matcher: matcher, + } +} + +func (s *Sonic) HasProvider() bool { + return len(s.pluginLoader.PluginNames(capabilitySonicSimilarity)) > 0 +} + +func (s *Sonic) loadProvider() (Provider, error) { + names := s.pluginLoader.PluginNames(capabilitySonicSimilarity) + if len(names) == 0 { + return nil, model.ErrNotFound + } + provider, ok := s.pluginLoader.LoadSonicSimilarity(names[0]) + if !ok { + return nil, model.ErrNotFound + } + return provider, nil +} + +func (s *Sonic) resolveMatches(ctx context.Context, results []SimilarResult) ([]SimilarMatch, error) { + songs := make([]agents.Song, len(results)) + for i, r := range results { + songs[i] = r.Song + } + + matchMap, err := s.matcher.MatchSongsIndexed(ctx, songs) + if err != nil { + return nil, fmt.Errorf("matching songs to library: %w", err) + } + + var matches []SimilarMatch + for i, r := range results { + if mf, ok := matchMap[i]; ok { + matches = append(matches, SimilarMatch{ + MediaFile: mf, + Similarity: r.Similarity, + }) + } + } + return matches, nil +} + +func (s *Sonic) GetSonicSimilarTracks(ctx context.Context, id string, count int) ([]SimilarMatch, error) { + provider, err := s.loadProvider() + if err != nil { + return nil, err + } + + mf, err := s.ds.MediaFile(ctx).Get(id) + if err != nil { + return nil, fmt.Errorf("getting media file %s: %w", id, err) + } + + results, err := provider.GetSonicSimilarTracks(ctx, mf, count) + if err != nil { + log.Error(ctx, "Plugin GetSonicSimilarTracks failed", "id", id, err) + return nil, err + } + + return s.resolveMatches(ctx, results) +} + +func (s *Sonic) FindSonicPath(ctx context.Context, startID, endID string, count int) ([]SimilarMatch, error) { + provider, err := s.loadProvider() + if err != nil { + return nil, err + } + + startMF, err := s.ds.MediaFile(ctx).Get(startID) + if err != nil { + return nil, fmt.Errorf("getting start media file %s: %w", startID, err) + } + endMF, err := s.ds.MediaFile(ctx).Get(endID) + if err != nil { + return nil, fmt.Errorf("getting end media file %s: %w", endID, err) + } + + results, err := provider.FindSonicPath(ctx, startMF, endMF, count) + if err != nil { + log.Error(ctx, "Plugin FindSonicPath failed", "startId", startID, "endId", endID, err) + return nil, err + } + + return s.resolveMatches(ctx, results) +} diff --git a/core/sonic/sonic_suite_test.go b/core/sonic/sonic_suite_test.go new file mode 100644 index 000000000..7058e5de9 --- /dev/null +++ b/core/sonic/sonic_suite_test.go @@ -0,0 +1,17 @@ +package sonic_test + +import ( + "testing" + + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestSonic(t *testing.T) { + tests.Init(t, false) + log.SetLevel(log.LevelFatal) + RegisterFailHandler(Fail) + RunSpecs(t, "Sonic Suite") +} diff --git a/core/sonic/sonic_test.go b/core/sonic/sonic_test.go new file mode 100644 index 000000000..81739b726 --- /dev/null +++ b/core/sonic/sonic_test.go @@ -0,0 +1,146 @@ +package sonic_test + +import ( + "context" + "errors" + + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/tests" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type mockPluginLoader struct { + names []string + provider sonic.Provider + loadOk bool +} + +func (m *mockPluginLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" { + return m.names + } + return nil +} + +func (m *mockPluginLoader) LoadSonicSimilarity(name string) (sonic.Provider, bool) { + return m.provider, m.loadOk +} + +type mockProvider struct { + similarResults []sonic.SimilarResult + similarErr error + pathResults []sonic.SimilarResult + pathErr error +} + +func (m *mockProvider) GetSonicSimilarTracks(_ context.Context, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) { + return m.similarResults, m.similarErr +} + +func (m *mockProvider) FindSonicPath(_ context.Context, _, _ *model.MediaFile, _ int) ([]sonic.SimilarResult, error) { + return m.pathResults, m.pathErr +} + +var _ = Describe("Sonic", func() { + var ( + ctx context.Context + ds *tests.MockDataStore + loader *mockPluginLoader + service *sonic.Sonic + ) + + BeforeEach(func() { + ctx = GinkgoT().Context() + ds = &tests.MockDataStore{} + loader = &mockPluginLoader{} + }) + + Describe("HasProvider", func() { + It("returns false when no plugins available", func() { + loader.names = nil + service = sonic.New(ds, loader, nil) + Expect(service.HasProvider()).To(BeFalse()) + }) + + It("returns true when a plugin is available", func() { + loader.names = []string{"test-plugin"} + service = sonic.New(ds, loader, nil) + Expect(service.HasProvider()).To(BeTrue()) + }) + }) + + Describe("GetSonicSimilarTracks", func() { + It("returns error when no plugin available", func() { + loader.names = nil + service = sonic.New(ds, loader, nil) + _, err := service.GetSonicSimilarTracks(ctx, "song-1", 10) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("returns error when media file not found", func() { + loader.names = []string{"test-plugin"} + loader.provider = &mockProvider{} + loader.loadOk = true + ds.MockedMediaFile = &tests.MockMediaFileRepo{} + service = sonic.New(ds, loader, matcher.New(ds)) + _, err := service.GetSonicSimilarTracks(ctx, "nonexistent", 10) + Expect(err).To(HaveOccurred()) + }) + + It("returns matched results from plugin", func() { + mf1 := model.MediaFile{ID: "song-1", Title: "Test Song", Artist: "Test Artist"} + mf2 := model.MediaFile{ID: "song-2", Title: "Similar Song", Artist: "Test Artist"} + + mockRepo := tests.CreateMockMediaFileRepo() + mockRepo.SetData(model.MediaFiles{mf1, mf2}) + ds.MockedMediaFile = mockRepo + + provider := &mockProvider{ + similarResults: []sonic.SimilarResult{ + {Song: agents.Song{ID: "song-2", Name: "Similar Song", Artist: "Test Artist"}, Similarity: 0.85}, + }, + } + loader.names = []string{"test-plugin"} + loader.provider = provider + loader.loadOk = true + + service = sonic.New(ds, loader, matcher.New(ds)) + matches, err := service.GetSonicSimilarTracks(ctx, "song-1", 10) + Expect(err).ToNot(HaveOccurred()) + Expect(matches).To(HaveLen(1)) + Expect(matches[0].MediaFile.ID).To(Equal("song-2")) + Expect(matches[0].Similarity).To(Equal(0.85)) + }) + }) + + Describe("FindSonicPath", func() { + It("returns error when no plugin available", func() { + loader.names = nil + service = sonic.New(ds, loader, nil) + _, err := service.FindSonicPath(ctx, "song-1", "song-2", 25) + Expect(err).To(MatchError(model.ErrNotFound)) + }) + + It("returns error when plugin call fails", func() { + mf1 := model.MediaFile{ID: "song-1", Title: "Start", Artist: "Artist"} + mf2 := model.MediaFile{ID: "song-2", Title: "End", Artist: "Artist"} + + mockRepo := tests.CreateMockMediaFileRepo() + mockRepo.SetData(model.MediaFiles{mf1, mf2}) + ds.MockedMediaFile = mockRepo + + provider := &mockProvider{pathErr: errors.New("plugin error")} + loader.names = []string{"test-plugin"} + loader.provider = provider + loader.loadOk = true + + service = sonic.New(ds, loader, matcher.New(ds)) + _, err := service.FindSonicPath(ctx, "song-1", "song-2", 25) + Expect(err).To(HaveOccurred()) + }) + }) +}) diff --git a/plugins/README.md b/plugins/README.md index e37a94d7a..048cf549d 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -1,8 +1,8 @@ # Navidrome Plugin System -Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, and other integrations through host services like scheduling, caching, WebSockets, and Subsonic API access. +Navidrome supports WebAssembly (Wasm) plugins for extending functionality. Plugins run in a secure sandbox and can provide metadata agents, scrobblers, lyrics providers, audio similarity, and other integrations through host services like scheduling, caching, task queues, WebSockets, and Subsonic API access. -The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. This means you can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs). +The plugin system is built on **[Extism](https://extism.org/)**, a cross-language framework for building WebAssembly plugins. You can write plugins in any language that Extism supports (Go, Rust, Python, TypeScript, and more) using their Plugin Development Kits (PDKs). **Essential Extism Resources:** - [Extism Documentation](https://extism.org/docs/overview) – Core concepts and architecture @@ -19,12 +19,18 @@ The plugin system is built on **[Extism](https://extism.org/)**, a cross-languag - [Capabilities](#capabilities) - [MetadataAgent](#metadataagent) - [Scrobbler](#scrobbler) + - [Lyrics](#lyrics) + - [SonicSimilarity](#sonicsimilarity) + - [TaskWorker](#taskworker) - [Lifecycle](#lifecycle) + - [SchedulerCallback](#schedulercallback) + - [WebSocketCallback](#websocketcallback) - [Host Services](#host-services) - - [HTTP Requests](#http-requests) + - [HTTP](#http) - [Scheduler](#scheduler) - [Cache](#cache) - [KVStore](#kvstore) + - [Task](#task) - [WebSocket](#websocket) - [Library](#library) - [Artwork](#artwork) @@ -95,14 +101,6 @@ A Navidrome plugin is an `.ndp` package file (zip archive) containing: 1. **`manifest.json`** – Plugin metadata (name, author, version, permissions) 2. **`plugin.wasm`** – Compiled WebAssembly module with capability functions -### Plugin Package Structure - -``` -my-plugin.ndp (zip archive) -├── manifest.json # Required: Plugin metadata -└── plugin.wasm # Required: Compiled WebAssembly module -``` - ### Plugin Naming Plugins are identified by their **filename** (without `.ndp` extension), not the manifest `name` field: @@ -123,6 +121,10 @@ Every plugin must include a `manifest.json` file. Example: "version": "1.0.0", "description": "What this plugin does", "website": "https://example.com", + "config": { + "schema": { ... }, + "uiSchema": { ... } + }, "permissions": { "http": { "reason": "Fetch metadata from external API", @@ -134,6 +136,30 @@ Every plugin must include a `manifest.json` file. Example: **Required fields:** `name`, `author`, `version` +**Optional fields:** `description`, `website`, `config`, `permissions`, `experimental` + +#### Config Definition + +The `config` field defines the plugin's configuration schema using [JSON Schema (draft-07)](https://json-schema.org/) and an optional [JSONForms](https://jsonforms.io/) UI schema for rendering in the Navidrome web UI: + +```json +{ + "config": { + "schema": { + "type": "object", + "properties": { + "api_key": { "type": "string", "title": "API Key" }, + "max_retries": { "type": "integer", "default": 3 } + }, + "required": ["api_key"] + }, + "uiSchema": { + "api_key": { "ui:widget": "password" } + } + } +} +``` + #### Experimental Features Plugins can opt-in to experimental WebAssembly features that may change or be removed in future versions. Currently supported: @@ -142,9 +168,6 @@ Plugins can opt-in to experimental WebAssembly features that may change or be re ```json { - "name": "Threaded Plugin", - "author": "Author Name", - "version": "1.0.0", "experimental": { "threads": { "reason": "Required for concurrent audio processing" @@ -159,50 +182,25 @@ Plugins can opt-in to experimental WebAssembly features that may change or be re ## Capabilities -Capabilities define what your plugin can do. They're automatically detected based on which functions you export. +Capabilities define what your plugin can do. They're automatically detected based on which functions you export. A plugin can implement multiple capabilities. ### MetadataAgent -Provides artist and album metadata. Export one or more of these functions: +Provides artist and album metadata. All methods are **optional** — implement only the ones your data source supports. -| Function | Input | Output | Description | -|---------------------------|----------------------------|----------------------------------|----------------------| -| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID | -| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL | -| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography | -| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists | -| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images | -| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs | -| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info | -| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images | - -**Example:** - -```go -type ArtistInput struct { - ID string `json:"id"` - Name string `json:"name"` - MBID string `json:"mbid,omitempty"` -} - -type BiographyOutput struct { - Biography string `json:"biography"` -} - -//go:wasmexport nd_get_artist_biography -func ndGetArtistBiography() int32 { - var input ArtistInput - if err := pdk.InputJSON(&input); err != nil { - pdk.SetError(err) - return 1 - } - - // Fetch biography from your data source... - output := BiographyOutput{Biography: "Artist biography..."} - pdk.OutputJSON(output) - return 0 -} -``` +| Function | Input | Output | Description | +|-----------------------------------|----------------------------|----------------------------------|--------------------------| +| `nd_get_artist_mbid` | `{id, name}` | `{mbid}` | Get MusicBrainz ID | +| `nd_get_artist_url` | `{id, name, mbid?}` | `{url}` | Get artist URL | +| `nd_get_artist_biography` | `{id, name, mbid?}` | `{biography}` | Get artist biography | +| `nd_get_similar_artists` | `{id, name, mbid?, limit}` | `{artists: [{name, mbid?}]}` | Get similar artists | +| `nd_get_artist_images` | `{id, name, mbid?}` | `{images: [{url, size}]}` | Get artist images | +| `nd_get_artist_top_songs` | `{id, name, mbid?, count}` | `{songs: [{name, mbid?}]}` | Get top songs | +| `nd_get_album_info` | `{name, artist, mbid?}` | `{name, mbid, description, url}` | Get album info | +| `nd_get_album_images` | `{name, artist, mbid?}` | `{images: [{url, size}]}` | Get album images | +| `nd_get_similar_songs_by_track` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by track | +| `nd_get_similar_songs_by_album` | `{id, name, artist, ...}` | `{songs: [{name, artist}]}` | Similar songs by album | +| `nd_get_similar_songs_by_artist` | `{id, name, mbid?, count}` | `{songs: [{name, artist}]}` | Similar songs by artist | To use the plugin as a metadata agent, add it to your config: @@ -210,17 +208,49 @@ To use the plugin as a metadata agent, add it to your config: Agents = "lastfm,spotify,my-plugin" ``` +**Example (using Go PDK package):** + +```go +package main + +import "github.com/navidrome/navidrome/plugins/pdk/go/metadata" + +type myPlugin struct{} + +func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.ArtistBiographyResponse, error) { + return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil +} + +func init() { metadata.Register(&myPlugin{}) } +func main() {} +``` + +**Example (raw wasmexport):** + +```go +//go:wasmexport nd_get_artist_biography +func ndGetArtistBiography() int32 { + var input ArtistInput + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return 1 + } + pdk.OutputJSON(BiographyOutput{Biography: "Artist biography..."}) + return 0 +} +``` + ### Scrobbler -Integrates with external scrobbling services. Export one or more of these functions: +Integrates with external scrobbling services. All three methods are **required**. -| Function | Input | Output | Description | -|------------------------------|-----------------------|----------------|-----------------------------| -| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized | -| `nd_scrobbler_now_playing` | See below | (none) | Send now playing | -| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble | +| Function | Input | Output | Description | +|------------------------------|-----------------------|--------|-----------------------------| +| `nd_scrobbler_is_authorized` | `{username}` | `bool` | Check if user is authorized | +| `nd_scrobbler_now_playing` | See below | (none) | Send now playing | +| `nd_scrobbler_scrobble` | See below | (none) | Submit a scrobble | -> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration. The `nd_scrobbler_is_authorized` function is called after the server-side user check passes. +> **Important:** Scrobbler plugins require the `users` permission in their manifest. Scrobble events are only sent for users assigned to the plugin through Navidrome's configuration. **Manifest permission:** @@ -267,31 +297,95 @@ On success, return `0`. On failure, use `pdk.SetError()` with one of these error ```go import "github.com/navidrome/navidrome/plugins/pdk/go/scrobbler" -// Return error using predefined constants return scrobbler.ScrobblerErrorNotAuthorized return scrobbler.ScrobblerErrorRetryLater return scrobbler.ScrobblerErrorUnrecoverable ``` +### Lyrics + +Provides lyrics for tracks. The single method is **required**. + +| Function | Input | Output | Description | +|-------------------------|-------------------------------|------------------------------------|-----------------| +| `nd_lyrics_get_lyrics` | `{artistName, title, ...}` | `{lyrics: [{lang, text}]}` | Get lyrics | + +Each returned lyric entry has a `lang` (language code) and `text` field. Multiple entries can be returned for different languages. + +### SonicSimilarity + +Audio-similarity discovery based on acoustic features (e.g., embeddings). Both methods are **required**. + +| Function | Input | Output | Description | +|---------------------------------|----------------------------------|--------------------------------------------|---------------------------------------| +| `nd_get_sonic_similar_tracks` | `{song, count}` | `{matches: [{song, similarity}]}` | Find acoustically similar tracks | +| `nd_find_sonic_path` | `{startSong, endSong, count}` | `{matches: [{song, similarity}]}` | Find a path between two songs | + +Each match contains a `song` reference and a `similarity` score (float64, 0.0–1.0). + +### TaskWorker + +Processes tasks from a queue. The method is **optional** — export it if your plugin uses the [Task](#task) host service for background work. + +| Function | Input | Output | Description | +|---------------------|---------------------------------------------|---------|----------------------| +| `nd_task_execute` | `{queueName, taskID, payload, attempt}` | `string`| Execute a queued task| + +The `payload` is raw bytes (the same bytes passed to `TaskEnqueue`). The `attempt` counter starts at 1 and increments on retries. Return a string result on success. + ### Lifecycle -Optional initialization callback. Export this function to run code when your plugin loads: +Optional initialization callback. Called once after the plugin fully loads. | Function | Input | Output | Description | |--------------|-------|------------|--------------------------------| | `nd_on_init` | `{}` | `{error?}` | Called once after plugin loads | -Useful for initializing connections, scheduling recurring tasks, etc. +Useful for initializing connections, scheduling recurring tasks, etc. Errors are logged but don't prevent the plugin from loading. + +### SchedulerCallback + +Receives scheduled task events. **Required** if your plugin uses the [Scheduler](#scheduler) host service. + +| Function | Input | Output | Description | +|---------------------------|----------------------------------------------|--------|-----------------------------| +| `nd_scheduler_callback` | `{scheduleId, payload, isRecurring}` | (none) | Handle scheduled task event | + +### WebSocketCallback + +Receives WebSocket events. Export any subset of these to handle events from the [WebSocket](#websocket) host service. + +| Function | Input | Description | +|----------------------------------|---------------------------------|----------------------------------| +| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received | +| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) | +| `nd_websocket_on_error` | `{connectionId, error}` | Connection error | +| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed | --- ## Host Services -Host services let your plugin call back into Navidrome for advanced functionality. Each service requires declaring the permission in your manifest. +Host services let your plugin call back into Navidrome for advanced functionality. Each service (except [Config](#config)) requires declaring the corresponding permission in your manifest. -### HTTP Requests +### Go PDK Setup -Make HTTP requests using the Extism PDK's built-in HTTP support. See your [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for more details on making requests. +All host service examples below use the generated Go SDK. Add this to your `go.mod`: + +``` +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go +``` + +Then import: + +```go +import "github.com/navidrome/navidrome/plugins/pdk/go/host" +``` + +### HTTP + +Make HTTP requests to external services. This is a dedicated host service (separate from Extism's built-in HTTP support) with additional features like timeouts and redirect control. **Manifest permission:** @@ -306,22 +400,28 @@ Make HTTP requests using the Extism PDK's built-in HTTP support. See your [Extis } ``` +**Host functions:** + +| Function | Parameters | Returns | +|-------------|----------------------------------------------------------|----------------------------------| +| `http_send` | `method, url, headers, body, timeoutMs, noFollowRedirects` | `statusCode, headers, body` | + **Usage:** ```go -req := pdk.NewHTTPRequest(pdk.MethodGet, "https://api.example.com/data") -req.SetHeader("Authorization", "Bearer " + apiKey) -resp := req.Send() - -if resp.Status() == 200 { - data := resp.Body() - // Process response... +resp, err := host.HTTPSend(host.HTTPRequest{ + Method: "GET", + URL: "https://api.example.com/data", + Headers: map[string]string{"Authorization": "Bearer " + apiKey}, +}) +if resp.StatusCode == 200 { + // Process resp.Body } ``` ### Scheduler -Schedule one-time or recurring tasks. Your plugin must export `nd_scheduler_callback` to receive events. +Schedule one-time or recurring tasks. Your plugin must export the [`nd_scheduler_callback`](#schedulercallback) function to receive events. **Manifest permission:** @@ -343,40 +443,9 @@ Schedule one-time or recurring tasks. Your plugin must export `nd_scheduler_call | `scheduler_schedulerecurring` | `cronExpression, payload, scheduleId?` | Schedule recurring callback | | `scheduler_cancelschedule` | `scheduleId` | Cancel a scheduled task | -**Callback function:** +**Usage:** ```go -type SchedulerCallbackInput struct { - ScheduleID string `json:"scheduleId"` - Payload string `json:"payload"` - IsRecurring bool `json:"isRecurring"` -} - -//go:wasmexport nd_scheduler_callback -func ndSchedulerCallback() int32 { - var input SchedulerCallbackInput - pdk.InputJSON(&input) - - // Handle the scheduled task based on payload - pdk.Log(pdk.LogInfo, "Task fired: " + input.ScheduleID) - return 0 -} -``` - -**Scheduling tasks (using generated SDK):** - -Add the generated SDK to your `go.mod`: - -``` -require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 -replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go -``` - -Then import and use: - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - // Schedule one-time task in 60 seconds scheduleID, err := host.SchedulerScheduleOneTime(60, "my-payload", "") @@ -389,7 +458,7 @@ err := host.SchedulerCancelSchedule(scheduleID) ### Cache -Store and retrieve data in an in-memory TTL-based cache. Each plugin has its own isolated namespace. +In-memory TTL-based cache. Each plugin has its own isolated namespace. Cleared on server restart. **Manifest permission:** @@ -420,28 +489,22 @@ Store and retrieve data in an in-memory TTL-based cache. Each plugin has its own **TTL:** Pass `0` for the default (24 hours), or specify seconds. -**Usage (with generated SDK):** - -Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup): +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - // Cache a value for 1 hour host.CacheSetString("api-response", responseData, 3600) -// Retrieve (check Exists before using Value) -result, err := host.CacheGetString("api-response") -if result.Exists { - data := result.Value +// Retrieve (returns value, exists, error) +value, exists, err := host.CacheGetString("api-response") +if exists { + // Use value } ``` -> **Note:** Cache is in-memory only and cleared on server restart. - ### KVStore -Persistent key-value storage that survives server restarts. Each plugin has its own isolated SQLite database. +Persistent key-value storage backed by SQLite. Survives server restarts. Each plugin has its own isolated database at `${DataFolder}/plugins/${pluginID}/kvstore.db`. **Manifest permission:** @@ -456,61 +519,101 @@ Persistent key-value storage that survives server restarts. Each plugin has its } ``` -**Permission options:** - `maxSize`: Maximum storage size (e.g., `"1MB"`, `"500KB"`). Default: 1MB +**Key constraints:** Maximum 256 bytes, must be valid UTF-8. + **Host functions:** -| Function | Parameters | Description | -|--------------------------|--------------|-----------------------------------| -| `kvstore_set` | `key, value` | Store a byte value | -| `kvstore_get` | `key` | Retrieve a byte value | -| `kvstore_delete` | `key` | Delete a value | -| `kvstore_has` | `key` | Check if key exists | -| `kvstore_list` | `prefix` | List keys matching prefix | -| `kvstore_getstorageused` | - | Get current storage usage (bytes) | +| Function | Parameters | Description | +|-----------------------------|--------------------------|-----------------------------------| +| `kvstore_set` | `key, value` | Store a byte value | +| `kvstore_setwithttl` | `key, value, ttlSeconds` | Store with auto-expiration | +| `kvstore_get` | `key` | Retrieve a byte value | +| `kvstore_getmany` | `keys` | Retrieve multiple values at once | +| `kvstore_has` | `key` | Check if key exists | +| `kvstore_list` | `prefix` | List keys matching prefix | +| `kvstore_delete` | `key` | Delete a value | +| `kvstore_deletebyprefix` | `prefix` | Delete all keys matching prefix | +| `kvstore_getstorageused` | – | Get current storage usage (bytes) | -**Key constraints:** -- Maximum key length: 256 bytes -- Keys must be valid UTF-8 strings - -**Usage (with generated SDK):** - -Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup): +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - // Store a value (as raw bytes) token := []byte(`{"access_token": "xyz", "refresh_token": "abc"}`) -_, err := host.KVStoreSet("oauth:spotify", token) +host.KVStoreSet("oauth:spotify", token) + +// Store with TTL (auto-expires after 1 hour) +host.KVStoreSetWithTTL("session:abc", sessionData, 3600) // Retrieve a value -result, err := host.KVStoreGet("oauth:spotify") -if result.Exists { +value, exists, err := host.KVStoreGet("oauth:spotify") +if exists { var tokenData map[string]string - json.Unmarshal(result.Value, &tokenData) + json.Unmarshal(value, &tokenData) } -// List all keys with prefix -keysResult, err := host.KVStoreList("user:") -for _, key := range keysResult.Keys { - // Process each key -} +// Batch retrieve +results, err := host.KVStoreGetMany([]string{"key1", "key2", "key3"}) + +// List and delete by prefix +keys, err := host.KVStoreList("user:") +host.KVStoreDeleteByPrefix("user:") // Check storage usage -usageResult, err := host.KVStoreGetStorageUsed() -fmt.Printf("Using %d bytes\n", usageResult.Bytes) - -// Delete a value -host.KVStoreDelete("oauth:spotify") +usage, err := host.KVStoreGetStorageUsed() +fmt.Printf("Using %d bytes\n", usage) ``` -> **Note:** Unlike Cache, KVStore data persists across server restarts. Storage is located at `${DataFolder}/plugins/${pluginID}/kvstore.db`. +### Task + +Background task queue with retry support. Plugins enqueue tasks and process them by exporting the [`nd_task_execute`](#taskworker) capability function. + +**Manifest permission:** + +```json +{ + "permissions": { + "taskqueue": { + "reason": "Process audio analysis in the background", + "maxConcurrency": 2 + } + } +} +``` + +**Host functions:** + +| Function | Parameters | Description | +|---------------------|---------------------------------------------------|----------------------------| +| `task_createqueue` | `name, concurrency, maxRetries, backoffMs, ...` | Create a named task queue | +| `task_enqueue` | `queueName, payload` | Add a task to the queue | +| `task_get` | `taskID` | Get task status and result | +| `task_cancel` | `taskID` | Cancel a pending task | +| `task_clearqueue` | `queueName` | Remove all tasks from queue| + +**Usage:** + +```go +// Create a queue with retry configuration +host.TaskCreateQueue("analysis", host.QueueConfig{ + Concurrency: 2, + MaxRetries: 3, + BackoffMs: 1000, +}) + +// Enqueue a task +taskID, err := host.TaskEnqueue("analysis", []byte(`{"trackId": "abc"}`)) + +// Check task status +info, err := host.TaskGet(taskID) +fmt.Printf("Status: %s, Attempt: %d\n", info.Status, info.Attempt) +``` ### WebSocket -Establish persistent WebSocket connections to external services. +Establish persistent WebSocket connections to external services. Your plugin must export [WebSocketCallback](#websocketcallback) functions to receive events. **Manifest permission:** @@ -527,21 +630,20 @@ Establish persistent WebSocket connections to external services. **Host functions:** -| Function | Parameters | Description | -|------------------------|---------------------------------|-------------------| -| `websocket_connect` | `url, headers?, connectionId?` | Open a connection | -| `websocket_sendtext` | `connectionId, message` | Send text message | -| `websocket_sendbinary` | `connectionId, data` | Send binary data | -| `websocket_close` | `connectionId, code?, reason?` | Close connection | +| Function | Parameters | Description | +|----------------------------|---------------------------------|-------------------| +| `websocket_connect` | `url, headers?, connectionId?` | Open a connection | +| `websocket_sendtext` | `connectionId, message` | Send text message | +| `websocket_sendbinary` | `connectionId, data` | Send binary data | +| `websocket_closeconnection`| `connectionId, code?, reason?` | Close connection | -**Callback functions (export these to receive events):** +**Usage:** -| Function | Input | Description | -|----------------------------------|---------------------------------|----------------------------------| -| `nd_websocket_on_text_message` | `{connectionId, message}` | Text message received | -| `nd_websocket_on_binary_message` | `{connectionId, data}` | Binary message received (base64) | -| `nd_websocket_on_error` | `{connectionId, error}` | Connection error | -| `nd_websocket_on_close` | `{connectionId, code, reason}` | Connection closed | +```go +connID, err := host.WebSocketConnect("wss://gateway.example.com", nil, "") +host.WebSocketSendText(connID, `{"op": 1, "d": null}`) +host.WebSocketCloseConnection(connID, 1000, "done") +``` ### Library @@ -595,33 +697,22 @@ When `filesystem: true`, your plugin can read files from library directories via ```go import "os" -// Read a file from library 1 content, err := os.ReadFile("/libraries/1/Artist/Album/track.mp3") - -// List directory contents entries, err := os.ReadDir("/libraries/1/Artist") ``` -> **Security:** Filesystem access is read-only and restricted to configured library paths only. Plugins cannot access other parts of the host filesystem. +> **Security:** Filesystem access is read-only and restricted to configured library paths only. -**Usage (with generated SDK):** - -Import the Go SDK (see [Scheduler](#scheduler) for `go.mod` setup). The `Library` struct is provided by the SDK: +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - // Get a specific library -resp, err := host.LibraryGetLibrary(1) -if err != nil { - // Handle error -} -library := resp.Result +library, err := host.LibraryGetLibrary(1) +fmt.Printf("Library: %s (%d songs)\n", library.Name, library.TotalSongs) // Get all libraries -resp, err := host.LibraryGetAllLibraries() -for _, lib := range resp.Result { - // lib is of type host.Library +libraries, err := host.LibraryGetAllLibraries() +for _, lib := range libraries { fmt.Printf("Library: %s (%d songs)\n", lib.Name, lib.TotalSongs) } ``` @@ -651,6 +742,12 @@ Generate public URLs for Navidrome artwork (albums, artists, tracks, playlists). | `artwork_gettrackurl` | `id, size` | Artwork URL | | `artwork_getplaylisturl` | `id, size` | Artwork URL | +**Usage:** + +```go +url, err := host.ArtworkGetAlbumUrl("album-id", 300) +``` + ### SubsonicAPI Call Navidrome's Subsonic API internally (no network round-trip). @@ -670,24 +767,28 @@ Call Navidrome's Subsonic API internally (no network round-trip). } ``` -> **Important:** The `subsonicapi` permission requires the `users` permission. User access is controlled through the plugin's database configuration, not the manifest. Configure which users can use the plugin through the Navidrome UI or API. +> **Important:** The `subsonicapi` permission requires the `users` permission. Which users the plugin can act as is controlled through the Navidrome UI. -**Host function:** +**Host functions:** -| Function | Parameters | Returns | -|--------------------|------------|---------------| -| `subsonicapi_call` | `uri` | JSON response | +| Function | Parameters | Returns | +|-----------------------|------------|--------------------------------| +| `subsonicapi_call` | `uri` | JSON response string | +| `subsonicapi_callraw` | `uri` | Content type + binary response | **Usage:** ```go -// The URI must include the 'u' parameter with the username -response, err := SubsonicAPICall("getAlbumList2?type=random&size=10&u=username") +// JSON response +response, err := host.SubsonicAPICall("getAlbumList2?type=random&size=10&u=username") + +// Binary response (e.g., cover art, streams) +contentType, data, err := host.SubsonicAPICallRaw("getCoverArt?id=al-123&u=username") ``` ### Config -Access plugin configuration values programmatically. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys—useful for discovering dynamic configuration (e.g., user-to-token mappings). +Access plugin configuration values. Unlike `pdk.GetConfig()` which only retrieves individual values, this service can list all available configuration keys — useful for discovering dynamic configuration. > **Note:** This service is always available and does not require a manifest permission. @@ -699,25 +800,17 @@ Access plugin configuration values programmatically. Unlike `pdk.GetConfig()` wh | `config_getint` | `key` | `value, exists` | | `config_keys` | `prefix` | Array of matching key names | -**Usage (with generated SDK):** +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - -// Get a string configuration value +// Get a configuration value value, exists := host.ConfigGet("api_key") -if exists { - // Use the value -} // Get an integer configuration value count, exists := host.ConfigGetInt("max_retries") // List all keys with a prefix (useful for user-specific config) keys := host.ConfigKeys("user:") -for _, key := range keys { - // key might be "user:john", "user:jane", etc. -} // List all configuration keys allKeys := host.ConfigKeys("") @@ -725,7 +818,7 @@ allKeys := host.ConfigKeys("") ### Users -Access user information for the users that the plugin has been granted access to. This is useful for plugins that need to associate data with specific users or display user information. +Access user information for the users that the plugin has been granted access to. **Manifest permission:** @@ -739,7 +832,7 @@ Access user information for the users that the plugin has been granted access to } ``` -**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access. This can be done in two ways: +**Important:** Before enabling a plugin that requires the `users` permission, an administrator must configure which users the plugin can access: 1. **Allow all users** – Enable the "Allow all users" toggle in the plugin settings 2. **Select specific users** – Choose individual users from the user list @@ -751,6 +844,7 @@ If neither option is configured, the plugin cannot be enabled. | Function | Parameters | Returns | |------------------|------------|-----------------------| | `users_getusers` | – | Array of User objects | +| `users_getadmins`| – | Array of admin Users | **User object fields:** @@ -762,45 +856,15 @@ If neither option is configured, the plugin cannot be enabled. > **Security:** Sensitive fields like passwords, email addresses, and internal IDs are never exposed to plugins. -**Usage (with generated SDK):** +**Usage:** ```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" - -// Get all users the plugin has access to users, err := host.UsersGetUsers() -if err != nil { - pdk.Log(pdk.LogError, "Failed to get users: " + err.Error()) - return -} - for _, user := range users { pdk.Log(pdk.LogInfo, "User: " + user.UserName + " (" + user.Name + ")") - if user.IsAdmin { - pdk.Log(pdk.LogInfo, " - Administrator") - } } -``` -**Rust example:** - -```rust -use nd_pdk_host::users::get_users; - -let users = get_users()?; -for user in users { - println!("User: {} ({})", user.user_name, user.name); -} -``` - -**Python example:** - -```python -from host.nd_host_users import users_get_users - -users = users_get_users() -for user in users: - print(f"User: {user['userName']} ({user['name']})") +admins, err := host.UsersGetAdmins() ``` --- @@ -834,20 +898,20 @@ if !ok { } ``` +For more advanced access (listing keys, integer values), use the [Config](#config) host service. + --- ## Building Plugins ### Supported Languages -Plugins can be written in any language that Extism supports. Each language has its own PDK (Plugin Development Kit) that provides the APIs for I/O, logging, configuration, and HTTP requests. See the [Extism PDK documentation](https://extism.org/docs/concepts/pdk) for details. +Plugins can be written in any language that Extism supports. We recommend: -We recommend: - -- **Go** – Best experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk) -- **Rust** – Excellent performance with the [Rust PDK](https://github.com/extism/rust-pdk) -- **Python** – Experimental support via [extism-py](https://github.com/extism/python-pdk) -- **TypeScript** – Experimental support via [extism-js](https://github.com/extism/js-pdk) +- **Go** – Best overall experience with [TinyGo](https://tinygo.org/) and the [Go PDK](https://github.com/extism/go-pdk). Familiar syntax, excellent stdlib support. +- **Rust** – Best for performance-critical plugins. Smallest binaries, excellent type safety. Uses the [Rust PDK](https://github.com/extism/rust-pdk). +- **Python** – Best for rapid prototyping. Experimental support via [extism-py](https://github.com/extism/python-pdk). Note some limitations compared to compiled languages. +- **TypeScript** – Experimental support via [extism-js](https://github.com/extism/js-pdk). ### Go with TinyGo (Recommended) @@ -863,14 +927,12 @@ zip -j my-plugin.ndp manifest.json plugin.wasm #### Using Go PDK Packages -Navidrome provides type-safe Go packages for each capability in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern: +Navidrome provides type-safe Go packages for each capability and host service in `plugins/pdk/go/`. Instead of manually exporting functions with `//go:wasmexport`, use the `Register()` pattern: ```go package main -import ( - "github.com/navidrome/navidrome/plugins/pdk/go/metadata" -) +import "github.com/navidrome/navidrome/plugins/pdk/go/metadata" type myPlugin struct{} @@ -878,10 +940,7 @@ func (p *myPlugin) GetArtistBiography(input metadata.ArtistRequest) (*metadata.A return &metadata.ArtistBiographyResponse{Biography: "Biography text..."}, nil } -func init() { - metadata.Register(&myPlugin{}) -} - +func init() { metadata.Register(&myPlugin{}) } func main() {} ``` @@ -892,16 +951,19 @@ require github.com/navidrome/navidrome v0.0.0 replace github.com/navidrome/navidrome => ../../.. ``` -Available capability packages: +**Available capability packages:** -| Package | Import Path | Description | -|-------------|----------------------------|--------------------------------------| -| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers | -| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services | -| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization | -| `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks | -| `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers | -| `host` | `plugins/pdk/go/host` | Host service SDK (HTTP, cache, etc.) | +| Package | Import Path | Description | +|-------------------|--------------------------------------|--------------------------------------| +| `metadata` | `plugins/pdk/go/metadata` | Artist/album metadata providers | +| `scrobbler` | `plugins/pdk/go/scrobbler` | Scrobbling services | +| `lyrics` | `plugins/pdk/go/lyrics` | Lyrics providers | +| `sonicsimilarity` | `plugins/pdk/go/sonicsimilarity` | Audio similarity discovery | +| `taskworker` | `plugins/pdk/go/taskworker` | Background task processing | +| `lifecycle` | `plugins/pdk/go/lifecycle` | Plugin initialization | +| `scheduler` | `plugins/pdk/go/scheduler` | Scheduled task callbacks | +| `websocket` | `plugins/pdk/go/websocket` | WebSocket event handlers | +| `host` | `plugins/pdk/go/host` | Host service SDK (all services) | See the example plugins in [examples/](examples/) for complete usage patterns. @@ -917,8 +979,6 @@ zip -j my-plugin.ndp manifest.json target/wasm32-wasip1/release/plugin.wasm #### Using Rust PDK -The Rust PDK provides generated type-safe wrappers for both capabilities and host services: - ```toml # Cargo.toml [dependencies] @@ -953,17 +1013,12 @@ register_scrobbler!(MyPlugin); // Generates all WASM exports ```rust use nd_pdk::host::{cache, scheduler, library}; -// Cache a value for 1 hour cache::set_string("my_key", "my_value", 3600)?; - -// Schedule a recurring task scheduler::schedule_recurring("@every 5m", "payload", "task_id")?; - -// Access library metadata let libs = library::get_all_libraries()?; ``` -See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation and examples. +See [pdk/rust/README.md](pdk/rust/README.md) for detailed documentation. ### Python (with extism-py) @@ -975,6 +1030,8 @@ extism-py plugin.wasm -o plugin.wasm *.py zip -j my-plugin.ndp manifest.json plugin.wasm ``` +**For Python host services:** Copy functions from the `nd_host_*.py` files in `plugins/pdk/python/host/` into your `__init__.py` (see comments in those files for extism-py limitations). + ### Using XTP CLI (Scaffolding) Bootstrap a new plugin from a schema: @@ -996,66 +1053,38 @@ zip -j my-agent.ndp manifest.json dist/plugin.wasm See [capabilities/README.md](capabilities/README.md) for available schemas and scaffolding examples. -### Using Host Service SDKs - -Generated SDKs for calling host services are in `plugins/pdk/go/`, `plugins/pdk/python/` and `plugins/pdk/rust`. - -**For Go plugins:** Import the SDK as a Go module: - -```go -import "github.com/navidrome/navidrome/plugins/pdk/go/host" -``` - -Add to your `go.mod`: - -``` -require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 -replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go -``` - -See [pdk/go/README.md](pdk/go/README.md) for detailed documentation. - -**For Python plugins:** Copy functions from `nd_host_*.py` into your `__init__.py` (see comments in those files for extism-py limitations). - -**Recommendations:** - -- **Go:** Best overall experience with excellent stdlib support and familiar syntax for most developers. Recommended if you're already in the Go ecosystem. -- **Rust:** Best for performance-critical plugins or when leveraging Rust's ecosystem. Produces smallest binaries with excellent type safety. -- **Python:** Best for rapid prototyping or simple plugins. Note that extism-py has limitations compared to compiled languages. - --- ## Examples See [examples/](examples/) for complete working plugins: -| Plugin | Language | Capabilities | Host Services | Description | -|----------------------------------------------------------------|----------|---------------|--------------------------------------------|--------------------------------| -| [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example | -| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration | -| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive | -| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | -| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger | -| [library-inspector](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging | -| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo | -| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration (Rust) | +| Plugin | Language | Capabilities | Host Services | Description | +|----------------------------------------------------------------|----------------|---------------|--------------------------------------------|--------------------------------| +| [minimal](examples/minimal/) | Go | MetadataAgent | – | Basic structure example | +| [wikimedia](examples/wikimedia/) | Go | MetadataAgent | HTTP | Wikidata/Wikipedia integration | +| [coverartarchive-py](examples/coverartarchive-py/) | Python | MetadataAgent | HTTP | Cover Art Archive | +| [coverartarchive-as](examples/coverartarchive-as/) | AssemblyScript | MetadataAgent | HTTP | Cover Art Archive | +| [webhook-rs](examples/webhook-rs/) | Rust | Scrobbler | HTTP | HTTP webhooks | +| [nowplaying-py](examples/nowplaying-py/) | Python | Lifecycle | Scheduler, SubsonicAPI | Periodic now-playing logger | +| [library-inspector-rs](examples/library-inspector-rs/) | Rust | Lifecycle | Library, Scheduler | Periodic library stats logging | +| [crypto-ticker](examples/crypto-ticker/) | Go | Lifecycle | WebSocket, Scheduler | Real-time crypto prices demo | +| [discord-rich-presence-rs](examples/discord-rich-presence-rs/) | Rust | Scrobbler | HTTP, WebSocket, Cache, Scheduler, Artwork | Discord integration | --- - ## Security Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism.org/) and the [Wazero](https://wazero.io/) runtime: 1. **Host Allowlisting** – Only explicitly allowed hosts are accessible via HTTP/WebSocket -2. **Limited File System** – Plugins can only access library directories when explicitly granted the `library.filesystem` permission, and access is read-only +2. **Limited File System** – Read-only access to library directories, only when explicitly granted the `library.filesystem` permission 3. **No Network Listeners** – Plugins cannot bind ports 4. **Config Isolation** – Plugins only receive their own config section 5. **Memory Limits** – Controlled by the WebAssembly runtime -6. **User-Scoped Authorization** – Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration. The `users` permission is required for these features. +6. **User-Scoped Authorization** – Plugins with `subsonicapi` or `scrobbler` capabilities can only access/receive events for users assigned to them through Navidrome's configuration 7. **Users Permission** – Plugins requesting user access must be explicitly configured with allowed users; sensitive data (passwords, emails) is never exposed - --- ## Runtime Management @@ -1064,7 +1093,7 @@ Plugins run in a secure WebAssembly sandbox provided by [Extism](https://extism. With `AutoReload = true`, Navidrome watches the plugins folder and automatically detects when `.ndp` files are added, modified, or removed. When a plugin file changes, the plugin is disabled and its metadata is re-read from the archive. -If the `AutoReload` setting is disabled, Navidrome needs to be restarted to pick up plugin changes. +If `AutoReload` is disabled, Navidrome needs to be restarted to pick up plugin changes. ### Enabling/Disabling Plugins @@ -1074,4 +1103,4 @@ Plugins can be enabled/disabled via the Navidrome UI. The plugin state is persis - **In-flight requests** – When reloading, existing requests complete before the new version takes over - **Config changes** – Changes to the plugin configuration in the UI are applied immediately -- **Cache persistence** – The in-memory cache is cleared when a plugin is unloaded +- **Cache persistence** – The in-memory cache is cleared when a plugin is unloaded \ No newline at end of file diff --git a/plugins/capabilities/lyrics.yaml b/plugins/capabilities/lyrics.yaml index 4ac907559..04dd283dd 100644 --- a/plugins/capabilities/lyrics.yaml +++ b/plugins/capabilities/lyrics.yaml @@ -102,6 +102,12 @@ components: mbzReleaseTrackId: type: string description: MBZReleaseTrackID is the MusicBrainz release track ID. + libraryId: + type: integer + format: int32 + description: |- + LibraryID is the ID of the library the track belongs to. + Only included if the plugin has library permission with filesystem access for the track's library. path: type: string description: |- diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index f62da1745..8ada5f7e4 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -128,6 +128,12 @@ components: mbzReleaseTrackId: type: string description: MBZReleaseTrackID is the MusicBrainz release track ID. + libraryId: + type: integer + format: int32 + description: |- + LibraryID is the ID of the library the track belongs to. + Only included if the plugin has library permission with filesystem access for the track's library. path: type: string description: |- diff --git a/plugins/capabilities/sonic_similarity.go b/plugins/capabilities/sonic_similarity.go new file mode 100644 index 000000000..aadb9396e --- /dev/null +++ b/plugins/capabilities/sonic_similarity.go @@ -0,0 +1,32 @@ +package capabilities + +// SonicSimilarity provides audio-similarity based track discovery. +// +//nd:capability name=sonicsimilarity required=true +type SonicSimilarity interface { + //nd:export name=nd_get_sonic_similar_tracks + GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error) + + //nd:export name=nd_find_sonic_path + FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error) +} + +type GetSonicSimilarTracksRequest struct { + Song SongRef `json:"song"` + Count int32 `json:"count"` +} + +type FindSonicPathRequest struct { + StartSong SongRef `json:"startSong"` + EndSong SongRef `json:"endSong"` + Count int32 `json:"count"` +} + +type SonicSimilarityResponse struct { + Matches []SonicMatch `json:"matches"` +} + +type SonicMatch struct { + Song SongRef `json:"song"` + Similarity float64 `json:"similarity"` +} diff --git a/plugins/capabilities/sonic_similarity.yaml b/plugins/capabilities/sonic_similarity.yaml new file mode 100644 index 000000000..cba97d9b0 --- /dev/null +++ b/plugins/capabilities/sonic_similarity.yaml @@ -0,0 +1,92 @@ +version: v1-draft +exports: + nd_get_sonic_similar_tracks: + input: + $ref: '#/components/schemas/GetSonicSimilarTracksRequest' + contentType: application/json + output: + $ref: '#/components/schemas/SonicSimilarityResponse' + contentType: application/json + nd_find_sonic_path: + input: + $ref: '#/components/schemas/FindSonicPathRequest' + contentType: application/json + output: + $ref: '#/components/schemas/SonicSimilarityResponse' + contentType: application/json +components: + schemas: + FindSonicPathRequest: + properties: + startSong: + $ref: '#/components/schemas/SongRef' + endSong: + $ref: '#/components/schemas/SongRef' + count: + type: integer + format: int32 + required: + - startSong + - endSong + - count + GetSonicSimilarTracksRequest: + properties: + song: + $ref: '#/components/schemas/SongRef' + count: + type: integer + format: int32 + required: + - song + - count + SongRef: + description: SongRef is a reference to a song with metadata for matching. + properties: + id: + type: string + description: ID is the internal Navidrome mediafile ID (if known). + name: + type: string + description: Name is the song name. + mbid: + type: string + description: MBID is the MusicBrainz ID for the song. + isrc: + type: string + description: ISRC is the International Standard Recording Code for the song. + artist: + type: string + description: Artist is the artist name. + artistMbid: + type: string + description: ArtistMBID is the MusicBrainz artist ID. + album: + type: string + description: Album is the album name. + albumMbid: + type: string + description: AlbumMBID is the MusicBrainz release ID. + duration: + type: number + format: float + description: Duration is the song duration in seconds. + required: + - name + SonicMatch: + properties: + song: + $ref: '#/components/schemas/SongRef' + similarity: + type: number + format: float + required: + - song + - similarity + SonicSimilarityResponse: + properties: + matches: + type: array + items: + $ref: '#/components/schemas/SonicMatch' + required: + - matches diff --git a/plugins/lyrics_adapter.go b/plugins/lyrics_adapter.go index 43ebc0e4b..c66b027d7 100644 --- a/plugins/lyrics_adapter.go +++ b/plugins/lyrics_adapter.go @@ -21,6 +21,10 @@ func init() { ) } +func newLyricsPlugin(p *plugin) *LyricsPlugin { + return &LyricsPlugin{name: p.name, plugin: p} +} + // LyricsPlugin adapts a WASM plugin with the Lyrics capability. type LyricsPlugin struct { name string diff --git a/plugins/manager.go b/plugins/manager.go index 0c7c91ed8..0e9419bfd 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/core/lyrics" "github.com/navidrome/navidrome/core/scrobbler" + "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server/events" @@ -238,65 +239,32 @@ func (m *Manager) PluginNames(capability string) []string { return names } -// LoadMediaAgent loads and returns a media agent plugin by name. -// Returns false if the plugin is not found or doesn't have the MetadataAgent capability. func (m *Manager) LoadMediaAgent(name string) (agents.Interface, bool) { - m.mu.RLock() - plugin, ok := m.plugins[name] - m.mu.RUnlock() - - if !ok || !hasCapability(plugin.capabilities, CapabilityMetadataAgent) { - return nil, false - } - - // Create a new metadata agent adapter for this plugin - return &MetadataAgent{ - name: plugin.name, - plugin: plugin, - }, true + return loadPlugin(m, name, CapabilityMetadataAgent, newMetadataAgent) } -// LoadScrobbler loads and returns a scrobbler plugin by name. -// Returns false if the plugin is not found or doesn't have the Scrobbler capability. func (m *Manager) LoadScrobbler(name string) (scrobbler.Scrobbler, bool) { - m.mu.RLock() - plugin, ok := m.plugins[name] - m.mu.RUnlock() - - if !ok || !hasCapability(plugin.capabilities, CapabilityScrobbler) { - return nil, false - } - - // Build user ID map for fast lookups - userIDMap := make(map[string]struct{}) - for _, id := range plugin.allowedUserIDs { - userIDMap[id] = struct{}{} - } - - // Create a new scrobbler adapter for this plugin with user authorization config - return &ScrobblerPlugin{ - name: plugin.name, - plugin: plugin, - allowedUserIDs: plugin.allowedUserIDs, - allUsers: plugin.allUsers, - userIDMap: userIDMap, - }, true + return loadPlugin(m, name, CapabilityScrobbler, newScrobblerPlugin) } -// LoadLyricsProvider loads and returns a lyrics provider plugin by name. func (m *Manager) LoadLyricsProvider(name string) (lyrics.Lyrics, bool) { + return loadPlugin(m, name, CapabilityLyrics, newLyricsPlugin) +} + +func (m *Manager) LoadSonicSimilarity(name string) (sonic.Provider, bool) { + return loadPlugin(m, name, CapabilitySonicSimilarity, newSonicSimilarityPlugin) +} + +func loadPlugin[T any](m *Manager, name string, cap Capability, newAdapter func(*plugin) T) (T, bool) { m.mu.RLock() - plugin, ok := m.plugins[name] + p, ok := m.plugins[name] m.mu.RUnlock() - if !ok || !hasCapability(plugin.capabilities, CapabilityLyrics) { - return nil, false + var zero T + if !ok || !hasCapability(p.capabilities, cap) { + return zero, false } - - return &LyricsPlugin{ - name: plugin.name, - plugin: plugin, - }, true + return newAdapter(p), true } // PluginInfo contains basic information about a plugin for metrics/insights. diff --git a/plugins/metadata_agent.go b/plugins/metadata_agent.go index 451ef26d1..52542300c 100644 --- a/plugins/metadata_agent.go +++ b/plugins/metadata_agent.go @@ -6,6 +6,7 @@ import ( "github.com/navidrome/navidrome/core/agents" "github.com/navidrome/navidrome/plugins/capabilities" + "github.com/navidrome/navidrome/utils/slice" ) // CapabilityMetadataAgent indicates the plugin can provide artist/album metadata. @@ -44,6 +45,10 @@ func init() { ) } +func newMetadataAgent(p *plugin) *MetadataAgent { + return &MetadataAgent{name: p.name, plugin: p} +} + // MetadataAgent is an adapter that wraps an Extism plugin and implements // the agents interfaces for metadata retrieval. type MetadataAgent struct { @@ -222,23 +227,24 @@ func (a *MetadataAgent) GetSimilarSongsByArtist(ctx context.Context, id, name, m return callSimilarSongsPluginFunction[capabilities.SimilarSongsByArtistRequest](ctx, a.plugin, FuncGetSimilarSongsByArtist, capabilities.SimilarSongsByArtistRequest{ID: id, Name: name, MBID: mbid, Count: int32(count)}) } +// songRefToAgentSong converts a single SongRef to agents.Song +func songRefToAgentSong(s capabilities.SongRef) agents.Song { + return agents.Song{ + ID: s.ID, + Name: s.Name, + MBID: s.MBID, + ISRC: s.ISRC, + Artist: s.Artist, + ArtistMBID: s.ArtistMBID, + Album: s.Album, + AlbumMBID: s.AlbumMBID, + Duration: uint32(s.Duration * 1000), + } +} + // songRefsToAgentSongs converts a slice of SongRef to agents.Song func songRefsToAgentSongs(refs []capabilities.SongRef) []agents.Song { - songs := make([]agents.Song, len(refs)) - for i, s := range refs { - songs[i] = agents.Song{ - ID: s.ID, - Name: s.Name, - MBID: s.MBID, - ISRC: s.ISRC, - Artist: s.Artist, - ArtistMBID: s.ArtistMBID, - Album: s.Album, - AlbumMBID: s.AlbumMBID, - Duration: uint32(s.Duration * 1000), - } - } - return songs + return slice.Map(refs, songRefToAgentSong) } // Verify interface implementations at compile time diff --git a/plugins/pdk/go/lyrics/lyrics.go b/plugins/pdk/go/lyrics/lyrics.go index 188371fee..6696bc4a1 100644 --- a/plugins/pdk/go/lyrics/lyrics.go +++ b/plugins/pdk/go/lyrics/lyrics.go @@ -68,6 +68,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` // Path is the full path to the track file, relative to the library root. // Only included if the plugin has library permission with filesystem access for the track's library. Path string `json:"path,omitempty"` diff --git a/plugins/pdk/go/lyrics/lyrics_stub.go b/plugins/pdk/go/lyrics/lyrics_stub.go index 91eec4997..fb3e3fb1a 100644 --- a/plugins/pdk/go/lyrics/lyrics_stub.go +++ b/plugins/pdk/go/lyrics/lyrics_stub.go @@ -65,6 +65,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` // Path is the full path to the track file, relative to the library root. // Only included if the plugin has library permission with filesystem access for the track's library. Path string `json:"path,omitempty"` diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index e16bfed4b..d27ae3a9c 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -92,6 +92,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` // Path is the full path to the track file, relative to the library root. // Only included if the plugin has library permission with filesystem access for the track's library. Path string `json:"path,omitempty"` diff --git a/plugins/pdk/go/scrobbler/scrobbler_stub.go b/plugins/pdk/go/scrobbler/scrobbler_stub.go index 86a71af03..9e6f706ac 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -89,6 +89,9 @@ type TrackInfo struct { MBZReleaseGroupID string `json:"mbzReleaseGroupId,omitempty"` // MBZReleaseTrackID is the MusicBrainz release track ID. MBZReleaseTrackID string `json:"mbzReleaseTrackId,omitempty"` + // LibraryID is the ID of the library the track belongs to. + // Only included if the plugin has library permission with filesystem access for the track's library. + LibraryID int32 `json:"libraryId,omitempty"` // Path is the full path to the track file, relative to the library root. // Only included if the plugin has library permission with filesystem access for the track's library. Path string `json:"path,omitempty"` diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go new file mode 100644 index 000000000..7a2681f93 --- /dev/null +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity.go @@ -0,0 +1,136 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the SonicSimilarity capability. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package sonicsimilarity + +import ( + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" +) + +// FindSonicPathRequest represents the FindSonicPathRequest data structure. +type FindSonicPathRequest struct { + StartSong SongRef `json:"startSong"` + EndSong SongRef `json:"endSong"` + Count int32 `json:"count"` +} + +// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. +type GetSonicSimilarTracksRequest struct { + Song SongRef `json:"song"` + Count int32 `json:"count"` +} + +// SongRef is a reference to a song with metadata for matching. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` +} + +// SonicMatch represents the SonicMatch data structure. +type SonicMatch struct { + Song SongRef `json:"song"` + Similarity float64 `json:"similarity"` +} + +// SonicSimilarityResponse represents the SonicSimilarityResponse data structure. +type SonicSimilarityResponse struct { + Matches []SonicMatch `json:"matches"` +} + +// SonicSimilarity requires all methods to be implemented. +// SonicSimilarity provides audio-similarity based track discovery. +type SonicSimilarity interface { + // GetSonicSimilarTracks + GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error) + // FindSonicPath + FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error) +} // Internal implementation holders +var ( + sonicSimilarTracksImpl func(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error) + findSonicPathImpl func(FindSonicPathRequest) (SonicSimilarityResponse, error) +) + +// Register registers a sonicsimilarity implementation. +// All methods are required. +func Register(impl SonicSimilarity) { + sonicSimilarTracksImpl = impl.GetSonicSimilarTracks + findSonicPathImpl = impl.FindSonicPath +} + +// NotImplementedCode is the standard return code for unimplemented functions. +// The host recognizes this and skips the plugin gracefully. +const NotImplementedCode int32 = -2 + +//go:wasmexport nd_get_sonic_similar_tracks +func _NdGetSonicSimilarTracks() int32 { + if sonicSimilarTracksImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input GetSonicSimilarTracksRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := sonicSimilarTracksImpl(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} + +//go:wasmexport nd_find_sonic_path +func _NdFindSonicPath() int32 { + if findSonicPathImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input FindSonicPathRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + output, err := findSonicPathImpl(input) + if err != nil { + pdk.SetError(err) + return -1 + } + + if err := pdk.OutputJSON(output); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} diff --git a/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go new file mode 100644 index 000000000..28d86301c --- /dev/null +++ b/plugins/pdk/go/sonicsimilarity/sonicsimilarity_stub.go @@ -0,0 +1,71 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file provides stub implementations for non-WASM platforms. +// It allows Go plugins to compile and run tests outside of WASM, +// but the actual functionality is only available in WASM builds. +// +//go:build !wasip1 + +package sonicsimilarity + +// FindSonicPathRequest represents the FindSonicPathRequest data structure. +type FindSonicPathRequest struct { + StartSong SongRef `json:"startSong"` + EndSong SongRef `json:"endSong"` + Count int32 `json:"count"` +} + +// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. +type GetSonicSimilarTracksRequest struct { + Song SongRef `json:"song"` + Count int32 `json:"count"` +} + +// SongRef is a reference to a song with metadata for matching. +type SongRef struct { + // ID is the internal Navidrome mediafile ID (if known). + ID string `json:"id,omitempty"` + // Name is the song name. + Name string `json:"name"` + // MBID is the MusicBrainz ID for the song. + MBID string `json:"mbid,omitempty"` + // ISRC is the International Standard Recording Code for the song. + ISRC string `json:"isrc,omitempty"` + // Artist is the artist name. + Artist string `json:"artist,omitempty"` + // ArtistMBID is the MusicBrainz artist ID. + ArtistMBID string `json:"artistMbid,omitempty"` + // Album is the album name. + Album string `json:"album,omitempty"` + // AlbumMBID is the MusicBrainz release ID. + AlbumMBID string `json:"albumMbid,omitempty"` + // Duration is the song duration in seconds. + Duration float32 `json:"duration,omitempty"` +} + +// SonicMatch represents the SonicMatch data structure. +type SonicMatch struct { + Song SongRef `json:"song"` + Similarity float64 `json:"similarity"` +} + +// SonicSimilarityResponse represents the SonicSimilarityResponse data structure. +type SonicSimilarityResponse struct { + Matches []SonicMatch `json:"matches"` +} + +// SonicSimilarity requires all methods to be implemented. +// SonicSimilarity provides audio-similarity based track discovery. +type SonicSimilarity interface { + // GetSonicSimilarTracks + GetSonicSimilarTracks(GetSonicSimilarTracksRequest) (SonicSimilarityResponse, error) + // FindSonicPath + FindSonicPath(FindSonicPathRequest) (SonicSimilarityResponse, error) +} + +// NotImplementedCode is the standard return code for unimplemented functions. +const NotImplementedCode int32 = -2 + +// Register is a no-op on non-WASM platforms. +// This stub allows code to compile outside of WASM. +func Register(_ SonicSimilarity) {} diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs index 85375b525..b0361a3a3 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lib.rs @@ -10,5 +10,6 @@ pub mod lyrics; pub mod metadata; pub mod scheduler; pub mod scrobbler; +pub mod sonicsimilarity; pub mod taskworker; pub mod websocket; diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs index fcfe553f8..0a861a1ed 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/lyrics.rs @@ -102,6 +102,10 @@ pub struct TrackInfo { /// MBZReleaseTrackID is the MusicBrainz release track ID. #[serde(default, skip_serializing_if = "String::is_empty")] pub mbz_release_track_id: String, + /// LibraryID is the ID of the library the track belongs to. + /// Only included if the plugin has library permission with filesystem access for the track's library. + #[serde(default, skip_serializing_if = "is_zero_i32")] + pub library_id: i32, /// Path is the full path to the track file, relative to the library root. /// Only included if the plugin has library permission with filesystem access for the track's library. #[serde(default, skip_serializing_if = "String::is_empty")] diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index dd42e6803..348460374 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -122,6 +122,10 @@ pub struct TrackInfo { /// MBZReleaseTrackID is the MusicBrainz release track ID. #[serde(default, skip_serializing_if = "String::is_empty")] pub mbz_release_track_id: String, + /// LibraryID is the ID of the library the track belongs to. + /// Only included if the plugin has library permission with filesystem access for the track's library. + #[serde(default, skip_serializing_if = "is_zero_i32")] + pub library_id: i32, /// Path is the full path to the track file, relative to the library root. /// Only included if the plugin has library permission with filesystem access for the track's library. #[serde(default, skip_serializing_if = "String::is_empty")] diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs new file mode 100644 index 000000000..eb2868929 --- /dev/null +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/sonicsimilarity.rs @@ -0,0 +1,141 @@ +// Code generated by ndpgen. DO NOT EDIT. +// +// This file contains export wrappers for the SonicSimilarity capability. +// It is intended for use in Navidrome plugins built with extism-pdk. + +use serde::{Deserialize, Serialize}; + +// Helper functions for skip_serializing_if with numeric types +#[allow(dead_code)] +fn is_zero_i32(value: &i32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u32(value: &u32) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_i64(value: &i64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_u64(value: &u64) -> bool { *value == 0 } +#[allow(dead_code)] +fn is_zero_f32(value: &f32) -> bool { *value == 0.0 } +#[allow(dead_code)] +fn is_zero_f64(value: &f64) -> bool { *value == 0.0 } +/// FindSonicPathRequest represents the FindSonicPathRequest data structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FindSonicPathRequest { + #[serde(default)] + pub start_song: SongRef, + #[serde(default)] + pub end_song: SongRef, + #[serde(default)] + pub count: i32, +} +/// GetSonicSimilarTracksRequest represents the GetSonicSimilarTracksRequest data structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSonicSimilarTracksRequest { + #[serde(default)] + pub song: SongRef, + #[serde(default)] + pub count: i32, +} +/// SongRef is a reference to a song with metadata for matching. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SongRef { + /// ID is the internal Navidrome mediafile ID (if known). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub id: String, + /// Name is the song name. + #[serde(default)] + pub name: String, + /// MBID is the MusicBrainz ID for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub mbid: String, + /// ISRC is the International Standard Recording Code for the song. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub isrc: String, + /// Artist is the artist name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist: String, + /// ArtistMBID is the MusicBrainz artist ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub artist_mbid: String, + /// Album is the album name. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album: String, + /// AlbumMBID is the MusicBrainz release ID. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub album_mbid: String, + /// Duration is the song duration in seconds. + #[serde(default, skip_serializing_if = "is_zero_f32")] + pub duration: f32, +} +/// SonicMatch represents the SonicMatch data structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SonicMatch { + #[serde(default)] + pub song: SongRef, + #[serde(default)] + pub similarity: f64, +} +/// SonicSimilarityResponse represents the SonicSimilarityResponse data structure. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SonicSimilarityResponse { + #[serde(default)] + pub matches: Vec, +} + +/// Error represents an error from a capability method. +#[derive(Debug)] +pub struct Error { + pub message: String, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for Error {} + +impl Error { + pub fn new(message: impl Into) -> Self { + Self { message: message.into() } + } +} + +/// SonicSimilarity requires all methods to be implemented. +/// SonicSimilarity provides audio-similarity based track discovery. +pub trait SonicSimilarity { + /// GetSonicSimilarTracks + fn get_sonic_similar_tracks(&self, req: GetSonicSimilarTracksRequest) -> Result; + /// FindSonicPath + fn find_sonic_path(&self, req: FindSonicPathRequest) -> Result; +} + +/// Register all exports for the SonicSimilarity capability. +/// This macro generates the WASM export functions for all trait methods. +#[macro_export] +macro_rules! register_sonicsimilarity { + ($plugin_type:ty) => { + #[extism_pdk::plugin_fn] + pub fn nd_get_sonic_similar_tracks( + req: extism_pdk::Json<$crate::sonicsimilarity::GetSonicSimilarTracksRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::sonicsimilarity::SonicSimilarity::get_sonic_similar_tracks(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + #[extism_pdk::plugin_fn] + pub fn nd_find_sonic_path( + req: extism_pdk::Json<$crate::sonicsimilarity::FindSonicPathRequest> + ) -> extism_pdk::FnResult> { + let plugin = <$plugin_type>::default(); + let result = $crate::sonicsimilarity::SonicSimilarity::find_sonic_path(&plugin, req.into_inner())?; + Ok(extism_pdk::Json(result)) + } + }; +} diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 02c2b2889..302f5e1da 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -30,6 +30,20 @@ func init() { ) } +func newScrobblerPlugin(p *plugin) *ScrobblerPlugin { + userIDMap := make(map[string]struct{}) + for _, id := range p.allowedUserIDs { + userIDMap[id] = struct{}{} + } + return &ScrobblerPlugin{ + name: p.name, + plugin: p, + allowedUserIDs: p.allowedUserIDs, + allUsers: p.allUsers, + userIDMap: userIDMap, + } +} + // ScrobblerPlugin is an adapter that wraps an Extism plugin and implements // the scrobbler.Scrobbler interface for scrobbling to external services. type ScrobblerPlugin struct { diff --git a/plugins/sonic_similarity_adapter.go b/plugins/sonic_similarity_adapter.go new file mode 100644 index 000000000..ff399680e --- /dev/null +++ b/plugins/sonic_similarity_adapter.go @@ -0,0 +1,92 @@ +package plugins + +import ( + "context" + + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/plugins/capabilities" +) + +const CapabilitySonicSimilarity Capability = "SonicSimilarity" + +const ( + FuncGetSonicSimilarTracks = "nd_get_sonic_similar_tracks" + FuncFindSonicPath = "nd_find_sonic_path" +) + +func init() { + registerCapability( + CapabilitySonicSimilarity, + FuncGetSonicSimilarTracks, + FuncFindSonicPath, + ) +} + +func newSonicSimilarityPlugin(p *plugin) *SonicSimilarityPlugin { + return &SonicSimilarityPlugin{name: p.name, plugin: p} +} + +type SonicSimilarityPlugin struct { + name string + plugin *plugin +} + +func (a *SonicSimilarityPlugin) GetSonicSimilarTracks(ctx context.Context, mf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + req := capabilities.GetSonicSimilarTracksRequest{ + Song: mediaFileToSongRef(mf), + Count: int32(count), + } + resp, err := callPluginFunction[capabilities.GetSonicSimilarTracksRequest, capabilities.SonicSimilarityResponse]( + ctx, a.plugin, FuncGetSonicSimilarTracks, req, + ) + if err != nil { + return nil, err + } + return sonicMatchesToSimilarResults(resp.Matches), nil +} + +func (a *SonicSimilarityPlugin) FindSonicPath(ctx context.Context, startMf, endMf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + req := capabilities.FindSonicPathRequest{ + StartSong: mediaFileToSongRef(startMf), + EndSong: mediaFileToSongRef(endMf), + Count: int32(count), + } + resp, err := callPluginFunction[capabilities.FindSonicPathRequest, capabilities.SonicSimilarityResponse]( + ctx, a.plugin, FuncFindSonicPath, req, + ) + if err != nil { + return nil, err + } + return sonicMatchesToSimilarResults(resp.Matches), nil +} + +func mediaFileToSongRef(mf *model.MediaFile) capabilities.SongRef { + ref := capabilities.SongRef{ + ID: mf.ID, + Name: mf.Title, + MBID: mf.MbzRecordingID, + Artist: mf.Artist, + ArtistMBID: mf.MbzArtistID, + Album: mf.Album, + AlbumMBID: mf.MbzAlbumID, + Duration: mf.Duration, + } + if isrcs := mf.Tags.Values(model.TagISRC); len(isrcs) > 0 { + ref.ISRC = isrcs[0] + } + return ref +} + +func sonicMatchesToSimilarResults(matches []capabilities.SonicMatch) []sonic.SimilarResult { + results := make([]sonic.SimilarResult, len(matches)) + for i, m := range matches { + results[i] = sonic.SimilarResult{ + Song: songRefToAgentSong(m.Song), + Similarity: m.Similarity, + } + } + return results +} + +var _ sonic.Provider = (*SonicSimilarityPlugin)(nil) diff --git a/plugins/sonic_similarity_adapter_test.go b/plugins/sonic_similarity_adapter_test.go new file mode 100644 index 000000000..daea04761 --- /dev/null +++ b/plugins/sonic_similarity_adapter_test.go @@ -0,0 +1,110 @@ +//go:build !windows + +package plugins + +import ( + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/model" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("SonicSimilarityPlugin", Ordered, func() { + var ( + manager *Manager + provider sonic.Provider + ) + + BeforeAll(func() { + manager, _ = createTestManagerWithPlugins(nil, "test-sonic-similarity"+PackageExtension) + + var ok bool + provider, ok = manager.LoadSonicSimilarity("test-sonic-similarity") + Expect(ok).To(BeTrue()) + }) + + Describe("PluginNames", func() { + It("reports the sonic similarity capability", func() { + names := manager.PluginNames(string(CapabilitySonicSimilarity)) + Expect(names).To(ContainElement("test-sonic-similarity")) + }) + }) + + Describe("GetSonicSimilarTracks", func() { + It("returns similar tracks from the plugin", func() { + mf := &model.MediaFile{ + ID: "track-1", + Title: "Yesterday", + Artist: "The Beatles", + } + + results, err := provider.GetSonicSimilarTracks(GinkgoT().Context(), mf, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + Expect(results[0].Song.Name).To(Equal("Similar to Yesterday #1")) + Expect(results[0].Song.Artist).To(Equal("The Beatles")) + Expect(results[0].Similarity).To(Equal(1.0)) + Expect(results[1].Similarity).To(Equal(0.9)) + Expect(results[2].Similarity).To(Equal(0.8)) + }) + }) + + Describe("FindSonicPath", func() { + It("returns a path between two tracks from the plugin", func() { + startMf := &model.MediaFile{ + ID: "track-1", + Title: "Yesterday", + Artist: "The Beatles", + } + + endMf := &model.MediaFile{ + ID: "track-2", + Title: "Tomorrow Never Knows", + Artist: "The Beatles", + } + + results, err := provider.FindSonicPath(GinkgoT().Context(), startMf, endMf, 3) + Expect(err).ToNot(HaveOccurred()) + Expect(results).To(HaveLen(3)) + Expect(results[0].Song.Name).To(Equal("Path Yesterday to Tomorrow Never Knows #1")) + Expect(results[0].Song.Artist).To(Equal("The Beatles")) + Expect(results[0].Similarity).To(Equal(1.0)) + Expect(results[1].Similarity).To(Equal(0.95)) + Expect(results[2].Similarity).To(Equal(0.9)) + }) + }) +}) + +var _ = Describe("SonicSimilarityPlugin error handling", Ordered, func() { + var ( + errorManager *Manager + errorProvider sonic.Provider + ) + + BeforeAll(func() { + errorManager, _ = createTestManagerWithPlugins(map[string]map[string]string{ + "test-sonic-similarity": { + "error": "simulated plugin error", + }, + }, "test-sonic-similarity"+PackageExtension) + + var ok bool + errorProvider, ok = errorManager.LoadSonicSimilarity("test-sonic-similarity") + Expect(ok).To(BeTrue()) + }) + + It("returns error from GetSonicSimilarTracks", func() { + mf := &model.MediaFile{ID: "track-1", Title: "Test"} + _, err := errorProvider.GetSonicSimilarTracks(GinkgoT().Context(), mf, 3) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) + + It("returns error from FindSonicPath", func() { + startMf := &model.MediaFile{ID: "track-1", Title: "Start"} + endMf := &model.MediaFile{ID: "track-2", Title: "End"} + _, err := errorProvider.FindSonicPath(GinkgoT().Context(), startMf, endMf, 3) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("simulated plugin error")) + }) +}) diff --git a/plugins/testdata/test-sonic-similarity/go.mod b/plugins/testdata/test-sonic-similarity/go.mod new file mode 100644 index 000000000..6265288f9 --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/go.mod @@ -0,0 +1,16 @@ +module test-sonic-similarity + +go 1.25 + +require github.com/navidrome/navidrome/plugins/pdk/go v0.0.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/extism/go-pdk v1.1.3 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/navidrome/navidrome/plugins/pdk/go => ../../pdk/go diff --git a/plugins/testdata/test-sonic-similarity/go.sum b/plugins/testdata/test-sonic-similarity/go.sum new file mode 100644 index 000000000..af880eb51 --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/testdata/test-sonic-similarity/main.go b/plugins/testdata/test-sonic-similarity/main.go new file mode 100644 index 000000000..e315bff15 --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/main.go @@ -0,0 +1,71 @@ +// Test plugin for Navidrome sonic similarity integration tests. +// Build with: tinygo build -o ../test-sonic-similarity.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + "errors" + "strconv" + + "github.com/navidrome/navidrome/plugins/pdk/go/pdk" + "github.com/navidrome/navidrome/plugins/pdk/go/sonicsimilarity" +) + +func init() { + sonicsimilarity.Register(&testSonicSimilarity{}) +} + +type testSonicSimilarity struct{} + +func checkConfigError() error { + errMsg, hasErr := pdk.GetConfig("error") + if !hasErr || errMsg == "" { + return nil + } + return errors.New(errMsg) +} + +func (t *testSonicSimilarity) GetSonicSimilarTracks(input sonicsimilarity.GetSonicSimilarTracksRequest) (sonicsimilarity.SonicSimilarityResponse, error) { + if err := checkConfigError(); err != nil { + return sonicsimilarity.SonicSimilarityResponse{}, err + } + count := int(input.Count) + if count == 0 { + count = 5 + } + matches := make([]sonicsimilarity.SonicMatch, 0, count) + for i := range count { + matches = append(matches, sonicsimilarity.SonicMatch{ + Song: sonicsimilarity.SongRef{ + ID: "similar-track-" + strconv.Itoa(i+1), + Name: "Similar to " + input.Song.Name + " #" + strconv.Itoa(i+1), + Artist: input.Song.Artist, + }, + Similarity: 1.0 - float64(i)*0.1, + }) + } + return sonicsimilarity.SonicSimilarityResponse{Matches: matches}, nil +} + +func (t *testSonicSimilarity) FindSonicPath(input sonicsimilarity.FindSonicPathRequest) (sonicsimilarity.SonicSimilarityResponse, error) { + if err := checkConfigError(); err != nil { + return sonicsimilarity.SonicSimilarityResponse{}, err + } + count := int(input.Count) + if count == 0 { + count = 5 + } + matches := make([]sonicsimilarity.SonicMatch, 0, count) + for i := range count { + matches = append(matches, sonicsimilarity.SonicMatch{ + Song: sonicsimilarity.SongRef{ + ID: "path-track-" + strconv.Itoa(i+1), + Name: "Path " + input.StartSong.Name + " to " + input.EndSong.Name + " #" + strconv.Itoa(i+1), + Artist: input.StartSong.Artist, + }, + Similarity: 1.0 - float64(i)*0.05, + }) + } + return sonicsimilarity.SonicSimilarityResponse{Matches: matches}, nil +} + +func main() {} diff --git a/plugins/testdata/test-sonic-similarity/manifest.json b/plugins/testdata/test-sonic-similarity/manifest.json new file mode 100644 index 000000000..a55c4021f --- /dev/null +++ b/plugins/testdata/test-sonic-similarity/manifest.json @@ -0,0 +1,7 @@ +{ + "name": "Test Sonic Similarity", + "author": "Navidrome Test", + "version": "1.0.0", + "description": "A test plugin for sonic similarity integration testing", + "capabilities": ["SonicSimilarity"] +} diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 4ad9e3daa..4b003167a 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -519,6 +519,7 @@ func setupTestDB() { metrics.NewNoopInstance(), lyrics.NewLyrics(nil), decider, + nil, ) } diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/e2e/subsonic_sonic_similarity_test.go new file mode 100644 index 000000000..3a010bc67 --- /dev/null +++ b/server/e2e/subsonic_sonic_similarity_test.go @@ -0,0 +1,272 @@ +package e2e + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + + "github.com/Masterminds/squirrel" + "github.com/navidrome/navidrome/core" + "github.com/navidrome/navidrome/core/agents" + "github.com/navidrome/navidrome/core/lyrics" + "github.com/navidrome/navidrome/core/matcher" + "github.com/navidrome/navidrome/core/metrics" + "github.com/navidrome/navidrome/core/playback" + "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/core/stream" + "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/subsonic" + "github.com/navidrome/navidrome/server/subsonic/responses" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// buildSonicRouter creates a subsonic.Router with a real sonic.Sonic service +// backed by the given provider and the shared e2e DataStore. +func buildSonicRouter(provider sonic.Provider) *subsonic.Router { + loader := &mockSonicPluginLoader{provider: provider} + m := matcher.New(ds) + sonicSvc := sonic.New(ds, loader, m) + decider := stream.NewTranscodeDecider(ds, noopFFmpeg{}) + return subsonic.New( + ds, + noopArtwork{}, + &spyStreamer{}, + noopArchiver{}, + core.NewPlayers(ds), + noopProvider{}, + nil, // scanner + events.NoopBroker(), + playlists.NewPlaylists(ds, core.NewImageUploadService()), + noopPlayTracker{}, + core.NewShare(ds), + playback.PlaybackServer(nil), + metrics.NewNoopInstance(), + lyrics.NewLyrics(nil), + decider, + sonicSvc, + ) +} + +// doSonicReq makes a request through a sonic-enabled router and returns the parsed response. +func doSonicReq(sonicRouter *subsonic.Router, endpoint string, params ...string) *responses.Subsonic { + w := httptest.NewRecorder() + r := buildReq(adminUser, endpoint, params...) + sonicRouter.ServeHTTP(w, r) + return parseJSONResponse(w) +} + +// doSonicRawReq makes a request through a sonic-enabled router and returns the raw recorder. +func doSonicRawReq(sonicRouter *subsonic.Router, endpoint string, params ...string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + r := buildReq(adminUser, endpoint, params...) + sonicRouter.ServeHTTP(w, r) + return w +} + +var _ = Describe("Sonic Similarity Endpoints", func() { + BeforeEach(func() { + setupTestDB() + }) + + Context("without sonic similarity plugin", func() { + Describe("getSonicSimilarTracks", func() { + It("returns 404 when no sonic similarity plugin is available", func() { + w := doRawReq("getSonicSimilarTracks", "id", "any-song-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + + Describe("findSonicPath", func() { + It("returns 404 when no sonic similarity plugin is available", func() { + w := doRawReq("findSonicPath", "startSongId", "any-song-id", "endSongId", "another-song-id") + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + }) + }) + + Context("with sonic similarity plugin", func() { + var ( + sonicRouter *subsonic.Router + comeTogether model.MediaFile + something model.MediaFile + ) + + BeforeEach(func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Come Together"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + comeTogether = songs[0] + + songs, err = ds.MediaFile(ctx).GetAll(model.QueryOptions{ + Filters: squirrel.Eq{"title": "Something"}, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + something = songs[0] + + provider := &mockSonicProvider{ + similarIDs: []string{something.ID, comeTogether.ID}, + pathIDs: []string{comeTogether.ID, something.ID}, + } + sonicRouter = buildSonicRouter(provider) + }) + + Describe("getSonicSimilarTracks", func() { + It("returns similar tracks with similarity scores", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + matches := *resp.SonicMatches + Expect(matches).To(HaveLen(2)) + Expect(matches[0].Entry.Title).To(Equal("Something")) + Expect(matches[0].Similarity).To(Equal(1.0)) + Expect(matches[1].Entry.Title).To(Equal("Come Together")) + Expect(matches[1].Similarity).To(Equal(0.9)) + }) + + It("respects the count parameter", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID, "count", "1") + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(*resp.SonicMatches).To(HaveLen(1)) + }) + + It("returns an error for a missing id parameter", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a non-existent song ID", func() { + resp := doSonicReq(sonicRouter, "getSonicSimilarTracks", "id", "non-existent-id") + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns correct JSON structure", func() { + w := doSonicRawReq(sonicRouter, "getSonicSimilarTracks", "id", comeTogether.ID) + Expect(w.Code).To(Equal(http.StatusOK)) + + var wrapper responses.JsonWrapper + Expect(json.Unmarshal(w.Body.Bytes(), &wrapper)).To(Succeed()) + matches := *wrapper.Subsonic.SonicMatches + Expect(matches).To(HaveLen(2)) + Expect(matches[0].Similarity).To(BeNumerically(">", 0)) + Expect(matches[0].Entry.Id).ToNot(BeEmpty()) + }) + }) + + Describe("findSonicPath", func() { + It("returns a path between two tracks with similarity scores", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", + "startSongId", comeTogether.ID, + "endSongId", something.ID, + ) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + matches := *resp.SonicMatches + Expect(matches).To(HaveLen(2)) + Expect(matches[0].Entry.Title).To(Equal("Come Together")) + Expect(matches[0].Similarity).To(Equal(1.0)) + Expect(matches[1].Entry.Title).To(Equal("Something")) + Expect(matches[1].Similarity).To(Equal(0.95)) + }) + + It("returns an error for a missing startSongId parameter", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", "endSongId", something.ID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a missing endSongId parameter", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", "startSongId", comeTogether.ID) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a non-existent start song ID", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", + "startSongId", "non-existent-id", + "endSongId", something.ID, + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + + It("returns an error for a non-existent end song ID", func() { + resp := doSonicReq(sonicRouter, "findSonicPath", + "startSongId", comeTogether.ID, + "endSongId", "non-existent-id", + ) + + Expect(resp.Status).To(Equal(responses.StatusFailed)) + Expect(resp.Error).ToNot(BeNil()) + }) + }) + }) +}) + +// mockSonicProvider returns results using IDs from the real test library, +// so that the matcher can resolve them back to actual MediaFiles. +type mockSonicProvider struct { + similarIDs []string + pathIDs []string +} + +func (m *mockSonicProvider) GetSonicSimilarTracks(_ context.Context, mf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + var results []sonic.SimilarResult + for i, id := range m.similarIDs { + if i >= count { + break + } + results = append(results, sonic.SimilarResult{ + Song: agents.Song{ID: id}, + Similarity: 1.0 - float64(i)*0.1, + }) + } + return results, nil +} + +func (m *mockSonicProvider) FindSonicPath(_ context.Context, startMf, endMf *model.MediaFile, count int) ([]sonic.SimilarResult, error) { + var results []sonic.SimilarResult + for i, id := range m.pathIDs { + if i >= count { + break + } + results = append(results, sonic.SimilarResult{ + Song: agents.Song{ID: id}, + Similarity: 1.0 - float64(i)*0.05, + }) + } + return results, nil +} + +type mockSonicPluginLoader struct { + provider sonic.Provider +} + +func (m *mockSonicPluginLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" && m.provider != nil { + return []string{"mock-sonic"} + } + return nil +} + +func (m *mockSonicPluginLoader) LoadSonicSimilarity(_ string) (sonic.Provider, bool) { + if m.provider != nil { + return m.provider, true + } + return nil, false +} diff --git a/server/subsonic/album_lists_test.go b/server/subsonic/album_lists_test.go index ae4ef9bb9..220376b15 100644 --- a/server/subsonic/album_lists_test.go +++ b/server/subsonic/album_lists_test.go @@ -27,7 +27,7 @@ var _ = Describe("Album Lists", func() { ds = &tests.MockDataStore{} auth.Init(ds) mockRepo = ds.Album(ctx).(*tests.MockAlbumRepo) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) w = httptest.NewRecorder() }) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index e91c02aa4..5b6565260 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -19,6 +19,7 @@ import ( "github.com/navidrome/navidrome/core/playback" playlistsvc "github.com/navidrome/navidrome/core/playlists" "github.com/navidrome/navidrome/core/scrobbler" + sonicsvc "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" @@ -52,12 +53,14 @@ type Router struct { metrics metrics.Metrics lyrics lyricssvc.Lyrics transcodeDecision stream.TranscodeDecider + sonic *sonicsvc.Sonic } func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStreamer, archiver core.Archiver, players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker, playlists playlistsvc.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer, metrics metrics.Metrics, lyrics lyricssvc.Lyrics, transcodeDecision stream.TranscodeDecider, + sonic *sonicsvc.Sonic, ) *Router { r := &Router{ ds: ds, @@ -75,6 +78,7 @@ func New(ds model.DataStore, artwork artwork.Artwork, streamer stream.MediaStrea metrics: metrics, lyrics: lyrics, transcodeDecision: transcodeDecision, + sonic: sonic, } r.Handler = r.routes() return r @@ -121,6 +125,8 @@ func (api *Router) routes() http.Handler { h(r, "getTopSongs", api.GetTopSongs) h(r, "getSimilarSongs", api.GetSimilarSongs) h(r, "getSimilarSongs2", api.GetSimilarSongs2) + hr(r, "getSonicSimilarTracks", api.GetSonicSimilarTracks) + hr(r, "findSonicPath", api.FindSonicPath) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index e110e2b93..1c513edf4 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -27,7 +27,7 @@ var _ = Describe("MediaAnnotationController", func() { ds = &tests.MockDataStore{} playTracker = &fakePlayTracker{} eventBroker = &fakeEventBroker{} - router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, eventBroker, nil, playTracker, nil, nil, nil, nil, nil, nil) }) Describe("Scrobble", func() { diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 7f64fb47f..589a609da 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -34,7 +34,7 @@ var _ = Describe("MediaRetrievalController", func() { MockedMediaFile: mockRepo, } artwork = &fakeArtwork{data: "image data"} - router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil) + router = New(ds, artwork, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, lyrics.NewLyrics(nil), nil, nil) w = httptest.NewRecorder() DeferCleanup(configtest.SetupConfig()) conf.Server.LyricsPriority = "embedded,.lrc" diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 353cf1077..6c54d36d0 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -8,12 +8,18 @@ import ( func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subsonic, error) { response := newResponse() - response.OpenSubsonicExtensions = &responses.OpenSubsonicExtensions{ + extensions := responses.OpenSubsonicExtensions{ {Name: "transcodeOffset", Versions: []int32{1}}, {Name: "formPost", Versions: []int32{1}}, {Name: "songLyrics", Versions: []int32{1}}, {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, } + if api.sonic != nil && api.sonic.HasProvider() { + extensions = append(extensions, responses.OpenSubsonicExtension{ + Name: "sonicSimilarity", Versions: []int32{1}, + }) + } + response.OpenSubsonicExtensions = &extensions return response, nil } diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index 92d1c3e84..d98599f8f 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -5,12 +5,28 @@ import ( "net/http" "net/http/httptest" + sonicsvc "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/server/subsonic" "github.com/navidrome/navidrome/server/subsonic/responses" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) +type mockSonicPluginLoader struct { + names []string +} + +func (m *mockSonicPluginLoader) PluginNames(capability string) []string { + if capability == "SonicSimilarity" { + return m.names + } + return nil +} + +func (m *mockSonicPluginLoader) LoadSonicSimilarity(_ string) (sonicsvc.Provider, bool) { + return nil, false +} + var _ = Describe("GetOpenSubsonicExtensions", func() { var ( router *subsonic.Router @@ -18,29 +34,64 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { r *http.Request ) - BeforeEach(func() { - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + JustBeforeEach(func() { w = httptest.NewRecorder() r = httptest.NewRequest("GET", "/getOpenSubsonicExtensions?f=json", nil) }) - It("should return the correct OpenSubsonicExtensions", func() { - router.ServeHTTP(w, r) + Context("without sonic similarity plugin", func() { + BeforeEach(func() { + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + }) - // Make sure the endpoint is public, by not passing any authentication - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + It("should return the base 5 OpenSubsonicExtensions without sonicSimilarity", func() { + router.ServeHTTP(w, r) - var response responses.JsonWrapper - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(5), - ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), - )) + // Make sure the endpoint is public, by not passing any authentication + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(5), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + )) + Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + ) + }) + }) + + Context("with sonic similarity plugin", func() { + BeforeEach(func() { + sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) + }) + + It("should return 6 extensions including sonicSimilarity", func() { + router.ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(6), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + )) + }) }) }) diff --git a/server/subsonic/playlists_test.go b/server/subsonic/playlists_test.go index 3f2a2068e..1d5f6a70a 100644 --- a/server/subsonic/playlists_test.go +++ b/server/subsonic/playlists_test.go @@ -24,7 +24,7 @@ var _ = Describe("buildPlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) ctx = context.Background() }) @@ -258,7 +258,7 @@ var _ = Describe("UpdatePlaylist", func() { BeforeEach(func() { ds = &tests.MockDataStore{} playlists = &fakePlaylists{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, playlists, nil, nil, nil, nil, nil, nil, nil) }) It("clears the comment when parameter is empty", func() { diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON new file mode 100644 index 000000000..b22f18e83 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .JSON @@ -0,0 +1,25 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "sonicMatch": [ + { + "entry": { + "id": "1", + "isDir": false, + "title": "Bohemian Rhapsody" + }, + "similarity": 0.95 + }, + { + "entry": { + "id": "2", + "isDir": false, + "title": "We Will Rock You" + }, + "similarity": 0.78 + } + ] +} diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML new file mode 100644 index 000000000..14d17d00d --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches with data should match .XML @@ -0,0 +1,10 @@ + + + + 0.95 + + + + 0.78 + + diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON new file mode 100644 index 000000000..75562871f --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "sonicMatch": [] +} diff --git a/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML new file mode 100644 index 000000000..184228a0e --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses SonicMatches without data should match .XML @@ -0,0 +1 @@ + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index f0bb26f66..c2e863b0f 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -62,6 +62,7 @@ type Subsonic struct { LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"` PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"` TranscodeDecision *TranscodeDecision `xml:"transcodeDecision,omitempty" json:"transcodeDecision,omitempty"` + SonicMatches *Array[SonicMatch] `xml:"sonicMatch,omitempty" json:"sonicMatch,omitempty"` } const ( @@ -441,6 +442,11 @@ type TopSongs struct { Song []Child `xml:"song,omitempty" json:"song,omitempty"` } +type SonicMatch struct { + Entry Child `xml:"entry" json:"entry"` + Similarity float64 `xml:"similarity" json:"similarity"` +} + type PlayQueue struct { Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"` Current string `xml:"current,attr,omitempty" json:"current,omitempty"` diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 15f2da9c6..9910ced14 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -1114,4 +1114,33 @@ var _ = Describe("Responses", func() { }) }) + Describe("SonicMatches", func() { + Context("without data", func() { + BeforeEach(func() { + response.SonicMatches = &Array[SonicMatch]{} + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + + Context("with data", func() { + BeforeEach(func() { + response.SonicMatches = &Array[SonicMatch]{ + {Entry: Child{Id: "1", Title: "Bohemian Rhapsody", IsDir: false}, Similarity: 0.95}, + {Entry: Child{Id: "2", Title: "We Will Rock You", IsDir: false}, Similarity: 0.78}, + } + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + }) diff --git a/server/subsonic/searching_test.go b/server/subsonic/searching_test.go index ab40a726f..9a9c6af6f 100644 --- a/server/subsonic/searching_test.go +++ b/server/subsonic/searching_test.go @@ -21,7 +21,7 @@ var _ = Describe("Search", func() { ds = &tests.MockDataStore{} auth.Init(ds) - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) // Get references to the mock repositories so we can inspect their Options mockAlbumRepo = ds.Album(nil).(*tests.MockAlbumRepo) diff --git a/server/subsonic/sonic_similarity.go b/server/subsonic/sonic_similarity.go new file mode 100644 index 000000000..3640a116b --- /dev/null +++ b/server/subsonic/sonic_similarity.go @@ -0,0 +1,69 @@ +package subsonic + +import ( + "context" + "net/http" + + "github.com/navidrome/navidrome/core/sonic" + "github.com/navidrome/navidrome/server/subsonic/responses" + "github.com/navidrome/navidrome/utils/req" +) + +func (api *Router) GetSonicSimilarTracks(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + if api.sonic == nil || !api.sonic.HasProvider() { + w.WriteHeader(http.StatusNotFound) + return nil, nil + } + ctx := r.Context() + p := req.Params(r) + id, err := p.String("id") + if err != nil { + return nil, err + } + count := p.IntOr("count", 10) + + matches, err := api.sonic.GetSonicSimilarTracks(ctx, id, count) + if err != nil { + return nil, err + } + + return sonicMatchResponse(ctx, matches), nil +} + +func (api *Router) FindSonicPath(w http.ResponseWriter, r *http.Request) (*responses.Subsonic, error) { + if api.sonic == nil || !api.sonic.HasProvider() { + w.WriteHeader(http.StatusNotFound) + return nil, nil + } + ctx := r.Context() + p := req.Params(r) + startSongID, err := p.String("startSongId") + if err != nil { + return nil, err + } + endSongID, err := p.String("endSongId") + if err != nil { + return nil, err + } + count := p.IntOr("count", 25) + + matches, err := api.sonic.FindSonicPath(ctx, startSongID, endSongID, count) + if err != nil { + return nil, err + } + + return sonicMatchResponse(ctx, matches), nil +} + +func sonicMatchResponse(ctx context.Context, matches []sonic.SimilarMatch) *responses.Subsonic { + response := newResponse() + resp := make(responses.Array[responses.SonicMatch], len(matches)) + for i, m := range matches { + resp[i] = responses.SonicMatch{ + Entry: childFromMediaFile(ctx, m.MediaFile), + Similarity: m.Similarity, + } + } + response.SonicMatches = &resp + return response +} diff --git a/server/subsonic/transcode_test.go b/server/subsonic/transcode_test.go index 15ba168d7..adc7b7600 100644 --- a/server/subsonic/transcode_test.go +++ b/server/subsonic/transcode_test.go @@ -27,7 +27,7 @@ var _ = Describe("Transcode endpoints", func() { mockMFRepo = &tests.MockMediaFileRepo{} ds = &tests.MockDataStore{MockedMediaFile: mockMFRepo} mockTD = &mockTranscodeDecision{} - router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + router = New(ds, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) w = httptest.NewRecorder() }) @@ -284,7 +284,7 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for direct play", func() { fakeStreamer := &fakeMediaStreamer{} - router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) mockMFRepo.SetData(model.MediaFiles{{ID: "song-1"}}) mockTD.resolvedReq = stream.Request{} @@ -301,7 +301,7 @@ var _ = Describe("Transcode endpoints", func() { It("builds correct StreamRequest for transcoding", func() { fakeStreamer := &fakeMediaStreamer{} - router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD) + router = New(ds, nil, fakeStreamer, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, mockTD, nil) mockMFRepo.SetData(model.MediaFiles{{ID: "song-2"}}) mockTD.resolvedReq = stream.Request{ Format: "mp3", From e06362218be394cb2727655932c01dcaea8c1061 Mon Sep 17 00:00:00 2001 From: Daniel Barrientos Anariba <69573860+danielbanariba@users.noreply.github.com> Date: Mon, 27 Apr 2026 16:21:32 -0600 Subject: [PATCH 039/117] feat(ui): add Not Starred filter option (#5362) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add Not Starred filter option - #5108 Signed-off-by: Daniel Banariba * fix(ui): apply notStarred translation to playlistTrack resource - #5108 Signed-off-by: Daniel Banariba * refactor(ui): use NullableBooleanInput for starred filter - #5108 Replace QuickFilter approach with NullableBooleanInput per maintainer review feedback. Single tri-state filter (Yes/No/Any) instead of two separate buttons + dataProvider translation. Matches the existing pattern used by the 'missing' filter. Signed-off-by: Daniel Banariba --------- Signed-off-by: Daniel Banariba Co-authored-by: Deluan Quintão --- ui/src/album/AlbumList.jsx | 4 +--- ui/src/artist/ArtistList.jsx | 4 +--- ui/src/song/SongList.jsx | 4 +--- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx index 28a24981c..0b8c256df 100644 --- a/ui/src/album/AlbumList.jsx +++ b/ui/src/album/AlbumList.jsx @@ -20,7 +20,6 @@ import FavoriteIcon from '@material-ui/icons/Favorite' import { withWidth } from '@material-ui/core' import { List, - QuickFilter, Title, useAlbumsPerPage, useResourceRefresh, @@ -152,10 +151,9 @@ const AlbumFilter = (props) => { {config.enableFavourites && ( - } - defaultValue={true} /> )} {isAdmin && } diff --git a/ui/src/artist/ArtistList.jsx b/ui/src/artist/ArtistList.jsx index 6c526a5a5..4559aa8e6 100644 --- a/ui/src/artist/ArtistList.jsx +++ b/ui/src/artist/ArtistList.jsx @@ -24,7 +24,6 @@ import { ArtistContextMenu, CoverArtAvatar, List, - QuickFilter, useGetHandleArtistClick, RatingField, useSelectedFields, @@ -88,10 +87,9 @@ const ArtistFilter = (props) => { {config.enableFavourites && ( - } - defaultValue={true} /> )} {isAdmin && } diff --git a/ui/src/song/SongList.jsx b/ui/src/song/SongList.jsx index 98684132f..d928af549 100644 --- a/ui/src/song/SongList.jsx +++ b/ui/src/song/SongList.jsx @@ -20,7 +20,6 @@ import { SongContextMenu, SongDatagrid, SongInfo, - QuickFilter, SongTitleField, SongSimpleList, RatingField, @@ -119,10 +118,9 @@ const SongFilter = (props) => { /> {config.enableFavourites && ( - } - defaultValue={true} /> )} {isAdmin && } From 9508dfcf06514e02daa79abc983c9ba7032aedbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 27 Apr 2026 19:36:57 -0400 Subject: [PATCH 040/117] fix(sharing): validate JWT expiration and share existence on stream endpoint (#5426) * fix(sharing): validate JWT expiration and share existence on stream endpoint The public stream endpoint (/public/s/{token}) was using TokenAuth.Decode() which only verifies the JWT signature but skips exp claim validation. This allowed expired share stream URLs to remain functional indefinitely. Additionally, deleting a share did not revoke previously issued stream tokens since the handler never performed a server-side share lookup. Fixed by switching decodeStreamInfo() to use auth.Validate() which properly checks the exp claim, and by embedding the share ID ("sid") in stream tokens so the handler can verify the share still exists. Old tokens without the sid claim remain backward compatible but still benefit from expiration validation. * fix(sharing): check share expiration on stream requests Replace the lightweight Exists() check with Get() + expiration validation, so that shares whose ExpiresAt was updated to an earlier time after token issuance are also rejected (410 Gone). Reuses the existing checkShareError handler for consistent error responses. --- core/auth/claims.go | 8 ++ core/auth/claims_test.go | 9 ++ server/public/handle_shares.go | 1 + server/public/handle_streams.go | 22 ++- server/public/handle_streams_test.go | 196 +++++++++++++++++++++++++++ tests/mock_share_repo.go | 13 ++ 6 files changed, 244 insertions(+), 5 deletions(-) create mode 100644 server/public/handle_streams_test.go diff --git a/core/auth/claims.go b/core/auth/claims.go index c0e4dea7f..c7e6f02fe 100644 --- a/core/auth/claims.go +++ b/core/auth/claims.go @@ -21,6 +21,7 @@ type Claims struct { ID string // "id" - artwork/mediafile ID Format string // "f" - audio format BitRate int // "b" - audio bitrate + ShareID string // "sid" - share ID for share stream tokens } // ToMap converts Claims to a map[string]any for use with TokenAuth.Encode(). @@ -54,6 +55,9 @@ func (c Claims) ToMap() map[string]any { if c.BitRate != 0 { m["b"] = c.BitRate } + if c.ShareID != "" { + m["sid"] = c.ShareID + } return m } @@ -92,5 +96,9 @@ func ClaimsFromToken(token jwt.Token) Claims { c.BitRate = int(bf) } } + var sid string + if err := token.Get("sid", &sid); err == nil { + c.ShareID = sid + } return c } diff --git a/core/auth/claims_test.go b/core/auth/claims_test.go index cf6b07263..8820fd295 100644 --- a/core/auth/claims_test.go +++ b/core/auth/claims_test.go @@ -28,6 +28,7 @@ var _ = Describe("Claims", func() { Expect(m).NotTo(HaveKey("id")) Expect(m).NotTo(HaveKey("f")) Expect(m).NotTo(HaveKey("b")) + Expect(m).NotTo(HaveKey("sid")) }) It("includes expiration and issued-at when set", func() { @@ -52,6 +53,12 @@ var _ = Describe("Claims", func() { Expect(m).To(HaveKeyWithValue("f", "mp3")) Expect(m).To(HaveKeyWithValue("b", 192)) }) + + It("includes share ID claim when set", func() { + c := auth.Claims{ShareID: "abc1234567"} + m := c.ToMap() + Expect(m).To(HaveKeyWithValue("sid", "abc1234567")) + }) }) Describe("ClaimsFromToken", func() { @@ -84,6 +91,7 @@ var _ = Describe("Claims", func() { ID: "al-456", Format: "opus", BitRate: 128, + ShareID: "abc1234567", } token, _, err := tokenAuth.Encode(original.ToMap()) Expect(err).NotTo(HaveOccurred()) @@ -91,6 +99,7 @@ var _ = Describe("Claims", func() { c := auth.ClaimsFromToken(token) Expect(c.Issuer).To(Equal("ND")) Expect(c.ID).To(Equal("al-456")) + Expect(c.ShareID).To(Equal("abc1234567")) Expect(c.Format).To(Equal("opus")) Expect(c.BitRate).To(Equal(128)) }) diff --git a/server/public/handle_shares.go b/server/public/handle_shares.go index 24ecff1d6..18bfcc01c 100644 --- a/server/public/handle_shares.go +++ b/server/public/handle_shares.go @@ -102,6 +102,7 @@ func encodeMediafileShare(s model.Share, id string) string { ID: id, Format: s.Format, BitRate: s.MaxBitRate, + ShareID: s.ID, } token, _ := auth.CreateExpiringPublicToken(V(s.ExpiresAt), claims) return token diff --git a/server/public/handle_streams.go b/server/public/handle_streams.go index daa09c375..7d5a836b3 100644 --- a/server/public/handle_streams.go +++ b/server/public/handle_streams.go @@ -4,11 +4,13 @@ import ( "errors" "net/http" "strconv" + "time" "github.com/navidrome/navidrome/core/auth" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + . "github.com/navidrome/navidrome/utils/gg" "github.com/navidrome/navidrome/utils/req" ) @@ -23,6 +25,18 @@ func (pub *Router) handleStream(w http.ResponseWriter, r *http.Request) { return } + if info.shareID != "" { + share, err := pub.ds.Share(ctx).Get(info.shareID) + if err != nil { + checkShareError(ctx, w, err, info.shareID) + return + } + if expiresAt := V(share.ExpiresAt); !expiresAt.IsZero() && expiresAt.Before(time.Now()) { + checkShareError(ctx, w, model.ErrExpired, info.shareID) + return + } + } + mf, err := pub.ds.MediaFile(ctx).Get(info.id) if err != nil { if errors.Is(err, model.ErrNotFound) { @@ -63,17 +77,14 @@ type shareTrackInfo struct { id string format string bitrate int + shareID string } func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { - token, err := auth.TokenAuth.Decode(tokenString) + c, err := auth.Validate(tokenString) if err != nil { return shareTrackInfo{}, err } - if token == nil { - return shareTrackInfo{}, errors.New("unauthorized") - } - c := auth.ClaimsFromToken(token) if c.ID == "" { return shareTrackInfo{}, errors.New("required claim \"id\" not found") } @@ -81,5 +92,6 @@ func decodeStreamInfo(tokenString string) (shareTrackInfo, error) { id: c.ID, format: c.Format, bitrate: c.BitRate, + shareID: c.ShareID, }, nil } diff --git a/server/public/handle_streams_test.go b/server/public/handle_streams_test.go new file mode 100644 index 000000000..222d5ef1a --- /dev/null +++ b/server/public/handle_streams_test.go @@ -0,0 +1,196 @@ +package public + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "time" + + "github.com/go-chi/jwtauth/v5" + "github.com/navidrome/navidrome/core/auth" + "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" +) + +type mockStreamer struct { + req stream.Request + called bool +} + +func (m *mockStreamer) NewStream(_ context.Context, _ *model.MediaFile, r stream.Request) (*stream.Stream, error) { + m.called = true + m.req = r + return nil, errors.New("mock: not implemented") +} + +var _ = Describe("decodeStreamInfo", func() { + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + }) + + It("decodes a valid token with all fields", func() { + claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.format).To(Equal("mp3")) + Expect(info.bitrate).To(Equal(192)) + Expect(info.shareID).To(Equal("share123")) + }) + + It("rejects an expired token", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) + }) + + It("accepts a token without exp (non-expiring share)", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.shareID).To(Equal("share123")) + }) + + It("rejects a token without an id claim", func() { + claims := auth.Claims{ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + _, err := decodeStreamInfo(token) + Expect(err).To(HaveOccurred()) + }) + + It("rejects an invalid token string", func() { + _, err := decodeStreamInfo("not-a-valid-token") + Expect(err).To(HaveOccurred()) + }) + + It("handles tokens without shareID (backward compat)", func() { + claims := auth.Claims{ID: "mf-123", Format: "opus"} + token, _ := auth.CreatePublicToken(claims) + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.id).To(Equal("mf-123")) + Expect(info.format).To(Equal("opus")) + Expect(info.shareID).To(BeEmpty()) + }) +}) + +var _ = Describe("encodeMediafileShare", func() { + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + }) + + It("includes the share ID in the token", func() { + exp := P(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) + Expect(err).NotTo(HaveOccurred()) + Expect(info.shareID).To(Equal("shareABC")) + Expect(info.id).To(Equal("mf-999")) + Expect(info.format).To(Equal("mp3")) + Expect(info.bitrate).To(Equal(320)) + }) + + It("creates a non-expiring token when share has no expiry", func() { + s := model.Share{ID: "shareXYZ", ExpiresAt: nil} + token := encodeMediafileShare(s, "mf-111") + info, err := decodeStreamInfo(token) + Expect(err).NotTo(HaveOccurred()) + Expect(info.shareID).To(Equal("shareXYZ")) + Expect(info.id).To(Equal("mf-111")) + }) +}) + +var _ = Describe("handleStream", func() { + var ds *tests.MockDataStore + var shareRepo *tests.MockShareRepo + var streamer *mockStreamer + var pub *Router + + BeforeEach(func() { + auth.TokenAuth = jwtauth.New("HS256", []byte("test-secret"), nil) + ds = &tests.MockDataStore{} + shareRepo = &tests.MockShareRepo{} + ds.MockedShare = shareRepo + streamer = &mockStreamer{} + pub = &Router{ds: ds, streamer: streamer} + }) + + makeRequest := func(token string) *httptest.ResponseRecorder { + r := httptest.NewRequest("GET", "/public/s/token?%3Aid="+token, nil) + w := httptest.NewRecorder() + pub.handleStream(w, r) + return w + } + + It("passes all validation and reaches the streamer for a valid token", func() { + shareRepo.ID = "share123" + mfRepo := tests.CreateMockMediaFileRepo() + mfRepo.SetData(model.MediaFiles{{ID: "mf-123", Title: "Test Song"}}) + ds.MockedMediaFile = mfRepo + + claims := auth.Claims{ID: "mf-123", Format: "mp3", BitRate: 192, ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + makeRequest(token) + + Expect(streamer.called).To(BeTrue()) + Expect(streamer.req.Format).To(Equal("mp3")) + Expect(streamer.req.BitRate).To(Equal(192)) + }) + + It("returns 400 for an expired token", func() { + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(-time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) + + It("returns 404 when share has been deleted", func() { + shareRepo.ID = "other-share" + claims := auth.Claims{ID: "mf-123", ShareID: "deleted-share"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + 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} + + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreatePublicToken(claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusGone)) + }) + + It("returns 500 when share lookup fails", func() { + shareRepo.Error = errors.New("db error") + claims := auth.Claims{ID: "mf-123", ShareID: "share123"} + token, _ := auth.CreateExpiringPublicToken(time.Now().Add(time.Hour), claims) + w := makeRequest(token) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + }) + + It("skips share check for tokens without shareID (backward compat)", func() { + claims := auth.Claims{ID: "mf-123"} + token, _ := auth.CreatePublicToken(claims) + w := makeRequest(token) + // Should get past share check, then fail on media file lookup (no mock data) + Expect(w.Code).To(Equal(http.StatusNotFound)) + }) + + It("returns 400 for an invalid token", func() { + w := makeRequest("not-a-valid-token") + Expect(w.Code).To(Equal(http.StatusBadRequest)) + }) +}) diff --git a/tests/mock_share_repo.go b/tests/mock_share_repo.go index 22eb9446c..9fbf0057e 100644 --- a/tests/mock_share_repo.go +++ b/tests/mock_share_repo.go @@ -44,3 +44,16 @@ func (m *MockShareRepo) Exists(id string) (bool, error) { } return id == m.ID, nil } + +func (m *MockShareRepo) Get(id string) (*model.Share, error) { + if m.Error != nil { + return nil, m.Error + } + if id != m.ID { + return nil, model.ErrNotFound + } + if s, ok := m.Entity.(*model.Share); ok { + return s, nil + } + return &model.Share{ID: id}, nil +} From fe40f7d182b5c3140a90b803f5fd243b4a64320d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 27 Apr 2026 20:19:28 -0400 Subject: [PATCH 041/117] test: enable Subsonic response snapshot tests on Windows (#5427) * fix(test): enable Subsonic response snapshot tests on Windows Replaced cupaloy with a simple custom snapshot matcher that normalizes CRLF line endings before comparison. The tests were skipped on Windows via a //go:build unix tag because Git for Windows checks out snapshot files with CRLF, while Go's xml/json.MarshalIndent always produces LF, causing direct string comparison to fail. The new matcher reads snapshot files with os.ReadFile and normalizes \r\n to \n before comparing. Also added a .gitattributes in the .snapshots directory to enforce LF checkout, and removed the now-unused cupaloy dependency. * fix(test): add UPDATE_SNAPSHOTS support to custom snapshot matcher Restore the ability to update snapshots via `make snapshots` (UPDATE_SNAPSHOTS=true), which was lost when replacing cupaloy with the custom matcher. --- go.mod | 3 +- go.sum | 5 +- .../responses/.snapshots/.gitattributes | 1 + .../responses/responses_suite_test.go | 63 +++++++++++++++---- server/subsonic/responses/responses_test.go | 5 -- 5 files changed, 54 insertions(+), 23 deletions(-) create mode 100644 server/subsonic/responses/.snapshots/.gitattributes diff --git a/go.mod b/go.mod index b7dbb9eeb..b1d7d3516 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( github.com/Masterminds/squirrel v1.5.4 github.com/andybalholm/cascadia v1.3.3 github.com/bmatcuk/doublestar/v4 v4.10.0 - github.com/bradleyjkemp/cupaloy/v2 v2.8.0 github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 github.com/dexterlb/mpvipc v0.0.0-20241005113212-7cdefca0e933 @@ -42,6 +41,7 @@ require ( github.com/onsi/ginkgo/v2 v2.28.1 github.com/onsi/gomega v1.39.1 github.com/pelletier/go-toml/v2 v2.3.0 + github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 github.com/pressly/goose/v3 v3.27.0 github.com/prometheus/client_golang v1.23.2 @@ -112,7 +112,6 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ogier/pflag v0.0.1 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.19.2 // indirect diff --git a/go.sum b/go.sum index 29b979413..6ce31dc22 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,6 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= -github.com/bradleyjkemp/cupaloy/v2 v2.8.0 h1:any4BmKE+jGIaMpnU8YgH/I2LPiLBufr6oMMlVBbn9M= -github.com/bradleyjkemp/cupaloy/v2 v2.8.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/cespare/reflex v0.3.1 h1:N4Y/UmRrjwOkNT0oQQnYsdr6YBxvHqtSfPB4mqOyAKk= github.com/cespare/reflex v0.3.1/go.mod h1:I+0Pnu2W693i7Hv6ZZG76qHTY0mgUa7uCIfCtikXojE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -205,9 +203,8 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v0.0.0-20151028094244-d8ed2627bdf0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= -github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= diff --git a/server/subsonic/responses/.snapshots/.gitattributes b/server/subsonic/responses/.snapshots/.gitattributes new file mode 100644 index 000000000..44b4224b1 --- /dev/null +++ b/server/subsonic/responses/.snapshots/.gitattributes @@ -0,0 +1 @@ +* eol=lf \ No newline at end of file diff --git a/server/subsonic/responses/responses_suite_test.go b/server/subsonic/responses/responses_suite_test.go index e10957af2..7fc10aa15 100644 --- a/server/subsonic/responses/responses_suite_test.go +++ b/server/subsonic/responses/responses_suite_test.go @@ -1,14 +1,17 @@ package responses import ( + "fmt" + "os" + "path/filepath" "strings" "testing" - "github.com/bradleyjkemp/cupaloy/v2" "github.com/navidrome/navidrome/log" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" "github.com/onsi/gomega/types" + "github.com/pmezard/go-difflib/difflib" ) func TestSubsonicApiResponses(t *testing.T) { @@ -18,25 +21,61 @@ func TestSubsonicApiResponses(t *testing.T) { } func MatchSnapshot() types.GomegaMatcher { - c := cupaloy.New(cupaloy.FailOnUpdate(false)) - return &snapshotMatcher{c} + return &snapshotMatcher{} } type snapshotMatcher struct { - c *cupaloy.Config + diff string } -func (matcher snapshotMatcher) Match(actual any) (success bool, err error) { - actualJson := strings.TrimSpace(string(actual.([]byte))) - err = matcher.c.SnapshotWithName(ginkgo.CurrentSpecReport().FullText(), actualJson) - success = err == nil - return +func (matcher *snapshotMatcher) Match(actual any) (success bool, err error) { + actualBytes, ok := actual.([]byte) + if !ok { + return false, fmt.Errorf("MatchSnapshot expects []byte, got %T", actual) + } + + name := ginkgo.CurrentSpecReport().FullText() + actualStr := strings.TrimSpace(string(actualBytes)) + + snapshotPath := filepath.Join(".snapshots", name) + + // Support UPDATE_SNAPSHOTS=true for `make snapshots` + if _, update := os.LookupEnv("UPDATE_SNAPSHOTS"); update { + err := os.MkdirAll(".snapshots", os.ModePerm) + if err != nil { + return false, err + } + return true, os.WriteFile(snapshotPath, []byte(actualStr+"\n"), 0600) + } + + expected, err := os.ReadFile(snapshotPath) + if err != nil { + return false, err + } + + // Normalize line endings so snapshots work on Windows (CRLF) and Unix (LF) + normalizedExpected := strings.ReplaceAll(string(expected), "\r\n", "\n") + normalizedExpected = strings.TrimSpace(normalizedExpected) + + if actualStr == normalizedExpected { + return true, nil + } + + diff, _ := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: difflib.SplitLines(normalizedExpected), + B: difflib.SplitLines(actualStr), + FromFile: "snapshot", + ToFile: "actual", + Context: 3, + }) + matcher.diff = diff + return false, nil } -func (matcher snapshotMatcher) FailureMessage(_ any) (message string) { - return "Expected to match saved snapshot\n" +func (matcher *snapshotMatcher) FailureMessage(_ any) (message string) { + return "Expected to match saved snapshot\n" + matcher.diff } -func (matcher snapshotMatcher) NegatedFailureMessage(_ any) (message string) { +func (matcher *snapshotMatcher) NegatedFailureMessage(_ any) (message string) { return "Expected to not match saved snapshot\n" } diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index 9910ced14..ba86deaf2 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -1,8 +1,3 @@ -//go:build unix - -// TODO Fix snapshot tests in Windows -// Response Snapshot tests. Only run in Linux and macOS, as they fail in Windows -// Probably because of EOL char differences package responses_test import ( From fd9c191c94cd7d2baabb4ac6c850704b1dd65c57 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 27 Apr 2026 20:24:01 -0400 Subject: [PATCH 042/117] chore(deps): update dependencies to latest versions Signed-off-by: Deluan --- go.mod | 8 ++++---- go.sum | 34 ++++++++++++++++------------------ 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index b1d7d3516..a4c0c014b 100644 --- a/go.mod +++ b/go.mod @@ -34,16 +34,16 @@ require ( github.com/jellydator/ttlcache/v3 v3.4.0 github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 - github.com/lestrrat-go/jwx/v3 v3.0.13 + github.com/lestrrat-go/jwx/v3 v3.1.0 github.com/mattn/go-sqlite3 v1.14.42 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 - github.com/onsi/ginkgo/v2 v2.28.1 + github.com/onsi/ginkgo/v2 v2.28.2 github.com/onsi/gomega v1.39.1 github.com/pelletier/go-toml/v2 v2.3.0 github.com/pmezard/go-difflib v1.0.0 github.com/pocketbase/dbx v1.12.0 - github.com/pressly/goose/v3 v3.27.0 + github.com/pressly/goose/v3 v3.27.1 github.com/prometheus/client_golang v1.23.2 github.com/rjeczalik/notify v0.9.3 github.com/robfig/cron/v3 v3.0.1 @@ -114,7 +114,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.19.2 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect github.com/sanity-io/litter v1.5.8 // indirect diff --git a/go.sum b/go.sum index 6ce31dc22..3e665ba02 100644 --- a/go.sum +++ b/go.sum @@ -139,8 +139,8 @@ github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2Og github.com/kardianos/service v1.2.4/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs= github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= @@ -167,14 +167,14 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= -github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk= -github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= +github.com/lestrrat-go/jwx/v3 v3.1.0 h1:AyyLtxc0QM75F75JroWgt1phwC7X+wOb3XKhH7XBZWw= +github.com/lestrrat-go/jwx/v3 v3.1.0/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= +github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= @@ -193,8 +193,8 @@ 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.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI= -github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= +github.com/onsi/ginkgo/v2 v2.28.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps= +github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= @@ -207,16 +207,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA= github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= -github.com/pressly/goose/v3 v3.27.0 h1:/D30gVTuQhu0WsNZYbJi4DMOsx1lNq+6SkLe+Wp59BM= -github.com/pressly/goose/v3 v3.27.0/go.mod h1:3ZBeCXqzkgIRvrEMDkYh1guvtoJTU5oMMuDdkutoM78= +github.com/pressly/goose/v3 v3.27.1 h1:6uEvcprBybDmW4hcz3gYujhARhye+GoWKhEWyzD5sh4= +github.com/pressly/goose/v3 v3.27.1/go.mod h1:maruOxsPnIG2yHHyo8UqKWXYKFcH7Q76csUV7+7KYoM= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rjeczalik/notify v0.9.3 h1:6rJAzHTGKXGj76sbRgDiDcYj/HniypXmSJo1SWakZeY= @@ -318,8 +318,6 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= -golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -419,11 +417,11 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= -modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= +modernc.org/libc v1.72.1 h1:db1xwJ6u1kE3KHTFTTbe2GCrczHPKzlURP0aDC4NGD0= +modernc.org/libc v1.72.1/go.mod h1:HRMiC/PhPGLIPM7GzAFCbI+oSgE3dhZ8FWftmRrHVlY= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= -modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/sqlite v1.49.1 h1:dYGHTKcX1sJ+EQDnUzvz4TJ5GbuvhNJa8Fg6ElGx73U= +modernc.org/sqlite v1.49.1/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew= From 028a5473a39b7f7f65ec48e26bff24e09e77d7b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 27 Apr 2026 21:45:13 -0400 Subject: [PATCH 043/117] fix(ui): prevent autoplay when clearing the play queue (#5430) When clearing the queue, the reducer resets to initialState which lacks an autoPlay field (undefined). The autoPlay option was computed as true because undefined !== false, causing the music player library to attempt playback of the first song before internal state was fully cleared. Added a queue.length > 0 guard to the autoPlay calculation so it is never true when the queue is empty, regardless of other state flags. Fixes #5331 --- ui/src/audioplayer/Player.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index eba3b82d7..5599b9e1d 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -210,6 +210,7 @@ const Player = () => { audioLists: playerState.queue.map((item) => item), playIndex: playerState.playIndex, autoPlay: + playerState.queue.length > 0 && playerState.autoPlay !== false && (playerState.clear || playerState.playIndex === 0), clearPriorAudioLists: playerState.clear, From b7cc395895f5a87669872f84690eace437b3f363 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 28 Apr 2026 19:40:08 -0400 Subject: [PATCH 044/117] feat(smartplaylist): add isMissing and isPresent operators (#5436) * feat(smartplaylist): add IsMissing and IsPresent operator types Add two new Expression types for detecting absent/present tags and roles in smart playlist criteria. Includes JSON marshal/unmarshal support and Walk visitor registration. * test(smartplaylist): add JSON marshal/unmarshal tests for isMissing/isPresent * feat(smartplaylist): add SQL generation for isMissing/isPresent operators Tags check json_tree(media_file.tags) for key existence. Roles check json_tree(media_file.participants) for key existence. Regular DB column fields are rejected with an error. * test(smartplaylist): add e2e tests for isMissing/isPresent operators Tests cover tag presence/absence with selective matching (grouping), universal absence (lyricist role), universal presence (composer role), and combined operator usage. * refactor(smartplaylist): use strconv.ParseBool in IsTruthy Replace hand-rolled string truthiness check with strconv.ParseBool, which correctly handles standard boolean strings and rejects unrecognized values as false. * refactor(smartplaylist): clarify missingExpr parameter naming Rename defaultNegate to checkAbsence and extract truthy local for readability. The XNOR logic (checkAbsence == truthy) is now easier to follow: isMissing passes true, isPresent passes false. * refactor(smartplaylist): reuse jsonExpr in missingExpr, improve errors - tagCond/roleCond now handle nil cond (existence-only check) - missingExpr delegates to jsonExpr(info, nil, negate) instead of building SQL manually - Better error messages: unknown fields now report the field name --- model/criteria/json.go | 4 +++ model/criteria/operators.go | 35 +++++++++++++++++++- model/criteria/operators_test.go | 4 +++ model/criteria/walk.go | 2 +- persistence/criteria_sql.go | 46 +++++++++++++++++++++++---- persistence/criteria_sql_test.go | 30 +++++++++++++++++ persistence/e2e/e2e_suite_test.go | 4 +-- persistence/e2e/smartplaylist_test.go | 41 ++++++++++++++++++++++++ 8 files changed, 156 insertions(+), 10 deletions(-) diff --git a/model/criteria/json.go b/model/criteria/json.go index f6ab56eda..18a664988 100644 --- a/model/criteria/json.go +++ b/model/criteria/json.go @@ -69,6 +69,10 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { return InPlaylist(m) case "notinplaylist": return NotInPlaylist(m) + case "ismissing": + return IsMissing(m) + case "ispresent": + return IsPresent(m) } return nil } diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 983c6aa1a..7def18934 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,6 +1,9 @@ package criteria -import "time" +import ( + "strconv" + "time" +) // Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively type conjunction interface { @@ -162,6 +165,36 @@ func (nipl NotInPlaylist) MarshalJSON() ([]byte, error) { func (nipl NotInPlaylist) fields() map[string]any { return nipl } +type IsMissing map[string]any + +func (im IsMissing) MarshalJSON() ([]byte, error) { + return marshalExpression("isMissing", im) +} + +func (im IsMissing) fields() map[string]any { return im } + +type IsPresent map[string]any + +func (ip IsPresent) MarshalJSON() ([]byte, error) { + return marshalExpression("isPresent", ip) +} + +func (ip IsPresent) fields() map[string]any { return ip } + +func IsTruthy(v any) bool { + switch val := v.(type) { + case bool: + return val + case float64: + return val != 0 + case string: + b, err := strconv.ParseBool(val) + return err == nil && b + default: + return v != nil + } +} + func extractPlaylistIds(inputRule any) (ids []string) { var id string var ok bool diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index c93e8f2b2..bfdca3e31 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -46,5 +46,9 @@ var _ = Describe("Operators", func() { Entry("notInTheLast", NotInTheLast{"lastPlayed": 30.0}, `{"notInTheLast":{"lastPlayed":30}}`), Entry("inPlaylist", InPlaylist{"id": "deadbeef-dead-beef"}, `{"inPlaylist":{"id":"deadbeef-dead-beef"}}`), Entry("notInPlaylist", NotInPlaylist{"id": "deadbeef-dead-beef"}, `{"notInPlaylist":{"id":"deadbeef-dead-beef"}}`), + Entry("isMissing [true]", IsMissing{"genre": true}, `{"isMissing":{"genre":true}}`), + Entry("isMissing [false]", IsMissing{"genre": false}, `{"isMissing":{"genre":false}}`), + Entry("isPresent [true]", IsPresent{"genre": true}, `{"isPresent":{"genre":true}}`), + Entry("isPresent [false]", IsPresent{"genre": false}, `{"isPresent":{"genre":false}}`), ) }) diff --git a/model/criteria/walk.go b/model/criteria/walk.go index acaf48289..7445c2aef 100644 --- a/model/criteria/walk.go +++ b/model/criteria/walk.go @@ -24,7 +24,7 @@ func Walk(expr Expression, visit Visitor) error { return err } } - case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist: + case Is, IsNot, Gt, Lt, Before, After, Contains, NotContains, StartsWith, EndsWith, InTheRange, InTheLast, NotInTheLast, InPlaylist, NotInPlaylist, IsMissing, IsPresent: return nil default: return fmt.Errorf("unknown criteria expression type %T", expr) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 1431f0d0e..8fca6b2a2 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -185,6 +185,10 @@ func (c smartPlaylistCriteria) exprSQL(expr criteria.Expression) (squirrel.Sqliz return c.inList(e, false) case criteria.NotInPlaylist: return c.inList(e, true) + case criteria.IsMissing: + return missingExpr(e, true) + case criteria.IsPresent: + return missingExpr(e, false) default: return nil, fmt.Errorf("unknown criteria expression type %T", expr) } @@ -201,6 +205,22 @@ func isNotExpr(values map[string]any) (squirrel.Sqlizer, error) { return squirrel.NotEq(fields), nil } +func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, error) { + field, value, info, ok := singleField(values) + if !ok { + if len(values) != 1 { + return nil, fmt.Errorf("invalid field in criteria: isMissing/isPresent requires exactly one field") + } + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + if !info.IsTag && !info.IsRole { + return nil, fmt.Errorf("isMissing/isPresent operator is only supported for tag and role fields, got: %s", field) + } + + negate := checkAbsence == criteria.IsTruthy(value) + return jsonExpr(info, nil, negate), nil +} + func mapExpr(values map[string]any, makeCond func(map[string]any) squirrel.Sqlizer, negateJSON bool) (squirrel.Sqlizer, error) { if _, value, info, ok := singleField(values); ok && (info.IsTag || info.IsRole) { return jsonExpr(info, makeCond(map[string]any{"value": value}), negateJSON), nil @@ -327,11 +347,18 @@ type tagCond struct { } func (e tagCond) ToSql() (string, []any, error) { - cond, args, err := e.cond.ToSql() - if e.numeric { - cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + var cond string + var args []any + var err error + if e.cond != nil { + cond, args, err = e.cond.ToSql() + if e.numeric { + cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)") + } + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond) + } else { + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value')", e.tag) } - cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.tags, '$.%s') where key='value' and %s)", e.tag, cond) if e.not { cond = "not " + cond } @@ -345,8 +372,15 @@ type roleCond struct { } func (e roleCond) ToSql() (string, []any, error) { - 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) + 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) + } else { + cond = fmt.Sprintf("exists (select 1 from json_tree(media_file.participants, '$.%s') where key='name')", e.role) + } if e.not { cond = "not " + cond } diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index e02032d9a..a2212c4e5 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -59,6 +59,26 @@ var _ = Describe("Smart playlist criteria SQL", func() { 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%"), + // isMissing — tags + Entry("isMissing tag [true]", criteria.IsMissing{"genre": true}, + "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + Entry("isMissing tag [false]", criteria.IsMissing{"genre": false}, + "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')"), + Entry("isMissing role [false]", criteria.IsMissing{"artist": false}, + "exists (select 1 from json_tree(media_file.participants, '$.artist') where key='name')"), + // isPresent — tags + Entry("isPresent tag [true]", criteria.IsPresent{"genre": true}, + "exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), + Entry("isPresent tag [false]", criteria.IsPresent{"genre": false}, + "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')"), + Entry("isPresent role [false]", criteria.IsPresent{"composer": false}, + "not exists (select 1 from json_tree(media_file.participants, '$.composer') where key='name')"), ) Describe("playlist permissions", func() { @@ -115,6 +135,16 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(err).To(MatchError("invalid field in criteria: unknown")) }) + It("returns an error when isMissing is used with a regular field", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where() + Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) + }) + + It("returns an error when isPresent is used with a regular field", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where() + Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) + }) + Describe("sort", func() { It("sorts by regular fields", func() { Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc")) diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go index ea0d0fea8..f42292f02 100644 --- a/persistence/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -116,9 +116,9 @@ func buildTestFS() { fs := storagetest.FakeFS{} fs.SetFiles(fstest.MapFS{ "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together", - _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120})), + _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks"})), "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", - _t{"genre": "Rock", "composer": "Harrison", "bpm": 100})), + _t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks"})), "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.flac": ledZepIV(track(1, "Stairway To Heaven", _t{"genre": "Rock;Folk", "composer": "Page/Plant", "bpm": 82, "suffix": "flac", "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go index 086e73703..a844dc982 100644 --- a/persistence/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -330,4 +330,45 @@ var _ = Describe("Smart Playlists", func() { }) }) + + Describe("isMissing/isPresent operators", func() { + It("isMissing finds tracks without grouping tag", func() { + results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isMissing false finds tracks with grouping tag", func() { + results := evaluateRule(`{"all":[{"isMissing":{"grouping":false}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isPresent finds tracks with grouping tag", func() { + results := evaluateRule(`{"all":[{"isPresent":{"grouping":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isPresent false finds tracks without grouping tag", func() { + results := evaluateRule(`{"all":[{"isPresent":{"grouping":false}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isMissing returns all tracks for a tag nobody has", func() { + results := evaluateRule(`{"all":[{"isMissing":{"lyricist":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isPresent returns all tracks for a role everyone has", func() { + results := evaluateRule(`{"all":[{"isPresent":{"composer":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven", "Black Dog", + "So What", "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("combines isMissing with other operators", func() { + results := evaluateRule(`{"all":[{"isMissing":{"grouping":true}},{"is":{"genre":"Blues"}}]}`) + Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower")) + }) + }) }) From 6c065870b5191ef8fb46db8f226bf143753f396f Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 28 Apr 2026 19:46:12 -0400 Subject: [PATCH 045/117] feat(smartplaylist): add ReplayGain fields to criteria system Expose the four ReplayGain database columns (rg_album_gain, rg_album_peak, rg_track_gain, rg_track_peak) as first-class numeric criteria fields for smart playlists. This allows users to filter tracks by ReplayGain values, e.g. finding tracks with album gain below a threshold. --- model/criteria/fields.go | 4 ++++ persistence/criteria_sql.go | 4 ++++ persistence/criteria_sql_test.go | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 20c0048b3..37b4cc017 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -72,6 +72,10 @@ var fieldMap = map[string]FieldInfo{ "mbz_recording_id": {Name: "mbz_recording_id"}, "mbz_release_track_id": {Name: "mbz_release_track_id"}, "mbz_release_group_id": {Name: "mbz_release_group_id"}, + "rgalbumgain": {Name: "rgalbumgain", Numeric: true}, + "rgalbumpeak": {Name: "rgalbumpeak", Numeric: true}, + "rgtrackgain": {Name: "rgtrackgain", Numeric: true}, + "rgtrackpeak": {Name: "rgtrackpeak", Numeric: true}, "library_id": {Name: "library_id", Numeric: true}, // Backward compatibility: albumtype is an alias for the releasetype tag. diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 8fca6b2a2..123ec29eb 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -111,6 +111,10 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "mbz_recording_id": {expr: "media_file.mbz_recording_id"}, "mbz_release_track_id": {expr: "media_file.mbz_release_track_id"}, "mbz_release_group_id": {expr: "media_file.mbz_release_group_id"}, + "rgalbumgain": {expr: "media_file.rg_album_gain"}, + "rgalbumpeak": {expr: "media_file.rg_album_peak"}, + "rgtrackgain": {expr: "media_file.rg_track_gain"}, + "rgtrackpeak": {expr: "media_file.rg_track_peak"}, "library_id": {expr: "media_file.library_id"}, "random": {order: "random()"}, "value": {expr: "value"}, diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index a2212c4e5..c93465735 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -59,6 +59,10 @@ var _ = Describe("Smart playlist criteria SQL", func() { 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%"), + // 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), + Entry("rgTrackPeak lt", criteria.Lt{"rgTrackPeak": 1.0}, "media_file.rg_track_peak < ?", 1.0), // isMissing — tags Entry("isMissing tag [true]", criteria.IsMissing{"genre": true}, "not exists (select 1 from json_tree(media_file.tags, '$.genre') where key='value')"), From 25b604905ceca4969ce518995c1b19fc66fe8735 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 28 Apr 2026 20:22:48 -0400 Subject: [PATCH 046/117] refactor(smartplaylist): clarify FieldInfo naming in criteria package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename FieldInfo.Name to Alias (only meaningful for backward-compat entries like albumtype→releasetype) and FieldInfo.alias to tagAlias (tag names from mappings.yml that resolve to existing fields). Add a Name() method that derives the canonical name from the map key, eliminating 60+ redundant Name declarations where the value matched the key. LookupField now populates the private name field automatically. Signed-off-by: Deluan --- model/criteria/fields.go | 171 +++++++++++++++++-------------- model/criteria/fields_test.go | 12 +-- model/criteria/sort.go | 2 +- persistence/criteria_sql.go | 16 +-- persistence/criteria_sql_test.go | 4 +- 5 files changed, 111 insertions(+), 94 deletions(-) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 37b4cc017..44d43457b 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -2,87 +2,94 @@ package criteria import "strings" -// FieldInfo contains semantic metadata about a criteria field +// FieldInfo contains semantic metadata about a criteria field. type FieldInfo struct { - Name string + Alias string // If set, this field is a backward-compat alias for another canonical name IsTag bool IsRole bool Numeric bool - alias string + + tagAlias string // If set, a tag name from mappings.yml that resolves to this field + name string // Canonical name, populated by LookupField from the map key +} + +// Name returns the canonical field name (the map key used to register this field). +func (f FieldInfo) Name() string { + return f.name } var fieldMap = map[string]FieldInfo{ - "title": {Name: "title"}, - "album": {Name: "album"}, - "hascoverart": {Name: "hascoverart"}, - "tracknumber": {Name: "tracknumber"}, - "discnumber": {Name: "discnumber"}, - "year": {Name: "year"}, - "date": {Name: "date", alias: "recordingdate"}, - "originalyear": {Name: "originalyear"}, - "originaldate": {Name: "originaldate"}, - "releaseyear": {Name: "releaseyear"}, - "releasedate": {Name: "releasedate"}, - "size": {Name: "size"}, - "compilation": {Name: "compilation"}, - "missing": {Name: "missing"}, - "explicitstatus": {Name: "explicitstatus"}, - "dateadded": {Name: "dateadded"}, - "datemodified": {Name: "datemodified"}, - "discsubtitle": {Name: "discsubtitle"}, - "comment": {Name: "comment"}, - "lyrics": {Name: "lyrics"}, - "sorttitle": {Name: "sorttitle"}, - "sortalbum": {Name: "sortalbum"}, - "sortartist": {Name: "sortartist"}, - "sortalbumartist": {Name: "sortalbumartist"}, - "albumcomment": {Name: "albumcomment"}, - "catalognumber": {Name: "catalognumber"}, - "filepath": {Name: "filepath"}, - "filetype": {Name: "filetype"}, - "codec": {Name: "codec"}, - "duration": {Name: "duration"}, - "bitrate": {Name: "bitrate"}, - "bitdepth": {Name: "bitdepth"}, - "samplerate": {Name: "samplerate"}, - "bpm": {Name: "bpm"}, - "channels": {Name: "channels"}, - "loved": {Name: "loved"}, - "dateloved": {Name: "dateloved"}, - "lastplayed": {Name: "lastplayed"}, - "daterated": {Name: "daterated"}, - "playcount": {Name: "playcount"}, - "rating": {Name: "rating"}, - "averagerating": {Name: "averagerating", Numeric: true}, - "albumrating": {Name: "albumrating"}, - "albumloved": {Name: "albumloved"}, - "albumplaycount": {Name: "albumplaycount"}, - "albumlastplayed": {Name: "albumlastplayed"}, - "albumdateloved": {Name: "albumdateloved"}, - "albumdaterated": {Name: "albumdaterated"}, - "artistrating": {Name: "artistrating"}, - "artistloved": {Name: "artistloved"}, - "artistplaycount": {Name: "artistplaycount"}, - "artistlastplayed": {Name: "artistlastplayed"}, - "artistdateloved": {Name: "artistdateloved"}, - "artistdaterated": {Name: "artistdaterated"}, - "mbz_album_id": {Name: "mbz_album_id"}, - "mbz_album_artist_id": {Name: "mbz_album_artist_id"}, - "mbz_artist_id": {Name: "mbz_artist_id"}, - "mbz_recording_id": {Name: "mbz_recording_id"}, - "mbz_release_track_id": {Name: "mbz_release_track_id"}, - "mbz_release_group_id": {Name: "mbz_release_group_id"}, - "rgalbumgain": {Name: "rgalbumgain", Numeric: true}, - "rgalbumpeak": {Name: "rgalbumpeak", Numeric: true}, - "rgtrackgain": {Name: "rgtrackgain", Numeric: true}, - "rgtrackpeak": {Name: "rgtrackpeak", Numeric: true}, - "library_id": {Name: "library_id", Numeric: true}, + "title": {}, + "album": {}, + "hascoverart": {}, + "tracknumber": {}, + "discnumber": {}, + "year": {}, + "date": {tagAlias: "recordingdate"}, + "originalyear": {}, + "originaldate": {}, + "releaseyear": {}, + "releasedate": {}, + "size": {}, + "compilation": {}, + "missing": {}, + "explicitstatus": {}, + "dateadded": {}, + "datemodified": {}, + "discsubtitle": {}, + "comment": {}, + "lyrics": {}, + "sorttitle": {}, + "sortalbum": {}, + "sortartist": {}, + "sortalbumartist": {}, + "albumcomment": {}, + "catalognumber": {}, + "filepath": {}, + "filetype": {}, + "codec": {}, + "duration": {}, + "bitrate": {}, + "bitdepth": {}, + "samplerate": {}, + "bpm": {}, + "channels": {}, + "loved": {}, + "dateloved": {}, + "lastplayed": {}, + "daterated": {}, + "playcount": {}, + "rating": {}, + "averagerating": {Numeric: true}, + "albumrating": {}, + "albumloved": {}, + "albumplaycount": {}, + "albumlastplayed": {}, + "albumdateloved": {}, + "albumdaterated": {}, + "artistrating": {}, + "artistloved": {}, + "artistplaycount": {}, + "artistlastplayed": {}, + "artistdateloved": {}, + "artistdaterated": {}, + "mbz_album_id": {}, + "mbz_album_artist_id": {}, + "mbz_artist_id": {}, + "mbz_recording_id": {}, + "mbz_release_track_id": {}, + "mbz_release_group_id": {}, + "rgalbumgain": {Numeric: true}, + "rgalbumpeak": {Numeric: true}, + "rgtrackgain": {Numeric: true}, + "rgtrackpeak": {Numeric: true}, + "library_id": {Numeric: true}, // Backward compatibility: albumtype is an alias for the releasetype tag. - "albumtype": {Name: "releasetype", IsTag: true}, + "albumtype": {Alias: "releasetype", IsTag: true}, - "random": {Name: "random"}, - "value": {Name: "value"}, + "random": {}, + "value": {}, } // AllFieldNames returns the names of all registered criteria fields. @@ -96,7 +103,15 @@ func AllFieldNames() []string { // LookupField returns semantic metadata for a criteria field name. func LookupField(name string) (FieldInfo, bool) { - f, ok := fieldMap[strings.ToLower(name)] + key := strings.ToLower(name) + f, ok := fieldMap[key] + if ok { + if f.Alias != "" { + f.name = f.Alias + } else { + f.name = key + } + } return f, ok } @@ -108,7 +123,7 @@ func AddRoles(roles []string) { if _, ok := fieldMap[name]; ok { continue } - fieldMap[name] = FieldInfo{Name: name, IsRole: true} + fieldMap[name] = FieldInfo{IsRole: true} } } @@ -120,14 +135,16 @@ func AddTagNames(tagNames []string) { if _, ok := fieldMap[name]; ok { continue } - for _, fm := range fieldMap { - if fm.alias == name { + for key, fm := range fieldMap { + if fm.tagAlias == name { + fm.Alias = key + fm.tagAlias = "" fieldMap[name] = fm break } } if _, ok := fieldMap[name]; !ok { - fieldMap[name] = FieldInfo{Name: name, IsTag: true} + fieldMap[name] = FieldInfo{IsTag: true} } } } @@ -140,7 +157,7 @@ func AddNumericTags(tagNames []string) { fm.Numeric = true fieldMap[name] = fm } else { - fieldMap[name] = FieldInfo{Name: name, IsTag: true, Numeric: true} + fieldMap[name] = FieldInfo{IsTag: true, Numeric: true} } } } diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index ecbeb5857..f60666788 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -11,14 +11,14 @@ var _ = Describe("fields", func() { field, ok := LookupField("Title") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field).To(gomega.Equal(FieldInfo{Name: "title"})) + gomega.Expect(field.Name()).To(gomega.Equal("title")) }) - It("resolves aliases to their semantic field name", func() { + It("resolves aliases to their canonical field name", func() { field, ok := LookupField("albumtype") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name).To(gomega.Equal("releasetype")) + gomega.Expect(field.Name()).To(gomega.Equal("releasetype")) gomega.Expect(field.IsTag).To(gomega.BeTrue()) }) @@ -26,7 +26,7 @@ var _ = Describe("fields", func() { field, ok := LookupField("value") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name).To(gomega.Equal("value")) + gomega.Expect(field.Name()).To(gomega.Equal("value")) }) It("finds registered tag names", func() { @@ -35,7 +35,7 @@ var _ = Describe("fields", func() { field, ok := LookupField("task3_mood") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name).To(gomega.Equal("task3_mood")) + gomega.Expect(field.Name()).To(gomega.Equal("task3_mood")) gomega.Expect(field.IsTag).To(gomega.BeTrue()) }) @@ -56,7 +56,7 @@ var _ = Describe("fields", func() { field, ok := LookupField("task3_producer") gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name).To(gomega.Equal("task3_producer")) + gomega.Expect(field.Name()).To(gomega.Equal("task3_producer")) gomega.Expect(field.IsRole).To(gomega.BeTrue()) }) }) diff --git a/model/criteria/sort.go b/model/criteria/sort.go index 05b108cf9..e38fe0551 100644 --- a/model/criteria/sort.go +++ b/model/criteria/sort.go @@ -43,7 +43,7 @@ func (c Criteria) OrderByFields() []SortField { if order == "desc" { desc = !desc } - fields = append(fields, SortField{Field: info.Name, Desc: desc}) + fields = append(fields, SortField{Field: info.Name(), Desc: desc}) } if len(fields) == 0 { log.Warn("No valid sort fields found in 'sort', falling back to 'title'", "sort", sortValue) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 123ec29eb..4fd8e62e6 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -338,9 +338,9 @@ func (c smartPlaylistCriteria) inList(values map[string]any, negate bool) (squir func jsonExpr(info criteria.FieldInfo, cond squirrel.Sqlizer, negate bool) squirrel.Sqlizer { if info.IsRole { - return roleCond{role: info.Name, cond: cond, not: negate} + return roleCond{role: info.Name(), cond: cond, not: negate} } - return tagCond{tag: info.Name, numeric: info.Numeric, cond: cond, not: negate} + return tagCond{tag: info.Name(), numeric: info.Numeric, cond: cond, not: negate} } type tagCond struct { @@ -412,7 +412,7 @@ func sqlFields(values map[string]any) (map[string]any, error) { if info.IsTag || info.IsRole { return nil, fmt.Errorf("tag and role criteria must contain exactly one field: %s", field) } - sqlField, ok := fieldExpr(info.Name) + sqlField, ok := fieldExpr(info.Name()) if !ok || sqlField == "" { return nil, fmt.Errorf("invalid field in criteria: %s", field) } @@ -431,7 +431,7 @@ func fieldJoinType(name string) smartPlaylistJoinType { if !ok { return smartPlaylistJoinNone } - field, ok := smartPlaylistFields[info.Name] + field, ok := smartPlaylistFields[info.Name()] if !ok { return smartPlaylistJoinNone } @@ -479,17 +479,17 @@ func sortExpr(sortField string) (string, bool) { if !ok { return "", false } - if field, ok := smartPlaylistFields[info.Name]; ok && field.order != "" { + if field, ok := smartPlaylistFields[info.Name()]; ok && field.order != "" { return field.order, true } var mapped string switch { case info.IsTag: - mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name + "[0].value'), '')" + mapped = "COALESCE(json_extract(media_file.tags, '$." + info.Name() + "[0].value'), '')" case info.IsRole: - mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name + "[0].name'), '')" + mapped = "COALESCE(json_extract(media_file.participants, '$." + info.Name() + "[0].name'), '')" default: - field, ok := smartPlaylistFields[info.Name] + field, ok := smartPlaylistFields[info.Name()] if !ok || field.expr == "" { return "", false } diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index c93465735..40d21c9bf 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -194,8 +194,8 @@ var _ = Describe("Smart playlist criteria SQL", func() { if info.IsTag || info.IsRole { continue } - _, hasSQLField := smartPlaylistFields[info.Name] - Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name) + _, hasSQLField := smartPlaylistFields[info.Name()] + Expect(hasSQLField).To(BeTrue(), "criteria field %q (name=%q) has no entry in smartPlaylistFields", name, info.Name()) } }) From 76510b1251adc1b5cb93452d1ceed2f76bd2f54d Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 28 Apr 2026 20:44:54 -0400 Subject: [PATCH 047/117] refactor(smartplaylist): remove unused 'value' field and clarify 'random' usage Signed-off-by: Deluan --- model/criteria/fields.go | 2 +- model/criteria/fields_test.go | 7 ------- persistence/criteria_sql.go | 1 - 3 files changed, 1 insertion(+), 9 deletions(-) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 44d43457b..b35913f28 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -88,8 +88,8 @@ var fieldMap = map[string]FieldInfo{ // Backward compatibility: albumtype is an alias for the releasetype tag. "albumtype": {Alias: "releasetype", IsTag: true}, + // Pseudo-field for random sorting "random": {}, - "value": {}, } // AllFieldNames returns the names of all registered criteria fields. diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index f60666788..270a14473 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -22,13 +22,6 @@ var _ = Describe("fields", func() { gomega.Expect(field.IsTag).To(gomega.BeTrue()) }) - It("finds special fields", func() { - field, ok := LookupField("value") - - gomega.Expect(ok).To(gomega.BeTrue()) - gomega.Expect(field.Name()).To(gomega.Equal("value")) - }) - It("finds registered tag names", func() { AddTagNames([]string{"task3_mood"}) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 4fd8e62e6..9a99cfa8e 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -117,7 +117,6 @@ var smartPlaylistFields = map[string]smartPlaylistField{ "rgtrackpeak": {expr: "media_file.rg_track_peak"}, "library_id": {expr: "media_file.library_id"}, "random": {order: "random()"}, - "value": {expr: "value"}, } func (c smartPlaylistCriteria) Where() (squirrel.Sqlizer, error) { From 6ec3b43336d7a0a33428d657fb74e72e535a47ff Mon Sep 17 00:00:00 2001 From: Daniel Barrientos Anariba <69573860+danielbanariba@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:48:59 -0600 Subject: [PATCH 048/117] fix(ui): show album tile actions on keyboard focus (#5434) * fix(ui): show album tile actions on keyboard focus - #4836 The album grid tile bar (containing play/heart/context-menu buttons) had opacity:0 by default and only became visible on mouse :hover. Keyboard users tabbing through the album grid never saw which tile was focused and could not discover the available actions. Mirrors the existing :hover rule with :focus-within, which matches when the link itself or any descendant (e.g. the play button) has focus. Signed-off-by: Daniel Banariba * fix(ui): also disable pointer events on hidden album tile bar - #4836 Per review feedback (@gemini-code-assist): the tileBar's buttons remained clickable even at opacity:0, causing accidental Play/Menu triggers when a user clicked what looked like the album cover. Set 'pointerEvents: none' on the base tileBar and restore 'auto' on the same selector that turns it visible. Signed-off-by: Daniel Banariba --------- Signed-off-by: Daniel Banariba --- ui/src/album/AlbumGridView.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/src/album/AlbumGridView.jsx b/ui/src/album/AlbumGridView.jsx index 9717618fa..33db9e40f 100644 --- a/ui/src/album/AlbumGridView.jsx +++ b/ui/src/album/AlbumGridView.jsx @@ -34,6 +34,7 @@ const useStyles = makeStyles( tileBar: { transition: 'all 150ms ease-out', opacity: 0, + pointerEvents: 'none', textAlign: 'left', background: 'linear-gradient(to top, rgba(0,0,0,0.7) 0%,rgba(0,0,0,0.4) 70%,rgba(0,0,0,0) 100%)', @@ -78,8 +79,9 @@ const useStyles = makeStyles( position: 'relative', display: 'block', textDecoration: 'none', - '&:hover $tileBar': { + '&:hover $tileBar, &:focus-within $tileBar': { opacity: 1, + pointerEvents: 'auto', }, }, albumLink: { From 9771283ed0c4c277006e84302d26f680652aa981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 29 Apr 2026 15:36:39 -0400 Subject: [PATCH 049/117] fix(ui): start new album from track 1 after closing player (#5441) When a user played an album, advanced a few tracks, closed the player, then played a different album, playback started mid-album at the previous track index instead of track 1. The root cause was in reduceSyncQueue: the hasPendingSwitch check compared playIndex against savedPlayIndex, but after clearQueue both reset to 0, making the check falsely conclude no switch was pending. This caused PLAYER_SYNC_QUEUE to prematurely clear the playIndex and clear flags before the music player library could act on them. Fix: also treat clear=true as a signal that a track switch is pending, since it means a new queue was just loaded. --- ui/src/reducers/playerReducer.js | 14 +++-- ui/src/reducers/playerReducer.test.js | 82 +++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/ui/src/reducers/playerReducer.js b/ui/src/reducers/playerReducer.js index 466a3ec87..d6ab7484b 100644 --- a/ui/src/reducers/playerReducer.js +++ b/ui/src/reducers/playerReducer.js @@ -164,13 +164,15 @@ const reduceSetVolume = (state, { data: { volume } }) => { } const reduceSyncQueue = (state, { data: { audioInfo, audioLists } }) => { - // Only keep clear and playIndex alive when there is an actual pending - // track switch (playIndex differs from savedPlayIndex). This lets - // PLAYER_PLAY_TRACKS selections survive the sync, while allowing - // PLAYER_PLAY_NEXT (which sets playIndex to the current track) to - // reset immediately and avoid restarting playback. + // Keep clear and playIndex alive when there is a pending track switch. + // A switch is pending when playIndex is set AND either: + // - playIndex differs from savedPlayIndex, OR + // - clear is true (a new queue was loaded, e.g. after clearQueue + playTracks) + // The clear check handles the edge case where both playIndex and + // savedPlayIndex are 0 (close player then play a new album from track 1). const hasPendingSwitch = - state.playIndex != null && state.playIndex !== state.savedPlayIndex + state.playIndex != null && + (state.clear || state.playIndex !== state.savedPlayIndex) return { ...state, queue: audioLists, diff --git a/ui/src/reducers/playerReducer.test.js b/ui/src/reducers/playerReducer.test.js index 10e9512d7..110ce8c53 100644 --- a/ui/src/reducers/playerReducer.test.js +++ b/ui/src/reducers/playerReducer.test.js @@ -96,6 +96,88 @@ describe('playerReducer', () => { }) }) + describe('play new album after closing player (issue #5440)', () => { + it('SYNC_QUEUE preserves pending playIndex=0 after clearQueue', () => { + // Scenario: user plays album A, advances to track 3, closes player, + // then plays album B. After clearQueue, savedPlayIndex=0. + // PLAYER_PLAY_TRACKS sets playIndex=0. SYNC_QUEUE must NOT clear it. + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + { trackId: 'b3', uuid: 'u3', name: 'B Song 3' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, // reset by clearQueue + clear: true, + volume: 1, + } + + const action = { + type: PLAYER_SYNC_QUEUE, + data: { + audioInfo: {}, + audioLists: stateAfterClearThenPlay.queue, + }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBe(0) + expect(result.clear).toBe(true) + }) + + it('CURRENT for wrong track preserves pending playIndex=0 after clearQueue', () => { + // The music player fires onAudioPlay for the old track (at index 3) + // before switching to the new track at index 0. + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + { trackId: 'b3', uuid: 'u3', name: 'B Song 3' }, + { trackId: 'b4', uuid: 'u4', name: 'B Song 4' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, + clear: true, + volume: 1, + } + + // Player reports track at index 3 as current (stale callback) + const action = { + type: PLAYER_CURRENT, + data: { uuid: 'u4', name: 'B Song 4', volume: 1 }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBe(0) + expect(result.clear).toBe(true) + }) + + it('CURRENT for correct track consumes pending playIndex=0', () => { + const stateAfterClearThenPlay = { + queue: [ + { trackId: 'b1', uuid: 'u1', name: 'B Song 1' }, + { trackId: 'b2', uuid: 'u2', name: 'B Song 2' }, + ], + current: {}, + playIndex: 0, + savedPlayIndex: 0, + clear: true, + volume: 1, + } + + // Player confirms it switched to track at index 0 + const action = { + type: PLAYER_CURRENT, + data: { uuid: 'u1', name: 'B Song 1', volume: 1 }, + } + const result = playerReducer(stateAfterClearThenPlay, action) + expect(result.playIndex).toBeUndefined() + expect(result.clear).toBe(false) + expect(result.savedPlayIndex).toBe(0) + }) + }) + describe('PLAYER_REFRESH_QUEUE', () => { it('clamps negative savedPlayIndex to 0', () => { const state = { From a44e53914737fc5f5e5baaf55f05863378a7e490 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 30 Apr 2026 16:55:10 -0400 Subject: [PATCH 050/117] chore(docker): add app directory to PATH in Dockerfile --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 105656afb..ad1e2a41c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -164,6 +164,7 @@ RUN touch /.nddockerenv EXPOSE ${ND_PORT} WORKDIR /app +ENV PATH="/app:${PATH}" ENTRYPOINT ["/app/navidrome"] From 81293c209ea7a538a54a5902b64393e3c79623bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Thu, 30 Apr 2026 23:04:05 -0400 Subject: [PATCH 051/117] feat(subsonic): implement playbackReport OpenSubsonic extension (#5442) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(req): add Float64Or helper for parsing float query params * feat(scrobbler): extend NowPlayingInfo with state/position/rate fields * feat(scrobbler): implement ReportPlayback with state machine and auto-scrobble * feat(responses): add state/positionMs/playbackRate to NowPlayingEntry * feat(subsonic): add reportPlayback endpoint handler * feat(subsonic): include state/positionMs/playbackRate in getNowPlaying response * feat(subsonic): register playbackReport OpenSubsonic extension * test(e2e): add reportPlayback endpoint e2e tests * refactor(scrobbler): simplify ReportPlayback — extract helpers, remove duplication - Add state constants and exported ValidStates map - Extract remainingTTL() helper (was duplicated 3x) - Merge playing/paused switch cases into single branch - Use Get instead of GetWithParticipants for non-stopped states - Guard NowPlayingCount broadcast with count-change detection - Use cache entry for NowPlaying dispatch instead of extra DB query - Remove redundant Position field from NowPlayingInfo * refactor(scrobbler): skip DB query in playing/paused when playMap has entry * fix(play_tracker): handle errors when adding/updating NowPlayingInfo in cache Signed-off-by: Deluan * refactor(play_tracker): replace sort with slices.SortFunc for NowPlayingInfo Signed-off-by: Deluan * fix(play_tracker): check all ReportPlayback errors in tests Replace _ = with explicit error assertions to avoid masking failures in intermediate calls. Signed-off-by: Deluan * test(e2e): use real PlayTracker and assert getNowPlaying after reportPlayback Replace noopPlayTracker with a real PlayTracker backed by the E2E database. E2E tests now verify the full round-trip: reportPlayback creates/updates/removes entries visible via getNowPlaying, including state, positionMs, and playbackRate fields. Export NewPlayTracker constructor for use outside the scrobbler package. * fix(play_tracker): account for playback rate in TTL and detect track switches The remainingTTL function now divides remaining time by the playback rate, so cache entries expire correctly at non-1x speeds (e.g., 2x playback halves the TTL). Zero/negative rates default to 1.0. The playing/paused case now checks if the cached MediaFile ID matches the reported mediaId, falling back to a DB fetch when the client switches tracks without sending stopped/starting. Adds parameterized tests for remainingTTL covering rate variations and edge cases. * fix(subsonic): validate positionMs and playbackRate in reportPlayback Reject negative positionMs values and invalid playbackRate values (NaN, Inf, zero, negative) at the API boundary before they reach TTL and position estimation math. Returns clear error messages for each case. * feat(play_tracker): add ClientId and ClientName to ReportPlayback parameters Signed-off-by: Deluan * refactor(play_tracker): replace NowPlaying method with ReportPlayback calls Signed-off-by: Deluan * refactor(play_tracker_test): remove redundant TTL behavior tests and clean up mockPluginLoader Signed-off-by: Deluan --------- Signed-off-by: Deluan --- core/scrobbler/play_tracker.go | 175 ++++-- core/scrobbler/play_tracker_test.go | 514 ++++++++++++++---- server/e2e/e2e_suite_test.go | 28 +- server/e2e/subsonic_media_annotation_test.go | 111 ++++ server/e2e/subsonic_sonic_similarity_test.go | 3 +- server/subsonic/album_lists.go | 13 +- server/subsonic/api.go | 1 + server/subsonic/media_annotation.go | 72 ++- server/subsonic/media_annotation_test.go | 118 +++- server/subsonic/opensubsonic.go | 1 + server/subsonic/opensubsonic_test.go | 62 ++- ...es NowPlaying with data should match .JSON | 23 + ...ses NowPlaying with data should match .XML | 5 + ...NowPlaying without data should match .JSON | 8 + ... NowPlaying without data should match .XML | 3 + server/subsonic/responses/responses.go | 11 +- server/subsonic/responses/responses_test.go | 36 ++ utils/cache/simple_cache.go | 5 + utils/req/req.go | 12 + utils/req/req_test.go | 17 + 20 files changed, 1002 insertions(+), 216 deletions(-) create mode 100644 server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML create mode 100644 server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON create mode 100644 server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index d1338ca39..462fc85ba 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -3,7 +3,7 @@ package scrobbler import ( "context" "maps" - "sort" + "slices" "sync" "time" @@ -17,13 +17,30 @@ import ( "github.com/navidrome/navidrome/utils/singleton" ) +const ( + StateStarting = "starting" + StatePlaying = "playing" + StatePaused = "paused" + StateStopped = "stopped" +) + +var ValidStates = map[string]bool{ + StateStarting: true, + StatePlaying: true, + StatePaused: true, + StateStopped: true, +} + type NowPlayingInfo struct { - MediaFile model.MediaFile - Start time.Time - Position int - Username string - PlayerId string - PlayerName string + MediaFile model.MediaFile + Start time.Time + Username string + PlayerId string + PlayerName string + State string + PositionMs int64 + PlaybackRate float64 + LastReport time.Time } type Submission struct { @@ -31,6 +48,16 @@ type Submission struct { Timestamp time.Time } +type ReportPlaybackParams struct { + MediaId string + PositionMs int64 + State string + PlaybackRate float64 + IgnoreScrobble bool + ClientId string + ClientName string +} + type nowPlayingEntry struct { ctx context.Context userId string @@ -39,9 +66,9 @@ type nowPlayingEntry struct { } type PlayTracker interface { - NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) Submit(ctx context.Context, submissions []Submission) error + ReportPlayback(ctx context.Context, params ReportPlaybackParams) error } // PluginLoader is a minimal interface for plugin manager usage in PlayTracker @@ -72,8 +99,12 @@ func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug }) } -// This constructor only exists for testing. For normal usage, the PlayTracker has to be a singleton, returned by -// the GetPlayTracker function above +// NewPlayTracker creates a new PlayTracker instance. For normal usage, the PlayTracker has to be a singleton, +// returned by the GetPlayTracker function above. This constructor is exported for testing. +func NewPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker { + return newPlayTracker(ds, broker, pluginManager) +} + func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker { m := cache.NewSimpleCache[string, NowPlayingInfo]() p := &playTracker{ @@ -193,36 +224,104 @@ func (p *playTracker) getActiveScrobblers() map[string]Scrobbler { return combined } -func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error { - mf, err := p.ds.MediaFile(ctx).GetWithParticipants(trackId) - if err != nil { - log.Error(ctx, "Error retrieving mediaFile", "id", trackId, err) - return err +func remainingTTL(durationSec float32, positionMs int64, rate float64) time.Duration { + if rate <= 0 { + rate = 1.0 } + remainingMs := float64(int64(durationSec*1000)-positionMs) / rate + remainingSec := max(int(remainingMs/1000), 0) + return time.Duration(remainingSec+5) * time.Second +} +func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackParams) error { + player, _ := request.PlayerFrom(ctx) user, _ := request.UserFrom(ctx) - info := NowPlayingInfo{ - MediaFile: *mf, - Start: time.Now(), - Position: position, - Username: user.UserName, - PlayerId: playerId, - PlayerName: playerName, + clientId := params.ClientId + client := params.ClientName + + now := time.Now() + prevCount := p.playMap.Len() + + switch params.State { + case StateStarting: + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if err != nil { + return err + } + info := NowPlayingInfo{ + MediaFile: *mf, + Start: now, + Username: user.UserName, + PlayerId: clientId, + PlayerName: client, + State: params.State, + PositionMs: params.PositionMs, + PlaybackRate: params.PlaybackRate, + LastReport: now, + } + err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate)) + if err != nil { + log.Warn(ctx, "Error adding NowPlayingInfo to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) + } + + case StatePlaying, StatePaused: + info, getErr := p.playMap.Get(clientId) + if getErr != nil || info.MediaFile.ID != params.MediaId { + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if err != nil { + return err + } + info = NowPlayingInfo{ + MediaFile: *mf, + Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond), + Username: user.UserName, + PlayerId: clientId, + PlayerName: client, + } + } + info.State = params.State + info.PositionMs = params.PositionMs + info.PlaybackRate = params.PlaybackRate + info.LastReport = now + ttl := 30 * time.Minute + if params.State == StatePlaying { + ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate) + } + err := p.playMap.AddWithTTL(clientId, info, ttl) + if err != nil { + log.Warn(ctx, "Error updating NowPlayingInfo in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) + } + + case StateStopped: + if !params.IgnoreScrobble && player.ScrobbleEnabled { + mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if err != nil { + return err + } + trackDurationMs := int64(mf.Duration * 1000) + threshold := min(trackDurationMs*50/100, 240_000) + if params.PositionMs >= threshold { + err = p.incPlay(ctx, mf, now) + if err != nil { + log.Warn(ctx, "Error updating play counts", "id", mf.ID, "track", mf.Title, "user", user.UserName, err) + } + p.dispatchScrobble(ctx, mf, now) + } + } + p.playMap.Remove(clientId) } - // Calculate TTL based on remaining track duration. If position exceeds track duration, - // remaining is set to 0 to avoid negative TTL. - remaining := max(int(mf.Duration)-position, 0) - // Add 5 seconds buffer to ensure the NowPlaying info is available slightly longer than the track duration. - ttl := time.Duration(remaining+5) * time.Second - _ = p.playMap.AddWithTTL(playerId, info, ttl) - if conf.Server.EnableNowPlaying { + if conf.Server.EnableNowPlaying && p.playMap.Len() != prevCount { p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } - player, _ := request.PlayerFrom(ctx) - if player.ScrobbleEnabled { - p.enqueueNowPlaying(ctx, playerId, user.ID, mf, position) + + if !params.IgnoreScrobble && 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)) + } } + return nil } @@ -296,9 +395,17 @@ func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t * func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) { res := p.playMap.Values() - sort.Slice(res, func(i, j int) bool { - return res[i].Start.After(res[j].Start) + slices.SortFunc(res, func(a, b NowPlayingInfo) int { + return b.Start.Compare(a.Start) }) + for i := range res { + if res[i].State == StatePlaying { + elapsed := time.Since(res[i].LastReport).Milliseconds() + estimated := res[i].PositionMs + int64(float64(elapsed)*res[i].PlaybackRate) + trackDurationMs := int64(res[i].MediaFile.Duration * 1000) + res[i].PositionMs = min(estimated, trackDurationMs) + } + } return res, nil } diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index f7edecdfd..64b3d3ee0 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -20,9 +20,6 @@ import ( . "github.com/onsi/gomega" ) -// mockPluginLoader is a test implementation of PluginLoader for plugin scrobbler tests -// Moved to top-level scope to avoid linter issues - type mockPluginLoader struct { mu sync.RWMutex names []string @@ -107,91 +104,21 @@ var _ = Describe("PlayTracker", func() { Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled")) }) - Describe("NowPlaying", func() { - It("sends track to agent", func() { - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) - Expect(fake.GetUserID()).To(Equal("u-1")) - Expect(fake.GetTrack().ID).To(Equal("123")) - Expect(fake.GetTrack().Participants).To(Equal(track.Participants)) - }) - It("does not send track to agent if user has not authorized", func() { - fake.Authorized = false - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.GetNowPlayingCalled()).To(BeFalse()) - }) - It("does not send track to agent if player is not enabled to send scrobbles", func() { - ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: false}) - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.GetNowPlayingCalled()).To(BeFalse()) - }) - It("does not send track to agent if artist is unknown", func() { - track.Artist = consts.UnknownArtist - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - - Expect(err).ToNot(HaveOccurred()) - Expect(fake.GetNowPlayingCalled()).To(BeFalse()) - }) - - It("stores position when greater than zero", func() { - pos := 42 - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", pos) - Expect(err).ToNot(HaveOccurred()) - - Eventually(func() int { return fake.GetPosition() }).Should(Equal(pos)) - - playing, err := tracker.GetNowPlaying(ctx) - Expect(err).ToNot(HaveOccurred()) - Expect(playing).To(HaveLen(1)) - Expect(playing[0].Position).To(Equal(pos)) - }) - - It("sends event with count", func() { - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - eventList := eventBroker.getEvents() - Expect(eventList).ToNot(BeEmpty()) - evt, ok := eventList[0].(*events.NowPlayingCount) - Expect(ok).To(BeTrue()) - Expect(evt.Count).To(Equal(1)) - }) - - It("does not send event when disabled", func() { - conf.Server.EnableNowPlaying = false - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - Expect(eventBroker.getEvents()).To(BeEmpty()) - }) - - It("passes user to scrobbler via context (fix for issue #4787)", func() { - ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "testuser"}) - ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true}) - - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - Expect(err).ToNot(HaveOccurred()) - Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) - // Verify the username was passed through async dispatch via context - Eventually(func() string { return fake.GetUsername() }).Should(Equal("testuser")) - }) - }) - Describe("GetNowPlaying", func() { It("returns current playing music", func() { track2 := track track2.ID = "456" _ = ds.MediaFile(ctx).Put(&track2) - ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"}) - _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"}) - _ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0) + ctx1 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"}) + ctx1 = request.WithPlayer(ctx1, model.Player{ScrobbleEnabled: true}) + _ = tracker.ReportPlayback(ctx1, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", ClientName: "player-one", + }) + ctx2 := request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"}) + ctx2 = request.WithPlayer(ctx2, model.Player{ScrobbleEnabled: true}) + _ = tracker.ReportPlayback(ctx2, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-2", ClientName: "player-two", + }) playing, err := tracker.GetNowPlaying(ctx) @@ -336,6 +263,379 @@ var _ = Describe("PlayTracker", func() { }) }) + Describe("ReportPlayback", func() { + const defaultClientId = "client-1" + + BeforeEach(func() { + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: true}) + }) + + It("creates entry on starting and removes on stopped", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("starting")) + Expect(playing[0].MediaFile.ID).To(Equal("123")) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + + playing, err = tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(BeEmpty()) + }) + + It("full lifecycle: starting -> playing -> paused -> playing -> stopped", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("playing")) + Expect(playing[0].PositionMs).To(BeNumerically(">=", int64(10000))) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err = tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing[0].State).To(Equal("paused")) + Expect(playing[0].PositionMs).To(Equal(int64(30000))) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 100000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err = tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(BeEmpty()) + }) + + It("starting replaces existing entry for same player", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 50000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("starting")) + Expect(playing[0].PositionMs).To(Equal(int64(0))) + }) + + It("multiple players have independent sessions", func() { + ctx1 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"}) + ctx1 = request.WithPlayer(ctx1, model.Player{ID: "p1", ScrobbleEnabled: true}) + + ctx2 := request.WithUser(ctx, model.User{ID: "u-1", UserName: "user1"}) + ctx2 = request.WithPlayer(ctx2, model.Player{ID: "p2", ScrobbleEnabled: true}) + + track2 := track + track2.ID = "456" + _ = ds.MediaFile(ctx).Put(&track2) + + err := tracker.ReportPlayback(ctx1, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-1", + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx2, ReportPlaybackParams{ + MediaId: "456", PositionMs: 0, State: "playing", PlaybackRate: 1.0, ClientId: "client-2", + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(2)) + }) + + Describe("auto-scrobble", func() { + It("scrobbles on stopped when positionMs >= 50% of track", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(1))) + Expect(album.PlayCount).To(Equal(int64(1))) + Expect(artist1.PlayCount).To(Equal(int64(1))) + }) + + It("scrobbles on stopped when positionMs >= 4 min for long tracks", func() { + longTrack := model.MediaFile{ + ID: "long", Title: "Long Song", Album: "Album", AlbumID: "al-1", + Duration: 600, + Participants: map[model.Role]model.ParticipantList{ + model.RoleArtist: []model.Participant{_p("ar-1", "Artist 1")}, + }, + } + _ = ds.MediaFile(ctx).Put(&longTrack) + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "long", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "long", PositionMs: 240000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(longTrack.PlayCount).To(Equal(int64(1))) + }) + + It("does NOT scrobble when positionMs below threshold", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + + It("does NOT scrobble when ignoreScrobble=true even if threshold met", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + + It("does NOT scrobble when player ScrobbleEnabled=false even if threshold met", func() { + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false}) + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + + It("scrobbles twice for two separate sessions of same song", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(2))) + }) + + It("dispatches to external scrobblers on auto-scrobble", func() { + fake.ScrobbleCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(fake.ScrobbleCalled.Load()).To(BeTrue()) + }) + }) + + Describe("position estimation", func() { + It("estimates position for playing state based on elapsed time", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10000))) + }) + + It("does NOT estimate for paused", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(Equal(int64(10000))) + }) + + It("does NOT estimate for starting", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(Equal(int64(0))) + }) + + It("respects playbackRate", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 2.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(100 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + // At 2x speed, 100ms real time = ~200ms playback time + Expect(playing[0].PositionMs).To(BeNumerically(">", int64(10100))) + }) + + It("caps estimated position at track duration", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 179990, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + time.Sleep(50 * time.Millisecond) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].PositionMs).To(Equal(int64(180000))) // track.Duration * 1000 + }) + + }) + + Describe("resilience (no prior starting)", func() { + It("playing without prior starting creates entry with Start approx now - positionMs", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("playing")) + expectedStart := time.Now().Add(-30 * time.Second) + Expect(playing[0].Start).To(BeTemporally("~", expectedStart, 2*time.Second)) + }) + + It("paused without prior starting creates entry", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + playing, err := tracker.GetNowPlaying(ctx) + Expect(err).ToNot(HaveOccurred()) + Expect(playing).To(HaveLen(1)) + Expect(playing[0].State).To(Equal("paused")) + }) + + It("stopped without prior starting auto-scrobbles if threshold met", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 90000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(1))) + }) + + It("stopped without prior starting does NOT scrobble if below threshold", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(track.PlayCount).To(Equal(int64(0))) + }) + }) + + Describe("external scrobbler dispatch", func() { + It("dispatches NowPlaying on starting", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) + }) + + It("dispatches NowPlaying on playing", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) + }) + + It("does NOT dispatch on paused", func() { + fake.nowPlayingCalled.Store(false) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + + It("does NOT dispatch 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()) + }) + + It("does NOT dispatch when ScrobbleEnabled=false", func() { + fake.nowPlayingCalled.Store(false) + ctx = request.WithPlayer(ctx, model.Player{ID: "p1", ScrobbleEnabled: false}) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) + }) + }) + }) + Describe("Plugin scrobbler logic", func() { var pluginLoader *mockPluginLoader var pluginFake *fakeScrobbler @@ -354,27 +654,32 @@ var _ = Describe("PlayTracker", func() { }) It("registers and uses plugin scrobbler for NowPlaying", func() { - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) }) It("removes plugin scrobbler if not present anymore", func() { - // First call: plugin present - _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) pluginFake.nowPlayingCalled.Store(false) - // Remove plugin pluginLoader.SetNames([]string{}) - _ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) - // Should not be called since plugin was removed + _ = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse()) }) It("calls both builtin and plugin scrobblers for NowPlaying", func() { fake.nowPlayingCalled.Store(false) pluginFake.nowPlayingCalled.Store(false) - err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0) + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StatePlaying, PlaybackRate: 1.0, ClientId: "player-1", + }) Expect(err).ToNot(HaveOccurred()) Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue()) Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue()) @@ -550,6 +855,24 @@ var _ = Describe("PlayTracker", func() { }) }) +var _ = DescribeTable("remainingTTL", + func(durationSec float32, positionMs int64, rate float64, expected time.Duration) { + Expect(remainingTTL(durationSec, positionMs, rate)).To(Equal(expected)) + }, + Entry("full track at 1x", float32(300), int64(0), 1.0, 305*time.Second), + Entry("halfway through at 1x", float32(300), int64(150000), 1.0, 155*time.Second), + Entry("near end at 1x", float32(300), int64(298000), 1.0, 7*time.Second), + Entry("at end of track", float32(300), int64(300000), 1.0, 5*time.Second), + Entry("past end of track", float32(300), int64(310000), 1.0, 5*time.Second), + Entry("2x speed halves remaining time", float32(300), int64(0), 2.0, 155*time.Second), + Entry("2x speed halfway", float32(300), int64(150000), 2.0, 80*time.Second), + Entry("0.5x speed doubles remaining time", float32(300), int64(0), 0.5, 605*time.Second), + Entry("zero rate defaults to 1x", float32(300), int64(0), 0.0, 305*time.Second), + Entry("negative rate defaults to 1x", float32(300), int64(0), -1.0, 305*time.Second), + Entry("short track", float32(3.5), int64(0), 1.0, 8*time.Second), + Entry("zero duration", float32(0), int64(0), 1.0, 5*time.Second), +) + type fakeScrobbler struct { Authorized bool nowPlayingCalled atomic.Bool @@ -577,17 +900,6 @@ func (f *fakeScrobbler) GetTrack() *model.MediaFile { return f.track.Load() } -func (f *fakeScrobbler) GetPosition() int { - return int(f.position.Load()) -} - -func (f *fakeScrobbler) GetUsername() string { - if p := f.username.Load(); p != nil { - return *p - } - return "" -} - func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool { return f.Error == nil && f.Authorized } diff --git a/server/e2e/e2e_suite_test.go b/server/e2e/e2e_suite_test.go index 4b003167a..7ce8de2e6 100644 --- a/server/e2e/e2e_suite_test.go +++ b/server/e2e/e2e_suite_test.go @@ -390,29 +390,13 @@ func (n noopProvider) AlbumImage(context.Context, string) (*url.URL, error) { return nil, model.ErrNotFound } -// noopPlayTracker implements scrobbler.PlayTracker -type noopPlayTracker struct{} - -func (n noopPlayTracker) NowPlaying(context.Context, string, string, string, int) error { - return nil -} - -func (n noopPlayTracker) GetNowPlaying(context.Context) ([]scrobbler.NowPlayingInfo, error) { - return nil, nil -} - -func (n noopPlayTracker) Submit(context.Context, []scrobbler.Submission) error { - return nil -} - // Compile-time interface checks var ( - _ artwork.Artwork = noopArtwork{} - _ stream.MediaStreamer = &spyStreamer{} - _ core.Archiver = noopArchiver{} - _ external.Provider = noopProvider{} - _ scrobbler.PlayTracker = noopPlayTracker{} - _ ffmpeg.FFmpeg = noopFFmpeg{} + _ artwork.Artwork = noopArtwork{} + _ stream.MediaStreamer = &spyStreamer{} + _ core.Archiver = noopArchiver{} + _ external.Provider = noopProvider{} + _ ffmpeg.FFmpeg = noopFFmpeg{} ) var _ = BeforeSuite(func() { @@ -513,7 +497,7 @@ func setupTestDB() { s, events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), - noopPlayTracker{}, + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), diff --git a/server/e2e/subsonic_media_annotation_test.go b/server/e2e/subsonic_media_annotation_test.go index 0f7e92083..ec9b070de 100644 --- a/server/e2e/subsonic_media_annotation_test.go +++ b/server/e2e/subsonic_media_annotation_test.go @@ -157,4 +157,115 @@ var _ = Describe("Media Annotation Endpoints", Ordered, func() { Expect(resp.Error).ToNot(BeNil()) }) }) + + Describe("ReportPlayback", Ordered, func() { + var songID string + + BeforeAll(func() { + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Max: 1, Sort: "title"}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + }) + + It("returns error when required params are missing", func() { + resp := doReq("reportPlayback") + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + + It("returns error for invalid state", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "0", + "state", "invalid", + ) + Expect(resp.Status).To(Equal(responses.StatusFailed)) + }) + + It("starting report creates a getNowPlaying entry", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "0", + "state", "starting", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.Status).To(Equal(responses.StatusOK)) + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].Id).To(Equal(songID)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("starting")) + }) + + It("playing report updates getNowPlaying state and position", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "30000", + "state", "playing", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("playing")) + Expect(np.NowPlaying.Entry[0].PositionMs).To(BeNumerically(">=", int64(30000))) + }) + + It("paused report freezes position in getNowPlaying", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "30000", + "state", "paused", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].State).To(Equal("paused")) + Expect(np.NowPlaying.Entry[0].PositionMs).To(Equal(int64(30000))) + }) + + It("stopped report removes entry from getNowPlaying", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "90000", + "state", "stopped", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(BeEmpty()) + }) + + It("accepts mediaType=podcast without error", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "podcast", + "positionMs", "0", + "state", "starting", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("accepts optional playbackRate and ignoreScrobble", func() { + resp := doReq("reportPlayback", + "mediaId", songID, + "mediaType", "song", + "positionMs", "5000", + "state", "playing", + "playbackRate", "1.5", + "ignoreScrobble", "true", + ) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + np := doReq("getNowPlaying") + Expect(np.NowPlaying.Entry).To(HaveLen(1)) + Expect(np.NowPlaying.Entry[0].PlaybackRate).To(Equal(1.5)) + }) + }) }) diff --git a/server/e2e/subsonic_sonic_similarity_test.go b/server/e2e/subsonic_sonic_similarity_test.go index 3a010bc67..40161470b 100644 --- a/server/e2e/subsonic_sonic_similarity_test.go +++ b/server/e2e/subsonic_sonic_similarity_test.go @@ -14,6 +14,7 @@ import ( "github.com/navidrome/navidrome/core/metrics" "github.com/navidrome/navidrome/core/playback" "github.com/navidrome/navidrome/core/playlists" + "github.com/navidrome/navidrome/core/scrobbler" "github.com/navidrome/navidrome/core/sonic" "github.com/navidrome/navidrome/core/stream" "github.com/navidrome/navidrome/model" @@ -42,7 +43,7 @@ func buildSonicRouter(provider sonic.Provider) *subsonic.Router { nil, // scanner events.NoopBroker(), playlists.NewPlaylists(ds, core.NewImageUploadService()), - noopPlayTracker{}, + scrobbler.NewPlayTracker(ds, events.NoopBroker(), nil), core.NewShare(ds), playback.PlaybackServer(nil), metrics.NewNoopInstance(), diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 56cf469c5..275665b02 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -214,11 +214,14 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { var i int32 response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { return responses.NowPlayingEntry{ - Child: childFromMediaFile(ctx, np.MediaFile), - UserName: np.Username, - MinutesAgo: int32(time.Since(np.Start).Minutes()), - PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything - PlayerName: np.PlayerName, + Child: childFromMediaFile(ctx, np.MediaFile), + UserName: np.Username, + MinutesAgo: int32(time.Since(np.Start).Minutes()), + PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything + PlayerName: np.PlayerName, + State: np.State, + PositionMs: np.PositionMs, + PlaybackRate: np.PlaybackRate, } }) return response, nil diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 5b6565260..0bbfcb83e 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -144,6 +144,7 @@ func (api *Router) routes() http.Handler { h(r, "star", api.Star) h(r, "unstar", api.Unstar) h(r, "scrobble", api.Scrobble) + h(r, "reportPlayback", api.ReportPlayback) }) r.Group(func(r chi.Router) { r.Use(getPlayer(api.players)) diff --git a/server/subsonic/media_annotation.go b/server/subsonic/media_annotation.go index 39bc83fa9..e8b0278c1 100644 --- a/server/subsonic/media_annotation.go +++ b/server/subsonic/media_annotation.go @@ -3,6 +3,7 @@ package subsonic import ( "context" "fmt" + "math" "net/http" "time" @@ -217,6 +218,73 @@ func (api *Router) scrobblerNowPlaying(ctx context.Context, trackId string, posi } log.Info(ctx, "Now Playing", "title", mf.Title, "artist", mf.Artist, "user", username, "player", player.Name, "position", position) - err = api.scrobbler.NowPlaying(ctx, clientId, client, trackId, position) - return err + return api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: trackId, + PositionMs: int64(position) * 1000, + State: scrobbler.StatePlaying, + PlaybackRate: 1.0, + ClientId: clientId, + ClientName: client, + }) +} + +func (api *Router) ReportPlayback(r *http.Request) (*responses.Subsonic, error) { + p := req.Params(r) + mediaId, err := p.String("mediaId") + if err != nil { + return nil, err + } + mediaType, err := p.String("mediaType") + if err != nil { + return nil, err + } + positionMs, err := p.Int64("positionMs") + if err != nil { + return nil, err + } + if positionMs < 0 { + return nil, newError(responses.ErrorGeneric, "positionMs must be non-negative") + } + state, err := p.String("state") + if err != nil { + return nil, err + } + + if !scrobbler.ValidStates[state] { + return nil, newError(responses.ErrorGeneric, "Invalid state: %s", state) + } + + playbackRate := p.Float64Or("playbackRate", 1.0) + if math.IsNaN(playbackRate) || math.IsInf(playbackRate, 0) || playbackRate <= 0 { + return nil, newError(responses.ErrorGeneric, "playbackRate must be a finite positive number") + } + ignoreScrobble := p.BoolOr("ignoreScrobble", false) + + ctx := r.Context() + if mediaType != "song" { + log.Warn(ctx, "reportPlayback received unsupported mediaType", "mediaType", mediaType, "mediaId", mediaId) + } + + player, _ := request.PlayerFrom(ctx) + client, _ := request.ClientFrom(ctx) + clientId, ok := request.ClientUniqueIdFrom(ctx) + if !ok { + clientId = player.ID + } + + err = api.scrobbler.ReportPlayback(ctx, scrobbler.ReportPlaybackParams{ + MediaId: mediaId, + PositionMs: positionMs, + State: state, + PlaybackRate: playbackRate, + IgnoreScrobble: ignoreScrobble, + ClientId: clientId, + ClientName: client, + }) + if err != nil { + log.Error(ctx, "Error in ReportPlayback", "mediaId", mediaId, "state", state, err) + return nil, err + } + + return newResponse(), nil } diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index 1c513edf4..f5cf434e0 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -10,6 +10,7 @@ import ( "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/model/request" "github.com/navidrome/navidrome/server/events" + "github.com/navidrome/navidrome/server/subsonic/responses" "github.com/navidrome/navidrome/tests" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -89,32 +90,107 @@ var _ = Describe("MediaAnnotationController", func() { Expect(playTracker.Submissions).To(BeEmpty()) }) - It("registers a NowPlaying", func() { + It("registers a NowPlaying via ReportPlayback", func() { _, err := router.Scrobble(req) Expect(err).ToNot(HaveOccurred()) - Expect(playTracker.Playing).To(HaveLen(1)) - Expect(playTracker.Playing).To(HaveKey("player-1")) + Expect(playTracker.ReportedPlayback).To(HaveLen(1)) + Expect(playTracker.ReportedPlayback[0].MediaId).To(Equal("12")) + Expect(playTracker.ReportedPlayback[0].State).To(Equal(scrobbler.StatePlaying)) + Expect(playTracker.ReportedPlayback[0].ClientId).To(Equal("player-1")) }) }) }) + + Describe("ReportPlayback", func() { + It("returns error when mediaId is missing", func() { + r := newGetRequest("mediaType=song", "positionMs=0", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when mediaType is missing", func() { + r := newGetRequest("mediaId=123", "positionMs=0", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when positionMs is missing", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error when state is missing", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for invalid state value", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=invalid") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for negative positionMs", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=-1", "state=playing") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for NaN playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=NaN") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for Inf playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=Inf") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for negative playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=-1.0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("returns error for zero playbackRate", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=0", "state=playing", "playbackRate=0") + _, err := router.ReportPlayback(r) + Expect(err).To(HaveOccurred()) + }) + + It("accepts mediaType=podcast without error", func() { + r := newGetRequest("mediaId=123", "mediaType=podcast", "positionMs=0", "state=playing") + ctx := request.WithPlayer(r.Context(), model.Player{ID: "p1"}) + r = r.WithContext(ctx) + resp, err := router.ReportPlayback(r) + Expect(err).ToNot(HaveOccurred()) + Expect(resp.Status).To(Equal(responses.StatusOK)) + }) + + It("defaults playbackRate to 1.0 and ignoreScrobble to false", func() { + r := newGetRequest("mediaId=123", "mediaType=song", "positionMs=5000", "state=playing") + ctx := request.WithPlayer(r.Context(), model.Player{ID: "p1"}) + r = r.WithContext(ctx) + _, err := router.ReportPlayback(r) + Expect(err).ToNot(HaveOccurred()) + Expect(playTracker.ReportedPlayback).To(HaveLen(1)) + Expect(playTracker.ReportedPlayback[0].PlaybackRate).To(Equal(1.0)) + Expect(playTracker.ReportedPlayback[0].IgnoreScrobble).To(BeFalse()) + Expect(playTracker.ReportedPlayback[0].ClientId).To(Equal("p1")) + Expect(playTracker.ReportedPlayback[0].ClientName).To(BeEmpty()) + }) + }) }) type fakePlayTracker struct { - Submissions []scrobbler.Submission - Playing map[string]string - Error error -} - -func (f *fakePlayTracker) NowPlaying(_ context.Context, playerId string, _ string, trackId string, position int) error { - if f.Error != nil { - return f.Error - } - if f.Playing == nil { - f.Playing = make(map[string]string) - } - f.Playing[playerId] = trackId - return nil + Submissions []scrobbler.Submission + ReportedPlayback []scrobbler.ReportPlaybackParams + Error error } func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) { @@ -129,6 +205,14 @@ func (f *fakePlayTracker) Submit(_ context.Context, submissions []scrobbler.Subm return nil } +func (f *fakePlayTracker) ReportPlayback(_ context.Context, params scrobbler.ReportPlaybackParams) error { + if f.Error != nil { + return f.Error + } + f.ReportedPlayback = append(f.ReportedPlayback, params) + return nil +} + var _ scrobbler.PlayTracker = (*fakePlayTracker)(nil) type fakeEventBroker struct { diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go index 6c54d36d0..85edb1012 100644 --- a/server/subsonic/opensubsonic.go +++ b/server/subsonic/opensubsonic.go @@ -14,6 +14,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson {Name: "songLyrics", Versions: []int32{1}}, {Name: "indexBasedQueue", Versions: []int32{1}}, {Name: "transcoding", Versions: []int32{1}}, + {Name: "playbackReport", Versions: []int32{1}}, } if api.sonic != nil && api.sonic.HasProvider() { extensions = append(extensions, responses.OpenSubsonicExtension{ diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go index d98599f8f..3ccbf232e 100644 --- a/server/subsonic/opensubsonic_test.go +++ b/server/subsonic/opensubsonic_test.go @@ -44,42 +44,13 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) }) - It("should return the base 5 OpenSubsonicExtensions without sonicSimilarity", func() { + It("should return the base 6 OpenSubsonicExtensions without sonicSimilarity", func() { router.ServeHTTP(w, r) // Make sure the endpoint is public, by not passing any authentication Expect(w.Code).To(Equal(http.StatusOK)) Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var response responses.JsonWrapper - err := json.Unmarshal(w.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( - HaveLen(5), - ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), - ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), - )) - Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( - ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), - ) - }) - }) - - Context("with sonic similarity plugin", func() { - BeforeEach(func() { - sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) - router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) - }) - - It("should return 6 extensions including sonicSimilarity", func() { - router.ServeHTTP(w, r) - - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) - var response responses.JsonWrapper err := json.Unmarshal(w.Body.Bytes(), &response) Expect(err).NotTo(HaveOccurred()) @@ -90,6 +61,37 @@ var _ = Describe("GetOpenSubsonicExtensions", func() { ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), + )) + Expect(*response.Subsonic.OpenSubsonicExtensions).NotTo( + ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), + ) + }) + }) + + Context("with sonic similarity plugin", func() { + BeforeEach(func() { + sonicService := sonicsvc.New(nil, &mockSonicPluginLoader{names: []string{"test-plugin"}}, nil) + router = subsonic.New(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, sonicService) + }) + + It("should return 7 extensions including sonicSimilarity", func() { + router.ServeHTTP(w, r) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Header().Get("Content-Type")).To(Equal("application/json")) + + var response responses.JsonWrapper + err := json.Unmarshal(w.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll( + HaveLen(7), + ContainElement(responses.OpenSubsonicExtension{Name: "transcodeOffset", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "formPost", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "songLyrics", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "indexBasedQueue", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "transcoding", Versions: []int32{1}}), + ContainElement(responses.OpenSubsonicExtension{Name: "playbackReport", Versions: []int32{1}}), ContainElement(responses.OpenSubsonicExtension{Name: "sonicSimilarity", Versions: []int32{1}}), )) }) diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON new file mode 100644 index 000000000..f99cb62af --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .JSON @@ -0,0 +1,23 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "nowPlaying": { + "entry": [ + { + "id": "1", + "isDir": false, + "title": "Song", + "username": "testuser", + "minutesAgo": 2, + "playerId": 1, + "playerName": "TestPlayer", + "state": "playing", + "positionMs": 120000, + "playbackRate": 1.5 + } + ] + } +} diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML new file mode 100644 index 000000000..ae05b68d0 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying with data should match .XML @@ -0,0 +1,5 @@ + + + + + diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON new file mode 100644 index 000000000..ab8f793fa --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .JSON @@ -0,0 +1,8 @@ +{ + "status": "ok", + "version": "1.16.1", + "type": "navidrome", + "serverVersion": "v0.55.0", + "openSubsonic": true, + "nowPlaying": {} +} diff --git a/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML new file mode 100644 index 000000000..763b4b583 --- /dev/null +++ b/server/subsonic/responses/.snapshots/Responses NowPlaying without data should match .XML @@ -0,0 +1,3 @@ + + + diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index c2e863b0f..b0b2b8752 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -358,10 +358,13 @@ type Starred2 struct { type NowPlayingEntry struct { Child - UserName string `xml:"username,attr" json:"username"` - MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"` - PlayerId int32 `xml:"playerId,attr" json:"playerId"` - PlayerName string `xml:"playerName,attr" json:"playerName,omitempty"` + UserName string `xml:"username,attr" json:"username"` + MinutesAgo int32 `xml:"minutesAgo,attr" json:"minutesAgo"` + PlayerId int32 `xml:"playerId,attr" json:"playerId"` + PlayerName string `xml:"playerName,attr" json:"playerName,omitempty"` + State string `xml:"state,attr" json:"state"` + PositionMs int64 `xml:"positionMs,attr" json:"positionMs"` + PlaybackRate float64 `xml:"playbackRate,attr" json:"playbackRate"` } type NowPlaying struct { diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index ba86deaf2..ee98a3daa 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -1109,6 +1109,42 @@ var _ = Describe("Responses", func() { }) }) + Describe("NowPlaying", func() { + BeforeEach(func() { + response.NowPlaying = &NowPlaying{} + }) + + Describe("without data", func() { + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + + Describe("with data", func() { + BeforeEach(func() { + response.NowPlaying.Entry = []NowPlayingEntry{{ + Child: Child{Id: "1", Title: "Song", IsDir: false}, + UserName: "testuser", + MinutesAgo: 2, + PlayerId: 1, + PlayerName: "TestPlayer", + State: "playing", + PositionMs: 120000, + PlaybackRate: 1.5, + }} + }) + It("should match .XML", func() { + Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + It("should match .JSON", func() { + Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot()) + }) + }) + }) + Describe("SonicMatches", func() { Context("without data", func() { BeforeEach(func() { diff --git a/utils/cache/simple_cache.go b/utils/cache/simple_cache.go index cac41be7b..2f0ff4764 100644 --- a/utils/cache/simple_cache.go +++ b/utils/cache/simple_cache.go @@ -17,6 +17,7 @@ type SimpleCache[K comparable, V any] interface { AddWithTTL(key K, value V, ttl time.Duration) error Get(key K) (V, error) GetWithLoader(key K, loader func(key K) (V, time.Duration, error)) (V, error) + Remove(key K) Keys() []K Values() []V Len() int @@ -77,6 +78,10 @@ func (c *simpleCache[K, V]) AddWithTTL(key K, value V, ttl time.Duration) error return nil } +func (c *simpleCache[K, V]) Remove(key K) { + c.data.Delete(key) +} + func (c *simpleCache[K, V]) Get(key K) (V, error) { item := c.data.Get(key) if item == nil { diff --git a/utils/req/req.go b/utils/req/req.go index f9fa5724b..6b6135e1a 100644 --- a/utils/req/req.go +++ b/utils/req/req.go @@ -170,3 +170,15 @@ func (r *Values) BoolOr(param string, def bool) bool { } return v } + +func (r *Values) Float64Or(param string, def float64) float64 { + v, err := r.String(param) + if err != nil { + return def + } + f, err := strconv.ParseFloat(v, 64) + if err != nil { + return def + } + return f +} diff --git a/utils/req/req_test.go b/utils/req/req_test.go index e710365bd..d76b3b934 100644 --- a/utils/req/req_test.go +++ b/utils/req/req_test.go @@ -244,6 +244,23 @@ var _ = Describe("Request Helpers", func() { }) }) + Describe("Float64Or", func() { + It("returns parsed float value", func() { + r := req.Params(httptest.NewRequest("GET", "/test?rate=1.5", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.5)) + }) + + It("returns default when param is missing", func() { + r := req.Params(httptest.NewRequest("GET", "/test", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.0)) + }) + + It("returns default when param is not a valid float", func() { + r := req.Params(httptest.NewRequest("GET", "/test?rate=abc", nil)) + Expect(r.Float64Or("rate", 1.0)).To(Equal(1.0)) + }) + }) + Describe("ParamBoolPtr", func() { Context("value is true", func() { BeforeEach(func() { From a003b83c67fa49630915fe70e9c5dd394d3c5275 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 1 May 2026 14:22:51 -0400 Subject: [PATCH 052/117] chore(lint): upgrade golangci-lint, fix/ignore new errors Signed-off-by: Deluan --- Makefile | 2 +- server/subsonic/api_test.go | 2 +- server/subsonic/media_retrieval.go | 2 +- server/subsonic/middlewares.go | 6 ++++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/Makefile b/Makefile index ad96afd31..e303017c7 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ IMAGE_PLATFORMS ?= $(shell echo $(SUPPORTED_PLATFORMS) | tr ',' '\n' | grep "lin PLATFORMS ?= $(SUPPORTED_PLATFORMS) DOCKER_TAG ?= deluan/navidrome:develop -GOLANGCI_LINT_VERSION ?= v2.11.1 +GOLANGCI_LINT_VERSION ?= v2.12.0 UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*") diff --git a/server/subsonic/api_test.go b/server/subsonic/api_test.go index 3b88704b1..f3053c8af 100644 --- a/server/subsonic/api_test.go +++ b/server/subsonic/api_test.go @@ -156,7 +156,7 @@ var _ = Describe("sendResponse", func() { It("updates status pointer when an error occurs", func() { pointer := int32(0) - ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) + ctx := context.WithValue(r.Context(), subsonicErrorPointer, &pointer) //nolint:govet r = r.WithContext(ctx) payload.Status = responses.StatusFailed diff --git a/server/subsonic/media_retrieval.go b/server/subsonic/media_retrieval.go index 3faae1650..9ab3a20b0 100644 --- a/server/subsonic/media_retrieval.go +++ b/server/subsonic/media_retrieval.go @@ -37,7 +37,7 @@ func (api *Router) GetAvatar(w http.ResponseWriter, r *http.Request) (*responses log.Warn(ctx, "User needs an email for gravatar to work", "username", username) return api.getPlaceHolderAvatar(w, r) } - http.Redirect(w, r, gravatar.Url(u.Email, 0), http.StatusFound) + http.Redirect(w, r, gravatar.Url(u.Email, 0), http.StatusFound) //nolint:gosec // URL is not constructed from user input return nil, nil } diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go index 2d8b1fd94..5832bb1de 100644 --- a/server/subsonic/middlewares.go +++ b/server/subsonic/middlewares.go @@ -199,7 +199,7 @@ func getPlayer(players core.Players) func(next http.Handler) http.Handler { } r = r.WithContext(ctx) - cookie := &http.Cookie{ + cookie := &http.Cookie{ //nolint:gosec // Secure omitted: Navidrome may run over plain HTTP Name: playerIDCookieName(userName), Value: player.ID, MaxAge: consts.CookieExpiry, @@ -239,7 +239,9 @@ func playerIDCookieName(userName string) string { return cookieName } -const subsonicErrorPointer = "subsonicErrorPointer" +type contextKey string + +const subsonicErrorPointer contextKey = "subsonicErrorPointer" func recordStats(metrics metrics.Metrics) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { From 73bba1d1d2a887c5f64865fab375f542f9b64bef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 1 May 2026 15:27:32 -0400 Subject: [PATCH 053/117] feat(ui): replace UI scrobble with reportPlayback and redesign NowPlaying panel (#5448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(config): add UIPlaybackReportInterval setting * feat(server): expose playbackReportIntervalMs to UI config * feat(ui): add playbackReportIntervalMs config default * feat(ui): replace scrobble/nowPlaying with reportPlayback in subsonic API layer * feat(ui): replace scrobble logic with reportPlayback state machine in Player * refactor(ui): simplify Player heartbeat using useInterval hook - Replace manual setInterval/clearInterval with existing useInterval hook - Extract shared reportPlaybackUrl helper to deduplicate URL construction - Use ref for currentTrackId in beforeunload to stabilize effect deps - Have heartbeat read lastPositionMsRef instead of audioInstance.currentTime * feat(ui): redesign NowPlaying panel with Discord-style layout Show album art with play/pause overlay icon, track title, artist, album name, progress bar with position/duration, and username. * fix(ui): adjust NowPlaying panel height to fit 3 entries * fix(ui): send stopped on player close and tab close while paused - onBeforeDestroy now sends reportPlayback stopped before clearing queue - beforeunload sends stopped beacon regardless of pause state * feat(ui): animate NowPlaying progress bar with 1s client-side tick * fix(ui): account for playbackRate in NowPlaying progress interpolation * fix(ui): use timestamp-based interpolation for smooth NowPlaying progress Replace tick counter with fetchedAt timestamp so the progress bar advances smoothly without resetting on each server poll. * fix(ui): fix NowPlaying progress bar not animating Pass `now` (Date.now()) as a prop that changes every tick, so React.memo'd components actually re-render each second. * fix(ui): prevent progress bar reset on NowPlaying poll Set fetchedAt and now atomically on fetch so the elapsed offset starts at zero and the server's already-estimated positionMs is used as the base without a visible jump. * fix(ui): stamp entries with fetch time to prevent progress bar reset Embed _fetchedAt timestamp directly into each entry object so the position and its reference timestamp are always in the same state update, eliminating the React 17 multi-setState batching race. * fix(server): estimate position for starting state in GetNowPlaying GetNowPlaying was only estimating elapsed position for the "playing" state, returning raw positionMs=0 for "starting". Since the UI player sends "starting" once and then doesn't update until the 60s heartbeat, NowPlaying polls returned 0 for up to a minute, causing the progress bar to reset on every poll. * fix(ui): send playing immediately after starting to enable position estimation The server only estimates elapsed position for "playing" state in GetNowPlaying. The Player was sending "starting" once and then not updating until the 60s heartbeat, leaving the server state as "starting" with positionMs=0 for up to a minute. Now the Player follows up "starting" with an immediate "playing" call, transitioning the server state so position estimation works from the first poll. * fix(subsonic): fix getNowPlaying returning same playerId for all entries PlayerId was never incremented in the map callback, so every entry got playerId=1. This caused the UI to use duplicate React keys, mixing up rendered entries between players. Also use a stable composite key in the UI instead of the sequential playerId. * fix(ui): only send stopped beacon when tab is actually closing Move the reportPlaybackBeacon call from beforeunload to pagehide. beforeunload fires before the confirmation dialog, so cancelling the close would still send stopped. pagehide only fires when the page is actually being unloaded. * fix(ui): revert to beforeunload for stopped beacon pagehide does not fire reliably in Chrome when closing tabs. Use beforeunload instead — if the user cancels the close dialog, the heartbeat will re-register the NowPlaying entry on its next tick. * fix(ui): use synchronous XHR for stopped report on tab close Replace sendBeacon with synchronous XMLHttpRequest in beforeunload. This blocks the page from closing until the server acknowledges the stopped state, ensuring the NowPlaying entry is always removed. * fix(ui): fix confirmation dialog and use fetch keepalive for tab close - Move e.preventDefault() before the stopped report so the dialog always shows regardless of XHR errors - Use fetch with keepalive:true instead of sync XHR (more reliable, non-blocking, survives page teardown) - Fall back to sendBeacon if fetch throws * fix(ui): prevent heartbeat from re-adding entry after stopped on tab close Set a stoppedRef flag in beforeunload so the heartbeat interval skips sending playing reports after stopped has been sent. Without this, the heartbeat could re-register the NowPlaying entry after the stopped event removed it. * fix(ui): include client unique ID header in stopped report on tab close Root cause: reportPlaybackSync (fetch keepalive) did not include the X-ND-Client-Unique-Id header. Regular reportPlayback calls via httpClient include this header, and the server uses it as the playMap key. Without the header, the stopped call fell back to player.ID as the key, which didn't match the entry added with the UUID key. The playMap.Remove targeted the wrong key, so the entry persisted. Fix: export clientUniqueId from httpClient and include it as a header in the fetch keepalive request. * fix(ui): use pagehide for stopped report to avoid premature send beforeunload fires before the confirmation dialog, so the stopped event was sent even when the user cancelled closing. Move the stopped report to pagehide, which only fires when the page is actually being unloaded (after confirmation). * feat(server): broadcast NowPlaying SSE on every state change Previously, the SSE broadcast only fired when the NowPlaying count changed. Now it fires on every reportPlayback call (starting, playing, paused, stopped), so the NowPlaying panel gets instant updates for state transitions and position changes. The UI reducer stores a nowPlayingLastUpdate timestamp alongside the count, ensuring every SSE event triggers a re-fetch even when the count is unchanged (e.g., pause/resume). * fix(ui): clamp NowPlaying position to prevent negative time display * fix(ui): debounce NowPlaying fetches to prevent progress bar trembling During track changes, rapid SSE events (stopped, starting, playing) triggered multiple refetches within milliseconds, each resetting the interpolation base and causing the progress bar to oscillate. Skip fetches within 1 second of the previous fetch. * feat(ui): report playback position on seek Send a reportPlayback(playing) call when the user seeks/scrubs in the player, so the NowPlaying panel and server position stay in sync immediately instead of waiting for the next 60s heartbeat. * refactor: code review cleanup - Export clientUniqueIdHeader from httpClient, use in subsonic layer - Fix variable shadowing (now → fetchNow) in NowPlayingPanel fetchList - Fix onBeforeDestroy nested dep (read isRadio from ref instead) - Only broadcast SSE on state transitions, not heartbeat position updates - Only enqueue NowPlaying to external scrobblers on state transitions Signed-off-by: Deluan * fix(ui): use trailing-edge debounce for NowPlaying fetch Replace the leading-edge throttle (which fetched on the first event and blocked subsequent ones) with a trailing-edge debounce (300ms). During track transitions, the burst of events (stopped → starting → playing) now collapses into a single fetch after the burst settles, showing the new track immediately instead of briefly showing empty. * fix(ui): only show overlay on NowPlaying artwork when paused Signed-off-by: Deluan * refactor(ui): remove unnecessary sendBeacon fallback from reportPlaybackSync * refactor(ui): rename reportPlaybackSync to reportPlaybackKeepalive The function was never synchronous — it uses fetch with keepalive:true, which is fire-and-forget. The name now reflects the actual behavior. * style: format code with prettier * test: add tests for reportPlayback SSE broadcast and UI changes - play_tracker: verify SSE broadcast on every state transition and that broadcasts are skipped when EnableNowPlaying is false - activityReducer: verify nowPlayingLastUpdate timestamp is set - subsonic/index: verify reportPlayback URL construction Signed-off-by: Deluan * fix(ui): prevent NowPlaying from fetching every second when panel is open fetchList had unstable identity because it depended on doFetch (which depended on notify/dispatch). Each 1s setNow re-render recreated the callback chain, re-triggering the useEffect that calls fetchList. Use a ref for the fetch logic so fetchList has a stable identity with empty deps. * fix(ui): break fetch→dispatch→effect→fetch loop in NowPlaying panel The fetch dispatched nowPlayingCountUpdate on every result, which updated nowPlayingLastUpdate in Redux, which triggered the SSE effect to call fetchList again — creating a fetch loop. Fix: remove dispatch from fetch results. The badge count uses entries.length (from local state) when entries are loaded, falling back to Redux count (from SSE) when they aren't. SSE events remain the only trigger for nowPlayingLastUpdate, breaking the loop. * fix(ui): clear NowPlaying entries on panel close so badge uses SSE count * style: format code with prettier * fix: address code review feedback - Fix currentTime truthiness check to handle position 0 correctly - Report actual player state (playing/paused) on seek instead of always sending 'playing' - Remove idx from React key to avoid reorder issues - Add debounce timer cleanup on unmount - Keep entries on panel close so badge stays accurate from polling - Fix test description to match actual assertion * fix(ui): keep NowPlaying badge count accurate from polling Add a separate nowPlayingCountSync action that updates the Redux count without setting nowPlayingLastUpdate (which would trigger the SSE effect and cause a fetch loop). Polling results now sync the badge count via this action, so the badge stays accurate even when SSE is unavailable. * style: format code with prettier --------- Signed-off-by: Deluan --- conf/configuration.go | 2 + consts/consts.go | 11 +- core/scrobbler/play_tracker.go | 3 +- core/scrobbler/play_tracker_test.go | 63 +++++ server/serve_index.go | 1 + server/serve_index_test.go | 1 + server/subsonic/album_lists.go | 3 +- ui/src/actions/serverEvents.js | 6 + ui/src/audioplayer/Player.jsx | 170 ++++++++----- ui/src/config.js | 1 + ui/src/dataProvider/httpClient.js | 4 +- ui/src/dataProvider/index.js | 4 +- ui/src/layout/NowPlayingPanel.jsx | 306 +++++++++++++++++------- ui/src/layout/NowPlayingPanel.test.jsx | 50 ++-- ui/src/reducers/activityReducer.js | 8 + ui/src/reducers/activityReducer.test.js | 17 ++ ui/src/subsonic/index.js | 33 ++- ui/src/subsonic/index.test.js | 30 +++ 18 files changed, 517 insertions(+), 196 deletions(-) diff --git a/conf/configuration.go b/conf/configuration.go index 916efe70b..1c4829d82 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -95,6 +95,7 @@ type configOptions struct { EnableReplayGain bool EnableCoverAnimation bool EnableNowPlaying bool + UIPlaybackReportInterval time.Duration GATrackingID string EnableLogRedacting bool AuthRequestLimit int @@ -776,6 +777,7 @@ func setViperDefaults() { viper.SetDefault("enablereplaygain", true) viper.SetDefault("enablecoveranimation", true) viper.SetDefault("enablenowplaying", true) + viper.SetDefault("uiplaybackreportinterval", consts.DefaultUIPlaybackReportInterval) viper.SetDefault("enableartworkupload", true) viper.SetDefault("maximageuploadsize", consts.DefaultMaxImageUploadSize) viper.SetDefault("enablesharing", false) diff --git a/consts/consts.go b/consts/consts.go index 3db0b831a..bf32006d6 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -67,11 +67,12 @@ const ( ScanIgnoreFile = ".ndignore" ArtworkFolder = "artwork" - PlaceholderArtistArt = "artist-placeholder.webp" - PlaceholderAlbumArt = "album-placeholder.webp" - PlaceholderAvatar = "logo-192x192.png" - DefaultUIVolume = 100 - DefaultUISearchDebounceMs = 200 + PlaceholderArtistArt = "artist-placeholder.webp" + PlaceholderAlbumArt = "album-placeholder.webp" + PlaceholderAvatar = "logo-192x192.png" + DefaultUIVolume = 100 + DefaultUISearchDebounceMs = 200 + DefaultUIPlaybackReportInterval = time.Minute DefaultHttpClientTimeOut = 10 * time.Second diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 462fc85ba..161d04d7d 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -240,7 +240,6 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP client := params.ClientName now := time.Now() - prevCount := p.playMap.Len() switch params.State { case StateStarting: @@ -311,7 +310,7 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP p.playMap.Remove(clientId) } - if conf.Server.EnableNowPlaying && p.playMap.Len() != prevCount { + if conf.Server.EnableNowPlaying { p.broker.SendBroadcastMessage(ctx, &events.NowPlayingCount{Count: p.playMap.Len()}) } diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 64b3d3ee0..734f8f66f 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -371,6 +371,69 @@ var _ = Describe("PlayTracker", func() { Expect(playing).To(HaveLen(2)) }) + Describe("SSE broadcast on state change", func() { + BeforeEach(func() { + eventBroker = &fakeEventBroker{} + tracker = newPlayTracker(ds, eventBroker, nil) + tracker.(*playTracker).builtinScrobblers["fake"] = fake + }) + + It("broadcasts NowPlayingCount on every state change", func() { + // starting -> count should be 1 + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts := eventBroker.getEvents() + Expect(evts).To(HaveLen(1)) + Expect(evts[0].(*events.NowPlayingCount).Count).To(Equal(1)) + + // playing -> count should be 1 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(2)) + Expect(evts[1].(*events.NowPlayingCount).Count).To(Equal(1)) + + // paused -> count should be 1 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "paused", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(3)) + Expect(evts[2].(*events.NowPlayingCount).Count).To(Equal(1)) + + // stopped -> count should be 0 + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: "stopped", PlaybackRate: 1.0, ClientId: defaultClientId, + IgnoreScrobble: true, + }) + Expect(err).ToNot(HaveOccurred()) + evts = eventBroker.getEvents() + Expect(evts).To(HaveLen(4)) + Expect(evts[3].(*events.NowPlayingCount).Count).To(Equal(0)) + }) + + It("does NOT broadcast when EnableNowPlaying is false", func() { + conf.Server.EnableNowPlaying = false + tracker = newPlayTracker(ds, eventBroker, nil) + tracker.(*playTracker).builtinScrobblers["fake"] = fake + + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 10000, State: "playing", PlaybackRate: 1.0, ClientId: defaultClientId, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(eventBroker.getEvents()).To(BeEmpty()) + }) + }) + Describe("auto-scrobble", func() { It("scrobbles on stopped when positionMs >= 50% of track", func() { err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ diff --git a/server/serve_index.go b/server/serve_index.go index 734aabc70..13fa4a9ce 100644 --- a/server/serve_index.go +++ b/server/serve_index.go @@ -58,6 +58,7 @@ func serveIndex(ds model.DataStore, fs fs.FS, shareInfo *model.Share) http.Handl "uiCoverArtSize": conf.Server.UICoverArtSize, "enableCoverAnimation": conf.Server.EnableCoverAnimation, "enableNowPlaying": conf.Server.EnableNowPlaying, + "playbackReportIntervalMs": conf.Server.UIPlaybackReportInterval.Milliseconds(), "gaTrackingId": conf.Server.GATrackingID, "losslessFormats": strings.ToUpper(strings.Join(mime.LosslessFormats, ",")), "devActivityPanel": conf.Server.DevActivityPanel, diff --git a/server/serve_index_test.go b/server/serve_index_test.go index 31bca02cf..78f3873b8 100644 --- a/server/serve_index_test.go +++ b/server/serve_index_test.go @@ -106,6 +106,7 @@ var _ = Describe("serveIndex", func() { Entry("enableSharing", func() { conf.Server.EnableSharing = true }, "enableSharing", true), Entry("devNewEventStream", func() { conf.Server.DevNewEventStream = true }, "devNewEventStream", true), Entry("extAuthLogoutURL", func() { conf.Server.ExtAuth.LogoutURL = "https://auth.example.com/logout" }, "extAuthLogoutURL", "https://auth.example.com/logout"), + Entry("playbackReportIntervalMs", func() { conf.Server.UIPlaybackReportInterval = 30 * time.Second }, "playbackReportIntervalMs", float64(30000)), ) It("sanitizes entity-encoded welcomeMessage as html", func() { diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 275665b02..2a2338d89 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -213,11 +213,12 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { response.NowPlaying = &responses.NowPlaying{} var i int32 response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { + i++ return responses.NowPlayingEntry{ Child: childFromMediaFile(ctx, np.MediaFile), UserName: np.Username, MinutesAgo: int32(time.Since(np.Start).Minutes()), - PlayerId: i + 1, // Fake numeric playerId, it does not seem to be used for anything + PlayerId: i, PlayerName: np.PlayerName, State: np.State, PositionMs: np.PositionMs, diff --git a/ui/src/actions/serverEvents.js b/ui/src/actions/serverEvents.js index 995534550..892f2e27e 100644 --- a/ui/src/actions/serverEvents.js +++ b/ui/src/actions/serverEvents.js @@ -2,6 +2,7 @@ export const EVENT_SCAN_STATUS = 'scanStatus' export const EVENT_SERVER_START = 'serverStart' export const EVENT_REFRESH_RESOURCE = 'refreshResource' export const EVENT_NOW_PLAYING_COUNT = 'nowPlayingCount' +export const EVENT_NOW_PLAYING_COUNT_SYNC = 'nowPlayingCountSync' export const EVENT_STREAM_RECONNECTED = 'streamReconnected' export const processEvent = (type, data) => ({ @@ -18,6 +19,11 @@ export const nowPlayingCountUpdate = (data) => ({ data: data, }) +export const nowPlayingCountSync = (data) => ({ + type: EVENT_NOW_PLAYING_COUNT_SYNC, + data: data, +}) + export const serverDown = () => ({ type: EVENT_SERVER_START, data: {}, diff --git a/ui/src/audioplayer/Player.jsx b/ui/src/audioplayer/Player.jsx index 5599b9e1d..e2070deea 100644 --- a/ui/src/audioplayer/Player.jsx +++ b/ui/src/audioplayer/Player.jsx @@ -1,4 +1,5 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useInterval } from '../common' import { useDispatch, useSelector } from 'react-redux' import { useMediaQuery } from '@material-ui/core' import { ThemeProvider } from '@material-ui/core/styles' @@ -41,9 +42,11 @@ const Player = () => { const dataProvider = useDataProvider() const playerState = useSelector((state) => state.player) const dispatch = useDispatch() - const [startTime, setStartTime] = useState(null) - const [scrobbled, setScrobbled] = useState(false) - const [preloaded, setPreload] = useState(false) + const [currentTrackId, setCurrentTrackId] = useState(null) + const [heartbeatTrackId, setHeartbeatTrackId] = useState(null) + const lastPositionMsRef = useRef(0) + const currentTrackIdRef = useRef(null) + const stoppedRef = useRef(false) const [audioInstance, setAudioInstance] = useState(null) const isDesktop = useMediaQuery('(min-width:810px)') const isMobilePlayer = @@ -58,6 +61,21 @@ const Player = () => { const playerStateRef = useRef(playerState) playerStateRef.current = playerState + currentTrackIdRef.current = currentTrackId + + useInterval( + () => { + if (heartbeatTrackId && !stoppedRef.current) { + subsonic.reportPlayback( + heartbeatTrackId, + lastPositionMsRef.current, + 'playing', + ) + } + }, + heartbeatTrackId ? config.playbackReportIntervalMs : null, + ) + // Detect browser codec profile and eagerly resolve transcode URLs for the // persisted queue once on mount (e.g. after a browser refresh) useEffect(() => { @@ -155,15 +173,33 @@ const Player = () => { useEffect(() => { const handleBeforeUnload = (e) => { - // Check there's a current track and is actually playing/not paused if (playerState.current?.uuid && audioInstance && !audioInstance.paused) { e.preventDefault() - e.returnValue = '' // Chrome requires returnValue to be set + e.returnValue = '' + } + } + + const handlePageHide = () => { + if (currentTrackIdRef.current && !playerState.current?.isRadio) { + stoppedRef.current = true + try { + subsonic.reportPlaybackKeepalive( + currentTrackIdRef.current, + lastPositionMsRef.current, + 'stopped', + ) + } catch { + // fetch/sendBeacon may throw; ignore + } } } window.addEventListener('beforeunload', handleBeforeUnload) - return () => window.removeEventListener('beforeunload', handleBeforeUnload) + window.addEventListener('pagehide', handlePageHide) + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload) + window.removeEventListener('pagehide', handlePageHide) + } }, [playerState, audioInstance]) const defaultOptions = useMemo( @@ -227,44 +263,25 @@ const Player = () => { [dispatch], ) - const nextSong = useCallback(() => { - const idx = playerState.queue.findIndex( - (item) => item.uuid === playerState.current.uuid, - ) - return idx !== null ? playerState.queue[idx + 1] : null - }, [playerState]) + const onAudioProgress = useCallback((info) => { + if (info.ended) { + document.title = 'Navidrome' + } + if (!info.isRadio && info.currentTime != null) { + lastPositionMsRef.current = Math.floor(info.currentTime * 1000) + } + }, []) - const onAudioProgress = useCallback( + const onAudioSeeked = useCallback( (info) => { - if (info.ended) { - document.title = 'Navidrome' - } - - const progress = (info.currentTime / info.duration) * 100 - if (isNaN(info.duration) || (progress < 50 && info.currentTime < 240)) { - return - } - - if (info.isRadio) { - return - } - - if (!preloaded) { - const next = nextSong() - if (next != null && !next.isRadio) { - // Trigger decision pre-fetch (this also warms the cache) - decisionService.prefetchDecisions([next.trackId]) - } - setPreload(true) - return - } - - if (!scrobbled) { - info.trackId && subsonic.scrobble(info.trackId, startTime) - setScrobbled(true) + 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) } }, - [startTime, scrobbled, nextSong, preloaded], + [currentTrackId, audioInstance], ) const onAudioVolumeChange = useCallback( @@ -275,24 +292,30 @@ const Player = () => { const onAudioPlay = useCallback( (info) => { - // Do this to start the context; on chrome-based browsers, the context - // will start paused since it is created prior to user interaction if (context && context.state !== 'running') { context.resume() } dispatch(currentPlaying(info)) - if (startTime === null) { - setStartTime(Date.now()) - } if (info.duration) { const song = info.song document.title = `${song.title} - ${song.artist} - Navidrome` if (!info.isRadio) { - const pos = startTime === null ? null : Math.floor(info.currentTime) - subsonic.nowPlaying(info.trackId, pos) + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + const isNewTrack = info.trackId !== currentTrackId + if (isNewTrack) { + subsonic + .reportPlayback(info.trackId, posMs, 'starting') + .then(() => + subsonic.reportPlayback(info.trackId, posMs, 'playing'), + ) + setCurrentTrackId(info.trackId) + } else { + subsonic.reportPlayback(info.trackId, posMs, 'playing') + } + setHeartbeatTrackId(info.trackId) } - setPreload(false) if (config.gaTrackingId) { ReactGA.event({ category: 'Player', @@ -309,34 +332,49 @@ const Player = () => { } } }, - [context, dispatch, showNotifications, startTime], + [context, dispatch, showNotifications, currentTrackId], ) const onAudioPlayTrackChange = useCallback(() => { - if (scrobbled) { - setScrobbled(false) + if (currentTrackId) { + subsonic.reportPlayback( + currentTrackId, + lastPositionMsRef.current, + 'stopped', + ) } - if (startTime !== null) { - setStartTime(null) - } - }, [scrobbled, startTime]) + setHeartbeatTrackId(null) + setCurrentTrackId(null) + }, [currentTrackId]) const onAudioPause = useCallback( - (info) => dispatch(currentPlaying(info)), - [dispatch], + (info) => { + dispatch(currentPlaying(info)) + if (!info.isRadio && currentTrackId) { + const posMs = Math.floor(info.currentTime * 1000) + lastPositionMsRef.current = posMs + subsonic.reportPlayback(currentTrackId, posMs, 'paused') + } + setHeartbeatTrackId(null) + }, + [dispatch, currentTrackId], ) const onAudioEnded = useCallback( (currentPlayId, audioLists, info) => { - setScrobbled(false) - setStartTime(null) + if (currentTrackId && !info.isRadio) { + const posMs = Math.floor((info.duration || 0) * 1000) + subsonic.reportPlayback(currentTrackId, posMs, 'stopped') + } + setHeartbeatTrackId(null) + setCurrentTrackId(null) dispatch(currentPlaying(info)) dataProvider .getOne('keepalive', { id: info.trackId }) // eslint-disable-next-line no-console .catch((e) => console.log('Keepalive error:', e)) }, - [dispatch, dataProvider], + [dispatch, dataProvider, currentTrackId], ) const onCoverClick = useCallback((mode, audioLists, audioInfo) => { @@ -369,10 +407,19 @@ const Player = () => { const onBeforeDestroy = useCallback(() => { return new Promise((resolve, reject) => { + if (currentTrackId && !playerStateRef.current?.current?.isRadio) { + subsonic.reportPlayback( + currentTrackId, + lastPositionMsRef.current, + 'stopped', + ) + } + setHeartbeatTrackId(null) + setCurrentTrackId(null) dispatch(clearQueue()) reject() }) - }, [dispatch]) + }, [dispatch, currentTrackId]) if (!visible) { document.title = 'Navidrome' @@ -397,6 +444,7 @@ const Player = () => { onAudioListsChange={onAudioListsChange} onAudioVolumeChange={onAudioVolumeChange} onAudioProgress={onAudioProgress} + onAudioSeeked={onAudioSeeked} onAudioPlay={onAudioPlay} onAudioPlayTrackChange={onAudioPlayTrackChange} onAudioPause={onAudioPause} diff --git a/ui/src/config.js b/ui/src/config.js index f4d60dfba..39f0cd467 100644 --- a/ui/src/config.js +++ b/ui/src/config.js @@ -33,6 +33,7 @@ const defaultConfig = { enableExternalServices: true, enableCoverAnimation: true, enableNowPlaying: true, + playbackReportIntervalMs: 60000, devShowArtistPage: true, devUIShowConfig: true, devNewEventStream: false, diff --git a/ui/src/dataProvider/httpClient.js b/ui/src/dataProvider/httpClient.js index a8897aef8..3820f658d 100644 --- a/ui/src/dataProvider/httpClient.js +++ b/ui/src/dataProvider/httpClient.js @@ -6,8 +6,8 @@ import { jwtDecode } from 'jwt-decode' import { removeHomeCache } from '../utils/removeHomeCache' const customAuthorizationHeader = 'X-ND-Authorization' -const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' -const clientUniqueId = uuidv4() +export const clientUniqueIdHeader = 'X-ND-Client-Unique-Id' +export const clientUniqueId = uuidv4() const httpClient = (url, options = {}) => { url = baseUrl(url) diff --git a/ui/src/dataProvider/index.js b/ui/src/dataProvider/index.js index e9ceea761..059023378 100644 --- a/ui/src/dataProvider/index.js +++ b/ui/src/dataProvider/index.js @@ -1,6 +1,6 @@ -import httpClient from './httpClient' +import httpClient, { clientUniqueId, clientUniqueIdHeader } from './httpClient' import wrapperDataProvider from './wrapperDataProvider' -export { httpClient } +export { httpClient, clientUniqueId, clientUniqueIdHeader } export default wrapperDataProvider diff --git a/ui/src/layout/NowPlayingPanel.jsx b/ui/src/layout/NowPlayingPanel.jsx index 4aaee1bee..509263b42 100644 --- a/ui/src/layout/NowPlayingPanel.jsx +++ b/ui/src/layout/NowPlayingPanel.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState, useEffect, useCallback, useRef } from 'react' import PropTypes from 'prop-types' import { useSelector, useDispatch } from 'react-redux' import { useTranslate, Link, useNotify } from 'react-admin' @@ -9,30 +9,29 @@ import { Tooltip, List, ListItem, - ListItemText, - ListItemAvatar, Avatar, Badge, Card, CardContent, Typography, + LinearProgress, useTheme, useMediaQuery, } from '@material-ui/core' -import { FaRegCirclePlay } from 'react-icons/fa6' +import { FaRegCirclePlay, FaPause } from 'react-icons/fa6' import subsonic from '../subsonic' import { useInterval } from '../common' -import { nowPlayingCountUpdate } from '../actions' +import { nowPlayingCountSync } from '../actions' +import { formatDuration } from '../utils' import config from '../config' const useStyles = makeStyles((theme) => ({ button: { color: 'inherit' }, list: { - width: '30em', + width: '26em', maxHeight: (props) => { - // Calculate height for up to 4 entries before scrolling - const entryHeight = 80 - const maxEntries = Math.min(props.entryCount || 0, 4) + const entryHeight = 120 + const maxEntries = Math.min(props.entryCount || 0, 3) return maxEntries > 0 ? `${maxEntries * entryHeight}px` : '12em' }, overflowY: 'auto', @@ -42,42 +41,111 @@ const useStyles = makeStyles((theme) => ({ padding: 0, }, cardContent: { - padding: `${theme.spacing(1)}px !important`, // Minimal padding, override default + padding: `${theme.spacing(1)}px !important`, '&:last-child': { - paddingBottom: `${theme.spacing(1)}px !important`, // Override Material-UI's last-child padding + paddingBottom: `${theme.spacing(1)}px !important`, }, }, listItem: { - paddingTop: theme.spacing(0.5), - paddingBottom: theme.spacing(0.5), - paddingLeft: theme.spacing(1), - paddingRight: theme.spacing(1), + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(1.5), + padding: theme.spacing(1), + }, + avatarContainer: { + position: 'relative', + flexShrink: 0, + width: theme.spacing(8), + height: theme.spacing(8), }, avatar: { - width: theme.spacing(6), - height: theme.spacing(6), + width: '100%', + height: '100%', cursor: 'pointer', + borderRadius: theme.spacing(0.5), '&:hover': { opacity: 0.8, }, }, + stateOverlay: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.45)', + borderRadius: theme.spacing(0.5), + pointerEvents: 'none', + }, + stateIcon: { + color: 'rgba(255, 255, 255, 0.85)', + fontSize: 18, + }, + entryContent: { + flex: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(0.25), + }, + trackTitle: { + fontWeight: 600, + fontSize: '0.875rem', + lineHeight: 1.3, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + trackDetail: { + fontSize: '0.75rem', + color: theme.palette.text.secondary, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + artistLink: { + cursor: 'pointer', + color: theme.palette.text.secondary, + fontSize: '0.75rem', + '&:hover': { + textDecoration: 'underline', + }, + }, + progressRow: { + display: 'flex', + alignItems: 'center', + gap: theme.spacing(0.75), + marginTop: theme.spacing(0.5), + }, + progressTime: { + fontSize: '0.65rem', + color: theme.palette.text.secondary, + fontVariantNumeric: 'tabular-nums', + flexShrink: 0, + }, + progressBar: { + flex: 1, + height: 3, + borderRadius: 2, + backgroundColor: theme.palette.action.disabledBackground, + '& .MuiLinearProgress-bar': { + borderRadius: 2, + }, + }, + userInfo: { + fontSize: '0.65rem', + color: theme.palette.text.disabled, + marginTop: theme.spacing(0.25), + }, badge: { '& .MuiBadge-badge': { backgroundColor: theme.palette.primary.main, color: theme.palette.primary.contrastText, }, }, - artistLink: { - cursor: 'pointer', - '&:hover': { - textDecoration: 'underline', - }, - }, - primaryText: { - display: 'flex', - alignItems: 'center', - flexWrap: 'wrap', - }, })) // NowPlayingButton component - handles the button with badge @@ -113,15 +181,32 @@ NowPlayingButton.propTypes = { onClick: PropTypes.func.isRequired, } -// NowPlayingItem component - individual list item const NowPlayingItem = React.memo( - ({ nowPlayingEntry, onLinkClick, getArtistLink }) => { + ({ nowPlayingEntry, onLinkClick, getArtistLink, now }) => { const classes = useStyles() - const translate = useTranslate() + const isPaused = nowPlayingEntry.state === 'paused' + const isPlaying = + nowPlayingEntry.state === 'playing' || + nowPlayingEntry.state === 'starting' + const basePositionMs = nowPlayingEntry.positionMs || 0 + const rate = nowPlayingEntry.playbackRate || 1 + const elapsedSinceFetch = now - (nowPlayingEntry._fetchedAt || now) + const interpolatedMs = isPlaying + ? basePositionMs + elapsedSinceFetch * rate + : basePositionMs + const durationMs = (nowPlayingEntry.duration || 0) * 1000 + const clampedMs = Math.max(0, interpolatedMs) + const positionMs = + durationMs > 0 ? Math.min(clampedMs, durationMs) : clampedMs + const positionSec = positionMs / 1000 + const durationSec = nowPlayingEntry.duration || 0 + const progress = durationSec > 0 ? (positionSec / durationSec) * 100 : 0 + const artistId = nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId + const artistName = nowPlayingEntry.albumArtist || nowPlayingEntry.artist return ( - - + +

- - - {nowPlayingEntry.albumArtistId || nowPlayingEntry.artistId ? ( - - {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} - - ) : ( - - {nowPlayingEntry.albumArtist || nowPlayingEntry.artist} - - )} -  - {nowPlayingEntry.title} + {isPaused && ( +
+
- } - secondary={`${nowPlayingEntry.username}${nowPlayingEntry.playerName ? ` (${nowPlayingEntry.playerName})` : ''} • ${translate('nowPlaying.minutesAgo', { smart_count: nowPlayingEntry.minutesAgo })}`} - /> + )} +
+
+ + {nowPlayingEntry.title} + + {artistId ? ( + + {artistName} + + ) : ( + + {artistName} + + )} + + {nowPlayingEntry.album} + +
+ + {formatDuration(positionSec)} + + + + {formatDuration(durationSec)} + +
+ + {nowPlayingEntry.username} + {nowPlayingEntry.playerName + ? ` (${nowPlayingEntry.playerName})` + : ''} + +
) }, @@ -178,16 +291,19 @@ NowPlayingItem.propTypes = { title: PropTypes.string.isRequired, username: PropTypes.string.isRequired, playerName: PropTypes.string, - minutesAgo: PropTypes.number.isRequired, album: PropTypes.string, + state: PropTypes.string, + positionMs: PropTypes.number, + duration: PropTypes.number, }).isRequired, onLinkClick: PropTypes.func.isRequired, getArtistLink: PropTypes.func.isRequired, + now: PropTypes.number.isRequired, } // NowPlayingList component - handles the popover content const NowPlayingList = React.memo( - ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink }) => { + ({ anchorEl, open, onClose, entries, onLinkClick, getArtistLink, now }) => { const classes = useStyles({ entryCount: entries.length }) const translate = useTranslate() @@ -215,10 +331,11 @@ const NowPlayingList = React.memo( > {entries.map((nowPlayingEntry) => ( ))} @@ -239,12 +356,14 @@ NowPlayingList.propTypes = { entries: PropTypes.arrayOf(PropTypes.object).isRequired, onLinkClick: PropTypes.func.isRequired, getArtistLink: PropTypes.func.isRequired, + now: PropTypes.number.isRequired, } // Main NowPlayingPanel component const NowPlayingPanel = () => { const dispatch = useDispatch() const count = useSelector((state) => state.activity.nowPlayingCount) + const lastUpdate = useSelector((state) => state.activity.nowPlayingLastUpdate) const streamReconnected = useSelector( (state) => state.activity.streamReconnected, ) @@ -258,6 +377,7 @@ const NowPlayingPanel = () => { const [anchorEl, setAnchorEl] = useState(null) const [entries, setEntries] = useState([]) + const [now, setNow] = useState(Date.now()) const open = Boolean(anchorEl) const handleMenuOpen = useCallback((event) => { @@ -282,40 +402,57 @@ const NowPlayingPanel = () => { : `/album?filter={"artist_id":"${artistId}"}&order=ASC&sort=max_year&displayedFilters={"compilation":true}&perPage=15` }, []) - const fetchList = useCallback( - () => - subsonic - .getNowPlaying() - .then((resp) => resp.json['subsonic-response']) - .then((data) => { - if (data.status === 'ok') { - const nowPlayingEntries = data.nowPlaying?.entry || [] - setEntries(nowPlayingEntries) - // Also update the count in Redux store - dispatch(nowPlayingCountUpdate({ count: nowPlayingEntries.length })) - } else { - throw new Error( - data.error?.message || 'Failed to fetch now playing data', - ) - } + const fetchTimerRef = useRef(null) + const doFetchRef = useRef() + doFetchRef.current = () => + subsonic + .getNowPlaying() + .then((resp) => resp.json['subsonic-response']) + .then((data) => { + if (data.status === 'ok') { + const nowPlayingEntries = data.nowPlaying?.entry || [] + const fetchTime = Date.now() + setEntries( + nowPlayingEntries.map((e) => ({ ...e, _fetchedAt: fetchTime })), + ) + dispatch(nowPlayingCountSync({ count: nowPlayingEntries.length })) + } else { + throw new Error( + data.error?.message || 'Failed to fetch now playing data', + ) + } + }) + .catch((error) => { + notify('ra.page.error', 'warning', { + messageArgs: { error: error.message || 'Unknown error' }, }) - .catch((error) => { - notify('ra.page.error', 'warning', { - messageArgs: { error: error.message || 'Unknown error' }, - }) - }), - [dispatch, notify], - ) + }) + const fetchList = useCallback(() => { + if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current) + fetchTimerRef.current = setTimeout(() => { + fetchTimerRef.current = null + doFetchRef.current() + }, 300) + }, []) + + useEffect(() => { + return () => { + if (fetchTimerRef.current) clearTimeout(fetchTimerRef.current) + } + }, []) // Initialize count and entries on mount, and refresh on server/stream changes useEffect(() => { if (serverUp) fetchList() }, [fetchList, serverUp, streamReconnected]) - // Refresh when count changes from WebSocket events (if panel is open) + // Refresh when NowPlaying updates from SSE events (if panel is open) useEffect(() => { if (open && serverUp) fetchList() - }, [count, open, fetchList, serverUp]) + }, [lastUpdate, open, fetchList, serverUp]) + + // Update current time every second when open to animate progress bars + useInterval(() => setNow(Date.now()), open ? 1000 : null) // Periodic refresh when panel is open (10 seconds) useInterval( @@ -341,6 +478,7 @@ const NowPlayingPanel = () => { open={open} onClose={handleMenuClose} entries={entries} + now={now} onLinkClick={handleLinkClick} getArtistLink={getArtistLink} /> diff --git a/ui/src/layout/NowPlayingPanel.test.jsx b/ui/src/layout/NowPlayingPanel.test.jsx index 4dd5dac8b..ea4a3568b 100644 --- a/ui/src/layout/NowPlayingPanel.test.jsx +++ b/ui/src/layout/NowPlayingPanel.test.jsx @@ -70,7 +70,12 @@ describe('', () => { ) } + afterEach(() => { + vi.useRealTimers() + }) + beforeEach(() => { + vi.useFakeTimers() vi.clearAllMocks() mockUseMediaQuery.mockReturnValue(false) // Default to large screen @@ -105,10 +110,8 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + // Advance past debounce and flush promises + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -128,21 +131,16 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { - expect( - screen.getByText('u1 (Chrome Browser) • nowPlaying.minutesAgo'), - ).toBeInTheDocument() + expect(screen.getByText('u1 (Chrome Browser)')).toBeInTheDocument() }) }) it('handles entries without player name', async () => { - subsonic.getNowPlaying.mockResolvedValueOnce({ + subsonic.getNowPlaying.mockResolvedValue({ json: { 'subsonic-response': { status: 'ok', @@ -170,19 +168,16 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { - expect(screen.getByText('u1 • nowPlaying.minutesAgo')).toBeInTheDocument() + expect(screen.getByText('u1')).toBeInTheDocument() }) }) it('shows empty message when no entries', async () => { - subsonic.getNowPlaying.mockResolvedValueOnce({ + subsonic.getNowPlaying.mockResolvedValue({ json: { 'subsonic-response': { status: 'ok', nowPlaying: { entry: [] } }, }, @@ -194,10 +189,7 @@ describe('', () => { , ) - // Wait for initial fetch - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) fireEvent.click(screen.getByRole('button')) await waitFor(() => { @@ -215,10 +207,7 @@ describe('', () => { , ) - // Wait for initial fetch to complete - await waitFor(() => { - expect(subsonic.getNowPlaying).toHaveBeenCalled() - }) + await vi.advanceTimersByTimeAsync(500) // Open the panel fireEvent.click(screen.getByRole('button')) @@ -268,7 +257,9 @@ describe('', () => { expect(subsonic.getNowPlaying).not.toHaveBeenCalled() }) - it('does not double-fetch on server reconnection', () => { + it('does not double-fetch on server reconnection', async () => { + vi.useFakeTimers() + const initialStore = createMockStore({ nowPlayingCount: 1, serverStart: { startTime: null }, // Server initially down @@ -295,8 +286,13 @@ describe('', () => { , ) + // Advance past the debounce window + vi.advanceTimersByTime(500) + // Should only make one call despite both serverUp and streamReconnected changing expect(subsonic.getNowPlaying).toHaveBeenCalledTimes(1) + + vi.useRealTimers() }) it('skips polling when server is down', () => { diff --git a/ui/src/reducers/activityReducer.js b/ui/src/reducers/activityReducer.js index 8238e395a..d61ed6018 100644 --- a/ui/src/reducers/activityReducer.js +++ b/ui/src/reducers/activityReducer.js @@ -3,6 +3,7 @@ import { EVENT_SCAN_STATUS, EVENT_SERVER_START, EVENT_NOW_PLAYING_COUNT, + EVENT_NOW_PLAYING_COUNT_SYNC, EVENT_STREAM_RECONNECTED, } from '../actions' import config from '../config' @@ -17,6 +18,7 @@ const initialState = { }, serverStart: { version: config.version }, nowPlayingCount: 0, + nowPlayingLastUpdate: 0, streamReconnected: 0, // Timestamp of last reconnection } @@ -45,6 +47,12 @@ export const activityReducer = (previousState = initialState, payload) => { }, } case EVENT_NOW_PLAYING_COUNT: + return { + ...previousState, + nowPlayingCount: data.count, + nowPlayingLastUpdate: Date.now(), + } + case EVENT_NOW_PLAYING_COUNT_SYNC: return { ...previousState, nowPlayingCount: data.count } case EVENT_STREAM_RECONNECTED: return { ...previousState, streamReconnected: Date.now() } diff --git a/ui/src/reducers/activityReducer.test.js b/ui/src/reducers/activityReducer.test.js index c9db38dbb..e52a262c1 100644 --- a/ui/src/reducers/activityReducer.test.js +++ b/ui/src/reducers/activityReducer.test.js @@ -18,6 +18,7 @@ describe('activityReducer', () => { }, serverStart: { version: config.version }, nowPlayingCount: 0, + nowPlayingLastUpdate: 0, streamReconnected: 0, } @@ -133,6 +134,22 @@ describe('activityReducer', () => { expect(newState.nowPlayingCount).toEqual(5) }) + it('handles EVENT_NOW_PLAYING_COUNT with nowPlayingLastUpdate', () => { + const action = { + type: EVENT_NOW_PLAYING_COUNT, + data: { count: 3 }, + } + const beforeTimestamp = Date.now() + const newState = activityReducer(initialState, action) + const afterTimestamp = Date.now() + + expect(newState.nowPlayingCount).toEqual(3) + expect(newState.nowPlayingLastUpdate).toBeGreaterThanOrEqual( + beforeTimestamp, + ) + expect(newState.nowPlayingLastUpdate).toBeLessThanOrEqual(afterTimestamp) + }) + it('handles EVENT_STREAM_RECONNECTED', () => { const action = { type: EVENT_STREAM_RECONNECTED, diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js index 3579619aa..7d93972e0 100644 --- a/ui/src/subsonic/index.js +++ b/ui/src/subsonic/index.js @@ -1,5 +1,9 @@ import { baseUrl } from '../utils' -import { httpClient } from '../dataProvider' +import { + httpClient, + clientUniqueId, + clientUniqueIdHeader, +} from '../dataProvider' const url = (command, id, options) => { const username = localStorage.getItem('username') @@ -37,16 +41,21 @@ const url = (command, id, options) => { const ping = () => httpClient(url('ping')) -const scrobble = (id, time, submission = true, position = null) => - httpClient( - url('scrobble', id, { - ...(submission && time && { time }), - submission, - ...(!submission && position !== null && { position }), - }), - ) +const reportPlaybackUrl = (mediaId, positionMs, state) => + url('reportPlayback', null, { mediaId, mediaType: 'song', positionMs, state }) -const nowPlaying = (id, position = null) => scrobble(id, null, false, position) +const reportPlayback = (mediaId, positionMs, state) => + httpClient(reportPlaybackUrl(mediaId, positionMs, state)) + +const reportPlaybackKeepalive = (mediaId, positionMs, state) => { + const u = reportPlaybackUrl(mediaId, positionMs, state) + if (u) { + fetch(baseUrl(u), { + keepalive: true, + headers: { [clientUniqueIdHeader]: clientUniqueId }, + }) + } +} const star = (id) => httpClient(url('star', id)) @@ -132,8 +141,8 @@ const streamUrl = (id, options) => { export default { url, ping, - scrobble, - nowPlaying, + reportPlayback, + reportPlaybackKeepalive, download, star, unstar, diff --git a/ui/src/subsonic/index.test.js b/ui/src/subsonic/index.test.js index a750694f4..ad4764c24 100644 --- a/ui/src/subsonic/index.test.js +++ b/ui/src/subsonic/index.test.js @@ -194,3 +194,33 @@ describe('getAvatarUrl', () => { expect(url).toContain('username=john') }) }) + +describe('reportPlayback', () => { + beforeEach(() => { + const localStorageMock = { + getItem: vi.fn((key) => { + const values = { + username: 'testuser', + 'subsonic-token': 'testtoken', + 'subsonic-salt': 'testsalt', + } + return values[key] || null + }), + } + Object.defineProperty(window, 'localStorage', { value: localStorageMock }) + }) + + it('should construct reportPlayback URL with correct parameters', () => { + const url = subsonic.url('reportPlayback', null, { + mediaId: 'song-123', + mediaType: 'song', + positionMs: 5000, + state: 'playing', + }) + expect(url).toContain('reportPlayback') + expect(url).toContain('mediaId=song-123') + expect(url).toContain('mediaType=song') + expect(url).toContain('positionMs=5000') + expect(url).toContain('state=playing') + }) +}) From 86051fa9ca5fbf9eac8239e8c16da09df8be7f09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Fri, 1 May 2026 19:21:48 -0400 Subject: [PATCH 054/117] fix(smartplaylists): coerce string booleans in smart playlist rules (#5450) * fix(criteria): coerce string booleans in smart playlist rules - #4826 When clients (e.g. Feishin) send boolean values as strings ("true"/"false") in smart playlist JSON rules, the SQL comparison fails because SQLite stores booleans as 0/1 integers. For example, `COALESCE(annotation.starred, false) = 'true'` never matches. This adds a `boolean` flag to mapped fields and coerces string values to native Go bools in `mapFields`, so squirrel generates correct SQL parameters. Signed-off-by: mango766 Signed-off-by: easonysliu * fix(criteria): implement boolean string coercion for smart playlist rules Signed-off-by: Deluan --------- Signed-off-by: mango766 Signed-off-by: easonysliu Signed-off-by: Deluan Co-authored-by: easonysliu --- model/criteria/fields.go | 13 +- model/criteria/fields_test.go | 1 + model/criteria/json.go | 36 +++++ model/criteria/operators.go | 19 +-- model/criteria/operators_test.go | 75 ++++++++++ persistence/criteria_sql.go | 6 +- persistence/criteria_sql_test.go | 5 + persistence/smart_playlist_repository_test.go | 65 +++++++++ server/e2e/subsonic_playlists_test.go | 130 ++++++++++++++++++ 9 files changed, 325 insertions(+), 25 deletions(-) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index b35913f28..9eafff7ab 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -8,6 +8,7 @@ type FieldInfo struct { IsTag bool IsRole bool Numeric bool + Boolean bool tagAlias string // If set, a tag name from mappings.yml that resolves to this field name string // Canonical name, populated by LookupField from the map key @@ -21,7 +22,7 @@ func (f FieldInfo) Name() string { var fieldMap = map[string]FieldInfo{ "title": {}, "album": {}, - "hascoverart": {}, + "hascoverart": {Boolean: true}, "tracknumber": {}, "discnumber": {}, "year": {}, @@ -31,8 +32,8 @@ var fieldMap = map[string]FieldInfo{ "releaseyear": {}, "releasedate": {}, "size": {}, - "compilation": {}, - "missing": {}, + "compilation": {Boolean: true}, + "missing": {Boolean: true}, "explicitstatus": {}, "dateadded": {}, "datemodified": {}, @@ -54,7 +55,7 @@ var fieldMap = map[string]FieldInfo{ "samplerate": {}, "bpm": {}, "channels": {}, - "loved": {}, + "loved": {Boolean: true}, "dateloved": {}, "lastplayed": {}, "daterated": {}, @@ -62,13 +63,13 @@ var fieldMap = map[string]FieldInfo{ "rating": {}, "averagerating": {Numeric: true}, "albumrating": {}, - "albumloved": {}, + "albumloved": {Boolean: true}, "albumplaycount": {}, "albumlastplayed": {}, "albumdateloved": {}, "albumdaterated": {}, "artistrating": {}, - "artistloved": {}, + "artistloved": {Boolean: true}, "artistplaycount": {}, "artistlastplayed": {}, "artistdateloved": {}, diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index 270a14473..5b6f53341 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -52,5 +52,6 @@ var _ = Describe("fields", func() { gomega.Expect(field.Name()).To(gomega.Equal("task3_producer")) gomega.Expect(field.IsRole).To(gomega.BeTrue()) }) + }) }) diff --git a/model/criteria/json.go b/model/criteria/json.go index 18a664988..ca47ceb95 100644 --- a/model/criteria/json.go +++ b/model/criteria/json.go @@ -3,6 +3,7 @@ package criteria import ( "encoding/json" "fmt" + "strconv" "strings" ) @@ -38,6 +39,7 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { if err != nil { return nil } + normalizeBoolFields(m) switch opName { case "is": return Is(m) @@ -70,13 +72,47 @@ func unmarshalExpression(opName string, rawValue json.RawMessage) Expression { case "notinplaylist": return NotInPlaylist(m) case "ismissing": + normalizeAllBoolFields(m) return IsMissing(m) case "ispresent": + normalizeAllBoolFields(m) return IsPresent(m) } return nil } +func normalizeAllBoolFields(m map[string]any) { + for k, v := range m { + m[k] = normalizeBoolValue(v) + } +} + +func normalizeBoolFields(m map[string]any) { + for field, value := range m { + info, ok := LookupField(field) + if ok && info.Boolean { + m[field] = normalizeBoolValue(value) + } + } +} + +func normalizeBoolValue(v any) any { + switch val := v.(type) { + case string: + if b, err := strconv.ParseBool(val); err == nil { + return b + } + case float64: + if val == 1 { + return true + } + if val == 0 { + return false + } + } + return v +} + func unmarshalConjunction(conjName string, rawValue json.RawMessage) Expression { var items unmarshalConjunctionType err := json.Unmarshal(rawValue, &items) diff --git a/model/criteria/operators.go b/model/criteria/operators.go index 7def18934..3ddd77f8b 100644 --- a/model/criteria/operators.go +++ b/model/criteria/operators.go @@ -1,9 +1,6 @@ package criteria -import ( - "strconv" - "time" -) +import "time" // Conjunctions need to implement this interface, to allow Criteria to extract child playlist IDs recursively type conjunction interface { @@ -181,20 +178,6 @@ func (ip IsPresent) MarshalJSON() ([]byte, error) { func (ip IsPresent) fields() map[string]any { return ip } -func IsTruthy(v any) bool { - switch val := v.(type) { - case bool: - return val - case float64: - return val != 0 - case string: - b, err := strconv.ParseBool(val) - return err == nil && b - default: - return v != nil - } -} - func extractPlaylistIds(inputRule any) (ids []string) { var id string var ok bool diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go index bfdca3e31..17c4272ba 100644 --- a/model/criteria/operators_test.go +++ b/model/criteria/operators_test.go @@ -31,6 +31,7 @@ var _ = Describe("Operators", func() { }, Entry("is [string]", Is{"title": "Low Rider"}, `{"is":{"title":"Low Rider"}}`), Entry("is [bool]", Is{"loved": false}, `{"is":{"loved":false}}`), + Entry("is [string does not coerce non-boolean field]", Is{"title": "true"}, `{"is":{"title":"true"}}`), Entry("isNot", IsNot{"title": "Low Rider"}, `{"isNot":{"title":"Low Rider"}}`), Entry("gt", Gt{"playCount": 10.0}, `{"gt":{"playCount":10}}`), Entry("lt", Lt{"playCount": 10.0}, `{"lt":{"playCount":10}}`), @@ -51,4 +52,78 @@ var _ = Describe("Operators", func() { Entry("isPresent [true]", IsPresent{"genre": true}, `{"isPresent":{"genre":true}}`), Entry("isPresent [false]", IsPresent{"genre": false}, `{"isPresent":{"genre":false}}`), ) + + Describe("Boolean string coercion at unmarshal time (issue #4826)", func() { + It("coerces string 'true' to bool for boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"loved":"true"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": true})) + }) + + It("coerces string 'false' to bool for boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"loved":"false"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": false})) + }) + + It("does not coerce string values for non-boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"title":"true"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"title": "true"})) + }) + + It("coerces numeric 1 to bool true for boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"loved":1}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": true})) + }) + + It("coerces numeric 0 to bool false for boolean fields", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"is":{"loved":0}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(Is{"loved": false})) + }) + + It("coerces in nested any/all groups", func() { + var c Criteria + err := json.Unmarshal([]byte(`{"all":[{"contains":{"title":"love"}},{"any":[{"is":{"loved":"true"}}]}]}`), &c) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + all := c.Expression.(All) + nested := all[1].(Any) + gomega.Expect(nested[0]).To(gomega.Equal(Is{"loved": true})) + }) + + It("coerces isMissing string 'true' to bool", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"isMissing":{"genre":"true"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(IsMissing{"genre": true})) + }) + + It("coerces isMissing numeric 0 to bool false", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"isMissing":{"genre":0}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(IsMissing{"genre": false})) + }) + + It("coerces isPresent string 'false' to bool", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"isPresent":{"genre":"false"}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": false})) + }) + + It("coerces isPresent numeric 1 to bool true", func() { + var obj UnmarshalConjunctionType + err := json.Unmarshal([]byte(`[{"isPresent":{"genre":1}}]`), &obj) + gomega.Expect(err).ToNot(gomega.HaveOccurred()) + gomega.Expect(obj[0]).To(gomega.Equal(IsPresent{"genre": true})) + }) + }) }) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 9a99cfa8e..a1bae3170 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -220,7 +220,11 @@ func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, er return nil, fmt.Errorf("isMissing/isPresent operator is only supported for tag and role fields, got: %s", field) } - negate := checkAbsence == criteria.IsTruthy(value) + b, ok := value.(bool) + if !ok { + return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value) + } + negate := checkAbsence == b return jsonExpr(info, nil, negate), nil } diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 40d21c9bf..ae2695a4d 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -149,6 +149,11 @@ var _ = Describe("Smart playlist criteria SQL", func() { Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) }) + It("returns an error when isMissing has a non-boolean value", func() { + _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"genre": "hello"}}).Where() + Expect(err).To(MatchError(ContainSubstring("invalid boolean value for 'missing' expression"))) + }) + Describe("sort", func() { It("sorts by regular fields", func() { Expect(newSmartPlaylistCriteria(criteria.Criteria{Sort: "title"}).OrderBy()).To(Equal("media_file.title asc")) diff --git a/persistence/smart_playlist_repository_test.go b/persistence/smart_playlist_repository_test.go index 207fe0c36..7bc705385 100644 --- a/persistence/smart_playlist_repository_test.go +++ b/persistence/smart_playlist_repository_test.go @@ -281,6 +281,71 @@ var _ = Describe("PlaylistRepository - Smart Playlists", func() { Expect(pls.Tracks).To(BeEmpty()) }) + + It("matches loved tracks when loved value is a string in nested group (issue #4826)", func() { + // songComeTogether (ID "1002") is starred in test fixtures + rules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Any{ + criteria.Is{"loved": "true"}, + }, + }, + } + newPls := model.Playlist{Name: "String Loved Nested", OwnerID: "userid", Rules: rules} + Expect(repo.Put(&newPls)).To(Succeed()) + testPlaylistID = newPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + pls, err := repo.GetWithTracks(newPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + trackIDs := make([]string, len(pls.Tracks)) + for i, t := range pls.Tracks { + trackIDs[i] = t.MediaFileID + } + Expect(trackIDs).To(ContainElement("1002")) + Expect(len(pls.Tracks)).To(BeNumerically(">=", 1)) + }) + + It("returns same results for string and bool loved values (issue #4826)", func() { + boolRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Any{ + criteria.Is{"loved": true}, + }, + }, + } + boolPls := model.Playlist{Name: "Bool Loved", OwnerID: "userid", Rules: boolRules} + Expect(repo.Put(&boolPls)).To(Succeed()) + DeferCleanup(func() { _ = repo.Delete(boolPls.ID) }) + + stringRules := &criteria.Criteria{ + Expression: criteria.All{ + criteria.Any{ + criteria.Is{"loved": "true"}, + }, + }, + } + stringPls := model.Playlist{Name: "String Loved", OwnerID: "userid", Rules: stringRules} + Expect(repo.Put(&stringPls)).To(Succeed()) + testPlaylistID = stringPls.ID + + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + boolResult, err := repo.GetWithTracks(boolPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + stringResult, err := repo.GetWithTracks(stringPls.ID, true, false) + Expect(err).ToNot(HaveOccurred()) + + boolIDs := make([]string, len(boolResult.Tracks)) + for i, t := range boolResult.Tracks { + boolIDs[i] = t.MediaFileID + } + stringIDs := make([]string, len(stringResult.Tracks)) + for i, t := range stringResult.Tracks { + stringIDs[i] = t.MediaFileID + } + Expect(stringIDs).To(ConsistOf(boolIDs)) + }) }) Describe("Smart Playlists with Tag Criteria", func() { diff --git a/server/e2e/subsonic_playlists_test.go b/server/e2e/subsonic_playlists_test.go index 3468979f4..466e68cf0 100644 --- a/server/e2e/subsonic_playlists_test.go +++ b/server/e2e/subsonic_playlists_test.go @@ -517,4 +517,134 @@ var _ = Describe("Playlist Endpoints", Ordered, func() { Expect(resp.Status).To(Equal(responses.StatusFailed)) }) }) + + Describe("Smart Playlist Boolean String Normalization (issue #4826)", Ordered, func() { + var songID string + var boolPlaylistID, stringPlaylistID, nestedPlaylistID string + + BeforeAll(func() { + setupTestDB() + + songs, err := ds.MediaFile(ctx).GetAll(model.QueryOptions{Sort: "title", Max: 1}) + Expect(err).ToNot(HaveOccurred()) + Expect(songs).ToNot(BeEmpty()) + songID = songs[0].ID + + // Star the song via the Subsonic API + resp := doReq("star", "id", songID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + + // Force immediate refresh for all smart playlists + conf.Server.SmartPlaylistRefreshDelay = -1 * time.Second + + // Create smart playlist with boolean true + boolPls := &model.Playlist{ + Name: "Bool Loved", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": true}}}, + } + Expect(ds.Playlist(ctx).Put(boolPls)).To(Succeed()) + boolPlaylistID = boolPls.ID + + // Create smart playlist with string "true" + stringPls := &model.Playlist{ + Name: "String Loved", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.Is{"loved": "true"}}}, + } + Expect(ds.Playlist(ctx).Put(stringPls)).To(Succeed()) + stringPlaylistID = stringPls.ID + + // Create smart playlist with string "true" in nested any group (exact issue #4826 scenario) + nestedPls := &model.Playlist{ + Name: "Nested String Loved", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{ + criteria.Any{ + criteria.Is{"loved": "true"}, + }, + }}, + } + Expect(ds.Playlist(ctx).Put(nestedPls)).To(Succeed()) + nestedPlaylistID = nestedPls.ID + }) + + It("smart playlist with bool loved=true returns starred song", func() { + resp := doReq("getPlaylist", "id", boolPlaylistID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1))) + entryIDs := make([]string, len(resp.Playlist.Entry)) + for i, e := range resp.Playlist.Entry { + entryIDs[i] = e.Id + } + Expect(entryIDs).To(ContainElement(songID)) + }) + + It("smart playlist with string loved='true' returns same results as bool (issue #4826)", func() { + boolResp := doReq("getPlaylist", "id", boolPlaylistID) + stringResp := doReq("getPlaylist", "id", stringPlaylistID) + + Expect(stringResp.Status).To(Equal(responses.StatusOK)) + Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount)) + }) + + It("nested any group with string loved='true' returns starred song (issue #4826)", func() { + resp := doReq("getPlaylist", "id", nestedPlaylistID) + + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1))) + entryIDs := make([]string, len(resp.Playlist.Entry)) + for i, e := range resp.Playlist.Entry { + entryIDs[i] = e.Id + } + Expect(entryIDs).To(ContainElement(songID)) + }) + + It("isPresent with string 'true' matches songs that have the tag", func() { + pls := &model.Playlist{ + Name: "Genre Present String", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsPresent{"genre": "true"}}}, + } + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + + resp := doReq("getPlaylist", "id", pls.ID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.SongCount).To(BeNumerically(">=", int32(1))) + }) + + It("isMissing with string 'true' excludes songs that have the tag", func() { + pls := &model.Playlist{ + Name: "Genre Missing String", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}}, + } + Expect(ds.Playlist(ctx).Put(pls)).To(Succeed()) + + resp := doReq("getPlaylist", "id", pls.ID) + Expect(resp.Status).To(Equal(responses.StatusOK)) + Expect(resp.Playlist.SongCount).To(Equal(int32(0))) + }) + + It("isMissing with string 'true' returns same results as bool true", func() { + boolPls := &model.Playlist{ + Name: "Genre Missing Bool", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": true}}}, + } + Expect(ds.Playlist(ctx).Put(boolPls)).To(Succeed()) + + stringPls := &model.Playlist{ + Name: "Genre Missing String2", + OwnerID: adminUser.ID, + Rules: &criteria.Criteria{Expression: criteria.All{criteria.IsMissing{"genre": "true"}}}, + } + Expect(ds.Playlist(ctx).Put(stringPls)).To(Succeed()) + + boolResp := doReq("getPlaylist", "id", boolPls.ID) + stringResp := doReq("getPlaylist", "id", stringPls.ID) + Expect(stringResp.Playlist.SongCount).To(Equal(boolResp.Playlist.SongCount)) + }) + }) }) From ac91401755e849967cb18a549dcee87f5ee9aa0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 2 May 2026 16:14:53 -0400 Subject: [PATCH 055/117] feat(plugins): add PlaybackReport to scrobbler capability (#5452) * feat(plugins): add PlaybackReport to Scrobbler interface and all implementations * feat(plugins): add PlaybackReport worker and dispatch in PlayTracker * feat(plugins): add PlaybackReportRequest to plugin scrobbler capability * chore(plugins): regenerate PDK files with PlaybackReport * feat(plugins): add PlaybackReport to test scrobbler plugin * feat(plugins): add PlaybackReport to plugin scrobbler adapter * refactor(plugins): fix double DB fetch in StateStopped and batch getActiveScrobblers - Hoist mf from scrobble branch so PlaybackReport reuses it instead of fetching again from DB - Call getActiveScrobblers once per drain batch instead of per-entry * chore(plugins): include generated scrobbler schema with PlaybackReport * fix(plugins): skip PlaybackReport for plugins that don't export it Plugins detected as scrobblers only need to export one scrobbler function. Older plugins that don't export nd_scrobbler_playback_report would cause noisy error logs on every reportPlayback call. Now errFunctionNotFound and errNotImplemented are treated as no-ops. * refactor: rename NowPlayingInfo to PlaybackReport Signed-off-by: Deluan * refactor: rename stopNowPlayingWorker to stopBackgroundWorkers Signed-off-by: Deluan * refactor: move NowPlaying and PlaybackReport logic to separate worker files Signed-off-by: Deluan * refactor(scrobbler): rename NowPlayingInfo to PlaybackSession and add expired state Rename NowPlayingInfo struct to PlaybackSession to better reflect its role as a complete playback session representation. Add UserId field to make sessions self-contained, removing redundant userId parameters from PlaybackReport interface method and internal dispatch functions. Introduce StateExpired internal state that fires when a session cache entry expires without an explicit stop, ensuring plugins always receive a terminal event regardless of client behavior. * fix(scrobbler): update playback state description to include 'expired' Signed-off-by: Deluan * fix(scrobbler): resolve data race in OnExpiration callback Capture conf.Server.EnableNowPlaying at construction time instead of reading it from the background ttlcache eviction goroutine. The previous code raced with test config cleanup that writes to the same field concurrently. * fix(scrobbler): return error when media file lookup fails in StateStopped Simplify the MediaFile population logic in the stopped case to return an error if the track cannot be found. A stop report with an empty MediaFile is useless to plugins, and returning the error allows clients to retry or alert the user when auto-scrobble is enabled. * refactor(scrobbler): use session data directly in PlaybackReport adapter Use info.Username from PlaybackSession instead of extracting it from context in the plugin adapter, since the session is now self-contained. Add debug/trace logging for session expiration and enqueue the expired report with a user-enriched context so downstream handlers can identify the user. --------- Signed-off-by: Deluan --- adapters/lastfm/agent.go | 4 + adapters/listenbrainz/agent.go | 4 + core/scrobbler/buffered_scrobbler.go | 8 + core/scrobbler/interfaces.go | 1 + core/scrobbler/nowplaying_worker.go | 78 +++++++ core/scrobbler/play_tracker.go | 159 +++++++-------- core/scrobbler/play_tracker_test.go | 191 +++++++++++++++--- core/scrobbler/playbackreport_worker.go | 64 ++++++ plugins/capabilities/scrobbler.go | 26 ++- plugins/capabilities/scrobbler.yaml | 44 ++++ plugins/pdk/go/scrobbler/scrobbler.go | 53 ++++- plugins/pdk/go/scrobbler/scrobbler_stub.go | 24 ++- .../rust/nd-pdk-capabilities/src/scrobbler.rs | 41 +++- plugins/scrobbler_adapter.go | 29 ++- plugins/scrobbler_adapter_test.go | 56 +++++ plugins/testdata/test-scrobbler/main.go | 14 ++ server/subsonic/album_lists.go | 2 +- server/subsonic/media_annotation_test.go | 2 +- 18 files changed, 680 insertions(+), 120 deletions(-) create mode 100644 core/scrobbler/nowplaying_worker.go create mode 100644 core/scrobbler/playbackreport_worker.go diff --git a/adapters/lastfm/agent.go b/adapters/lastfm/agent.go index b3e89a9dc..02c198120 100644 --- a/adapters/lastfm/agent.go +++ b/adapters/lastfm/agent.go @@ -416,6 +416,10 @@ func (l *lastfmAgent) IsAuthorized(ctx context.Context, userId string) bool { return err == nil && sk != "" } +func (l *lastfmAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error { + return nil +} + func init() { conf.AddHook(func() { agents.Register(lastFMAgentName, func(ds model.DataStore) agents.Interface { diff --git a/adapters/listenbrainz/agent.go b/adapters/listenbrainz/agent.go index 019c6e9f4..826a9672e 100644 --- a/adapters/listenbrainz/agent.go +++ b/adapters/listenbrainz/agent.go @@ -212,6 +212,10 @@ func (l *listenBrainzAgent) GetSimilarSongsByTrack(ctx context.Context, id strin return songs, nil } +func (l *listenBrainzAgent) PlaybackReport(context.Context, scrobbler.PlaybackSession) error { + return nil +} + func init() { conf.AddHook(func() { if conf.Server.ListenBrainz.Enabled { diff --git a/core/scrobbler/buffered_scrobbler.go b/core/scrobbler/buffered_scrobbler.go index be36e1f24..67593e9eb 100644 --- a/core/scrobbler/buffered_scrobbler.go +++ b/core/scrobbler/buffered_scrobbler.go @@ -80,6 +80,14 @@ func (b *bufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrob return nil } +func (b *bufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { + s, ok := b.loader() + if !ok { + return errors.New("scrobbler not available") + } + return s.PlaybackReport(ctx, info) +} + func (b *bufferedScrobbler) sendWakeSignal() { // Don't block if the previous signal was not read yet select { diff --git a/core/scrobbler/interfaces.go b/core/scrobbler/interfaces.go index f8567e91b..8a18bb37e 100644 --- a/core/scrobbler/interfaces.go +++ b/core/scrobbler/interfaces.go @@ -23,6 +23,7 @@ type Scrobbler interface { IsAuthorized(ctx context.Context, userId string) bool NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error Scrobble(ctx context.Context, userId string, s Scrobble) error + PlaybackReport(ctx context.Context, info PlaybackSession) error } type Constructor func(ds model.DataStore) Scrobbler diff --git a/core/scrobbler/nowplaying_worker.go b/core/scrobbler/nowplaying_worker.go new file mode 100644 index 000000000..1bacec689 --- /dev/null +++ b/core/scrobbler/nowplaying_worker.go @@ -0,0 +1,78 @@ +package scrobbler + +import ( + "context" + "time" + + "github.com/navidrome/navidrome/consts" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/model" +) + +func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) { + p.npMu.Lock() + defer p.npMu.Unlock() + ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing + p.npQueue[playerId] = nowPlayingEntry{ + ctx: ctx, + userId: userId, + track: track, + position: position, + } + p.sendNowPlayingSignal() +} + +func (p *playTracker) sendNowPlayingSignal() { + // Don't block if the previous signal was not read yet + select { + case p.npSignal <- struct{}{}: + default: + } +} + +func (p *playTracker) nowPlayingWorker() { + defer close(p.workerDone) + for { + select { + case <-p.shutdown: + return + case <-time.After(time.Second): + case <-p.npSignal: + } + + p.npMu.Lock() + if len(p.npQueue) == 0 { + p.npMu.Unlock() + continue + } + + // Keep a copy of the entries to process and clear the queue + entries := p.npQueue + p.npQueue = make(map[string]nowPlayingEntry) + p.npMu.Unlock() + + // Process entries without holding lock + for _, entry := range entries { + p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position) + } + } +} + +func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) { + if t.Artist == consts.UnknownArtist { + log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist) + return + } + allScrobblers := p.getActiveScrobblers() + for name, s := range allScrobblers { + if !s.IsAuthorized(ctx, userId) { + continue + } + log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position) + err := s.NowPlaying(ctx, userId, t, position) + if err != nil { + log.Error(ctx, "Error sending PlaybackSession", "scrobbler", name, "track", t.Title, "artist", t.Artist, err) + continue + } + } +} diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go index 161d04d7d..bdb261ef2 100644 --- a/core/scrobbler/play_tracker.go +++ b/core/scrobbler/play_tracker.go @@ -22,6 +22,7 @@ const ( StatePlaying = "playing" StatePaused = "paused" StateStopped = "stopped" + StateExpired = "expired" ) var ValidStates = map[string]bool{ @@ -31,9 +32,10 @@ var ValidStates = map[string]bool{ StateStopped: true, } -type NowPlayingInfo struct { +type PlaybackSession struct { MediaFile model.MediaFile Start time.Time + UserId string Username string PlayerId string PlayerName string @@ -65,8 +67,13 @@ type nowPlayingEntry struct { position int } +type playbackReportEntry struct { + ctx context.Context + info PlaybackSession +} + type PlayTracker interface { - GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error) + GetNowPlaying(ctx context.Context) ([]PlaybackSession, error) Submit(ctx context.Context, submissions []Submission) error ReportPlayback(ctx context.Context, params ReportPlaybackParams) error } @@ -81,7 +88,7 @@ type PluginLoader interface { type playTracker struct { ds model.DataStore broker events.Broker - playMap cache.SimpleCache[string, NowPlayingInfo] + playMap cache.SimpleCache[string, PlaybackSession] builtinScrobblers map[string]Scrobbler pluginScrobblers map[string]Scrobbler pluginLoader PluginLoader @@ -91,6 +98,10 @@ type playTracker struct { npSignal chan struct{} shutdown chan struct{} workerDone chan struct{} + prQueue []playbackReportEntry + prMu sync.Mutex + prSignal chan struct{} + prWorkerDone chan struct{} } func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker { @@ -106,7 +117,7 @@ func NewPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug } func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) *playTracker { - m := cache.NewSimpleCache[string, NowPlayingInfo]() + m := cache.NewSimpleCache[string, PlaybackSession]() p := &playTracker{ ds: ds, playMap: m, @@ -118,12 +129,24 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug npSignal: make(chan struct{}, 1), shutdown: make(chan struct{}), workerDone: make(chan struct{}), + prSignal: make(chan struct{}, 1), + prWorkerDone: make(chan struct{}), } - if conf.Server.EnableNowPlaying { - m.OnExpiration(func(_ string, _ NowPlayingInfo) { + enableNowPlaying := conf.Server.EnableNowPlaying + m.OnExpiration(func(_ string, info PlaybackSession) { + log.Debug("PlaybackSession expired", "clientId", info.PlayerId, "mediaId", info.MediaFile.ID, "state", + info.State, "username", info.Username, "userId", info.UserId) + if enableNowPlaying { broker.SendBroadcastMessage(context.Background(), &events.NowPlayingCount{Count: m.Len()}) - }) - } + } + ctx := request.WithUser(context.Background(), model.User{ID: info.UserId, UserName: info.Username}) + if info.State != StateStopped { + log.Trace("Enqueueing PlaybackReport for expired session", "session", info) + info.State = StateExpired + info.LastReport = time.Now() + p.enqueuePlaybackReport(ctx, info) + } + }) var enabled []string for name, constructor := range constructors { @@ -138,13 +161,15 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug } log.Debug("List of builtin scrobblers enabled", "names", enabled) go p.nowPlayingWorker() + go p.playbackReportWorker() return p } -// stopNowPlayingWorker stops the background worker. This is primarily for testing. -func (p *playTracker) stopNowPlayingWorker() { +// stopBackgroundWorkers stops the background workers. This is primarily for testing. +func (p *playTracker) stopBackgroundWorkers() { close(p.shutdown) - <-p.workerDone // Wait for worker to finish + <-p.workerDone // Wait for nowPlaying worker to finish + <-p.prWorkerDone // Wait for playbackReport worker to finish } // pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers. @@ -247,9 +272,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if err != nil { return err } - info := NowPlayingInfo{ + info := PlaybackSession{ MediaFile: *mf, Start: now, + UserId: user.ID, Username: user.UserName, PlayerId: clientId, PlayerName: client, @@ -260,8 +286,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP } err = p.playMap.AddWithTTL(clientId, info, remainingTTL(mf.Duration, params.PositionMs, params.PlaybackRate)) if err != nil { - log.Warn(ctx, "Error adding NowPlayingInfo to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) + log.Warn(ctx, "Error adding PlaybackSession to cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } + p.enqueuePlaybackReport(ctx, info) case StatePlaying, StatePaused: info, getErr := p.playMap.Get(clientId) @@ -270,9 +297,10 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if err != nil { return err } - info = NowPlayingInfo{ + info = PlaybackSession{ MediaFile: *mf, Start: now.Add(-time.Duration(params.PositionMs) * time.Millisecond), + UserId: user.ID, Username: user.UserName, PlayerId: clientId, PlayerName: client, @@ -286,17 +314,21 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP if params.State == StatePlaying { ttl = remainingTTL(info.MediaFile.Duration, params.PositionMs, params.PlaybackRate) } + log.Trace(ctx, "Updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, "positionMs", params.PositionMs, "playbackRate", params.PlaybackRate, "ttl", ttl) err := p.playMap.AddWithTTL(clientId, info, ttl) if err != nil { - log.Warn(ctx, "Error updating NowPlayingInfo in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) + log.Warn(ctx, "Error updating PlaybackSession in cache", "clientId", clientId, "mediaId", params.MediaId, "state", params.State, err) } + p.enqueuePlaybackReport(ctx, info) case StateStopped: + var loadedMF *model.MediaFile if !params.IgnoreScrobble && player.ScrobbleEnabled { mf, err := p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) if err != nil { return err } + loadedMF = mf trackDurationMs := int64(mf.Duration * 1000) threshold := min(trackDurationMs*50/100, 240_000) if params.PositionMs >= threshold { @@ -307,6 +339,31 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP p.dispatchScrobble(ctx, mf, now) } } + stoppedInfo := PlaybackSession{ + UserId: user.ID, + Username: user.UserName, + PlayerId: clientId, + PlayerName: client, + State: params.State, + PositionMs: params.PositionMs, + PlaybackRate: params.PlaybackRate, + LastReport: now, + } + if info, getErr := p.playMap.Get(clientId); getErr == nil { + stoppedInfo.MediaFile = info.MediaFile + stoppedInfo.Start = info.Start + } else { + mf := loadedMF + if mf == nil { + var mfErr error + mf, mfErr = p.ds.MediaFile(ctx).GetWithParticipants(params.MediaId) + if mfErr != nil { + return mfErr + } + } + stoppedInfo.MediaFile = *mf + } + p.enqueuePlaybackReport(ctx, stoppedInfo) p.playMap.Remove(clientId) } @@ -324,77 +381,9 @@ func (p *playTracker) ReportPlayback(ctx context.Context, params ReportPlaybackP return nil } -func (p *playTracker) enqueueNowPlaying(ctx context.Context, playerId string, userId string, track *model.MediaFile, position int) { - p.npMu.Lock() - defer p.npMu.Unlock() - ctx = context.WithoutCancel(ctx) // Prevent cancellation from affecting background processing - p.npQueue[playerId] = nowPlayingEntry{ - ctx: ctx, - userId: userId, - track: track, - position: position, - } - p.sendNowPlayingSignal() -} - -func (p *playTracker) sendNowPlayingSignal() { - // Don't block if the previous signal was not read yet - select { - case p.npSignal <- struct{}{}: - default: - } -} - -func (p *playTracker) nowPlayingWorker() { - defer close(p.workerDone) - for { - select { - case <-p.shutdown: - return - case <-time.After(time.Second): - case <-p.npSignal: - } - - p.npMu.Lock() - if len(p.npQueue) == 0 { - p.npMu.Unlock() - continue - } - - // Keep a copy of the entries to process and clear the queue - entries := p.npQueue - p.npQueue = make(map[string]nowPlayingEntry) - p.npMu.Unlock() - - // Process entries without holding lock - for _, entry := range entries { - p.dispatchNowPlaying(entry.ctx, entry.userId, entry.track, entry.position) - } - } -} - -func (p *playTracker) dispatchNowPlaying(ctx context.Context, userId string, t *model.MediaFile, position int) { - if t.Artist == consts.UnknownArtist { - log.Debug(ctx, "Ignoring external NowPlaying update for track with unknown artist", "track", t.Title, "artist", t.Artist) - return - } - allScrobblers := p.getActiveScrobblers() - for name, s := range allScrobblers { - if !s.IsAuthorized(ctx, userId) { - continue - } - log.Debug(ctx, "Sending NowPlaying update", "scrobbler", name, "track", t.Title, "artist", t.Artist, "position", position) - err := s.NowPlaying(ctx, userId, t, position) - if err != nil { - log.Error(ctx, "Error sending NowPlayingInfo", "scrobbler", name, "track", t.Title, "artist", t.Artist, err) - continue - } - } -} - -func (p *playTracker) GetNowPlaying(_ context.Context) ([]NowPlayingInfo, error) { +func (p *playTracker) GetNowPlaying(_ context.Context) ([]PlaybackSession, error) { res := p.playMap.Values() - slices.SortFunc(res, func(a, b NowPlayingInfo) int { + slices.SortFunc(res, func(a, b PlaybackSession) int { return b.Start.Compare(a.Start) }) for i := range res { diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go index 734f8f66f..684b887fe 100644 --- a/core/scrobbler/play_tracker_test.go +++ b/core/scrobbler/play_tracker_test.go @@ -48,7 +48,7 @@ func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) { var _ = Describe("PlayTracker", func() { var ctx context.Context var ds model.DataStore - var tracker PlayTracker + var tracker *playTracker var eventBroker *fakeEventBroker var track model.MediaFile var album model.Album @@ -71,7 +71,7 @@ var _ = Describe("PlayTracker", func() { }) eventBroker = &fakeEventBroker{} tracker = newPlayTracker(ds, eventBroker, nil) - tracker.(*playTracker).builtinScrobblers["fake"] = fake // Bypass buffering for tests + tracker.builtinScrobblers["fake"] = fake // Bypass buffering for tests track = model.MediaFile{ ID: "123", @@ -96,12 +96,12 @@ var _ = Describe("PlayTracker", func() { AfterEach(func() { // Stop the worker goroutine to prevent data races between tests - tracker.(*playTracker).stopNowPlayingWorker() + tracker.stopBackgroundWorkers() }) It("does not register disabled scrobblers", func() { - Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake")) - Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled")) + Expect(tracker.builtinScrobblers).To(HaveKey("fake")) + Expect(tracker.builtinScrobblers).ToNot(HaveKey("disabled")) }) Describe("GetNowPlaying", func() { @@ -138,8 +138,8 @@ var _ = Describe("PlayTracker", func() { Describe("Expiration events", func() { It("sends event when entry expires", func() { - info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} - _ = tracker.(*playTracker).playMap.AddWithTTL("player-1", info, 10*time.Millisecond) + info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"} + _ = tracker.playMap.AddWithTTL("player-1", info, 10*time.Millisecond) Eventually(func() int { return len(eventBroker.getEvents()) }).Should(BeNumerically(">", 0)) eventList := eventBroker.getEvents() evt, ok := eventList[len(eventList)-1].(*events.NowPlayingCount) @@ -150,10 +150,48 @@ var _ = Describe("PlayTracker", func() { It("does not send event when disabled", func() { conf.Server.EnableNowPlaying = false tracker = newPlayTracker(ds, eventBroker, nil) - info := NowPlayingInfo{MediaFile: track, Start: time.Now(), Username: "user"} - _ = tracker.(*playTracker).playMap.AddWithTTL("player-2", info, 10*time.Millisecond) + info := PlaybackSession{MediaFile: track, Start: time.Now(), Username: "user"} + _ = tracker.playMap.AddWithTTL("player-2", info, 10*time.Millisecond) Consistently(func() int { return len(eventBroker.getEvents()) }).Should(Equal(0)) }) + + It("sends expired playback report when session expires", func() { + info := PlaybackSession{ + MediaFile: track, + Start: time.Now(), + UserId: "u-1", + Username: "user", + PlayerId: "player-3", + PlayerName: "test-player", + State: StatePlaying, + PositionMs: 5000, + } + _ = tracker.playMap.AddWithTTL("player-3", info, 10*time.Millisecond) + Eventually(func() *PlaybackSession { + return fake.LastPlaybackReport.Load() + }).ShouldNot(BeNil()) + report := fake.LastPlaybackReport.Load() + Expect(report.State).To(Equal(StateExpired)) + Expect(report.MediaFile.ID).To(Equal("123")) + Expect(report.PlayerId).To(Equal("player-3")) + }) + + It("does not send expired report when session was already stopped", func() { + info := PlaybackSession{ + MediaFile: track, + Start: time.Now(), + UserId: "u-1", + Username: "user", + PlayerId: "player-4", + PlayerName: "test-player", + State: StateStopped, + PositionMs: 180000, + } + _ = tracker.playMap.AddWithTTL("player-4", info, 10*time.Millisecond) + Consistently(func() *PlaybackSession { + return fake.LastPlaybackReport.Load() + }).Should(BeNil()) + }) }) Describe("Submit", func() { @@ -375,7 +413,7 @@ var _ = Describe("PlayTracker", func() { BeforeEach(func() { eventBroker = &fakeEventBroker{} tracker = newPlayTracker(ds, eventBroker, nil) - tracker.(*playTracker).builtinScrobblers["fake"] = fake + tracker.builtinScrobblers["fake"] = fake }) It("broadcasts NowPlayingCount on every state change", func() { @@ -420,7 +458,7 @@ var _ = Describe("PlayTracker", func() { It("does NOT broadcast when EnableNowPlaying is false", func() { conf.Server.EnableNowPlaying = false tracker = newPlayTracker(ds, eventBroker, nil) - tracker.(*playTracker).builtinScrobblers["fake"] = fake + tracker.builtinScrobblers["fake"] = fake err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ MediaId: "123", PositionMs: 0, State: "starting", PlaybackRate: 1.0, ClientId: defaultClientId, @@ -697,6 +735,96 @@ var _ = Describe("PlayTracker", func() { Consistently(func() bool { return fake.GetNowPlayingCalled() }).Should(BeFalse()) }) }) + + Describe("PlaybackReport dispatch", func() { + It("dispatches PlaybackReport for starting state", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { + return fake.PlaybackReportCalled.Load() + }).Should(BeTrue()) + + info := fake.LastPlaybackReport.Load() + Expect(info).ToNot(BeNil()) + Expect(info.MediaFile.ID).To(Equal("123")) + Expect(info.State).To(Equal(StateStarting)) + Expect(info.PositionMs).To(Equal(int64(0))) + Expect(info.PlaybackRate).To(Equal(1.0)) + Expect(info.PlayerId).To(Equal("client-1")) + Expect(info.PlayerName).To(Equal("Test Player")) + }) + + It("dispatches PlaybackReport for playing state", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + fake.PlaybackReportCalled.Store(false) + fake.LastPlaybackReport.Store(nil) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 30000, State: StatePlaying, PlaybackRate: 1.5, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + info := fake.LastPlaybackReport.Load() + Expect(info.State).To(Equal(StatePlaying)) + Expect(info.PositionMs).To(Equal(int64(30000))) + Expect(info.PlaybackRate).To(Equal(1.5)) + }) + + It("dispatches PlaybackReport for paused state", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + fake.PlaybackReportCalled.Store(false) + fake.LastPlaybackReport.Store(nil) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 45000, State: StatePaused, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + info := fake.LastPlaybackReport.Load() + Expect(info.State).To(Equal(StatePaused)) + Expect(info.PositionMs).To(Equal(int64(45000))) + }) + + It("dispatches PlaybackReport for stopped state", func() { + err := tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 0, State: StateStarting, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + fake.PlaybackReportCalled.Store(false) + fake.LastPlaybackReport.Store(nil) + + err = tracker.ReportPlayback(ctx, ReportPlaybackParams{ + MediaId: "123", PositionMs: 100000, State: StateStopped, PlaybackRate: 1.0, + ClientId: "client-1", ClientName: "Test Player", + }) + Expect(err).ToNot(HaveOccurred()) + + Eventually(func() bool { return fake.PlaybackReportCalled.Load() }).Should(BeTrue()) + info := fake.LastPlaybackReport.Load() + Expect(info.State).To(Equal(StateStopped)) + Expect(info.PositionMs).To(Equal(int64(100000))) + }) + }) }) Describe("Plugin scrobbler logic", func() { @@ -712,8 +840,8 @@ var _ = Describe("PlayTracker", func() { tracker = newPlayTracker(ds, events.GetBroker(), pluginLoader) // Bypass buffering for both built-in and plugin scrobblers - tracker.(*playTracker).builtinScrobblers["fake"] = fake - tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake + tracker.builtinScrobblers["fake"] = fake + tracker.pluginScrobblers["plugin1"] = pluginFake }) It("registers and uses plugin scrobbler for NowPlaying", func() { @@ -830,7 +958,7 @@ var _ = Describe("PlayTracker", func() { }) AfterEach(func() { - pTracker.stopNowPlayingWorker() + pTracker.stopBackgroundWorkers() }) It("uses the new plugin instance after reload (simulating config update)", func() { @@ -937,15 +1065,17 @@ var _ = DescribeTable("remainingTTL", ) type fakeScrobbler struct { - Authorized bool - nowPlayingCalled atomic.Bool - ScrobbleCalled atomic.Bool - userID atomic.Pointer[string] - username atomic.Pointer[string] - track atomic.Pointer[model.MediaFile] - position atomic.Int32 - LastScrobble atomic.Pointer[Scrobble] - Error error + Authorized bool + nowPlayingCalled atomic.Bool + ScrobbleCalled atomic.Bool + PlaybackReportCalled atomic.Bool + userID atomic.Pointer[string] + username atomic.Pointer[string] + track atomic.Pointer[model.MediaFile] + position atomic.Int32 + LastScrobble atomic.Pointer[Scrobble] + LastPlaybackReport atomic.Pointer[PlaybackSession] + Error error } func (f *fakeScrobbler) GetNowPlayingCalled() bool { @@ -998,6 +1128,17 @@ func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) return nil } +func (f *fakeScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { + f.PlaybackReportCalled.Store(true) + if f.Error != nil { + return f.Error + } + uid := info.UserId + f.userID.Store(&uid) + f.LastPlaybackReport.Store(&info) + return nil +} + func _p(id, name string, sortName ...string) model.Participant { p := model.Participant{Artist: model.Artist{ID: id, Name: name}} if len(sortName) > 0 { @@ -1053,3 +1194,7 @@ func (m *mockBufferedScrobbler) NowPlaying(ctx context.Context, userId string, t func (m *mockBufferedScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error { return m.wrapped.Scrobble(ctx, userId, s) } + +func (m *mockBufferedScrobbler) PlaybackReport(ctx context.Context, info PlaybackSession) error { + return m.wrapped.PlaybackReport(ctx, info) +} diff --git a/core/scrobbler/playbackreport_worker.go b/core/scrobbler/playbackreport_worker.go new file mode 100644 index 000000000..78ca6e0f7 --- /dev/null +++ b/core/scrobbler/playbackreport_worker.go @@ -0,0 +1,64 @@ +package scrobbler + +import ( + "context" + + "github.com/navidrome/navidrome/log" +) + +func (p *playTracker) enqueuePlaybackReport(ctx context.Context, info PlaybackSession) { + p.prMu.Lock() + defer p.prMu.Unlock() + ctx = context.WithoutCancel(ctx) + p.prQueue = append(p.prQueue, playbackReportEntry{ + ctx: ctx, + info: info, + }) + p.sendPlaybackReportSignal() +} + +func (p *playTracker) sendPlaybackReportSignal() { + select { + case p.prSignal <- struct{}{}: + default: + } +} + +func (p *playTracker) playbackReportWorker() { + defer close(p.prWorkerDone) + for { + select { + case <-p.shutdown: + return + case <-p.prSignal: + } + + p.prMu.Lock() + if len(p.prQueue) == 0 { + p.prMu.Unlock() + continue + } + entries := p.prQueue + p.prQueue = nil + p.prMu.Unlock() + + allScrobblers := p.getActiveScrobblers() + for _, entry := range entries { + p.dispatchPlaybackReport(entry.ctx, entry.info, allScrobblers) + } + } +} + +func (p *playTracker) dispatchPlaybackReport(ctx context.Context, info PlaybackSession, allScrobblers map[string]Scrobbler) { + for name, s := range allScrobblers { + if !s.IsAuthorized(ctx, info.UserId) { + continue + } + log.Debug(ctx, "Sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, "positionMs", info.PositionMs) + err := s.PlaybackReport(ctx, info) + if err != nil { + log.Error(ctx, "Error sending PlaybackReport", "scrobbler", name, "track", info.MediaFile.Title, "state", info.State, err) + continue + } + } +} diff --git a/plugins/capabilities/scrobbler.go b/plugins/capabilities/scrobbler.go index ed8a4fb6c..4918d5e8f 100644 --- a/plugins/capabilities/scrobbler.go +++ b/plugins/capabilities/scrobbler.go @@ -5,7 +5,7 @@ package capabilities // ListenBrainz, or custom scrobbling backends. // // All methods are required - plugins implementing this capability must provide -// all three functions: IsAuthorized, NowPlaying, and Scrobble. +// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. // //nd:capability name=scrobbler required=true type Scrobbler interface { @@ -20,6 +20,10 @@ type Scrobbler interface { // Scrobble submits a completed scrobble to the scrobbling service. //nd:export name=nd_scrobbler_scrobble Scrobble(ScrobbleRequest) error + + // PlaybackReport sends a playback state report to the scrobbling service. + //nd:export name=nd_scrobbler_playback_report + PlaybackReport(PlaybackReportRequest) error } // IsAuthorizedRequest is the request for authorization check. @@ -96,6 +100,26 @@ type ScrobbleRequest struct { Timestamp int64 `json:"timestamp"` } +// PlaybackReportRequest is the request for playback report notifications. +type PlaybackReportRequest struct { + // Username is the username of the user. + Username string `json:"username"` + // Track is the track being played. + Track TrackInfo `json:"track"` + // State is the current playback state (starting/playing/paused/stopped/expired). + State string `json:"state"` + // PositionMs is the current playback position in milliseconds. + PositionMs int64 `json:"positionMs"` + // PlaybackRate is the playback speed (1.0 = normal). + PlaybackRate float64 `json:"playbackRate"` + // PlayerId is the unique client identifier. + PlayerId string `json:"playerId"` + // PlayerName is the human-readable player name. + PlayerName string `json:"playerName"` + // Timestamp is the Unix timestamp when this report was generated. + Timestamp int64 `json:"timestamp"` +} + // ScrobblerError represents an error type for scrobbling operations. type ScrobblerError string diff --git a/plugins/capabilities/scrobbler.yaml b/plugins/capabilities/scrobbler.yaml index 8ada5f7e4..9d5cfed30 100644 --- a/plugins/capabilities/scrobbler.yaml +++ b/plugins/capabilities/scrobbler.yaml @@ -18,6 +18,11 @@ exports: input: $ref: '#/components/schemas/ScrobbleRequest' contentType: application/json + nd_scrobbler_playback_report: + description: PlaybackReport sends a playback state report to the scrobbling service. + input: + $ref: '#/components/schemas/PlaybackReportRequest' + contentType: application/json components: schemas: ArtistRef: @@ -59,6 +64,45 @@ components: - username - track - position + PlaybackReportRequest: + description: PlaybackReportRequest is the request for playback report notifications. + properties: + username: + type: string + description: Username is the username of the user. + track: + $ref: '#/components/schemas/TrackInfo' + description: Track is the track being played. + state: + type: string + description: State is the current playback state (starting/playing/paused/stopped/expired). + positionMs: + type: integer + format: int64 + description: PositionMs is the current playback position in milliseconds. + playbackRate: + type: number + format: float + description: PlaybackRate is the playback speed (1.0 = normal). + playerId: + type: string + description: PlayerId is the unique client identifier. + playerName: + type: string + description: PlayerName is the human-readable player name. + timestamp: + type: integer + format: int64 + description: Timestamp is the Unix timestamp when this report was generated. + required: + - username + - track + - state + - positionMs + - playbackRate + - playerId + - playerName + - timestamp ScrobbleRequest: description: ScrobbleRequest is the request for submitting a scrobble. properties: diff --git a/plugins/pdk/go/scrobbler/scrobbler.go b/plugins/pdk/go/scrobbler/scrobbler.go index d27ae3a9c..0d045e597 100644 --- a/plugins/pdk/go/scrobbler/scrobbler.go +++ b/plugins/pdk/go/scrobbler/scrobbler.go @@ -52,6 +52,26 @@ type NowPlayingRequest struct { Position int32 `json:"position"` } +// PlaybackReportRequest is the request for playback report notifications. +type PlaybackReportRequest struct { + // Username is the username of the user. + Username string `json:"username"` + // Track is the track being played. + Track TrackInfo `json:"track"` + // State is the current playback state (starting/playing/paused/stopped/expired). + State string `json:"state"` + // PositionMs is the current playback position in milliseconds. + PositionMs int64 `json:"positionMs"` + // PlaybackRate is the playback speed (1.0 = normal). + PlaybackRate float64 `json:"playbackRate"` + // PlayerId is the unique client identifier. + PlayerId string `json:"playerId"` + // PlayerName is the human-readable player name. + PlayerName string `json:"playerName"` + // Timestamp is the Unix timestamp when this report was generated. + Timestamp int64 `json:"timestamp"` +} + // ScrobbleRequest is the request for submitting a scrobble. type ScrobbleRequest struct { // Username is the username of the user. @@ -106,7 +126,7 @@ type TrackInfo struct { // ListenBrainz, or custom scrobbling backends. // // All methods are required - plugins implementing this capability must provide -// all three functions: IsAuthorized, NowPlaying, and Scrobble. +// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. type Scrobbler interface { // IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. IsAuthorized(IsAuthorizedRequest) (bool, error) @@ -114,11 +134,14 @@ type Scrobbler interface { NowPlaying(NowPlayingRequest) error // Scrobble - Scrobble submits a completed scrobble to the scrobbling service. Scrobble(ScrobbleRequest) error + // PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service. + PlaybackReport(PlaybackReportRequest) error } // Internal implementation holders var ( - isAuthorizedImpl func(IsAuthorizedRequest) (bool, error) - nowPlayingImpl func(NowPlayingRequest) error - scrobbleImpl func(ScrobbleRequest) error + isAuthorizedImpl func(IsAuthorizedRequest) (bool, error) + nowPlayingImpl func(NowPlayingRequest) error + scrobbleImpl func(ScrobbleRequest) error + playbackReportImpl func(PlaybackReportRequest) error ) // Register registers a scrobbler implementation. @@ -127,6 +150,7 @@ func Register(impl Scrobbler) { isAuthorizedImpl = impl.IsAuthorized nowPlayingImpl = impl.NowPlaying scrobbleImpl = impl.Scrobble + playbackReportImpl = impl.PlaybackReport } // NotImplementedCode is the standard return code for unimplemented functions. @@ -201,3 +225,24 @@ func _NdScrobblerScrobble() int32 { return 0 } + +//go:wasmexport nd_scrobbler_playback_report +func _NdScrobblerPlaybackReport() int32 { + if playbackReportImpl == nil { + // Return standard code - host will skip this plugin gracefully + return NotImplementedCode + } + + var input PlaybackReportRequest + if err := pdk.InputJSON(&input); err != nil { + pdk.SetError(err) + return -1 + } + + if err := playbackReportImpl(input); err != nil { + pdk.SetError(err) + return -1 + } + + return 0 +} diff --git a/plugins/pdk/go/scrobbler/scrobbler_stub.go b/plugins/pdk/go/scrobbler/scrobbler_stub.go index 9e6f706ac..b35e7c40e 100644 --- a/plugins/pdk/go/scrobbler/scrobbler_stub.go +++ b/plugins/pdk/go/scrobbler/scrobbler_stub.go @@ -49,6 +49,26 @@ type NowPlayingRequest struct { Position int32 `json:"position"` } +// PlaybackReportRequest is the request for playback report notifications. +type PlaybackReportRequest struct { + // Username is the username of the user. + Username string `json:"username"` + // Track is the track being played. + Track TrackInfo `json:"track"` + // State is the current playback state (starting/playing/paused/stopped/expired). + State string `json:"state"` + // PositionMs is the current playback position in milliseconds. + PositionMs int64 `json:"positionMs"` + // PlaybackRate is the playback speed (1.0 = normal). + PlaybackRate float64 `json:"playbackRate"` + // PlayerId is the unique client identifier. + PlayerId string `json:"playerId"` + // PlayerName is the human-readable player name. + PlayerName string `json:"playerName"` + // Timestamp is the Unix timestamp when this report was generated. + Timestamp int64 `json:"timestamp"` +} + // ScrobbleRequest is the request for submitting a scrobble. type ScrobbleRequest struct { // Username is the username of the user. @@ -103,7 +123,7 @@ type TrackInfo struct { // ListenBrainz, or custom scrobbling backends. // // All methods are required - plugins implementing this capability must provide -// all three functions: IsAuthorized, NowPlaying, and Scrobble. +// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. type Scrobbler interface { // IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. IsAuthorized(IsAuthorizedRequest) (bool, error) @@ -111,6 +131,8 @@ type Scrobbler interface { NowPlaying(NowPlayingRequest) error // Scrobble - Scrobble submits a completed scrobble to the scrobbling service. Scrobble(ScrobbleRequest) error + // PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service. + PlaybackReport(PlaybackReportRequest) error } // NotImplementedCode is the standard return code for unimplemented functions. diff --git a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs index 348460374..1e9c51375 100644 --- a/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs +++ b/plugins/pdk/rust/nd-pdk-capabilities/src/scrobbler.rs @@ -62,6 +62,35 @@ pub struct NowPlayingRequest { #[serde(default)] pub position: i32, } +/// PlaybackReportRequest is the request for playback report notifications. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PlaybackReportRequest { + /// Username is the username of the user. + #[serde(default)] + pub username: String, + /// Track is the track being played. + #[serde(default)] + pub track: TrackInfo, + /// State is the current playback state (starting/playing/paused/stopped/expired). + #[serde(default)] + pub state: String, + /// PositionMs is the current playback position in milliseconds. + #[serde(default)] + pub position_ms: i64, + /// PlaybackRate is the playback speed (1.0 = normal). + #[serde(default)] + pub playback_rate: f64, + /// PlayerId is the unique client identifier. + #[serde(default)] + pub player_id: String, + /// PlayerName is the human-readable player name. + #[serde(default)] + pub player_name: String, + /// Timestamp is the Unix timestamp when this report was generated. + #[serde(default)] + pub timestamp: i64, +} /// ScrobbleRequest is the request for submitting a scrobble. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -158,7 +187,7 @@ impl Error { /// ListenBrainz, or custom scrobbling backends. /// /// All methods are required - plugins implementing this capability must provide -/// all three functions: IsAuthorized, NowPlaying, and Scrobble. +/// all four functions: IsAuthorized, NowPlaying, Scrobble, and PlaybackReport. pub trait Scrobbler { /// IsAuthorized - IsAuthorized checks if a user is authorized to scrobble to this service. fn is_authorized(&self, req: IsAuthorizedRequest) -> Result; @@ -166,6 +195,8 @@ pub trait Scrobbler { fn now_playing(&self, req: NowPlayingRequest) -> Result<(), Error>; /// Scrobble - Scrobble submits a completed scrobble to the scrobbling service. fn scrobble(&self, req: ScrobbleRequest) -> Result<(), Error>; + /// PlaybackReport - PlaybackReport sends a playback state report to the scrobbling service. + fn playback_report(&self, req: PlaybackReportRequest) -> Result<(), Error>; } /// Register all exports for the Scrobbler capability. @@ -197,5 +228,13 @@ macro_rules! register_scrobbler { $crate::scrobbler::Scrobbler::scrobble(&plugin, req.into_inner())?; Ok(()) } + #[extism_pdk::plugin_fn] + pub fn nd_scrobbler_playback_report( + req: extism_pdk::Json<$crate::scrobbler::PlaybackReportRequest> + ) -> extism_pdk::FnResult<()> { + let plugin = <$plugin_type>::default(); + $crate::scrobbler::Scrobbler::playback_report(&plugin, req.into_inner())?; + Ok(()) + } }; } diff --git a/plugins/scrobbler_adapter.go b/plugins/scrobbler_adapter.go index 302f5e1da..8abdccf07 100644 --- a/plugins/scrobbler_adapter.go +++ b/plugins/scrobbler_adapter.go @@ -2,6 +2,7 @@ package plugins import ( "context" + "errors" "strings" "github.com/navidrome/navidrome/core/scrobbler" @@ -16,9 +17,10 @@ const CapabilityScrobbler Capability = "Scrobbler" // Scrobbler function names (snake_case as per design) const ( - FuncScrobblerIsAuthorized = "nd_scrobbler_is_authorized" - FuncScrobblerNowPlaying = "nd_scrobbler_now_playing" - FuncScrobblerScrobble = "nd_scrobbler_scrobble" + FuncScrobblerIsAuthorized = "nd_scrobbler_is_authorized" + FuncScrobblerNowPlaying = "nd_scrobbler_now_playing" + FuncScrobblerScrobble = "nd_scrobbler_scrobble" + FuncScrobblerPlaybackReport = "nd_scrobbler_playback_report" ) func init() { @@ -27,6 +29,7 @@ func init() { FuncScrobblerIsAuthorized, FuncScrobblerNowPlaying, FuncScrobblerScrobble, + FuncScrobblerPlaybackReport, ) } @@ -182,5 +185,25 @@ func mapScrobblerError(err error) error { } } +// PlaybackReport sends a playback state report to the scrobbler +func (s *ScrobblerPlugin) PlaybackReport(ctx context.Context, info scrobbler.PlaybackSession) error { + input := capabilities.PlaybackReportRequest{ + Username: info.Username, + Track: mediaFileToTrackInfo(s.plugin, &info.MediaFile), + State: info.State, + PositionMs: info.PositionMs, + PlaybackRate: info.PlaybackRate, + PlayerId: info.PlayerId, + PlayerName: info.PlayerName, + Timestamp: info.LastReport.Unix(), + } + + err := callPluginFunctionNoOutput(ctx, s.plugin, FuncScrobblerPlaybackReport, input) + if errors.Is(err, errFunctionNotFound) || errors.Is(err, errNotImplemented) { + return nil + } + return mapScrobblerError(err) +} + // Verify interface implementation at compile time var _ scrobbler.Scrobbler = (*ScrobblerPlugin)(nil) diff --git a/plugins/scrobbler_adapter_test.go b/plugins/scrobbler_adapter_test.go index c56d8a900..56a452742 100644 --- a/plugins/scrobbler_adapter_test.go +++ b/plugins/scrobbler_adapter_test.go @@ -229,6 +229,62 @@ var _ = Describe("ScrobblerPlugin", Ordered, func() { }) }) + Describe("PlaybackReport", func() { + It("successfully calls the plugin", func() { + info := scrobbler.PlaybackSession{ + MediaFile: model.MediaFile{ + ID: "track-1", + Title: "Test Song", + Album: "Test Album", + Artist: "Test Artist", + AlbumArtist: "Test Album Artist", + Duration: 180, + TrackNumber: 1, + DiscNumber: 1, + Participants: model.Participants{ + model.RoleArtist: {{Artist: model.Artist{ID: "artist-1", Name: "Test Artist"}}}, + model.RoleAlbumArtist: {{Artist: model.Artist{ID: "album-artist-1", Name: "Test Album Artist"}}}, + }, + }, + Username: "testuser", + PlayerId: "player-1", + PlayerName: "Test Player", + State: "playing", + PositionMs: 30000, + PlaybackRate: 1.0, + LastReport: time.Now(), + } + + err := s.PlaybackReport(ctxWithUser(), info) + Expect(err).ToNot(HaveOccurred()) + }) + + Context("when plugin returns error", Ordered, func() { + var retryScrobbler scrobbler.Scrobbler + + BeforeAll(func() { + mgr, _ := createTestManagerWithPlugins(map[string]map[string]string{ + "test-scrobbler": {"error": "service unavailable", "error_type": "scrobbler(retry_later)"}, + }, "test-scrobbler"+PackageExtension) + + var ok bool + retryScrobbler, ok = mgr.LoadScrobbler("test-scrobbler") + Expect(ok).To(BeTrue()) + }) + + It("returns ErrRetryLater", func() { + info := scrobbler.PlaybackSession{ + MediaFile: model.MediaFile{ID: "track-1", Title: "Test Song"}, + State: "playing", + LastReport: time.Now(), + } + err := retryScrobbler.PlaybackReport(ctxWithUser(), info) + Expect(err).To(HaveOccurred()) + Expect(err).To(MatchError(scrobbler.ErrRetryLater)) + }) + }) + }) + Describe("PluginNames", func() { It("returns plugin names with Scrobbler capability", func() { names := scrobblerManager.PluginNames("Scrobbler") diff --git a/plugins/testdata/test-scrobbler/main.go b/plugins/testdata/test-scrobbler/main.go index d9c142d51..a8cee4a4e 100644 --- a/plugins/testdata/test-scrobbler/main.go +++ b/plugins/testdata/test-scrobbler/main.go @@ -53,6 +53,20 @@ func (t *testScrobbler) Scrobble(input scrobbler.ScrobbleRequest) error { return nil } +// PlaybackReport receives a playback state report. +func (t *testScrobbler) PlaybackReport(input scrobbler.PlaybackReportRequest) error { + if err := checkConfigError(); err != nil { + return err + } + + artistName := "" + if len(input.Track.Artists) > 0 { + artistName = input.Track.Artists[0].Name + } + pdk.Log(pdk.LogInfo, "PlaybackReport: "+input.Track.Title+" by "+artistName+" state="+input.State) + return nil +} + // checkConfigError checks if the plugin is configured to return an error. // If "error" config is set, it returns the appropriate ScrobblerError. // Error types: "not_authorized", "retry_later", "unrecoverable" diff --git a/server/subsonic/album_lists.go b/server/subsonic/album_lists.go index 2a2338d89..0d82c8be9 100644 --- a/server/subsonic/album_lists.go +++ b/server/subsonic/album_lists.go @@ -212,7 +212,7 @@ func (api *Router) GetNowPlaying(r *http.Request) (*responses.Subsonic, error) { response := newResponse() response.NowPlaying = &responses.NowPlaying{} var i int32 - response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.NowPlayingInfo) responses.NowPlayingEntry { + response.NowPlaying.Entry = slice.Map(npInfo, func(np scrobbler.PlaybackSession) responses.NowPlayingEntry { i++ return responses.NowPlayingEntry{ Child: childFromMediaFile(ctx, np.MediaFile), diff --git a/server/subsonic/media_annotation_test.go b/server/subsonic/media_annotation_test.go index f5cf434e0..487335d1a 100644 --- a/server/subsonic/media_annotation_test.go +++ b/server/subsonic/media_annotation_test.go @@ -193,7 +193,7 @@ type fakePlayTracker struct { Error error } -func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.NowPlayingInfo, error) { +func (f *fakePlayTracker) GetNowPlaying(_ context.Context) ([]scrobbler.PlaybackSession, error) { return nil, f.Error } From 976e9e873e3147ef764875c022e176238bb817d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sat, 2 May 2026 19:48:44 -0400 Subject: [PATCH 056/117] fix(artwork): prefer album-root images over disc-subfolder images for multi-disc albums (#5451) Fixed two bugs in album cover art resolution for multi-disc layouts: 1. compareImageFiles now sorts by path depth (shallower first) when basenames tie, so album-root images like Artist/Album/cover.jpg are preferred over disc-subfolder images like Artist/Album/CD1/cover.jpg. 2. commonParentFolder now includes the parent folder for single-disc-subfolder albums, with a Path != "." guard to avoid pulling artist-folder images. Closes #5376 --- core/artwork/e2e/album_test.go | 12 +++--- core/artwork/reader_album.go | 48 +++++++++++++--------- core/artwork/reader_album_test.go | 68 +++++++++++++++++++++++++++++-- 3 files changed, 98 insertions(+), 30 deletions(-) diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go index 3d5523afd..370844e34 100644 --- a/core/artwork/e2e/album_test.go +++ b/core/artwork/e2e/album_test.go @@ -39,7 +39,7 @@ var _ = Describe("Album artwork resolution", func() { // Bug 2 variant: cover.* basenames tie across album-root and per-disc folders; // compareImageFiles' lexicographic full-path tiebreaker ranks disc-subfolder - // files first. Flip from PIt to It once it prefers shorter/parent paths. + // files first. When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() { // Artist/ // └── Album/ @@ -50,7 +50,7 @@ var _ = Describe("Album artwork resolution", func() { // │ ├── 01 - Track.mp3 // │ └── cover.jpg // └── cover.jpg ← should win (album-root fallback) - PIt("uses the album-root cover (currently picks a disc subfolder image — bug)", func() { + It("prefers the album-root cover over per-disc covers", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), @@ -71,7 +71,6 @@ var _ = Describe("Album artwork resolution", func() { // Bug 2: folder.jpg basenames tie across album-root and per-disc folders; // the lexicographic full-path tiebreaker in compareImageFiles ranks // "Artist/Album/CD1/folder.jpg" ahead of "Artist/Album/folder.jpg". - // Flip from PIt to It once compareImageFiles prefers shorter/parent paths. When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() { // Artist/ // └── Album/ @@ -82,7 +81,7 @@ var _ = Describe("Album artwork resolution", func() { // │ ├── 01 - Track.mp3 // │ └── folder.jpg // └── folder.jpg ← should win (album-root fallback) - PIt("uses the album-root folder.jpg (currently picks a disc subfolder image — bug)", func() { + It("prefers the album-root folder.jpg over per-disc folder.jpg", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), @@ -100,15 +99,14 @@ var _ = Describe("Album artwork resolution", func() { // Bug 1: commonParentFolder's `len(folders) < 2` guard skips the parent-folder // lookup whenever an album lives entirely under a single subfolder, so an - // album-root cover is never considered. Flip from PIt to It once the guard - // accepts single-folder albums whose parent isn't already in the folder set. + // album-root cover is never considered. When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() { // Artist/ // └── Album/ // ├── disc1/ // │ └── 01 - Track.mp3 // └── cover.jpg ← should win (parent-folder fallback, currently ignored — bug) - PIt("uses the parent-folder cover (currently ignored — bug)", func() { + It("uses the parent-folder cover for single-disc-subfolder albums", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ "Artist/Album/disc1/01 - Track.mp3": trackFile(1, "Track"), diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index 8d7e14fd0..cf5497641 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -118,19 +118,22 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo folderIDSet[id] = true } - // For multi-disc albums (2+ folders), check if all folders share a common parent - // that is not already included. This finds cover art in the album root folder - // (e.g., "Artist/Album/cover.jpg" when tracks are in "Artist/Album/CD1/" and "Artist/Album/CD2/"). - // We skip single-folder albums to avoid pulling images from the artist folder. + // Check if all folders share a common parent that is not already included. + // This finds cover art in the album root folder (e.g., "Artist/Album/cover.jpg" + // when tracks are in disc subfolders like "Artist/Album/CD1/" and "Artist/Album/CD2/"). + // For single-folder albums, the parent is only included when the folder has no + // images of its own (indicating a disc subfolder needing parent artwork). if commonParentID := commonParentFolder(folders, folderIDSet); commonParentID != "" { - parentFolder, err := ds.Folder(ctx).Get(commonParentID) - if errors.Is(err, model.ErrNotFound) { - log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID) - } else if err != nil { - return nil, nil, nil, err - } - if parentFolder != nil { - folders = append(folders, *parentFolder) + if len(folders) >= 2 || !anyFolderHasImages(folders) { + parentFolder, err := ds.Folder(ctx).Get(commonParentID) + if errors.Is(err, model.ErrNotFound) { + log.Warn(ctx, "Parent folder not found for album cover art lookup", "parentID", commonParentID) + } else if err != nil { + return nil, nil, nil, err + } + if parentFolder != nil && parentFolder.Path != "." { + folders = append(folders, *parentFolder) + } } } @@ -156,10 +159,19 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo return paths, imgFiles, &updatedAt, nil } +func anyFolderHasImages(folders []model.Folder) bool { + for _, f := range folders { + if len(f.ImageFiles) > 0 { + return true + } + } + return false +} + // commonParentFolder returns the shared parent folder ID when all folders have the // same parent and that parent is not already in folderIDSet. Returns "" otherwise. func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) string { - if len(folders) < 2 { + if len(folders) == 0 { return "" } parentID := folders[0].ParentID @@ -174,11 +186,8 @@ func commonParentFolder(folders []model.Folder, folderIDSet map[string]bool) str return parentID } -// compareImageFiles compares two image file paths for sorting. -// It extracts the base filename (without extension) and compares case-insensitively. -// This ensures that "cover.jpg" sorts before "cover.1.jpg" since "cover" < "cover.1". -// Note: This function is called O(n log n) times during sorting, but in practice albums -// typically have only 1-20 image files, making the repeated string operations negligible. +// compareImageFiles sorts image paths by: base filename (natural order), +// then path depth (shallower first), then full path (stable tiebreaker). func compareImageFiles(a, b string) int { // Case-insensitive comparison a = strings.ToLower(a) @@ -188,9 +197,10 @@ func compareImageFiles(a, b string) int { baseA := strings.TrimSuffix(path.Base(a), path.Ext(a)) baseB := strings.TrimSuffix(path.Base(b), path.Ext(b)) - // Compare base names first, then full paths if equal + // Compare base names first, then prefer shallower paths, then full path as tiebreaker return cmp.Or( natural.Compare(baseA, baseB), + cmp.Compare(strings.Count(a, "/"), strings.Count(b, "/")), natural.Compare(a, b), ) } diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index 03412b6d9..b8f4f2dfa 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -213,9 +213,42 @@ var _ = Describe("Album Artwork Reader", func() { Expect(repo.getCallCount).To(Equal(0)) }) - It("does not query parent for single-folder albums", func() { - // A single-folder album's parent is typically the artist folder, - // which should not be searched for cover art + It("does not include top-level parent for multi-folder albums", func() { + // Two album parts under the same artist folder — parent is artist-level + repo.result = []model.Folder{ + { + ID: "folder1", + Path: ".", + Name: "AlbumPart1", + ParentID: "artistFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"cover.jpg"}, + }, + { + ID: "folder2", + Path: ".", + Name: "AlbumPart2", + ParentID: "artistFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "artistFolder", + Path: ".", + Name: "Artist", + ImageFiles: []string{"artist.jpg"}, + } + + _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal("AlbumPart1/cover.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + + It("does not query parent for single-folder albums that already have images", func() { repo.result = []model.Folder{ { ID: "folder1", @@ -232,10 +265,37 @@ var _ = Describe("Album Artwork Reader", func() { Expect(err).ToNot(HaveOccurred()) Expect(imgFiles).To(HaveLen(1)) Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) - // Get should not have been called (single folder, no parent lookup) Expect(repo.getCallCount).To(Equal(0)) }) + It("includes parent images for single-disc-subfolder albums", func() { + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Artist/Album", + Name: "disc1", + ParentID: "albumFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{}, + }, + } + repo.parentResult = &model.Folder{ + ID: "albumFolder", + Path: "Artist", + Name: "Album", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"cover.jpg"}, + } + + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(*imagesUpdatedAt).To(Equal(expectedAt)) + Expect(imgFiles).To(HaveLen(1)) + Expect(imgFiles[0]).To(Equal("Artist/Album/cover.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + It("propagates non-ErrNotFound errors from parent folder lookup", func() { repo.result = []model.Folder{ { From fac4053815cda83251216d9b24dcb6578b573285 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 3 May 2026 11:26:07 -0400 Subject: [PATCH 057/117] test: fix flaky watcher and scheduler tests on Windows CI Replace timing-sensitive time.Sleep synchronization with proper Eventually/Consistently assertions in watcher tests, and increase Eventually timeouts from 200ms to 500ms. Add FlakeAttempts(3) to the inherently timing-dependent tests. For the scheduler test, increase the Eventually timeout from 1s to 5s for the cron job execution check. --- scanner/watcher_test.go | 85 ++++++++++++++++--------------------- scheduler/scheduler_test.go | 5 ++- 2 files changed, 39 insertions(+), 51 deletions(-) diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go index a4016d470..9795129b0 100644 --- a/scanner/watcher_test.go +++ b/scanner/watcher_test.go @@ -74,23 +74,22 @@ var _ = Describe("Watcher", func() { time.Sleep(10 * time.Millisecond) }) - It("creates separate targets for different folders", func() { + It("creates separate targets for different folders", FlakeAttempts(3), func() { // Send notifications for different folders w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - time.Sleep(10 * time.Millisecond) w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist2"} - // Wait for watcher to process and trigger scan - Eventually(func() int { - return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + // Wait for a scan that collected both targets + Eventually(func() []model.ScanTarget { + calls := mockScanner.GetScanFoldersCalls() + if len(calls) == 0 { + return nil + } + return calls[0].Targets + }, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2)) - // Verify two targets + // Verify targets calls := mockScanner.GetScanFoldersCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].Targets).To(HaveLen(2)) - - // Extract folder paths folderPaths := make(map[string]bool) for _, target := range calls[0].Targets { Expect(target.LibraryID).To(Equal(1)) @@ -107,7 +106,7 @@ var _ = Describe("Watcher", func() { // Wait for watcher to process and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Verify the target calls := mockScanner.GetScanFoldersCalls() @@ -117,20 +116,15 @@ var _ = Describe("Watcher", func() { }) It("deduplicates folder and file within same folder", func() { - // Send notification for a folder + // Send multiple notifications for the same folder w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} - time.Sleep(10 * time.Millisecond) - // Send notification for same folder (as if file change was detected there) - // In practice, watchLibrary() would walk up from file path to folder w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} - time.Sleep(10 * time.Millisecond) - // Send another for same folder w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1/album1"} // Wait for watcher to process and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Verify only one target despite multiple file/folder changes calls := mockScanner.GetScanFoldersCalls() @@ -151,32 +145,27 @@ var _ = Describe("Watcher", func() { time.Sleep(10 * time.Millisecond) }) - It("resets timer on each change (debouncing)", func() { + It("resets timer on each change (debouncing)", FlakeAttempts(3), func() { // Send first notification w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - // Wait a bit less than half the watcher wait time to ensure timer doesn't fire - time.Sleep(20 * time.Millisecond) - - // No scan should have been triggered yet - Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0)) + // Verify no scan fires during a window shorter than the debounce wait + Consistently(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0)) // Send another notification (resets timer) w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - // Wait a bit less than half the watcher wait time again - time.Sleep(20 * time.Millisecond) + // Again, no scan should fire within a short window + Consistently(func() int { + return mockScanner.GetScanFoldersCallCount() + }, 20*time.Millisecond, 5*time.Millisecond).Should(Equal(0)) - // Still no scan - Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0)) - - // Wait for full timer to expire after last notification (plus margin) - time.Sleep(60 * time.Millisecond) - - // Now scan should have been triggered + // Now wait for the debounce timer to expire and trigger scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 100*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) }) It("triggers scan after quiet period", func() { @@ -189,7 +178,7 @@ var _ = Describe("Watcher", func() { // Wait for quiet period Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) }) }) @@ -211,7 +200,7 @@ var _ = Describe("Watcher", func() { // Wait for scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Should scan the library root calls := mockScanner.GetScanFoldersCalls() @@ -223,13 +212,12 @@ var _ = Describe("Watcher", func() { It("deduplicates empty and dot paths", func() { // Send notifications with empty and dot paths w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""} - time.Sleep(10 * time.Millisecond) w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""} // Wait for scan Eventually(func() int { return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) + }, 500*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) // Should have only one target calls := mockScanner.GetScanFoldersCalls() @@ -264,20 +252,19 @@ var _ = Describe("Watcher", func() { It("creates separate targets for different libraries", func() { // Send notifications for both libraries w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"} - time.Sleep(10 * time.Millisecond) w.watcherNotify <- scanNotification{Library: lib2, FolderPath: "artist2"} - // Wait for scan - Eventually(func() int { - return mockScanner.GetScanFoldersCallCount() - }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1)) - - // Verify two targets for different libraries - calls := mockScanner.GetScanFoldersCalls() - Expect(calls).To(HaveLen(1)) - Expect(calls[0].Targets).To(HaveLen(2)) + // Wait for a scan that collected both targets + Eventually(func() []model.ScanTarget { + calls := mockScanner.GetScanFoldersCalls() + if len(calls) == 0 { + return nil + } + return calls[0].Targets + }, 500*time.Millisecond, 10*time.Millisecond).Should(HaveLen(2)) // Verify library IDs are different + calls := mockScanner.GetScanFoldersCalls() libraryIDs := make(map[int]bool) for _, target := range calls[0].Targets { libraryIDs[target.LibraryID] = true diff --git a/scheduler/scheduler_test.go b/scheduler/scheduler_test.go index 1a134a7f3..8ae69c19b 100644 --- a/scheduler/scheduler_test.go +++ b/scheduler/scheduler_test.go @@ -2,6 +2,7 @@ package scheduler import ( "testing" + "time" "github.com/navidrome/navidrome/log" . "github.com/onsi/ginkgo/v2" @@ -28,7 +29,7 @@ var _ = Describe("Scheduler", func() { s.c.Stop() // Stop the scheduler after tests }) - It("adds and executes a job", func() { + It("adds and executes a job", FlakeAttempts(3), func() { done := make(chan struct{}) id, err := s.Add("@every 50ms", func() { @@ -38,7 +39,7 @@ var _ = Describe("Scheduler", func() { Expect(err).ToNot(HaveOccurred()) Expect(id).ToNot(BeZero()) - Eventually(done).Should(BeClosed()) + Eventually(done, 5*time.Second).Should(BeClosed()) }) It("adds a job with random ~ syntax", func() { From 2883287e96a3fbbb6efd3f32ac7cd8a469bab25d Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 3 May 2026 12:34:00 -0400 Subject: [PATCH 058/117] chore(deps): update Go dependencies in go.mod and go.sum Signed-off-by: Deluan --- go.mod | 18 +++++++++--------- go.sum | 32 ++++++++++++++++---------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/go.mod b/go.mod index a4c0c014b..36218ba6b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/navidrome/navidrome -go 1.26.0 +go 1.26 // Fork to implement raw tags support replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a @@ -35,12 +35,12 @@ require ( github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 github.com/lestrrat-go/jwx/v3 v3.1.0 - github.com/mattn/go-sqlite3 v1.14.42 + 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.2 - github.com/onsi/gomega v1.39.1 - github.com/pelletier/go-toml/v2 v2.3.0 + github.com/onsi/ginkgo/v2 v2.28.3 + github.com/onsi/gomega v1.40.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 github.com/pressly/goose/v3 v3.27.1 @@ -70,7 +70,7 @@ require ( require ( dario.cat/mergo v1.0.2 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/atombender/go-jsonschema v0.20.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -81,7 +81,7 @@ require ( 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/fsnotify/fsnotify v1.9.0 // indirect + github.com/fsnotify/fsnotify v1.10.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect @@ -93,7 +93,7 @@ require ( github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f // indirect + github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -138,7 +138,7 @@ require ( golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect golang.org/x/tools v0.44.0 // indirect google.golang.org/protobuf v1.36.11 // indirect - gopkg.in/ini.v1 v1.67.1 // indirect + gopkg.in/ini.v1 v1.67.2 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect ) diff --git a/go.sum b/go.sum index 3e665ba02..90e0ec040 100644 --- a/go.sum +++ b/go.sum @@ -2,8 +2,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -63,8 +63,8 @@ github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M= +github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg= github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -125,8 +125,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f h1:Fnl4pzx8SR7k7JuzyW8lEtSFH6EQ8xgcypgIn8pcGIE= -github.com/ianlancetaylor/demangle v0.0.0-20251118225945-96ee0021ea0f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c h1:A1enk+iN8X/J1M/eN4U4NFGQToI51gCvRxEXYrfmqNs= +github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= @@ -175,8 +175,8 @@ github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= github.com/maruel/natural v1.3.0/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= -github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= +github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= @@ -193,12 +193,12 @@ 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.2 h1:DTrMfpqxiNUyQ3Y0zhn1n3cOO2euFgQPYIpkWwxVFps= -github.com/onsi/ginkgo/v2 v2.28.2/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE= -github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= -github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= -github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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/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= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -409,8 +409,8 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k= -gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss= +gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce h1:+JknDZhAj8YMt7GC73Ei8pv4MzjDUNPHgQWJdtMAaDU= gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= From 2858cacdeab1ba346a5736d30ac4d4daf42ff6e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 4 May 2026 13:59:05 -0400 Subject: [PATCH 059/117] chore(ci): update GitHub Actions to latest major versions (#5462) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ci): update GitHub Actions to latest major versions Update actions/cache v4→v5, actions/github-script v3→v7, actions/stale v9→v10, docker/login-action v3→v4, docker/setup-buildx-action v3→v4, and docker/metadata-action v5→v6. The github-script upgrade also migrates Octokit API calls from github.* to github.rest.* namespace (required since v5). * fix(ci): address review feedback on GitHub Actions update Pass github_token to docker/metadata-action@v6 to avoid API rate limiting. Fix github-script pagination to use the correct Octokit paginate.iterator pattern (pass endpoint method, not awaited response). --- .github/actions/prepare-docker/action.yml | 9 +++++---- .github/workflows/download-link-on-pr.yml | 13 ++++++------- .github/workflows/pipeline.yml | 2 +- .github/workflows/stale.yml | 2 +- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/actions/prepare-docker/action.yml b/.github/actions/prepare-docker/action.yml index 760a0528b..7fe0b9148 100644 --- a/.github/actions/prepare-docker/action.yml +++ b/.github/actions/prepare-docker/action.yml @@ -53,13 +53,13 @@ runs: - name: Login to Docker Hub if: inputs.hub_username != '' && inputs.hub_password != '' - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: username: ${{ inputs.hub_username }} password: ${{ inputs.hub_password }} - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} @@ -67,12 +67,13 @@ runs: - name: Set up Docker Buildx id: buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4 - name: Extract metadata for Docker image id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6 with: + token: ${{ inputs.github_token }} labels: | maintainer=deluan@navidrome.org images: | diff --git a/.github/workflows/download-link-on-pr.yml b/.github/workflows/download-link-on-pr.yml index 38b7b8a86..076f963d4 100644 --- a/.github/workflows/download-link-on-pr.yml +++ b/.github/workflows/download-link-on-pr.yml @@ -8,7 +8,7 @@ jobs: if: github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success' runs-on: ubuntu-latest steps: - - uses: actions/github-script@v3 + - uses: actions/github-script@v7 with: # This snippet is public-domain, taken from # https://github.com/oprypin/nightly.link/blob/master/.github/workflows/pr-comment.yml @@ -19,8 +19,7 @@ jobs: const pull_user_id = ${{github.event.sender.id}}; const issue_number = await (async () => { - const pulls = await github.pulls.list({owner, repo}); - for await (const {data} of github.paginate.iterator(pulls)) { + for await (const {data} of github.paginate.iterator(github.rest.pulls.list, {owner, repo})) { for (const pull of data) { if (pull.head.sha === pull_head_sha && pull.user.id === pull_user_id) { return pull.number; @@ -34,7 +33,7 @@ jobs: return core.error(`No matching pull request found`); } - const {data: {artifacts}} = await github.actions.listWorkflowRunArtifacts({owner, repo, run_id}); + const {data: {artifacts}} = await github.rest.actions.listWorkflowRunArtifacts({owner, repo, run_id}); if (!artifacts.length) { return core.error(`No artifacts found`); } @@ -43,12 +42,12 @@ jobs: body += `\n* [${art.name}.zip](https://nightly.link/${owner}/${repo}/actions/artifacts/${art.id}.zip)`; } - const {data: comments} = await github.issues.listComments({repo, owner, issue_number}); + const {data: comments} = await github.rest.issues.listComments({repo, owner, issue_number}); const existing_comment = comments.find((c) => c.user.login === 'github-actions[bot]'); if (existing_comment) { core.info(`Updating comment ${existing_comment.id}`); - await github.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); + await github.rest.issues.updateComment({repo, owner, comment_id: existing_comment.id, body}); } else { core.info(`Creating a comment`); - await github.issues.createComment({repo, owner, issue_number, body}); + await github.rest.issues.createComment({repo, owner, issue_number, body}); } diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 09fca2572..6f858a5a7 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -145,7 +145,7 @@ jobs: - name: Cache ffmpeg id: ffmpeg-cache - uses: actions/cache@v4 + uses: actions/cache@v5 with: path: C:\ffmpeg key: ffmpeg-${{ env.FFMPEG_VERSION }}-win64 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 69e6ac99e..33c8fadbd 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -28,7 +28,7 @@ jobs: This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. - - uses: actions/stale@v9 + - uses: actions/stale@v10 with: operations-per-run: 999 days-before-issue-stale: 180 From fb06eaddd720b338e4605d6527043a3a3a199acf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 4 May 2026 17:26:39 -0400 Subject: [PATCH 060/117] fix(artwork): fix stale cache and top-level album artwork for multi-disc albums (#5457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(artwork): include top-level album folders in parent cover art lookup The Path != "." guard added in #5451 was too aggressive — it excluded any folder with Path=".", which includes top-level album folders (not just the library root). Changed to ParentID != "" which correctly excludes only the actual library root folder. Fixes #5456 * fix: correct comment in test — album is under library root, not artist root * test: add ascii tree diagram to top-level album e2e test * test: replace internal bug references with issue link in e2e comments Signed-off-by: Deluan * test: add e2e test matching reporter's exact library layout (#5456) Adds a deeply nested test (Genre/Artist/Album/Disc) with 12 discs using the reporter's actual folder names to verify artwork resolution works for non-top-level album folders too. * fix(scanner): use a syntectic admin user when no admin user is found Signed-off-by: Deluan * fix(scanner): bump album UpdatedAt on Phase 3 refresh to invalidate artwork cache When Phase 3 corrects an album's FolderIDs (or any other field), bump UpdatedAt to the current time. This ensures the artwork cache key changes, invalidating any stale artwork that was resolved and cached during Phase 1 when the album had incomplete folder data. * fix(artwork): include ImportedAt in artwork cache key to invalidate stale cache Reverts the Phase 3 UpdatedAt bump (which would change album.UpdatedAt semantics) and instead includes album.ImportedAt in the artwork cache key computation. Since ImportedAt is bumped to time.Now() on every album Put, any Phase 3 correction naturally invalidates cached artwork that was resolved mid-scan with incomplete folder data. * fix(artwork): simplify lastUpdate logic using TimeNewest utility Signed-off-by: Deluan --------- Signed-off-by: Deluan --- core/artwork/e2e/album_test.go | 49 ++++++++++++---- core/artwork/e2e/disc_test.go | 95 +++++++++++++++++++++++++++++++ core/artwork/reader_album.go | 10 ++-- core/artwork/reader_album_test.go | 59 ++++++++++++++++--- core/artwork/reader_disc.go | 8 +-- core/auth/auth.go | 2 +- 6 files changed, 193 insertions(+), 30 deletions(-) diff --git a/core/artwork/e2e/album_test.go b/core/artwork/e2e/album_test.go index 370844e34..e765e1b1b 100644 --- a/core/artwork/e2e/album_test.go +++ b/core/artwork/e2e/album_test.go @@ -37,15 +37,15 @@ var _ = Describe("Album artwork resolution", func() { }) }) - // Bug 2 variant: cover.* basenames tie across album-root and per-disc folders; - // compareImageFiles' lexicographic full-path tiebreaker ranks disc-subfolder - // files first. + // https://github.com/navidrome/navidrome/issues/5376 + // cover.* basenames tie across album-root and per-disc folders; + // compareImageFiles must prefer shallower paths. When("a multi-disc album has a cover.jpg at the album root and per-disc covers", func() { // Artist/ // └── Album/ // ├── CD1/ // │ ├── 01 - Track.mp3 - // │ └── cover.jpg ← currently wins (bug) + // │ └── cover.jpg ← should not win // ├── CD2/ // │ ├── 01 - Track.mp3 // │ └── cover.jpg @@ -68,15 +68,15 @@ var _ = Describe("Album artwork resolution", func() { }) }) - // Bug 2: folder.jpg basenames tie across album-root and per-disc folders; - // the lexicographic full-path tiebreaker in compareImageFiles ranks - // "Artist/Album/CD1/folder.jpg" ahead of "Artist/Album/folder.jpg". + // https://github.com/navidrome/navidrome/issues/5376 + // folder.jpg basenames tie across album-root and per-disc folders; + // compareImageFiles must prefer shallower paths. When("a multi-disc album has folder.jpg at the album root AND in each disc subfolder", func() { // Artist/ // └── Album/ // ├── CD1/ // │ ├── 01 - Track.mp3 - // │ └── folder.jpg ← currently wins (bug) + // │ └── folder.jpg ← should not win // ├── CD2/ // │ ├── 01 - Track.mp3 // │ └── folder.jpg @@ -97,15 +97,14 @@ var _ = Describe("Album artwork resolution", func() { }) }) - // Bug 1: commonParentFolder's `len(folders) < 2` guard skips the parent-folder - // lookup whenever an album lives entirely under a single subfolder, so an - // album-root cover is never considered. + // https://github.com/navidrome/navidrome/issues/5376 + // Single-subfolder albums must still consider the parent folder's images. When("an album lives entirely under a single disc subfolder with cover.jpg at the parent", func() { // Artist/ // └── Album/ // ├── disc1/ // │ └── 01 - Track.mp3 - // └── cover.jpg ← should win (parent-folder fallback, currently ignored — bug) + // └── cover.jpg ← should win (parent-folder fallback) It("uses the parent-folder cover for single-disc-subfolder albums", func() { conf.Server.CoverArtPriority = defaultCoverPriority setLayout(fstest.MapFS{ @@ -119,6 +118,32 @@ var _ = Describe("Album artwork resolution", func() { }) }) + // https://github.com/navidrome/navidrome/issues/5456 + When("a top-level multi-disc album has cover.jpg at the album root and per-disc folder.jpg", func() { + // Album/ (top-level folder, Path=".") + // ├── CD1/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // ├── CD2/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // └── cover.jpg ← should win (album-root) + It("prefers the album-root cover.jpg", func() { + conf.Server.CoverArtPriority = defaultCoverPriority + setLayout(fstest.MapFS{ + "Album/CD1/01 - Track.mp3": trackFile(1, "Track CD1"), + "Album/CD2/01 - Track.mp3": trackFile(1, "Track CD2"), + "Album/cover.jpg": imageFile("album-root"), + "Album/CD1/folder.jpg": imageFile("disc1"), + "Album/CD2/folder.jpg": imageFile("disc2"), + }) + scan() + + al := firstAlbum() + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root"))) + }) + }) + When("CoverArtPriority puts embedded first and the album has both embedded and external art", func() { // Artist/ // └── Album/ diff --git a/core/artwork/e2e/disc_test.go b/core/artwork/e2e/disc_test.go index 7569cbc32..667079458 100644 --- a/core/artwork/e2e/disc_test.go +++ b/core/artwork/e2e/disc_test.go @@ -1,6 +1,7 @@ package artworke2e_test import ( + "fmt" "testing/fstest" "github.com/navidrome/navidrome/conf" @@ -255,6 +256,100 @@ var _ = Describe("Disc artwork resolution", func() { }) }) + // Reproduces https://github.com/navidrome/navidrome/issues/5456 + // Deeply nested layout matching the reporter's actual structure. + When("a deeply nested multi-disc album has cover.jpg and per-disc folder.jpg", func() { + // Genre/Artist/Album/ ← album root with cover.jpg + // ├── cover.jpg ← album-level cover + // ├── Disc 01 (Subtitle)/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg ← disc 1 art + // ├── Disc 02 (Subtitle)/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // └── ... (12 discs) + It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + conf.Server.CoverArtPriority = defaultCoverPriority + discNames := []string{ + "Disc 01 (Birth of the Dead - The Studio Sides)", + "Disc 02 (Birth of the Dead - The Live Sides)", + "Disc 03 (The Grateful Dead)", + "Disc 04 (Anthem of the Sun)", + "Disc 05 (Aoxomoxoa)", + "Disc 06 (Live; Dead)", + "Disc 07 (Workingman's Dead)", + "Disc 08 (American Beauty)", + "Disc 09 (Grateful Dead)", + "Disc 10 (Europe '72)", + "Disc 11 (Europe '72)", + "Disc 12 (History of the Grateful Dead, Volume One (Bear's Choice))", + } + layout := fstest.MapFS{ + "Pop; Rock/Grateful Dead/(2001) The Golden Road/cover.jpg": imageFile("album-root-cover"), + } + for i, name := range discNames { + discNum := i + 1 + prefix := fmt.Sprintf("Pop; Rock/Grateful Dead/(2001) The Golden Road/%s/", name) + layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", discNum), map[string]any{"disc": fmt.Sprintf("%d", discNum)}) + layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", discNum)) + } + setLayout(layout) + scan() + + al := firstAlbum() + + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) + + for i := range discNames { + discNum := i + 1 + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, discNum), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", discNum))), + "disc %d should use its own folder.jpg", discNum) + } + }) + }) + + // https://github.com/navidrome/navidrome/issues/5456 + // Top-level album variant — album folder at library root (Path="."). + When("a top-level multi-disc album has cover.jpg and per-disc folder.jpg", func() { + // Album/ (top-level, Path=".") + // ├── cover.jpg ← album-level cover + // ├── Disc 01/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg ← disc 1 art + // ├── Disc 02/ + // │ ├── 01 - Track.mp3 + // │ └── folder.jpg + // └── Disc 03/ + // ├── 01 - Track.mp3 + // └── folder.jpg + It("uses album-root cover.jpg for album art and per-disc folder.jpg for each disc", func() { + conf.Server.DiscArtPriority = defaultDiscPriority + conf.Server.CoverArtPriority = defaultCoverPriority + layout := fstest.MapFS{ + "Album/cover.jpg": imageFile("album-root-cover"), + } + for i := 1; i <= 3; i++ { + prefix := fmt.Sprintf("Album/Disc %02d/", i) + layout[prefix+"01 - Track.mp3"] = trackFile(1, fmt.Sprintf("T%d", i), map[string]any{"disc": fmt.Sprintf("%d", i)}) + layout[prefix+"folder.jpg"] = imageFile(fmt.Sprintf("disc-%02d-folder", i)) + } + setLayout(layout) + scan() + + al := firstAlbum() + + Expect(readArtwork(al.CoverArtID())).To(Equal(imageBytes("album-root-cover"))) + + for i := 1; i <= 3; i++ { + discID := model.NewArtworkID(model.KindDiscArtwork, model.DiscArtworkID(al.ID, i), &al.UpdatedAt) + Expect(readArtwork(discID)).To(Equal(imageBytes(fmt.Sprintf("disc-%02d-folder", i))), + "disc %d should use its own folder.jpg", i) + } + }) + }) + When("discsubtitle is set but no image filename matches the subtitle", func() { // Artist/ // └── Album/ diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go index cf5497641..73ba9b5ee 100644 --- a/core/artwork/reader_album.go +++ b/core/artwork/reader_album.go @@ -18,6 +18,7 @@ import ( "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" "github.com/navidrome/navidrome/utils/natural" ) @@ -53,10 +54,9 @@ func newAlbumArtworkReader(ctx context.Context, artwork *artwork, artID model.Ar lib: lib, } a.cacheKey.artID = artID - if a.updatedAt != nil && a.updatedAt.After(al.UpdatedAt) { - a.cacheKey.lastUpdate = *a.updatedAt - } else { - a.cacheKey.lastUpdate = al.UpdatedAt + a.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt) + if imagesUpdateAt != nil { + a.cacheKey.lastUpdate = utils.TimeNewest(a.cacheKey.lastUpdate, *imagesUpdateAt) } return a, nil } @@ -131,7 +131,7 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo } else if err != nil { return nil, nil, nil, err } - if parentFolder != nil && parentFolder.Path != "." { + if parentFolder != nil && parentFolder.ParentID != "" { folders = append(folders, *parentFolder) } } diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go index b8f4f2dfa..1cf039bee 100644 --- a/core/artwork/reader_album_test.go +++ b/core/artwork/reader_album_test.go @@ -141,6 +141,7 @@ var _ = Describe("Album Artwork Reader", func() { ID: "parentFolder", Path: "Artist", Name: "Album", + ParentID: "artistFolder", ImagesUpdatedAt: expectedAt, ImageFiles: []string{"cover.jpg", "back.jpg"}, } @@ -213,14 +214,14 @@ var _ = Describe("Album Artwork Reader", func() { Expect(repo.getCallCount).To(Equal(0)) }) - It("does not include top-level parent for multi-folder albums", func() { - // Two album parts under the same artist folder — parent is artist-level + It("does not include library root parent for multi-folder albums", func() { + // Two album parts directly under the library root — parent is the root itself repo.result = []model.Folder{ { ID: "folder1", Path: ".", Name: "AlbumPart1", - ParentID: "artistFolder", + ParentID: "rootFolder", ImagesUpdatedAt: now, ImageFiles: []string{"cover.jpg"}, }, @@ -228,16 +229,17 @@ var _ = Describe("Album Artwork Reader", func() { ID: "folder2", Path: ".", Name: "AlbumPart2", - ParentID: "artistFolder", + ParentID: "rootFolder", ImagesUpdatedAt: now, ImageFiles: []string{}, }, } repo.parentResult = &model.Folder{ - ID: "artistFolder", - Path: ".", - Name: "Artist", - ImageFiles: []string{"artist.jpg"}, + ID: "rootFolder", + Path: "", + Name: ".", + ParentID: "", + ImageFiles: []string{"unrelated.jpg"}, } _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album) @@ -248,6 +250,46 @@ var _ = Describe("Album Artwork Reader", func() { Expect(repo.getCallCount).To(Equal(1)) }) + It("includes top-level album folder for multi-disc albums", func() { + // Album folder directly under library root, with disc subfolders + repo.result = []model.Folder{ + { + ID: "folder1", + Path: "Album", + Name: "Disc1", + ParentID: "albumFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"folder.jpg"}, + }, + { + ID: "folder2", + Path: "Album", + Name: "Disc2", + ParentID: "albumFolder", + ImagesUpdatedAt: now, + ImageFiles: []string{"folder.jpg"}, + }, + } + repo.parentResult = &model.Folder{ + ID: "albumFolder", + Path: ".", + Name: "Album", + ParentID: "rootFolder", + ImagesUpdatedAt: expectedAt, + ImageFiles: []string{"cover.jpg"}, + } + + _, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album) + + Expect(err).ToNot(HaveOccurred()) + Expect(*imagesUpdatedAt).To(Equal(expectedAt)) + Expect(imgFiles).To(HaveLen(3)) + Expect(imgFiles[0]).To(Equal("Album/cover.jpg")) + Expect(imgFiles[1]).To(Equal("Album/Disc1/folder.jpg")) + Expect(imgFiles[2]).To(Equal("Album/Disc2/folder.jpg")) + Expect(repo.getCallCount).To(Equal(1)) + }) + It("does not query parent for single-folder albums that already have images", func() { repo.result = []model.Folder{ { @@ -283,6 +325,7 @@ var _ = Describe("Album Artwork Reader", func() { ID: "albumFolder", Path: "Artist", Name: "Album", + ParentID: "artistFolder", ImagesUpdatedAt: expectedAt, ImageFiles: []string{"cover.jpg"}, } diff --git a/core/artwork/reader_disc.go b/core/artwork/reader_disc.go index de0a765f0..0f648c987 100644 --- a/core/artwork/reader_disc.go +++ b/core/artwork/reader_disc.go @@ -16,6 +16,7 @@ import ( "github.com/navidrome/navidrome/core/ffmpeg" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/utils" ) type discArtworkReader struct { @@ -105,10 +106,9 @@ func newDiscArtworkReader(ctx context.Context, a *artwork, artID model.ArtworkID updatedAt: imagesUpdatedAt, } r.cacheKey.artID = artID - if r.updatedAt != nil && r.updatedAt.After(al.UpdatedAt) { - r.cacheKey.lastUpdate = *r.updatedAt - } else { - r.cacheKey.lastUpdate = al.UpdatedAt + r.cacheKey.lastUpdate = utils.TimeNewest(al.UpdatedAt, al.ImportedAt) + if imagesUpdatedAt != nil { + r.cacheKey.lastUpdate = utils.TimeNewest(r.cacheKey.lastUpdate, *imagesUpdatedAt) } return r, nil } diff --git a/core/auth/auth.go b/core/auth/auth.go index a75111b35..7b3511bdf 100644 --- a/core/auth/auth.go +++ b/core/auth/auth.go @@ -100,7 +100,7 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context { } else { log.Error(ctx, "No admin user found!", err) } - u = &model.User{} + u = &model.User{IsAdmin: true, UserName: "admin"} } ctx = request.WithUsername(ctx, u.UserName) From 219ceebd2cbcd9314a3855854453fd0a5ce3252e Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 4 May 2026 18:04:11 -0400 Subject: [PATCH 061/117] fix(scanner): fix error when importing playlists without an admin user Signed-off-by: Deluan --- scanner/phase_4_playlists.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scanner/phase_4_playlists.go b/scanner/phase_4_playlists.go index f726343f2..d52743966 100644 --- a/scanner/phase_4_playlists.go +++ b/scanner/phase_4_playlists.go @@ -50,7 +50,7 @@ func (p *phasePlaylists) produce(put func(entry *model.Folder)) error { return nil } u, _ := request.UserFrom(p.ctx) - if !u.IsAdmin { + if !u.IsAdmin || u.ID == "" { log.Warn(p.ctx, "Playlists will not be imported, as there are no admin users yet, "+ "Please create an admin user first, and then update the playlists for them to be imported") return nil From 6ebe742f5fa6281f2491714c8e9be9e839d04877 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 5 May 2026 18:49:24 -0400 Subject: [PATCH 062/117] fix(ui): add Rescan button to plugin list empty state (#5471) * feat(ui): add Rescan button to plugin list empty state When no plugins are installed and the folder watcher fails to detect new plugins, users had no way to trigger a rescan. Extract RescanButton into a shared component and render it in a custom empty state for the plugin list. * refactor(ui): address review feedback for plugin empty state - Pass label translation key directly to RA Button (auto-translates) - Use within() from Testing Library instead of querySelector for scoped queries with better error messages --- ui/src/plugin/PluginList.jsx | 46 ++++++++++++++++++++++-------- ui/src/plugin/PluginList.test.jsx | 47 ++++++++++++++++++++++++++----- 2 files changed, 75 insertions(+), 18 deletions(-) diff --git a/ui/src/plugin/PluginList.jsx b/ui/src/plugin/PluginList.jsx index 67af85b81..346e1ceae 100644 --- a/ui/src/plugin/PluginList.jsx +++ b/ui/src/plugin/PluginList.jsx @@ -2,6 +2,7 @@ import React, { useMemo, useState, useCallback } from 'react' import { Button, Datagrid, + Empty, TextField, TopToolbar, useNotify, @@ -10,7 +11,13 @@ import { useTranslate, } from 'react-admin' import { makeStyles } from '@material-ui/core/styles' -import { useMediaQuery, Tooltip, Chip, Typography } from '@material-ui/core' +import { + useMediaQuery, + Tooltip, + Chip, + Typography, + Box, +} from '@material-ui/core' import { MdError, MdRefresh } from 'react-icons/md' import { List, DateField, SimpleList, useResourceRefresh } from '../common' import { httpClient } from '../dataProvider' @@ -72,8 +79,7 @@ const ManifestField = ({ source }) => { return {manifest[source] || '-'} } -const PluginListActions = () => { - const translate = useTranslate() +const RescanButton = () => { const notify = useNotify() const refresh = useRefresh() const [loading, setLoading] = useState(false) @@ -92,20 +98,37 @@ const PluginListActions = () => { }) }, [notify, refresh]) + return ( + + ) +} + +const PluginListActions = () => { return ( - + ) } +const PluginEmpty = () => { + return ( + <> + + + + + + ) +} + const PluginList = (props) => { const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs')) const translate = useTranslate() @@ -118,6 +141,7 @@ const PluginList = (props) => { exporter={false} bulkActionButtons={false} actions={} + empty={} > {isXsmall ? ( { TopToolbar: ({ children }) => (
{children}
), + Empty: () =>
No resources
, Datagrid: ({ children }) =>
{children}
, TextField: ({ source }) => , } @@ -42,9 +49,10 @@ vi.mock('react-admin', async () => { // Mock common components vi.mock('../common', async () => { return { - List: ({ children, actions, ...props }) => ( + List: ({ children, actions, empty, ...props }) => (
{actions} + {empty &&
{empty}
} {children}
), @@ -94,14 +102,16 @@ describe('PluginList', () => { expect(screen.getByTestId('datagrid')).toBeInTheDocument() }) - it('renders the rescan button', () => { + it('renders the rescan button in the toolbar', () => { render() - expect(screen.getByTestId('rescan-button')).toBeInTheDocument() + const toolbar = screen.getByTestId('top-toolbar') + expect(within(toolbar).getByTestId('rescan-button')).toBeInTheDocument() }) it('calls rescan endpoint when rescan button is clicked', async () => { render() - const rescanButton = screen.getByTestId('rescan-button') + const toolbar = screen.getByTestId('top-toolbar') + const rescanButton = within(toolbar).getByTestId('rescan-button') fireEvent.click(rescanButton) @@ -114,7 +124,8 @@ describe('PluginList', () => { it('calls refresh after successful rescan', async () => { render() - const rescanButton = screen.getByTestId('rescan-button') + const toolbar = screen.getByTestId('top-toolbar') + const rescanButton = within(toolbar).getByTestId('rescan-button') fireEvent.click(rescanButton) @@ -127,7 +138,8 @@ describe('PluginList', () => { mockHttpClient.mockRejectedValue(new Error('Network error')) render() - const rescanButton = screen.getByTestId('rescan-button') + const toolbar = screen.getByTestId('top-toolbar') + const rescanButton = within(toolbar).getByTestId('rescan-button') fireEvent.click(rescanButton) @@ -137,4 +149,25 @@ describe('PluginList', () => { }) }) }) + + it('renders a rescan button in the empty state', () => { + render() + const emptyState = screen.getByTestId('empty-state') + expect(emptyState).toBeInTheDocument() + expect(within(emptyState).getByTestId('rescan-button')).toBeInTheDocument() + }) + + it('empty state rescan button triggers rescan', async () => { + render() + const emptyState = screen.getByTestId('empty-state') + const rescanButton = within(emptyState).getByTestId('rescan-button') + + fireEvent.click(rescanButton) + + await waitFor(() => { + expect(mockHttpClient).toHaveBeenCalledWith('/api/plugin/rescan', { + method: 'POST', + }) + }) + }) }) From 4668fd3464b69fef80c4496223d42e11f791c706 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 5 May 2026 18:55:59 -0400 Subject: [PATCH 063/117] fix(plugins): add debug logs to syncPlugins for troubleshooting Log skipped entries, total entries vs plugins found, and DB state to help diagnose why sync may not find new plugins. Signed-off-by: Deluan --- plugins/manager_sync.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugins/manager_sync.go b/plugins/manager_sync.go index 2e024ca37..f97069e74 100644 --- a/plugins/manager_sync.go +++ b/plugins/manager_sync.go @@ -138,11 +138,13 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { filesOnDisk := make(map[string]string) // name -> path for _, entry := range entries { if entry.IsDir() || !strings.HasSuffix(entry.Name(), PackageExtension) { + log.Trace(ctx, "Skipping non-plugin entry", "name", entry.Name(), "isDir", entry.IsDir()) continue } name := strings.TrimSuffix(entry.Name(), PackageExtension) filesOnDisk[name] = filepath.Join(folder, entry.Name()) } + log.Debug(ctx, "Plugin sync: scanned folder", "folder", folder, "entriesTotal", len(entries), "pluginsFound", len(filesOnDisk)) // Get all plugins from DB repo := m.ds.Plugin(adminCtx) @@ -154,6 +156,7 @@ func (m *Manager) syncPlugins(ctx context.Context, folder string) error { for i := range dbPlugins { pluginsInDB[dbPlugins[i].ID] = &dbPlugins[i] } + log.Debug(ctx, "Plugin sync: current DB state", "pluginsInDB", len(pluginsInDB)) now := time.Now() From d1c3015a1d22f213a6dc44a5f3215aeb7457b2f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 6 May 2026 00:12:50 -0400 Subject: [PATCH 064/117] fix(server): prevent artwork throttle token starvation on slow clients (#5472) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): prevent artwork throttle token starvation on slow clients Replace Chi's ThrottleBacklog middleware for artwork endpoints with a custom RequestThrottle that releases processing tokens before writing the HTTP response. Previously, a slow or stalled client could hold a throttle token indefinitely during io.Copy, exhausting all 2-4 slots and blocking artwork requests for all users (reported after 15+ days uptime). The new approach buffers artwork into memory while holding the token, releases it immediately, then writes the buffered response. A 30-second per-request write deadline (SetWriteTimeout) prevents stalled writes from blocking indefinitely. Throttle exhaustion is now logged with context for operator visibility. * refactor(server): simplify throttle to middleware with same API as Chi Restructure RequestThrottle from a DI-injected type into a drop-in middleware function with the same signature as Chi's ThrottleBacklog. Handlers are reverted to their original simple form (no throttle awareness), and the middleware is applied at route definition time just like before. This eliminates the DI dependency, removes the artworkThrottle field from both Router structs, and consolidates SetWriteTimeout into the throttle file. When limit <= 0, the middleware returns a passthrough so callers don't need a guard. Signed-off-by: Deluan * feat(server): add opt-out flag for buffered artwork throttle Add DevArtworkThrottleBuffered config (default true) that controls whether the new buffered ThrottleBacklog middleware is used. When set to false, it falls back to Chi's original middleware, giving users a safety valve in case the buffered implementation causes issues. Signed-off-by: Deluan * test(server): clean up throttle tests for clarity and speed Consolidate duplicate router setup into runTwoRequests() and slowClientTest() helpers. Replace time.Sleep-based token holding with channel synchronization, reducing suite time from ~7s to ~1.5s. Remove redundant test, fix duplicate comment block, and add comment explaining why slowTestWriter can't embed httptest.ResponseRecorder. * fix: release artwork throttle tokens on panic Defer the buffered artwork throttle release inside the handler closure so tokens are returned even when a downstream handler panics before response flushing. Document that the middleware buffers full responses in memory and add a regression test covering recovery after a panic. * fix: align buffered throttle response behavior Keep only the first status code written to the buffered artwork throttle response writer so it matches net/http semantics. Strengthen the opt-out test to verify DevArtworkThrottleBuffered=false uses Chi's original slow-client behavior instead of only checking shared 429 handling. * refactor(server): remove setWriteTimeout from throttle middleware SetWriteDeadline only constrains the server's Write syscall, not how fast the client reads from the TCP buffer. For artwork-sized responses (up to ~500KB), the kernel accepts the entire write immediately even over real network interfaces due to TCP buffer auto-tuning. Verified by testing with a stalled client over both loopback and en0 — the deadline never triggers. The actual protection comes from buffering + early token release, which is already in place. --------- Signed-off-by: Deluan --- conf/configuration.go | 2 + server/public/public.go | 11 +- server/subsonic/api.go | 11 +- server/subsonic/media_retrieval_test.go | 16 +- server/throttle_backlog.go | 150 +++++++++++++ server/throttle_backlog_test.go | 266 ++++++++++++++++++++++++ 6 files changed, 426 insertions(+), 30 deletions(-) create mode 100644 server/throttle_backlog.go create mode 100644 server/throttle_backlog_test.go diff --git a/conf/configuration.go b/conf/configuration.go index 1c4829d82..d93024c8a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -134,6 +134,7 @@ type configOptions struct { DevArtworkMaxRequests int DevArtworkThrottleBacklogLimit int DevArtworkThrottleBacklogTimeout time.Duration + DevArtworkThrottleBuffered bool DevArtistInfoTimeToLive time.Duration DevAlbumInfoTimeToLive time.Duration DevExternalScanner bool @@ -861,6 +862,7 @@ func setViperDefaults() { viper.SetDefault("devartworkmaxrequests", max(2, runtime.NumCPU()/2)) viper.SetDefault("devartworkthrottlebackloglimit", consts.RequestThrottleBacklogLimit) viper.SetDefault("devartworkthrottlebacklogtimeout", consts.RequestThrottleBacklogTimeout) + viper.SetDefault("devartworkthrottlebuffered", true) viper.SetDefault("devartistinfotimetolive", consts.ArtistInfoTimeToLive) viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive) viper.SetDefault("devexternalscanner", true) diff --git a/server/public/public.go b/server/public/public.go index 5e3407c19..18867e1c4 100644 --- a/server/public/public.go +++ b/server/public/public.go @@ -5,14 +5,12 @@ import ( "path" "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" "github.com/navidrome/navidrome/core/publicurl" "github.com/navidrome/navidrome/core/stream" - "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" "github.com/navidrome/navidrome/server" "github.com/navidrome/navidrome/ui" @@ -43,13 +41,8 @@ func (pub *Router) routes() http.Handler { r.Group(func(r chi.Router) { r.Use(server.URLParamsMiddleware) r.Group(func(r chi.Router) { - if conf.Server.DevArtworkMaxRequests > 0 { - log.Debug("Throttling public images endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests, - "backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout", - conf.Server.DevArtworkThrottleBacklogTimeout) - r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, - conf.Server.DevArtworkThrottleBacklogTimeout)) - } + r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, + conf.Server.DevArtworkThrottleBacklogTimeout)) r.HandleFunc("/img/{id}", pub.handleImages) }) if conf.Server.EnableSharing { diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 0bbfcb83e..1ca364449 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -9,7 +9,6 @@ import ( "regexp" "github.com/go-chi/chi/v5" - "github.com/go-chi/chi/v5/middleware" "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/core" "github.com/navidrome/navidrome/core/artwork" @@ -190,14 +189,8 @@ func (api *Router) routes() http.Handler { hr(r, "getTranscodeStream", api.GetTranscodeStream) }) r.Group(func(r chi.Router) { - // configure request throttling - if conf.Server.DevArtworkMaxRequests > 0 { - log.Debug("Throttling Subsonic getCoverArt endpoint", "maxRequests", conf.Server.DevArtworkMaxRequests, - "backlogLimit", conf.Server.DevArtworkThrottleBacklogLimit, "backlogTimeout", - conf.Server.DevArtworkThrottleBacklogTimeout) - r.Use(middleware.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, - conf.Server.DevArtworkThrottleBacklogTimeout)) - } + r.Use(server.ThrottleBacklog(conf.Server.DevArtworkMaxRequests, conf.Server.DevArtworkThrottleBacklogLimit, + conf.Server.DevArtworkThrottleBacklogTimeout)) hr(r, "getCoverArt", api.GetCoverArt) }) r.Group(func(r chi.Router) { diff --git a/server/subsonic/media_retrieval_test.go b/server/subsonic/media_retrieval_test.go index 589a609da..27d1edb84 100644 --- a/server/subsonic/media_retrieval_test.go +++ b/server/subsonic/media_retrieval_test.go @@ -78,16 +78,13 @@ var _ = Describe("MediaRetrievalController", func() { When("client disconnects (context is cancelled)", func() { It("should not call the service if cancelled before the call", func() { - // Create a request ctx, cancel := context.WithCancel(context.Background()) r := newGetRequest("id=34", "size=128", "square=true") r = r.WithContext(ctx) - cancel() // Cancel the context before the call + cancel() - // Call the GetCoverArt method _, err := router.GetCoverArt(w, r) - // Expect no error and no call to the artwork service Expect(err).ToNot(HaveOccurred()) Expect(artwork.recvId).To(Equal("")) Expect(artwork.recvSize).To(Equal(0)) @@ -96,17 +93,14 @@ var _ = Describe("MediaRetrievalController", func() { }) It("should not return data if cancelled during the call", func() { - // Create a request with a context that will be cancelled ctx, cancel := context.WithCancel(context.Background()) - defer cancel() // Ensure the context is cancelled after the test (best practices) + defer cancel() r := newGetRequest("id=34", "size=128", "square=true") r = r.WithContext(ctx) - artwork.ctxCancelFunc = cancel // Set the cancel function to simulate cancellation in the service + artwork.ctxCancelFunc = cancel - // Call the GetCoverArt method _, err := router.GetCoverArt(w, r) - // Expect no error and the service to have been called Expect(err).ToNot(HaveOccurred()) Expect(artwork.recvId).To(Equal("34")) Expect(artwork.recvSize).To(Equal(128)) @@ -344,7 +338,7 @@ func (c *fakeArtwork) GetOrPlaceholder(_ context.Context, id string, size int, s c.recvSize = size c.recvSquare = square if c.ctxCancelFunc != nil { - c.ctxCancelFunc() // Simulate context cancellation + c.ctxCancelFunc() return nil, time.Time{}, context.Canceled } return io.NopCloser(bytes.NewReader([]byte(c.data))), time.Time{}, nil @@ -363,9 +357,7 @@ func (m *mockedMediaFile) GetAll(opts ...model.QueryOptions) (model.MediaFiles, return data, nil } - // Hardcoded support for lyrics sorting result := slices.Clone(data) - // Sort by presence of lyrics, then by updated_at. Respect the order specified in opts. slices.SortFunc(result, func(a, b model.MediaFile) int { diff := cmp.Or( cmp.Compare(a.Lyrics, b.Lyrics), diff --git a/server/throttle_backlog.go b/server/throttle_backlog.go new file mode 100644 index 000000000..c3672fd1e --- /dev/null +++ b/server/throttle_backlog.go @@ -0,0 +1,150 @@ +package server + +import ( + "bytes" + "context" + "errors" + "net/http" + "sync" + "time" + + "github.com/go-chi/chi/v5/middleware" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/log" +) + +var ( + ErrThrottleCapacityExceeded = errors.New("throttle: capacity exceeded") + ErrThrottleTimeout = errors.New("throttle: backlog timeout") +) + +type requestThrottle struct { + tokens chan struct{} + backlogTokens chan struct{} + backlogTimeout time.Duration +} + +// ThrottleBacklog creates a Chi-compatible middleware that limits concurrent +// request processing. Unlike Chi's ThrottleBacklog, it buffers the handler's +// response while holding the token, releases it, then flushes the buffer to +// the client with a write deadline. This prevents slow clients from holding +// throttle capacity. +// +// Because it buffers the entire response in memory, this middleware should only +// be used for endpoints that return small responses (e.g., artwork images). Do +// not use it for audio streaming or download endpoints. +func ThrottleBacklog(limit, backlogLimit int, backlogTimeout time.Duration) func(http.Handler) http.Handler { + if limit <= 0 { + return func(next http.Handler) http.Handler { return next } + } + if !conf.Server.DevArtworkThrottleBuffered { + return middleware.ThrottleBacklog(limit, backlogLimit, backlogTimeout) + } + t := &requestThrottle{ + tokens: make(chan struct{}, limit), + backlogTokens: make(chan struct{}, limit+backlogLimit), + backlogTimeout: backlogTimeout, + } + for range limit { + t.tokens <- struct{}{} + } + for range limit + backlogLimit { + t.backlogTokens <- struct{}{} + } + return t.handler +} + +func (t *requestThrottle) handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + release, err := t.acquire(ctx) + if err != nil { + switch { + case errors.Is(err, ErrThrottleCapacityExceeded): + log.Warn(ctx, "Request throttle capacity exceeded", "path", r.URL.Path) + case errors.Is(err, ErrThrottleTimeout): + log.Warn(ctx, "Request throttle backlog timeout", "path", r.URL.Path) + } + http.Error(w, http.StatusText(http.StatusTooManyRequests), http.StatusTooManyRequests) + return + } + + buf := &bufferedResponseWriter{header: make(http.Header)} + func() { + defer release() + next.ServeHTTP(buf, r) + }() + + for k, v := range buf.header { + w.Header()[k] = v + } + if buf.code > 0 { + w.WriteHeader(buf.code) + } + if _, err := w.Write(buf.body.Bytes()); err != nil { + log.Warn(ctx, "Error writing throttled response", err) + } + }) +} + +func (t *requestThrottle) acquire(ctx context.Context) (release func(), err error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-t.backlogTokens: + default: + return nil, ErrThrottleCapacityExceeded + } + + select { + case <-t.tokens: + return t.releaseFunc(), nil + default: + } + + timer := time.NewTimer(t.backlogTimeout) + select { + case <-timer.C: + t.backlogTokens <- struct{}{} + return nil, ErrThrottleTimeout + case <-ctx.Done(): + timer.Stop() + t.backlogTokens <- struct{}{} + return nil, ctx.Err() + case <-t.tokens: + timer.Stop() + return t.releaseFunc(), nil + } +} + +func (t *requestThrottle) releaseFunc() func() { + var once sync.Once + return func() { + once.Do(func() { + t.tokens <- struct{}{} + t.backlogTokens <- struct{}{} + }) + } +} + +type bufferedResponseWriter struct { + header http.Header + body bytes.Buffer + code int +} + +func (w *bufferedResponseWriter) Header() http.Header { + return w.header +} + +func (w *bufferedResponseWriter) Write(b []byte) (int, error) { + return w.body.Write(b) +} + +func (w *bufferedResponseWriter) WriteHeader(code int) { + if w.code != 0 { + return + } + w.code = code +} diff --git a/server/throttle_backlog_test.go b/server/throttle_backlog_test.go new file mode 100644 index 000000000..eb181b4ce --- /dev/null +++ b/server/throttle_backlog_test.go @@ -0,0 +1,266 @@ +package server + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ThrottleBacklog", func() { + It("is a passthrough when limit is 0", func() { + m := ThrottleBacklog(0, 10, time.Second) + r := chi.NewRouter() + r.Use(m) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("ok")) + }) + + It("returns 429 when capacity is exceeded", func() { + _, secondStatus := runTwoRequests(ThrottleBacklog(1, 0, time.Second)) + Expect(secondStatus).To(Equal(http.StatusTooManyRequests)) + }) + + It("returns 429 when backlog times out", func() { + _, secondStatus := runTwoRequests(ThrottleBacklog(1, 1, 50*time.Millisecond)) + Expect(secondStatus).To(Equal(http.StatusTooManyRequests)) + }) + + It("releases capacity when the handler panics", func() { + m := ThrottleBacklog(1, 0, time.Second) + r := chi.NewRouter() + r.Use(middleware.Recoverer) + r.Use(m) + r.Get("/panic", func(w http.ResponseWriter, r *http.Request) { + panic("boom") + }) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/panic", nil) + r.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusInternalServerError)) + + w = httptest.NewRecorder() + req, _ = http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusOK)) + Expect(w.Body.String()).To(Equal("ok")) + }) + + It("preserves response headers and status code", func() { + m := ThrottleBacklog(2, 0, time.Second) + r := chi.NewRouter() + r.Use(m) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Cache-Control", "public") + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte("body")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusCreated)) + Expect(w.Header().Get("Content-Type")).To(Equal("image/jpeg")) + Expect(w.Header().Get("Cache-Control")).To(Equal("public")) + Expect(w.Body.String()).To(Equal("body")) + }) + + It("uses the first response status code", func() { + m := ThrottleBacklog(2, 0, time.Second) + r := chi.NewRouter() + r.Use(m) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte("body")) + }) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + + Expect(w.Code).To(Equal(http.StatusCreated)) + Expect(w.Body.String()).To(Equal("body")) + }) + + It("never exceeds the concurrency limit", func() { + const limit = 3 + const goroutines = 20 + m := ThrottleBacklog(limit, goroutines, 5*time.Second) + + var concurrent atomic.Int32 + var maxConcurrent atomic.Int32 + + r := chi.NewRouter() + r.Use(m) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + cur := concurrent.Add(1) + for { + old := maxConcurrent.Load() + if cur <= old || maxConcurrent.CompareAndSwap(old, cur) { + break + } + } + time.Sleep(5 * time.Millisecond) + concurrent.Add(-1) + _, _ = w.Write([]byte("ok")) + }) + + var wg sync.WaitGroup + for range goroutines { + wg.Go(func() { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + }) + } + + wg.Wait() + Expect(maxConcurrent.Load()).To(BeNumerically("<=", limit)) + }) + + // Regression: with only 1 token, a slow client blocking during response + // writing must NOT prevent other requests from being served. Chi's original + // ThrottleBacklog holds the token for the entire handler lifecycle including + // io.Copy, causing starvation. The buffered implementation releases it first. + Context("when a client is slow to read the response", func() { + slowClientTest := func(m func(http.Handler) http.Handler) (*chi.Mux, chan struct{}, chan struct{}) { + handlerReached := make(chan struct{}, 1) + router := chi.NewRouter() + router.Use(m) + router.Get("/test", func(w http.ResponseWriter, r *http.Request) { + select { + case handlerReached <- struct{}{}: + default: + } + _, _ = io.Copy(w, strings.NewReader("image data")) + }) + + unblocked := make(chan struct{}) + slow := newSlowTestWriter(unblocked) + + reqDone := make(chan struct{}) + go func() { + defer close(reqDone) + req, _ := http.NewRequest("GET", "/test", nil) + router.ServeHTTP(slow, req) + }() + <-handlerReached + + return router, unblocked, reqDone + } + + It("does not starve concurrent requests with buffered middleware", func() { + router, unblocked, reqDone := slowClientTest(ThrottleBacklog(1, 1, 500*time.Millisecond)) + + Eventually(func() int { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + router.ServeHTTP(w, req) + return w.Code + }, 2*time.Second, 10*time.Millisecond).Should(Equal(http.StatusOK)) + + close(unblocked) + Eventually(reqDone, 2*time.Second).Should(BeClosed()) + }) + + It("starves concurrent requests with Chi's original middleware", func() { + DeferCleanup(configtest.SetupConfig()) + conf.Server.DevArtworkThrottleBuffered = false + + router, unblocked, reqDone := slowClientTest(ThrottleBacklog(1, 1, 500*time.Millisecond)) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + router.ServeHTTP(w, req) + Expect(w.Code).To(Equal(http.StatusTooManyRequests)) + + close(unblocked) + Eventually(reqDone, 2*time.Second).Should(BeClosed()) + }) + }) +}) + +// runTwoRequests sends two concurrent requests through a throttled router. The +// first request holds the token until the second has been dispatched. +func runTwoRequests(m func(http.Handler) http.Handler) (firstStatus, secondStatus int) { + held := make(chan struct{}) + release := make(chan struct{}) + r := chi.NewRouter() + r.Use(m) + r.Get("/test", func(w http.ResponseWriter, r *http.Request) { + select { + case held <- struct{}{}: + default: + } + <-release + _, _ = w.Write([]byte("ok")) + }) + + done := make(chan int) + go func() { + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + done <- w.Code + }() + <-held + + w := httptest.NewRecorder() + req, _ := http.NewRequest("GET", "/test", nil) + r.ServeHTTP(w, req) + secondStatus = w.Code + + close(release) + firstStatus = <-done + return firstStatus, secondStatus +} + +// slowTestWriter implements http.ResponseWriter without embedding +// httptest.ResponseRecorder. This is necessary because ResponseRecorder +// promotes io.ReaderFrom, which io.Copy prefers over Write — bypassing +// our blocking Write and defeating the slow-client simulation. +type slowTestWriter struct { + header http.Header + body bytes.Buffer + code int + unblocked chan struct{} +} + +func newSlowTestWriter(unblocked chan struct{}) *slowTestWriter { + return &slowTestWriter{header: make(http.Header), unblocked: unblocked} +} + +func (w *slowTestWriter) Header() http.Header { return w.header } + +func (w *slowTestWriter) WriteHeader(code int) { w.code = code } + +func (w *slowTestWriter) Write(p []byte) (int, error) { + <-w.unblocked + return w.body.Write(p) +} From 770fd62ce869465da2fe793ba2c50ce7723f0943 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 6 May 2026 00:16:09 -0400 Subject: [PATCH 065/117] ci: fix input parameter name for GitHub token Signed-off-by: Deluan --- .github/actions/prepare-docker/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/prepare-docker/action.yml b/.github/actions/prepare-docker/action.yml index 7fe0b9148..b8cde4aaf 100644 --- a/.github/actions/prepare-docker/action.yml +++ b/.github/actions/prepare-docker/action.yml @@ -73,7 +73,7 @@ runs: id: meta uses: docker/metadata-action@v6 with: - token: ${{ inputs.github_token }} + github-token: ${{ inputs.github_token }} labels: | maintainer=deluan@navidrome.org images: | From 0cb476266e155e4bd59fab93d09c8389158f8c89 Mon Sep 17 00:00:00 2001 From: IEEE-754 <253034919+IEEE-754@users.noreply.github.com> Date: Wed, 6 May 2026 20:49:13 +0800 Subject: [PATCH 066/117] fix(ui): update zh-Hant.json (#5470) --- resources/i18n/zh-Hant.json | 56 ++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json index 93951a311..92b4af3d0 100644 --- a/resources/i18n/zh-Hant.json +++ b/resources/i18n/zh-Hant.json @@ -180,7 +180,7 @@ "name": "名稱", "transcodingId": "轉碼", "maxBitRate": "最大位元率", - "client": "客戶端", + "client": "用戶端", "userName": "使用者名稱", "lastSeen": "上次上線", "reportRealPath": "回報實際路徑", @@ -333,7 +333,7 @@ } }, "plugin": { - "name": "插件 |||| 插件", + "name": "外掛 |||| 外掛", "fields": { "id": "ID", "name": "名稱", @@ -359,7 +359,7 @@ }, "sections": { "status": "狀態", - "info": "插件資訊", + "info": "外掛資訊", "configuration": "設定", "manifest": "資訊清單", "usersPermission": "使用者權限", @@ -379,29 +379,29 @@ "rescan": "重新掃描" }, "notifications": { - "enabled": "插件已啟用", - "disabled": "插件已停用", - "updated": "插件已更新", - "error": "更新插件時發生錯誤" + "enabled": "外掛已啟用", + "disabled": "外掛已停用", + "updated": "外掛已更新", + "error": "更新外掛時發生錯誤" }, "validation": { "invalidJson": "設定必須是有效的 JSON" }, "messages": { - "configHelp": "使用鍵值對設定插件。若插件無需設定則留空。", + "configHelp": "使用鍵值對設定外掛。若外掛無需設定則留空。", "clickPermissions": "點擊權限以查看詳細資訊", "noConfig": "無設定", - "allUsersHelp": "啟用後,插件將可存取所有使用者,包含未來建立的使用者。", + "allUsersHelp": "啟用後,外掛將可存取所有使用者,包含未來建立的使用者。", "noUsers": "未選擇使用者", "permissionReason": "原因", - "usersRequired": "此插件需要存取使用者資訊。請選擇插件可存取的使用者,或啟用「允許所有使用者」。", - "allLibrariesHelp": "啟用後,插件將可存取所有媒體庫,包含未來建立的媒體庫。", + "usersRequired": "此外掛需要存取使用者資訊。請選擇外掛可存取的使用者,或啟用「允許所有使用者」。", + "allLibrariesHelp": "啟用後,外掛將可存取所有媒體庫,包含未來建立的媒體庫。", "noLibraries": "未選擇媒體庫", - "librariesRequired": "此插件需要存取媒體庫資訊。請選擇插件可存取的媒體庫,或啟用「允許所有媒體庫」。", + "librariesRequired": "此外掛需要存取媒體庫資訊。請選擇外掛可存取的媒體庫,或啟用「允許所有媒體庫」。", "requiredHosts": "必要的 Hosts", "configValidationError": "設定驗證失敗:", - "schemaRenderError": "無法顯示設定表單。插件的 schema 可能無效。", - "allowWriteAccessHelp": "啟用後,插件可以修改媒體庫目錄中的檔案。 預設情況下,插件具有唯讀權限。" + "schemaRenderError": "無法顯示設定表單。外掛的 schema 可能無效。", + "allowWriteAccessHelp": "啟用後,外掛可以修改媒體庫目錄中的檔案。 預設情況下,外掛具有唯讀權限。" }, "placeholders": { "configKey": "鍵", @@ -452,7 +452,7 @@ "delete": "刪除", "edit": "編輯", "export": "匯出", - "list": "列表", + "list": "清單", "refresh": "重新整理", "remove_filter": "清除此條件", "remove": "移除", @@ -497,9 +497,9 @@ "upload_single": "拖曳單個圖片上傳或點擊選擇一個" }, "references": { - "all_missing": "未找到參考數據", - "many_missing": "至少有一條參考數據不再可用", - "single_missing": "關聯的參考數據不再可用" + "all_missing": "未找到參考資料", + "many_missing": "至少有一條參考資料不再可用", + "single_missing": "關聯的參考資料不再可用" }, "password": { "toggle_visible": "隱藏密碼", @@ -514,7 +514,7 @@ "delete_content": "您確定要刪除該項目?", "delete_title": "刪除 %{name} #%{id}", "details": "詳細資訊", - "error": "發生客戶端錯誤,您的請求無法完成", + "error": "發生用戶端錯誤,您的請求無法完成", "invalid_form": "提交內容無效,請檢查錯誤", "loading": "正在載入頁面,請稍候", "no": "否", @@ -564,19 +564,19 @@ "delete_user_content": "您確定要刪除此使用者及其所有資料(包括播放清單和偏好設定)嗎?", "notifications_blocked": "您已在瀏覽器設定中封鎖了此網站的通知", "notifications_not_available": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome", - "lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄", - "lastfmLinkFailure": "無法連接 Last.fm", - "lastfmUnlinkSuccess": "已取消與 Last.fm 的連接並停用音樂記錄", - "lastfmUnlinkFailure": "無法取消與 Last.fm 的連接", + "lastfmLinkSuccess": "已成功連結 Last.fm 並開啟音樂記錄", + "lastfmLinkFailure": "無法連結 Last.fm", + "lastfmUnlinkSuccess": "已取消與 Last.fm 的連結並停用音樂記錄", + "lastfmUnlinkFailure": "無法取消與 Last.fm 的連結", "openIn": { "lastfm": "在 Last.fm 中開啟", "musicbrainz": "在 MusicBrainz 中開啟" }, "lastfmLink": "查看更多…", - "listenBrainzLinkSuccess": "已成功以 %{user} 的身份連接 ListenBrainz 並開啟音樂記錄", - "listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}", - "listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連接並停用音樂記錄", - "listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連接", + "listenBrainzLinkSuccess": "已成功以 %{user} 的身份連結 ListenBrainz 並開啟音樂記錄", + "listenBrainzLinkFailure": "無法連結 ListenBrainz:%{error}", + "listenBrainzUnlinkSuccess": "已取消與 ListenBrainz 的連結並停用音樂記錄", + "listenBrainzUnlinkFailure": "無法取消與 ListenBrainz 的連結", "downloadOriginalFormat": "下載原始格式", "shareOriginalFormat": "分享原始格式", "shareDialogTitle": "分享 %{resource} '%{name}'", @@ -718,4 +718,4 @@ "empty": "無播放內容", "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前" } -} \ No newline at end of file +} From 6766d60bee7242bbd838bd2fd5ec5c5df2eb273f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 6 May 2026 10:03:24 -0400 Subject: [PATCH 067/117] fix(transcoding): don't apply server-side override on getTranscodeDecision (#5473) * fix(transcoding): don't apply server-side transcoding override on getTranscodeDecision The getTranscodeDecision endpoint was incorrectly applying server-side player transcoding overrides (forced format and MaxBitRate cap), which replaced the client's declared capabilities with synthetic profiles. This caused the endpoint to ignore what the client can actually play and return decisions for formats the client never requested (e.g. AAC when the client only supports FLAC/opus/mp3). The override is now gated behind an ApplyServerOverride flag in TranscodeOptions, which is only set by the legacy stream endpoint where this behavior is expected. Signed-off-by: Deluan * refactor: move server-side transcoding override to ResolveRequest Moved the server-side player transcoding override logic (forced format and MaxBitRate cap) from MakeDecision into ResolveRequest, where the legacy stream context is handled. This makes MakeDecision a pure function that only operates on the ClientInfo it receives, removing the ApplyServerOverride flag and all context-sniffing from the decision engine. Tests moved accordingly to legacy_client_test.go. * test(e2e): update transcode decision tests for server override removal Updated e2e tests to reflect that getTranscodeDecision no longer applies server-side player overrides (MaxBitRate cap and forced transcoding profile). The player MaxBitRate tests now verify the endpoint ignores the player cap and relies solely on client-declared capabilities. * test(e2e): assert opus default bitrate when player cap is ignored Added bitrate assertion to verify the player MaxBitRate cap is truly ignored: the target bitrate should be the opus format default (128kbps), not the player cap (320kbps). --------- Signed-off-by: Deluan --- core/stream/decider.go | 12 --- core/stream/decider_test.go | 135 +------------------------- core/stream/legacy_client.go | 16 ++- core/stream/legacy_client_test.go | 104 ++++++++++++++++++++ server/e2e/subsonic_transcode_test.go | 95 ++++-------------- 5 files changed, 141 insertions(+), 221 deletions(-) diff --git a/core/stream/decider.go b/core/stream/decider.go index cde12f0f3..d6e48497c 100644 --- a/core/stream/decider.go +++ b/core/stream/decider.go @@ -59,18 +59,6 @@ func (s *deciderService) MakeDecision(ctx context.Context, mf *model.MediaFile, decision.SourceStream = buildSourceStream(mf, probe) src := &decision.SourceStream - // Check for server-side player transcoding override - if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { - clientInfo = applyServerOverride(ctx, clientInfo, &trc) - } else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 { - if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate { - modified := *clientInfo - modified.MaxAudioBitrate = player.MaxBitRate - clientInfo = &modified - log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) - } - } - log.Trace(ctx, "Making transcode decision", "mediaID", mf.ID, "container", src.Container, "codec", src.Codec, "bitrate", src.Bitrate, "channels", src.Channels, "sampleRate", src.SampleRate, "lossless", src.IsLossless, "client", clientInfo.Name) diff --git a/core/stream/decider_test.go b/core/stream/decider_test.go index 8b58f3323..f74953258 100644 --- a/core/stream/decider_test.go +++ b/core/stream/decider_test.go @@ -1042,8 +1042,8 @@ var _ = Describe("Decider", func() { }) }) - Context("Server-side player transcoding override", func() { - It("forces transcoding when override targets a different format", func() { + Context("Server-side context is ignored by MakeDecision", func() { + It("ignores transcoding override in context", func() { mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) ci := &ClientInfo{ Name: "TestClient", @@ -1051,148 +1051,21 @@ var _ = Describe("Decider", func() { {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, }, } - // Set server override in context overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) - overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) - decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("mp3")) - Expect(decision.TargetBitrate).To(Equal(192)) - }) - - It("allows direct play when source matches forced format and bitrate is within cap", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, - }, - } - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256}) - - decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeTrue()) - Expect(decision.CanTranscode).To(BeFalse()) - }) - - It("transcodes when source bitrate exceeds the forced cap", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - } - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) - - decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("mp3")) - Expect(decision.TargetBitrate).To(Equal(192)) - }) - - It("uses player MaxBitRate over transcoding DefaultBitRate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - } - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) - overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320}) - - decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("mp3")) - Expect(decision.TargetBitrate).To(Equal(320)) - }) - - It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - } - overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0}) - overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) - - decision, err := svc.MakeDecision(overrideCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - Expect(decision.TargetFormat).To(Equal("mp3")) - // With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock) - Expect(decision.TargetBitrate).To(Equal(160)) - }) - - It("does not apply override when no transcoding is in context", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, - }, - } - // No override in context — client profiles used as-is - decision, err := svc.MakeDecision(ctx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) }) - }) - - Context("Player MaxBitRate cap", func() { - It("applies player MaxBitRate cap when client has no limit", func() { + It("ignores player MaxBitRate in context", func() { mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) ci := &ClientInfo{ Name: "TestClient", DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"flac", "mp3"}, AudioCodecs: []string{"flac", "mp3"}, Protocols: []string{ProtocolHTTP}}, - }, - TranscodingProfiles: []Profile{ - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, + {Containers: []string{"flac"}, Protocols: []string{ProtocolHTTP}}, }, } playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320}) - - decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - // Source bitrate 1000 > player cap 320, so direct play is not possible - Expect(decision.CanDirectPlay).To(BeFalse()) - Expect(decision.CanTranscode).To(BeTrue()) - // Lossless→lossy should use MaxAudioBitrate (320) as target, not format default - Expect(decision.TargetBitrate).To(Equal(320)) - }) - - It("uses client limit when it is more restrictive than player MaxBitRate", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) - ci := &ClientInfo{ - Name: "TestClient", - MaxAudioBitrate: 256, - MaxTranscodingAudioBitrate: 256, - TranscodingProfiles: []Profile{ - {Container: "mp3", AudioCodec: "mp3", Protocol: ProtocolHTTP}, - }, - } - playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500}) - - decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{}) - Expect(err).ToNot(HaveOccurred()) - Expect(decision.CanTranscode).To(BeTrue()) - // Client limit 256 < player cap 500, so player cap doesn't apply; client limit wins - Expect(decision.TargetBitrate).To(Equal(256)) - }) - - It("does not cap when player MaxBitRate is 0", func() { - mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) - ci := &ClientInfo{ - Name: "TestClient", - DirectPlayProfiles: []DirectPlayProfile{ - {Containers: []string{"mp3"}, AudioCodecs: []string{"mp3"}, Protocols: []string{ProtocolHTTP}}, - }, - } - playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0}) - decision, err := svc.MakeDecision(playerCtx, mf, ci, TranscodeOptions{}) Expect(err).ToNot(HaveOccurred()) Expect(decision.CanDirectPlay).To(BeTrue()) diff --git a/core/stream/legacy_client.go b/core/stream/legacy_client.go index d6e929ec8..9dd6179a0 100644 --- a/core/stream/legacy_client.go +++ b/core/stream/legacy_client.go @@ -7,12 +7,11 @@ import ( "github.com/navidrome/navidrome/conf" "github.com/navidrome/navidrome/log" "github.com/navidrome/navidrome/model" + "github.com/navidrome/navidrome/model/request" ) // buildLegacyClientInfo translates legacy Subsonic stream/download parameters // into a ClientInfo for use with MakeDecision. -// It does NOT read request.TranscodingFrom(ctx) — that is handled by -// MakeDecision's applyServerOverride. func buildLegacyClientInfo(mf *model.MediaFile, reqFormat string, reqBitRate int) *ClientInfo { ci := &ClientInfo{Name: "legacy"} @@ -65,6 +64,19 @@ func (s *deciderService) ResolveRequest(ctx context.Context, mf *model.MediaFile } clientInfo := buildLegacyClientInfo(mf, reqFormat, reqBitRate) + + // Apply server-side player transcoding override before making the decision + if trc, ok := request.TranscodingFrom(ctx); ok && trc.TargetFormat != "" { + clientInfo = applyServerOverride(ctx, clientInfo, &trc) + } else if player, ok := request.PlayerFrom(ctx); ok && player.MaxBitRate > 0 { + if clientInfo.MaxAudioBitrate == 0 || player.MaxBitRate < clientInfo.MaxAudioBitrate { + modified := *clientInfo + modified.MaxAudioBitrate = player.MaxBitRate + clientInfo = &modified + log.Debug(ctx, "Applied player MaxBitRate cap", "playerMaxBitRate", player.MaxBitRate, "client", clientInfo.Name) + } + } + decision, err := s.MakeDecision(ctx, mf, clientInfo, TranscodeOptions{SkipProbe: true}) if err != nil { log.Error(ctx, "Error making transcode decision, falling back to raw", "id", mf.ID, err) diff --git a/core/stream/legacy_client_test.go b/core/stream/legacy_client_test.go index de1eb1339..ce7b38650 100644 --- a/core/stream/legacy_client_test.go +++ b/core/stream/legacy_client_test.go @@ -7,6 +7,7 @@ import ( "github.com/navidrome/navidrome/conf/configtest" "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" @@ -187,6 +188,109 @@ var _ = Describe("ResolveRequest", func() { Expect(req.Offset).To(Equal(30)) }) + Context("Server-side player transcoding override", func() { + It("forces transcoding when override targets a different format", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(192)) + }) + + It("allows direct play when source matches forced format and bitrate is within cap", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 128, Channels: 2, SampleRate: 44100}) + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 256}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + + It("transcodes when source bitrate exceeds the forced cap", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(192)) + }) + + It("uses player MaxBitRate over transcoding DefaultBitRate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 192}) + overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 320}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(320)) + }) + + It("applies no bitrate cap when both MaxBitRate and DefaultBitRate are 0", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + overrideCtx := request.WithTranscoding(ctx, model.Transcoding{TargetFormat: "mp3", DefaultBitRate: 0}) + overrideCtx = request.WithPlayer(overrideCtx, model.Player{MaxBitRate: 0}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(overrideCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("mp3")) + // With no cap, lossless→lossy uses format default bitrate (160 for mp3 from mock) + Expect(req.BitRate).To(Equal(160)) + }) + + It("does not apply override when no transcoding is in context", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(ctx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + }) + + Context("Player MaxBitRate cap", func() { + It("applies player MaxBitRate cap when client has no limit", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 320}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(playerCtx, mf, "mp3", 0, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(320)) + }) + + It("uses client limit when it is more restrictive than player MaxBitRate", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "flac", Codec: "FLAC", BitRate: 1000, Channels: 2, SampleRate: 44100, BitDepth: 16}) + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 500}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(playerCtx, mf, "mp3", 256, 0) + + Expect(req.Format).To(Equal("mp3")) + Expect(req.BitRate).To(Equal(256)) + }) + + It("does not cap when player MaxBitRate is 0", func() { + mf := withProbe(&model.MediaFile{ID: "1", Suffix: "mp3", Codec: "MP3", BitRate: 320, Channels: 2, SampleRate: 44100}) + playerCtx := request.WithPlayer(ctx, model.Player{MaxBitRate: 0}) + + decider := svc.(*deciderService) + req := decider.ResolveRequest(playerCtx, mf, "", 0, 0) + + Expect(req.Format).To(Equal("raw")) + }) + }) + Context("fallback for unknown format", func() { It("falls back to DefaultDownsamplingFormat", func() { DeferCleanup(configtest.SetupConfig()) diff --git a/server/e2e/subsonic_transcode_test.go b/server/e2e/subsonic_transcode_test.go index 6041cd013..ae3d6208c 100644 --- a/server/e2e/subsonic_transcode_test.go +++ b/server/e2e/subsonic_transcode_test.go @@ -396,68 +396,30 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { }) }) - Describe("player MaxBitRate cap", func() { - It("forces transcode when source bitrate exceeds player MaxBitRate", func() { + Describe("player MaxBitRate cap is ignored", func() { + It("allows direct play even when source bitrate exceeds player MaxBitRate", func() { setPlayerMaxBitRate(320) // 320 kbps cap - // FLAC is 900kbps, client has no bitrate limit but player cap is 320 + // FLAC is 900kbps, player cap is 320, but getTranscodeDecision + // ignores server-side overrides — client profiles are used as-is resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeFalse()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - Expect(resp.TranscodeDecision.TranscodeStream.Container).To(Equal("mp3")) - // Target bitrate should be capped at player's 320kbps = 320000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) - }) - - It("does not affect direct play when source bitrate is under player MaxBitRate", func() { - setPlayerMaxBitRate(500) // 500 kbps cap - - // MP3 is 320kbps, under the 500kbps player cap → direct play - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", mp3TrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) }) - It("uses client limit when more restrictive than player MaxBitRate", func() { - setPlayerMaxBitRate(500) // 500 kbps player cap - - // Client caps at 320kbps (bitrateCapClient), which is more restrictive than 500 - // FLAC is 900kbps → exceeds both limits → transcode - resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // Client limit (320kbps) is more restrictive → 320000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) - }) - - It("uses player MaxBitRate when more restrictive than client limit", func() { + It("uses only client limit, not player MaxBitRate", func() { setPlayerMaxBitRate(192) // 192 kbps player cap // Client caps at 320kbps (bitrateCapClient), player is more restrictive at 192 - // FLAC is 900kbps → transcode at 192kbps + // but getTranscodeDecision ignores player cap → client limit (320kbps) applies resp := doPostReq("getTranscodeDecision", bitrateCapClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // Player limit (192kbps) is more restrictive → 192000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) - }) - - It("has no effect when player MaxBitRate is 0", func() { - setPlayerMaxBitRate(0) // No player cap - - // FLAC with flac+mp3 client → direct play (no bitrate constraint) - resp := doPostReq("getTranscodeDecision", flacAndMp3Client, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision).ToNot(BeNil()) - Expect(resp.TranscodeDecision.CanDirectPlay).To(BeTrue()) + // Only client limit (320kbps) applies → 320000 bps + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) }) }) @@ -513,56 +475,37 @@ var _ = Describe("Transcode Endpoints", Ordered, func() { }) }) - Describe("player MaxBitRate + client limits combined", func() { - It("player MaxBitRate injects maxAudioBitrate, format default used for transcode target", func() { + Describe("player MaxBitRate is ignored by getTranscodeDecision", func() { + It("does not inject maxAudioBitrate from player cap", func() { setPlayerMaxBitRate(320) // opusTranscodeClient has no client bitrate limits - // Player cap injects maxAudioBitrate=320 - // FLAC (900kbps) → exceeds 320 → transcode to opus - // Lossless→lossy: maxTranscodingAudioBitrate=0, so falls back to maxAudioBitrate=320 + // Player cap is 320, but getTranscodeDecision ignores it + // FLAC (900kbps) → can't direct play → transcode to opus using format default resp := doPostReq("getTranscodeDecision", opusTranscodeClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) Expect(resp.TranscodeDecision.TranscodeStream.Codec).To(Equal("opus")) - // maxAudioBitrate=320 used as fallback → 320000 bps - Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(320000))) + // Bitrate should be opus format default (128kbps), not player cap (320kbps) + Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(128000))) }) - It("player MaxBitRate + client maxTranscodingAudioBitrate work together", func() { + It("uses only client maxTranscodingAudioBitrate, ignoring player cap", func() { setPlayerMaxBitRate(320) - // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps), no maxAudioBitrate - // Player cap injects maxAudioBitrate=320 - // FLAC (900kbps) → exceeds 320 → transcode to mp3 - // Lossless→lossy: maxTranscodingAudioBitrate=192 takes priority + // maxTranscodeBitrateClient: maxTranscodingAudioBitrate=192000 (192kbps) + // Player cap is 320, but getTranscodeDecision ignores it + // Only client maxTranscodingAudioBitrate=192 applies resp := doPostReq("getTranscodeDecision", maxTranscodeBitrateClient, "mediaId", flacTrackID, "mediaType", "song") Expect(resp.Status).To(Equal(responses.StatusOK)) Expect(resp.TranscodeDecision).ToNot(BeNil()) Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) Expect(resp.TranscodeDecision.TranscodeStream).ToNot(BeNil()) - // maxTranscodingAudioBitrate=192 is preferred → 192000 bps + // maxTranscodingAudioBitrate=192 → 192000 bps Expect(resp.TranscodeDecision.TranscodeStream.AudioBitrate).To(Equal(int32(192000))) }) - - It("streams with correct bitrate after player MaxBitRate-triggered transcode", func() { - setPlayerMaxBitRate(128) - - // Get decision: FLAC (900kbps) with player cap 128 → transcode - resp := doPostReq("getTranscodeDecision", mp3OnlyClient, "mediaId", flacTrackID, "mediaType", "song") - Expect(resp.Status).To(Equal(responses.StatusOK)) - Expect(resp.TranscodeDecision.CanTranscode).To(BeTrue()) - token := resp.TranscodeDecision.TranscodeParams - Expect(token).ToNot(BeEmpty()) - - // Stream using the token - w := doRawReq("getTranscodeStream", "mediaId", flacTrackID, "mediaType", "song", "transcodeParams", token) - Expect(w.Code).To(Equal(http.StatusOK)) - Expect(streamerSpy.LastRequest.Format).To(Equal("mp3")) - Expect(streamerSpy.LastRequest.BitRate).To(Equal(128)) - }) }) }) From ec37859fbc8b31e0d31f24b818541cc270d41ffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 6 May 2026 11:03:11 -0400 Subject: [PATCH 068/117] fix(test): prevent flaky deadlock in throttle backlog test (#5474) The `held` channel in `runTwoRequests` was unbuffered, creating a race condition with the `select/default` send in the handler. Under CI load (slow runner, -race, -shuffle=on), the handler goroutine could reach the select before the test goroutine blocked on `<-held`, causing the send to silently fall through to `default` and deadlocking both goroutines permanently. Buffer the channel (capacity 1) so the send always succeeds regardless of goroutine scheduling order. --- server/throttle_backlog_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/throttle_backlog_test.go b/server/throttle_backlog_test.go index eb181b4ce..4d8567db5 100644 --- a/server/throttle_backlog_test.go +++ b/server/throttle_backlog_test.go @@ -209,7 +209,7 @@ var _ = Describe("ThrottleBacklog", func() { // runTwoRequests sends two concurrent requests through a throttled router. The // first request holds the token until the second has been dispatched. func runTwoRequests(m func(http.Handler) http.Handler) (firstStatus, secondStatus int) { - held := make(chan struct{}) + held := make(chan struct{}, 1) release := make(chan struct{}) r := chi.NewRouter() r.Use(m) From 6830003c65f693c1aef09a0d1a7b6760e376f5d8 Mon Sep 17 00:00:00 2001 From: Deluan Date: Sun, 10 May 2026 18:34:45 -0300 Subject: [PATCH 069/117] feat(subsonic): add groupings field to OpenSubsonic Child response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Include the ID3 grouping tag in OpenSubsonic responses as an array of strings, per opensubsonic/open-subsonic-api#232. The grouping tag was already being extracted and stored in MediaFile.Tags via mappings.yaml aliases (GRP1, GROUPING, ©grp, wm/contentgroupdescription), so this change only adds the field to the response struct and populates it in both song and album child builders. Signed-off-by: Deluan --- server/subsonic/helpers.go | 2 ++ .../Responses AlbumList with OS data should match .JSON | 5 ++++- .../Responses AlbumList with OS data should match .XML | 1 + ...ponses AlbumWithSongsID3 with data should match .JSON | 9 +++++++-- ...sponses AlbumWithSongsID3 with data should match .XML | 2 ++ .../Responses Child with data should match .JSON | 9 +++++++-- .../Responses Child with data should match .XML | 2 ++ ...es Child without data should match OpenSubsonic .JSON | 3 ++- server/subsonic/responses/responses.go | 1 + server/subsonic/responses/responses_test.go | 3 +++ 10 files changed, 31 insertions(+), 6 deletions(-) diff --git a/server/subsonic/helpers.go b/server/subsonic/helpers.go index 74d57ade4..8a9eb7110 100644 --- a/server/subsonic/helpers.go +++ b/server/subsonic/helpers.go @@ -266,6 +266,7 @@ func osChildFromMediaFile(ctx context.Context, mf model.MediaFile) *responses.Op child.BitDepth = int32(mf.BitDepth) child.Genres = toItemGenres(mf.Genres) child.Moods = mf.Tags.Values(model.TagMood) + child.Groupings = mf.Tags.Values(model.TagGrouping) child.DisplayArtist = mf.Artist child.Artists = artistRefs(mf.Participants[model.RoleArtist]) child.DisplayAlbumArtist = mf.AlbumArtist @@ -375,6 +376,7 @@ func osChildFromAlbum(ctx context.Context, al model.Album) *responses.OpenSubson child.MusicBrainzId = al.MbzAlbumID child.Genres = toItemGenres(al.Genres) child.Moods = al.Tags.Values(model.TagMood) + child.Groupings = al.Tags.Values(model.TagGrouping) child.DisplayArtist = al.AlbumArtist child.Artists = artistRefs(al.Participants[model.RoleAlbumArtist]) child.DisplayAlbumArtist = al.AlbumArtist diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON index 8491a577b..9d9ae2195 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .JSON @@ -56,7 +56,10 @@ "displayAlbumArtist": "Display album artist", "contributors": [], "displayComposer": "", - "explicitStatus": "explicit" + "explicitStatus": "explicit", + "groupings": [ + "Soundtrack" + ] } ] } diff --git a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML index 5d9e83f96..d39fe2e7d 100644 --- a/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses AlbumList with OS data should match .XML @@ -9,6 +9,7 @@ + Soundtrack 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 07678407a..a07914344 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 @@ -165,7 +165,11 @@ } ], "displayComposer": "composer 1 \u0026 composer 2", - "explicitStatus": "clean" + "explicitStatus": "clean", + "groupings": [ + "Soundtrack", + "Live" + ] }, { "id": "2", @@ -210,7 +214,8 @@ "displayAlbumArtist": "", "contributors": [], "displayComposer": "", - "explicitStatus": "" + "explicitStatus": "", + "groupings": [] } ] } 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 f7b23cb4e..6c96391bc 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 @@ -32,6 +32,8 @@ + Soundtrack + Live diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON index d20a6d48c..4c0ea6c68 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .JSON @@ -110,7 +110,11 @@ } ], "displayComposer": "composer 1 \u0026 composer 2", - "explicitStatus": "clean" + "explicitStatus": "clean", + "groupings": [ + "Soundtrack", + "Live" + ] }, { "id": "", @@ -141,7 +145,8 @@ "displayAlbumArtist": "", "contributors": [], "displayComposer": "", - "explicitStatus": "" + "explicitStatus": "", + "groupings": [] } ], "id": "1", diff --git a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML index 1d307b0b9..ddceb67d4 100644 --- a/server/subsonic/responses/.snapshots/Responses Child with data should match .XML +++ b/server/subsonic/responses/.snapshots/Responses Child with data should match .XML @@ -24,6 +24,8 @@ + Soundtrack + Live diff --git a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON index 25284295e..9a9ab1ff6 100644 --- a/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON +++ b/server/subsonic/responses/.snapshots/Responses Child without data should match OpenSubsonic .JSON @@ -28,7 +28,8 @@ "displayAlbumArtist": "", "contributors": [], "displayComposer": "", - "explicitStatus": "" + "explicitStatus": "", + "groupings": [] } ], "id": "", diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go index b0b2b8752..4165669df 100644 --- a/server/subsonic/responses/responses.go +++ b/server/subsonic/responses/responses.go @@ -189,6 +189,7 @@ type OpenSubsonicChild struct { Contributors Array[Contributor] `xml:"contributors,omitempty" json:"contributors"` DisplayComposer string `xml:"displayComposer,attr,omitempty" json:"displayComposer"` ExplicitStatus string `xml:"explicitStatus,attr,omitempty" json:"explicitStatus"` + Groupings Array[string] `xml:"groupings,omitempty" json:"groupings"` } type Songs struct { diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go index ee98a3daa..0b72fab19 100644 --- a/server/subsonic/responses/responses_test.go +++ b/server/subsonic/responses/responses_test.go @@ -224,6 +224,7 @@ var _ = Describe("Responses", func() { Isrc: []string{"ISRC-1", "ISRC-2"}, 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)}, DisplayArtist: "artist 1 & artist 2", Artists: []ArtistID3Ref{ @@ -320,6 +321,7 @@ var _ = Describe("Responses", func() { Comment: "a comment", MediaType: MediaTypeSong, MusicBrainzId: "4321", SortName: "sorted song", 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)}, BPM: 127, ChannelCount: 2, SamplingRate: 44100, BitDepth: 16, DisplayArtist: "artist1 & artist2", @@ -424,6 +426,7 @@ var _ = Describe("Responses", func() { ItemGenre{Name: "Genre 2"}, }, Moods: []string{"mood1", "mood2"}, + Groupings: []string{"Soundtrack"}, DisplayArtist: "Display artist", Artists: Array[ArtistID3Ref]{ ArtistID3Ref{Id: "artist-1", Name: "Artist 1"}, From 33f3ac01948a1bb63d62d5d28ded9cc3663a2e9e Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 11 May 2026 20:33:14 -0300 Subject: [PATCH 070/117] chore(deps): update TagLib to 2.3 Signed-off-by: Deluan --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 36218ba6b..7fe9ba59d 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/navidrome/navidrome go 1.26 // Fork to implement raw tags support -replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a +replace go.senan.xyz/taglib => github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a require ( github.com/Masterminds/squirrel v1.5.4 @@ -53,7 +53,7 @@ require ( github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 - github.com/tetratelabs/wazero v1.11.0 + github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 github.com/unrolled/secure v1.17.0 github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 diff --git a/go.sum b/go.sum index 90e0ec040..29eec8620 100644 --- a/go.sum +++ b/go.sum @@ -32,8 +32,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= -github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a h1:ZPwh87Xa08FCg5MU5e0Did5WgapEWGxb5d4Je0pLjJw= -github.com/deluan/go-taglib v0.0.0-20260407173416-cf47afbaa67a/go.mod h1:sKDN0U4qXDlq6LFK+aOAkDH4Me5nDV1V/A4B+B69xBA= +github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a h1:L5E3uF4hKLEqoEYT0tXXuFH6c3PEEzQSWLfTqF5Lpqw= +github.com/deluan/go-taglib v0.0.0-20260511232939-ccd334abae3a/go.mod h1:+k5CamBu88xgydgNGJjugYVeafoCCswoGjpw5w5CvD4= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf h1:tb246l2Zmpt/GpF9EcHCKTtwzrd0HGfEmoODFA/qnk4= github.com/deluan/rest v0.0.0-20211102003136-6260bc399cbf/go.mod h1:tSgDythFsl0QgS/PFWfIZqcJKnkADWneY80jaVRlqK8= github.com/deluan/sanitize v0.0.0-20241120162836-fdfd8fdfaa55 h1:wSCnggTs2f2ji6nFwQmfwgINcmSMj0xF0oHnoyRSPe4= @@ -279,8 +279,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 h1:ZF+QBjOI+tILZjBaFj3HgFonKXUcwgJ4djLb6i42S3Q= github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834/go.mod h1:m9ymHTgNSEjuxvw8E7WWe4Pl4hZQHXONY8wE6dMLaRk= -github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA= -github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633 h1:6GN/lazdqr69FIzz1U6c4TF/ppE2dInMR4GzU9QKxjg= +github.com/tetratelabs/wazero v1.11.1-0.20260428013916-2bbd517b7633/go.mod h1:3ghOSSWYnzX0zd/3Ns4ni2tKxcXDE9/QgkwuH1PW3Rs= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= From 2f41398276b46795b14f2b9c06e5935c0e3208a7 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 11 May 2026 20:34:04 -0300 Subject: [PATCH 071/117] chore(deps): update dependencies to latest versions Signed-off-by: Deluan --- go.mod | 26 +++++++++++++------------- go.sum | 52 ++++++++++++++++++++++++++-------------------------- 2 files changed, 39 insertions(+), 39 deletions(-) diff --git a/go.mod b/go.mod index 7fe9ba59d..6a0acf2d6 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( github.com/jellydator/ttlcache/v3 v3.4.0 github.com/kardianos/service v1.2.4 github.com/kr/pretty v0.3.1 - github.com/lestrrat-go/jwx/v3 v3.1.0 + github.com/lestrrat-go/jwx/v3 v3.1.1 github.com/mattn/go-sqlite3 v1.14.44 github.com/microcosm-cc/bluemonday v1.0.27 github.com/mileusna/useragent v1.3.5 @@ -58,12 +58,12 @@ require ( github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 go.senan.xyz/taglib v0.11.1 go.uber.org/goleak v1.3.0 - golang.org/x/image v0.39.0 - golang.org/x/net v0.53.0 + golang.org/x/image v0.40.0 + golang.org/x/net v0.54.0 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.43.0 - golang.org/x/term v0.42.0 - golang.org/x/text v0.36.0 + golang.org/x/sys v0.44.0 + golang.org/x/term v0.43.0 + golang.org/x/text v0.37.0 golang.org/x/time v0.15.0 gopkg.in/yaml.v3 v3.0.1 ) @@ -81,7 +81,7 @@ require ( 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/fsnotify/fsnotify v1.10.0 // 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 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect @@ -89,11 +89,11 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect + github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect - github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c // indirect + github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect @@ -133,10 +133,10 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/mod v0.35.0 // indirect - golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect - golang.org/x/tools v0.44.0 // indirect + golang.org/x/crypto v0.51.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 google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.2 // indirect gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect diff --git a/go.sum b/go.sum index 29eec8620..0c550e47d 100644 --- a/go.sum +++ b/go.sum @@ -63,8 +63,8 @@ github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M= -github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gen2brain/webp v0.5.5 h1:MvQR75yIPU/9nSqYT5h13k4URaJK3gf9tgz/ksRbyEg= github.com/gen2brain/webp v0.5.5/go.mod h1:xOSMzp4aROt2KFW++9qcK/RBTOVC2S9tJG66ip/9Oc0= github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= @@ -106,8 +106,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc h1:hd+uUVsB1vdxohPneMrhGH2YfQuH5hRIK9u4/XCeUtw= github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc/go.mod h1:SL66SJVysrh7YbDCP9tH30b8a9o/N2HeiQNUm85EKhc= -github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= -github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M= +github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -125,8 +125,8 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c h1:A1enk+iN8X/J1M/eN4U4NFGQToI51gCvRxEXYrfmqNs= -github.com/ianlancetaylor/demangle v0.0.0-20260502231528-600b0e508b8c/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= +github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f h1:NW3E2QSchEk63/fjeEvWOa2cE02FSv9ox//VE/N4c8g= +github.com/ianlancetaylor/demangle v0.0.0-20260505044615-1ff4bf46051f/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= @@ -167,8 +167,8 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM= github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= -github.com/lestrrat-go/jwx/v3 v3.1.0 h1:AyyLtxc0QM75F75JroWgt1phwC7X+wOb3XKhH7XBZWw= -github.com/lestrrat-go/jwx/v3 v3.1.0/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= +github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw= +github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU= github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= github.com/maruel/natural v1.3.0 h1:VsmCsBmEyrR46RomtgHs5hbKADGRVtliHTyCOLFBpsg= @@ -316,17 +316,17 @@ 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.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= -golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= +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/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= -golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +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/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,11 +364,11 @@ 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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= -golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4= -golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= +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= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -377,8 +377,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= @@ -389,8 +389,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -400,8 +400,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= -golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= From 7a5ffdb3670d5ec2467ce5febd36ce5cc4182faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 13 May 2026 17:17:20 -0300 Subject: [PATCH 072/117] fix(transcoding): place -ss before -i for fast input seeking (#5492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the ffmpeg -ss (seek/offset) parameter before -i in all transcoding commands so ffmpeg uses input seeking instead of output seeking. Per the ffmpeg docs, placing -ss before -i seeks at the demuxer level by keyframe (very fast), and since FFmpeg 2.1 it is also frame-accurate when transcoding. The previous placement after -i caused ffmpeg to decode and discard all audio up to the seek point, which was unnecessarily slow — especially problematic for lengthy files (4+ hours). Both code paths are updated: buildDynamicArgs (for default formats) and createFFmpegCommand (for custom templates without %t). A database migration updates existing default commands in the transcoding table. --- consts/consts.go | 8 +-- core/ffmpeg/ffmpeg.go | 19 +++++-- core/ffmpeg/ffmpeg_test.go | 19 ++++--- .../20260513173954_move_ss_before_input.go | 55 +++++++++++++++++++ tests/mock_transcoding_repo.go | 4 +- 5 files changed, 86 insertions(+), 19 deletions(-) create mode 100644 db/migrations/20260513173954_move_ss_before_input.go diff --git a/consts/consts.go b/consts/consts.go index bf32006d6..edd8f2b54 100644 --- a/consts/consts.go +++ b/consts/consts.go @@ -153,25 +153,25 @@ var ( Name: "mp3 audio", TargetFormat: "mp3", DefaultBitRate: 192, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -", }, { Name: "opus audio", TargetFormat: "opus", DefaultBitRate: 128, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", }, { Name: "aac audio", TargetFormat: "aac", DefaultBitRate: 256, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", }, { Name: "flac audio", TargetFormat: "flac", DefaultBitRate: 0, - Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", + Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", }, } ) diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go index 80790c8d6..3225ff150 100644 --- a/core/ffmpeg/ffmpeg.go +++ b/core/ffmpeg/ffmpeg.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strconv" "strings" "sync" @@ -394,12 +395,13 @@ func isDefaultCommand(format, command string) bool { // including all transcoding parameters (bitrate, sample rate, channels). func buildDynamicArgs(opts TranscodeOptions) []string { cmdPath, _ := ffmpegCmd() - args := []string{cmdPath, "-i", opts.FilePath} + args := []string{cmdPath} if opts.Offset > 0 { args = append(args, "-ss", strconv.Itoa(opts.Offset)) } + args = append(args, "-i", opts.FilePath) args = append(args, "-map", "0:a:0") if codec, ok := formatCodecMap[opts.Format]; ok { @@ -491,11 +493,20 @@ func createFFmpegCommand(cmd, path string, maxBitRate, offset int) []string { var args []string for _, s := range fixCmd(cmd) { if strings.Contains(s, "%s") { + if offset > 0 && !strings.Contains(cmd, "%t") { + // 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" { + insertAt = i + break + } + } + args = slices.Insert(args, insertAt, "-ss", strconv.Itoa(offset)) + } s = strings.ReplaceAll(s, "%s", path) args = append(args, s) - if offset > 0 && !strings.Contains(cmd, "%t") { - args = append(args, "-ss", strconv.Itoa(offset)) - } } else { s = strings.ReplaceAll(s, "%t", strconv.Itoa(offset)) s = strings.ReplaceAll(s, "%b", strconv.Itoa(maxBitRate)) diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go index 1649015d9..562fd9100 100644 --- a/core/ffmpeg/ffmpeg_test.go +++ b/core/ffmpeg/ffmpeg_test.go @@ -47,15 +47,15 @@ var _ = Describe("ffmpeg", func() { }) Context("when command has time offset param", func() { It("creates a valid command line with offset", func() { - args := createFFmpegCommand("ffmpeg -i %s -b:a %bk -ss %t mp3 -", "/music library/file.mp3", 123, 456) - Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-b:a", "123k", "-ss", "456", "mp3", "-"})) + args := createFFmpegCommand("ffmpeg -ss %t -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456) + Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) }) }) Context("when command does not have time offset param", func() { - It("adds time offset after the input file name", func() { + It("adds time offset before the input file name", func() { args := createFFmpegCommand("ffmpeg -i %s -b:a %bk mp3 -", "/music library/file.mp3", 123, 456) - Expect(args).To(Equal([]string{"ffmpeg", "-i", "/music library/file.mp3", "-ss", "456", "-b:a", "123k", "mp3", "-"})) + Expect(args).To(Equal([]string{"ffmpeg", "-ss", "456", "-i", "/music library/file.mp3", "-b:a", "123k", "mp3", "-"})) }) }) }) @@ -82,16 +82,16 @@ var _ = Describe("ffmpeg", func() { Describe("isDefaultCommand", func() { It("returns true for known default mp3 command", func() { - Expect(isDefaultCommand("mp3", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) + Expect(isDefaultCommand("mp3", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -")).To(BeTrue()) }) It("returns true for known default opus command", func() { - Expect(isDefaultCommand("opus", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) + Expect(isDefaultCommand("opus", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -")).To(BeTrue()) }) It("returns true for known default aac command", func() { - Expect(isDefaultCommand("aac", "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) + Expect(isDefaultCommand("aac", "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -")).To(BeTrue()) }) It("returns true for known default flac command", func() { - Expect(isDefaultCommand("flac", "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) + Expect(isDefaultCommand("flac", "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -")).To(BeTrue()) }) It("returns false for a custom command", func() { Expect(isDefaultCommand("mp3", "ffmpeg -i %s -b:a %bk -custom-flag -f mp3 -")).To(BeFalse()) @@ -165,8 +165,9 @@ var _ = Describe("ffmpeg", func() { Offset: 30, }) Expect(args).To(Equal([]string{ - "ffmpeg", "-i", "/music/file.mp3", + "ffmpeg", "-ss", "30", + "-i", "/music/file.mp3", "-map", "0:a:0", "-c:a", "libmp3lame", "-b:a", "192k", diff --git a/db/migrations/20260513173954_move_ss_before_input.go b/db/migrations/20260513173954_move_ss_before_input.go new file mode 100644 index 000000000..c16583aa0 --- /dev/null +++ b/db/migrations/20260513173954_move_ss_before_input.go @@ -0,0 +1,55 @@ +package migrations + +import ( + "context" + "database/sql" + + "github.com/pressly/goose/v3" +) + +func init() { + goose.AddMigrationContext(upMoveSsBeforeInput, downMoveSsBeforeInput) +} + +// ssSeekPairs maps old commands (output seeking) to new commands (input seeking). +// Index 0 = old (after -i), index 1 = new (before -i). +var ssSeekPairs = [][2]string{ + { + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -f mp3 -", + }, + { + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a libopus -f opus -", + }, + { + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f adts -", + }, + { + "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", + "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -", + }, + { + "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -", + "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -", + }, +} + +func upMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error { + for _, p := range ssSeekPairs { + if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[1], p[0]); err != nil { + return err + } + } + return nil +} + +func downMoveSsBeforeInput(_ context.Context, tx *sql.Tx) error { + for _, p := range ssSeekPairs { + if _, err := tx.Exec(`UPDATE transcoding SET command = ? WHERE command = ?`, p[0], p[1]); err != nil { + return err + } + } + return nil +} diff --git a/tests/mock_transcoding_repo.go b/tests/mock_transcoding_repo.go index 796e84111..641daca8a 100644 --- a/tests/mock_transcoding_repo.go +++ b/tests/mock_transcoding_repo.go @@ -19,9 +19,9 @@ func (m *MockTranscodingRepo) FindByFormat(format string) (*model.Transcoding, e case "opus": return &model.Transcoding{ID: "opus1", TargetFormat: "opus", DefaultBitRate: 96}, nil case "flac": - return &model.Transcoding{ID: "flac1", TargetFormat: "flac", DefaultBitRate: 0, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil + return &model.Transcoding{ID: "flac1", TargetFormat: "flac", DefaultBitRate: 0, Command: "ffmpeg -ss %t -i %s -map 0:a:0 -v 0 -c:a flac -f flac -"}, nil case "aac": - return &model.Transcoding{ID: "aac1", TargetFormat: "aac", DefaultBitRate: 256, Command: "ffmpeg -i %s -ss %t -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"}, nil + return &model.Transcoding{ID: "aac1", TargetFormat: "aac", DefaultBitRate: 256, Command: "ffmpeg -ss %t -i %s -map 0:a:0 -b:a %bk -v 0 -c:a aac -f ipod -movflags frag_keyframe+empty_moov -"}, nil default: return nil, model.ErrNotFound } From 5526d76129310ff06ee7234fa10aa01d7a3c710e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 13 May 2026 17:44:22 -0300 Subject: [PATCH 073/117] refactor(conf): replace eager dir creation with lazy Dir type (#5495) * feat(conf): add Dir type with lazy directory creation Introduces the Dir type that wraps a directory path string and defers os.MkdirAll until the first call to Path() or MustPath(), using sync.Once to ensure the creation happens exactly once. Implements fmt.Stringer, encoding.TextMarshaler, and encoding.TextUnmarshaler for config integration. Includes Ginkgo/Gomega tests covering all methods and error paths. * refactor(conf): replace eager dir creation with lazy Dir type Change DataFolder, CacheFolder, Plugins.Folder, and Backup.Path from string to Dir. Remove all os.MkdirAll calls from Load() so directories are created lazily on first Path()/MustPath() call. Artwork folder creation was already handled at point-of-use in image_upload.go. Add SnapshotConfig() to conf package for safe test config save/restore that avoids copying sync.Once inside Dir fields. Fix copy-lock vet warning in nativeapi/config.go by marshalling pointer instead of value. * refactor(conf): migrate tests and db init to lazy Dir type Update all test files to use conf.NewDir() for Dir field assignments. Ensure DataFolder is created lazily when the database is first opened in db.Db(). Remove eager directory creation from conf.Load() tests. * fix(conf): address review findings for Dir type - Use os.ModePerm for DataFolder/CacheFolder (was 0700, should match original behavior). Add NewDirWithPerm for PluginsFolder (0700). - Use Path() instead of MustPath() in db.Prune() to avoid logFatal from background cron job. - Panic on marshal/unmarshal errors in SnapshotConfig (test helper). - Clean up redundant String()/MustPath() calls in plugin manager. - Remove dead code in dir_test.go. Signed-off-by: Deluan * fix(conf): add GoString to Dir for clean config dump output Implement fmt.GoStringer on Dir so pretty.Sprintf shows the path string instead of internal struct fields (sync.Once, perm, err). Also add TODO comment to configtest about removing the indirection. * fix(dir): improve error logging in MustPath method Signed-off-by: Deluan * refactor(tests): remove redundant tests for unwritable DataFolder and CacheFolder Signed-off-by: Deluan * fix(conf): address PR review feedback - Ensure Plugins.Folder always uses 0700, even when user-configured (previously only the derived default got restrictive permissions). - Create LogFile parent directory before opening, so LogFile paths inside a not-yet-created DataFolder work correctly. --------- Signed-off-by: Deluan --- cmd/backup.go | 4 +- cmd/svc.go | 8 +- conf/configtest/configtest.go | 6 +- conf/configuration.go | 76 +++++++-------- conf/configuration_test.go | 17 +--- conf/dir.go | 76 +++++++++++++++ conf/dir_test.go | 127 ++++++++++++++++++++++++++ core/artwork/benchmark_e2e_test.go | 2 +- core/artwork/e2e/suite_test.go | 2 +- core/artwork/reader_artist_test.go | 2 +- core/artwork/reader_radio_test.go | 2 +- core/image_upload_test.go | 2 +- core/metrics/insights.go | 12 +-- core/playlists/playlists_test.go | 4 +- core/stream/media_streamer_test.go | 5 +- db/backup.go | 8 +- db/backup_test.go | 4 +- db/db.go | 2 + go.mod | 2 +- model/artist_test.go | 2 +- model/image.go | 2 +- model/radio_test.go | 2 +- persistence/artist_repository_test.go | 2 +- plugins/host_artwork_test.go | 2 +- plugins/host_cache_test.go | 2 +- plugins/host_config_test.go | 2 +- plugins/host_kvstore.go | 2 +- plugins/host_kvstore_test.go | 6 +- plugins/host_library_test.go | 4 +- plugins/host_scheduler_test.go | 2 +- plugins/host_subsonicapi_test.go | 2 +- plugins/host_taskqueue.go | 2 +- plugins/host_taskqueue_test.go | 8 +- plugins/host_users_test.go | 2 +- plugins/host_websocket_test.go | 2 +- plugins/manager.go | 14 +-- plugins/manager_watcher.go | 4 +- plugins/plugins_suite_test.go | 4 +- resources/embed.go | 2 +- scanner/external.go | 4 +- server/nativeapi/config.go | 2 +- utils/cache/benchmark_test.go | 2 +- utils/cache/file_caches.go | 2 +- utils/cache/file_caches_test.go | 4 +- 44 files changed, 317 insertions(+), 126 deletions(-) create mode 100644 conf/dir.go create mode 100644 conf/dir_test.go diff --git a/cmd/backup.go b/cmd/backup.go index ab73f7537..c02f3a19f 100644 --- a/cmd/backup.go +++ b/cmd/backup.go @@ -75,7 +75,7 @@ var ( func runBackup(ctx context.Context) { if backupDir != "" { - conf.Server.Backup.Path = backupDir + conf.Server.Backup.Path = conf.NewDir(backupDir) } idx := strings.LastIndex(conf.Server.DbPath, "?") @@ -104,7 +104,7 @@ func runBackup(ctx context.Context) { func runPrune(ctx context.Context) { if backupDir != "" { - conf.Server.Backup.Path = backupDir + conf.Server.Backup.Path = conf.NewDir(backupDir) } if backupCount != -1 { diff --git a/cmd/svc.go b/cmd/svc.go index 89ca08056..cc8d6bb54 100644 --- a/cmd/svc.go +++ b/cmd/svc.go @@ -76,13 +76,13 @@ var svcInstance = sync.OnceValue(func() service.Service { options["Restart"] = "on-failure" options["SuccessExitStatus"] = "1 2 8 SIGKILL" options["UserService"] = false - options["LogDirectory"] = conf.Server.DataFolder + options["LogDirectory"] = conf.Server.DataFolder.String() options["SystemdScript"] = systemdScript if conf.Server.LogFile != "" { options["LogOutput"] = false } else { options["LogOutput"] = true - options["LogDirectory"] = conf.Server.DataFolder + options["LogDirectory"] = conf.Server.DataFolder.String() } svcConfig := &service.Config{ UserName: installUser, @@ -131,11 +131,11 @@ func buildInstallCmd() *cobra.Command { println("Installing service with:") println(" working directory: " + executablePath()) println(" music folder: " + conf.Server.MusicFolder) - println(" data folder: " + conf.Server.DataFolder) + println(" data folder: " + conf.Server.DataFolder.String()) if conf.Server.LogFile != "" { println(" log file: " + conf.Server.LogFile) } else { - println(" logs folder: " + conf.Server.DataFolder) + println(" logs folder: " + conf.Server.DataFolder.String()) } if cfgFile != "" { conf.Server.ConfigFile, err = filepath.Abs(cfgFile) diff --git a/conf/configtest/configtest.go b/conf/configtest/configtest.go index b947e6263..cd0ac41ed 100644 --- a/conf/configtest/configtest.go +++ b/conf/configtest/configtest.go @@ -2,9 +2,7 @@ package configtest import "github.com/navidrome/navidrome/conf" +// TODO Remove this redirection and call SnapshotConfig directly from tests func SetupConfig() func() { - oldValues := *conf.Server - return func() { - conf.Server = &oldValues - } + return conf.SnapshotConfig() } diff --git a/conf/configuration.go b/conf/configuration.go index d93024c8a..6fff1641a 100644 --- a/conf/configuration.go +++ b/conf/configuration.go @@ -2,6 +2,7 @@ package conf import ( "cmp" + "encoding/json" "fmt" "net/url" "os" @@ -14,6 +15,7 @@ import ( "github.com/bmatcuk/doublestar/v4" "github.com/dustin/go-humanize" "github.com/go-viper/encoding/ini" + "github.com/go-viper/mapstructure/v2" "github.com/kr/pretty" "github.com/navidrome/navidrome/consts" "github.com/navidrome/navidrome/log" @@ -29,8 +31,8 @@ type configOptions struct { UnixSocketPerm string EnforceNonRootUser bool MusicFolder string - DataFolder string - CacheFolder string + DataFolder Dir + CacheFolder Dir DbPath string LogLevel string LogFile string @@ -229,7 +231,7 @@ type jukeboxOptions struct { type backupOptions struct { Count int - Path string + Path Dir Schedule string } @@ -247,7 +249,7 @@ type inspectOptions struct { type pluginsOptions struct { Enabled bool - Folder string + Folder Dir CacheSize string AutoReload bool LogLevel string @@ -287,6 +289,22 @@ var ( hooks []func() ) +// SnapshotConfig returns a function that restores Server to its current state. +// Uses JSON round-tripping so Dir fields get fresh sync.Once values. +func SnapshotConfig() func() { + snapshot, err := json.Marshal(Server) + if err != nil { + panic(fmt.Sprintf("SnapshotConfig: marshal failed: %v", err)) + } + return func() { + var restored configOptions + if err := json.Unmarshal(snapshot, &restored); err != nil { + panic(fmt.Sprintf("SnapshotConfig: unmarshal failed: %v", err)) + } + Server = &restored + } +} + func LoadFromFile(confFile string) { viper.SetConfigFile(confFile) err := viper.ReadInConfig() @@ -307,7 +325,13 @@ func Load(noConfigDump bool) { mapDeprecatedOption("CoverJpegQuality", "CoverArtQuality") mapDeprecatedOption("SimilarSongsMatchThreshold", "Matcher.FuzzyThreshold") - err := viper.Unmarshal(&Server) + err := viper.Unmarshal(&Server, viper.DecodeHook( + mapstructure.ComposeDecodeHookFunc( + mapstructure.TextUnmarshallerHookFunc(), + mapstructure.StringToTimeDurationHookFunc(), + mapstructure.StringToSliceHookFunc(","), + ), + )) if err != nil { logFatal("Error parsing config:", err) } @@ -317,48 +341,28 @@ func Load(noConfigDump bool) { logFatal(err) } - err = os.MkdirAll(Server.DataFolder, os.ModePerm) - if err != nil { - logFatal("Error creating data path:", err) - } - - if Server.CacheFolder == "" { - Server.CacheFolder = filepath.Join(Server.DataFolder, "cache") - } - err = os.MkdirAll(Server.CacheFolder, os.ModePerm) - if err != nil { - logFatal("Error creating cache path:", err) - } - - err = os.MkdirAll(filepath.Join(Server.DataFolder, consts.ArtworkFolder), os.ModePerm) - if err != nil { - logFatal("Error creating artwork path:", err) + if Server.CacheFolder.String() == "" { + Server.CacheFolder = NewDir(filepath.Join(Server.DataFolder.String(), "cache")) } if Server.Plugins.Enabled { - if Server.Plugins.Folder == "" { - Server.Plugins.Folder = filepath.Join(Server.DataFolder, "plugins") - } - err = os.MkdirAll(Server.Plugins.Folder, 0700) - if err != nil { - logFatal("Error creating plugins path:", err) + if Server.Plugins.Folder.String() == "" { + Server.Plugins.Folder = NewDirWithPerm(filepath.Join(Server.DataFolder.String(), "plugins"), 0700) + } else { + Server.Plugins.Folder = NewDirWithPerm(Server.Plugins.Folder.String(), 0700) } } Server.ConfigFile = viper.GetViper().ConfigFileUsed() if Server.DbPath == "" { - Server.DbPath = filepath.Join(Server.DataFolder, consts.DefaultDbPath) - } - - if Server.Backup.Path != "" { - err = os.MkdirAll(Server.Backup.Path, os.ModePerm) - if err != nil { - logFatal("Error creating backup path:", err) - } + Server.DbPath = filepath.Join(Server.DataFolder.String(), consts.DefaultDbPath) } out := os.Stderr if Server.LogFile != "" { + if mkErr := os.MkdirAll(filepath.Dir(Server.LogFile), os.ModePerm); mkErr != nil { + logFatal(fmt.Sprintf("Error creating log file directory: %s", mkErr.Error())) + } out, err = os.OpenFile(Server.LogFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { logFatal(fmt.Sprintf("Error opening log file %s: %s", Server.LogFile, err.Error())) @@ -636,7 +640,7 @@ func validateScanSchedule() error { } func validateBackupSchedule() error { - if Server.Backup.Path == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 { + if Server.Backup.Path.String() == "" || Server.Backup.Schedule == "" || Server.Backup.Count == 0 { Server.Backup.Schedule = "" return nil } diff --git a/conf/configuration_test.go b/conf/configuration_test.go index 5d4e73fad..9c25a0d19 100644 --- a/conf/configuration_test.go +++ b/conf/configuration_test.go @@ -186,27 +186,12 @@ var _ = Describe("Configuration", func() { }).To(PanicWith(ContainSubstring("Error reading config file"))) }) - It("is called when DataFolder is not writable", func() { - viper.SetDefault("datafolder", invalidPath) - Expect(func() { - conf.Load(true) - }).To(PanicWith(ContainSubstring("Error creating data path"))) - }) - - It("is called when CacheFolder is not writable", func() { - viper.SetDefault("datafolder", GinkgoT().TempDir()) - viper.SetDefault("cachefolder", invalidPath) - Expect(func() { - conf.Load(true) - }).To(PanicWith(ContainSubstring("Error creating cache path"))) - }) - It("is called when LogFile path is not writable", func() { viper.SetDefault("datafolder", GinkgoT().TempDir()) viper.SetDefault("logfile", filepath.Join(invalidPath, "log.txt")) Expect(func() { conf.Load(true) - }).To(PanicWith(ContainSubstring("Error opening log file"))) + }).To(PanicWith(ContainSubstring("Error creating log file directory"))) }) It("is called when BaseURL is invalid", func() { diff --git a/conf/dir.go b/conf/dir.go new file mode 100644 index 000000000..8ed43039b --- /dev/null +++ b/conf/dir.go @@ -0,0 +1,76 @@ +package conf + +import ( + "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. +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). +func NewDir(path string) Dir { + return Dir{path: path, perm: os.ModePerm} +} + +// NewDirWithPerm creates a new Dir with the given path and 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 { + 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 +} + +// MustPath calls Path() and calls logFatal on error. +func (d *Dir) MustPath() string { + path, err := d.Path() + if err != nil { + logFatal("creating directory:", err) + } + return path +} + +// 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 + return fmt.Sprintf("%q", d.path) +} + +// MarshalText returns the raw path bytes. No side effects. +func (d *Dir) MarshalText() ([]byte, error) { + return []byte(d.path), nil +} + +// UnmarshalText sets the path from bytes. No side effects. +func (d *Dir) UnmarshalText(text []byte) error { + d.path = string(text) + if d.perm == 0 { + d.perm = os.ModePerm + } + return nil +} diff --git a/conf/dir_test.go b/conf/dir_test.go new file mode 100644 index 000000000..2dd4250bc --- /dev/null +++ b/conf/dir_test.go @@ -0,0 +1,127 @@ +package conf_test + +import ( + "os" + + "github.com/navidrome/navidrome/conf" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Dir", func() { + Describe("NewDir", func() { + It("creates a Dir with the given path without side effects", func() { + d := conf.NewDir("/some/path") + Expect(d.String()).To(Equal("/some/path")) + }) + }) + + Describe("String", func() { + It("returns the raw path without creating the directory", func() { + d := conf.NewDir("/nonexistent/path/that/should/not/be/created") + Expect(d.String()).To(Equal("/nonexistent/path/that/should/not/be/created")) + }) + }) + + Describe("Path", func() { + It("creates the directory and returns the path on first call", func() { + dir := GinkgoT().TempDir() + target := dir + "/subdir/nested" + d := conf.NewDir(target) + + path, err := d.Path() + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(Equal(target)) + Expect(target).To(BeADirectory()) + }) + + It("returns the same result on subsequent calls (sync.Once)", func() { + dir := GinkgoT().TempDir() + target := dir + "/once" + d := conf.NewDir(target) + + path1, err1 := d.Path() + path2, err2 := d.Path() + Expect(err1).ToNot(HaveOccurred()) + Expect(err2).ToNot(HaveOccurred()) + Expect(path1).To(Equal(path2)) + }) + + It("returns an error when directory cannot be created", func() { + f := GinkgoT().TempDir() + blocker := f + "/blocker" + By("creating a file that blocks directory creation") + Expect(os.WriteFile(blocker, []byte("x"), 0600)).To(Succeed()) + invalid := blocker + "/subdir" + + d := conf.NewDir(invalid) + _, pathErr := d.Path() + Expect(pathErr).To(HaveOccurred()) + }) + + It("returns empty path and no error for empty path", func() { + d := conf.NewDir("") + path, err := d.Path() + Expect(err).ToNot(HaveOccurred()) + Expect(path).To(BeEmpty()) + }) + }) + + Describe("MustPath", func() { + It("returns the path when directory is created successfully", func() { + dir := GinkgoT().TempDir() + target := dir + "/mustpath" + d := conf.NewDir(target) + + path := d.MustPath() + Expect(path).To(Equal(target)) + Expect(target).To(BeADirectory()) + }) + + It("calls logFatal on error", func() { + var fatalMsg []any + restore := conf.SetLogFatal(func(args ...any) { + fatalMsg = args + panic("logFatal called") + }) + DeferCleanup(restore) + + f := GinkgoT().TempDir() + "/blocker" + Expect(os.WriteFile(f, []byte("x"), 0600)).To(Succeed()) + invalid := f + "/subdir" + + d := conf.NewDir(invalid) + Expect(func() { d.MustPath() }).To(Panic()) + Expect(fatalMsg).ToNot(BeEmpty()) + }) + }) + + Describe("MarshalText", func() { + It("returns the raw path bytes without side effects", func() { + d := conf.NewDir("/marshal/path") + b, err := d.MarshalText() + Expect(err).ToNot(HaveOccurred()) + Expect(string(b)).To(Equal("/marshal/path")) + }) + }) + + Describe("UnmarshalText", func() { + It("sets the path from bytes without side effects", func() { + d := conf.NewDir("") + err := d.UnmarshalText([]byte("/unmarshal/path")) + Expect(err).ToNot(HaveOccurred()) + Expect(d.String()).To(Equal("/unmarshal/path")) + }) + + It("allows round-trip marshal/unmarshal", func() { + d1 := conf.NewDir("/round/trip") + b, err := d1.MarshalText() + Expect(err).ToNot(HaveOccurred()) + + var d2 conf.Dir + err = d2.UnmarshalText(b) + Expect(err).ToNot(HaveOccurred()) + Expect(d2.String()).To(Equal(d1.String())) + }) + }) +}) diff --git a/core/artwork/benchmark_e2e_test.go b/core/artwork/benchmark_e2e_test.go index c27964018..393cbb473 100644 --- a/core/artwork/benchmark_e2e_test.go +++ b/core/artwork/benchmark_e2e_test.go @@ -52,7 +52,7 @@ func setupE2EBenchmark(b *testing.B, cacheSize string) (Artwork, model.ArtworkID // Configure cache conf.Server.ImageCacheSize = cacheSize - conf.Server.CacheFolder = tmpDir + conf.Server.CacheFolder = conf.NewDir(tmpDir) conf.Server.CoverArtQuality = 75 conf.Server.CoverArtPriority = "cover.*" diff --git a/core/artwork/e2e/suite_test.go b/core/artwork/e2e/suite_test.go index 9ce0edb8b..733e2e98c 100644 --- a/core/artwork/e2e/suite_test.go +++ b/core/artwork/e2e/suite_test.go @@ -63,7 +63,7 @@ func setupHarness() { // Reuse the suite-level DB path so the singleton connection keeps working // across specs (see suiteDBTempDir comment). conf.Server.DbPath = filepath.Join(suiteDBTempDir, "artwork-e2e.db") + "?_journal_mode=WAL" - conf.Server.DataFolder = tempDir + conf.Server.DataFolder = conf.NewDir(tempDir) conf.Server.MusicFolder = fakeLibPath conf.Server.DevExternalScanner = false conf.Server.ImageCacheSize = "0" // disabled cache → reader runs on every call diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go index e2a1f2094..50ca3a2ce 100644 --- a/core/artwork/reader_artist_test.go +++ b/core/artwork/reader_artist_test.go @@ -452,7 +452,7 @@ var _ = Describe("artistArtworkReader", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tempDir = GinkgoT().TempDir() - conf.Server.DataFolder = tempDir + conf.Server.DataFolder = conf.NewDir(tempDir) // Create the artwork/artist directory Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "artist"), 0755)).To(Succeed()) diff --git a/core/artwork/reader_radio_test.go b/core/artwork/reader_radio_test.go index 1f5bc9084..37ce1d827 100644 --- a/core/artwork/reader_radio_test.go +++ b/core/artwork/reader_radio_test.go @@ -21,7 +21,7 @@ var _ = Describe("radioArtworkReader", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tempDir = GinkgoT().TempDir() - conf.Server.DataFolder = tempDir + conf.Server.DataFolder = conf.NewDir(tempDir) Expect(os.MkdirAll(filepath.Join(tempDir, "artwork", "radio"), 0755)).To(Succeed()) diff --git a/core/image_upload_test.go b/core/image_upload_test.go index d13a04775..265f60a95 100644 --- a/core/image_upload_test.go +++ b/core/image_upload_test.go @@ -21,7 +21,7 @@ var _ = Describe("ImageUploadService", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) svc = core.NewImageUploadService() }) diff --git a/core/metrics/insights.go b/core/metrics/insights.go index f069d3fb6..bcd0343c2 100644 --- a/core/metrics/insights.go +++ b/core/metrics/insights.go @@ -165,7 +165,7 @@ var staticData = sync.OnceValue(func() insights.Data { data.OS.Containerized = consts.InContainer // Install info - packageFilename := filepath.Join(conf.Server.DataFolder, ".package") + packageFilename := filepath.Join(conf.Server.DataFolder.String(), ".package") packageFileData, err := os.ReadFile(packageFilename) if err == nil { data.OS.Package = string(packageFileData) @@ -179,12 +179,12 @@ var staticData = sync.OnceValue(func() insights.Data { // FS info data.FS.Music = getFSInfo(conf.Server.MusicFolder) - data.FS.Data = getFSInfo(conf.Server.DataFolder) - if conf.Server.CacheFolder != "" { - data.FS.Cache = getFSInfo(conf.Server.CacheFolder) + data.FS.Data = getFSInfo(conf.Server.DataFolder.String()) + if conf.Server.CacheFolder.String() != "" { + data.FS.Cache = getFSInfo(conf.Server.CacheFolder.String()) } - if conf.Server.Backup.Path != "" { - data.FS.Backup = getFSInfo(conf.Server.Backup.Path) + if conf.Server.Backup.Path.String() != "" { + data.FS.Backup = getFSInfo(conf.Server.Backup.Path.String()) } // Config info diff --git a/core/playlists/playlists_test.go b/core/playlists/playlists_test.go index 52d5c88d8..95d7b3e6a 100644 --- a/core/playlists/playlists_test.go +++ b/core/playlists/playlists_test.go @@ -307,7 +307,7 @@ var _ = Describe("Playlists", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) mockPlsRepo.Data = map[string]*model.Playlist{ "pls-1": {ID: "pls-1", Name: "My Playlist", OwnerID: "user-1"}, @@ -371,7 +371,7 @@ var _ = Describe("Playlists", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) // Create a real image file on disk imgDir := filepath.Join(tmpDir, "artwork", "playlist") diff --git a/core/stream/media_streamer_test.go b/core/stream/media_streamer_test.go index 1bc21e239..1bbf868fa 100644 --- a/core/stream/media_streamer_test.go +++ b/core/stream/media_streamer_test.go @@ -23,7 +23,8 @@ var _ = Describe("MediaStreamer", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.CacheFolder, _ = os.MkdirTemp("", "file_caches") + cacheDir, _ := os.MkdirTemp("", "file_caches") + conf.Server.CacheFolder = conf.NewDir(cacheDir) conf.Server.TranscodingCacheSize = "100MB" ds = &tests.MockDataStore{MockedTranscoding: &tests.MockTranscodingRepo{}} ds.MediaFile(ctx).(*tests.MockMediaFileRepo).SetData(model.MediaFiles{ @@ -34,7 +35,7 @@ var _ = Describe("MediaStreamer", func() { streamer = stream.NewMediaStreamer(ds, ffmpeg, testCache) }) AfterEach(func() { - _ = os.RemoveAll(conf.Server.CacheFolder) + _ = os.RemoveAll(conf.Server.CacheFolder.String()) }) Context("NewStream", func() { diff --git a/db/backup.go b/db/backup.go index a34255d7e..806bef8e2 100644 --- a/db/backup.go +++ b/db/backup.go @@ -27,7 +27,7 @@ const backupSuffixLayout = "2006.01.02_15.04.05" func backupPath(t time.Time) string { return filepath.Join( - conf.Server.Backup.Path, + conf.Server.Backup.Path.MustPath(), fmt.Sprintf("%s_%s.db", backupPrefix, t.Format(backupSuffixLayout)), ) } @@ -117,7 +117,11 @@ func Restore(ctx context.Context, path string) error { } func Prune(ctx context.Context) (int, error) { - files, err := os.ReadDir(conf.Server.Backup.Path) + backupDir, err := conf.Server.Backup.Path.Path() + if err != nil { + return 0, fmt.Errorf("backup directory not available: %w", err) + } + files, err := os.ReadDir(backupDir) if err != nil { return 0, fmt.Errorf("unable to read database backup entries: %w", err) } diff --git a/db/backup_test.go b/db/backup_test.go index aec43446d..5e8f877e6 100644 --- a/db/backup_test.go +++ b/db/backup_test.go @@ -60,7 +60,7 @@ var _ = Describe("database backups", func() { tempFolder, err := os.MkdirTemp("", "navidrome_backup") Expect(err).ToNot(HaveOccurred()) - conf.Server.Backup.Path = tempFolder + conf.Server.Backup.Path = conf.NewDir(tempFolder) DeferCleanup(func() { _ = os.RemoveAll(tempFolder) @@ -118,7 +118,7 @@ var _ = Describe("database backups", func() { BeforeEach(func() { tempFolder, err := os.MkdirTemp("", "navidrome_backup") Expect(err).ToNot(HaveOccurred()) - conf.Server.Backup.Path = tempFolder + conf.Server.Backup.Path = conf.NewDir(tempFolder) DeferCleanup(func() { _ = os.RemoveAll(tempFolder) diff --git a/db/db.go b/db/db.go index 0945d1a00..168c12122 100644 --- a/db/db.go +++ b/db/db.go @@ -38,6 +38,8 @@ func Db() *sql.DB { if Path == ":memory:" { Path = "file::memory:?cache=shared&_foreign_keys=on" conf.Server.DbPath = Path + } else { + conf.Server.DataFolder.MustPath() } log.Debug("Opening DataBase", "dbPath", Path, "driver", Driver) db, err := sql.Open(Driver, Path) diff --git a/go.mod b/go.mod index 6a0acf2d6..937cffbd5 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( github.com/go-chi/httprate v0.15.0 github.com/go-chi/jwtauth/v5 v5.4.0 github.com/go-viper/encoding/ini v0.1.1 + github.com/go-viper/mapstructure/v2 v2.5.0 github.com/gohugoio/hashstructure v0.6.0 github.com/google/go-pipeline v0.0.0-20230411140531-6cbedfc1d3fc github.com/google/uuid v1.6.0 @@ -84,7 +85,6 @@ require ( 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 - github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/gobwas/glob v0.2.3 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect diff --git a/model/artist_test.go b/model/artist_test.go index 5a24504eb..db897d3d5 100644 --- a/model/artist_test.go +++ b/model/artist_test.go @@ -14,7 +14,7 @@ var _ = Describe("Artist", func() { Describe("UploadedImagePath", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = "/data" + conf.Server.DataFolder = conf.NewDir("/data") }) It("returns empty string when no image uploaded", func() { diff --git a/model/image.go b/model/image.go index 68d8ae64c..30307fcea 100644 --- a/model/image.go +++ b/model/image.go @@ -13,5 +13,5 @@ func UploadedImagePath(entityType, filename string) string { if filename == "" { return "" } - return filepath.Join(conf.Server.DataFolder, consts.ArtworkFolder, entityType, filename) + return filepath.Join(conf.Server.DataFolder.String(), consts.ArtworkFolder, entityType, filename) } diff --git a/model/radio_test.go b/model/radio_test.go index dc421454e..860331f17 100644 --- a/model/radio_test.go +++ b/model/radio_test.go @@ -26,7 +26,7 @@ var _ = Describe("Radio", func() { Describe("UploadedImagePath", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = "/data" + conf.Server.DataFolder = conf.NewDir("/data") }) It("returns empty string when no image uploaded", func() { diff --git a/persistence/artist_repository_test.go b/persistence/artist_repository_test.go index e2904466c..076a9da3b 100644 --- a/persistence/artist_repository_test.go +++ b/persistence/artist_repository_test.go @@ -840,7 +840,7 @@ var _ = Describe("ArtistRepository", func() { BeforeEach(func() { DeferCleanup(configtest.SetupConfig()) tmpDir = GinkgoT().TempDir() - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) ctx := request.WithUser(GinkgoT().Context(), adminUser) repo = NewArtistRepository(ctx, GetDBXBuilder()).(*artistRepository) diff --git a/plugins/host_artwork_test.go b/plugins/host_artwork_test.go index 151a0d03c..ed8a0e810 100644 --- a/plugins/host_artwork_test.go +++ b/plugins/host_artwork_test.go @@ -47,7 +47,7 @@ var _ = Describe("ArtworkService", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Initialize auth (required for token generation) diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go index 0f55bcfda..cf3973fc4 100644 --- a/plugins/host_cache_test.go +++ b/plugins/host_cache_test.go @@ -343,7 +343,7 @@ var _ = Describe("CacheService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugin diff --git a/plugins/host_config_test.go b/plugins/host_config_test.go index bd3368a67..b296d29fb 100644 --- a/plugins/host_config_test.go +++ b/plugins/host_config_test.go @@ -57,7 +57,7 @@ func setupTestConfigPlugin(configJSON string) (*Manager, func(context.Context, t // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore diff --git a/plugins/host_kvstore.go b/plugins/host_kvstore.go index c3f6ec734..2224b7485 100644 --- a/plugins/host_kvstore.go +++ b/plugins/host_kvstore.go @@ -54,7 +54,7 @@ func newKVStoreService(ctx context.Context, pluginName string, perm *KVStorePerm } // Create plugin data directory - dataDir := filepath.Join(conf.Server.DataFolder, "plugins", pluginName) + dataDir := filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName) if err := os.MkdirAll(dataDir, 0700); err != nil { return nil, fmt.Errorf("creating plugin data directory: %w", err) } diff --git a/plugins/host_kvstore_test.go b/plugins/host_kvstore_test.go index e5d467f79..109ae8131 100644 --- a/plugins/host_kvstore_test.go +++ b/plugins/host_kvstore_test.go @@ -34,7 +34,7 @@ var _ = Describe("KVStoreService", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) // Create service with 1KB limit for testing maxSize := "1KB" @@ -705,9 +705,9 @@ var _ = Describe("KVStoreService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) // Setup mock DataStore with pre-enabled plugin mockPluginRepo := tests.CreateMockPluginRepo() diff --git a/plugins/host_library_test.go b/plugins/host_library_test.go index 5746a3bed..67f5f9b0f 100644 --- a/plugins/host_library_test.go +++ b/plugins/host_library_test.go @@ -263,7 +263,7 @@ var _ = Describe("LibraryService", Ordered, func() { // the service registration and configuration without full plugin execution DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) // Create mock &tests.MockLibraryRepo{} mockLibRepo := &tests.MockLibraryRepo{} @@ -357,7 +357,7 @@ var _ = Describe("LibraryService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugin and library diff --git a/plugins/host_scheduler_test.go b/plugins/host_scheduler_test.go index 334d9b738..ca53aed56 100644 --- a/plugins/host_scheduler_test.go +++ b/plugins/host_scheduler_test.go @@ -51,7 +51,7 @@ var _ = Describe("SchedulerService", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Create mock scheduler and timer registry diff --git a/plugins/host_subsonicapi_test.go b/plugins/host_subsonicapi_test.go index 607f3a64b..6f7ff4dd3 100644 --- a/plugins/host_subsonicapi_test.go +++ b/plugins/host_subsonicapi_test.go @@ -44,7 +44,7 @@ var _ = Describe("SubsonicAPI Host Function", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock router and data store diff --git a/plugins/host_taskqueue.go b/plugins/host_taskqueue.go index 9f2ed85f6..eff73c822 100644 --- a/plugins/host_taskqueue.go +++ b/plugins/host_taskqueue.go @@ -82,7 +82,7 @@ type taskQueueServiceImpl struct { // newTaskQueueService creates a new taskQueueServiceImpl with its own SQLite database. func newTaskQueueService(pluginName string, manager *Manager, maxConcurrency int32) (*taskQueueServiceImpl, error) { - dataDir := filepath.Join(conf.Server.DataFolder, "plugins", pluginName) + dataDir := filepath.Join(conf.Server.DataFolder.String(), "plugins", pluginName) if err := os.MkdirAll(dataDir, 0700); err != nil { return nil, fmt.Errorf("creating plugin data directory: %w", err) } diff --git a/plugins/host_taskqueue_test.go b/plugins/host_taskqueue_test.go index c3ab8d119..8a58f1eb4 100644 --- a/plugins/host_taskqueue_test.go +++ b/plugins/host_taskqueue_test.go @@ -40,7 +40,7 @@ var _ = Describe("TaskQueueService", func() { Expect(err).ToNot(HaveOccurred()) DeferCleanup(configtest.SetupConfig()) - conf.Server.DataFolder = tmpDir + conf.Server.DataFolder = conf.NewDir(tmpDir) // Create a mock manager with context managerCtx, cancel := context.WithCancel(ctx) @@ -853,10 +853,10 @@ var _ = Describe("TaskQueueService Integration", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false - conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") - conf.Server.DataFolder = tmpDir + conf.Server.CacheFolder = conf.NewDir(filepath.Join(tmpDir, "cache")) + conf.Server.DataFolder = conf.NewDir(tmpDir) // Setup mock DataStore with pre-enabled plugin mockPluginRepo := tests.CreateMockPluginRepo() diff --git a/plugins/host_users_test.go b/plugins/host_users_test.go index 1c0de7d03..42f6a3032 100644 --- a/plugins/host_users_test.go +++ b/plugins/host_users_test.go @@ -484,7 +484,7 @@ func createTestUsers(mockUserRepo *tests.MockedUserRepo) { // setupTestUsersConfig sets up common plugin configuration func setupTestUsersConfig(tmpDir string) { conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false } diff --git a/plugins/host_websocket_test.go b/plugins/host_websocket_test.go index 83fca9898..e41cfbb82 100644 --- a/plugins/host_websocket_test.go +++ b/plugins/host_websocket_test.go @@ -51,7 +51,7 @@ var _ = Describe("WebSocketService", Ordered, func() { // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugin diff --git a/plugins/manager.go b/plugins/manager.go index 0e9419bfd..67e0ee987 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -5,7 +5,6 @@ import ( "encoding/json" "fmt" "net/http" - "os" "path/filepath" "runtime" "sync" @@ -124,7 +123,7 @@ func (m *Manager) Start(ctx context.Context) error { m.ctx, m.cancel = context.WithCancel(ctx) // Initialize wazero compilation cache for better performance - cacheDir := filepath.Join(conf.Server.CacheFolder, "plugins") + cacheDir := filepath.Join(conf.Server.CacheFolder.MustPath(), "plugins") purgeCacheBySize(ctx, cacheDir, conf.Server.Plugins.CacheSize) var err error @@ -134,17 +133,12 @@ func (m *Manager) Start(ctx context.Context) error { return fmt.Errorf("creating wazero compilation cache: %w", err) } - folder := conf.Server.Plugins.Folder - if folder == "" { + if conf.Server.Plugins.Folder.String() == "" { log.Debug(ctx, "No plugins folder configured") return nil } - // Create plugins folder if it doesn't exist - if err := os.MkdirAll(folder, 0755); err != nil { - log.Error(ctx, "Failed to create plugins folder", "folder", folder, err) - return fmt.Errorf("creating plugins folder: %w", err) - } + folder := conf.Server.Plugins.Folder.MustPath() log.Info(ctx, "Starting plugin manager", "folder", folder) @@ -431,7 +425,7 @@ func (m *Manager) UpdatePluginLibraries(ctx context.Context, id, librariesJSON s // This synchronizes the database with the filesystem, discovering new plugins, // updating changed ones, and removing deleted ones. func (m *Manager) RescanPlugins(ctx context.Context) error { - folder := conf.Server.Plugins.Folder + folder := conf.Server.Plugins.Folder.String() if folder == "" { return fmt.Errorf("plugins folder not configured") } diff --git a/plugins/manager_watcher.go b/plugins/manager_watcher.go index 4f266bda1..b7022b46e 100644 --- a/plugins/manager_watcher.go +++ b/plugins/manager_watcher.go @@ -19,7 +19,7 @@ const debounceDuration = 2 * time.Second // startWatcher starts the file watcher for the plugins folder. // It watches for CREATE, WRITE, and REMOVE events on .wasm files. func (m *Manager) startWatcher() error { - folder := conf.Server.Plugins.Folder + folder := conf.Server.Plugins.Folder.String() if folder == "" { return nil } @@ -146,7 +146,7 @@ func (m *Manager) processPluginEvent(pluginName string) { delete(m.debounceTimers, pluginName) m.debounceMu.Unlock() - folder := conf.Server.Plugins.Folder + folder := conf.Server.Plugins.Folder.String() ndpPath := filepath.Join(folder, pluginName+PackageExtension) action := determinePluginAction(ndpPath) diff --git a/plugins/plugins_suite_test.go b/plugins/plugins_suite_test.go index 1799ba3ce..bb081988e 100644 --- a/plugins/plugins_suite_test.go +++ b/plugins/plugins_suite_test.go @@ -48,7 +48,7 @@ func TestPlugins(t *testing.T) { // Set CacheFolder globally so all tests (including those using // configtest.SetupConfig) inherit it without needing to set it manually. - conf.Server.CacheFolder = sharedCacheDir + conf.Server.CacheFolder = conf.NewDir(sharedCacheDir) log.SetLevel(log.LevelFatal) RegisterFailHandler(Fail) @@ -126,7 +126,7 @@ func createTestManagerWithPluginsAndMetrics(pluginConfig map[string]map[string]s // Setup config DeferCleanup(configtest.SetupConfig()) conf.Server.Plugins.Enabled = true - conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.Folder = conf.NewDir(tmpDir) conf.Server.Plugins.AutoReload = false // Setup mock DataStore with pre-enabled plugins diff --git a/resources/embed.go b/resources/embed.go index 0386e6f79..040bb5d84 100644 --- a/resources/embed.go +++ b/resources/embed.go @@ -16,6 +16,6 @@ var embedFS embed.FS func FS() fs.FS { return merge.FS{ Base: embedFS, - Overlay: os.DirFS(path.Join(conf.Server.DataFolder, "resources")), + Overlay: os.DirFS(path.Join(conf.Server.DataFolder.String(), "resources")), } } diff --git a/scanner/external.go b/scanner/external.go index 29ca90be6..393a9278c 100644 --- a/scanner/external.go +++ b/scanner/external.go @@ -45,8 +45,8 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod "scan", "--nobanner", "--subprocess", "--configfile", conf.Server.ConfigFile, - "--datafolder", conf.Server.DataFolder, - "--cachefolder", conf.Server.CacheFolder, + "--datafolder", conf.Server.DataFolder.String(), + "--cachefolder", conf.Server.CacheFolder.String(), } // Add targets if provided diff --git a/server/nativeapi/config.go b/server/nativeapi/config.go index 02626a4ee..cfecfa663 100644 --- a/server/nativeapi/config.go +++ b/server/nativeapi/config.go @@ -97,7 +97,7 @@ func getConfig(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Marshal the actual configuration struct to preserve original field names - configBytes, err := json.Marshal(*conf.Server) + configBytes, err := json.Marshal(conf.Server) if err != nil { log.Error(ctx, "Error marshaling config", err) http.Error(w, "Internal server error", http.StatusInternalServerError) diff --git a/utils/cache/benchmark_test.go b/utils/cache/benchmark_test.go index 1fe448f84..e3fc08eda 100644 --- a/utils/cache/benchmark_test.go +++ b/utils/cache/benchmark_test.go @@ -28,7 +28,7 @@ func setupBenchCache(b *testing.B, cacheSize string, getReader ReadFunc) (*fileC b.Fatal(err) } b.Cleanup(configtest.SetupConfig()) - conf.Server.CacheFolder = tmpDir + conf.Server.CacheFolder = conf.NewDir(tmpDir) fc := NewFileCache("bench", cacheSize, "bench", 0, getReader).(*fileCache) diff --git a/utils/cache/file_caches.go b/utils/cache/file_caches.go index 5edc533f8..9788926d5 100644 --- a/utils/cache/file_caches.go +++ b/utils/cache/file_caches.go @@ -262,7 +262,7 @@ func newFSCache(name, cacheSize, cacheFolder string, maxItems int) (fscache.Cach lru := NewFileHaunter(name, maxItems, size, consts.DefaultCacheCleanUpInterval) h := fscache.NewLRUHaunterStrategy(lru) - cacheFolder = filepath.Join(conf.Server.CacheFolder, cacheFolder) + cacheFolder = filepath.Join(conf.Server.CacheFolder.MustPath(), cacheFolder) var fs *spreadFS log.Info(fmt.Sprintf("Creating %s cache", name), "path", cacheFolder, "maxSize", humanize.Bytes(size)) diff --git a/utils/cache/file_caches_test.go b/utils/cache/file_caches_test.go index 72f4463d1..9a9a9444f 100644 --- a/utils/cache/file_caches_test.go +++ b/utils/cache/file_caches_test.go @@ -28,14 +28,14 @@ var _ = Describe("File Caches", func() { configtest.SetupConfig() _ = os.RemoveAll(tmpDir) }) - conf.Server.CacheFolder = tmpDir + conf.Server.CacheFolder = conf.NewDir(tmpDir) }) Describe("NewFileCache", func() { It("creates the cache folder", func() { Expect(callNewFileCache("test", "1k", "test", 0, nil)).ToNot(BeNil()) - _, err := os.Stat(filepath.Join(conf.Server.CacheFolder, "test")) + _, err := os.Stat(filepath.Join(conf.Server.CacheFolder.String(), "test")) Expect(os.IsNotExist(err)).To(BeFalse()) }) From ce19d79f239c81a7515cde7db82c15c89cbcc70b Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 18 May 2026 09:31:57 -0300 Subject: [PATCH 074/117] feat(i18n): add Estonian translation file Signed-off-by: Deluan --- resources/i18n/et.json | 723 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 723 insertions(+) create mode 100644 resources/i18n/et.json diff --git a/resources/i18n/et.json b/resources/i18n/et.json new file mode 100644 index 000000000..d511c1246 --- /dev/null +++ b/resources/i18n/et.json @@ -0,0 +1,723 @@ +{ + "languageName": "eesti keel", + "resources": { + "song": { + "name": "Laul |||| Laulud", + "fields": { + "albumArtist": "Albumi esitaja", + "duration": "Kestus", + "trackNumber": "Nr", + "playCount": "Esituskordi", + "title": "Pealkiri", + "artist": "Esitaja", + "album": "Album", + "path": "Faili asukoht", + "genre": "Žanr", + "compilation": "Kogumik", + "year": "Aasta", + "size": "Faili suurus", + "updatedAt": "Uuendatud", + "bitRate": "Bitikiirus", + "discSubtitle": "Plaadi alapealkiri", + "starred": "Märgi lemmikuks", + "comment": "Kommentaar", + "rating": "Hinnang", + "quality": "Kvaliteet", + "bpm": "BPM", + "playDate": "Viimati esitatud", + "channels": "Kanaleid", + "createdAt": "Lisamise kuupäev", + "grouping": "Rühmitamine", + "mood": "Meeleolu", + "participants": "Täiendavad osalejad", + "tags": "Täiendavad sildid", + "mappedTags": "Tuvastatud sildid", + "rawTags": "Sildid töötlemata vaates", + "bitDepth": "Bitisügavus", + "sampleRate": "Diskreetmisagedus", + "missing": "Puudub", + "libraryName": "Kogumik", + "composer": "Helilooja", + "disc": "%{discNumber}. plaat", + "albumGain": "Albumikohane esitusvaljuse tundlikkus", + "trackGain": "Rajakohane esitusvaljuse tundlikkus" + }, + "actions": { + "addToQueue": "Esita hiljem", + "playNow": "Esita kohe", + "addToPlaylist": "Lisa esitusloendisse", + "shuffleAll": "Sega kõik", + "download": "Laadi alla", + "playNext": "Esita järgmisena", + "info": "Loo teave", + "showInPlaylist": "Näita esitusloendis", + "instantMix": "Kohene miks" + } + }, + "album": { + "name": "Album |||| Albumid", + "fields": { + "albumArtist": "Albumi esitaja", + "artist": "Esitaja", + "duration": "Kestus", + "songCount": "laulu", + "playCount": "Esituskordi", + "name": "Nimi", + "genre": "Žanr", + "compilation": "Kogumik", + "year": "Aasta", + "updatedAt": "Uuendatud", + "comment": "Kommentaar", + "rating": "Hinnangud", + "createdAt": "Lisamise kuupäev", + "size": "Suurus", + "originalDate": "Originaal", + "releaseDate": "Avaldatud", + "releases": "Väljalase ||| Väljalasked", + "released": "Avaldatud", + "recordLabel": "Plaadifirma", + "catalogNum": "Tunnus kataloogides", + "releaseType": "Tüüp", + "grouping": "Grupeerimine", + "media": "Meedium", + "mood": "Meeleolu", + "date": "Salvestuskuupäev", + "missing": "Puudu", + "libraryName": "Kogumik" + }, + "actions": { + "playAll": "Esita", + "playNext": "Esita järgmisena", + "addToQueue": "Esita hiljem", + "shuffle": "Sega lood", + "addToPlaylist": "Lisa esitusloendisse", + "download": "Laadi alla", + "info": "Albumi teave", + "share": "Jaga" + }, + "lists": { + "all": "Kõik", + "random": "Juhuslik", + "recentlyAdded": "Hiljuti lisatud", + "recentlyPlayed": "Hiljuti esitatud", + "mostPlayed": "Enimesitatud", + "starred": "Lemmikud", + "topRated": "Kõrgeima hinnanguga" + } + }, + "artist": { + "name": "Esitaja |||| Esitajad", + "fields": { + "name": "Nimi", + "albumCount": "Albumeid", + "songCount": "Lugusid", + "playCount": "Esituskordi", + "rating": "Hinnang", + "genre": "Žanr", + "size": "Suurus", + "role": "Roll", + "missing": "Puudub" + }, + "roles": { + "albumartist": "Albumi esitaja ||| Albumi esitajad", + "artist": "Esitaja ||| Esitajad", + "composer": "Helilooja ||| Heliloojad", + "conductor": "Dirigent ||| Dirigendid", + "lyricist": "Laulusõnade autor ||| Laulusõnade autorid", + "arranger": "Seade autor ||| Seade autorid", + "producer": "Produtsent ||| Produtsendid", + "director": "Lavastaja ||| Lavastajad", + "engineer": "Helirežissöör ||| Helirežissöörid", + "mixer": "Miksija ||| Miksijad", + "remixer": "Remiksija ||| Remiksijad", + "djmixer": "DJ-versiooni remiksija ||| DJ-versiooni remiksijad", + "performer": "Esineja ||| Esinejad", + "maincredit": "Albumi esitaja või Esitaja ||| Albumi esitajad või Esitajad" + }, + "actions": { + "shuffle": "Sega", + "radio": "Raadio", + "topSongs": "Populaarsed lood" + } + }, + "user": { + "name": "Kasutaja |||| Kasutajad", + "fields": { + "userName": "Kasutajanimi", + "isAdmin": "On peakasutaja", + "lastLoginAt": "Viimane sisselogimine", + "updatedAt": "Uuendatud", + "name": "Nimi", + "password": "Salasõna", + "createdAt": "Loodud", + "changePassword": "Kas soovid salasõna muuta?", + "currentPassword": "Senine salasõna", + "newPassword": "Uus salasõna", + "token": "Tunnusluba", + "lastAccessAt": "Viimasti avatud", + "libraries": "Kogumikud" + }, + "helperTexts": { + "name": "Sinu nime muudatused on näha järgmisel sisselogimisel", + "libraries": "Vali selle kasutaja jaoks konkreetsed kogumikus või jäta vaikimisi väärtuse kasutamiseks tühjaks" + }, + "notifications": { + "created": "Kasutaja on lisatud", + "updated": "Kasutaja andmed on uuendatud", + "deleted": "Kasutaja on kustutatud" + }, + "message": { + "listenBrainzToken": "Sisesta oma ListenBrainzi tunnusluba.", + "clickHereForToken": "Tunnusloa saamiseks klõpsi siin", + "selectAllLibraries": "Vali kõik kogumikud", + "adminAutoLibraries": "Peakasutajatel on automaatselt ligipääs kõikidele kogumikele" + }, + "validation": { + "librariesRequired": "Vähemalt üks kogumik peab olema valitud muude, kui peakasutajate jaoks" + } + }, + "player": { + "name": "Meediaesitaja |||| Meediaesitajad", + "fields": { + "name": "Nimi", + "transcodingId": "Teisendamine", + "maxBitRate": "Maksimaalne bitikiirus", + "client": "Klient", + "userName": "Kasutajanimi", + "lastSeen": "Viimati nähtud", + "reportRealPath": "Teata tegelikust asukohast", + "scrobbleEnabled": "Saada kraasimisandmed välistesse teenustesse" + } + }, + "transcoding": { + "name": "Teisendamine |||| Teisendamised", + "fields": { + "name": "Nimi", + "targetFormat": "Sihtvorming", + "defaultBitRate": "Vaikimisi bitikiirus", + "command": "Käsk" + } + }, + "playlist": { + "name": "Esitusloend ||| Esitusloendid", + "fields": { + "name": "Nimi", + "duration": "Kestus", + "ownerName": "Omanik", + "public": "Avalik", + "updatedAt": "Muudetud", + "createdAt": "Loodud", + "songCount": "Lood", + "comment": "Kommentaar", + "sync": "Automaatne import", + "path": "Impordi siit" + }, + "actions": { + "selectPlaylist": "Valo esitusloend:", + "addNewPlaylist": "Loo „%{name}\"“", + "export": "Ekspordi", + "makePublic": "Muuda avalikuks", + "makePrivate": "Muuda privaatseks", + "saveQueue": "Salvesta esitusjärjekord esitusloendina", + "searchOrCreate": "Otsi esitusloendeid või uue loomiseks sisesta nimi...", + "pressEnterToCreate": "Uue esitusloendi lisamiseks vajuta sisestusklahvi", + "removeFromSelection": "Eemalda valikust" + }, + "message": { + "duplicate_song": "Lisa topeltlood", + "song_exist": "Tundub, et oled esitusloendisse lisamas topeltkirjeid. Kas tahad nii jätkata või soovid topeltkirjed vahele jätta?", + "noPlaylistsFound": "Esitusloendeid ei leidu", + "noPlaylists": "Esitusloendeid pole saadaval" + } + }, + "radio": { + "name": "Raadio ||| Raadiod", + "fields": { + "name": "Nimi", + "streamUrl": "Voogedastuse võrguaadress", + "homePageUrl": "Avalehe võrguaadress", + "updatedAt": "Uuendatud", + "createdAt": "Lisatud" + }, + "actions": { + "playNow": "Esita kohe" + } + }, + "share": { + "name": "Jagamine ||| Jagamised", + "fields": { + "username": "Seda jagas", + "url": "Võrguaadress", + "description": "Kirjeldus", + "contents": "Sisu", + "expiresAt": "Aegub", + "lastVisitedAt": "Viimati vaadatud", + "visitCount": "Külastusi", + "format": "Vorming", + "maxBitRate": "Maksimaalne bitikiirus", + "updatedAt": "Muudetud", + "createdAt": "Lisatud", + "downloadable": "Kas lubad allalaadimised?" + } + }, + "missing": { + "name": "Puuduv fail ||| Puuduvad failid", + "fields": { + "path": "Asukoht", + "size": "Suurus", + "updatedAt": "Kadumise aeg", + "libraryName": "Kogumik" + }, + "actions": { + "remove": "Eemalda", + "remove_all": "Eemalda kõik" + }, + "notifications": { + "removed": "Puuduv(ad) fail(id) on eemaldatud" + }, + "empty": "Puuduvaid faile pole" + }, + "library": { + "name": "Kogumik ||| Kogumikud", + "fields": { + "name": "Nimi", + "path": "Asukoht", + "remotePath": "Asukoht kaugseadmes", + "lastScanAt": "Viimane skaneerimine", + "songCount": "Lood", + "albumCount": "Albumid", + "artistCount": "Esitajad", + "totalSongs": "Lood", + "totalAlbums": "Albumid", + "totalArtists": "Esitajad", + "totalFolders": "Kaustad", + "totalFiles": "Failid", + "totalMissingFiles": "Puuduvad failid", + "totalSize": "Kogumaht", + "totalDuration": "Kestus", + "defaultNewUsers": "Vaikimisi väärtus uutele kasutajatele", + "createdAt": "Lisatud", + "updatedAt": "Muudetud" + }, + "sections": { + "basic": "Põhiteave", + "statistics": "Statistika" + }, + "actions": { + "scan": "Skaneeri kogumikku", + "manageUsers": "Halda kasutajate õigusi", + "viewDetails": "Vaata üksikasju", + "quickScan": "Kiirskaneerimine", + "fullScan": "Täismahuline skaneerimine" + }, + "notifications": { + "created": "Kogumiku loomine õnnestus", + "updated": "Kogumiku uuendamine õnnestus", + "deleted": "Kogumiku kustutamine õnnestus", + "scanStarted": "Kogumiku skaneerimine algas", + "scanCompleted": "Kogumiku skaneerimine lõppes", + "quickScanStarted": "Kiirskaneerimine algas", + "fullScanStarted": "Täismahuline skaneerimine algas", + "scanError": "Viga skaneerimise käivitamisel. Lisateavet leiad logidest" + }, + "validation": { + "nameRequired": "Pead sisestama kogumiku nime", + "pathRequired": "Pead sisestama kogumiku asukoha", + "pathNotDirectory": "Kogumiku asukoht peab olema kaust", + "pathNotFound": "Kogumiku asukoha kausta ei leidu", + "pathNotAccessible": "Kogumiku asukoha kaust pole ligipääsetav", + "pathInvalid": "Vigane kogumiku asukoha kaust" + }, + "messages": { + "deleteConfirm": "Kas oled kindel, et soovid selle kogumiku kustutada? Samaga eemaldad ka kõik seotud andmed ja kasutajate ligipääsu.", + "scanInProgress": "Skaneerimine on pooleli...", + "noLibrariesAssigned": "Selle kasutajaga pole veel ühtegi kogumikku seotud" + } + }, + "plugin": { + "name": "Lisamoodul |||| Lisamoodulid", + "fields": { + "id": "Tunnus", + "name": "Nimi", + "description": "Kirjeldus", + "version": "Versioon", + "author": "Autor", + "website": "Veebisait", + "permissions": "Õigused", + "enabled": "Kasutusel", + "status": "Olek", + "path": "Asukoht", + "lastError": "Viga", + "hasError": "Viga", + "updatedAt": "Uuendatud", + "createdAt": "Paigaldatud", + "configKey": "Võti", + "configValue": "Väärtus", + "allUsers": "Luba kõiki kasutajaid", + "selectedUsers": "Valitud kasutajad", + "allLibraries": "Luba kõik kogumikud", + "selectedLibraries": "Valitud kogumikud", + "allowWriteAccess": "Luba kirjutusõigused" + }, + "sections": { + "status": "Olek", + "info": "Lisamooduli teave", + "configuration": "Seadistus", + "manifest": "Manifest", + "usersPermission": "Kasutajate õigused", + "libraryPermission": "Kogumike õigused" + }, + "status": { + "enabled": "Kasutusel", + "disabled": "Pole kasutusel" + }, + "actions": { + "enable": "Võta kasutusele", + "disable": "Eemalda kasutuselt", + "disabledDueToError": "Enne kasutuselevõtmist paranda viga", + "disabledUsersRequired": "Enne kasutuselevõtmist vali kasutajad", + "disabledLibrariesRequired": "Enne kasutuselevõtmist vali kogumikud", + "addConfig": "Lisa seadistus", + "rescan": "Skaneeri uuesti" + }, + "notifications": { + "enabled": "Lisamoodul on kasutusel", + "disabled": "Lisamoodul pole kasutusel", + "updated": "Lisamoodul on uuendatud", + "error": "Viga lisamooduli uuendamisel" + }, + "validation": { + "invalidJson": "Seadistus peab olema koostatud korrektses JSON-vormingus" + }, + "messages": { + "configHelp": "Seadista lisamoodulit võti-väärtus paaride abil. Kui lisamoodul seadistamist ei vaja, siis jäta tühjaks.", + "clickPermissions": "Üksikasjade vaatamiseks klõpsa õigust", + "noConfig": "Ühtegi seadistust pole määratud", + "allUsersHelp": "Kasutuselevõetuna on sellel lisamoodulil ligipääs kõikidele kasutajatele, sealhulgas tulevikus loodavatele.", + "noUsers": "Ühtegi kasutajat pole valitud", + "permissionReason": "Põhjus", + "usersRequired": "See lisamoodul vajab ligipääsu kasutajate teabele. Vali kasutajad, millele ta ligi peaks saama või vali „Kõik kasutajad“.", + "allLibrariesHelp": "Kasutuselevõetuna on sellel lisamoodulil ligipääs kõikidele kogumikele, sealhulgas tulevikus loodavatele.", + "noLibraries": "Ühtegi kogumikku pole valitud", + "librariesRequired": "See lisamoodul vajab ligipääsu kogumiku teabele. Vali kogumikud, millele ta ligi peaks saama või vali „Kõik kogumikud“.", + "requiredHosts": "Nõutavad hostid", + "configValidationError": "Seadistuse õigsuse kontrollimine ei õnnestunud:", + "schemaRenderError": "Seadistuste vormi lugemine ja töötlemine ei õnnestunud. Lisamooduli ülesehitus/skeem võib olla vigane.", + "allowWriteAccessHelp": "Kui valik on kasutusel, siis lisamoodul võib muuta vaid faile kogumike kaustades. Vaikimisi on lisamoodulitel vaid lugemisõigus." + }, + "placeholders": { + "configKey": "võti", + "configValue": "väärtus" + } + } + }, + "ra": { + "auth": { + "welcome1": "Aitäh, et paigaldasite Navidrome'i!", + "welcome2": "Alustamiseks lisa peakasutaja", + "confirmPassword": "Korda salasõna", + "buttonCreateAdmin": "Loo admin", + "auth_check_error": "Jätkamiseks palun logi sisse", + "user_menu": "Profiil", + "username": "Kasutajanimi", + "password": "Salasõna", + "sign_in": "Logi sisse", + "sign_in_error": "Tuvastamine ei toiminud, palun proovi uuesti", + "logout": "Logi välja", + "insightsCollectionNote": "Navidrome kogub anonüümset kasutustusstatistikat, mille alusel on võimalik projekti paremaks muuta. Klõpsides [siin], saad lugeda lisateavet ning soovi korral sellest kogumisest loobuda" + }, + "validation": { + "invalidChars": "Palun kasutage ainult tähti ja numbreid", + "passwordDoesNotMatch": "Salasõnad ei kattu", + "required": "Nõutav", + "minLength": "Pikkus peab olema vähemalt %{min} tähemärki", + "maxLength": "Pikkus ei tohi olla üle %{max} tähemärgi", + "minValue": "Väärtus peab olema vähemalt %{min}", + "maxValue": "Väärtus ei tohi olla enam, kui %{max}", + "number": "Sisend peab olema number", + "email": "Sisend peab korrektne e-posti aadress", + "oneOf": "Väärtus peab olema üks järgnevaist: %{options}", + "regex": "Väärtus peab vastama kindlale vormingule (regulaaravaldis): %{pattern}", + "unique": "Sisend peab olema unikaalne", + "url": "Sisend peab olema korrektne võrguaadress" + }, + "action": { + "add_filter": "Lisa filter", + "add": "Lisa", + "back": "Mine tagasi", + "bulk_actions": "1 objekt on valitud |||| %{smart_count} objekti on valitud", + "cancel": "Katkesta", + "clear_input_value": "Eemalda väärtus", + "clone": "Klooni", + "confirm": "Kinnita", + "create": "Loo", + "delete": "Kustuta", + "edit": "Muuda", + "export": "Ekspordi", + "list": "Loend", + "refresh": "Uuenda andmed", + "remove_filter": "Eemalda see filter", + "remove": "Eemalda", + "save": "Salvesta", + "search": "Otsi", + "show": "Näita", + "sort": "Järjesta", + "undo": "Võta tegevus tagasi", + "expand": "Laienda", + "close": "Sulge", + "open_menu": "Ava menüü", + "close_menu": "Sulge menüü", + "unselect": "Eemalda valik", + "skip": "Jäta vahele", + "bulk_actions_mobile": "1 |||| %{smart_count}", + "share": "Jaga", + "download": "Laadi alla" + }, + "boolean": { + "true": "Jah", + "false": "Ei" + }, + "page": { + "create": "Loo %{name}", + "dashboard": "Töölaud", + "edit": "%{name} #%{id}", + "error": "Midagi läks valesti", + "list": "%{name}", + "loading": "Laadin", + "not_found": "Ei leidu", + "show": "%{name} #%{id}", + "empty": "Nimi on veel puudu - %{name}.", + "invite": "Kas sa sooviksid ühe sellise lisada?" + }, + "input": { + "file": { + "upload_several": "Lohista üleslaadimiseks mõned failid või vali üks failivalijast.", + "upload_single": "Lohista üleslaadimiseks fail või vali ta failivalijast." + }, + "image": { + "upload_several": "Lohista üleslaadimiseks mõned pildid või vali üks failivalijast.", + "upload_single": "Lohista üleslaadimiseks pilt või vali ta failivalijast." + }, + "references": { + "all_missing": "Viitenumbrite andmeid ei leidu.", + "many_missing": "Vähemalt üks seotud viide ei tundu enam olema saadaval.", + "single_missing": "Seotud viide ei tundu enam olema saadaval." + }, + "password": { + "toggle_visible": "Peida salasõna", + "toggle_hidden": "Näita salasõna" + } + }, + "message": { + "about": "Teave", + "are_you_sure": "Kas oled kindel?", + "bulk_delete_content": "Kas sa oled kindel, et soovid kustutada selle objekti - %{name}? |||| Kas sa oled kindel, et soovid kustutada need %{smart_count} objekti?", + "bulk_delete_title": "Kustuta %{name} |||| Kustuta %{name} - %{smart_count} kirjet", + "delete_content": "Kas oled kindel, et soovid selle objekti kustutada?", + "delete_title": "Kustuta %{name} #%{id}", + "details": "Üksikasjad", + "error": "Tekkis klientrakenduse viga ja päringut polnud võimalik lõpetada.", + "invalid_form": "Vormi andmed pole õiged. Palun kontrolli sisestusi", + "loading": "Leht on just laadimisel, palun oota hetke", + "no": "Ei", + "not_found": "Sa kas sisestasid vigase võrguaadressi või klõpsisid vigast linki.", + "yes": "Jah", + "unsaved_changes": "Mõned sinu muudatused pole salvestatud. Kas sa soovid neist loobuda?" + }, + "navigation": { + "no_results": "Tulemusi ei leidu", + "no_more_results": "Lehe number %{page} on väljaspool etteantud piire. Proovi eelmist lehte.", + "page_out_of_boundaries": "Lehe number %{page} on väljaspool etteantud piire", + "page_out_from_end": "Viimasest lehest ei saa edasi minna", + "page_out_from_begin": "Esimese lehe ette ei saa minna", + "page_range_info": "%{offsetBegin}-%{offsetEnd} - kokku %{total}", + "page_rows_per_page": "Kirjeid lehel:", + "next": "Edasi", + "prev": "Tagasi", + "skip_nav": "Mine sisu juurde" + }, + "notification": { + "updated": "Objekt on uuendatud |||| %{smart_count} objekti on uuendatud", + "created": "Objekt on loodud", + "deleted": "Objekt on kustutatud |||| %{smart_count} objekti on kustutatud", + "bad_item": "Vigane objekt", + "item_doesnt_exist": "Objekti pole olemas", + "http_error": "Viga suhtlemisel serveriga", + "data_provider_error": "Andmeteenusepakkuja viga. Lisateavet leiad brauseri konsoolist.", + "i18n_error": "Vastava keele tõlget ei saa laadida", + "canceled": "Tegevus on tühistatud", + "logged_out": "Sinu sessioon on lõppenud, palun ühenda uuesti.", + "new_version": "Uus versioon on saadaval! Palun laadi see vaade uuesti." + }, + "toggleFieldsMenu": { + "columnsToDisplay": "Kuvatavad veerud", + "layout": "Paigutus", + "grid": "Ruudustik", + "table": "Tabel" + } + }, + "message": { + "note": "MÄRGE", + "transcodingDisabled": "Transkodeeringu seadistuse muutmine läbi veebiliidese ei ole turvariskide tõttu saadaval. Kui soovite muuta või lisada transkodeerimisega seotud seadistusi, taaskäivitage server %{config} valikuga.", + "transcodingEnabled": "Navidrome käivitati %{config} valikuga, mis lubab läbi veebiliidese transkodeerimisseadistuste jooksutada süsteemikäsklusi. Turvakaalutlustel on soovitatav kasutada seda valikut ainult transkodeerimisseadete muutmiseks.", + "songsAddedToPlaylist": "Lisasin ühe loo esitusloendisse |||| Lisasin %{smart_count} lugu esitusloendisse", + "noPlaylistsAvailable": "Pole saadaval", + "delete_user_title": "Kustuta kasutaja „%{name}“", + "delete_user_content": "Kas oled kindel, et soovid selle kasutaja ja kõik tema andmed (sh esitusloendid ja eelistused) kustutada?", + "notifications_blocked": "Sa oled selle saidi teavitused veebibrauseri seadistusest keelanud", + "notifications_not_available": "See veebibrauser kas ei toeta töölauateavitusi või sa ei kasuta Navidrome'i üle https-protokolli", + "lastfmLinkSuccess": "Last.fm-i seos on lisatud ja kraasimine on lülitatud sisse", + "lastfmLinkFailure": "Last.fm-i seose lisamine ei õnnestunud", + "lastfmUnlinkSuccess": "Last.fm-i seos on eemaldatud ja kraasimine on lülitatud välja", + "lastfmUnlinkFailure": "Last.fm-i seose eemaldamine ei õnnestunud", + "openIn": { + "lastfm": "Ava Last.fm-is", + "musicbrainz": "Ava MusicBrainzis" + }, + "lastfmLink": "Lisateave...", + "listenBrainzLinkSuccess": "ListenBrainzi seos on lisatud ja kraasimine on lülitatud sisse kasutajana: %{user}", + "listenBrainzLinkFailure": "ListenBrainzi seose lisamine ei õnnestunud: %{error}", + "listenBrainzUnlinkSuccess": "ListenBrainzi seos on eemaldatud ja kraasimine on lülitatud välja", + "listenBrainzUnlinkFailure": "ListenBrainzi seose eemaldamine ei õnnestunud", + "downloadOriginalFormat": "Laadi alla algses vormingus", + "shareOriginalFormat": "Jaga algses vormingus", + "shareDialogTitle": "Jaga - %{resource} „%{name}“", + "shareBatchDialogTitle": "Jaga - %{resource} |||| Jaga %{smart_count} kirjet - %{resource}", + "shareSuccess": "Võrguaadress on kopeeritud lõikelauale: %{url}", + "shareFailure": "Viga %{url} võrguaadressi kopeerimisel lõikelauale", + "downloadDialogTitle": "Laadi alla - %{resource} '%{name}' (%{size})", + "shareCopyToClipboard": "Kopeeri lõikelauale: Ctrl+C, sisestusklahv", + "remove_missing_title": "Eemalda puuduvad failid", + "remove_missing_content": "Kas sa oled kindel, et soovid valitud puuduvate failide andmed andmebaasist eemaldada? Sellega kaovad kõik viited neile, sealhulgas esituskordade ja hinnangute märked.", + "remove_all_missing_title": "Eemalda kõik puuduvad failid", + "remove_all_missing_content": "Kas sa oled kindel, et soovid kõik puuduvate failide andmed andmebaasist eemaldada? Sellega kaovad kõik viited neile, sealhulgas esituskordade ja hinnangute märked.", + "noSimilarSongsFound": "Sarnaseid lugusid ei leidu", + "noTopSongsFound": "Populaarsemaid lugusid ei leidu", + "startingInstantMix": "Laadin kohest miksi...", + "uploadCover": "Laadi kaanepilt üles", + "removeCover": "Eemalda kaanepilt", + "coverUploaded": "Kaanepilt on uuendatud", + "coverRemoved": "Kaanepilt on eemaldatud", + "coverUploadError": "Viga kaanepildi üleslaadimisel", + "coverRemoveError": "Viga kaanepildi eemaldamisel" + }, + "menu": { + "library": "Kogumik", + "settings": "Seaded", + "version": "Versioon", + "theme": "Teema", + "personal": { + "name": "Isiklik", + "options": { + "theme": "Teema", + "language": "Keel", + "defaultView": "Vaikimisi vaade", + "desktop_notifications": "Teavitused töölaual", + "lastfmScrobbling": "Kraasi Last.fm-i teenusesse", + "listenBrainzScrobbling": "Kraasi ListenBrainzi teenusesse", + "replaygain": "Esitusvaljuse tundlikkuse režiim", + "preAmp": "Esitusvaljuse tundlikkuse eelvõimendus (dB)", + "gain": { + "none": "Pole kasutusel", + "album": "Kasuta albumikohast esitusvaljuse tundlikkust", + "track": "Kasuta lookohast esitusvaljuse tundlikkust" + }, + "lastfmNotConfigured": "Last.fm-i API-võti pole seadistatud" + } + }, + "albumList": "Albumid", + "about": "Rakenduse teave", + "playlists": "Esitusloendid", + "sharedPlaylists": "Jagatud esitusloendid", + "librarySelector": { + "allLibraries": "Kõik kogumikud (%{count})", + "multipleLibraries": "%{selected} / %{total} kogumikest", + "selectLibraries": "Vali kogumikud", + "none": "Puudub" + } + }, + "player": { + "playListsText": "Esitusjärjekord", + "openText": "Ava", + "closeText": "Sulge", + "notContentText": "Muusikat pole", + "clickToPlayText": "Klõpsa esitamiseks", + "clickToPauseText": "Klõpsa peatamiseks", + "nextTrackText": "Järgmine lugu", + "previousTrackText": "Eelmine lugu", + "reloadText": "Laadi uuesti", + "volumeText": "Helivaljus", + "toggleLyricText": "Näita/peida laulusõnad", + "toggleMiniModeText": "Minimeeri", + "destroyText": "Hävita", + "downloadText": "Laadi alla", + "removeAudioListsText": "Kustuta heliloendid", + "clickToDeleteText": "„%{name}“ kustutamiseks klõpsa", + "emptyLyricText": "Laulusõnu pole", + "playModeText": { + "order": "Oma järjekorras", + "orderLoop": "Korda", + "singleLoop": "Korda üks kord", + "shufflePlay": "Sega lood" + } + }, + "about": { + "links": { + "homepage": "Avaleht", + "source": "Lähtekood", + "featureRequests": "Arendusettepanekud", + "lastInsightsCollection": "Viimati kogutud statistika", + "insights": { + "disabled": "Pole kasutusel", + "waiting": "Ootel" + } + }, + "tabs": { + "about": "Teave", + "config": "Seadistus" + }, + "config": { + "configName": "Seadistuse nimi", + "environmentVariable": "Keskkonnamuutuja", + "currentValue": "Praegune väärtus", + "configurationFile": "Seadistusfail", + "exportToml": "Ekspordi seadistused (TOML-failina)", + "exportSuccess": "Seadistused on eksporditud lõikelauale TOML-failina", + "exportFailed": "Seadistuse kopeerimine ei õnnestunud", + "devFlagsHeader": "Arendusparameetrid (võivad muutuda või sootuks kaduda)", + "devFlagsComment": "Need on katselised seadistused, mis võivad tulevastest versioonidest kaduda", + "downloadToml": "Laadi seadistused alla (TOML-failina)" + } + }, + "activity": { + "title": "Tegevus", + "totalScanned": "Kokku skaneeritud kaustu", + "quickScan": "Kiirskaneerimine", + "fullScan": "Täisskaneerimine", + "serverUptime": "Serveri katkematu tööaeg", + "serverDown": "POLE VÕRGUS", + "scanType": "Tüüp", + "status": "Skaneerimisviga", + "elapsedTime": "Möödunud aeg", + "selectiveScan": "Valikuline" + }, + "help": { + "title": "Navidrome'i kiirklahvid", + "hotkeys": { + "show_help": "Näita seda abiteavet", + "toggle_menu": "Lülita menüü külgriba sisse/välja", + "toggle_play": "Esita / Peata esitus", + "prev_song": "Eelmine lugu", + "next_song": "Järgmine lugu", + "vol_up": "Heli valjemaks", + "vol_down": "Heli vaiksemaks", + "toggle_love": "Lisa see lugu lemmikute hulka", + "current_song": "Mine esitamisel loo juurde" + } + }, + "nowPlaying": { + "title": "Hetkel esitamisel", + "empty": "Mitte midagi pole hetkel esitamisel", + "minutesAgo": "%{smart_count} minut tagasi |||| %{smart_count} minutit tagasi" + } +} \ No newline at end of file From 900c6b40db5d5f1b236bb5cbb96643f6fa7bd4c0 Mon Sep 17 00:00:00 2001 From: Kendall Garner <17521368+kgarner7@users.noreply.github.com> Date: Mon, 18 May 2026 12:50:30 +0000 Subject: [PATCH 075/117] chore(subsonic): only log response body on send error when at trace level or higher (#5501) --- server/subsonic/api.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/subsonic/api.go b/server/subsonic/api.go index 1ca364449..fbab02794 100644 --- a/server/subsonic/api.go +++ b/server/subsonic/api.go @@ -375,6 +375,10 @@ func sendResponse(w http.ResponseWriter, r *http.Request, payload *responses.Sub } if _, err := w.Write(response); err != nil { //nolint:gosec - log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err) + if log.IsGreaterOrEqualTo(log.LevelTrace) { + log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, "payload", string(response), err) + } else { + log.Error(r, "Error sending response to client", "endpoint", r.URL.Path, err) + } } } From 3b5980559bf7e0e1a24d51a0b9be6be17176e600 Mon Sep 17 00:00:00 2001 From: VirtualWolf Date: Wed, 20 May 2026 00:01:41 +1000 Subject: [PATCH 076/117] 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 e299a5dfb49ca50ba11afaa4ffa8c51da7a7d37e 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 077/117] 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 d43baf7d54d19219999c1075b0752ab5f54c0130 Mon Sep 17 00:00:00 2001 From: nenadjokic Date: Tue, 19 May 2026 19:50:34 +0200 Subject: [PATCH 078/117] 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 de4974d1b3b749ec4b88219b7414ce938b53a5f8 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 19 May 2026 18:02:29 -0300 Subject: [PATCH 079/117] 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 67a0413aea900b2fb1a49f49cae29c580569c165 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 19 May 2026 18:23:36 -0300 Subject: [PATCH 080/117] 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 753c4cda785cdba5359215d04958e8c4de8b5384 Mon Sep 17 00:00:00 2001 From: Deluan Date: Wed, 20 May 2026 19:33:42 -0300 Subject: [PATCH 081/117] 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 14b80377cd7a47284ab28b9fa81827770d009469 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 082/117] 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 3b8b9445982bf9b661f918b200e591e988ac3064 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 22 May 2026 18:42:17 -0300 Subject: [PATCH 083/117] 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 f5e48445dde89fbcdfcc63c7539cdebb3813d4c1 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 22 May 2026 22:22:23 -0300 Subject: [PATCH 084/117] 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 8a211e78733aa25151d920dd8b9fbcec9a96ed8e 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 085/117] 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 696fee2f51aee5a0acc1b8a9fd2a149177c0f706 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sat, 23 May 2026 18:20:18 -0400 Subject: [PATCH 086/117] 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 2cb3f00094cb83af978827e991c2117883cb2730 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 087/117] 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 99e76877a22728d16c1bafbb2d9af22662651cad 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 088/117] 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 aeb28c1a4270cf94f25d9d2dbfefc805cd3d57cd 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 089/117] 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 7f7b91f785dda522f2aa676f6b5e9f0d5c4a9e06 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 090/117] 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 abe6d5903704247770e76b003ec334966d4e3e07 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 28 May 2026 00:07:49 -0300 Subject: [PATCH 091/117] 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 29c377bb31302e8e43d7153b911b53b002321c12 Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 28 May 2026 19:25:26 -0300 Subject: [PATCH 092/117] 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 9231104e5c5ba920d3c59ce2dde99ae004b5026b Mon Sep 17 00:00:00 2001 From: Deluan Date: Thu, 28 May 2026 22:09:54 -0300 Subject: [PATCH 093/117] 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 69dde778e0b5bd2f440a92001443612c61ef0bc9 Mon Sep 17 00:00:00 2001 From: Tales Costa Date: Tue, 2 Jun 2026 09:38:57 -0300 Subject: [PATCH 094/117] 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 314e99e71cbbfe104ee90614222bb7543815a7bc 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 095/117] 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 f62127d24a1026c01bc78d2025a91be5099129cc 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 096/117] 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 227eb57194fe78f6b86acdd8d4831c3692ff1718 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 097/117] 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 d661babd81a7ec020f1bffa2a5074fede923929a Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 5 Jun 2026 08:14:00 -0400 Subject: [PATCH 098/117] 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 51e8294b5b39de1c455b47aaac940c62d9a1b9ac 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 099/117] 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 594fd84e2418a170cfca5dc285e00f58cb7893d3 Mon Sep 17 00:00:00 2001 From: Deluan Date: Fri, 5 Jun 2026 15:50:59 -0400 Subject: [PATCH 100/117] 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 484f87411a79dcc47b1d96aaae29db8916069aa3 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 101/117] 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 d4145ea15b3d2a63502a281e4390fded08b7b176 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 102/117] 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 6f3232ab69f647f5ae34eeff1754d16f38502171 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 103/117] 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 639f8bd68b15d58243c2cabf5fddeafd39e8d934 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 104/117] 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 15f38cf4205a98d22dd0f43e59b62f6c691c574c Mon Sep 17 00:00:00 2001 From: Buck DeFore Date: Fri, 5 Jun 2026 18:35:00 -0400 Subject: [PATCH 105/117] 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 17eb88fd6f9849a113fee4693714448afb75f1f4 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 106/117] 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 7edbfbf79523d4a354bd22646e92437ba3ae327c 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 107/117] 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, From f4c19cf3a732319c3105c2d0bb2a35ebf21c4637 Mon Sep 17 00:00:00 2001 From: Metalhearf <6446231+Metalhearf@users.noreply.github.com> Date: Sat, 6 Jun 2026 03:04:48 +0200 Subject: [PATCH 108/117] feat(ui): add Tokyo Night theme (#5497) * feat(themes): add Tokyo Night theme Signed-off-by: Metalhearf <6446231+Metalhearf@users.noreply.github.com> * fix(themes): address review feedback on Tokyo Night Signed-off-by: Metalhearf <6446231+Metalhearf@users.noreply.github.com> --------- Signed-off-by: Metalhearf <6446231+Metalhearf@users.noreply.github.com> Co-authored-by: Deluan --- ui/src/themes/index.js | 4 + ui/src/themes/tokyoNight.css.js | 143 ++++++++++ ui/src/themes/tokyoNight.js | 382 +++++++++++++++++++++++++++ ui/src/themes/tokyoNightLight.css.js | 123 +++++++++ ui/src/themes/tokyoNightLight.js | 382 +++++++++++++++++++++++++++ 5 files changed, 1034 insertions(+) create mode 100644 ui/src/themes/tokyoNight.css.js create mode 100644 ui/src/themes/tokyoNight.js create mode 100644 ui/src/themes/tokyoNightLight.css.js create mode 100644 ui/src/themes/tokyoNightLight.js diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js index f4886fd31..f79a6a999 100644 --- a/ui/src/themes/index.js +++ b/ui/src/themes/index.js @@ -18,6 +18,8 @@ import SquiddiesGlassTheme from './SquiddiesGlass' import NautilineTheme from './nautiline' import MoonbaseAlphaTheme from './moonbaseAlpha' import MoonbaseBravoTheme from './moonbaseBravo' +import TokyoNightLightTheme from './tokyoNightLight' +import TokyoNightTheme from './tokyoNight' export default { // Classic default themes @@ -43,4 +45,6 @@ export default { NutballTheme, SpotifyTheme, SquiddiesGlassTheme, + TokyoNightLightTheme, + TokyoNightTheme, } diff --git a/ui/src/themes/tokyoNight.css.js b/ui/src/themes/tokyoNight.css.js new file mode 100644 index 000000000..882fcd3eb --- /dev/null +++ b/ui/src/themes/tokyoNight.css.js @@ -0,0 +1,143 @@ +const stylesheet = ` + +.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover { + color: #7aa2f7 +} + +.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: #7aa2f7 +} + +.react-jinke-music-player-main ::-webkit-scrollbar-thumb { + background-color: #7aa2f7; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #7aa2f7 +} + +.react-jinke-music-player-main .audio-item.playing svg { + color: #7aa2f7 +} + +.react-jinke-music-player-main .audio-item.playing .player-singer { + color: #7aa2f7 !important +} + +.react-jinke-music-player-main .loading svg { + color: #7aa2f7 !important +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle { + border: hidden; + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; +} + +.rc-slider-rail, .rc-slider-track { + height: 6px; +} + +.rc-slider { + padding: 3px 0; +} + +.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: #24283b; + color: #c0caf5; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.25); +} + +.audio-lists-panel { + background-color: #24283b; + bottom: 6.25rem; + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 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: #292e42; +} + +.audio-lists-panel-header { + border-bottom: 1px solid rgba(0, 0, 0, 0.25); + 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; +} + +.audio-lists-panel-content .audio-item { + line-height: 32px; +} + +.react-jinke-music-player-main .music-player-panel .panel-content .img-content { + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; +} + +.react-jinke-music-player-main .music-player-lyric { + color: #c0caf5; + -webkit-text-stroke: 0.5px #1a1b26; + font-weight: bolder; +} + +.react-jinke-music-player-main .lyric-btn-active, .react-jinke-music-player-main .lyric-btn-active svg { + color: #7aa2f7 !important; +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #7aa2f7 +} + +.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: #7aa2f7 +} + +.audio-lists-panel-content .audio-item .player-icons { + scale: 75%; +} + +/* Mobile */ + +.react-jinke-music-player-mobile-cover { + border: none; + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; +} + +.react-jinke-music-player .music-player-controller { + border: none; + box-shadow: rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px; + color: #7aa2f7; +} + +.react-jinke-music-player .music-player-controller .music-player-controller-setting { + color: rgba(122, 162, 247, .3); +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle, .react-jinke-music-player-mobile-progress .rc-slider-track { + background-color: #7aa2f7; +} + +.react-jinke-music-player-mobile-progress .rc-slider-handle { + border: none; +} +` + +export default stylesheet diff --git a/ui/src/themes/tokyoNight.js b/ui/src/themes/tokyoNight.js new file mode 100644 index 000000000..07d372a6b --- /dev/null +++ b/ui/src/themes/tokyoNight.js @@ -0,0 +1,382 @@ +import stylesheet from './tokyoNight.css.js' + +const background = '#1a1b26' +const surface = '#24283b' +const currentLine = '#292e42' +const foreground = '#c0caf5' +const comment = '#565f89' +const blue = '#7aa2f7' +const cyan = '#7dcfff' +const purple = '#bb9af7' +const red = '#f7768e' + +// For Album, Playlist play button +const musicListActions = { + alignItems: 'center', + '@global': { + 'button:first-child:not(:only-child)': { + '@media screen and (max-width: 720px)': { + transform: 'scale(1.5)', + margin: '1rem', + '&:hover': { + transform: 'scale(1.6) !important', + }, + }, + transform: 'scale(2)', + margin: '1.5rem', + minWidth: 0, + padding: 5, + transition: 'transform .3s ease', + backgroundColor: `${blue} !important`, + color: background, + borderRadius: 500, + border: 0, + '&:hover': { + transform: 'scale(2.1)', + backgroundColor: `${blue} !important`, + border: 0, + }, + }, + 'button:only-child': { + margin: '1.5rem', + }, + 'button:first-child>span:first-child': { + padding: 0, + }, + 'button:first-child>span:first-child>span': { + display: 'none', + }, + 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': + { + color: foreground, + }, + }, +} + +export default { + themeName: 'Tokyo Night', + palette: { + primary: { + main: blue, + }, + secondary: { + main: purple, + contrastText: foreground, + }, + error: { + main: red, + }, + type: 'dark', + background: { + default: background, + paper: surface, + }, + }, + overrides: { + MuiPaper: { + root: { + color: foreground, + backgroundColor: surface, + }, + }, + MuiAppBar: { + positionFixed: { + backgroundColor: `${surface} !important`, + boxShadow: + 'rgba(15, 17, 21, 0.25) 0px 4px 6px, rgba(15, 17, 21, 0.1) 0px 5px 7px', + }, + }, + MuiDrawer: { + root: { + background: background, + }, + }, + MuiButton: { + textPrimary: { + color: blue, + }, + textSecondary: { + color: foreground, + }, + }, + MuiIconButton: { + root: { + color: foreground, + }, + }, + MuiChip: { + root: { + backgroundColor: currentLine, + }, + }, + MuiFormGroup: { + root: { + color: foreground, + }, + }, + MuiFormLabel: { + root: { + color: comment, + '&$focused': { + color: blue, + }, + }, + }, + MuiFormHelperText: { + error: { + color: red, + }, + }, + MuiToolbar: { + root: { + backgroundColor: `${surface} !important`, + }, + }, + MuiOutlinedInput: { + root: { + '& $notchedOutline': { + borderColor: currentLine, + }, + '&:hover $notchedOutline': { + borderColor: comment, + }, + '&$focused $notchedOutline': { + borderColor: blue, + }, + }, + }, + MuiFilledInput: { + root: { + backgroundColor: currentLine, + '&:hover': { + backgroundColor: comment, + }, + '&$focused': { + backgroundColor: currentLine, + }, + }, + }, + MuiTableRow: { + root: { + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: `${currentLine} !important`, + }, + }, + }, + MuiTableHead: { + root: { + color: foreground, + background: surface, + }, + }, + MuiTableCell: { + root: { + color: foreground, + background: `${surface} !important`, + borderBottom: `1px solid ${currentLine}`, + }, + head: { + color: `${blue} !important`, + background: `${currentLine} !important`, + }, + body: { + color: `${foreground} !important`, + }, + }, + MuiSwitch: { + colorSecondary: { + '&$checked': { + color: blue, + }, + '&$checked + $track': { + backgroundColor: blue, + }, + }, + }, + NDAlbumGridView: { + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + color: foreground, + }, + albumSubtitle: { + color: comment, + }, + albumContainer: { + backgroundColor: surface, + borderRadius: '8px', + padding: '.75rem', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: currentLine, + }, + }, + albumPlayButton: { + backgroundColor: blue, + borderRadius: '50%', + boxShadow: '0 8px 8px rgb(0 0 0 / 30%)', + padding: '0.35rem', + transition: 'padding .3s ease', + '&:hover': { + background: `${blue} !important`, + padding: '0.45rem', + }, + }, + }, + NDPlaylistDetails: { + container: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + paddingTop: '2.5rem !important', + boxShadow: 'none', + }, + title: { + fontWeight: 700, + color: foreground, + }, + details: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumDetails: { + root: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + boxShadow: 'none', + }, + cardContents: { + alignItems: 'center', + paddingTop: '1.5rem', + }, + recordName: { + fontWeight: 700, + color: foreground, + }, + recordArtist: { + fontSize: '.875rem', + fontWeight: 700, + color: purple, + }, + recordMeta: { + fontSize: '.875rem', + color: comment, + }, + }, + NDCollapsibleComment: { + commentBlock: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumShow: { + albumActions: musicListActions, + }, + NDPlaylistShow: { + playlistActions: musicListActions, + }, + NDAudioPlayer: { + audioTitle: { + color: foreground, + fontSize: '0.875rem', + }, + songTitle: { + fontWeight: 400, + }, + songInfo: { + fontSize: '0.675rem', + color: comment, + }, + }, + NDLogin: { + systemNameLink: { + color: blue, + }, + welcome: { + color: foreground, + }, + card: { + minWidth: 300, + background: surface, + }, + button: { + boxShadow: '3px 3px 5px #15161e', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: `linear-gradient(to bottom, rgba(26 27 38 / 72%), ${background})!important`, + }, + }, + RaLayout: { + content: { + padding: '0 !important', + background: background, + }, + root: { + backgroundColor: background, + }, + }, + RaList: { + content: { + backgroundColor: background, + }, + }, + RaListToolbar: { + toolbar: { + backgroundColor: background, + padding: '0 .55rem !important', + }, + }, + RaSidebar: { + fixed: { + backgroundColor: background, + }, + drawerPaper: { + backgroundColor: `${background} !important`, + }, + }, + RaMenuItemLink: { + root: { + color: foreground, + '&[aria-current="page"]': { + color: `${blue} !important`, + }, + '&[aria-current="page"] .MuiListItemIcon-root': { + color: `${blue} !important`, + }, + }, + active: { + color: `${blue} !important`, + '& .MuiListItemIcon-root': { + color: `${blue} !important`, + }, + }, + }, + RaLink: { + link: { + color: cyan, + }, + }, + RaButton: { + button: { + margin: '0 5px 0 5px', + }, + }, + RaPaginationActions: { + currentPageButton: { + border: `2px solid ${blue}`, + }, + button: { + backgroundColor: currentLine, + minWidth: 48, + margin: '0 4px', + }, + }, + }, + player: { + theme: 'dark', + stylesheet, + }, +} diff --git a/ui/src/themes/tokyoNightLight.css.js b/ui/src/themes/tokyoNightLight.css.js new file mode 100644 index 000000000..a22c82d03 --- /dev/null +++ b/ui/src/themes/tokyoNightLight.css.js @@ -0,0 +1,123 @@ +const stylesheet = ` +.react-jinke-music-player-main.light-theme .loading svg { + color: #2e7de9; + font-size: 24px +} + +.react-jinke-music-player-mobile-play-model-tip { + background-color: #2e7de9; +} + +.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: #2e7de9 +} + +.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active { + box-shadow: 0 0 2px #2e7de9 +} + +.react-jinke-music-player-main.light-theme .audio-item.playing svg { + color: #2e7de9 +} + +.react-jinke-music-player-main.light-theme .audio-item.playing .player-singer { + color: #2e7de9 !important +} + +.audio-lists-panel-content .audio-item.playing, .audio-lists-panel-content .audio-item.playing svg { + color: #2e7de9 +} + +.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: #2e7de9 +} + +.react-jinke-music-player-main.light-theme ::-webkit-scrollbar-thumb { + background-color: #2e7de9; +} + +.react-jinke-music-player-main.light-theme svg { + color: #3760bf +} + +.react-jinke-music-player-main.light-theme svg:active, .react-jinke-music-player-main.light-theme svg:hover { + color: #2e7de9 +} + +.react-jinke-music-player-main.light-theme .rc-slider-rail { + background-color: rgba(55, 96, 191, .12) !important +} + +.react-jinke-music-player-main.light-theme .music-player-controller { + background-color: #d5d6db; + border-color: #d5d6db +} + +.react-jinke-music-player-main.light-theme .music-player-panel { + background-color: #d5d6db; + box-shadow: 0 1px 2px 0 rgba(0, 34, 77, .05); + color: #3760bf +} + +.react-jinke-music-player-main.light-theme .music-player-panel .img-content { + box-shadow: 0 0 10px #c4c8da +} + +.react-jinke-music-player-main.light-theme .music-player-panel .progress-load-bar { + background-color: rgba(55, 96, 191, .08) !important +} + +.react-jinke-music-player-main.light-theme .rc-switch { + color: #fff +} + +.react-jinke-music-player-main.light-theme .rc-switch:after { + background-color: #fff +} + +.react-jinke-music-player-main.light-theme .rc-switch-checked { + background-color: #2e7de9 !important; + border: 1px solid #2e7de9 +} + +.react-jinke-music-player-main.light-theme .rc-switch-inner { + color: #fff +} + +.react-jinke-music-player-main.light-theme .audio-lists-btn { + background-color: #e1e2e7 !important +} + +.react-jinke-music-player-main.light-theme .audio-lists-btn:active, .react-jinke-music-player-main.light-theme .audio-lists-btn:hover { + background-color: #ebebed; + color: #3760bf +} + +.react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover, .react-jinke-music-player-main.light-theme .audio-lists-btn > .group:hover > svg { + color: #2e7de9 +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel { + background-color: #d5d6db; + box-shadow: 0 0 2px #c4c8da; + color: #3760bf +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item { + background-color: #d5d6db +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item:nth-child(odd) { + background-color: #dadbe0 !important +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing { + background-color: #c4c8da !important +} + +.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing, .react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing svg { + color: #2e7de9 !important +} +` + +export default stylesheet diff --git a/ui/src/themes/tokyoNightLight.js b/ui/src/themes/tokyoNightLight.js new file mode 100644 index 000000000..f84cd0be9 --- /dev/null +++ b/ui/src/themes/tokyoNightLight.js @@ -0,0 +1,382 @@ +import stylesheet from './tokyoNightLight.css.js' + +const background = '#e1e2e7' +const surface = '#d5d6db' +const currentLine = '#c4c8da' +const foreground = '#3760bf' +const comment = '#848cb5' +const blue = '#2e7de9' +const cyan = '#007197' +const purple = '#9854f1' +const red = '#f52a65' + +// For Album, Playlist play button +const musicListActions = { + alignItems: 'center', + '@global': { + 'button:first-child:not(:only-child)': { + '@media screen and (max-width: 720px)': { + transform: 'scale(1.5)', + margin: '1rem', + '&:hover': { + transform: 'scale(1.6) !important', + }, + }, + transform: 'scale(2)', + margin: '1.5rem', + minWidth: 0, + padding: 5, + transition: 'transform .3s ease', + backgroundColor: `${blue} !important`, + color: background, + borderRadius: 500, + border: 0, + '&:hover': { + transform: 'scale(2.1)', + backgroundColor: `${blue} !important`, + border: 0, + }, + }, + 'button:only-child': { + margin: '1.5rem', + }, + 'button:first-child>span:first-child': { + padding: 0, + }, + 'button:first-child>span:first-child>span': { + display: 'none', + }, + 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg': + { + color: foreground, + }, + }, +} + +export default { + themeName: 'Tokyo Night Light', + palette: { + primary: { + main: blue, + }, + secondary: { + main: purple, + contrastText: foreground, + }, + error: { + main: red, + }, + type: 'light', + background: { + default: background, + paper: surface, + }, + }, + overrides: { + MuiPaper: { + root: { + color: foreground, + backgroundColor: surface, + }, + }, + MuiAppBar: { + positionFixed: { + backgroundColor: `${surface} !important`, + boxShadow: + 'rgba(15, 17, 21, 0.15) 0px 4px 6px, rgba(15, 17, 21, 0.08) 0px 5px 7px', + }, + }, + MuiDrawer: { + root: { + background: background, + }, + }, + MuiButton: { + textPrimary: { + color: blue, + }, + textSecondary: { + color: foreground, + }, + }, + MuiIconButton: { + root: { + color: foreground, + }, + }, + MuiChip: { + root: { + backgroundColor: currentLine, + }, + }, + MuiFormGroup: { + root: { + color: foreground, + }, + }, + MuiFormLabel: { + root: { + color: comment, + '&$focused': { + color: blue, + }, + }, + }, + MuiFormHelperText: { + error: { + color: red, + }, + }, + MuiToolbar: { + root: { + backgroundColor: `${surface} !important`, + }, + }, + MuiOutlinedInput: { + root: { + '& $notchedOutline': { + borderColor: currentLine, + }, + '&:hover $notchedOutline': { + borderColor: comment, + }, + '&$focused $notchedOutline': { + borderColor: blue, + }, + }, + }, + MuiFilledInput: { + root: { + backgroundColor: currentLine, + '&:hover': { + backgroundColor: comment, + }, + '&$focused': { + backgroundColor: currentLine, + }, + }, + }, + MuiTableRow: { + root: { + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: `${currentLine} !important`, + }, + }, + }, + MuiTableHead: { + root: { + color: foreground, + background: surface, + }, + }, + MuiTableCell: { + root: { + color: foreground, + background: `${surface} !important`, + borderBottom: `1px solid ${currentLine}`, + }, + head: { + color: `${blue} !important`, + background: `${currentLine} !important`, + }, + body: { + color: `${foreground} !important`, + }, + }, + MuiSwitch: { + colorSecondary: { + '&$checked': { + color: blue, + }, + '&$checked + $track': { + backgroundColor: blue, + }, + }, + }, + NDAlbumGridView: { + albumName: { + marginTop: '0.5rem', + fontWeight: 700, + color: foreground, + }, + albumSubtitle: { + color: comment, + }, + albumContainer: { + backgroundColor: surface, + borderRadius: '8px', + padding: '.75rem', + transition: 'background-color .3s ease', + '&:hover': { + backgroundColor: currentLine, + }, + }, + albumPlayButton: { + backgroundColor: blue, + borderRadius: '50%', + boxShadow: '0 8px 8px rgb(0 0 0 / 20%)', + padding: '0.35rem', + transition: 'padding .3s ease', + '&:hover': { + background: `${blue} !important`, + padding: '0.45rem', + }, + }, + }, + NDPlaylistDetails: { + container: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + paddingTop: '2.5rem !important', + boxShadow: 'none', + }, + title: { + fontWeight: 700, + color: foreground, + }, + details: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumDetails: { + root: { + background: `linear-gradient(${currentLine}, transparent)`, + borderRadius: 0, + boxShadow: 'none', + }, + cardContents: { + alignItems: 'center', + paddingTop: '1.5rem', + }, + recordName: { + fontWeight: 700, + color: foreground, + }, + recordArtist: { + fontSize: '.875rem', + fontWeight: 700, + color: purple, + }, + recordMeta: { + fontSize: '.875rem', + color: comment, + }, + }, + NDCollapsibleComment: { + commentBlock: { + fontSize: '.875rem', + color: comment, + }, + }, + NDAlbumShow: { + albumActions: musicListActions, + }, + NDPlaylistShow: { + playlistActions: musicListActions, + }, + NDAudioPlayer: { + audioTitle: { + color: foreground, + fontSize: '0.875rem', + }, + songTitle: { + fontWeight: 400, + }, + songInfo: { + fontSize: '0.675rem', + color: comment, + }, + }, + NDLogin: { + systemNameLink: { + color: blue, + }, + welcome: { + color: foreground, + }, + card: { + minWidth: 300, + background: surface, + }, + button: { + boxShadow: '3px 3px 5px #a8aecb', + }, + }, + NDMobileArtistDetails: { + bgContainer: { + background: `linear-gradient(to bottom, rgba(225 226 231 / 72%), ${background})!important`, + }, + }, + RaLayout: { + content: { + padding: '0 !important', + background: background, + }, + root: { + backgroundColor: background, + }, + }, + RaList: { + content: { + backgroundColor: background, + }, + }, + RaListToolbar: { + toolbar: { + backgroundColor: background, + padding: '0 .55rem !important', + }, + }, + RaSidebar: { + fixed: { + backgroundColor: background, + }, + drawerPaper: { + backgroundColor: `${background} !important`, + }, + }, + RaMenuItemLink: { + root: { + color: foreground, + '&[aria-current="page"]': { + color: `${blue} !important`, + }, + '&[aria-current="page"] .MuiListItemIcon-root': { + color: `${blue} !important`, + }, + }, + active: { + color: `${blue} !important`, + '& .MuiListItemIcon-root': { + color: `${blue} !important`, + }, + }, + }, + RaLink: { + link: { + color: cyan, + }, + }, + RaButton: { + button: { + margin: '0 5px 0 5px', + }, + }, + RaPaginationActions: { + currentPageButton: { + border: `2px solid ${blue}`, + }, + button: { + backgroundColor: currentLine, + minWidth: 48, + margin: '0 4px', + }, + }, + }, + player: { + theme: 'light', + stylesheet, + }, +} From 14aca10243353ce34d245eb73450429212fcbcaf Mon Sep 17 00:00:00 2001 From: Deluan Date: Sat, 6 Jun 2026 10:58:13 -0400 Subject: [PATCH 109/117] fix(transcode): log warning for invalid or stale transcode tokens Signed-off-by: Deluan --- server/subsonic/transcode.go | 1 + 1 file changed, 1 insertion(+) diff --git a/server/subsonic/transcode.go b/server/subsonic/transcode.go index 4e494b324..578ad44fc 100644 --- a/server/subsonic/transcode.go +++ b/server/subsonic/transcode.go @@ -370,6 +370,7 @@ func (api *Router) GetTranscodeStream(w http.ResponseWriter, r *http.Request) (* if err != nil { switch { case errors.Is(err, stream.ErrTokenInvalid), errors.Is(err, stream.ErrTokenStale): + log.Warn(ctx, "Invalid or stale transcode token", "mediaID", mediaID, err) http.Error(w, "Gone", http.StatusGone) default: log.Error(ctx, "Error validating transcode params", err) From e739a196ea6c4ea37d03114716667c6035694c8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Sun, 7 Jun 2026 12:29:17 -0400 Subject: [PATCH 110/117] fix(ui): update Estonian translations from POEditor (#5573) Co-authored-by: navidrome-bot --- resources/i18n/et.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/resources/i18n/et.json b/resources/i18n/et.json index d511c1246..b0131f4ea 100644 --- a/resources/i18n/et.json +++ b/resources/i18n/et.json @@ -154,12 +154,12 @@ "currentPassword": "Senine salasõna", "newPassword": "Uus salasõna", "token": "Tunnusluba", - "lastAccessAt": "Viimasti avatud", + "lastAccessAt": "Viimati avatud", "libraries": "Kogumikud" }, "helperTexts": { "name": "Sinu nime muudatused on näha järgmisel sisselogimisel", - "libraries": "Vali selle kasutaja jaoks konkreetsed kogumikus või jäta vaikimisi väärtuse kasutamiseks tühjaks" + "libraries": "Vali selle kasutaja jaoks konkreetsed kogumikud või jäta vaikimisi väärtuse kasutamiseks tühjaks" }, "notifications": { "created": "Kasutaja on lisatud", @@ -413,10 +413,10 @@ }, "ra": { "auth": { - "welcome1": "Aitäh, et paigaldasite Navidrome'i!", + "welcome1": "Aitäh, et paigaldasid Navidrome'i!", "welcome2": "Alustamiseks lisa peakasutaja", "confirmPassword": "Korda salasõna", - "buttonCreateAdmin": "Loo admin", + "buttonCreateAdmin": "Lisa peakasutaja", "auth_check_error": "Jätkamiseks palun logi sisse", "user_menu": "Profiil", "username": "Kasutajanimi", @@ -427,7 +427,7 @@ "insightsCollectionNote": "Navidrome kogub anonüümset kasutustusstatistikat, mille alusel on võimalik projekti paremaks muuta. Klõpsides [siin], saad lugeda lisateavet ning soovi korral sellest kogumisest loobuda" }, "validation": { - "invalidChars": "Palun kasutage ainult tähti ja numbreid", + "invalidChars": "Palun kasuta ainult tähti ja numbreid", "passwordDoesNotMatch": "Salasõnad ei kattu", "required": "Nõutav", "minLength": "Pikkus peab olema vähemalt %{min} tähemärki", @@ -558,8 +558,8 @@ }, "message": { "note": "MÄRGE", - "transcodingDisabled": "Transkodeeringu seadistuse muutmine läbi veebiliidese ei ole turvariskide tõttu saadaval. Kui soovite muuta või lisada transkodeerimisega seotud seadistusi, taaskäivitage server %{config} valikuga.", - "transcodingEnabled": "Navidrome käivitati %{config} valikuga, mis lubab läbi veebiliidese transkodeerimisseadistuste jooksutada süsteemikäsklusi. Turvakaalutlustel on soovitatav kasutada seda valikut ainult transkodeerimisseadete muutmiseks.", + "transcodingDisabled": "Teisendusseadistuste muutmine läbi veebiliidese ei ole turvariskide tõttu saadaval. Kui soovid muuta või lisada teisendamisega seotud seadistusi, taaskäivita server %{config} valikuga.", + "transcodingEnabled": "Navidrome käivitati %{config} valikuga, mis lubab läbi veebiliidese teisendusseadistuste käivitada süsteemikäsklusi. Turvakaalutlustel on soovitatav kasutada seda valikut ainult teisendusvalikute muutmiseks.", "songsAddedToPlaylist": "Lisasin ühe loo esitusloendisse |||| Lisasin %{smart_count} lugu esitusloendisse", "noPlaylistsAvailable": "Pole saadaval", "delete_user_title": "Kustuta kasutaja „%{name}“", @@ -603,13 +603,13 @@ }, "menu": { "library": "Kogumik", - "settings": "Seaded", + "settings": "Seadistused", "version": "Versioon", - "theme": "Teema", + "theme": "Kujundus", "personal": { "name": "Isiklik", "options": { - "theme": "Teema", + "theme": "Kujundus", "language": "Keel", "defaultView": "Vaikimisi vaade", "desktop_notifications": "Teavitused töölaual", From 2bab6c8f5a9746e2d0b5f2ea7a26e1a3bb9a37ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Mon, 8 Jun 2026 08:22:59 -0400 Subject: [PATCH 111/117] fix(ui): update Indonesian translations from POEditor (#5575) Co-authored-by: navidrome-bot --- resources/i18n/id.json | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/resources/i18n/id.json b/resources/i18n/id.json index cdba66663..762ce4ebb 100644 --- a/resources/i18n/id.json +++ b/resources/i18n/id.json @@ -37,7 +37,10 @@ "sampleRate": "Sample rate", "missing": "Hilang", "libraryName": "Pustaka", - "composer": "Komposer" + "composer": "Komposer", + "disc": "Disk %{discNumber}", + "albumGain": "Album gain", + "trackGain": "Trek gain" }, "actions": { "addToQueue": "Tambah ke antrean", @@ -353,7 +356,8 @@ "allUsers": "Izinkan semua pengguna", "selectedUsers": "Pengguna yang dipilih", "allLibraries": "Izinkan semua pustaka", - "selectedLibraries": "Pustaka dipilih" + "selectedLibraries": "Pustaka dipilih", + "allowWriteAccess": "Izinkan akses tulis" }, "sections": { "status": "Status", @@ -398,7 +402,8 @@ "librariesRequired": "Plugin ini membutuhkan akses ke informasi pustaka. Pilih beberapa pustaka yang bisa diakses, atau aktifkan 'Izinkan semua pustaka'.", "requiredHosts": "Hosts diperlukan", "configValidationError": "Validasi konfigurasi gagal:", - "schemaRenderError": "Tidak dapat menampilkan form konfigurasi. Skema plugin mungkin tidak valid." + "schemaRenderError": "Tidak dapat menampilkan form konfigurasi. Skema plugin mungkin tidak valid.", + "allowWriteAccessHelp": "Ketika diaktifkan, plugin dapat mengubah file di direktori pustaka. Bawaannya, plugin hanya memiliki akses read-only" }, "placeholders": { "configKey": "key", @@ -588,7 +593,13 @@ "remove_all_missing_content": "Apa kamu yakin ingin menghapus semua file dari database? Ini akan menghapus permanen dan apapun referensi ke mereka, termasuk hitungan pemutaran dan rating mereka.", "noSimilarSongsFound": "Tidak ada lagu yang serupa ditemukan", "noTopSongsFound": "Tidak ada lagu teratas ditemukan", - "startingInstantMix": "Memuat Mix Instan..." + "startingInstantMix": "Memuat Mix Instan...", + "uploadCover": "Unggah Sampul", + "removeCover": "Hapus Sampul", + "coverUploaded": "Sampul diperbarui", + "coverRemoved": "Sampul dihapus", + "coverUploadError": "Kesalahan mengunggah sampul", + "coverRemoveError": "Kesalahan menghapus sampul" }, "menu": { "library": "Pustaka", @@ -674,7 +685,8 @@ "exportSuccess": "Konfigurasi sudah diekspor ke papan klip dalam bentuk format TOML", "exportFailed": "Gagal menyalin konfigurasi", "devFlagsHeader": "Flag Pengembangan (subyek untuk perubahan/pemindahan)", - "devFlagsComment": "Ini adalan pengaturan eksperimen dan mungkin akan dihapus di versi mendatang" + "devFlagsComment": "Ini adalan pengaturan eksperimen dan mungkin akan dihapus di versi mendatang", + "downloadToml": "Unduh Konfigurasi (TOML)" } }, "activity": { From 67b77558aa5c69912b42939efc9fd94ab08bc59d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Tue, 9 Jun 2026 19:27:15 -0400 Subject: [PATCH 112/117] fix(ui): load ND_DEFAULTLANGUAGE on app startup (#4000) * fix: load ND_DEFAULTLANGUAGE on app startup Added in to apply on initial mount, ensuring the locale is set even when the login page is skipped by reverse-proxy authentication. Removed the redundant language-init effect from . Fixes #3605. * style(ui): format App.jsx with Prettier Ran Prettier on ui/src/App.jsx to satisfy code style checks after adding default-language useEffect. * fix(ui): move default language initialization to Admin component Signed-off-by: Deluan * fix(ui): streamline locale setting in App component Signed-off-by: Deluan --------- Signed-off-by: Deluan --- ui/src/App.jsx | 28 ++++++++++++++++++++++++++-- ui/src/layout/Login.jsx | 24 +----------------------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/ui/src/App.jsx b/ui/src/App.jsx index 35eaee3eb..d10aa5a33 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -1,7 +1,12 @@ import ReactGA from 'react-ga' import { Provider } from 'react-redux' import { createHashHistory } from 'history' -import { Admin as RAAdmin, Resource } from 'react-admin' +import { + Admin as RAAdmin, + Resource, + useSetLocale, + useRefresh, +} from 'react-admin' import { HotKeys } from 'react-hotkeys' import dataProvider from './dataProvider' import authProvider from './authProvider' @@ -36,7 +41,7 @@ import { transcodingReducer, } from './reducers' import createAdminStore from './store/createAdminStore' -import { i18nProvider } from './i18n' +import { i18nProvider, retrieveTranslation } from './i18n' import config, { shareInfo } from './config' import { keyMap } from './hotkeys' import useChangeThemeColor from './useChangeThemeColor' @@ -44,6 +49,7 @@ import SharePlayer from './share/SharePlayer' import { HTML5Backend } from 'react-dnd-html5-backend' import { DndProvider } from 'react-dnd' import missing from './missing/index.js' +import { useEffect } from 'react' const history = createHashHistory() @@ -84,6 +90,24 @@ const App = () => ( ) const Admin = (props) => { + const setLocale = useSetLocale() + const refresh = useRefresh() + useEffect(() => { + if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) { + retrieveTranslation(config.defaultLanguage) + .then(() => setLocale(config.defaultLanguage)) + .then(() => { + localStorage.setItem('locale', config.defaultLanguage) + refresh(true) + }) + .catch((e) => { + // eslint-disable-next-line no-console + console.error( + 'Cannot load language "' + config.defaultLanguage + '": ' + e, + ) + }) + } + }, [setLocale, refresh]) useChangeThemeColor() /* eslint-disable react/jsx-key */ return ( diff --git a/ui/src/layout/Login.jsx b/ui/src/layout/Login.jsx index a84e01f4d..a7763cff3 100644 --- a/ui/src/layout/Login.jsx +++ b/ui/src/layout/Login.jsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useEffect } from 'react' +import React, { useState, useCallback } from 'react' import PropTypes from 'prop-types' import { Field, Form } from 'react-final-form' import { useDispatch } from 'react-redux' @@ -13,8 +13,6 @@ import { createMuiTheme, useLogin, useNotify, - useRefresh, - useSetLocale, useTranslate, useVersion, } from 'react-admin' @@ -24,7 +22,6 @@ import Notification from './Notification' import useCurrentTheme from '../themes/useCurrentTheme' import config from '../config' import { clearQueue } from '../actions' -import { retrieveTranslation } from '../i18n' import { INSIGHTS_DOC_URL } from '../consts.js' const useStyles = makeStyles( @@ -407,27 +404,8 @@ Login.propTypes = { // the right theme const LoginWithTheme = (props) => { const theme = useCurrentTheme() - const setLocale = useSetLocale() - const refresh = useRefresh() const version = useVersion() - useEffect(() => { - if (config.defaultLanguage !== '' && !localStorage.getItem('locale')) { - retrieveTranslation(config.defaultLanguage) - .then(() => { - setLocale(config.defaultLanguage).then(() => { - localStorage.setItem('locale', config.defaultLanguage) - }) - refresh(true) - }) - .catch((e) => { - throw new Error( - 'Cannot load language "' + config.defaultLanguage + '": ' + e, - ) - }) - } - }, [refresh, setLocale]) - return ( From b9a4caa5c3de9934efa62d9b8f222d01d9b44d76 Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 9 Jun 2026 08:50:09 -0400 Subject: [PATCH 113/117] chore(docs): add Danian hosting option to installation instructions Signed-off-by: Deluan --- release/goreleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/release/goreleaser.yml b/release/goreleaser.yml index e5035adda..103f2beaf 100644 --- a/release/goreleaser.yml +++ b/release/goreleaser.yml @@ -114,7 +114,7 @@ release: ## Where to go next? * Read installation instructions on our [website](https://www.navidrome.org/docs/installation/). - * Host Navidrome on [PikaPods](https://www.pikapods.com/pods/navidrome) for a simple cloud solution. + * Host Navidrome on [PikaPods](https://www.pikapods.com/pods/navidrome) or [Danian](https://danian.co/navidrome?nd) for a simple cloud solution. * Reach out on [Discord](https://discord.gg/xh7j7yF), [Reddit](https://www.reddit.com/r/navidrome/) and [Twitter](https://twitter.com/navidrome)! # Add the MSI installers to the release From 6e7fba9a29f7a58d1aa8acda3935e7464cb9086a Mon Sep 17 00:00:00 2001 From: Deluan Date: Tue, 9 Jun 2026 19:29:51 -0400 Subject: [PATCH 114/117] fix(server): make DB `PRAGMA optimize` error non-fatal Signed-off-by: Deluan --- db/db.go | 1 - 1 file changed, 1 deletion(-) diff --git a/db/db.go b/db/db.go index 168c12122..6e5b2f569 100644 --- a/db/db.go +++ b/db/db.go @@ -51,7 +51,6 @@ func Db() *sql.DB { _, err = db.Exec("PRAGMA optimize=0x10002") if err != nil { log.Error("Error applying PRAGMA optimize", err) - return nil } } return db From ded80522c1f7d1e27eb7106ffb2210e55ea38f2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Mrzyg=C5=82=C3=B3d?= <77896685+devBoi76@users.noreply.github.com> Date: Wed, 10 Jun 2026 22:47:54 +0200 Subject: [PATCH 115/117] fix(ui): Fix Nautiline theme font and width on mobile devices (#5590) Signed-off-by: devBoi76 --- ui/public/fonts/Unbounded-Variable.woff2 | Bin 50892 -> 259144 bytes ui/src/themes/nautiline.js | 1 - 2 files changed, 1 deletion(-) diff --git a/ui/public/fonts/Unbounded-Variable.woff2 b/ui/public/fonts/Unbounded-Variable.woff2 index 96d8ff5fa1426b92047112b5c3c7b02279dd523e..a2f74491f07bcf8019a62424d62a78f568882ed8 100644 GIT binary patch literal 259144 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 }DJEOzL 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 z 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 Rh|iUC@JA|Y5V=`7;1Hzsj>7z=F22P&~` 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 Date: Wed, 10 Jun 2026 21:12:31 -0400 Subject: [PATCH 116/117] fix(smartplaylist): support isMissing/isPresent operators on ReplayGain fields (#5585) * fix(smartplaylist): support isMissing/isPresent operators on ReplayGain fields ReplayGain values are stored in dedicated nullable columns (rg_album_gain, rg_album_peak, rg_track_gain, rg_track_peak) rather than in the media_file.tags JSON blob. The isMissing/isPresent operators previously only supported tag and role fields, causing two failure modes: 1. Using the documented alias names (replaygain_album_gain etc.) from PR #5256: these got registered as JSON tags from mappings.yaml, so isMissing queried json_tree(media_file.tags, '$.replaygain_album_gain') which is always empty -> the playlist matched ALL songs. 2. Using the canonical field names (rgalbumgain etc.): not a tag/role, so SQL generation returned an error. Because refreshSmartPlaylist deletes old tracks before regenerating, the abort left the playlist empty. Fix: add Nullable bool to FieldInfo and mark the four ReplayGain fields. Add static alias entries (replaygain_album_gain -> rgalbumgain etc.) with Numeric+Nullable set; because AddTagNames skips names already in the field map, these static entries take precedence over the mappings.yaml tag registration. missingExpr now emits IS NULL / IS NOT NULL for nullable column fields instead of the json_tree lookup. Fixes #5584 * chore(smartplaylist): address code review feedback - Simplify the isMissing/isPresent unsupported-field error message, removing the internal "nullable fields" jargon - Standardize comments on mappings.yaml (the actual filename) - Clarify the alias precedence comment in the LookupField test --- model/criteria/fields.go | 31 +++++++++------ model/criteria/fields_test.go | 22 +++++++++++ persistence/criteria_sql.go | 18 ++++++++- persistence/criteria_sql_test.go | 20 ++++++++-- persistence/e2e/e2e_suite_test.go | 22 +++++++---- persistence/e2e/smartplaylist_test.go | 57 +++++++++++++++++++++++++++ 6 files changed, 146 insertions(+), 24 deletions(-) diff --git a/model/criteria/fields.go b/model/criteria/fields.go index 9eafff7ab..36206712f 100644 --- a/model/criteria/fields.go +++ b/model/criteria/fields.go @@ -4,13 +4,14 @@ import "strings" // FieldInfo contains semantic metadata about a criteria field. type FieldInfo struct { - Alias string // If set, this field is a backward-compat alias for another canonical name - IsTag bool - IsRole bool - Numeric bool - Boolean bool + Alias string // If set, this field is a backward-compat alias for another canonical name + IsTag bool + IsRole bool + Numeric bool + Boolean bool + Nullable bool // If set, this column field can be NULL, so isMissing/isPresent are supported on it - tagAlias string // If set, a tag name from mappings.yml that resolves to this field + tagAlias string // If set, a tag name from mappings.yaml that resolves to this field name string // Canonical name, populated by LookupField from the map key } @@ -80,15 +81,23 @@ var fieldMap = map[string]FieldInfo{ "mbz_recording_id": {}, "mbz_release_track_id": {}, "mbz_release_group_id": {}, - "rgalbumgain": {Numeric: true}, - "rgalbumpeak": {Numeric: true}, - "rgtrackgain": {Numeric: true}, - "rgtrackpeak": {Numeric: true}, + "rgalbumgain": {Numeric: true, Nullable: true}, + "rgalbumpeak": {Numeric: true, Nullable: true}, + "rgtrackgain": {Numeric: true, Nullable: true}, + "rgtrackpeak": {Numeric: true, Nullable: true}, "library_id": {Numeric: true}, // Backward compatibility: albumtype is an alias for the releasetype tag. "albumtype": {Alias: "releasetype", IsTag: true}, + // Backward compatibility: the replaygain_* tag names (as written in metadata and in the + // PR #5256 example) are aliases for the canonical rg* column fields. Without these, the tag + // names would be registered as empty tags from mappings.yaml and isMissing would always match. + "replaygain_album_gain": {Alias: "rgalbumgain", Numeric: true, Nullable: true}, + "replaygain_album_peak": {Alias: "rgalbumpeak", Numeric: true, Nullable: true}, + "replaygain_track_gain": {Alias: "rgtrackgain", Numeric: true, Nullable: true}, + "replaygain_track_peak": {Alias: "rgtrackpeak", Numeric: true, Nullable: true}, + // Pseudo-field for random sorting "random": {}, } @@ -128,7 +137,7 @@ func AddRoles(roles []string) { } } -// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yml` +// AddTagNames adds tag names to the field map. This is used to add all tags mapped in the `mappings.yaml` // configuration file. func AddTagNames(tagNames []string) { for _, tagName := range tagNames { diff --git a/model/criteria/fields_test.go b/model/criteria/fields_test.go index 5b6f53341..3dc0c7b90 100644 --- a/model/criteria/fields_test.go +++ b/model/criteria/fields_test.go @@ -53,5 +53,27 @@ var _ = Describe("fields", func() { gomega.Expect(field.IsRole).To(gomega.BeTrue()) }) + It("marks ReplayGain column fields as nullable", func() { + field, ok := LookupField("rgAlbumGain") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name()).To(gomega.Equal("rgalbumgain")) + gomega.Expect(field.Nullable).To(gomega.BeTrue()) + gomega.Expect(field.IsTag).To(gomega.BeFalse()) + }) + + It("resolves replaygain_* tag names as aliases to nullable column fields", func() { + // AddTagNames skips names already in the field map, so the startup tag registration + // (from mappings.yaml) must not convert the pre-registered alias into a tag field. + AddTagNames([]string{"replaygain_album_gain"}) + + field, ok := LookupField("replaygain_album_gain") + + gomega.Expect(ok).To(gomega.BeTrue()) + gomega.Expect(field.Name()).To(gomega.Equal("rgalbumgain")) + gomega.Expect(field.Nullable).To(gomega.BeTrue()) + gomega.Expect(field.IsTag).To(gomega.BeFalse()) + }) + }) }) diff --git a/persistence/criteria_sql.go b/persistence/criteria_sql.go index 37e4ae340..ee0baca18 100644 --- a/persistence/criteria_sql.go +++ b/persistence/criteria_sql.go @@ -218,8 +218,8 @@ func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, er } return nil, fmt.Errorf("invalid field in criteria: %s", field) } - if !info.IsTag && !info.IsRole { - return nil, fmt.Errorf("isMissing/isPresent operator is only supported for tag and role fields, got: %s", field) + if !info.IsTag && !info.IsRole && !info.Nullable { + return nil, fmt.Errorf("isMissing/isPresent operator is not supported for field: %s", field) } b, ok := value.(bool) @@ -227,6 +227,20 @@ func missingExpr(values map[string]any, checkAbsence bool) (squirrel.Sqlizer, er return nil, fmt.Errorf("invalid boolean value for 'missing' expression: %s: %v", field, value) } negate := checkAbsence == b + + // Nullable column fields (e.g. ReplayGain) are stored in dedicated columns, not in the tags + // JSON, so "missing" maps to a NULL check on the column rather than a json_tree lookup. + if info.Nullable && !info.IsTag && !info.IsRole { + col, ok := fieldExpr(info.Name()) + if !ok || col == "" { + return nil, fmt.Errorf("invalid field in criteria: %s", field) + } + if negate { + return squirrel.Eq{col: nil}, nil + } + return squirrel.NotEq{col: nil}, nil + } + return jsonExpr(info, nil, negate), nil } diff --git a/persistence/criteria_sql_test.go b/persistence/criteria_sql_test.go index 5c8909e1c..8e801a703 100644 --- a/persistence/criteria_sql_test.go +++ b/persistence/criteria_sql_test.go @@ -14,7 +14,7 @@ import ( var _ = Describe("Smart playlist criteria SQL", func() { BeforeEach(func() { criteria.AddRoles([]string{"artist", "composer", "producer"}) - criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate"}) + criteria.AddTagNames([]string{"genre", "mood", "releasetype", "recordingdate", "replaygain_album_gain"}) criteria.AddNumericTags([]string{"rate"}) }) @@ -85,6 +85,20 @@ var _ = Describe("Smart playlist criteria SQL", func() { "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 media_file_artists mfa where mfa.media_file_id = media_file.id and mfa.role = ?)", "composer"), + // isMissing/isPresent — nullable column fields (ReplayGain) + Entry("isMissing rgAlbumGain [true]", criteria.IsMissing{"rgAlbumGain": true}, + "media_file.rg_album_gain IS NULL"), + Entry("isMissing rgAlbumGain [false]", criteria.IsMissing{"rgAlbumGain": false}, + "media_file.rg_album_gain IS NOT NULL"), + Entry("isPresent rgTrackPeak [true]", criteria.IsPresent{"rgTrackPeak": true}, + "media_file.rg_track_peak IS NOT NULL"), + Entry("isPresent rgTrackPeak [false]", criteria.IsPresent{"rgTrackPeak": false}, + "media_file.rg_track_peak IS NULL"), + // isMissing — replaygain_* tag-name alias resolves to the nullable column (issue #5584) + Entry("isMissing replaygain_album_gain alias [true]", criteria.IsMissing{"replaygain_album_gain": true}, + "media_file.rg_album_gain IS NULL"), + Entry("isPresent replaygain_album_gain alias [true]", criteria.IsPresent{"replaygain_album_gain": true}, + "media_file.rg_album_gain IS NOT NULL"), ) Describe("playlist permissions", func() { @@ -143,12 +157,12 @@ var _ = Describe("Smart playlist criteria SQL", func() { It("returns an error when isMissing is used with a regular field", func() { _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsMissing{"year": true}}).Where() - Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) + Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field"))) }) It("returns an error when isPresent is used with a regular field", func() { _, err := newSmartPlaylistCriteria(criteria.Criteria{Expression: criteria.IsPresent{"title": true}}).Where() - Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is only supported for tag and role fields"))) + Expect(err).To(MatchError(ContainSubstring("isMissing/isPresent operator is not supported for field"))) }) It("returns an error when isMissing has a non-boolean value", func() { diff --git a/persistence/e2e/e2e_suite_test.go b/persistence/e2e/e2e_suite_test.go index f42292f02..1ff2139e6 100644 --- a/persistence/e2e/e2e_suite_test.go +++ b/persistence/e2e/e2e_suite_test.go @@ -70,11 +70,13 @@ var ( func buildTestFS() { abbeyRoad := template(_t{ - "albumartist": "The Beatles", - "artist": "The Beatles", - "album": "Abbey Road", - "year": 1969, - "genre": "Rock;Blues", + "albumartist": "The Beatles", + "artist": "The Beatles", + "album": "Abbey Road", + "year": 1969, + "genre": "Rock;Blues", + "replaygain_album_gain": "-6.5 dB", + "replaygain_album_peak": "0.98", }) ledZepIV := template(_t{ "albumartist": "Led Zeppelin", @@ -116,12 +118,16 @@ func buildTestFS() { fs := storagetest.FakeFS{} fs.SetFiles(fstest.MapFS{ "Rock/The Beatles/Abbey Road/01 - Come Together.mp3": abbeyRoad(track(1, "Come Together", - _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks"})), + _t{"genre": "Rock;Blues", "composer": "Lennon/McCartney", "bpm": 120, "grouping": "Beatles Tracks", + "replaygain_track_gain": "-7.1 dB", "replaygain_track_peak": "0.95"})), "Rock/The Beatles/Abbey Road/02 - Something.mp3": abbeyRoad(track(2, "Something", - _t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks"})), + _t{"genre": "Rock", "composer": "Harrison", "bpm": 100, "grouping": "Beatles Tracks", + "replaygain_track_gain": "-6.0 dB", "replaygain_track_peak": "0.92"})), + // Stairway To Heaven has track gain but no album gain, to distinguish the two fields "Rock/Led Zeppelin/IV/01 - Stairway To Heaven.flac": ledZepIV(track(1, "Stairway To Heaven", _t{"genre": "Rock;Folk", "composer": "Page/Plant", "bpm": 82, "suffix": "flac", - "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), + "bitrate": 900, "samplerate": 44100, "bitdepth": 16, + "replaygain_track_gain": "-8.25 dB", "replaygain_track_peak": "0.99"})), "Rock/Led Zeppelin/IV/02 - Black Dog.flac": ledZepIV(track(2, "Black Dog", _t{"genre": "Rock;Blues", "composer": "Page/Plant/Jones", "bpm": 150, "suffix": "flac", "bitrate": 900, "samplerate": 44100, "bitdepth": 16})), diff --git a/persistence/e2e/smartplaylist_test.go b/persistence/e2e/smartplaylist_test.go index a844dc982..4966d3b8a 100644 --- a/persistence/e2e/smartplaylist_test.go +++ b/persistence/e2e/smartplaylist_test.go @@ -371,4 +371,61 @@ var _ = Describe("Smart Playlists", func() { Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower")) }) }) + + // ReplayGain values are stored in nullable media_file columns (not in the tags JSON), so + // isMissing/isPresent translate to IS [NOT] NULL checks on those columns (issue #5584). + Describe("isMissing/isPresent on ReplayGain fields", func() { + It("isMissing finds tracks without album gain", func() { + results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":true}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("isMissing false finds tracks with album gain", func() { + results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":false}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isPresent finds tracks with album gain", func() { + results := evaluateRule(`{"all":[{"isPresent":{"rgalbumgain":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isPresent finds tracks with album peak", func() { + results := evaluateRule(`{"all":[{"isPresent":{"rgalbumpeak":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("isMissing distinguishes track gain from album gain", func() { + results := evaluateRule(`{"all":[{"isMissing":{"rgtrackgain":true}}]}`) + Expect(results).To(ConsistOf("Black Dog", "So What", "Bohemian Rhapsody", + "All Along the Watchtower", "We Are the Champions")) + }) + + It("isPresent finds tracks with track gain", func() { + results := evaluateRule(`{"all":[{"isPresent":{"rgtrackgain":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven")) + }) + + It("resolves the replaygain_album_gain alias to the rgalbumgain column", func() { + results := evaluateRule(`{"all":[{"isMissing":{"replaygain_album_gain":true}}]}`) + Expect(results).To(ConsistOf("Stairway To Heaven", "Black Dog", "So What", + "Bohemian Rhapsody", "All Along the Watchtower", "We Are the Champions")) + }) + + It("resolves the replaygain_track_gain alias to the rgtrackgain column", func() { + results := evaluateRule(`{"all":[{"isPresent":{"replaygain_track_gain":true}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something", "Stairway To Heaven")) + }) + + It("supports numeric comparisons through the replaygain_* alias", func() { + results := evaluateRule(`{"all":[{"gt":{"replaygain_track_gain":-7.5}}]}`) + Expect(results).To(ConsistOf("Come Together", "Something")) + }) + + It("combines isMissing on ReplayGain with other operators", func() { + results := evaluateRule(`{"all":[{"isMissing":{"rgalbumgain":true}},{"is":{"genre":"Blues"}}]}`) + Expect(results).To(ConsistOf("Black Dog", "All Along the Watchtower")) + }) + }) }) From 2cd8076cf91d7bb426b1b7b7d12d41db767ea672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Deluan=20Quint=C3=A3o?= Date: Wed, 10 Jun 2026 23:15:58 -0400 Subject: [PATCH 117/117] refactor(stream): remove dead type branches from getIntClaim (#5594) The jwx library always deserializes numeric claims from a parsed token as float64, so the int and int64 branches in getIntClaim could never succeed and were dead code. Keep only the float64 path, which is the one actually exercised by the token round-trip, and update the comment to document the library behavior. --- core/stream/token.go | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/core/stream/token.go b/core/stream/token.go index 24a154b54..21a26ca93 100644 --- a/core/stream/token.go +++ b/core/stream/token.go @@ -92,17 +92,10 @@ func paramsFromToken(token jwt.Token) (*params, error) { return &p, nil } -// getIntClaim extracts an int claim from a JWT token, handling the case where -// the value may be stored as int64 or float64 (common in JSON-based JWT libraries). +// getIntClaim extracts a numeric claim from a JWT token. Numeric claims in a +// parsed token are always deserialized as float64, regardless of the type used +// when encoding. func getIntClaim(token jwt.Token, key string) int { - var v int - if err := token.Get(key, &v); err == nil { - return v - } - var v64 int64 - if err := token.Get(key, &v64); err == nil { - return int(v64) - } var f float64 if err := token.Get(key, &f); err == nil { return int(f)