From c2657e0adb6bb904eb37bb18afa99424e3480d17 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Wed, 30 Jul 2025 17:47:46 -0400
Subject: [PATCH 001/102] chore: add `make stop` target to terminate
development servers
Signed-off-by: Deluan
---
Makefile | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/Makefile b/Makefile
index 034015740..e30c9a32f 100644
--- a/Makefile
+++ b/Makefile
@@ -32,6 +32,14 @@ server: check_go_env buildjs ##@Development Start the backend in development mod
@ND_ENABLEINSIGHTSCOLLECTOR="false" go tool reflex -d none -c reflex.conf
.PHONY: server
+stop: ##@Development Stop development servers (UI and backend)
+ @echo "Stopping development servers..."
+ @-pkill -f "vite"
+ @-pkill -f "go tool reflex.*reflex.conf"
+ @-pkill -f "go run.*netgo"
+ @echo "Development servers stopped."
+.PHONY: stop
+
watch: ##@Development Start Go tests in watch mode (re-run when code changes)
go tool ginkgo watch -tags=netgo -notify ./...
.PHONY: watch
From 871ee730cd143910319d4e04403aeab5d981617d Mon Sep 17 00:00:00 2001
From: yanggqi <44476296+yanggqi@users.noreply.github.com>
Date: Fri, 1 Aug 2025 00:18:06 +0800
Subject: [PATCH 002/102] fix(ui): update Chinese simplified translation
(#4403)
* Update zh-Hans.json
Updated Chinese translation
* Update resources/i18n/zh-Hans.json
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update resources/i18n/zh-Hans.json
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update resources/i18n/zh-Hans.json
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update resources/i18n/zh-Hans.json
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update zh-Hans.json
* Update resources/i18n/zh-Hans.json
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* Update resources/i18n/zh-Hans.json
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
---
resources/i18n/zh-Hans.json | 137 ++++++++++++++++++++++++++++++++----
1 file changed, 125 insertions(+), 12 deletions(-)
diff --git a/resources/i18n/zh-Hans.json b/resources/i18n/zh-Hans.json
index c447f7d72..cde28c4f3 100644
--- a/resources/i18n/zh-Hans.json
+++ b/resources/i18n/zh-Hans.json
@@ -13,12 +13,14 @@
"album": "专辑",
"path": "文件路径",
"genre": "流派",
+ "libraryName": "媒体库",
"compilation": "合辑",
"year": "发行年份",
"size": "文件大小",
"updatedAt": "更新于",
"bitRate": "比特率",
"bitDepth": "比特深度",
+ "sampleRate": "采样率",
"channels": "声道",
"discSubtitle": "字幕",
"starred": "收藏",
@@ -33,12 +35,14 @@
"participants": "其他参与人员",
"tags": "附加标签",
"mappedTags": "映射标签",
- "rawTags": "原始标签"
+ "rawTags": "原始标签",
+ "missing": "缺失"
},
"actions": {
"addToQueue": "加入播放列表",
"playNow": "立即播放",
"addToPlaylist": "加入歌单",
+ "showInPlaylist": "定位到播放列表",
"shuffleAll": "全部随机播放",
"download": "下载",
"playNext": "下一首播放",
@@ -56,6 +60,7 @@
"size": "文件大小",
"name": "名称",
"genre": "流派",
+ "libraryName": "媒体库",
"compilation": "合辑",
"year": "发行年份",
"date": "录制日期",
@@ -72,7 +77,8 @@
"releaseType": "发行类型",
"grouping": "分组",
"media": "媒体类型",
- "mood": "情绪"
+ "mood": "情绪",
+ "missing": "缺失"
},
"actions": {
"playAll": "立即播放",
@@ -104,7 +110,8 @@
"playCount": "播放次数",
"rating": "评分",
"genre": "流派",
- "role": "参与角色"
+ "role": "参与角色",
+ "missing": "缺失"
},
"roles": {
"albumartist": "专辑歌手",
@@ -119,7 +126,13 @@
"mixer": "混音师",
"remixer": "重混师",
"djmixer": "DJ混音师",
- "performer": "演奏家"
+ "performer": "演奏家",
+ "maincredit": "主要艺术家"
+ },
+ "actions": {
+ "topSongs": "热门歌曲",
+ "shuffle": "随机播放",
+ "radio": "电台"
}
},
"user": {
@@ -136,19 +149,26 @@
"changePassword": "修改密码?",
"currentPassword": "当前密码",
"newPassword": "新密码",
- "token": "令牌"
+ "token": "令牌",
+ "libraries": "媒体库"
},
"helperTexts": {
- "name": "名称的更改将在下次登录时生效"
+ "name": "名称的更改将在下次登录时生效",
+ "libraries": "为该用户选择指定媒体库,留空则使用默认媒体库"
},
"notifications": {
"created": "用户已创建",
"updated": "用户已更新",
"deleted": "用户已删除"
},
+ "validation": {
+ "librariesRequired": "普通用户必须至少选择一个媒体库"
+ },
"message": {
"listenBrainzToken": "输入您的 ListenBrainz 用户令牌",
- "clickHereForToken": "点击这里来获得你的 ListenBrainz 令牌"
+ "clickHereForToken": "点击这里来获得你的 ListenBrainz 令牌",
+ "selectAllLibraries": "选择全部媒体库",
+ "adminAutoLibraries": "管理员默认可访问所有媒体库"
}
},
"player": {
@@ -191,12 +211,18 @@
"selectPlaylist": "选择歌单",
"addNewPlaylist": "新建 %{name}",
"export": "导出",
+ "saveQueue": "保存为歌单",
"makePublic": "设为公开",
- "makePrivate": "设为私有"
+ "makePrivate": "设为私有",
+ "searchOrCreate": "搜索歌单,或输入名称新建…",
+ "pressEnterToCreate": "按 Enter 键新建歌单",
+ "removeFromSelection": "移除选中项"
},
"message": {
"duplicate_song": "添加重复的歌曲",
- "song_exist": "部分选定的歌曲已存在歌单中,继续添加或是跳过它们?"
+ "song_exist": "部分选定的歌曲已存在歌单中,继续添加或是跳过它们?",
+ "noPlaylistsFound": "未找到歌单",
+ "noPlaylists": "暂无可用歌单"
}
},
"radio": {
@@ -237,14 +263,68 @@
"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": "扫描媒体库",
+ "manageUsers": "管理用户权限",
+ "viewDetails": "查看详情"
+ },
+ "notifications": {
+ "created": "媒体库已创建",
+ "updated": "媒体库已更新",
+ "deleted": "媒体库已删除",
+ "scanStarted": "开始扫描媒体库",
+ "scanCompleted": "媒体库扫描已完成"
+ },
+ "validation": {
+ "nameRequired": "媒体库名称不能为空!",
+ "pathRequired": "媒体库路径不能为空!",
+ "pathNotDirectory": "媒体库路径必须为目录!",
+ "pathNotFound": "媒体库路径不存在!",
+ "pathNotAccessible": "媒体库路径无法访问!",
+ "pathInvalid": "媒体库路径无效!"
+ },
+ "messages": {
+ "deleteConfirm": "您确定要删除此媒体库吗?此操作将删除所有关联数据及用户访问权限!",
+ "scanInProgress": "正在扫描...",
+ "noLibrariesAssigned": "该用户未分配任何媒体库!"
+ }
}
},
"ra": {
@@ -397,11 +477,15 @@
"transcodingDisabled": "出于安全原因,从 Web 界面更改转码配置的功能已被禁用。要更改(编辑或新增)转码选项,请在启用 %{config} 选项的情况下重新启动服务器。",
"transcodingEnabled": "Navidrome 当前与 %{config} 一起使用,可以通过配置转码选项来执行任意命令,建议仅在配置转码选项时启用此功能。",
"songsAddedToPlaylist": "已添加 %{smart_count} 首歌到歌单",
+ "noSimilarSongsFound": "未找到相似歌曲",
+ "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": "此浏览器不支持桌面通知",
"lastfmLinkSuccess": "Last.fm 已关联并启用喜好记录",
@@ -428,6 +512,12 @@
},
"menu": {
"library": "曲库",
+ "librarySelector": {
+ "allLibraries": "全部媒体库 (%{count})",
+ "multipleLibraries": "已选 %{selected} 共 %{total} 媒体库",
+ "selectLibraries": "选择媒体库",
+ "none": "无"
+ },
"settings": "设置",
"version": "版本",
"theme": "主题",
@@ -490,6 +580,21 @@
"disabled": "禁用",
"waiting": "等待"
}
+ },
+ "tabs": {
+ "about": "关于",
+ "config": "配置"
+ },
+ "config": {
+ "configName": "配置名称",
+ "environmentVariable": "环境变量",
+ "currentValue": "当前值",
+ "configurationFile": "配置文件",
+ "exportToml": "导出配置(TOML)",
+ "exportSuccess": "配置以 TOML 格式导出到剪贴板",
+ "exportFailed": "复制配置失败",
+ "devFlagsHeader": "开发标志(可能会更改/删除)",
+ "devFlagsComment": "这些是实验性设置,可能会在未来版本中删除"
}
},
"activity": {
@@ -498,7 +603,15 @@
"quickScan": "快速扫描",
"fullScan": "完全扫描",
"serverUptime": "服务器已运行",
- "serverDown": "服务器已离线"
+ "serverDown": "服务器已离线",
+ "scanType": "扫描类型",
+ "status": "扫描状态",
+ "elapsedTime": "用时"
+ },
+ "nowPlaying": {
+ "title": "正在播放",
+ "empty": "无播放内容",
+ "minutesAgo": "%{smart_count} 分钟前"
},
"help": {
"title": "Navidrome 快捷键",
@@ -514,4 +627,4 @@
"toggle_love": "添加/移除星标"
}
}
-}
\ No newline at end of file
+}
From b2019da9999165dee92d1a9ecf6c1c1034c197db Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Sat, 25 Oct 2025 17:05:16 -0400
Subject: [PATCH 003/102] chore(deps): update all dependencies (#4618)
* chore: update to Go 1.25.3
Signed-off-by: Deluan
* chore: update to golangci-lint
Signed-off-by: Deluan
* chore: update go dependencies
Signed-off-by: Deluan
* chore: update vite dependencies in package.json and improve EventSource mock in tests
- Upgraded @vitejs/plugin-react to version 5.1.0 and @vitest/coverage-v8 to version 4.0.3.
- Updated vite to version 7.1.12 and vite-plugin-pwa to version 1.1.0.
- Enhanced the EventSource mock implementation in eventStream.test.js for better test isolation.
* ci: remove coverage flag from Go test command in pipeline
* chore: update Node.js version to v24 in devcontainer, pipeline, and .nvmrc
* chore: prettier
Signed-off-by: Deluan
* chore: update actions/checkout from v4 to v5 in pipeline and update-translations workflows
* chore: update JS dependencies remove unused jest-dom import in Linkify.test.jsx
* chore: update actions/download-artifact from v4 to v5 in pipeline
---------
Signed-off-by: Deluan
---
.devcontainer/devcontainer.json | 4 +-
.github/workflows/pipeline.yml | 32 +-
.github/workflows/update-translations.yml | 2 +-
.nvmrc | 2 +-
Dockerfile | 2 +-
Makefile | 2 +-
go.mod | 71 +-
go.sum | 183 ++--
scanner/walk_dir_tree_test.go | 2 +-
ui/package-lock.json | 1151 +++++++--------------
ui/package.json | 26 +-
ui/src/common/Linkify.test.jsx | 1 -
ui/src/eventStream.test.js | 2 +-
13 files changed, 566 insertions(+), 914 deletions(-)
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index f339f62f7..ff58994db 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -4,10 +4,10 @@
"dockerfile": "Dockerfile",
"args": {
// Update the VARIANT arg to pick a version of Go: 1, 1.15, 1.14
- "VARIANT": "1.24",
+ "VARIANT": "1.25",
// Options
"INSTALL_NODE": "true",
- "NODE_VERSION": "v20"
+ "NODE_VERSION": "v24"
}
},
"workspaceMount": "",
diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml
index 9488f20f7..232171c6d 100644
--- a/.github/workflows/pipeline.yml
+++ b/.github/workflows/pipeline.yml
@@ -25,7 +25,7 @@ jobs:
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
fetch-depth: 0
fetch-tags: true
@@ -63,7 +63,7 @@ jobs:
name: Lint Go code
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Download TagLib
uses: ./.github/actions/download-taglib
@@ -93,7 +93,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Download TagLib
uses: ./.github/actions/download-taglib
@@ -106,7 +106,7 @@ jobs:
- name: Test
run: |
pkg-config --define-prefix --cflags --libs taglib # for debugging
- go test -shuffle=on -tags netgo -race -cover ./... -v
+ go test -shuffle=on -tags netgo -race ./... -v
js:
name: Test JS code
@@ -114,10 +114,10 @@ jobs:
env:
NODE_OPTIONS: "--max_old_space_size=4096"
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@v5
+ - uses: actions/setup-node@v6
with:
- node-version: 20
+ node-version: 24
cache: "npm"
cache-dependency-path: "**/package-lock.json"
@@ -145,7 +145,7 @@ jobs:
name: Lint i18n files
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- run: |
set -e
for file in resources/i18n/*.json; do
@@ -191,7 +191,7 @@ jobs:
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
@@ -264,10 +264,10 @@ jobs:
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Download digests
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v5
with:
path: /tmp/digests
pattern: digests-*
@@ -318,9 +318,9 @@ jobs:
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v5
with:
path: ./binaries
pattern: navidrome-windows*
@@ -352,12 +352,12 @@ jobs:
outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
with:
fetch-depth: 0
fetch-tags: true
- - uses: actions/download-artifact@v4
+ - uses: actions/download-artifact@v5
with:
path: ./binaries
pattern: navidrome-*
@@ -406,7 +406,7 @@ jobs:
item: ${{ fromJson(needs.release.outputs.package_list) }}
steps:
- name: Download all-packages artifact
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v5
with:
name: packages
path: ./dist
diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml
index 70a9de3d8..69ca1cc94 100644
--- a/.github/workflows/update-translations.yml
+++ b/.github/workflows/update-translations.yml
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Get updated translations
id: poeditor
env:
diff --git a/.nvmrc b/.nvmrc
index 9a2a0e219..54c65116f 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-v20
+v24
diff --git a/Dockerfile b/Dockerfile
index ec3b6d938..eeb270e00 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -61,7 +61,7 @@ COPY --from=ui /build /build
########################################################################################################################
### Build Navidrome binary
-FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.24-bookworm AS base
+FROM --platform=$BUILDPLATFORM public.ecr.aws/docker/library/golang:1.25-bookworm AS base
RUN apt-get update && apt-get install -y clang lld
COPY --from=xx / /
WORKDIR /workspace
diff --git a/Makefile b/Makefile
index e30c9a32f..a4ba45ae0 100644
--- a/Makefile
+++ b/Makefile
@@ -65,7 +65,7 @@ test-i18n: ##@Development Validate all translations files
.PHONY: test-i18n
install-golangci-lint: ##@Development Install golangci-lint if not present
- @PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.1.6)
+ @PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.5.0)
.PHONY: install-golangci-lint
lint: install-golangci-lint ##@Development Lint Go code
diff --git a/go.mod b/go.mod
index e1a827f1d..265cbfa6d 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/navidrome/navidrome
-go 1.24.5
+go 1.25.3
// Fork to fix https://github.com/navidrome/navidrome/pull/3254
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
@@ -9,7 +9,7 @@ require (
github.com/Masterminds/squirrel v1.5.4
github.com/RaveNoX/go-jsoncommentstrip v1.0.0
github.com/andybalholm/cascadia v1.3.3
- github.com/bmatcuk/doublestar/v4 v4.9.0
+ github.com/bmatcuk/doublestar/v4 v4.9.1
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
@@ -22,15 +22,15 @@ require (
github.com/djherbis/times v1.6.0
github.com/dustin/go-humanize v1.0.1
github.com/fatih/structs v1.1.0
- github.com/go-chi/chi/v5 v5.2.2
+ github.com/go-chi/chi/v5 v5.2.3
github.com/go-chi/cors v1.2.2
github.com/go-chi/httprate v0.15.0
github.com/go-chi/jwtauth/v5 v5.3.3
github.com/go-viper/encoding/ini v0.1.1
- github.com/gohugoio/hashstructure v0.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
- github.com/google/wire v0.6.0
+ github.com/google/wire v0.7.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/go-multierror v1.1.1
github.com/jellydator/ttlcache/v3 v3.4.0
@@ -40,39 +40,40 @@ require (
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.1.6
github.com/matoous/go-nanoid/v2 v2.1.0
- github.com/mattn/go-sqlite3 v1.14.29
+ github.com/mattn/go-sqlite3 v1.14.32
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
- github.com/onsi/ginkgo/v2 v2.23.4
- github.com/onsi/gomega v1.38.0
+ github.com/onsi/ginkgo/v2 v2.27.1
+ github.com/onsi/gomega v1.38.2
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pocketbase/dbx v1.11.0
- github.com/pressly/goose/v3 v3.24.3
- github.com/prometheus/client_golang v1.22.0
+ github.com/pressly/goose/v3 v3.26.0
+ github.com/prometheus/client_golang v1.23.2
github.com/rjeczalik/notify v0.9.3
github.com/robfig/cron/v3 v3.0.1
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/sirupsen/logrus v1.9.3
- github.com/spf13/cobra v1.9.1
- github.com/spf13/viper v1.20.1
- github.com/stretchr/testify v1.10.0
+ github.com/spf13/cobra v1.10.1
+ github.com/spf13/viper v1.21.0
+ github.com/stretchr/testify v1.11.1
github.com/tetratelabs/wazero v1.9.0
github.com/unrolled/secure v1.17.0
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
go.uber.org/goleak v1.3.0
- golang.org/x/exp v0.0.0-20250718183923-645b1fa84792
- golang.org/x/image v0.29.0
- golang.org/x/net v0.42.0
- golang.org/x/sync v0.16.0
- golang.org/x/sys v0.34.0
- golang.org/x/text v0.27.0
- golang.org/x/time v0.12.0
- google.golang.org/protobuf v1.36.6
+ golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
+ golang.org/x/image v0.32.0
+ golang.org/x/net v0.46.0
+ golang.org/x/sync v0.17.0
+ golang.org/x/sys v0.37.0
+ golang.org/x/text v0.30.0
+ golang.org/x/time v0.14.0
+ google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v3 v3.0.1
)
require (
dario.cat/mergo v1.0.2 // indirect
+ github.com/Masterminds/semver/v3 v3.4.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
@@ -86,9 +87,9 @@ require (
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
- github.com/goccy/go-yaml v1.17.1 // indirect
+ github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
- github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5 // indirect
+ github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d // 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
@@ -108,27 +109,29 @@ require (
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.1 // indirect
- github.com/prometheus/common v0.62.0 // indirect
+ github.com/prometheus/client_model v0.6.2 // indirect
+ github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
- github.com/sagikazarmark/locafero v0.9.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.0 // indirect
+ github.com/segmentio/asm v1.2.1 // indirect
github.com/sethvargo/go-retry v0.3.0 // indirect
github.com/sosodev/duration v1.3.1 // indirect
- github.com/sourcegraph/conc v0.3.0 // indirect
- github.com/spf13/afero v1.14.0 // indirect
- github.com/spf13/cast v1.9.2 // indirect
- github.com/spf13/pflag v1.0.7 // indirect
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
+ github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
- golang.org/x/crypto v0.40.0 // indirect
- golang.org/x/mod v0.26.0 // indirect
- golang.org/x/tools v0.35.0 // indirect
+ go.yaml.in/yaml/v2 v2.4.2 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
+ golang.org/x/crypto v0.43.0 // indirect
+ golang.org/x/mod v0.29.0 // indirect
+ golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect
+ golang.org/x/tools v0.38.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
)
diff --git a/go.sum b/go.sum
index 36558f264..f9e620fb2 100644
--- a/go.sum
+++ b/go.sum
@@ -2,6 +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.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
+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/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/RaveNoX/go-jsoncommentstrip v1.0.0 h1:t527LHHE3HmiHrq74QMpNPZpGCIJzTx+apLkMKt4HC0=
@@ -14,8 +16,8 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
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.9.0 h1:DBvuZxjdKkRP/dr4GVV4w2fnmrk5Hxc90T51LZjv0JA=
-github.com/bmatcuk/doublestar/v4 v4.9.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
+github.com/bmatcuk/doublestar/v4 v4.9.1 h1:X8jg9rRZmJd4yRy7ZeNDRnM+T3ZfHv15JiBJ/avrEXE=
+github.com/bmatcuk/doublestar/v4 v4.9.1/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=
@@ -60,8 +62,14 @@ github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7z
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/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
-github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
+github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
+github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
+github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
+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.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
+github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
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=
@@ -71,8 +79,8 @@ github.com/go-chi/jwtauth/v5 v5.3.3/go.mod h1:O4QvPRuZLZghl9WvfVaON+ARfGzpD2PBX/
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
-github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU=
-github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
+github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
+github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
github.com/go-viper/encoding/ini v0.1.1 h1:MVWY7B2XNw7lnOqHutGRc97bF3rP7omOdgjdMPAJgbs=
@@ -81,25 +89,24 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
-github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
-github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
-github.com/gohugoio/hashstructure v0.5.0 h1:G2fjSBU36RdwEJBWJ+919ERvOVqAg9tfcYp47K9swqg=
-github.com/gohugoio/hashstructure v0.5.0/go.mod h1:Ser0TniXuu/eauYmrwM4o64EBvySxNzITEOLlm4igec=
+github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
+github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg=
+github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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-20250630185457-6e76a2b096b5 h1:xhMrHhTJ6zxu3gA4enFM9MLn9AY7613teCdFnlUVbSQ=
-github.com/google/pprof v0.0.0-20250630185457-6e76a2b096b5/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
+github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0=
+github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
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=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
-github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
+github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
+github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
@@ -115,6 +122,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY=
github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4=
+github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
+github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kardianos/service v1.2.4 h1:XNlGtZOYNx2u91urOdg/Kfmc+gfmuIo1Dd3rEi2OgBk=
@@ -153,14 +162,18 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
+github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
+github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
-github.com/mattn/go-sqlite3 v1.14.29 h1:1O6nRLJKvsi1H2Sj0Hzdfojwt8GiGKm+LOfLaBFaouQ=
-github.com/mattn/go-sqlite3 v1.14.29/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
+github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
+github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
+github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
@@ -173,10 +186,10 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/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.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
-github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
-github.com/onsi/gomega v1.38.0 h1:c/WX+w8SLAinvuKKQFh77WEucCnPk4j2OTUr7lt7BeY=
-github.com/onsi/gomega v1.38.0/go.mod h1:OcXcwId0b9QsE7Y49u+BTrL4IdKOBOKnD6VQNTJEB6o=
+github.com/onsi/ginkgo/v2 v2.27.1 h1:0LJC8MpUSQnfnp4n/3W3GdlmJP3ENGF0ZPzjQGLPP7s=
+github.com/onsi/ginkgo/v2 v2.27.1/go.mod h1:wmy3vCqiBjirARfVhAqFpYt8uvX0yaFe+GudAqqcCqA=
+github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
+github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
@@ -190,14 +203,14 @@ github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
-github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
-github.com/pressly/goose/v3 v3.24.3/go.mod h1:v9zYL4xdViLHCUUJh/mhjnm6JrK7Eul8AS93IxiZM4E=
-github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
-github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
-github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
-github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
-github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
-github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
+github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
+github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
+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.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
+github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -212,12 +225,12 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
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=
-github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
-github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
+github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
+github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/sanity-io/litter v1.5.8 h1:uM/2lKrWdGbRXDrIq08Lh9XtVYoeGtcQxk9rtQ7+rYg=
github.com/sanity-io/litter v1.5.8/go.mod h1:9gzJgR2i4ZpjZHsKvUXIRQVk7P+yM3e+jAF7bU2UI5U=
-github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
-github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
+github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
+github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE=
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
@@ -229,19 +242,17 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4=
github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg=
-github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
-github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
-github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
-github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
-github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
-github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
-github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
-github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
-github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M=
-github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
-github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
+github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
+github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
+github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
+github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
@@ -252,12 +263,20 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
-github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
+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=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/unrolled/secure v1.17.0 h1:Io7ifFgo99Bnh0J7+Q+qcMzWM6kaDPCA5FroFZEdbWU=
github.com/unrolled/secure v1.17.0/go.mod h1:BmF5hyM6tXczk3MpQkFf1hpKSRqCyhqcbiQtiAF7+40=
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAzt5X7s6266i6cSVkkFPS0TuXWbIg=
@@ -273,28 +292,30 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
+go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
+go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
-golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
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.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
-golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
-golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4=
-golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
+golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
+golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas=
-golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA=
+golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
+golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
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.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
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.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
-golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ=
+golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
+golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
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=
@@ -303,12 +324,11 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
-golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
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.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
-golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
+golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
+golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
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=
@@ -316,8 +336,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
-golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -331,19 +351,19 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
-golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
+golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU=
+golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=
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=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
-golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
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=
@@ -357,24 +377,23 @@ 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.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
-golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
-golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
-golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
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.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
-golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
-golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
+golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
+golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
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.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
-google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
+google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
+google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
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=
@@ -386,11 +405,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.65.0 h1:e183gLDnAp9VJh6gWKdTy0CThL9Pt7MfcR/0bgb7Y1Y=
-modernc.org/libc v1.65.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
+modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
+modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
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.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
-modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
-modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
-modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
+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.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
+modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go
index c4278ef82..1cab8a0b7 100644
--- a/scanner/walk_dir_tree_test.go
+++ b/scanner/walk_dir_tree_test.go
@@ -42,7 +42,7 @@ var _ = Describe("walk_dir_tree", func() {
"root/d/f2.mp3": {},
"root/d/f3.mp3": {},
"root/e/original/f1.mp3": {},
- "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("root/e/original")},
+ "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("original")},
},
}
job = &scanJob{
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 9e449c5e0..e9161739f 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -9,7 +9,7 @@
"dependencies": {
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3",
- "@material-ui/lab": "^4.0.0-alpha.58",
+ "@material-ui/lab": "^4.0.0-alpha.61",
"@material-ui/styles": "^4.11.5",
"blueimp-md5": "^2.19.0",
"clsx": "^2.1.1",
@@ -37,8 +37,8 @@
"react-redux": "^7.2.9",
"react-router-dom": "^5.3.4",
"redux": "^4.2.1",
- "redux-saga": "^1.3.0",
- "uuid": "^11.1.0",
+ "redux-saga": "^1.4.2",
+ "uuid": "^13.0.0",
"workbox-cli": "^7.3.0"
},
"devDependencies": {
@@ -46,46 +46,35 @@
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^7.0.2",
"@testing-library/user-event": "^14.6.1",
- "@types/node": "^22.15.21",
- "@types/react": "^17.0.86",
+ "@types/node": "^24.9.1",
+ "@types/react": "^17.0.89",
"@types/react-dom": "^17.0.26",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
- "@vitejs/plugin-react": "^4.5.0",
- "@vitest/coverage-v8": "^3.1.4",
+ "@vitejs/plugin-react": "^5.1.0",
+ "@vitest/coverage-v8": "^4.0.3",
"eslint": "^8.57.1",
- "eslint-config-prettier": "^10.1.5",
+ "eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
- "eslint-plugin-react-refresh": "^0.4.20",
+ "eslint-plugin-react-refresh": "^0.4.24",
"happy-dom": "^17.4.7",
"jsdom": "^26.1.0",
- "prettier": "^3.5.3",
+ "prettier": "^3.6.2",
"ra-test": "^3.19.12",
"typescript": "^5.8.3",
- "vite": "^6.3.5",
- "vite-plugin-pwa": "^0.21.2",
- "vitest": "^3.1.4"
+ "vite": "^7.1.12",
+ "vite-plugin-pwa": "^1.1.0",
+ "vitest": "^4.0.3"
}
},
"node_modules/@adobe/css-tools": {
- "version": "4.4.3",
- "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz",
- "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==",
- "dev": true
- },
- "node_modules/@ampproject/remapping": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
- "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
- "dependencies": {
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.24"
- },
- "engines": {
- "node": ">=6.0.0"
- }
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@asamuzakjp/css-color": {
"version": "3.2.0",
@@ -128,20 +117,20 @@
}
},
"node_modules/@babel/core": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.1.tgz",
- "integrity": "sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
+ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dependencies": {
- "@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.27.1",
- "@babel/helper-compilation-targets": "^7.27.1",
- "@babel/helper-module-transforms": "^7.27.1",
- "@babel/helpers": "^7.27.1",
- "@babel/parser": "^7.27.1",
- "@babel/template": "^7.27.1",
- "@babel/traverse": "^7.27.1",
- "@babel/types": "^7.27.1",
+ "@babel/generator": "^7.28.5",
+ "@babel/helper-compilation-targets": "^7.27.2",
+ "@babel/helper-module-transforms": "^7.28.3",
+ "@babel/helpers": "^7.28.4",
+ "@babel/parser": "^7.28.5",
+ "@babel/template": "^7.27.2",
+ "@babel/traverse": "^7.28.5",
+ "@babel/types": "^7.28.5",
+ "@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -165,14 +154,14 @@
}
},
"node_modules/@babel/generator": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.1.tgz",
- "integrity": "sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz",
+ "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==",
"dependencies": {
- "@babel/parser": "^7.27.1",
- "@babel/types": "^7.27.1",
- "@jridgewell/gen-mapping": "^0.3.5",
- "@jridgewell/trace-mapping": "^0.3.25",
+ "@babel/parser": "^7.28.5",
+ "@babel/types": "^7.28.5",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
"jsesc": "^3.0.2"
},
"engines": {
@@ -299,6 +288,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/@babel/helper-globals": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
+ "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
"node_modules/@babel/helper-member-expression-to-functions": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz",
@@ -324,13 +321,13 @@
}
},
"node_modules/@babel/helper-module-transforms": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz",
- "integrity": "sha512-9yHn519/8KvTU5BjTVEEeIM3w9/2yXNKoD82JifINImhpKkARMJKPP59kLo+BafpdN5zgNeIcS4jsGDmd3l58g==",
+ "version": "7.28.3",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz",
+ "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==",
"dependencies": {
"@babel/helper-module-imports": "^7.27.1",
"@babel/helper-validator-identifier": "^7.27.1",
- "@babel/traverse": "^7.27.1"
+ "@babel/traverse": "^7.28.3"
},
"engines": {
"node": ">=6.9.0"
@@ -411,9 +408,9 @@
}
},
"node_modules/@babel/helper-validator-identifier": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz",
- "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"engines": {
"node": ">=6.9.0"
}
@@ -440,23 +437,23 @@
}
},
"node_modules/@babel/helpers": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz",
- "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz",
+ "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==",
"dependencies": {
- "@babel/template": "^7.27.1",
- "@babel/types": "^7.27.1"
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.4"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
- "version": "7.27.2",
- "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.2.tgz",
- "integrity": "sha512-QYLs8299NA7WM/bZAdp+CviYYkVoYXlDW2rzliy3chxd1PQjej7JORuMJDJXJUb9g0TT+B99EwaVLKmX+sPXWw==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz",
+ "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
"dependencies": {
- "@babel/types": "^7.27.1"
+ "@babel/types": "^7.28.5"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -1464,9 +1461,10 @@
}
},
"node_modules/@babel/runtime": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz",
- "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==",
+ "version": "7.28.4",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
+ "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
+ "license": "MIT",
"engines": {
"node": ">=6.9.0"
}
@@ -1497,29 +1495,29 @@
}
},
"node_modules/@babel/traverse": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.1.tgz",
- "integrity": "sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz",
+ "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==",
"dependencies": {
"@babel/code-frame": "^7.27.1",
- "@babel/generator": "^7.27.1",
- "@babel/parser": "^7.27.1",
- "@babel/template": "^7.27.1",
- "@babel/types": "^7.27.1",
- "debug": "^4.3.1",
- "globals": "^11.1.0"
+ "@babel/generator": "^7.28.5",
+ "@babel/helper-globals": "^7.28.0",
+ "@babel/parser": "^7.28.5",
+ "@babel/template": "^7.27.2",
+ "@babel/types": "^7.28.5",
+ "debug": "^4.3.1"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/types": {
- "version": "7.27.1",
- "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz",
- "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==",
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz",
+ "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
- "@babel/helper-validator-identifier": "^7.27.1"
+ "@babel/helper-validator-identifier": "^7.28.5"
},
"engines": {
"node": ">=6.9.0"
@@ -2214,76 +2212,6 @@
"deprecated": "Use @eslint/object-schema instead",
"dev": true
},
- "node_modules/@isaacs/cliui": {
- "version": "8.0.2",
- "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
- "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
- "dev": true,
- "dependencies": {
- "string-width": "^5.1.2",
- "string-width-cjs": "npm:string-width@^4.2.0",
- "strip-ansi": "^7.0.1",
- "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
- "wrap-ansi": "^8.1.0",
- "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
- },
- "engines": {
- "node": ">=12"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
- "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
- "dev": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-regex?sponsor=1"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/string-width": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
- "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
- "dev": true,
- "dependencies": {
- "eastasianwidth": "^0.2.0",
- "emoji-regex": "^9.2.2",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
- "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^6.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/strip-ansi?sponsor=1"
- }
- },
- "node_modules/@istanbuljs/schema": {
- "version": "0.1.3",
- "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
- "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/@jest/types": {
"version": "26.6.2",
"resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz",
@@ -2301,16 +2229,21 @@
}
},
"node_modules/@jridgewell/gen-mapping": {
- "version": "0.3.8",
- "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
- "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==",
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
"dependencies": {
- "@jridgewell/set-array": "^1.2.1",
- "@jridgewell/sourcemap-codec": "^1.4.10",
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
- },
- "engines": {
- "node": ">=6.0.0"
}
},
"node_modules/@jridgewell/resolve-uri": {
@@ -2321,14 +2254,6 @@
"node": ">=6.0.0"
}
},
- "node_modules/@jridgewell/set-array": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz",
- "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==",
- "engines": {
- "node": ">=6.0.0"
- }
- },
"node_modules/@jridgewell/source-map": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz",
@@ -2339,14 +2264,14 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
- "version": "1.5.0",
- "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
- "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ=="
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="
},
"node_modules/@jridgewell/trace-mapping": {
- "version": "0.3.25",
- "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz",
- "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==",
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
@@ -2596,16 +2521,6 @@
"node": ">= 8"
}
},
- "node_modules/@pkgjs/parseargs": {
- "version": "0.11.0",
- "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
- "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
- "dev": true,
- "optional": true,
- "engines": {
- "node": ">=14"
- }
- },
"node_modules/@react-dnd/asap": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz",
@@ -2630,16 +2545,17 @@
}
},
"node_modules/@redux-saga/core": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.3.0.tgz",
- "integrity": "sha512-L+i+qIGuyWn7CIg7k1MteHGfttKPmxwZR5E7OsGikCL2LzYA0RERlaUY00Y3P3ZV2EYgrsYlBrGs6cJP5OKKqA==",
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/@redux-saga/core/-/core-1.4.2.tgz",
+ "integrity": "sha512-nIMLGKo6jV6Wc1sqtVQs1iqbB3Kq20udB/u9XEaZQisT6YZ0NRB8+4L6WqD/E+YziYutd27NJbG8EWUPkb7c6Q==",
+ "license": "MIT",
"dependencies": {
- "@babel/runtime": "^7.6.3",
- "@redux-saga/deferred": "^1.2.1",
- "@redux-saga/delay-p": "^1.2.1",
- "@redux-saga/is": "^1.1.3",
- "@redux-saga/symbols": "^1.1.3",
- "@redux-saga/types": "^1.2.1",
+ "@babel/runtime": "^7.28.4",
+ "@redux-saga/deferred": "^1.3.1",
+ "@redux-saga/delay-p": "^1.3.1",
+ "@redux-saga/is": "^1.2.1",
+ "@redux-saga/symbols": "^1.2.1",
+ "@redux-saga/types": "^1.3.1",
"typescript-tuple": "^2.2.1"
},
"funding": {
@@ -2648,41 +2564,46 @@
}
},
"node_modules/@redux-saga/deferred": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.2.1.tgz",
- "integrity": "sha512-cmin3IuuzMdfQjA0lG4B+jX+9HdTgHZZ+6u3jRAOwGUxy77GSlTi4Qp2d6PM1PUoTmQUR5aijlA39scWWPF31g=="
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@redux-saga/deferred/-/deferred-1.3.1.tgz",
+ "integrity": "sha512-0YZ4DUivWojXBqLB/TmuRRpDDz7tyq1I0AuDV7qi01XlLhM5m51W7+xYtIckH5U2cMlv9eAuicsfRAi1XHpXIg==",
+ "license": "MIT"
},
"node_modules/@redux-saga/delay-p": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.2.1.tgz",
- "integrity": "sha512-MdiDxZdvb1m+Y0s4/hgdcAXntpUytr9g0hpcOO1XFVyyzkrDu3SKPgBFOtHn7lhu7n24ZKIAT1qtKyQjHqRd+w==",
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@redux-saga/delay-p/-/delay-p-1.3.1.tgz",
+ "integrity": "sha512-597I7L5MXbD/1i3EmcaOOjL/5suxJD7p5tnbV1PiWnE28c2cYiIHqmSMK2s7us2/UrhOL2KTNBiD0qBg6KnImg==",
+ "license": "MIT",
"dependencies": {
- "@redux-saga/symbols": "^1.1.3"
+ "@redux-saga/symbols": "^1.2.1"
}
},
"node_modules/@redux-saga/is": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.1.3.tgz",
- "integrity": "sha512-naXrkETG1jLRfVfhOx/ZdLj0EyAzHYbgJWkXbB3qFliPcHKiWbv/ULQryOAEKyjrhiclmr6AMdgsXFyx7/yE6Q==",
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@redux-saga/is/-/is-1.2.1.tgz",
+ "integrity": "sha512-x3aWtX3GmQfEvn8dh0ovPbsXgK9JjpiR24wKztpGbZP8JZUWWvUgKrvnWZ/T/4iphOBftyVc9VrIwhAnsM+OFA==",
+ "license": "MIT",
"dependencies": {
- "@redux-saga/symbols": "^1.1.3",
- "@redux-saga/types": "^1.2.1"
+ "@redux-saga/symbols": "^1.2.1",
+ "@redux-saga/types": "^1.3.1"
}
},
"node_modules/@redux-saga/symbols": {
- "version": "1.1.3",
- "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.1.3.tgz",
- "integrity": "sha512-hCx6ZvU4QAEUojETnX8EVg4ubNLBFl1Lps4j2tX7o45x/2qg37m3c6v+kSp8xjDJY+2tJw4QB3j8o8dsl1FDXg=="
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@redux-saga/symbols/-/symbols-1.2.1.tgz",
+ "integrity": "sha512-3dh+uDvpBXi7EUp/eO+N7eFM4xKaU4yuGBXc50KnZGzIrR/vlvkTFQsX13zsY8PB6sCFYAgROfPSRUj8331QSA==",
+ "license": "MIT"
},
"node_modules/@redux-saga/types": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.2.1.tgz",
- "integrity": "sha512-1dgmkh+3so0+LlBWRhGA33ua4MYr7tUOj+a9Si28vUi0IUFNbff1T3sgpeDJI/LaC75bBYnQ0A3wXjn0OrRNBA=="
+ "version": "1.3.1",
+ "resolved": "https://registry.npmjs.org/@redux-saga/types/-/types-1.3.1.tgz",
+ "integrity": "sha512-YRCrJdhQLobGIQ8Cj1sta3nn6DrZDTSUnrIYhS2e5V590BmfVDleKoAquclAiKSBKWJwmuXTb+b4BL6rSHnahw==",
+ "license": "MIT"
},
"node_modules/@rolldown/pluginutils": {
- "version": "1.0.0-beta.9",
- "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz",
- "integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==",
+ "version": "1.0.0-beta.43",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.43.tgz",
+ "integrity": "sha512-5Uxg7fQUCmfhax7FJke2+8B6cqgeUJUD9o2uXIKXhD+mG0mL6NObmVoi9wXEU1tY89mZKgAYA6fTbftx3q2ZPQ==",
"dev": true
},
"node_modules/@rollup/plugin-node-resolve": {
@@ -2793,6 +2714,12 @@
"node": ">=6"
}
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
+ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
+ "dev": true
+ },
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@@ -2844,17 +2771,17 @@
}
},
"node_modules/@testing-library/jest-dom": {
- "version": "6.6.3",
- "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz",
- "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==",
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@adobe/css-tools": "^4.4.0",
"aria-query": "^5.0.0",
- "chalk": "^3.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
- "lodash": "^4.17.21",
+ "picocolors": "^1.1.1",
"redent": "^3.0.0"
},
"engines": {
@@ -2863,24 +2790,12 @@
"yarn": ">=1"
}
},
- "node_modules/@testing-library/jest-dom/node_modules/chalk": {
- "version": "3.0.0",
- "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz",
- "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==",
- "dev": true,
- "dependencies": {
- "ansi-styles": "^4.1.0",
- "supports-color": "^7.1.0"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/@testing-library/react": {
"version": "12.1.5",
@@ -3017,6 +2932,22 @@
"@babel/types": "^7.20.7"
}
},
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true
+ },
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@@ -3067,12 +2998,13 @@
"integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag=="
},
"node_modules/@types/node": {
- "version": "22.15.21",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.21.tgz",
- "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==",
+ "version": "24.9.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
+ "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
"devOptional": true,
+ "license": "MIT",
"dependencies": {
- "undici-types": "~6.21.0"
+ "undici-types": "~7.16.0"
}
},
"node_modules/@types/normalize-package-data": {
@@ -3086,9 +3018,10 @@
"integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="
},
"node_modules/@types/react": {
- "version": "17.0.86",
- "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.86.tgz",
- "integrity": "sha512-lPFuSjA85jecet6D4ZsPvCFuSrz6g2hkTSUw8MM0x5z2EndPV/itGnYQ39abjxd7F+cAcxLGtKQjnLn9cNUz3g==",
+ "version": "17.0.89",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.89.tgz",
+ "integrity": "sha512-I98SaDCar5lvEYl80ClRIUztH/hyWHR+I2f+5yTVp/MQ205HgYkA2b5mVdry/+nsEIrf8I65KA5V/PASx68MsQ==",
+ "license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"@types/scheduler": "^0.16",
@@ -3370,50 +3303,49 @@
"dev": true
},
"node_modules/@vitejs/plugin-react": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz",
- "integrity": "sha512-JuLWaEqypaJmOJPLWwO335Ig6jSgC1FTONCWAxnqcQthLTK/Yc9aH6hr9z/87xciejbQcnP3GnA1FWUSWeXaeg==",
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.0.tgz",
+ "integrity": "sha512-4LuWrg7EKWgQaMJfnN+wcmbAW+VSsCmqGohftWjuct47bv8uE4n/nPpq4XjJPsxgq00GGG5J8dvBczp8uxScew==",
"dev": true,
"dependencies": {
- "@babel/core": "^7.26.10",
- "@babel/plugin-transform-react-jsx-self": "^7.25.9",
- "@babel/plugin-transform-react-jsx-source": "^7.25.9",
- "@rolldown/pluginutils": "1.0.0-beta.9",
+ "@babel/core": "^7.28.4",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.43",
"@types/babel__core": "^7.20.5",
- "react-refresh": "^0.17.0"
+ "react-refresh": "^0.18.0"
},
"engines": {
- "node": "^14.18.0 || >=16.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"peerDependencies": {
- "vite": "^4.2.0 || ^5.0.0 || ^6.0.0"
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vitest/coverage-v8": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.1.4.tgz",
- "integrity": "sha512-G4p6OtioySL+hPV7Y6JHlhpsODbJzt1ndwHAFkyk6vVjpK03PFsKnauZIzcd0PrK4zAbc5lc+jeZ+eNGiMA+iw==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.0.3.tgz",
+ "integrity": "sha512-I+MlLwyJRBjmJr1kFYSxoseINbIdpxIAeK10jmXgB0FUtIfdYsvM3lGAvBu5yk8WPyhefzdmbCHCc1idFbNRcg==",
"dev": true,
"dependencies": {
- "@ampproject/remapping": "^2.3.0",
"@bcoe/v8-coverage": "^1.0.2",
- "debug": "^4.4.0",
+ "@vitest/utils": "4.0.3",
+ "ast-v8-to-istanbul": "^0.3.5",
+ "debug": "^4.4.3",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
"istanbul-lib-source-maps": "^5.0.6",
- "istanbul-reports": "^3.1.7",
- "magic-string": "^0.30.17",
+ "istanbul-reports": "^3.2.0",
"magicast": "^0.3.5",
"std-env": "^3.9.0",
- "test-exclude": "^7.0.1",
- "tinyrainbow": "^2.0.0"
+ "tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
- "@vitest/browser": "3.1.4",
- "vitest": "3.1.4"
+ "@vitest/browser": "4.0.3",
+ "vitest": "4.0.3"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -3422,36 +3354,38 @@
}
},
"node_modules/@vitest/expect": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.1.4.tgz",
- "integrity": "sha512-xkD/ljeliyaClDYqHPNCiJ0plY5YIcM0OlRiZizLhlPmpXWpxnGMyTZXOHFhFeG7w9P5PBeL4IdtJ/HeQwTbQA==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.3.tgz",
+ "integrity": "sha512-v3eSDx/bF25pzar6aEJrrdTXJduEBU3uSGXHslIdGIpJVP8tQQHV6x1ZfzbFQ/bLIomLSbR/2ZCfnaEGkWkiVQ==",
"dev": true,
"dependencies": {
- "@vitest/spy": "3.1.4",
- "@vitest/utils": "3.1.4",
- "chai": "^5.2.0",
- "tinyrainbow": "^2.0.0"
+ "@standard-schema/spec": "^1.0.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.0.3",
+ "@vitest/utils": "4.0.3",
+ "chai": "^6.0.1",
+ "tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.1.4.tgz",
- "integrity": "sha512-8IJ3CvwtSw/EFXqWFL8aCMu+YyYXG2WUSrQbViOZkWTKTVicVwZ/YiEZDSqD00kX+v/+W+OnxhNWoeVKorHygA==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.3.tgz",
+ "integrity": "sha512-evZcRspIPbbiJEe748zI2BRu94ThCBE+RkjCpVF8yoVYuTV7hMe+4wLF/7K86r8GwJHSmAPnPbZhpXWWrg1qbA==",
"dev": true,
"dependencies": {
- "@vitest/spy": "3.1.4",
+ "@vitest/spy": "4.0.3",
"estree-walker": "^3.0.3",
- "magic-string": "^0.30.17"
+ "magic-string": "^0.30.19"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
- "vite": "^5.0.0 || ^6.0.0"
+ "vite": "^6.0.0 || ^7.0.0-0"
},
"peerDependenciesMeta": {
"msw": {
@@ -3463,24 +3397,24 @@
}
},
"node_modules/@vitest/pretty-format": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.1.4.tgz",
- "integrity": "sha512-cqv9H9GvAEoTaoq+cYqUTCGscUjKqlJZC7PRwY5FMySVj5J+xOm1KQcCiYHJOEzOKRUhLH4R2pTwvFlWCEScsg==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.3.tgz",
+ "integrity": "sha512-N7gly/DRXzxa9w9sbDXwD9QNFYP2hw90LLLGDobPNwiWgyW95GMxsCt29/COIKKh3P7XJICR38PSDePenMBtsw==",
"dev": true,
"dependencies": {
- "tinyrainbow": "^2.0.0"
+ "tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.1.4.tgz",
- "integrity": "sha512-djTeF1/vt985I/wpKVFBMWUlk/I7mb5hmD5oP8K9ACRmVXgKTae3TUOtXAEBfslNKPzUQvnKhNd34nnRSYgLNQ==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.3.tgz",
+ "integrity": "sha512-1/aK6fPM0lYXWyGKwop2Gbvz1plyTps/HDbIIJXYtJtspHjpXIeB3If07eWpVH4HW7Rmd3Rl+IS/+zEAXrRtXA==",
"dev": true,
"dependencies": {
- "@vitest/utils": "3.1.4",
+ "@vitest/utils": "4.0.3",
"pathe": "^2.0.3"
},
"funding": {
@@ -3488,13 +3422,13 @@
}
},
"node_modules/@vitest/snapshot": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.1.4.tgz",
- "integrity": "sha512-JPHf68DvuO7vilmvwdPr9TS0SuuIzHvxeaCkxYcCD4jTk67XwL45ZhEHFKIuCm8CYstgI6LZ4XbwD6ANrwMpFg==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.3.tgz",
+ "integrity": "sha512-amnYmvZ5MTjNCP1HZmdeczAPLRD6iOm9+2nMRUGxbe/6sQ0Ymur0NnR9LIrWS8JA3wKE71X25D6ya/3LN9YytA==",
"dev": true,
"dependencies": {
- "@vitest/pretty-format": "3.1.4",
- "magic-string": "^0.30.17",
+ "@vitest/pretty-format": "4.0.3",
+ "magic-string": "^0.30.19",
"pathe": "^2.0.3"
},
"funding": {
@@ -3502,26 +3436,22 @@
}
},
"node_modules/@vitest/spy": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.1.4.tgz",
- "integrity": "sha512-Xg1bXhu+vtPXIodYN369M86K8shGLouNjoVI78g8iAq2rFoHFdajNvJJ5A/9bPMFcfQqdaCpOgWKEoMQg/s0Yg==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.3.tgz",
+ "integrity": "sha512-82vVL8Cqz7rbXaNUl35V2G7xeNMAjBdNOVaHbrzznT9BmiCiPOzhf0FhU3eP41nP1bLDm/5wWKZqkG4nyU95DQ==",
"dev": true,
- "dependencies": {
- "tinyspy": "^3.0.2"
- },
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.1.4.tgz",
- "integrity": "sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.3.tgz",
+ "integrity": "sha512-qV6KJkq8W3piW6MDIbGOmn1xhvcW4DuA07alqaQ+vdx7YA49J85pnwnxigZVQFQw3tWnQNRKWwhz5wbP6iv/GQ==",
"dev": true,
"dependencies": {
- "@vitest/pretty-format": "3.1.4",
- "loupe": "^3.1.3",
- "tinyrainbow": "^2.0.0"
+ "@vitest/pretty-format": "4.0.3",
+ "tinyrainbow": "^3.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -3813,6 +3743,23 @@
"integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==",
"dev": true
},
+ "node_modules/ast-v8-to-istanbul": {
+ "version": "0.3.8",
+ "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.8.tgz",
+ "integrity": "sha512-szgSZqUxI5T8mLKvS7WTjF9is+MVbOeLADU73IseOcrqhxr/VAvy6wfoVE39KnKzA7JRhjF5eUagNlHwvZPlKQ==",
+ "dev": true,
+ "dependencies": {
+ "@jridgewell/trace-mapping": "^0.3.31",
+ "estree-walker": "^3.0.3",
+ "js-tokens": "^9.0.1"
+ }
+ },
+ "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": {
+ "version": "9.0.1",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz",
+ "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==",
+ "dev": true
+ },
"node_modules/async": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@@ -4104,15 +4051,6 @@
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
- "node_modules/cac": {
- "version": "6.7.14",
- "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz",
- "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==",
- "dev": true,
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/cacheable-request": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-6.1.0.tgz",
@@ -4262,19 +4200,12 @@
]
},
"node_modules/chai": {
- "version": "5.2.0",
- "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz",
- "integrity": "sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==",
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz",
+ "integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==",
"dev": true,
- "dependencies": {
- "assertion-error": "^2.0.1",
- "check-error": "^2.1.1",
- "deep-eql": "^5.0.1",
- "loupe": "^3.1.0",
- "pathval": "^2.0.0"
- },
"engines": {
- "node": ">=12"
+ "node": ">=18"
}
},
"node_modules/chalk": {
@@ -4297,15 +4228,6 @@
"resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz",
"integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="
},
- "node_modules/check-error": {
- "version": "2.1.1",
- "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz",
- "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==",
- "dev": true,
- "engines": {
- "node": ">= 16"
- }
- },
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
@@ -4590,7 +4512,8 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
- "dev": true
+ "dev": true,
+ "license": "MIT"
},
"node_modules/cssstyle": {
"version": "4.3.1",
@@ -4692,9 +4615,9 @@
"integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw=="
},
"node_modules/debug": {
- "version": "4.4.1",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
- "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
@@ -4763,15 +4686,6 @@
"node": ">=4"
}
},
- "node_modules/deep-eql": {
- "version": "5.0.2",
- "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz",
- "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==",
- "dev": true,
- "engines": {
- "node": ">=6"
- }
- },
"node_modules/deep-equal": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz",
@@ -5014,12 +4928,6 @@
"resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.5.tgz",
"integrity": "sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA=="
},
- "node_modules/eastasianwidth": {
- "version": "0.2.0",
- "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
- "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
- "dev": true
- },
"node_modules/ejs": {
"version": "3.1.10",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
@@ -5390,9 +5298,9 @@
}
},
"node_modules/eslint-config-prettier": {
- "version": "10.1.5",
- "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz",
- "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==",
+ "version": "10.1.8",
+ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
+ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
"dev": true,
"license": "MIT",
"bin": {
@@ -5510,10 +5418,11 @@
}
},
"node_modules/eslint-plugin-react-refresh": {
- "version": "0.4.20",
- "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz",
- "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==",
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz",
+ "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==",
"dev": true,
+ "license": "MIT",
"peerDependencies": {
"eslint": ">=8.40"
}
@@ -5716,9 +5625,9 @@
"integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw=="
},
"node_modules/expect-type": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.1.tgz",
- "integrity": "sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==",
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz",
+ "integrity": "sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==",
"dev": true,
"engines": {
"node": ">=12.0.0"
@@ -5964,34 +5873,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
- "node_modules/foreground-child": {
- "version": "3.3.1",
- "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
- "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
- "dev": true,
- "dependencies": {
- "cross-spawn": "^7.0.6",
- "signal-exit": "^4.0.1"
- },
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/foreground-child/node_modules/signal-exit": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
- "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
- "dev": true,
- "engines": {
- "node": ">=14"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/fs-extra": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
@@ -7186,9 +7067,9 @@
}
},
"node_modules/istanbul-reports": {
- "version": "3.1.7",
- "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz",
- "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==",
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz",
+ "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==",
"dev": true,
"dependencies": {
"html-escaper": "^2.0.0",
@@ -7215,21 +7096,6 @@
"node": ">= 0.4"
}
},
- "node_modules/jackspeak": {
- "version": "3.4.3",
- "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
- "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
- "dev": true,
- "dependencies": {
- "@isaacs/cliui": "^8.0.2"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- },
- "optionalDependencies": {
- "@pkgjs/parseargs": "^0.11.0"
- }
- },
"node_modules/jake": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz",
@@ -7663,12 +7529,6 @@
"loose-envify": "cli.js"
}
},
- "node_modules/loupe": {
- "version": "3.1.3",
- "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.3.tgz",
- "integrity": "sha512-kkIp7XSkP78ZxJEsSxW3712C6teJVoeHHwgo9zJ380de7IYyJ2ISlxojcH2pC5OFLewESmnRi/+XCDIEEVyoug==",
- "dev": true
- },
"node_modules/lowercase-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-1.0.1.tgz",
@@ -7695,12 +7555,12 @@
}
},
"node_modules/magic-string": {
- "version": "0.30.17",
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
- "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
"dev": true,
"dependencies": {
- "@jridgewell/sourcemap-codec": "^1.5.0"
+ "@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/magicast": {
@@ -7865,15 +7725,6 @@
"node": ">= 6"
}
},
- "node_modules/minipass": {
- "version": "7.1.2",
- "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
- "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
- "dev": true,
- "engines": {
- "node": ">=16 || 14 >=14.17"
- }
- },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -8263,12 +8114,6 @@
"node": ">=8"
}
},
- "node_modules/package-json-from-dist": {
- "version": "1.0.1",
- "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
- "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
- "dev": true
- },
"node_modules/package-json/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -8348,28 +8193,6 @@
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
},
- "node_modules/path-scurry": {
- "version": "1.11.1",
- "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
- "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
- "dev": true,
- "dependencies": {
- "lru-cache": "^10.2.0",
- "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
- },
- "engines": {
- "node": ">=16 || 14 >=14.18"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/path-scurry/node_modules/lru-cache": {
- "version": "10.4.3",
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
- "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
- "dev": true
- },
"node_modules/path-to-regexp": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz",
@@ -8393,15 +8216,6 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true
},
- "node_modules/pathval": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz",
- "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==",
- "dev": true,
- "engines": {
- "node": ">= 14.16"
- }
- },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -8432,9 +8246,9 @@
}
},
"node_modules/postcss": {
- "version": "8.5.3",
- "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
- "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
+ "version": "8.5.6",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
+ "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"dev": true,
"funding": [
{
@@ -8451,7 +8265,7 @@
}
],
"dependencies": {
- "nanoid": "^3.3.8",
+ "nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -8477,9 +8291,9 @@
}
},
"node_modules/prettier": {
- "version": "3.5.3",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz",
- "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==",
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
+ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
@@ -9258,9 +9072,9 @@
}
},
"node_modules/react-refresh": {
- "version": "0.17.0",
- "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
- "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "version": "0.18.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
+ "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==",
"dev": true,
"engines": {
"node": ">=0.10.0"
@@ -9461,11 +9275,12 @@
}
},
"node_modules/redux-saga": {
- "version": "1.3.0",
- "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.3.0.tgz",
- "integrity": "sha512-J9RvCeAZXSTAibFY0kGw6Iy4EdyDNW7k6Q+liwX+bsck7QVsU78zz8vpBRweEfANxnnlG/xGGeOvf6r8UXzNJQ==",
+ "version": "1.4.2",
+ "resolved": "https://registry.npmjs.org/redux-saga/-/redux-saga-1.4.2.tgz",
+ "integrity": "sha512-QLIn/q+7MX/B+MkGJ/K6R3//60eJ4QNy65eqPsJrfGezbxdh1Jx+37VRKE2K4PsJnNET5JufJtgWdT30WBa+6w==",
+ "license": "MIT",
"dependencies": {
- "@redux-saga/core": "^1.3.0"
+ "@redux-saga/core": "^1.4.2"
}
},
"node_modules/reflect.getprototypeof": {
@@ -10184,9 +9999,9 @@
"dev": true
},
"node_modules/std-env": {
- "version": "3.9.0",
- "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.9.0.tgz",
- "integrity": "sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==",
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
"dev": true
},
"node_modules/stop-iteration-iterator": {
@@ -10231,27 +10046,6 @@
"node": ">=8"
}
},
- "node_modules/string-width-cjs": {
- "name": "string-width",
- "version": "4.2.3",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
- "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
- "dev": true,
- "dependencies": {
- "emoji-regex": "^8.0.0",
- "is-fullwidth-code-point": "^3.0.0",
- "strip-ansi": "^6.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
- "node_modules/string-width-cjs/node_modules/emoji-regex": {
- "version": "8.0.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
- "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
- "dev": true
- },
"node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -10384,19 +10178,6 @@
"node": ">=8"
}
},
- "node_modules/strip-ansi-cjs": {
- "name": "strip-ansi",
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
- "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^5.0.1"
- },
- "engines": {
- "node": ">=8"
- }
- },
"node_modules/strip-comments": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz",
@@ -10509,55 +10290,6 @@
"node": ">=10"
}
},
- "node_modules/test-exclude": {
- "version": "7.0.1",
- "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz",
- "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==",
- "dev": true,
- "dependencies": {
- "@istanbuljs/schema": "^0.1.2",
- "glob": "^10.4.1",
- "minimatch": "^9.0.4"
- },
- "engines": {
- "node": ">=18"
- }
- },
- "node_modules/test-exclude/node_modules/glob": {
- "version": "10.4.5",
- "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
- "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
- "dev": true,
- "dependencies": {
- "foreground-child": "^3.1.0",
- "jackspeak": "^3.1.2",
- "minimatch": "^9.0.4",
- "minipass": "^7.1.2",
- "package-json-from-dist": "^1.0.0",
- "path-scurry": "^1.11.1"
- },
- "bin": {
- "glob": "dist/esm/bin.mjs"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
- "node_modules/test-exclude/node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
- "dev": true,
- "dependencies": {
- "brace-expansion": "^2.0.1"
- },
- "engines": {
- "node": ">=16 || 14 >=14.17"
- },
- "funding": {
- "url": "https://github.com/sponsors/isaacs"
- }
- },
"node_modules/text-table": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
@@ -10592,13 +10324,13 @@
"dev": true
},
"node_modules/tinyglobby": {
- "version": "0.2.13",
- "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
- "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==",
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"dependencies": {
- "fdir": "^6.4.4",
- "picomatch": "^4.0.2"
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
@@ -10608,10 +10340,13 @@
}
},
"node_modules/tinyglobby/node_modules/fdir": {
- "version": "6.4.4",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
- "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ },
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -10622,9 +10357,9 @@
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
- "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"engines": {
"node": ">=12"
@@ -10633,28 +10368,10 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
- "node_modules/tinypool": {
- "version": "1.0.2",
- "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.0.2.tgz",
- "integrity": "sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==",
- "dev": true,
- "engines": {
- "node": "^18.0.0 || >=20.0.0"
- }
- },
"node_modules/tinyrainbow": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz",
- "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==",
- "dev": true,
- "engines": {
- "node": ">=14.0.0"
- }
- },
- "node_modules/tinyspy": {
- "version": "3.0.2",
- "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz",
- "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==",
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
+ "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
"dev": true,
"engines": {
"node": ">=14.0.0"
@@ -10875,6 +10592,7 @@
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/typescript-compare/-/typescript-compare-0.0.2.tgz",
"integrity": "sha512-8ja4j7pMHkfLJQO2/8tut7ub+J3Lw2S3061eJLFQcvs3tsmJKp8KG5NtpLn7KcY2w08edF74BSVN7qJS0U6oHA==",
+ "license": "MIT",
"dependencies": {
"typescript-logic": "^0.0.0"
}
@@ -10882,12 +10600,14 @@
"node_modules/typescript-logic": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/typescript-logic/-/typescript-logic-0.0.0.tgz",
- "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q=="
+ "integrity": "sha512-zXFars5LUkI3zP492ls0VskH3TtdeHCqu0i7/duGt60i5IGPIpAHE/DWo5FqJ6EjQ15YKXrt+AETjv60Dat34Q==",
+ "license": "MIT"
},
"node_modules/typescript-tuple": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/typescript-tuple/-/typescript-tuple-2.2.1.tgz",
"integrity": "sha512-Zcr0lbt8z5ZdEzERHAMAniTiIKerFCMgd7yjq1fPnDJ43et/k9twIFQMUYff9k5oXcsQ0WpvFcgzK2ZKASoW6Q==",
+ "license": "MIT",
"dependencies": {
"typescript-compare": "^0.0.2"
}
@@ -10910,10 +10630,11 @@
}
},
"node_modules/undici-types": {
- "version": "6.21.0",
- "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
- "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
- "devOptional": true
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
+ "devOptional": true,
+ "license": "MIT"
},
"node_modules/unicode-canonical-property-names-ecmascript": {
"version": "2.0.1",
@@ -11072,15 +10793,16 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/uuid": {
- "version": "11.1.0",
- "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
- "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
+ "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
+ "license": "MIT",
"bin": {
- "uuid": "dist/esm/bin/uuid"
+ "uuid": "dist-node/bin/uuid"
}
},
"node_modules/validate-npm-package-license": {
@@ -11098,23 +10820,23 @@
"integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw=="
},
"node_modules/vite": {
- "version": "6.3.5",
- "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz",
- "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==",
+ "version": "7.1.12",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz",
+ "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==",
"dev": true,
"dependencies": {
"esbuild": "^0.25.0",
- "fdir": "^6.4.4",
- "picomatch": "^4.0.2",
- "postcss": "^8.5.3",
- "rollup": "^4.34.9",
- "tinyglobby": "^0.2.13"
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ "node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
@@ -11123,14 +10845,14 @@
"fsevents": "~2.3.3"
},
"peerDependencies": {
- "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "@types/node": "^20.19.0 || >=22.12.0",
"jiti": ">=1.21.0",
- "less": "*",
+ "less": "^4.0.0",
"lightningcss": "^1.21.0",
- "sass": "*",
- "sass-embedded": "*",
- "stylus": "*",
- "sugarss": "*",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
@@ -11171,32 +10893,10 @@
}
}
},
- "node_modules/vite-node": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.1.4.tgz",
- "integrity": "sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==",
- "dev": true,
- "dependencies": {
- "cac": "^6.7.14",
- "debug": "^4.4.0",
- "es-module-lexer": "^1.7.0",
- "pathe": "^2.0.3",
- "vite": "^5.0.0 || ^6.0.0"
- },
- "bin": {
- "vite-node": "vite-node.mjs"
- },
- "engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
- },
- "funding": {
- "url": "https://opencollective.com/vitest"
- }
- },
"node_modules/vite-plugin-pwa": {
- "version": "0.21.2",
- "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-0.21.2.tgz",
- "integrity": "sha512-vFhH6Waw8itNu37hWUJxL50q+CBbNcMVzsKaYHQVrfxTt3ihk3PeLO22SbiP1UNWzcEPaTQv+YVxe4G0KOjAkg==",
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/vite-plugin-pwa/-/vite-plugin-pwa-1.1.0.tgz",
+ "integrity": "sha512-VsSpdubPzXhHWVINcSx6uHRMpOHVHQcHsef1QgkOlEoaIDAlssFEW88LBq1a59BuokAhsh2kUDJbaX1bZv4Bjw==",
"dev": true,
"dependencies": {
"debug": "^4.3.6",
@@ -11212,8 +10912,8 @@
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
- "@vite-pwa/assets-generator": "^0.2.6",
- "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0",
+ "@vite-pwa/assets-generator": "^1.0.0",
+ "vite": "^3.1.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0",
"workbox-build": "^7.3.0",
"workbox-window": "^7.3.0"
},
@@ -11224,10 +10924,13 @@
}
},
"node_modules/vite/node_modules/fdir": {
- "version": "6.4.4",
- "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz",
- "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==",
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
+ "engines": {
+ "node": ">=12.0.0"
+ },
"peerDependencies": {
"picomatch": "^3 || ^4"
},
@@ -11238,9 +10941,9 @@
}
},
"node_modules/vite/node_modules/picomatch": {
- "version": "4.0.2",
- "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
- "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"engines": {
"node": ">=12"
@@ -11250,38 +10953,37 @@
}
},
"node_modules/vitest": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.1.4.tgz",
- "integrity": "sha512-Ta56rT7uWxCSJXlBtKgIlApJnT6e6IGmTYxYcmxjJ4ujuZDI59GUQgVDObXXJujOmPDBYXHK1qmaGtneu6TNIQ==",
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.3.tgz",
+ "integrity": "sha512-IUSop8jgaT7w0g1yOM/35qVtKjr/8Va4PrjzH1OUb0YH4c3OXB2lCZDkMAB6glA8T5w8S164oJGsbcmAecr4sA==",
"dev": true,
"dependencies": {
- "@vitest/expect": "3.1.4",
- "@vitest/mocker": "3.1.4",
- "@vitest/pretty-format": "^3.1.4",
- "@vitest/runner": "3.1.4",
- "@vitest/snapshot": "3.1.4",
- "@vitest/spy": "3.1.4",
- "@vitest/utils": "3.1.4",
- "chai": "^5.2.0",
- "debug": "^4.4.0",
- "expect-type": "^1.2.1",
- "magic-string": "^0.30.17",
+ "@vitest/expect": "4.0.3",
+ "@vitest/mocker": "4.0.3",
+ "@vitest/pretty-format": "4.0.3",
+ "@vitest/runner": "4.0.3",
+ "@vitest/snapshot": "4.0.3",
+ "@vitest/spy": "4.0.3",
+ "@vitest/utils": "4.0.3",
+ "debug": "^4.4.3",
+ "es-module-lexer": "^1.7.0",
+ "expect-type": "^1.2.2",
+ "magic-string": "^0.30.19",
"pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
"std-env": "^3.9.0",
"tinybench": "^2.9.0",
"tinyexec": "^0.3.2",
- "tinyglobby": "^0.2.13",
- "tinypool": "^1.0.2",
- "tinyrainbow": "^2.0.0",
- "vite": "^5.0.0 || ^6.0.0",
- "vite-node": "3.1.4",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.0.3",
+ "vite": "^6.0.0 || ^7.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
- "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
@@ -11289,9 +10991,11 @@
"peerDependencies": {
"@edge-runtime/vm": "*",
"@types/debug": "^4.1.12",
- "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
- "@vitest/browser": "3.1.4",
- "@vitest/ui": "3.1.4",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.0.3",
+ "@vitest/browser-preview": "4.0.3",
+ "@vitest/browser-webdriverio": "4.0.3",
+ "@vitest/ui": "4.0.3",
"happy-dom": "*",
"jsdom": "*"
},
@@ -11305,7 +11009,13 @@
"@types/node": {
"optional": true
},
- "@vitest/browser": {
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/ui": {
@@ -11319,6 +11029,18 @@
}
}
},
+ "node_modules/vitest/node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
@@ -11868,97 +11590,6 @@
"workbox-core": "7.3.0"
}
},
- "node_modules/wrap-ansi": {
- "version": "8.1.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
- "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
- "dev": true,
- "dependencies": {
- "ansi-styles": "^6.1.0",
- "string-width": "^5.0.1",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
- "node_modules/wrap-ansi-cjs": {
- "name": "wrap-ansi",
- "version": "7.0.0",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
- "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
- "dev": true,
- "dependencies": {
- "ansi-styles": "^4.0.0",
- "string-width": "^4.1.0",
- "strip-ansi": "^6.0.0"
- },
- "engines": {
- "node": ">=10"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
- "node_modules/wrap-ansi/node_modules/ansi-regex": {
- "version": "6.1.0",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
- "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
- "dev": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-regex?sponsor=1"
- }
- },
- "node_modules/wrap-ansi/node_modules/ansi-styles": {
- "version": "6.2.1",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
- "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
- "dev": true,
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
- "node_modules/wrap-ansi/node_modules/string-width": {
- "version": "5.1.2",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
- "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
- "dev": true,
- "dependencies": {
- "eastasianwidth": "^0.2.0",
- "emoji-regex": "^9.2.2",
- "strip-ansi": "^7.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
- "node_modules/wrap-ansi/node_modules/strip-ansi": {
- "version": "7.1.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
- "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
- "dev": true,
- "dependencies": {
- "ansi-regex": "^6.0.1"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/strip-ansi?sponsor=1"
- }
- },
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
diff --git a/ui/package.json b/ui/package.json
index b9c93316b..3a39e5f32 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -18,7 +18,7 @@
"dependencies": {
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.11.3",
- "@material-ui/lab": "^4.0.0-alpha.58",
+ "@material-ui/lab": "^4.0.0-alpha.61",
"@material-ui/styles": "^4.11.5",
"blueimp-md5": "^2.19.0",
"clsx": "^2.1.1",
@@ -46,8 +46,8 @@
"react-redux": "^7.2.9",
"react-router-dom": "^5.3.4",
"redux": "^4.2.1",
- "redux-saga": "^1.3.0",
- "uuid": "^11.1.0",
+ "redux-saga": "^1.4.2",
+ "uuid": "^13.0.0",
"workbox-cli": "^7.3.0"
},
"devDependencies": {
@@ -55,27 +55,27 @@
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^7.0.2",
"@testing-library/user-event": "^14.6.1",
- "@types/node": "^22.15.21",
- "@types/react": "^17.0.86",
+ "@types/node": "^24.9.1",
+ "@types/react": "^17.0.89",
"@types/react-dom": "^17.0.26",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
- "@vitejs/plugin-react": "^4.5.0",
- "@vitest/coverage-v8": "^3.1.4",
+ "@vitejs/plugin-react": "^5.1.0",
+ "@vitest/coverage-v8": "^4.0.3",
"eslint": "^8.57.1",
- "eslint-config-prettier": "^10.1.5",
+ "eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
- "eslint-plugin-react-refresh": "^0.4.20",
+ "eslint-plugin-react-refresh": "^0.4.24",
"happy-dom": "^17.4.7",
"jsdom": "^26.1.0",
- "prettier": "^3.5.3",
+ "prettier": "^3.6.2",
"ra-test": "^3.19.12",
"typescript": "^5.8.3",
- "vite": "^6.3.5",
- "vite-plugin-pwa": "^0.21.2",
- "vitest": "^3.1.4"
+ "vite": "^7.1.12",
+ "vite-plugin-pwa": "^1.1.0",
+ "vitest": "^4.0.3"
},
"overrides": {
"vite": {
diff --git a/ui/src/common/Linkify.test.jsx b/ui/src/common/Linkify.test.jsx
index cef50b228..cd19ffa03 100644
--- a/ui/src/common/Linkify.test.jsx
+++ b/ui/src/common/Linkify.test.jsx
@@ -1,6 +1,5 @@
import React from 'react'
import { render, screen } from '@testing-library/react'
-import '@testing-library/jest-dom'
import Linkify from './Linkify'
const URL = 'http://www.example.com'
diff --git a/ui/src/eventStream.test.js b/ui/src/eventStream.test.js
index 5bd0dd0be..27f53c872 100644
--- a/ui/src/eventStream.test.js
+++ b/ui/src/eventStream.test.js
@@ -25,7 +25,7 @@ describe('startEventStream', () => {
beforeEach(() => {
dispatch = vi.fn()
- global.EventSource = vi.fn((url) => {
+ global.EventSource = vi.fn().mockImplementation(function (url) {
instance = new MockEventSource(url)
return instance
})
From ac3e6ae6a5a0548abf3a648295faea87761ea83e Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sat, 25 Oct 2025 17:24:31 -0400
Subject: [PATCH 004/102] chore(deps-dev): bump brace-expansion from 1.1.11 to
1.1.12 in /ui (#4217)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.11 to 1.1.12.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/1.1.11...v1.1.12)
---
updated-dependencies:
- dependency-name: brace-expansion
dependency-version: 1.1.12
dependency-type: indirect
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Deluan Quintão
---
ui/package-lock.json | 56 +++++++++++++++++++++++++-------------------
1 file changed, 32 insertions(+), 24 deletions(-)
diff --git a/ui/package-lock.json b/ui/package-lock.json
index e9161739f..89a6589e6 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -2098,10 +2098,11 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -2171,10 +2172,11 @@
}
},
"node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -3974,9 +3976,10 @@
}
},
"node_modules/brace-expansion": {
- "version": "2.0.1",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
- "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
+ "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
@@ -5352,10 +5355,11 @@
}
},
"node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -5428,10 +5432,11 @@
}
},
"node_modules/eslint-plugin-react/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -5499,10 +5504,11 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -6053,9 +6059,10 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -7114,9 +7121,10 @@
}
},
"node_modules/jake/node_modules/brace-expansion": {
- "version": "1.1.11",
- "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
- "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
+ "version": "1.1.12",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
+ "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
+ "license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
From e24f7984cc9018c59dc79adecc8c788bd7291d80 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Sat, 25 Oct 2025 17:25:48 -0400
Subject: [PATCH 005/102] chore(deps-dev): update happy-dom to version 20.0.8
Signed-off-by: Deluan
---
ui/package-lock.json | 38 ++++++++++++++++++++++++++++++++------
ui/package.json | 2 +-
2 files changed, 33 insertions(+), 7 deletions(-)
diff --git a/ui/package-lock.json b/ui/package-lock.json
index 89a6589e6..c0901a73d 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -59,7 +59,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.24",
- "happy-dom": "^17.4.7",
+ "happy-dom": "^20.0.8",
"jsdom": "^26.1.0",
"prettier": "^3.6.2",
"ra-test": "^3.19.12",
@@ -3093,6 +3093,13 @@
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="
},
+ "node_modules/@types/whatwg-mimetype": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
+ "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/yargs": {
"version": "15.0.19",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.19.tgz",
@@ -6180,18 +6187,37 @@
"dev": true
},
"node_modules/happy-dom": {
- "version": "17.4.7",
- "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-17.4.7.tgz",
- "integrity": "sha512-NZypxadhCiV5NT4A+Y86aQVVKQ05KDmueja3sz008uJfDRwz028wd0aTiJPwo4RQlvlz0fznkEEBBCHVNWc08g==",
+ "version": "20.0.8",
+ "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.0.8.tgz",
+ "integrity": "sha512-TlYaNQNtzsZ97rNMBAm8U+e2cUQXNithgfCizkDgc11lgmN4j9CKMhO3FPGKWQYPwwkFcPpoXYF/CqEPLgzfOg==",
"dev": true,
+ "license": "MIT",
"dependencies": {
- "webidl-conversions": "^7.0.0",
+ "@types/node": "^20.0.0",
+ "@types/whatwg-mimetype": "^3.0.2",
"whatwg-mimetype": "^3.0.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=20.0.0"
}
},
+ "node_modules/happy-dom/node_modules/@types/node": {
+ "version": "20.19.23",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.23.tgz",
+ "integrity": "sha512-yIdlVVVHXpmqRhtyovZAcSy0MiPcYWGkoO4CGe/+jpP0hmNuihm4XhHbADpK++MsiLHP5MVlv+bcgdF99kSiFQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/happy-dom/node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/hard-rejection": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz",
diff --git a/ui/package.json b/ui/package.json
index 3a39e5f32..a3612aaf4 100644
--- a/ui/package.json
+++ b/ui/package.json
@@ -68,7 +68,7 @@
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.24",
- "happy-dom": "^17.4.7",
+ "happy-dom": "^20.0.8",
"jsdom": "^26.1.0",
"prettier": "^3.6.2",
"ra-test": "^3.19.12",
From 925bfafc1f4d0f527004031f923c224bb70166a3 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Sat, 25 Oct 2025 17:42:33 -0400
Subject: [PATCH 006/102] build: enhance golangci-lint installation process to
check version and reinstall if necessary
---
Makefile | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/Makefile b/Makefile
index a4ba45ae0..df8155f56 100644
--- a/Makefile
+++ b/Makefile
@@ -16,6 +16,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.1.1-1
+GOLANGCI_LINT_VERSION ?= v2.5.0
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
@@ -65,7 +66,22 @@ test-i18n: ##@Development Validate all translations files
.PHONY: test-i18n
install-golangci-lint: ##@Development Install golangci-lint if not present
- @PATH=$$PATH:./bin which golangci-lint > /dev/null || (echo "Installing golangci-lint..." && curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s v2.5.0)
+ @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); \
+ 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..."; \
+ rm -f ./bin/golangci-lint; \
+ INSTALL=true; \
+ fi; \
+ else \
+ INSTALL=true; \
+ fi; \
+ if [ "$$INSTALL" = "true" ]; then \
+ echo "Installing golangci-lint $(GOLANGCI_LINT_VERSION)..."; \
+ curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s $(GOLANGCI_LINT_VERSION); \
+ fi
.PHONY: install-golangci-lint
lint: install-golangci-lint ##@Development Lint Go code
From aa7f55646dec28423913a4e6688bd001086edf14 Mon Sep 17 00:00:00 2001
From: Daniele Ricci
Date: Sat, 25 Oct 2025 23:47:09 +0200
Subject: [PATCH 007/102] build(docker): use standalone wget instead of the
busybox one, fix #4473
wget in busybox doesn't support redirects (required for downloading
artifacts from GitHub)
---
Dockerfile | 2 ++
1 file changed, 2 insertions(+)
diff --git a/Dockerfile b/Dockerfile
index eeb270e00..fb1cf997b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -31,7 +31,9 @@ ARG TARGETPLATFORM
ARG CROSS_TAGLIB_VERSION=2.1.1-1
ENV CROSS_TAGLIB_RELEASES_URL=https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/
+# wget in busybox can't follow redirects
RUN <
Date: Sun, 26 Oct 2025 19:36:44 -0400
Subject: [PATCH 008/102] fix: enable multi-valued releasetype in smart
playlists (#4621)
* fix: prevent infinite loop in Type filter autocomplete
Fixed an infinite loop issue in the album Type filter caused by an inline
arrow function in the optionText prop. The inline function created a new
reference on every render, causing React-Admin's AutocompleteInput to
continuously re-fetch data from the /api/tag endpoint.
The solution extracts the formatting function outside the component scope
as formatReleaseType, ensuring a stable function reference across renders.
This prevents unnecessary re-renders and API calls while maintaining the
humanized display format for release type values.
* fix: enable multi-valued releasetype in smart playlists
Smart playlists can now match all values in multi-valued releasetype tags.
Previously, the albumtype field was mapped to the single-valued mbz_album_type
database field, which only stored the first value from tags like album; soundtrack.
This prevented smart playlists from matching albums with secondary release types
like soundtrack, live, or compilation when tagged by MusicBrainz Picard.
The fix removes the direct database field mapping and allows both albumtype and
releasetype to use the multi-valued tag system. The albumtype field is now an
alias that points to the releasetype tag field, ensuring both query the same
JSON path in the tags column. This maintains backward compatibility with the
documented albumtype field while enabling proper multi-value tag matching.
Added tests to verify both releasetype and albumtype correctly generate
multi-valued tag queries.
Fixes #4616
* fix: resolve albumtype alias for all operators and sorting
Codex correctly identified that the initial fix only worked for Contains/StartsWith/EndsWith operators. The alias resolution was happening too late in the code path.
Fixed by resolving the alias in two places:
1. tagCond.ToSql() - now uses the actual field name (releasetype) in the JSON path
2. Criteria.OrderBy() - now uses the actual field name when building sort expressions
Added tests for Is/IsNot operators and sorting to ensure complete coverage.
---
model/criteria/criteria.go | 7 ++++++-
model/criteria/criteria_test.go | 10 ++++++++++
model/criteria/fields.go | 18 ++++++++++++-----
model/criteria/operators_test.go | 34 ++++++++++++++++++++++++++++++++
ui/src/album/AlbumList.jsx | 7 ++++---
5 files changed, 67 insertions(+), 9 deletions(-)
diff --git a/model/criteria/criteria.go b/model/criteria/criteria.go
index fa92c5aca..54ac59697 100644
--- a/model/criteria/criteria.go
+++ b/model/criteria/criteria.go
@@ -61,7 +61,12 @@ func (c Criteria) OrderBy() string {
if f.order != "" {
mapped = f.order
} else if f.isTag {
- mapped = "COALESCE(json_extract(media_file.tags, '$." + sortField + "[0].value'), '')"
+ // 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 {
diff --git a/model/criteria/criteria_test.go b/model/criteria/criteria_test.go
index 3792264a5..032ead5c8 100644
--- a/model/criteria/criteria_test.go
+++ b/model/criteria/criteria_test.go
@@ -118,6 +118,16 @@ var _ = Describe("Criteria", func() {
)
})
+ 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"
diff --git a/model/criteria/fields.go b/model/criteria/fields.go
index 3699eb14a..70719cd6f 100644
--- a/model/criteria/fields.go
+++ b/model/criteria/fields.go
@@ -32,7 +32,6 @@ var fieldMap = map[string]*mappedField{
"sortalbum": {field: "media_file.sort_album_name"},
"sortartist": {field: "media_file.sort_artist_name"},
"sortalbumartist": {field: "media_file.sort_album_artist_name"},
- "albumtype": {field: "media_file.mbz_album_type", alias: "releasetype"},
"albumcomment": {field: "media_file.mbz_album_comment"},
"catalognumber": {field: "media_file.catalog_num"},
"filepath": {field: "media_file.path"},
@@ -55,6 +54,9 @@ var fieldMap = map[string]*mappedField{
"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
@@ -154,13 +156,19 @@ type tagCond struct {
func (e tagCond) ToSql() (string, []any, error) {
cond, args, err := e.cond.ToSql()
- // Check if this tag is marked as numeric in the fieldMap
- if fm, ok := fieldMap[e.tag]; ok && fm.numeric {
- cond = strings.ReplaceAll(cond, "value", "CAST(value AS REAL)")
+ // 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(tags, '$.%s') where key='value' and %s)",
- e.tag, cond)
+ tagName, cond)
if e.not {
cond = "not " + cond
}
diff --git a/model/criteria/operators_test.go b/model/criteria/operators_test.go
index ee716a9cd..4c1db1303 100644
--- a/model/criteria/operators_test.go
+++ b/model/criteria/operators_test.go
@@ -105,6 +105,40 @@ var _ = Describe("Operators", func() {
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(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(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(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(tags, '$.releasetype') where key='value' and value = ?)"))
+ gomega.Expect(args).To(gomega.HaveExactElements("compilation"))
+ })
})
Describe("Custom Roles", func() {
diff --git a/ui/src/album/AlbumList.jsx b/ui/src/album/AlbumList.jsx
index 40b927a89..f10f8dbd3 100644
--- a/ui/src/album/AlbumList.jsx
+++ b/ui/src/album/AlbumList.jsx
@@ -42,6 +42,9 @@ const useStyles = makeStyles({
},
})
+const formatReleaseType = (record) =>
+ record?.tagValue ? humanize(record?.tagValue) : '-- None --'
+
const AlbumFilter = (props) => {
const classes = useStyles()
const translate = useTranslate()
@@ -142,9 +145,7 @@ const AlbumFilter = (props) => {
>
- record?.tagValue ? humanize(record?.tagValue) : '-- None --'
- }
+ optionText={formatReleaseType}
/>
From cce11c5416f9321942748626c217a4f0d1d3a445 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Sun, 26 Oct 2025 19:38:34 -0400
Subject: [PATCH 009/102] fix(scanner): restore basic tag extraction fallback
mechanism for improved metadata parsing (#4401)
* feat: add basic tag extraction fallback mechanism
Added basic tag extraction from TagLib's generic Tag interface as a fallback
when PropertyMap doesn't contain standard metadata fields. This ensures that
essential tags like title, artist, album, comment, genre, year, and track
are always available even when they're not present in format-specific
property maps.
Changes include:
- Extract basic tags (__title, __artist, etc.) in C++ wrapper
- Add parseBasicTag function to process basic tags in Go extractor
- Refactor parseProp function to be reusable across property parsing
- Ensure basic tags are preferred over PropertyMap when available
* feat(taglib): update tag parsing to use double underscores for properties
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
---
adapters/taglib/taglib.go | 57 ++++++++++++++++++++---------
adapters/taglib/taglib_wrapper.cpp | 58 +++++++++++++++++++++++-------
2 files changed, 85 insertions(+), 30 deletions(-)
diff --git a/adapters/taglib/taglib.go b/adapters/taglib/taglib.go
index 62a949d85..d32adf4ed 100644
--- a/adapters/taglib/taglib.go
+++ b/adapters/taglib/taglib.go
@@ -43,23 +43,21 @@ func (e extractor) extractMetadata(filePath string) (*metadata.Info, error) {
// Parse audio properties
ap := metadata.AudioProperties{}
- if length, ok := tags["_lengthinmilliseconds"]; ok && len(length) > 0 {
- millis, _ := strconv.Atoi(length[0])
- if millis > 0 {
- ap.Duration = (time.Millisecond * time.Duration(millis)).Round(time.Millisecond * 10)
- }
- delete(tags, "_lengthinmilliseconds")
- }
- parseProp := func(prop string, target *int) {
- if value, ok := tags[prop]; ok && len(value) > 0 {
- *target, _ = strconv.Atoi(value[0])
- delete(tags, prop)
- }
- }
- parseProp("_bitrate", &ap.BitRate)
- parseProp("_channels", &ap.Channels)
- parseProp("_samplerate", &ap.SampleRate)
- parseProp("_bitspersample", &ap.BitDepth)
+ ap.BitRate = parseProp(tags, "__bitrate")
+ ap.Channels = parseProp(tags, "__channels")
+ ap.SampleRate = parseProp(tags, "__samplerate")
+ ap.BitDepth = parseProp(tags, "__bitspersample")
+ length := parseProp(tags, "__lengthinmilliseconds")
+ ap.Duration = (time.Millisecond * time.Duration(length)).Round(time.Millisecond * 10)
+
+ // Extract basic tags
+ parseBasicTag(tags, "__title", "title")
+ parseBasicTag(tags, "__artist", "artist")
+ parseBasicTag(tags, "__album", "album")
+ parseBasicTag(tags, "__comment", "comment")
+ parseBasicTag(tags, "__genre", "genre")
+ parseBasicTag(tags, "__year", "year")
+ parseBasicTag(tags, "__track", "tracknumber")
// Parse track/disc totals
parseTuple := func(prop string) {
@@ -107,6 +105,31 @@ var tiplMapping = map[string]string{
"DJ-mix": "djmixer",
}
+// parseProp parses a property from the tags map and sets it to the target integer.
+// It also deletes the property from the tags map after parsing.
+func parseProp(tags map[string][]string, prop string) int {
+ if value, ok := tags[prop]; ok && len(value) > 0 {
+ v, _ := strconv.Atoi(value[0])
+ delete(tags, prop)
+ return v
+ }
+ return 0
+}
+
+// parseBasicTag checks if a basic tag (like __title, __artist, etc.) exists in the tags map.
+// If it does, it moves the value to a more appropriate tag name (like title, artist, etc.),
+// and deletes the basic tag from the map. If the target tag already exists, it ignores the basic tag.
+func parseBasicTag(tags map[string][]string, basicName string, tagName string) {
+ basicValue := tags[basicName]
+ if len(basicValue) == 0 {
+ return
+ }
+ delete(tags, basicName)
+ if len(tags[tagName]) == 0 {
+ tags[tagName] = basicValue
+ }
+}
+
// parseTIPL parses the ID3v2.4 TIPL frame string, which is received from TagLib in the format:
//
// "arranger Andrew Powell engineer Chris Blair engineer Pat Stapley producer Eric Woolfson".
diff --git a/adapters/taglib/taglib_wrapper.cpp b/adapters/taglib/taglib_wrapper.cpp
index 224642c6d..2985e8f18 100644
--- a/adapters/taglib/taglib_wrapper.cpp
+++ b/adapters/taglib/taglib_wrapper.cpp
@@ -45,31 +45,63 @@ int taglib_read(const FILENAME_CHAR_T *filename, unsigned long id) {
// Add audio properties to the tags
const TagLib::AudioProperties *props(f.audioProperties());
- goPutInt(id, (char *)"_lengthinmilliseconds", props->lengthInMilliseconds());
- goPutInt(id, (char *)"_bitrate", props->bitrate());
- goPutInt(id, (char *)"_channels", props->channels());
- goPutInt(id, (char *)"_samplerate", props->sampleRate());
+ goPutInt(id, (char *)"__lengthinmilliseconds", props->lengthInMilliseconds());
+ goPutInt(id, (char *)"__bitrate", props->bitrate());
+ goPutInt(id, (char *)"__channels", props->channels());
+ goPutInt(id, (char *)"__samplerate", props->sampleRate());
+ // Extract bits per sample for supported formats
+ int bitsPerSample = 0;
if (const auto* apeProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", apeProperties->bitsPerSample());
- if (const auto* asfProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", asfProperties->bitsPerSample());
+ bitsPerSample = apeProperties->bitsPerSample();
+ else if (const auto* asfProperties{ dynamic_cast(props) })
+ bitsPerSample = asfProperties->bitsPerSample();
else if (const auto* flacProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", flacProperties->bitsPerSample());
+ bitsPerSample = flacProperties->bitsPerSample();
else if (const auto* mp4Properties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", mp4Properties->bitsPerSample());
+ bitsPerSample = mp4Properties->bitsPerSample();
else if (const auto* wavePackProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", wavePackProperties->bitsPerSample());
+ bitsPerSample = wavePackProperties->bitsPerSample();
else if (const auto* aiffProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", aiffProperties->bitsPerSample());
+ bitsPerSample = aiffProperties->bitsPerSample();
else if (const auto* wavProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", wavProperties->bitsPerSample());
+ bitsPerSample = wavProperties->bitsPerSample();
else if (const auto* dsfProperties{ dynamic_cast(props) })
- goPutInt(id, (char *)"_bitspersample", dsfProperties->bitsPerSample());
+ bitsPerSample = dsfProperties->bitsPerSample();
+
+ if (bitsPerSample > 0) {
+ goPutInt(id, (char *)"__bitspersample", bitsPerSample);
+ }
// Send all properties to the Go map
TagLib::PropertyMap tags = f.file()->properties();
+ // Make sure at least the basic properties are extracted
+ TagLib::Tag *basic = f.file()->tag();
+ if (!basic->isEmpty()) {
+ if (!basic->title().isEmpty()) {
+ tags.insert("__title", basic->title());
+ }
+ if (!basic->artist().isEmpty()) {
+ tags.insert("__artist", basic->artist());
+ }
+ if (!basic->album().isEmpty()) {
+ tags.insert("__album", basic->album());
+ }
+ if (!basic->comment().isEmpty()) {
+ tags.insert("__comment", basic->comment());
+ }
+ if (!basic->genre().isEmpty()) {
+ tags.insert("__genre", basic->genre());
+ }
+ if (basic->year() > 0) {
+ tags.insert("__year", TagLib::String::number(basic->year()));
+ }
+ if (basic->track() > 0) {
+ tags.insert("__track", TagLib::String::number(basic->track()));
+ }
+ }
+
TagLib::ID3v2::Tag *id3Tags = NULL;
// Get some extended/non-standard ID3-only tags (ex: iTunes extended frames)
From 465846c1bc66a40a24f174ab3d23cc11f59a24a4 Mon Sep 17 00:00:00 2001
From: Konstantin Morenko
Date: Wed, 29 Oct 2025 16:14:40 +0300
Subject: [PATCH 010/102] fix(ui): fix color of MuiIconButton in Gruvbox Dark
theme (#4585)
* Fixed color of MuiIconButton in gruvboxDark.js
* Update ui/src/themes/gruvboxDark.js
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
---------
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
---
ui/src/themes/gruvboxDark.js | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/ui/src/themes/gruvboxDark.js b/ui/src/themes/gruvboxDark.js
index b576e7713..b1a2e4c90 100644
--- a/ui/src/themes/gruvboxDark.js
+++ b/ui/src/themes/gruvboxDark.js
@@ -40,6 +40,11 @@ export default {
color: '#ebdbb2',
},
},
+ MuiIconButton: {
+ root: {
+ color: '#ebdbb2',
+ },
+ },
MuiChip: {
clickable: {
background: '#49483e',
From 0bdd3e6f8ba29acf525a2a165407090b34f542b8 Mon Sep 17 00:00:00 2001
From: deluan
Date: Thu, 30 Oct 2025 16:34:31 -0400
Subject: [PATCH 011/102] fix(ui): fix Ligera theme's RaPaginationActions
contrast
---
ui/src/themes/ligera.js | 12 ++++++++++--
1 file changed, 10 insertions(+), 2 deletions(-)
diff --git a/ui/src/themes/ligera.js b/ui/src/themes/ligera.js
index 824cf7e67..0ef1601a2 100644
--- a/ui/src/themes/ligera.js
+++ b/ui/src/themes/ligera.js
@@ -450,13 +450,21 @@ export default {
},
RaPaginationActions: {
button: {
- backgroundColor: 'inherit',
+ backgroundColor: '#fff',
+ color: '#000',
minWidth: 48,
margin: '0 4px',
- border: '1px solid #282828',
+ border: '1px solid #cccccc',
'@global': {
'> .MuiButton-label': {
padding: 0,
+ color: '#656565',
+ '&:hover': {
+ color: '#fff !important',
+ },
+ },
+ '> .MuiButton-label > svg': {
+ color: '#656565',
},
},
},
From 91fab68578d8fa3ab7a8606c421ef1e3b67d77a2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Fri, 31 Oct 2025 09:07:23 -0400
Subject: [PATCH 012/102] fix: handle UTF BOM in lyrics and playlist files
(#4637)
* fix: handle UTF-8 BOM in lyrics and playlist files
Added UTF-8 BOM (Byte Order Mark) detection and stripping for external lyrics files and playlist files. This ensures that files with BOM markers are correctly parsed and recognized as synced lyrics or valid playlists.
The fix introduces a new ioutils package with UTF8Reader and UTF8ReadFile functions that automatically detect and remove UTF-8, UTF-16 LE, and UTF-16 BE BOMs. These utilities are now used when reading external lyrics and playlist files to ensure consistent parsing regardless of BOM presence.
Added comprehensive tests for BOM handling in both lyrics and playlists, including test fixtures with actual BOM markers to verify correct behavior.
* test: add test for UTF-16 LE encoded LRC files
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
---
core/lyrics/sources.go | 4 +-
core/lyrics/sources_test.go | 34 ++++++
core/playlists.go | 6 +-
core/playlists_test.go | 18 +++
tests/fixtures/bom-test.lrc | 4 +
tests/fixtures/bom-utf16-test.lrc | Bin 0 -> 164 bytes
tests/fixtures/playlists/bom-test-utf16.m3u | Bin 0 -> 412 bytes
tests/fixtures/playlists/bom-test.m3u | 6 +
utils/ioutils/ioutils.go | 33 ++++++
utils/ioutils/ioutils_test.go | 117 ++++++++++++++++++++
10 files changed, 218 insertions(+), 4 deletions(-)
create mode 100644 tests/fixtures/bom-test.lrc
create mode 100644 tests/fixtures/bom-utf16-test.lrc
create mode 100644 tests/fixtures/playlists/bom-test-utf16.m3u
create mode 100644 tests/fixtures/playlists/bom-test.m3u
create mode 100644 utils/ioutils/ioutils.go
create mode 100644 utils/ioutils/ioutils_test.go
diff --git a/core/lyrics/sources.go b/core/lyrics/sources.go
index 6d4a4cc6f..857dc2eef 100644
--- a/core/lyrics/sources.go
+++ b/core/lyrics/sources.go
@@ -8,6 +8,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils/ioutils"
)
func fromEmbedded(ctx context.Context, mf *model.MediaFile) (model.LyricList, error) {
@@ -27,8 +28,7 @@ func fromExternalFile(ctx context.Context, mf *model.MediaFile, suffix string) (
externalLyric := basePath[0:len(basePath)-len(ext)] + suffix
- contents, err := os.ReadFile(externalLyric)
-
+ contents, err := ioutils.UTF8ReadFile(externalLyric)
if errors.Is(err, os.ErrNotExist) {
log.Trace(ctx, "no lyrics found at path", "path", externalLyric)
return nil, nil
diff --git a/core/lyrics/sources_test.go b/core/lyrics/sources_test.go
index e92564c00..b3d502101 100644
--- a/core/lyrics/sources_test.go
+++ b/core/lyrics/sources_test.go
@@ -108,5 +108,39 @@ var _ = Describe("sources", func() {
},
}))
})
+
+ It("should handle LRC files with UTF-8 BOM marker (issue #4631)", func() {
+ // The function looks for , so we need to pass
+ // a MediaFile with .mp3 path and look for .lrc suffix
+ mf := model.MediaFile{Path: "tests/fixtures/bom-test.mp3"}
+ lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
+
+ Expect(err).To(BeNil())
+ Expect(lyrics).ToNot(BeNil())
+ Expect(lyrics).To(HaveLen(1))
+
+ // 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].Value).To(ContainSubstring("作曲"))
+ })
+
+ It("should handle UTF-16 LE encoded LRC files", func() {
+ mf := model.MediaFile{Path: "tests/fixtures/bom-utf16-test.mp3"}
+ lyrics, err := fromExternalFile(ctx, &mf, ".lrc")
+
+ Expect(err).To(BeNil())
+ Expect(lyrics).ToNot(BeNil())
+ Expect(lyrics).To(HaveLen(1))
+
+ // 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].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].Value).To(Equal("You know the rules and so do I"))
+ })
})
})
diff --git a/core/playlists.go b/core/playlists.go
index 2eebc94e7..f98179f88 100644
--- a/core/playlists.go
+++ b/core/playlists.go
@@ -20,6 +20,7 @@ import (
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/utils/ioutils"
"github.com/navidrome/navidrome/utils/slice"
"golang.org/x/text/unicode/norm"
)
@@ -97,12 +98,13 @@ func (s *playlists) parsePlaylist(ctx context.Context, playlistFile string, fold
}
defer file.Close()
+ reader := ioutils.UTF8Reader(file)
extension := strings.ToLower(filepath.Ext(playlistFile))
switch extension {
case ".nsp":
- err = s.parseNSP(ctx, pls, file)
+ err = s.parseNSP(ctx, pls, reader)
default:
- err = s.parseM3U(ctx, pls, folder, file)
+ err = s.parseM3U(ctx, pls, folder, reader)
}
return pls, err
}
diff --git a/core/playlists_test.go b/core/playlists_test.go
index 399210ac8..fb42f9c9f 100644
--- a/core/playlists_test.go
+++ b/core/playlists_test.go
@@ -74,6 +74,24 @@ var _ = Describe("Playlists", func() {
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")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.OwnerID).To(Equal("123"))
+ Expect(pls.Name).To(Equal("Test Playlist"))
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
+ })
+
+ 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")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(pls.OwnerID).To(Equal("123"))
+ Expect(pls.Name).To(Equal("UTF-16 Test Playlist"))
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].Path).To(Equal("tests/fixtures/playlists/test.mp3"))
+ })
})
Describe("NSP", func() {
diff --git a/tests/fixtures/bom-test.lrc b/tests/fixtures/bom-test.lrc
new file mode 100644
index 000000000..223c37de0
--- /dev/null
+++ b/tests/fixtures/bom-test.lrc
@@ -0,0 +1,4 @@
+[00:00.00] 作曲 : 柏大輔
+NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at byte 0.
+This tests BOM handling in lyrics parsing (GitHub issue #4631).
+The BOM bytes are: 0xEF 0xBB 0xBF
\ No newline at end of file
diff --git a/tests/fixtures/bom-utf16-test.lrc b/tests/fixtures/bom-utf16-test.lrc
new file mode 100644
index 0000000000000000000000000000000000000000..e40ea3255fd95fe3b366e41cad0d4502ce35bd6a
GIT binary patch
literal 164
zcmXwxK?;K~6hvq3DYA1X(UtTDoU+!?pVsC2jhAtvU#k~w>BPFF`LEbibh%5bBSXczJ$t*hDT<7d=2hY)
zU)soAr_8dgt5`EgGiGvL@t~bBmw$Y)MbYvEW(RulUd{a`UM;tC$hnt1Ri)V>sJwS_
WH@`2#-`_mZuBGU99`G#n$jmR%nn4r*
literal 0
HcmV?d00001
diff --git a/tests/fixtures/playlists/bom-test.m3u b/tests/fixtures/playlists/bom-test.m3u
new file mode 100644
index 000000000..f5a00806c
--- /dev/null
+++ b/tests/fixtures/playlists/bom-test.m3u
@@ -0,0 +1,6 @@
+#EXTM3U
+# NOTE: This file intentionally contains a UTF-8 BOM (Byte Order Mark) at the beginning
+# (bytes 0xEF 0xBB 0xBF) to test BOM handling in playlist parsing.
+#PLAYLIST:Test Playlist
+#EXTINF:123,Test Artist - Test Song
+test.mp3
diff --git a/utils/ioutils/ioutils.go b/utils/ioutils/ioutils.go
new file mode 100644
index 000000000..89d3997f3
--- /dev/null
+++ b/utils/ioutils/ioutils.go
@@ -0,0 +1,33 @@
+package ioutils
+
+import (
+ "io"
+ "os"
+
+ "golang.org/x/text/encoding/unicode"
+ "golang.org/x/text/transform"
+)
+
+// UTF8Reader wraps an io.Reader to handle Byte Order Mark (BOM) properly.
+// It strips UTF-8 BOM if present, and converts UTF-16 (LE/BE) to UTF-8.
+// This is particularly useful for reading user-provided text files (like LRC lyrics,
+// playlists) that may have been created on Windows, which often adds BOM markers.
+//
+// Reference: https://en.wikipedia.org/wiki/Byte_order_mark
+func UTF8Reader(r io.Reader) io.Reader {
+ return transform.NewReader(r, unicode.BOMOverride(unicode.UTF8.NewDecoder()))
+}
+
+// UTF8ReadFile reads the named file and returns its contents as a byte slice,
+// automatically handling BOM markers. It's similar to os.ReadFile but strips
+// UTF-8 BOM and converts UTF-16 encoded files to UTF-8.
+func UTF8ReadFile(filename string) ([]byte, error) {
+ file, err := os.Open(filename)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ reader := UTF8Reader(file)
+ return io.ReadAll(reader)
+}
diff --git a/utils/ioutils/ioutils_test.go b/utils/ioutils/ioutils_test.go
new file mode 100644
index 000000000..7f5483879
--- /dev/null
+++ b/utils/ioutils/ioutils_test.go
@@ -0,0 +1,117 @@
+package ioutils
+
+import (
+ "bytes"
+ "io"
+ "testing"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+func TestIOUtils(t *testing.T) {
+ RegisterFailHandler(Fail)
+ RunSpecs(t, "IO Utils Suite")
+}
+
+var _ = Describe("UTF8Reader", func() {
+ Context("when reading text with UTF-8 BOM", func() {
+ It("strips the UTF-8 BOM marker", func() {
+ // UTF-8 BOM is EF BB BF
+ input := []byte{0xEF, 0xBB, 0xBF, 'h', 'e', 'l', 'l', 'o'}
+ reader := UTF8Reader(bytes.NewReader(input))
+
+ output, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(output)).To(Equal("hello"))
+ })
+
+ It("strips UTF-8 BOM from multi-line text", func() {
+ // Test with the actual LRC file format
+ input := []byte{0xEF, 0xBB, 0xBF, '[', '0', '0', ':', '0', '0', '.', '0', '0', ']', ' ', 't', 'e', 's', 't'}
+ reader := UTF8Reader(bytes.NewReader(input))
+
+ output, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(output)).To(Equal("[00:00.00] test"))
+ })
+ })
+
+ Context("when reading text without BOM", func() {
+ It("passes through unchanged", func() {
+ input := []byte("hello world")
+ reader := UTF8Reader(bytes.NewReader(input))
+
+ output, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(output)).To(Equal("hello world"))
+ })
+ })
+
+ Context("when reading UTF-16 LE encoded text", func() {
+ It("converts to UTF-8 and strips BOM", func() {
+ // UTF-16 LE BOM (FF FE) followed by "hi" in UTF-16 LE
+ input := []byte{0xFF, 0xFE, 'h', 0x00, 'i', 0x00}
+ reader := UTF8Reader(bytes.NewReader(input))
+
+ output, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(output)).To(Equal("hi"))
+ })
+ })
+
+ Context("when reading UTF-16 BE encoded text", func() {
+ It("converts to UTF-8 and strips BOM", func() {
+ // UTF-16 BE BOM (FE FF) followed by "hi" in UTF-16 BE
+ input := []byte{0xFE, 0xFF, 0x00, 'h', 0x00, 'i'}
+ reader := UTF8Reader(bytes.NewReader(input))
+
+ output, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(output)).To(Equal("hi"))
+ })
+ })
+
+ Context("when reading empty content", func() {
+ It("returns empty string", func() {
+ reader := UTF8Reader(bytes.NewReader([]byte{}))
+
+ output, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(output)).To(Equal(""))
+ })
+ })
+})
+
+var _ = Describe("UTF8ReadFile", func() {
+ Context("when reading a file with UTF-8 BOM", func() {
+ It("strips the BOM marker", func() {
+ // Use the actual fixture from issue #4631
+ contents, err := UTF8ReadFile("../../tests/fixtures/bom-test.lrc")
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should NOT start with BOM
+ Expect(contents[0]).ToNot(Equal(byte(0xEF)))
+ // Should start with '['
+ Expect(contents[0]).To(Equal(byte('[')))
+ Expect(string(contents)).To(HavePrefix("[00:00.00]"))
+ })
+ })
+
+ Context("when reading a file without BOM", func() {
+ It("reads the file normally", func() {
+ contents, err := UTF8ReadFile("../../tests/fixtures/test.lrc")
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should contain the expected content
+ Expect(string(contents)).To(ContainSubstring("We're no strangers to love"))
+ })
+ })
+
+ Context("when reading a non-existent file", func() {
+ It("returns an error", func() {
+ _, err := UTF8ReadFile("../../tests/fixtures/nonexistent.lrc")
+ Expect(err).To(HaveOccurred())
+ })
+ })
+})
From 775626e037b4b7436be06167b7b8c30a38de3e9a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Sat, 1 Nov 2025 20:25:33 -0400
Subject: [PATCH 013/102] refactor(scanner): optimize update artist's
statistics using normalized media_file_artists table (#4641)
Optimized to use the normalized media_file_artists table instead of parsing JSONB
Signed-off-by: Deluan
---
persistence/artist_repository.go | 31 ++++++++++++-------------------
1 file changed, 12 insertions(+), 19 deletions(-)
diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go
index a7cf9272a..6d08c27db 100644
--- a/persistence/artist_repository.go
+++ b/persistence/artist_repository.go
@@ -400,23 +400,16 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
// This now calculates per-library statistics and stores them in library_artist.stats
batchUpdateStatsSQL := `
WITH artist_role_counters AS (
- SELECT jt.atom AS artist_id,
+ SELECT mfa.artist_id,
mf.library_id,
- substr(
- replace(jt.path, '$.', ''),
- 1,
- CASE WHEN instr(replace(jt.path, '$.', ''), '[') > 0
- THEN instr(replace(jt.path, '$.', ''), '[') - 1
- ELSE length(replace(jt.path, '$.', ''))
- END
- ) AS role,
+ mfa.role,
count(DISTINCT mf.album_id) AS album_count,
- count(mf.id) AS count,
+ count(DISTINCT mf.id) AS count,
sum(mf.size) AS size
- FROM media_file mf
- JOIN json_tree(mf.participants) jt ON jt.key = 'id' AND jt.atom IS NOT NULL
- WHERE jt.atom IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
- GROUP BY jt.atom, mf.library_id, role
+ FROM media_file_artists mfa
+ JOIN media_file mf ON mfa.media_file_id = mf.id
+ WHERE mfa.artist_id IN (ROLE_IDS_PLACEHOLDER) -- Will replace with actual placeholders
+ GROUP BY mfa.artist_id, mf.library_id, mfa.role
),
artist_total_counters AS (
SELECT mfa.artist_id,
@@ -445,24 +438,24 @@ func (r *artistRepository) RefreshStats(allArtists bool) (int64, error) {
),
combined_counters AS (
SELECT artist_id, library_id, role, album_count, count, size FROM artist_role_counters
- UNION
+ UNION ALL
SELECT artist_id, library_id, role, album_count, count, size FROM artist_total_counters
- UNION
+ UNION ALL
SELECT artist_id, library_id, role, album_count, count, size FROM artist_participant_counter
),
library_artist_counters AS (
SELECT artist_id,
library_id,
json_group_object(
- replace(role, '"', ''),
+ role,
json_object('a', album_count, 'm', count, 's', size)
) AS counters
FROM combined_counters
GROUP BY artist_id, library_id
)
UPDATE library_artist
- SET stats = coalesce((SELECT counters FROM library_artist_counters lac
- WHERE lac.artist_id = library_artist.artist_id
+ SET stats = coalesce((SELECT counters FROM library_artist_counters lac
+ WHERE lac.artist_id = library_artist.artist_id
AND lac.library_id = library_artist.library_id), '{}')
WHERE library_artist.artist_id IN (ROLE_IDS_PLACEHOLDER);` // Will replace with actual placeholders
From e86dc03619ffb8477083de23bb4daed567ef0a2c Mon Sep 17 00:00:00 2001
From: pca006132
Date: Sun, 2 Nov 2025 08:47:03 +0800
Subject: [PATCH 014/102] fix(ui): allow scrolling in play queue by adding
delay (#4562)
---
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 05ca6ddf7..03419add3 100644
--- a/ui/src/audioplayer/Player.jsx
+++ b/ui/src/audioplayer/Player.jsx
@@ -127,6 +127,7 @@ const Player = () => {
/>
),
locale: locale(translate),
+ sortableOptions: { delay: 200, delayOnTouchOnly: true },
}),
[gainInfo, isDesktop, playerTheme, translate, playerState.mode],
)
From 0c71842b12295dabfd3e14bfb5c8175312dde5fd Mon Sep 17 00:00:00 2001
From: Deluan
Date: Thu, 6 Nov 2025 12:40:44 -0500
Subject: [PATCH 015/102] chore: update Go version to 1.25.4
Signed-off-by: Deluan
---
go.mod | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/go.mod b/go.mod
index 265cbfa6d..2d760d78d 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/navidrome/navidrome
-go 1.25.3
+go 1.25.4
// Fork to fix https://github.com/navidrome/navidrome/pull/3254
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
From c501bc6996f48a99f75fb4727ec662da9d04ee99 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Thu, 6 Nov 2025 12:41:16 -0500
Subject: [PATCH 016/102] chore(deps): update ginkgo to version 2.27.2
Signed-off-by: Deluan
---
go.mod | 2 +-
go.sum | 2 ++
2 files changed, 3 insertions(+), 1 deletion(-)
diff --git a/go.mod b/go.mod
index 2d760d78d..894ad8372 100644
--- a/go.mod
+++ b/go.mod
@@ -43,7 +43,7 @@ require (
github.com/mattn/go-sqlite3 v1.14.32
github.com/microcosm-cc/bluemonday v1.0.27
github.com/mileusna/useragent v1.3.5
- github.com/onsi/ginkgo/v2 v2.27.1
+ github.com/onsi/ginkgo/v2 v2.27.2
github.com/onsi/gomega v1.38.2
github.com/pelletier/go-toml/v2 v2.2.4
github.com/pocketbase/dbx v1.11.0
diff --git a/go.sum b/go.sum
index f9e620fb2..917f923c9 100644
--- a/go.sum
+++ b/go.sum
@@ -188,6 +188,8 @@ 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.27.1 h1:0LJC8MpUSQnfnp4n/3W3GdlmJP3ENGF0ZPzjQGLPP7s=
github.com/onsi/ginkgo/v2 v2.27.1/go.mod h1:wmy3vCqiBjirARfVhAqFpYt8uvX0yaFe+GudAqqcCqA=
+github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
+github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
From 0a5abfc1b192ada4c82271e8bf622887ae78fde5 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Thu, 6 Nov 2025 12:43:35 -0500
Subject: [PATCH 017/102] chore: update actions/upload-artifact and
actions/download-artifact to latest versions
Signed-off-by: Deluan
---
.github/workflows/pipeline.yml | 18 +++++++++---------
1 file changed, 9 insertions(+), 9 deletions(-)
diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml
index 232171c6d..0767346fa 100644
--- a/.github/workflows/pipeline.yml
+++ b/.github/workflows/pipeline.yml
@@ -217,7 +217,7 @@ jobs:
CROSS_TAGLIB_VERSION=${{ env.CROSS_TAGLIB_VERSION }}
- name: Upload Binaries
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
with:
name: navidrome-${{ env.PLATFORM }}
path: ./output
@@ -248,7 +248,7 @@ jobs:
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
if: env.IS_LINUX == 'true' && env.IS_DOCKER_PUSH_CONFIGURED == 'true' && env.IS_ARMV5 == 'false'
with:
name: digests-${{ env.PLATFORM }}
@@ -267,7 +267,7 @@ jobs:
- uses: actions/checkout@v5
- name: Download digests
- uses: actions/download-artifact@v5
+ uses: actions/download-artifact@v6
with:
path: /tmp/digests
pattern: digests-*
@@ -320,7 +320,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- - uses: actions/download-artifact@v5
+ - uses: actions/download-artifact@v6
with:
path: ./binaries
pattern: navidrome-windows*
@@ -339,7 +339,7 @@ jobs:
du -h binaries/msi/*.msi
- name: Upload MSI files
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
with:
name: navidrome-windows-installers
path: binaries/msi/*.msi
@@ -357,7 +357,7 @@ jobs:
fetch-depth: 0
fetch-tags: true
- - uses: actions/download-artifact@v5
+ - uses: actions/download-artifact@v6
with:
path: ./binaries
pattern: navidrome-*
@@ -383,7 +383,7 @@ jobs:
rm ./dist/*.tar.gz ./dist/*.zip
- name: Upload all-packages artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
with:
name: packages
path: dist/navidrome_0*
@@ -406,13 +406,13 @@ jobs:
item: ${{ fromJson(needs.release.outputs.package_list) }}
steps:
- name: Download all-packages artifact
- uses: actions/download-artifact@v5
+ uses: actions/download-artifact@v6
with:
name: packages
path: ./dist
- name: Upload all-packages artifact
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v5
with:
name: navidrome_linux_${{ matrix.item }}
path: dist/navidrome_0*_linux_${{ matrix.item }}
From 3dfaa8cca15ea7a1ff3991c7c16d87ac218739f2 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Thu, 6 Nov 2025 12:53:41 -0500
Subject: [PATCH 018/102] ci: go mod tidy
Signed-off-by: Deluan
---
go.mod | 1 -
go.sum | 6 ------
2 files changed, 7 deletions(-)
diff --git a/go.mod b/go.mod
index 894ad8372..932e4c211 100644
--- a/go.mod
+++ b/go.mod
@@ -124,7 +124,6 @@ require (
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
- go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
diff --git a/go.sum b/go.sum
index 917f923c9..97fe24b35 100644
--- a/go.sum
+++ b/go.sum
@@ -186,8 +186,6 @@ github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdh
github.com/ncruces/go-strftime v0.1.9/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.27.1 h1:0LJC8MpUSQnfnp4n/3W3GdlmJP3ENGF0ZPzjQGLPP7s=
-github.com/onsi/ginkgo/v2 v2.27.1/go.mod h1:wmy3vCqiBjirARfVhAqFpYt8uvX0yaFe+GudAqqcCqA=
github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns=
github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo=
github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
@@ -203,8 +201,6 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
-github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
-github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/pressly/goose/v3 v3.26.0 h1:KJakav68jdH0WDvoAcj8+n61WqOIaPGgH0bJWS6jpmM=
github.com/pressly/goose/v3 v3.26.0/go.mod h1:4hC1KrritdCxtuFsqgs1R4AU5bWtTAf+cnWvfhf2DNY=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
@@ -288,8 +284,6 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
-go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
-go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
From fe1cee0159f0228ecaf64a0a7bcc0fd137de017a Mon Sep 17 00:00:00 2001
From: beerpsi <92439990+beer-psi@users.noreply.github.com>
Date: Fri, 7 Nov 2025 02:24:07 +0700
Subject: [PATCH 019/102] fix(share): slice content label by utf-8 runes
(#4634)
* fix(share): slice content label by utf-8 runes
* Apply suggestions about avoiding allocations
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
* lint: remove unused import
* test: add test cases for CJK truncation
* test: add tests for ASCII labels too
---------
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
---
core/share.go | 17 +++++++++++++++--
core/share_test.go | 32 ++++++++++++++++++++++++++++++++
2 files changed, 47 insertions(+), 2 deletions(-)
diff --git a/core/share.go b/core/share.go
index 202c27d89..d653795ec 100644
--- a/core/share.go
+++ b/core/share.go
@@ -119,8 +119,21 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
log.Error(r.ctx, "Invalid Resource ID", "id", firstId)
return "", model.ErrNotFound
}
- if len(s.Contents) > 30 {
- s.Contents = s.Contents[:26] + "..."
+
+ const maxContentRunes = 30
+ const truncateToRunes = 26
+
+ var runeCount int
+ var truncateIndex int
+ for i := range s.Contents {
+ runeCount++
+ if runeCount == truncateToRunes+1 {
+ truncateIndex = i
+ }
+ }
+
+ if runeCount > maxContentRunes {
+ s.Contents = s.Contents[:truncateIndex] + "..."
}
id, err = r.Persistable.Save(s)
diff --git a/core/share_test.go b/core/share_test.go
index 21069bb59..ad5a986b1 100644
--- a/core/share_test.go
+++ b/core/share_test.go
@@ -38,6 +38,38 @@ var _ = Describe("Share", func() {
Expect(id).ToNot(BeEmpty())
Expect(entity.ID).To(Equal(id))
})
+
+ It("does not truncate ASCII labels shorter than 30 characters", func() {
+ _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "Example Media File"})
+ entity := &model.Share{Description: "test", ResourceIDs: "456"}
+ _, err := repo.Save(entity)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(entity.Contents).To(Equal("Example Media File"))
+ })
+
+ It("truncates ASCII labels longer than 30 characters", func() {
+ _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "Example Media File But The Title Is Really Long For Testing Purposes"})
+ entity := &model.Share{Description: "test", ResourceIDs: "789"}
+ _, err := repo.Save(entity)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(entity.Contents).To(Equal("Example Media File But The..."))
+ })
+
+ It("does not truncate CJK labels shorter than 30 runes", func() {
+ _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "456", Title: "青春コンプレックス"})
+ entity := &model.Share{Description: "test", ResourceIDs: "456"}
+ _, err := repo.Save(entity)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(entity.Contents).To(Equal("青春コンプレックス"))
+ })
+
+ It("truncates CJK labels longer than 30 runes", func() {
+ _ = ds.MediaFile(ctx).Put(&model.MediaFile{ID: "789", Title: "私の中の幻想的世界観及びその顕現を想起させたある現実での出来事に関する一考察"})
+ entity := &model.Share{Description: "test", ResourceIDs: "789"}
+ _, err := repo.Save(entity)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実..."))
+ })
})
Describe("Update", func() {
From 58b5ed86dffb91c0da71f8933c332249a3613414 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Thu, 6 Nov 2025 14:26:51 -0500
Subject: [PATCH 020/102] refactor: extract TruncateRunes function for safe
string truncation with suffix
Signed-off-by: Deluan
# Conflicts:
# core/share.go
# core/share_test.go
---
core/share.go | 17 ++---------
core/share_test.go | 4 +--
utils/str/str.go | 23 +++++++++++++++
utils/str/str_test.go | 66 +++++++++++++++++++++++++++++++++++++++++++
4 files changed, 93 insertions(+), 17 deletions(-)
diff --git a/core/share.go b/core/share.go
index d653795ec..eb5e6679b 100644
--- a/core/share.go
+++ b/core/share.go
@@ -13,6 +13,7 @@ import (
"github.com/navidrome/navidrome/model"
. "github.com/navidrome/navidrome/utils/gg"
"github.com/navidrome/navidrome/utils/slice"
+ "github.com/navidrome/navidrome/utils/str"
)
type Share interface {
@@ -120,21 +121,7 @@ func (r *shareRepositoryWrapper) Save(entity interface{}) (string, error) {
return "", model.ErrNotFound
}
- const maxContentRunes = 30
- const truncateToRunes = 26
-
- var runeCount int
- var truncateIndex int
- for i := range s.Contents {
- runeCount++
- if runeCount == truncateToRunes+1 {
- truncateIndex = i
- }
- }
-
- if runeCount > maxContentRunes {
- s.Contents = s.Contents[:truncateIndex] + "..."
- }
+ s.Contents = str.TruncateRunes(s.Contents, 30, "...")
id, err = r.Persistable.Save(s)
return id, err
diff --git a/core/share_test.go b/core/share_test.go
index ad5a986b1..475d40ec9 100644
--- a/core/share_test.go
+++ b/core/share_test.go
@@ -52,7 +52,7 @@ var _ = Describe("Share", func() {
entity := &model.Share{Description: "test", ResourceIDs: "789"}
_, err := repo.Save(entity)
Expect(err).ToNot(HaveOccurred())
- Expect(entity.Contents).To(Equal("Example Media File But The..."))
+ Expect(entity.Contents).To(Equal("Example Media File But The ..."))
})
It("does not truncate CJK labels shorter than 30 runes", func() {
@@ -68,7 +68,7 @@ var _ = Describe("Share", func() {
entity := &model.Share{Description: "test", ResourceIDs: "789"}
_, err := repo.Save(entity)
Expect(err).ToNot(HaveOccurred())
- Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実..."))
+ Expect(entity.Contents).To(Equal("私の中の幻想的世界観及びその顕現を想起させたある現実で..."))
})
})
diff --git a/utils/str/str.go b/utils/str/str.go
index 8a94488de..f662473da 100644
--- a/utils/str/str.go
+++ b/utils/str/str.go
@@ -2,6 +2,7 @@ package str
import (
"strings"
+ "unicode/utf8"
)
var utf8ToAscii = func() *strings.Replacer {
@@ -39,3 +40,25 @@ func LongestCommonPrefix(list []string) string {
}
return list[0]
}
+
+// TruncateRunes truncates a string to a maximum number of runes, adding a suffix if truncated.
+// The suffix is included in the rune count, so if maxRunes is 30 and suffix is "...", the actual
+// string content will be truncated to fit within the maxRunes limit including the suffix.
+func TruncateRunes(s string, maxRunes int, suffix string) string {
+ if utf8.RuneCountInString(s) <= maxRunes {
+ return s
+ }
+
+ suffixRunes := utf8.RuneCountInString(suffix)
+ truncateAt := maxRunes - suffixRunes
+ if truncateAt < 0 {
+ truncateAt = 0
+ }
+
+ runes := []rune(s)
+ if truncateAt >= len(runes) {
+ return s + suffix
+ }
+
+ return string(runes[:truncateAt]) + suffix
+}
diff --git a/utils/str/str_test.go b/utils/str/str_test.go
index 0c3524e4e..511805831 100644
--- a/utils/str/str_test.go
+++ b/utils/str/str_test.go
@@ -31,6 +31,72 @@ var _ = Describe("String Utils", func() {
Expect(str.LongestCommonPrefix(albums)).To(Equal("/artist/album"))
})
})
+
+ Describe("TruncateRunes", func() {
+ It("returns string unchanged if under max runes", func() {
+ Expect(str.TruncateRunes("hello", 10, "...")).To(Equal("hello"))
+ })
+
+ It("returns string unchanged if exactly at max runes", func() {
+ Expect(str.TruncateRunes("hello", 5, "...")).To(Equal("hello"))
+ })
+
+ It("truncates and adds suffix when over max runes", func() {
+ Expect(str.TruncateRunes("hello world", 8, "...")).To(Equal("hello..."))
+ })
+
+ It("handles unicode characters correctly", func() {
+ // 6 emoji characters, maxRunes=5, suffix="..." (3 runes)
+ // So content gets 5-3=2 runes
+ Expect(str.TruncateRunes("😀😁😂😃😄😅", 5, "...")).To(Equal("😀😁..."))
+ })
+
+ It("handles multi-byte UTF-8 characters", func() {
+ // Characters like é are single runes
+ Expect(str.TruncateRunes("Café au Lait", 5, "...")).To(Equal("Ca..."))
+ })
+
+ It("works with empty suffix", func() {
+ Expect(str.TruncateRunes("hello world", 5, "")).To(Equal("hello"))
+ })
+
+ It("accounts for suffix length in truncation", func() {
+ // maxRunes=10, suffix="..." (3 runes) -> leaves 7 runes for content
+ result := str.TruncateRunes("hello world this is long", 10, "...")
+ Expect(result).To(Equal("hello w..."))
+ // Verify total rune count is <= maxRunes
+ runeCount := len([]rune(result))
+ Expect(runeCount).To(BeNumerically("<=", 10))
+ })
+
+ It("handles very long suffix gracefully", func() {
+ // If suffix is longer than maxRunes, we still add it
+ // but the content will be truncated to 0
+ result := str.TruncateRunes("hello world", 5, "... (truncated)")
+ // Result will be just the suffix (since truncateAt=0)
+ Expect(result).To(Equal("... (truncated)"))
+ })
+
+ It("handles empty string", func() {
+ Expect(str.TruncateRunes("", 10, "...")).To(Equal(""))
+ })
+
+ It("uses custom suffix", func() {
+ // maxRunes=11, suffix=" [...]" (6 runes) -> content gets 5 runes
+ // "hello world" is 11 runes exactly, so we need a longer string
+ Expect(str.TruncateRunes("hello world extra", 11, " [...]")).To(Equal("hello [...]"))
+ })
+
+ DescribeTable("truncates at rune boundaries (not byte boundaries)",
+ func(input string, maxRunes int, suffix string, expected string) {
+ Expect(str.TruncateRunes(input, maxRunes, suffix)).To(Equal(expected))
+ },
+ Entry("ASCII", "abcdefghij", 5, "...", "ab..."),
+ Entry("Mixed ASCII and Unicode", "ab😀cd", 4, ".", "ab😀."),
+ Entry("All emoji", "😀😁😂😃😄", 3, "…", "😀😁…"),
+ Entry("Japanese", "こんにちは世界", 3, "…", "こん…"),
+ )
+ })
})
var testPaths = []string{
From 290a9fdeaa5f776f30fb1f0eba4419a0546e1420 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Thu, 6 Nov 2025 14:34:00 -0500
Subject: [PATCH 021/102] test: fix locale-dependent tests by making
formatNumber locale-aware (#4619)
- Add optional locale parameter to formatNumber function
- Update tests to explicitly pass 'en-US' locale for deterministic results
- Maintains backward compatibility: defaults to system locale when no locale specified
- No need for cross-env or environment variable manipulation
- Tests now pass consistently regardless of system locale
Related to #4417
---
ui/src/utils/formatters.js | 4 ++--
ui/src/utils/formatters.test.js | 30 +++++++++++++++---------------
2 files changed, 17 insertions(+), 17 deletions(-)
diff --git a/ui/src/utils/formatters.js b/ui/src/utils/formatters.js
index 74cce6e15..cfcb84b05 100644
--- a/ui/src/utils/formatters.js
+++ b/ui/src/utils/formatters.js
@@ -95,7 +95,7 @@ export const formatFullDate = (date, locale) => {
return new Date(date).toLocaleDateString(locale, options)
}
-export const formatNumber = (value) => {
+export const formatNumber = (value, locale) => {
if (value === null || value === undefined) return '0'
- return value.toLocaleString()
+ return value.toLocaleString(locale)
}
diff --git a/ui/src/utils/formatters.test.js b/ui/src/utils/formatters.test.js
index 7709dd91b..d633e96f2 100644
--- a/ui/src/utils/formatters.test.js
+++ b/ui/src/utils/formatters.test.js
@@ -121,35 +121,35 @@ describe('formatDuration2', () => {
describe('formatNumber', () => {
it('handles null and undefined values', () => {
- expect(formatNumber(null)).toEqual('0')
- expect(formatNumber(undefined)).toEqual('0')
+ expect(formatNumber(null, 'en-CA')).toEqual('0')
+ expect(formatNumber(undefined, 'en-CA')).toEqual('0')
})
it('formats integers', () => {
- expect(formatNumber(0)).toEqual('0')
- expect(formatNumber(1)).toEqual('1')
- expect(formatNumber(123)).toEqual('123')
- expect(formatNumber(1000)).toEqual('1,000')
- expect(formatNumber(1234567)).toEqual('1,234,567')
+ expect(formatNumber(0, 'en-CA')).toEqual('0')
+ expect(formatNumber(1, 'en-CA')).toEqual('1')
+ expect(formatNumber(123, 'en-CA')).toEqual('123')
+ expect(formatNumber(1000, 'en-CA')).toEqual('1,000')
+ expect(formatNumber(1234567, 'en-CA')).toEqual('1,234,567')
})
it('formats decimal numbers', () => {
- expect(formatNumber(123.45)).toEqual('123.45')
- expect(formatNumber(1234.567)).toEqual('1,234.567')
+ expect(formatNumber(123.45, 'en-CA')).toEqual('123.45')
+ expect(formatNumber(1234.567, 'en-CA')).toEqual('1,234.567')
})
it('formats negative numbers', () => {
- expect(formatNumber(-123)).toEqual('-123')
- expect(formatNumber(-1234)).toEqual('-1,234')
- expect(formatNumber(-123.45)).toEqual('-123.45')
+ expect(formatNumber(-123, 'en-CA')).toEqual('-123')
+ expect(formatNumber(-1234, 'en-CA')).toEqual('-1,234')
+ expect(formatNumber(-123.45, 'en-CA')).toEqual('-123.45')
})
})
describe('formatFullDate', () => {
it('format dates', () => {
- expect(formatFullDate('2011', 'en-US')).toEqual('2011')
- expect(formatFullDate('2011-06', 'en-US')).toEqual('Jun 2011')
- expect(formatFullDate('1985-01-01', 'en-US')).toEqual('Jan 1, 1985')
+ expect(formatFullDate('2011', 'en-CA')).toEqual('2011')
+ expect(formatFullDate('2011-06', 'en-CA')).toEqual('Jun 2011')
+ expect(formatFullDate('1985-01-01', 'en-CA')).toEqual('Jan 1, 1985')
expect(formatFullDate('199704')).toEqual('')
})
})
From a128b3cf98a9c4e063526c8e3b7c76fd033a38f2 Mon Sep 17 00:00:00 2001
From: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
Date: Thu, 6 Nov 2025 19:41:09 +0000
Subject: [PATCH 022/102] fix(db): make playqueue position field an integer
(#4481)
---
.../20250823142158_make_playqueue_position_int.sql | 9 +++++++++
1 file changed, 9 insertions(+)
create mode 100644 db/migrations/20250823142158_make_playqueue_position_int.sql
diff --git a/db/migrations/20250823142158_make_playqueue_position_int.sql b/db/migrations/20250823142158_make_playqueue_position_int.sql
new file mode 100644
index 000000000..de20f0c79
--- /dev/null
+++ b/db/migrations/20250823142158_make_playqueue_position_int.sql
@@ -0,0 +1,9 @@
+-- +goose Up
+-- +goose StatementBegin
+ALTER TABLE playqueue ADD COLUMN position_int integer;
+UPDATE playqueue SET position_int = CAST(position as INTEGER) ;
+ALTER TABLE playqueue DROP COLUMN position;
+ALTER TABLE playqueue RENAME COLUMN position_int TO position;
+-- +goose StatementEnd
+
+-- +goose Down
From 1e8d28ff46239bba3e5ba38881d31a8d40f4af79 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Thu, 6 Nov 2025 14:54:01 -0500
Subject: [PATCH 023/102] fix: qualify user id filter to avoid ambiguous column
(#4511)
---
persistence/user_repository.go | 1 +
persistence/user_repository_test.go | 11 +++++++++++
2 files changed, 12 insertions(+)
diff --git a/persistence/user_repository.go b/persistence/user_repository.go
index a7181b1a7..7baa8f6a8 100644
--- a/persistence/user_repository.go
+++ b/persistence/user_repository.go
@@ -57,6 +57,7 @@ func NewUserRepository(ctx context.Context, db dbx.Builder) model.UserRepository
r.db = db
r.tableName = "user"
r.registerModel(&model.User{}, map[string]filterFunc{
+ "id": idFilter(r.tableName),
"password": invalidFilter(ctx),
"name": r.withTableName(startsWithFilter),
})
diff --git a/persistence/user_repository_test.go b/persistence/user_repository_test.go
index 7c0707ecd..8abbf76a9 100644
--- a/persistence/user_repository_test.go
+++ b/persistence/user_repository_test.go
@@ -559,4 +559,15 @@ var _ = Describe("UserRepository", func() {
Expect(user.Libraries[0].ID).To(Equal(1))
})
})
+
+ Describe("filters", func() {
+ It("qualifies id filter with table name", func() {
+ r := repo.(*userRepository)
+ qo := r.parseRestOptions(r.ctx, rest.QueryOptions{Filters: map[string]any{"id": "123"}})
+ sel := r.selectUserWithLibraries(qo)
+ query, _, err := r.toSQL(sel)
+ Expect(err).NotTo(HaveOccurred())
+ Expect(query).To(ContainSubstring("user.id = {:p0}"))
+ })
+ })
})
From e918e049e2e75e8612750983e9494cb6f70c9215 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Thu, 6 Nov 2025 15:07:09 -0500
Subject: [PATCH 024/102] fix: update wazero dependency to resolve ARM64 SIGILL
crash (#4655)
* fix(deps): update wazero dependencies to resolve issues
Signed-off-by: Deluan
* fix(deps): update wazero dependency to latest version
Signed-off-by: Deluan
* fix(deps): update wazero dependency to latest version for issue resolution
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
---
go.mod | 8 ++++++--
go.sum | 4 ++--
2 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/go.mod b/go.mod
index 932e4c211..bbe610710 100644
--- a/go.mod
+++ b/go.mod
@@ -2,8 +2,12 @@ module github.com/navidrome/navidrome
go 1.25.4
-// Fork to fix https://github.com/navidrome/navidrome/pull/3254
-replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
+replace (
+ // Fork to fix https://github.com/navidrome/navidrome/issues/3254
+ github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
+ // Using version from main that fixes https://github.com/navidrome/navidrome/issues/4396
+ github.com/tetratelabs/wazero v1.9.0 => github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684
+)
require (
github.com/Masterminds/squirrel v1.5.4
diff --git a/go.sum b/go.sum
index 97fe24b35..059ddd19f 100644
--- a/go.sum
+++ b/go.sum
@@ -265,8 +265,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
-github.com/tetratelabs/wazero v1.9.0 h1:IcZ56OuxrtaEz8UYNRHBrUa9bYeX9oVY93KspZZBf/I=
-github.com/tetratelabs/wazero v1.9.0/go.mod h1:TSbcXCfFP0L2FGkRPxHphadXPjo1T6W+CseNNY7EkjM=
+github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684 h1:ugT1JTRsK1Jhn95BWilCugyZ1Svsyxm9xSiflOa2e7E=
+github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
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 4f7dc105b0414cd202fc7e560d51b8abc27ad7ef Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Thu, 6 Nov 2025 16:50:54 -0500
Subject: [PATCH 025/102] fix(ui): correct track ordering when sorting
playlists by album (#4657)
* fix(deps): update wazero dependencies to resolve issues
Signed-off-by: Deluan
* fix(deps): update wazero dependency to latest version
Signed-off-by: Deluan
* fix: correct track ordering when sorting playlists by album
Fixed issue #3177 where tracks within multi-disc albums were displayed out of order when sorting playlists by album. The playlist track repository was using an incomplete sort mapping that only sorted by album name and artist, missing the critical disc_number and track_number fields.
Changed the album sort mapping in playlist_track_repository from:
order_album_name, order_album_artist_name
to:
order_album_name, order_album_artist_name, disc_number, track_number, order_artist_name, title
This now matches the sorting used in the media file repository, ensuring tracks are sorted by:
1. Album name (groups by album)
2. Album artist (handles compilations)
3. Disc number (multi-disc album discs in order)
4. Track number (tracks within disc in order)
5. Artist name and title (edge cases with missing metadata)
Added comprehensive tests with a multi-disc test album to verify correct sorting behavior.
* chore: sync go.mod and go.sum with master
* chore: align playlist album sort order with mediafile_repository (use album_id)
* fix: clean up test playlist to prevent state leakage in randomized test runs
---------
Signed-off-by: Deluan
---
persistence/album_repository_test.go | 2 ++
persistence/mediafile_repository_test.go | 2 +-
persistence/persistence_suite_test.go | 13 +++++++++-
persistence/playlist_repository_test.go | 33 ++++++++++++++++++++++++
persistence/playlist_track_repository.go | 2 +-
5 files changed, 49 insertions(+), 3 deletions(-)
diff --git a/persistence/album_repository_test.go b/persistence/album_repository_test.go
index 4be89bcb8..a062b4398 100644
--- a/persistence/album_repository_test.go
+++ b/persistence/album_repository_test.go
@@ -55,6 +55,7 @@ var _ = Describe("AlbumRepository", func() {
It("returns all records sorted", func() {
Expect(GetAll(model.QueryOptions{Sort: "name"})).To(Equal(model.Albums{
albumAbbeyRoad,
+ albumMultiDisc,
albumRadioactivity,
albumSgtPeppers,
}))
@@ -64,6 +65,7 @@ var _ = Describe("AlbumRepository", func() {
Expect(GetAll(model.QueryOptions{Sort: "name", Order: "desc"})).To(Equal(model.Albums{
albumSgtPeppers,
albumRadioactivity,
+ albumMultiDisc,
albumAbbeyRoad,
}))
})
diff --git a/persistence/mediafile_repository_test.go b/persistence/mediafile_repository_test.go
index 002b82499..ab926c00d 100644
--- a/persistence/mediafile_repository_test.go
+++ b/persistence/mediafile_repository_test.go
@@ -38,7 +38,7 @@ var _ = Describe("MediaRepository", func() {
})
It("counts the number of mediafiles in the DB", func() {
- Expect(mr.CountAll()).To(Equal(int64(6)))
+ Expect(mr.CountAll()).To(Equal(int64(10)))
})
It("returns songs ordered by lyrics with a specific title/artist", func() {
diff --git a/persistence/persistence_suite_test.go b/persistence/persistence_suite_test.go
index 1007d84fe..f3cb4f3d0 100644
--- a/persistence/persistence_suite_test.go
+++ b/persistence/persistence_suite_test.go
@@ -69,10 +69,12 @@ var (
albumSgtPeppers = al(model.Album{ID: "101", Name: "Sgt Peppers", AlbumArtist: "The Beatles", OrderAlbumName: "sgt peppers", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/sgt/a day.mp3"), SongCount: 1, MaxYear: 1967})
albumAbbeyRoad = al(model.Album{ID: "102", Name: "Abbey Road", AlbumArtist: "The Beatles", OrderAlbumName: "abbey road", AlbumArtistID: "3", EmbedArtPath: p("/beatles/1/come together.mp3"), SongCount: 1, MaxYear: 1969})
albumRadioactivity = al(model.Album{ID: "103", Name: "Radioactivity", AlbumArtist: "Kraftwerk", OrderAlbumName: "radioactivity", AlbumArtistID: "2", EmbedArtPath: p("/kraft/radio/radio.mp3"), SongCount: 2})
+ albumMultiDisc = al(model.Album{ID: "104", Name: "Multi Disc Album", AlbumArtist: "Test Artist", OrderAlbumName: "multi disc album", AlbumArtistID: "1", EmbedArtPath: p("/test/multi/disc1/track1.mp3"), SongCount: 4})
testAlbums = model.Albums{
albumSgtPeppers,
albumAbbeyRoad,
albumRadioactivity,
+ albumMultiDisc,
}
)
@@ -94,13 +96,22 @@ var (
Lyrics: `[{"lang":"xxx","line":[{"value":"This is a set of lyrics"}],"synced":false}]`,
})
songAntenna2 = mf(model.MediaFile{ID: "1006", Title: "Antenna", ArtistID: "2", Artist: "Kraftwerk", AlbumID: "103"})
- testSongs = model.MediaFiles{
+ // Multi-disc album tracks (intentionally out of order to test sorting)
+ songDisc2Track11 = mf(model.MediaFile{ID: "2001", Title: "Disc 2 Track 11", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 11, Path: p("/test/multi/disc2/track11.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
+ songDisc1Track01 = mf(model.MediaFile{ID: "2002", Title: "Disc 1 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 1, Path: p("/test/multi/disc1/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
+ songDisc2Track01 = mf(model.MediaFile{ID: "2003", Title: "Disc 2 Track 1", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 2, TrackNumber: 1, Path: p("/test/multi/disc2/track1.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
+ songDisc1Track02 = mf(model.MediaFile{ID: "2004", Title: "Disc 1 Track 2", ArtistID: "1", Artist: "Test Artist", AlbumID: "104", Album: "Multi Disc Album", DiscNumber: 1, TrackNumber: 2, Path: p("/test/multi/disc1/track2.mp3"), OrderAlbumName: "multi disc album", OrderArtistName: "test artist"})
+ testSongs = model.MediaFiles{
songDayInALife,
songComeTogether,
songRadioactivity,
songAntenna,
songAntennaWithLyrics,
songAntenna2,
+ songDisc2Track11,
+ songDisc1Track01,
+ songDisc2Track01,
+ songDisc1Track02,
}
)
diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go
index 15ae438d9..7fad93b1e 100644
--- a/persistence/playlist_repository_test.go
+++ b/persistence/playlist_repository_test.go
@@ -219,4 +219,37 @@ var _ = Describe("PlaylistRepository", func() {
})
})
})
+
+ 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
+ })
+ })
})
diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go
index 01eec0d02..b3f9e0c07 100644
--- a/persistence/playlist_track_repository.go
+++ b/persistence/playlist_track_repository.go
@@ -55,7 +55,7 @@ func (r *playlistRepository) Tracks(playlistId string, refreshSmartPlaylist bool
"id": "playlist_tracks.id",
"artist": "order_artist_name",
"album_artist": "order_album_artist_name",
- "album": "order_album_name, order_album_artist_name",
+ "album": "order_album_name, album_id, disc_number, track_number, order_artist_name, title",
"title": "order_title",
// To make sure these fields will be whitelisted
"duration": "duration",
From a59b59192a3bc11cfba9f2a3681eec6a8487e6ae Mon Sep 17 00:00:00 2001
From: York
Date: Sat, 8 Nov 2025 07:06:41 +0800
Subject: [PATCH 026/102] fix(ui): update zh-Hant.json (#4454)
* Update zh-Hant.json
Updated and optimized Traditional Chinese translation.
* Update zh-Hant.json
Updated and optimized Traditional Chinese translation.
* Update zh-Hant.json
Updated and optimized Traditional Chinese translation.
---
resources/i18n/zh-Hant.json | 1071 ++++++++++++++++++++---------------
1 file changed, 619 insertions(+), 452 deletions(-)
diff --git a/resources/i18n/zh-Hant.json b/resources/i18n/zh-Hant.json
index 3d6bbd268..7d8ce2872 100644
--- a/resources/i18n/zh-Hant.json
+++ b/resources/i18n/zh-Hant.json
@@ -1,463 +1,630 @@
{
- "languageName": "繁體中文",
- "resources": {
- "song": {
- "name": "歌曲 |||| 歌曲",
- "fields": {
- "albumArtist": "專輯藝人",
- "duration": "長度",
- "trackNumber": "#",
- "playCount": "播放次數",
- "title": "標題",
- "artist": "藝人",
- "album": "專輯",
- "path": "文件路徑",
- "genre": "類型",
- "compilation": "合輯",
- "year": "發行年份",
- "size": "檔案大小",
- "updatedAt": "更新於",
- "bitRate": "位元率",
- "discSubtitle": "字幕",
- "starred": "收藏",
- "comment": "註解",
- "rating": "評分",
- "quality": "品質",
- "bpm": "BPM",
- "playDate": "上次播放",
- "channels": "聲道",
- "createdAt": "創建於"
- },
- "actions": {
- "addToQueue": "加入至播放佇列",
- "playNow": "立即播放",
- "addToPlaylist": "加入至播放清單",
- "shuffleAll": "全部隨機播放",
- "download": "下載",
- "playNext": "下一首播放",
- "info": "取得資訊"
- }
- },
- "album": {
- "name": "專輯 |||| 專輯",
- "fields": {
- "albumArtist": "專輯藝人",
- "artist": "藝人",
- "duration": "長度",
- "songCount": "歌曲數量",
- "playCount": "播放次數",
- "name": "名稱",
- "genre": "類型",
- "compilation": "合輯",
- "year": "發行年份",
- "updatedAt": "更新於",
- "comment": "註解",
- "rating": "評分",
- "createdAt": "創建於",
- "size": "檔案大小",
- "originalDate": "原始日期",
- "releaseDate": "發行日期",
- "releases": "發行",
- "released": "已發行"
- },
- "actions": {
- "playAll": "立即播放",
- "playNext": "下首播放",
- "addToQueue": "加入至播放佇列",
- "shuffle": "隨機播放",
- "addToPlaylist": "加入播放清單",
- "download": "下載",
- "info": "取得資訊",
- "share": "分享"
- },
- "lists": {
- "all": "所有",
- "random": "隨機",
- "recentlyAdded": "最近加入",
- "recentlyPlayed": "最近播放",
- "mostPlayed": "最多播放的",
- "starred": "收藏",
- "topRated": "最高評分"
- }
- },
- "artist": {
- "name": "藝人 |||| 藝人",
- "fields": {
- "name": "名稱",
- "albumCount": "專輯數",
- "songCount": "歌曲數",
- "playCount": "播放次數",
- "rating": "評分",
- "genre": "類型",
- "size": "檔案大小"
- }
- },
- "user": {
- "name": "使用者 |||| 使用者",
- "fields": {
- "userName": "使用者名稱",
- "isAdmin": "是否管理員",
- "lastLoginAt": "上次登入",
- "lastAccessAt": "上此訪問",
- "updatedAt": "更新於",
- "name": "名稱",
- "password": "密碼",
- "createdAt": "創建於",
- "changePassword": "變更密碼?",
- "currentPassword": "現在的密碼",
- "newPassword": "新密碼",
- "token": "權杖"
- },
- "helperTexts": {
- "name": "你的名稱會在下次登入時生效"
- },
- "notifications": {
- "created": "使用者已創建",
- "updated": "使用者已更新",
- "deleted": "使用者已刪除"
- },
- "message": {
- "listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖",
- "clickHereForToken": "點擊此處來獲得你的 ListenBrainz 權杖"
- }
- },
- "player": {
- "name": "用戶端 |||| 用戶端",
- "fields": {
- "name": "名稱",
- "transcodingId": "轉碼",
- "maxBitRate": "最大位元率",
- "client": "用戶端",
- "userName": "使用者名稱",
- "lastSeen": "上次瀏覽",
- "reportRealPath": "回報實際路徑",
- "scrobbleEnabled": "傳送音樂記錄至外部服務"
- }
- },
- "transcoding": {
- "name": "轉碼 |||| 轉碼",
- "fields": {
- "name": "名稱",
- "targetFormat": "目標格式",
- "defaultBitRate": "預設位元率",
- "command": "命令"
- }
- },
- "playlist": {
- "name": "播放清單 |||| 播放清單",
- "fields": {
- "name": "名稱",
- "duration": "長度",
- "ownerName": "擁有者",
- "public": "公開",
- "updatedAt": "更新於",
- "createdAt": "創建於",
- "songCount": "歌曲數",
- "comment": "註解",
- "sync": "自動導入",
- "path": "導入"
- },
- "actions": {
- "selectPlaylist": "選擇播放清單",
- "addNewPlaylist": "創建 %{name}",
- "export": "導出",
- "makePublic": "設為公開",
- "makePrivate": "設為私人"
- },
- "message": {
- "duplicate_song": "加入重複的歌曲",
- "song_exist": "有重複歌曲正在播放清單裡,您要加入或略過重複歌曲?"
- }
- },
- "radio": {
- "name": "電台",
- "fields": {
- "name": "名稱",
- "streamUrl": "串流網址",
- "homePageUrl": "首頁網址",
- "updatedAt": "更新於",
- "createdAt": "創建於"
- },
- "actions": {
- "playNow": "立即播放"
- }
- },
- "share": {
- "name": "分享",
- "fields": {
- "username": "使用者名稱",
- "url": "網址",
- "description": "描述",
- "contents": "內容",
- "expiresAt": "過期時間",
- "lastVisitedAt": "上次訪問時間",
- "visitCount": "訪問次數",
- "format": "格式",
- "maxBitRate": "最大位元率",
- "updatedAt": "更新於",
- "createdAt": "創建於",
- "downloadable": "可下載"
- },
- "notifications": {},
- "actions": {}
- }
+ "languageName": "繁體中文",
+ "resources": {
+ "song": {
+ "name": "歌曲 |||| 歌曲",
+ "fields": {
+ "albumArtist": "專輯藝人",
+ "duration": "長度",
+ "trackNumber": "#",
+ "playCount": "播放次數",
+ "title": "標題",
+ "artist": "藝人",
+ "album": "專輯",
+ "path": "檔案路徑",
+ "libraryName": "媒體庫",
+ "genre": "曲風",
+ "compilation": "合輯",
+ "year": "發行年份",
+ "size": "檔案大小",
+ "updatedAt": "更新於",
+ "bitRate": "位元率",
+ "bitDepth": "位元深度",
+ "sampleRate": "取樣率",
+ "channels": "聲道",
+ "discSubtitle": "光碟副標題",
+ "starred": "收藏",
+ "comment": "註解",
+ "rating": "評分",
+ "quality": "品質",
+ "bpm": "BPM",
+ "playDate": "上次播放",
+ "createdAt": "建立於",
+ "grouping": "分組",
+ "mood": "情緒",
+ "participants": "其他參與人員",
+ "tags": "額外標籤",
+ "mappedTags": "分類後標籤",
+ "rawTags": "原始標籤",
+ "missing": "遺失"
+ },
+ "actions": {
+ "addToQueue": "加入至播放佇列",
+ "playNow": "立即播放",
+ "addToPlaylist": "加入至播放清單",
+ "showInPlaylist": "在播放清單中顯示",
+ "shuffleAll": "全部隨機播放",
+ "download": "下載",
+ "playNext": "下一首播放",
+ "info": "取得資訊"
+ }
},
- "ra": {
- "auth": {
- "welcome1": "感謝您安裝 Navidrome!",
- "welcome2": "開始前,請創建一個管理員帳戶",
- "confirmPassword": "確認密碼",
- "buttonCreateAdmin": "創建管理員",
- "auth_check_error": "請登入以訪問更多內容",
- "user_menu": "配置",
- "username": "使用者名稱",
- "password": "密碼",
- "sign_in": "登入",
- "sign_in_error": "驗證失敗,請重試",
- "logout": "登出"
- },
- "validation": {
- "invalidChars": "請使用字母和數字",
- "passwordDoesNotMatch": "密碼不相符",
- "required": "必填",
- "minLength": "必須不少於 %{min} 個字元",
- "maxLength": "必須不多於 %{max} 個字元",
- "minValue": "必須不小於 %{min}",
- "maxValue": "必須不大於 %{max}",
- "number": "必須為數字",
- "email": "必須是有效的電子郵件",
- "oneOf": "必須為: %{options}其中一項",
- "regex": "必須符合指定的格式(正規表達式):%{pattern}",
- "unique": "必須是唯一的",
- "url": "網址"
- },
- "action": {
- "add_filter": "加入篩選",
- "add": "加入",
- "back": "返回",
- "bulk_actions": "選中 %{smart_count} 項",
- "cancel": "取消",
- "clear_input_value": "清除",
- "clone": "複製",
- "confirm": "確認",
- "create": "創建",
- "delete": "刪除",
- "edit": "編輯",
- "export": "匯出",
- "list": "列表",
- "refresh": "重新整理",
- "remove_filter": "清除此條件",
- "remove": "清除",
- "save": "保存",
- "search": "搜尋",
- "show": "顯示",
- "sort": "排序",
- "undo": "撤銷",
- "expand": "展開",
- "close": "關閉",
- "open_menu": "打開選單",
- "close_menu": "關閉選單",
- "unselect": "未選擇",
- "skip": "略過",
- "bulk_actions_mobile": "%{smart_count}",
- "share": "分享",
- "download": "下載"
- },
- "boolean": {
- "true": "是",
- "false": "否"
- },
- "page": {
- "create": "創建 %{name}",
- "dashboard": "儀表板",
- "edit": "%{name} #%{id}",
- "error": "發生錯誤",
- "list": "%{name}",
- "loading": "載入中",
- "not_found": "未發現",
- "show": "%{name} #%{id}",
- "empty": "還沒有 %{name}。",
- "invite": "你要創建一個嗎?"
- },
- "input": {
- "file": {
- "upload_several": "拖拽多個文件上傳或點擊選擇一個",
- "upload_single": "拖拽單個文件上傳或點擊選擇一個"
- },
- "image": {
- "upload_several": "拖拽多個圖片上傳或點擊選擇一個",
- "upload_single": "拖拽單個圖片上傳或點擊選擇一個"
- },
- "references": {
- "all_missing": "未找到參考數據",
- "many_missing": "至少有一條參考數據不再可用",
- "single_missing": "關聯的參考數據不再可用"
- },
- "password": {
- "toggle_visible": "隱藏密碼",
- "toggle_hidden": "顯示密碼"
- }
- },
- "message": {
- "about": "關於",
- "are_you_sure": "確定進行此操作?",
- "bulk_delete_content": "您確定要刪除 %{name}? |||| 您確定要刪除 %{smart_count} 項?",
- "bulk_delete_title": "刪除 %{name} |||| 刪除 %{smart_count} 項 %{name}",
- "delete_content": "您確定要刪除該項目?",
- "delete_title": "刪除 %{name} #%{id}",
- "details": "詳細資訊",
- "error": "發生一個用戶端錯誤,您的請求無法完成",
- "invalid_form": "提交內容無效,請檢查錯誤",
- "loading": "正在載入頁面,請稍候",
- "no": "否",
- "not_found": "您輸入的連結格式不對或連結遺失",
- "yes": "是",
- "unsaved_changes": "某些更改尚未保存,您確定要離開此頁面嗎?"
- },
- "navigation": {
- "no_results": "無內容",
- "no_more_results": "頁碼 %{page} 超出邊界,嘗試返回上一頁",
- "page_out_of_boundaries": "頁碼 %{page} 超出邊界",
- "page_out_from_end": "已經最後一頁",
- "page_out_from_begin": "已經是第一頁",
- "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
- "page_rows_per_page": "每頁行數:",
- "next": "下一頁",
- "prev": "上一頁",
- "skip_nav": "跳過"
- },
- "notification": {
- "updated": "項已更新 |||| %{smart_count} 項已更新",
- "created": "項已創建",
- "deleted": "項已刪除 |||| %{smart_count} 項已刪除",
- "bad_item": "不確定的項",
- "item_doesnt_exist": "項不存在",
- "http_error": "伺服器通訊錯誤",
- "data_provider_error": "資料來源錯誤,請檢查控制台的詳細資訊",
- "i18n_error": "無法載入所選語言",
- "canceled": "操作已取消",
- "logged_out": "您的會話已結束,請重新登入",
- "new_version": "發現新版本!請重新整理視窗"
- },
- "toggleFieldsMenu": {
- "columnsToDisplay": "顯示欄目",
- "layout": "版面",
- "grid": "框格",
- "table": "表格"
- }
+ "album": {
+ "name": "專輯 |||| 專輯",
+ "fields": {
+ "albumArtist": "專輯藝人",
+ "artist": "藝人",
+ "duration": "長度",
+ "songCount": "歌曲數",
+ "playCount": "播放次數",
+ "size": "檔案大小",
+ "name": "名稱",
+ "libraryName": "媒體庫",
+ "genre": "曲風",
+ "compilation": "合輯",
+ "year": "發行年份",
+ "date": "錄製日期",
+ "originalDate": "原始日期",
+ "releaseDate": "發行日期",
+ "releases": "發行",
+ "released": "已發行",
+ "updatedAt": "更新於",
+ "comment": "註解",
+ "rating": "評分",
+ "createdAt": "建立於",
+ "recordLabel": "唱片公司",
+ "catalogNum": "目錄編號",
+ "releaseType": "發行類型",
+ "grouping": "分組",
+ "media": "媒體類型",
+ "mood": "情緒",
+ "missing": "遺失"
+ },
+ "actions": {
+ "playAll": "播放全部",
+ "playNext": "下一首播放",
+ "addToQueue": "加入至播放佇列",
+ "share": "分享",
+ "shuffle": "隨機播放",
+ "addToPlaylist": "加入至播放清單",
+ "download": "下載",
+ "info": "取得資訊"
+ },
+ "lists": {
+ "all": "所有",
+ "random": "隨機",
+ "recentlyAdded": "最近加入",
+ "recentlyPlayed": "最近播放",
+ "mostPlayed": "最常播放",
+ "starred": "收藏",
+ "topRated": "最高評分"
+ }
},
- "message": {
- "note": "註解",
- "transcodingDisabled": "出於安全原因,禁用了從 Web 介面更改參數。要更改(編輯或新增)轉檔選項,請在啟用 %{config} 選項的情況下重新啟動伺服器。",
- "transcodingEnabled": "Navidrome 當前與 %{config} 一起使用,可以通過配置轉檔參數執行任意命令,建議僅在配置轉檔選項時啟用此功能。",
- "songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已添加 %{smart_count} 首歌到播放清單",
- "noPlaylistsAvailable": "沒有可用的播放清單",
- "delete_user_title": "刪除使用者 %{name}",
- "delete_user_content": "您確定要刪除該使用者及其相關數據(包括播放清單和使用者配置)嗎?",
- "notifications_blocked": "您已在瀏覽器的設置中封鎖了此網站的通知",
- "notifications_not_available": "此瀏覽器不支援桌面通知",
- "lastfmLinkSuccess": "Last.fm 成功連接並開啟音樂記錄",
- "lastfmLinkFailure": "Last.fm 無法連接",
- "lastfmUnlinkSuccess": "Last.fm 已無連接並停用音樂記錄",
- "lastfmUnlinkFailure": "Last.fm 無法取消連接",
- "openIn": {
- "lastfm": "在 Last.fm 打開",
- "musicbrainz": "在 MusicBrainz 打開"
- },
- "lastfmLink": "繼續閱讀…",
- "listenBrainzLinkSuccess": "ListenBrainz 成功連接並開啟音樂記錄",
- "listenBrainzLinkFailure": "ListenBrainz 無法連接:%{error}",
- "listenBrainzUnlinkSuccess": "ListenBrainz 已無連接並停用音樂記錄",
- "listenBrainzUnlinkFailure": "ListenBrainz 無法取消連接",
- "downloadOriginalFormat": "下載原始格式",
- "shareOriginalFormat": "分享原始格式",
- "shareDialogTitle": "分享",
- "shareBatchDialogTitle": "批次分享",
- "shareSuccess": "分享成功",
- "shareFailure": "分享失敗",
- "downloadDialogTitle": "下載",
- "shareCopyToClipboard": "複製到剪貼簿"
+ "artist": {
+ "name": "藝人 |||| 藝人",
+ "fields": {
+ "name": "名稱",
+ "albumCount": "專輯數",
+ "songCount": "歌曲數",
+ "size": "檔案大小",
+ "playCount": "播放次數",
+ "rating": "評分",
+ "genre": "曲風",
+ "role": "參與角色",
+ "missing": "遺失"
+ },
+ "roles": {
+ "albumartist": "專輯藝人 |||| 專輯藝人",
+ "artist": "藝人 |||| 藝人",
+ "composer": "作曲 |||| 作曲",
+ "conductor": "指揮 |||| 指揮",
+ "lyricist": "作詞 |||| 作詞",
+ "arranger": "編曲 |||| 編曲",
+ "producer": "製作人 |||| 製作人",
+ "director": "導演 |||| 導演",
+ "engineer": "工程師 |||| 工程師",
+ "mixer": "混音師 |||| 混音師",
+ "remixer": "重混師 |||| 重混師",
+ "djmixer": "DJ 混音師 |||| DJ 混音師",
+ "performer": "表演者 |||| 表演者",
+ "maincredit": "專輯藝人或藝人 |||| 專輯藝人或藝人"
+ },
+ "actions": {
+ "topSongs": "熱門歌曲",
+ "shuffle": "隨機播放",
+ "radio": "電台"
+ }
},
- "menu": {
- "library": "音樂庫",
- "settings": "設定",
- "version": "版本",
- "theme": "主題",
- "personal": {
- "name": "個人化",
- "options": {
- "theme": "主題",
- "language": "語言",
- "defaultView": "預設畫面",
- "desktop_notifications": "桌面通知",
- "lastfmScrobbling": "啟用 Last.fm 音樂記錄",
- "listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄",
- "replaygain": "重播增益",
- "preAmp": "前置放大器 (dB)",
- "gain": {
- "none": "無",
- "album": "專輯增益",
- "track": "曲目增益"
- }
- }
- },
- "albumList": "專輯",
- "about": "關於",
- "playlists": "播放清單",
- "sharedPlaylists": "分享的播放清單"
+ "user": {
+ "name": "使用者 |||| 使用者",
+ "fields": {
+ "userName": "使用者名稱",
+ "isAdmin": "管理員",
+ "lastLoginAt": "上次登入",
+ "lastAccessAt": "上次存取",
+ "updatedAt": "更新於",
+ "name": "名稱",
+ "password": "密碼",
+ "createdAt": "建立於",
+ "changePassword": "變更密碼?",
+ "currentPassword": "目前密碼",
+ "newPassword": "新密碼",
+ "token": "權杖",
+ "libraries": "媒體庫"
+ },
+ "helperTexts": {
+ "name": "您的名稱會在下次登入時生效",
+ "libraries": "為該使用者選擇指定媒體庫,留空則使用預設媒體庫"
+ },
+ "notifications": {
+ "created": "使用者已建立",
+ "updated": "使用者已更新",
+ "deleted": "使用者已刪除"
+ },
+ "validation": {
+ "librariesRequired": "非管理員使用者必須至少選擇一個媒體庫"
+ },
+ "message": {
+ "listenBrainzToken": "輸入您的 ListenBrainz 使用者權杖",
+ "clickHereForToken": "點擊此處來獲得您的 ListenBrainz 權杖",
+ "selectAllLibraries": "選取全部媒體庫",
+ "adminAutoLibraries": "管理員預設可存取所有媒體庫"
+ }
},
"player": {
- "playListsText": "播放佇列",
- "openText": "打開",
- "closeText": "關閉",
- "notContentText": "沒有音樂",
- "clickToPlayText": "點擊播放",
- "clickToPauseText": "點擊暫停",
- "nextTrackText": "下一首",
- "previousTrackText": "上一首",
- "reloadText": "重新播放",
- "volumeText": "音量",
- "toggleLyricText": "切換歌詞",
- "toggleMiniModeText": "最小化",
- "destroyText": "關閉",
- "downloadText": "下載",
- "removeAudioListsText": "清空播放佇列",
- "clickToDeleteText": "點擊刪除 %{name}",
- "emptyLyricText": "無歌詞",
- "playModeText": {
- "order": "順序播放",
- "orderLoop": "列表循環",
- "singleLoop": "單曲循環",
- "shufflePlay": "隨機播放"
- }
+ "name": "播放器 |||| 播放器",
+ "fields": {
+ "name": "名稱",
+ "transcodingId": "轉碼",
+ "maxBitRate": "最大位元率",
+ "client": "客戶端",
+ "userName": "使用者名稱",
+ "lastSeen": "上次上線",
+ "reportRealPath": "回報實際路徑",
+ "scrobbleEnabled": "傳送音樂記錄至外部服務"
+ }
},
- "about": {
- "links": {
- "homepage": "主頁",
- "source": "原始碼",
- "featureRequests": "功能請求"
- }
+ "transcoding": {
+ "name": "轉碼 |||| 轉碼",
+ "fields": {
+ "name": "名稱",
+ "targetFormat": "目標格式",
+ "defaultBitRate": "預設位元率",
+ "command": "指令"
+ }
},
- "activity": {
- "title": "運作狀況",
- "totalScanned": "已完成掃描的目錄",
- "quickScan": "快速掃描",
- "fullScan": "完全掃描",
- "serverUptime": "伺服器已運作時間",
- "serverDown": "伺服器離線"
+ "playlist": {
+ "name": "播放清單 |||| 播放清單",
+ "fields": {
+ "name": "名稱",
+ "duration": "長度",
+ "ownerName": "擁有者",
+ "public": "公開",
+ "updatedAt": "更新於",
+ "createdAt": "建立於",
+ "songCount": "歌曲數",
+ "comment": "註解",
+ "sync": "自動匯入",
+ "path": "匯入來源"
+ },
+ "actions": {
+ "selectPlaylist": "選取播放清單:",
+ "addNewPlaylist": "建立「%{name}」",
+ "export": "匯出",
+ "saveQueue": "將播放佇列儲存到播放清單",
+ "makePublic": "設為公開",
+ "makePrivate": "設為私人",
+ "searchOrCreate": "搜尋播放清單,或輸入名稱來新建…",
+ "pressEnterToCreate": "按 Enter 鍵建立新的播放清單",
+ "removeFromSelection": "移除選取項目"
+ },
+ "message": {
+ "duplicate_song": "加入重複的歌曲",
+ "song_exist": "有重複歌曲正要加入播放清單,您要加入或略過重複歌曲?",
+ "noPlaylistsFound": "找不到播放清單",
+ "noPlaylists": "暫無播放清單"
+ }
},
- "help": {
- "title": "Navidrome 快捷鍵",
- "hotkeys": {
- "show_help": "顯示此幫助",
- "toggle_menu": "顯示/隱藏選單側欄",
- "toggle_play": "播放/暫停",
- "prev_song": "上一首歌",
- "next_song": "下一首歌",
- "vol_up": "提高音量",
- "vol_down": "降低音量",
- "toggle_love": "添加或移除星標",
- "current_song": "目前歌曲"
- }
+ "radio": {
+ "name": "電台 |||| 電台",
+ "fields": {
+ "name": "名稱",
+ "streamUrl": "串流網址",
+ "homePageUrl": "首頁網址",
+ "updatedAt": "更新於",
+ "createdAt": "建立於"
+ },
+ "actions": {
+ "playNow": "立即播放"
+ }
+ },
+ "share": {
+ "name": "分享 |||| 分享",
+ "fields": {
+ "username": "分享者",
+ "url": "網址",
+ "description": "描述",
+ "downloadable": "允許下載?",
+ "contents": "內容",
+ "expiresAt": "過期時間",
+ "lastVisitedAt": "上次造訪時間",
+ "visitCount": "造訪次數",
+ "format": "格式",
+ "maxBitRate": "最大位元率",
+ "updatedAt": "更新於",
+ "createdAt": "建立於"
+ },
+ "notifications": {},
+ "actions": {}
+ },
+ "missing": {
+ "name": "遺失檔案 |||| 遺失檔案",
+ "empty": "無遺失檔案",
+ "fields": {
+ "path": "路徑",
+ "size": "檔案大小",
+ "libraryName": "媒體庫",
+ "updatedAt": "遺失於"
+ },
+ "actions": {
+ "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": "掃描媒體庫",
+ "manageUsers": "管理使用者權限",
+ "viewDetails": "查看詳細資料"
+ },
+ "notifications": {
+ "created": "成功建立媒體庫",
+ "updated": "成功更新媒體庫",
+ "deleted": "成功刪除媒體庫",
+ "scanStarted": "開始掃描媒體庫",
+ "scanCompleted": "媒體庫掃描完成"
+ },
+ "validation": {
+ "nameRequired": "請輸入媒體庫名稱",
+ "pathRequired": "請提供媒體庫路徑",
+ "pathNotDirectory": "媒體庫路徑必須為目錄",
+ "pathNotFound": "媒體庫路徑不存在",
+ "pathNotAccessible": "無法存取媒體庫路徑",
+ "pathInvalid": "媒體庫路徑無效"
+ },
+ "messages": {
+ "deleteConfirm": "您確定要刪除此媒體庫嗎?這將刪除所有相關資料和使用者存取權限。",
+ "scanInProgress": "正在掃描...",
+ "noLibrariesAssigned": "沒有為該使用者指派任何媒體庫"
+ }
}
+ },
+ "ra": {
+ "auth": {
+ "welcome1": "感謝您安裝 Navidrome!",
+ "welcome2": "開始前,請先建立一個管理員帳號",
+ "confirmPassword": "確認密碼",
+ "buttonCreateAdmin": "建立管理員",
+ "auth_check_error": "請登入以繼續",
+ "user_menu": "個人檔案",
+ "username": "使用者名稱",
+ "password": "密碼",
+ "sign_in": "登入",
+ "sign_in_error": "驗證失敗,請重試",
+ "logout": "登出",
+ "insightsCollectionNote": "Navidrome 會收集匿名使用資料以協助改善項目。\n點擊[此處]了解更多資訊或選擇退出。"
+ },
+ "validation": {
+ "invalidChars": "請使用字母和數字",
+ "passwordDoesNotMatch": "密碼不相符",
+ "required": "必填",
+ "minLength": "必須不少於 %{min} 個字元",
+ "maxLength": "必須不多於 %{max} 個字元",
+ "minValue": "必須不小於 %{min}",
+ "maxValue": "必須不大於 %{max}",
+ "number": "必須為數字",
+ "email": "必須為有效的電子郵件",
+ "oneOf": "必須為以下其中一項:%{options}",
+ "regex": "必須符合指定的格式(正規表達式):%{pattern}",
+ "unique": "必須是唯一的",
+ "url": "必須為有效的網址"
+ },
+ "action": {
+ "add_filter": "加入篩選",
+ "add": "加入",
+ "back": "返回",
+ "bulk_actions": "選中 1 項 |||| 選中 %{smart_count} 項",
+ "bulk_actions_mobile": "1 |||| %{smart_count}",
+ "cancel": "取消",
+ "clear_input_value": "清除",
+ "clone": "複製",
+ "confirm": "確認",
+ "create": "建立",
+ "delete": "刪除",
+ "edit": "編輯",
+ "export": "匯出",
+ "list": "列表",
+ "refresh": "重新整理",
+ "remove_filter": "清除此條件",
+ "remove": "移除",
+ "save": "儲存",
+ "search": "搜尋",
+ "show": "顯示",
+ "sort": "排序",
+ "undo": "復原",
+ "expand": "展開",
+ "close": "關閉",
+ "open_menu": "開啟選單",
+ "close_menu": "關閉選單",
+ "unselect": "取消選取",
+ "skip": "略過",
+ "share": "分享",
+ "download": "下載"
+ },
+ "boolean": {
+ "true": "是",
+ "false": "否"
+ },
+ "page": {
+ "create": "建立 %{name}",
+ "dashboard": "儀表板",
+ "edit": "%{name} #%{id}",
+ "error": "發生錯誤",
+ "list": "%{name}",
+ "loading": "載入中",
+ "not_found": "找不到",
+ "show": "%{name} #%{id}",
+ "empty": "還沒有 %{name}。",
+ "invite": "您要建立一個嗎?"
+ },
+ "input": {
+ "file": {
+ "upload_several": "拖曳多個檔案上傳或點擊選擇一個",
+ "upload_single": "拖曳單個檔案上傳或點擊選擇一個"
+ },
+ "image": {
+ "upload_several": "拖曳多個圖片上傳或點擊選擇一個",
+ "upload_single": "拖曳單個圖片上傳或點擊選擇一個"
+ },
+ "references": {
+ "all_missing": "未找到參考數據",
+ "many_missing": "至少有一條參考數據不再可用",
+ "single_missing": "關聯的參考數據不再可用"
+ },
+ "password": {
+ "toggle_visible": "隱藏密碼",
+ "toggle_hidden": "顯示密碼"
+ }
+ },
+ "message": {
+ "about": "關於",
+ "are_you_sure": "您確定嗎?",
+ "bulk_delete_content": "您確定要刪除 %{name}? |||| 您確定要刪除這 %{smart_count} 個項目嗎?",
+ "bulk_delete_title": "刪除 %{name} |||| 刪除 %{smart_count} 項 %{name}",
+ "delete_content": "您確定要刪除該項目?",
+ "delete_title": "刪除 %{name} #%{id}",
+ "details": "詳細資訊",
+ "error": "發生客戶端錯誤,您的請求無法完成",
+ "invalid_form": "提交內容無效,請檢查錯誤",
+ "loading": "正在載入頁面,請稍候",
+ "no": "否",
+ "not_found": "您輸入了錯誤的連結或連結遺失",
+ "yes": "是",
+ "unsaved_changes": "某些更改尚未儲存,您確定要離開此頁面嗎?"
+ },
+ "navigation": {
+ "no_results": "沒有找到結果",
+ "no_more_results": "頁碼 %{page} 超出邊界,嘗試返回上一頁",
+ "page_out_of_boundaries": "頁碼 %{page} 超出邊界",
+ "page_out_from_end": "已經是最後一頁",
+ "page_out_from_begin": "已經是第一頁",
+ "page_range_info": "%{offsetBegin}-%{offsetEnd} / %{total}",
+ "page_rows_per_page": "每頁項目數:",
+ "next": "下一頁",
+ "prev": "上一頁",
+ "skip_nav": "跳至內容"
+ },
+ "notification": {
+ "updated": "項目已更新 |||| %{smart_count} 項已更新",
+ "created": "項目已建立",
+ "deleted": "項目已刪除 |||| %{smart_count} 項已刪除",
+ "bad_item": "項目不正確",
+ "item_doesnt_exist": "項目不存在",
+ "http_error": "伺服器通訊錯誤",
+ "data_provider_error": "資料來源錯誤,請檢查控制台的詳細資訊",
+ "i18n_error": "無法載入所選語言",
+ "canceled": "操作已取消",
+ "logged_out": "您的工作階段已結束,請重新登入",
+ "new_version": "發現新版本!請重新整理視窗"
+ },
+ "toggleFieldsMenu": {
+ "columnsToDisplay": "顯示欄位",
+ "layout": "版面",
+ "grid": "網格",
+ "table": "表格"
+ }
+ },
+ "message": {
+ "note": "注意",
+ "transcodingDisabled": "出於安全原因,已停用了從 Web 介面更改參數。要更改(編輯或新增)轉碼選項,請在啟用 %{config} 選項的情況下重新啟動伺服器。",
+ "transcodingEnabled": "Navidrome 目前與 %{config} 一起使用,因此可以透過 Web 介面從轉碼設定中執行系統命令。出於安全考慮,我們建議停用此功能,並僅在設定轉碼選項時啟用。",
+ "songsAddedToPlaylist": "已加入一首歌到播放清單 |||| 已新增 %{smart_count} 首歌到播放清單",
+ "noSimilarSongsFound": "找不到相似歌曲",
+ "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": "此瀏覽器不支援桌面通知,或您並非透過 HTTPS 存取 Navidrome",
+ "lastfmLinkSuccess": "已成功連接 Last.fm 並開啟音樂記錄",
+ "lastfmLinkFailure": "無法連接 Last.fm",
+ "lastfmUnlinkSuccess": "已取消 Last.fm 的連接並停用音樂記錄",
+ "lastfmUnlinkFailure": "無法取消 Last.fm 的連接",
+ "listenBrainzLinkSuccess": "已成功以 %{user} 身份連接 ListenBrainz 並開啟音樂記錄",
+ "listenBrainzLinkFailure": "無法連接 ListenBrainz:%{error}",
+ "listenBrainzUnlinkSuccess": "已取消 ListenBrainz 的連接並停用音樂記錄",
+ "listenBrainzUnlinkFailure": "無法取消 ListenBrainz 的連接",
+ "openIn": {
+ "lastfm": "在 Last.fm 中開啟",
+ "musicbrainz": "在 MusicBrainz 中開啟"
+ },
+ "lastfmLink": "查看更多…",
+ "shareOriginalFormat": "分享原始格式",
+ "shareDialogTitle": "分享 %{resource} '%{name}'",
+ "shareBatchDialogTitle": "分享 1 個%{resource} |||| 分享 %{smart_count} 個%{resource}",
+ "shareCopyToClipboard": "複製到剪貼簿:Ctrl+C, Enter",
+ "shareSuccess": "分享成功,連結已複製到剪貼簿:%{url}",
+ "shareFailure": "分享連結複製失敗:%{url}",
+ "downloadDialogTitle": "下載 %{resource} '%{name}' (%{size})",
+ "downloadOriginalFormat": "下載原始格式"
+ },
+ "menu": {
+ "library": "媒體庫",
+ "librarySelector": {
+ "allLibraries": "所有媒體庫 (%{count})",
+ "multipleLibraries": "已選 %{selected} 共 %{total} 媒體庫",
+ "selectLibraries": "選取媒體庫",
+ "none": "無"
+ },
+ "settings": "設定",
+ "version": "版本",
+ "theme": "主題",
+ "personal": {
+ "name": "個人化",
+ "options": {
+ "theme": "主題",
+ "language": "語言",
+ "defaultView": "預設畫面",
+ "desktop_notifications": "桌面通知",
+ "lastfmNotConfigured": "Last.fm API 金鑰未設定",
+ "lastfmScrobbling": "啟用 Last.fm 音樂記錄",
+ "listenBrainzScrobbling": "啟用 ListenBrainz 音樂記錄",
+ "replaygain": "重播增益模式",
+ "preAmp": "重播增益前置放大器 (dB)",
+ "gain": {
+ "none": "無",
+ "album": "專輯增益",
+ "track": "曲目增益"
+ }
+ }
+ },
+ "albumList": "專輯",
+ "playlists": "播放清單",
+ "sharedPlaylists": "分享的播放清單",
+ "about": "關於"
+ },
+ "player": {
+ "playListsText": "播放佇列",
+ "openText": "開啟",
+ "closeText": "關閉",
+ "notContentText": "沒有音樂",
+ "clickToPlayText": "點擊播放",
+ "clickToPauseText": "點擊暫停",
+ "nextTrackText": "下一首",
+ "previousTrackText": "上一首",
+ "reloadText": "重新載入",
+ "volumeText": "音量",
+ "toggleLyricText": "切換歌詞",
+ "toggleMiniModeText": "最小化",
+ "destroyText": "關閉",
+ "downloadText": "下載",
+ "removeAudioListsText": "清空播放佇列",
+ "clickToDeleteText": "點擊刪除 %{name}",
+ "emptyLyricText": "無歌詞",
+ "playModeText": {
+ "order": "順序播放",
+ "orderLoop": "循環播放",
+ "singleLoop": "單曲循環",
+ "shufflePlay": "隨機播放"
+ }
+ },
+ "about": {
+ "links": {
+ "homepage": "首頁",
+ "source": "原始碼",
+ "featureRequests": "功能請求",
+ "lastInsightsCollection": "最近一次洞察資料收集",
+ "insights": {
+ "disabled": "已停用",
+ "waiting": "等待中"
+ }
+ },
+ "tabs": {
+ "about": "關於",
+ "config": "設定"
+ },
+ "config": {
+ "configName": "設定名稱",
+ "environmentVariable": "環境變數",
+ "currentValue": "目前值",
+ "configurationFile": "設定檔案",
+ "exportToml": "匯出設定(TOML 格式)",
+ "exportSuccess": "設定已以 TOML 格式匯出至剪貼簿",
+ "exportFailed": "設定複製失敗",
+ "devFlagsHeader": "開發旗標(可能會更改/刪除)",
+ "devFlagsComment": "這些是實驗性設定,可能會在未來版本中刪除"
+ }
+ },
+ "activity": {
+ "title": "運作狀況",
+ "totalScanned": "已掃描的資料夾總數",
+ "quickScan": "快速掃描",
+ "fullScan": "完全掃描",
+ "serverUptime": "伺服器運作時間",
+ "serverDown": "伺服器已離線",
+ "scanType": "掃描類型",
+ "status": "掃描錯誤",
+ "elapsedTime": "經過時間"
+ },
+ "nowPlaying": {
+ "title": "正在播放",
+ "empty": "無播放內容",
+ "minutesAgo": "1 分鐘前 |||| %{smart_count} 分鐘前"
+ },
+ "help": {
+ "title": "Navidrome 快捷鍵",
+ "hotkeys": {
+ "show_help": "顯示此說明",
+ "toggle_menu": "顯示/隱藏選單側欄",
+ "toggle_play": "播放/暫停",
+ "prev_song": "上一首歌",
+ "next_song": "下一首歌",
+ "current_song": "前往目前歌曲",
+ "vol_up": "提高音量",
+ "vol_down": "降低音量",
+ "toggle_love": "新增此歌曲至收藏"
+ }
+ }
}
From df95dffa749eaa8abed13c4efba9ca2fe98d90a8 Mon Sep 17 00:00:00 2001
From: DDinghoya
Date: Sat, 8 Nov 2025 08:10:38 +0900
Subject: [PATCH 027/102] fix(ui): update ko.json (#4443)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Update ko.json
* Update ko.json
Removed remove one of the entrie as below
"shuffleAll": "모두 셔플"
* Update ko.json
* Update ko.json
* Update ko.json
* Update ko.json
* Update ko.json
---
resources/i18n/ko.json | 133 +++++++++++++++++++++++++++++++++++++----
1 file changed, 122 insertions(+), 11 deletions(-)
diff --git a/resources/i18n/ko.json b/resources/i18n/ko.json
index a8b26df6d..6b81e02d8 100644
--- a/resources/i18n/ko.json
+++ b/resources/i18n/ko.json
@@ -12,6 +12,7 @@
"artist": "아티스트",
"album": "앨범",
"path": "파일 경로",
+ "libraryName": "라이브러리",
"genre": "장르",
"compilation": "컴필레이션",
"year": "년",
@@ -34,7 +35,8 @@
"participants": "추가 참가자",
"tags": "추가 태그",
"mappedTags": "매핑된 태그",
- "rawTags": "원시 태그"
+ "rawTags": "원시 태그",
+ "missing": "누락"
},
"actions": {
"addToQueue": "나중에 재생",
@@ -56,6 +58,7 @@
"playCount": "재생 횟수",
"size": "크기",
"name": "이름",
+ "libraryName": "라이브러리",
"genre": "장르",
"compilation": "컴필레이션",
"year": "년",
@@ -73,7 +76,8 @@
"releaseType": "유형",
"grouping": "그룹",
"media": "미디어",
- "mood": "분위기"
+ "mood": "분위기",
+ "missing": "누락"
},
"actions": {
"playAll": "재생",
@@ -105,7 +109,8 @@
"playCount": "재생 횟수",
"rating": "평가",
"genre": "장르",
- "role": "역할"
+ "role": "역할",
+ "missing": "누락"
},
"roles": {
"albumartist": "앨범 아티스트 |||| 앨범 아티스트들",
@@ -120,7 +125,13 @@
"mixer": "믹서 |||| 믹서들",
"remixer": "리믹서 |||| 리믹서들",
"djmixer": "DJ 믹서 |||| DJ 믹서들",
- "performer": "공연자 |||| 공연자들"
+ "performer": "공연자 |||| 공연자들",
+ "maincredit": "앨범 아티스트 또는 아티스트 |||| 앨범 아티스트들 또는 아티스트들"
+ },
+ "actions": {
+ "topSongs": "인기곡",
+ "shuffle": "셔플",
+ "radio": "라디오"
}
},
"user": {
@@ -137,19 +148,26 @@
"changePassword": "비밀번호를 변경할까요?",
"currentPassword": "현재 비밀번호",
"newPassword": "새 비밀번호",
- "token": "토큰"
+ "token": "토큰",
+ "libraries": "라이브러리"
},
"helperTexts": {
- "name": "이름 변경 사항은 다음 로그인 시에만 반영됨"
+ "name": "이름 변경 사항은 다음 로그인 시에만 반영됨",
+ "libraries": "이 사용자에 대한 특정 라이브러리를 선택하거나 기본 라이브러리를 사용하려면 비움"
},
"notifications": {
"created": "사용자 생성됨",
"updated": "사용자 업데이트됨",
"deleted": "사용자 삭제됨"
},
+ "validation": {
+ "librariesRequired": "관리자가 아닌 사용자의 경우 최소한 하나의 라이브러리를 선택해야 함"
+ },
"message": {
"listenBrainzToken": "ListenBrainz 사용자 토큰을 입력하세요.",
- "clickHereForToken": "여기를 클릭하여 토큰을 얻으세요"
+ "clickHereForToken": "여기를 클릭하여 토큰을 얻으세요",
+ "selectAllLibraries": "모든 라이브러리 선택",
+ "adminAutoLibraries": "관리자 사용자는 자동으로 모든 라이브러리에 접속할 수 있음"
}
},
"player": {
@@ -192,12 +210,18 @@
"selectPlaylist": "재생목록 선택:",
"addNewPlaylist": "\"%{name}\" 만들기",
"export": "내보내기",
+ "saveQueue": "재생목록에 대기열 저장",
"makePublic": "공개 만들기",
- "makePrivate": "비공개 만들기"
+ "makePrivate": "비공개 만들기",
+ "searchOrCreate": "재생목록을 검색하거나 입력하여 새 재생목록을 만드세요...",
+ "pressEnterToCreate": "새 재생목록을 만드려면 Enter 키를 누름",
+ "removeFromSelection": "선택에서 제거"
},
"message": {
"duplicate_song": "중복된 노래 추가",
- "song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?"
+ "song_exist": "이미 재생목록에 존재하는 노래입니다. 중복을 추가할까요 아니면 건너뛸까요?",
+ "noPlaylistsFound": "재생목록을 찾을 수 없음",
+ "noPlaylists": "사용 가능한 재생 목록이 없음"
}
},
"radio": {
@@ -238,14 +262,68 @@
"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": "라이브러리 스캔",
+ "manageUsers": "자용자 접속 관리",
+ "viewDetails": "상세 보기"
+ },
+ "notifications": {
+ "created": "라이브러리가 성공적으로 생성됨",
+ "updated": "라이브러리가 성공적으로 업데이트됨",
+ "deleted": "라이브러리가 성공적으로 삭제됨",
+ "scanStarted": "라이브러리 스캔 스작됨",
+ "scanCompleted": "라이브러리 스캔 완료됨"
+ },
+ "validation": {
+ "nameRequired": "라이브러리 이름이 필요함",
+ "pathRequired": "라이브러리 경로가 필요함",
+ "pathNotDirectory": "라이브러리 경로는 디렉터리여야 함",
+ "pathNotFound": "라이브러리 경로를 찾을 수 없음",
+ "pathNotAccessible": "라이브러리 경로에 접근할 수 없음",
+ "pathInvalid": "잘못된 라이브러리 경로"
+ },
+ "messages": {
+ "deleteConfirm": "이 라이브러리를 삭제할까요? 삭제하면 연결된 모든 데이터와 사용자 접속 권한이 제거됩니다.",
+ "scanInProgress": "스캔 진행 중...",
+ "noLibrariesAssigned": "이 사용자에게 할당된 라이브러리가 없음"
+ }
}
},
"ra": {
@@ -398,11 +476,15 @@
"transcodingDisabled": "웹 인터페이스를 통한 트랜스코딩 구성 변경은 보안상의 이유로 비활성화되어 있습니다. 트랜스코딩 옵션을 변경(편집 또는 추가)하려면, %{config} 구성 옵션으로 서버를 다시 시작하세요.",
"transcodingEnabled": "Navidrome은 현재 %{config}로 실행 중이므로 웹 인터페이스를 사용하여 트랜스코딩 설정에서 시스템 명령을 실행할 수 있습니다. 보안상의 이유로 비활성화하고 트랜스코딩 옵션을 구성할 때만 활성화하는 것이 좋습니다.",
"songsAddedToPlaylist": "1 개의 노래를 재생목록에 추가하였음 |||| %{smart_count} 개의 노래를 재생 목록에 추가하였음",
+ "noSimilarSongsFound": "비슷한 노래를 찾을 수 없음",
+ "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": "이 브라우저는 데스크톱 알림을 지원하지 않거나 https를 통해 Navidrome에 접속하고 있지 않음",
"lastfmLinkSuccess": "Last.fm이 성공적으로 연결되었고 스크로블링이 활성화되었음",
@@ -429,6 +511,12 @@
},
"menu": {
"library": "라이브러리",
+ "librarySelector": {
+ "allLibraries": "모든 라이브러리 (%{count})",
+ "multipleLibraries": "%{selected} / %{total} 라이브러리",
+ "selectLibraries": "라이브러리 선택",
+ "none": "없음"
+ },
"settings": "설정",
"version": "버전",
"theme": "테마",
@@ -491,6 +579,21 @@
"disabled": "비활성화",
"waiting": "대기중"
}
+ },
+ "tabs": {
+ "about": "정보",
+ "config": "구성"
+ },
+ "config": {
+ "configName": "구성 이름",
+ "environmentVariable": "환경 변수",
+ "currentValue": "현재 값",
+ "configurationFile": "구성 파일",
+ "exportToml": "구성 내보내기 (TOML)",
+ "exportSuccess": "TOML 형식으로 클립보드로 내보낸 구성",
+ "exportFailed": "구성 복사 실패",
+ "devFlagsHeader": "개발 플래그 (변경/삭제 가능)",
+ "devFlagsComment": "이는 실험적 설정이므로 향후 버전에서 제거될 수 있음"
}
},
"activity": {
@@ -499,7 +602,15 @@
"quickScan": "빠른 스캔",
"fullScan": "전체 스캔",
"serverUptime": "서버 가동 시간",
- "serverDown": "오프라인"
+ "serverDown": "오프라인",
+ "scanType": "유형",
+ "status": "스캔 오류",
+ "elapsedTime": "경과 시간"
+ },
+ "nowPlaying": {
+ "title": "현재 재생 중",
+ "empty": "재생 중인 콘텐츠 없음",
+ "minutesAgo": "%{smart_count} 분 전"
},
"help": {
"title": "Navidrome 단축키",
From 9621a40f29a507b1e450da31a32134cdc7a9cf2a Mon Sep 17 00:00:00 2001
From: Deluan
Date: Fri, 7 Nov 2025 18:13:46 -0500
Subject: [PATCH 028/102] feat(ui): add Vietnamese localization for the
application
---
resources/i18n/vi.json | 628 +++++++++++++++++++++++++++++++++++++++++
1 file changed, 628 insertions(+)
create mode 100644 resources/i18n/vi.json
diff --git a/resources/i18n/vi.json b/resources/i18n/vi.json
new file mode 100644
index 000000000..a93a65588
--- /dev/null
+++ b/resources/i18n/vi.json
@@ -0,0 +1,628 @@
+{
+ "languageName": "Tiếng Việt",
+ "resources": {
+ "song": {
+ "name": "Tên bài hát",
+ "fields": {
+ "albumArtist": "Nghệ sĩ trong album",
+ "duration": "Thời lượng",
+ "trackNumber": "#",
+ "playCount": "Số lượt phát",
+ "title": "Tên",
+ "artist": "Nghệ sĩ",
+ "album": "Album",
+ "path": "Đường dẫn file",
+ "genre": "Thể loại",
+ "compilation": "Tuyển tập",
+ "year": "Năm",
+ "size": "Kích thước tệp",
+ "updatedAt": "Cập nhật vào",
+ "bitRate": "Số bit",
+ "discSubtitle": "Tiêu đề phụ của đĩa",
+ "starred": "Yêu thích",
+ "comment": "Bình luận",
+ "rating": "Đánh giá",
+ "quality": "Chất lượng",
+ "bpm": "BPM",
+ "playDate": "Phát lần cuối",
+ "channels": "Kênh",
+ "createdAt": "Ngày thêm bài hát",
+ "grouping": "Nhóm",
+ "mood": "Tâm trạng",
+ "participants": "Người tham gia bổ sung",
+ "tags": "Tag bổ sung",
+ "mappedTags": "Thẻ đã liên kết",
+ "rawTags": "Thẻ gốc",
+ "bitDepth": "",
+ "sampleRate": "",
+ "missing": "",
+ "libraryName": ""
+ },
+ "actions": {
+ "addToQueue": "Thêm bài hát vào hàng chờ",
+ "playNow": "Phát ",
+ "addToPlaylist": "Thêm vào danh sách",
+ "shuffleAll": "Ngẫu nhiên Tất cả",
+ "download": "Tải bài hát xuống",
+ "playNext": "Phát tiếp theo",
+ "info": "Lấy thông tin bài hát",
+ "showInPlaylist": ""
+ }
+ },
+ "album": {
+ "name": "Tên album",
+ "fields": {
+ "albumArtist": "Nghệ sĩ trong album",
+ "artist": "Nghệ sĩ",
+ "duration": "Thời lượng",
+ "songCount": "Số bài hát",
+ "playCount": "Số lượt phát",
+ "name": "Tên",
+ "genre": "Thể loại",
+ "compilation": "Tuyển tập",
+ "year": "Năm",
+ "updatedAt": "Cập nhật vào",
+ "comment": "Bình luận",
+ "rating": "Đánh giá",
+ "createdAt": "Ngày thêm album",
+ "size": "Kích cỡ",
+ "originalDate": "Bản gốc",
+ "releaseDate": "Ngày phát hành",
+ "releases": "Bản phát hành |||| Các bản phát hành",
+ "released": "Đã phát hành",
+ "recordLabel": "Hãng đĩa",
+ "catalogNum": "Số Catalog",
+ "releaseType": "Loai",
+ "grouping": "Nhóm",
+ "media": "",
+ "mood": "",
+ "date": "",
+ "missing": "",
+ "libraryName": ""
+ },
+ "actions": {
+ "playAll": "Phát",
+ "playNext": "Tiếp theo",
+ "addToQueue": "Thêm album vào hàng chờ",
+ "shuffle": "phát ngẫu nhiên",
+ "addToPlaylist": "Thêm vào danh sách phát",
+ "download": "Tải Album xuống",
+ "info": "Lấy thông tin album",
+ "share": "Chia sẻ"
+ },
+ "lists": {
+ "all": "Tất cả",
+ "random": "Ngẫu nhiên",
+ "recentlyAdded": "Thêm vào gần đây",
+ "recentlyPlayed": "Đã phát gần đây",
+ "mostPlayed": "Phát nhiều nhất",
+ "starred": "Album Yêu thích",
+ "topRated": "Được đánh giá cao nhất"
+ }
+ },
+ "artist": {
+ "name": "Nghệ sĩ",
+ "fields": {
+ "name": "Tên nghệ sĩ",
+ "albumCount": "Số Album",
+ "songCount": "Số bài hát",
+ "playCount": "Số lượt phát",
+ "rating": "Đánh giá",
+ "genre": "Thể loại",
+ "size": "Kích cỡ",
+ "role": "",
+ "missing": ""
+ },
+ "roles": {
+ "albumartist": "",
+ "artist": "",
+ "composer": "",
+ "conductor": "",
+ "lyricist": "",
+ "arranger": "",
+ "producer": "",
+ "director": "",
+ "engineer": "",
+ "mixer": "",
+ "remixer": "",
+ "djmixer": "",
+ "performer": "",
+ "maincredit": ""
+ },
+ "actions": {
+ "shuffle": "",
+ "radio": "",
+ "topSongs": ""
+ }
+ },
+ "user": {
+ "name": "Người dùng",
+ "fields": {
+ "userName": "Tên người dùng",
+ "isAdmin": "Quản trị viên",
+ "lastLoginAt": "Lần đăng nhập cuối",
+ "updatedAt": "Cập nhật lúc",
+ "name": "Tên người dùng",
+ "password": "Mật khẩu",
+ "createdAt": "Tạo vào",
+ "changePassword": "Đổi mật khẩu ?",
+ "currentPassword": "Mật khẩu hiện tại",
+ "newPassword": "Mật khẩu mới",
+ "token": "Token",
+ "lastAccessAt": "Lần truy cập cuối",
+ "libraries": ""
+ },
+ "helperTexts": {
+ "name": "Sự thay đổi về tên bạn sẽ có hiệu lực vào lần đăng nhập tiếp theo",
+ "libraries": ""
+ },
+ "notifications": {
+ "created": "Tạo bởi user",
+ "updated": "Cập nhật bởi user",
+ "deleted": "Xóa người dùng"
+ },
+ "message": {
+ "listenBrainzToken": "Nhập token của MusicBrainz",
+ "clickHereForToken": "",
+ "selectAllLibraries": "",
+ "adminAutoLibraries": ""
+ },
+ "validation": {
+ "librariesRequired": ""
+ }
+ },
+ "player": {
+ "name": "Trình phát |||| Các trình phát",
+ "fields": {
+ "name": "Tên trình phát",
+ "transcodingId": "Mã chuyển mã",
+ "maxBitRate": "Bit Rate cao nhất",
+ "client": "",
+ "userName": "Tên người dùng",
+ "lastSeen": "Lần cuối nhìn thấy",
+ "reportRealPath": "Hiện đường dẫn thực",
+ "scrobbleEnabled": ""
+ }
+ },
+ "transcoding": {
+ "name": "Chuyển đổi định dạng",
+ "fields": {
+ "name": "Tên cấu hình chuyển mã",
+ "targetFormat": "Định dạng cuối",
+ "defaultBitRate": "Số Bit mặc định",
+ "command": "Câu lệnh"
+ }
+ },
+ "playlist": {
+ "name": "Danh sách phát |||| Các danh sách phát",
+ "fields": {
+ "name": "Tên",
+ "duration": "Thời lượng",
+ "ownerName": "Chủ sở hữu",
+ "public": "Công khai",
+ "updatedAt": "Cập nhật vào",
+ "createdAt": "Tạo vào lúc",
+ "songCount": "Số bài hát",
+ "comment": "Bình luận",
+ "sync": "Tự động thêm vào",
+ "path": "Nhập từ"
+ },
+ "actions": {
+ "selectPlaylist": "Chọn 1 danh sách phát",
+ "addNewPlaylist": "Tạo \"%{name}\"",
+ "export": "Xuất danh sách phát",
+ "makePublic": "",
+ "makePrivate": "",
+ "saveQueue": "",
+ "searchOrCreate": "",
+ "pressEnterToCreate": "",
+ "removeFromSelection": ""
+ },
+ "message": {
+ "duplicate_song": "Thêm các bài hát trùng lặp",
+ "song_exist": "Có một số bài hát trùng đang được thêm vào danh sách phát. Bạn muốn thêm các bài trùng hay bỏ qua chúng?",
+ "noPlaylistsFound": "",
+ "noPlaylists": ""
+ }
+ },
+ "radio": {
+ "name": "Radio |||| Radios",
+ "fields": {
+ "name": "Tên",
+ "streamUrl": "Stream URL",
+ "homePageUrl": "URL trang chủ",
+ "updatedAt": "Cập nhật vào",
+ "createdAt": "Tạo vào lúc"
+ },
+ "actions": {
+ "playNow": "Phát ngay"
+ }
+ },
+ "share": {
+ "name": "Chia sẻ |||| Chia sẻ",
+ "fields": {
+ "username": "Chia sẻ bởi",
+ "url": "URL",
+ "description": "Phần mô tả",
+ "contents": "Nội dung",
+ "expiresAt": "Hết hạn",
+ "lastVisitedAt": "Lần mở cuối ",
+ "visitCount": "Lượt ",
+ "format": "Định dạng",
+ "maxBitRate": "Số Bit cao nhất",
+ "updatedAt": "Cập nhật vào",
+ "createdAt": "Tạo vào",
+ "downloadable": "Cho phép tải xuống?"
+ }
+ },
+ "missing": {
+ "name": "",
+ "fields": {
+ "path": "",
+ "size": "",
+ "updatedAt": "",
+ "libraryName": ""
+ },
+ "actions": {
+ "remove": "",
+ "remove_all": ""
+ },
+ "notifications": {
+ "removed": ""
+ },
+ "empty": ""
+ },
+ "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": "",
+ "manageUsers": "",
+ "viewDetails": ""
+ },
+ "notifications": {
+ "created": "",
+ "updated": "",
+ "deleted": "Xóa thư viện thành công",
+ "scanStarted": "Bắt đầu quét thư viện",
+ "scanCompleted": "Quét thư viện hoàn tất"
+ },
+ "validation": {
+ "nameRequired": "",
+ "pathRequired": "",
+ "pathNotDirectory": "",
+ "pathNotFound": "",
+ "pathNotAccessible": "",
+ "pathInvalid": ""
+ },
+ "messages": {
+ "deleteConfirm": "",
+ "scanInProgress": "Đang quét...",
+ "noLibrariesAssigned": ""
+ }
+ }
+ },
+ "ra": {
+ "auth": {
+ "welcome1": "Cảm ơn bạn vì đã sử dụng Navidrome",
+ "welcome2": "Để bắt đầu, hãy tạo một tài khoản quản trị viên.",
+ "confirmPassword": "Xác nhận mật khẩu",
+ "buttonCreateAdmin": "Tạo quản trị viên",
+ "auth_check_error": "Hãy đăng nhập để tiếp tục",
+ "user_menu": "Profile",
+ "username": "Tên người dùng",
+ "password": "Mật khẩu",
+ "sign_in": "Đăng nhập",
+ "sign_in_error": "Xác thực thất bại, hãy thử lại",
+ "logout": "Đăng xuất",
+ "insightsCollectionNote": "Navidrome thu thập dữ liệu sử dụng ẩn danh để giúp cải thiện dự án. Nhấp [here] để tìm hiểu thêm và tắt tính năng này nếu bạn muốn."
+ },
+ "validation": {
+ "invalidChars": "Vui lòng chỉ sử dụng chữ cái và số",
+ "passwordDoesNotMatch": "Mật khẩu không đúng",
+ "required": "Yêu cầu",
+ "minLength": "Ít nhất là %{min} ký tự",
+ "maxLength": "Phải nhiều hơn hoặc bằng hoặc bằng %{max}.",
+ "minValue": "Ít nhất là %{min}",
+ "maxValue": "Phải nhỏ hơn hoặc bằng %{max}",
+ "number": "Phải là một số",
+ "email": "Phải là một email ",
+ "oneOf": "Phải là một trong các lựa chọn sau: %{options}",
+ "regex": "Phải khớp với định dạng cụ thể (regex): %{pattern}",
+ "unique": "Phải đặc biệt",
+ "url": "Phải là một URL hợp lệ"
+ },
+ "action": {
+ "add_filter": "Thêm bộ lọc",
+ "add": "Thêm",
+ "back": "Quay lại",
+ "bulk_actions": "Đã chọn 1 mục |||| Đã chọn %{smart_count} mục",
+ "cancel": "Hủy",
+ "clear_input_value": "Xóa thiết đặt",
+ "clone": "Nhân bản",
+ "confirm": "Xác nhận",
+ "create": "Tạo",
+ "delete": "Xóa",
+ "edit": "Sửa",
+ "export": "Xuất",
+ "list": "Danh sách",
+ "refresh": "Làm mới",
+ "remove_filter": "Bỏ bộ lọc này",
+ "remove": "Gỡ bỏ",
+ "save": "Lưu lại",
+ "search": "Tìm kiếm",
+ "show": "Hiển thị",
+ "sort": "Lọc",
+ "undo": "Hoàn tác",
+ "expand": "Mở rộng",
+ "close": "Đóng",
+ "open_menu": "Mở menu",
+ "close_menu": "Đóng menu",
+ "unselect": "Bỏ chọn",
+ "skip": "Bỏ qua",
+ "bulk_actions_mobile": "1 |||| %{smart_count}",
+ "share": "Chia sẻ",
+ "download": "Tải xuống"
+ },
+ "boolean": {
+ "true": "Có",
+ "false": "Không"
+ },
+ "page": {
+ "create": "Tạo %{name}",
+ "dashboard": "Trang chủ",
+ "edit": "%{name} #%{id}",
+ "error": "Có gì đó không ổn",
+ "list": "%{name}",
+ "loading": "Đang tải",
+ "not_found": "Không tìm thấy",
+ "show": "%{name} #%{id}",
+ "empty": "Chưa có %{name}",
+ "invite": "Bạn muốn thêm vào không ?"
+ },
+ "input": {
+ "file": {
+ "upload_several": "Thả một vài tệp để tải lên hoặc nhấp để chọn",
+ "upload_single": "Thả một file để tải lên hoặc nhấp để chọn nó"
+ },
+ "image": {
+ "upload_several": "Thả một vài ảnh để tải lên hoặc nhấp để chọn",
+ "upload_single": "Thả một ảnh để tải lên hoặc nhấp để chọn nó"
+ },
+ "references": {
+ "all_missing": "Không thể tìm thấy dữ liệu",
+ "many_missing": "Ít nhất một mục được liên kết không còn tồn tại.",
+ "single_missing": "Tham chiếu liên kết không còn khả dụng nữa."
+ },
+ "password": {
+ "toggle_visible": "Ẩn mật khẩu",
+ "toggle_hidden": "Hiện mật khẩu"
+ }
+ },
+ "message": {
+ "about": "Giới thiệu",
+ "are_you_sure": "Bạn chắc chứ ?",
+ "bulk_delete_content": "Bạn có chắc chắn muốn xóa %{name} này không? |||| Bạn có chắc chắn muốn xóa %{smart_count} mục này không??",
+ "bulk_delete_title": "Xóa %{name} đã chọn |||| Xóa %{smart_count} mục %{name}",
+ "delete_content": "Xác nhận xóa ?",
+ "delete_title": "Xóa %{name} #%{id}",
+ "details": "Chi tiết",
+ "error": "Có lỗi xảy ra với client và yêu cầu của bạn không thành công.",
+ "invalid_form": "Biểu mẫu không hợp lệ. Vui lòng kiểm tra lại các lỗi",
+ "loading": "Trang đang được tải, hãy kiên nhận",
+ "no": "Không",
+ "not_found": "Có thể bạn đã nhập sai URL hoặc truy cập vào một liên kết không hợp lệ.",
+ "yes": "Có",
+ "unsaved_changes": "Một số thiết đặt chưa được lưu. Bạn muốn bỏ qua chúng không ?"
+ },
+ "navigation": {
+ "no_results": "Không tìm thấy kết quả",
+ "no_more_results": "Số trang %{page} nằm ngoài giới hạn. Hãy thử quay lại trang trước",
+ "page_out_of_boundaries": "Trang %{page} không hợp lệ",
+ "page_out_from_end": "Bạn đang ở trang cuối rồi",
+ "page_out_from_begin": "Không thể quay về trước trang 1",
+ "page_range_info": "%{offsetBegin}–%{offsetEnd} trong tổng số %{total}",
+ "page_rows_per_page": "Số mục mỗi trang :",
+ "next": "Tiếp theo",
+ "prev": "Trước",
+ "skip_nav": "Bỏ qua đến nội dung"
+ },
+ "notification": {
+ "updated": "Mục đã được cập nhật |||| %{smart_count} mục đã cập nhật",
+ "created": "Đã tạo mục mới",
+ "deleted": "Đã xóa muc |||| %{smart_count} mục đã xóa",
+ "bad_item": "Mục không đúng",
+ "item_doesnt_exist": "Mục không tồn tại",
+ "http_error": "Lỗi kết nối đến máy chủ",
+ "data_provider_error": "Lỗi dataProvider. Kiểm tra Console để biết thêm chi tiết",
+ "i18n_error": "Không thể tải bản dịch cho ngôn ngữ đã chọn",
+ "canceled": "Hành động đã bị hủy",
+ "logged_out": "Phiên của bạn đã kết thúc, vui lòng kết nối lại.",
+ "new_version": "Có phiên bản mới! Hãy làm mới trang"
+ },
+ "toggleFieldsMenu": {
+ "columnsToDisplay": "Các cột hiển thị",
+ "layout": "Bố cục",
+ "grid": "Lưới",
+ "table": "Bảng"
+ }
+ },
+ "message": {
+ "note": "Lưu ý",
+ "transcodingDisabled": "Việc thay đổi cấu hình chuyển mã (transcoding configuration) thông qua giao diện web đã bị vô hiệu hóa vì lý do bảo mật. Nếu bạn muốn chỉnh sửa hoặc thêm tùy chọn chuyển mã, hãy khởi động lại máy chủ kèm theo tùy chọn cấu hình %{config}",
+ "transcodingEnabled": "Navidrome hiện đang chạy với tùy chọn cấu hình %{config}, cho phép thực thi lệnh hệ thống từ phần cài đặt chuyển mã (transcoding) trong giao diện web. Chúng tôi khuyến nghị bạn nên tắt tùy chọn này vì lý do bảo mật, và chỉ bật lại khi cần cấu hình các tùy chọn chuyển mã.",
+ "songsAddedToPlaylist": "Đã thêm 1 bài hát vào danh sách phát |||| Đã thêm %{smart_count} bài hát vào danh sách phát",
+ "noPlaylistsAvailable": "Không có danh sách phát",
+ "delete_user_title": "Xóa người dùng '%{name}'",
+ "delete_user_content": "Bạn có muốn xóa người dùng này và tất cả các dữ liệu của họ không ( bao gồm danh sách phát và các thiết đặt )?",
+ "notifications_blocked": "Bạn đã tắt thông báo trong cài đặt trình duyệt",
+ "notifications_not_available": "Trình duyệt này không hỗ trợ thông báo trên desktop hoặc bạn đang truy cập Navidrome qua http",
+ "lastfmLinkSuccess": "",
+ "lastfmLinkFailure": "",
+ "lastfmUnlinkSuccess": "",
+ "lastfmUnlinkFailure": "",
+ "openIn": {
+ "lastfm": "Mở trong Last.fm",
+ "musicbrainz": "Mở trong MusicBrainz"
+ },
+ "lastfmLink": "Đọc thêm...",
+ "listenBrainzLinkSuccess": "",
+ "listenBrainzLinkFailure": "Không thể liên kết với ListenBrainz : %{error}",
+ "listenBrainzUnlinkSuccess": "Đã bỏ liên kết với ListenBrainz và ",
+ "listenBrainzUnlinkFailure": "Không thể liên kết với MusicBrainz",
+ "downloadOriginalFormat": "Tải xuống ở định dạng gốc",
+ "shareOriginalFormat": "Chia sẻ ở định dạng gốc",
+ "shareDialogTitle": "Chia sẻ %{resource} '%{name}'",
+ "shareBatchDialogTitle": "Chia sẻ 1 %{resource} |||| Chia sẻ %{smart_count} %{resource}",
+ "shareSuccess": "URL đã sao chép vào bảng nhớ tạm : %{url}",
+ "shareFailure": "Lỗi khi sao chép URL %{url} vào bảng nhớ tạm",
+ "downloadDialogTitle": "Tải xuống %{resource} '%{name}' (%{size})",
+ "shareCopyToClipboard": "Sao chép vào bảng nhớ tạm : Ctrl+C, Enter",
+ "remove_missing_title": "",
+ "remove_missing_content": "",
+ "remove_all_missing_title": "",
+ "remove_all_missing_content": "",
+ "noSimilarSongsFound": "",
+ "noTopSongsFound": ""
+ },
+ "menu": {
+ "library": "Thư viện",
+ "settings": "Cài đặt",
+ "version": "Phiên bản",
+ "theme": "Theme",
+ "personal": {
+ "name": "Cá nhân hóa",
+ "options": {
+ "theme": "Theme",
+ "language": "Ngôn ngữ",
+ "defaultView": "",
+ "desktop_notifications": "Thông báo trên desktop",
+ "lastfmScrobbling": "",
+ "listenBrainzScrobbling": "",
+ "replaygain": "Chế độ ReplayGain",
+ "preAmp": "ReplayGain PreAmp (dB)",
+ "gain": {
+ "none": "Tắt",
+ "album": "Dùng Album Gain",
+ "track": "Dùng Track Gain"
+ },
+ "lastfmNotConfigured": "Khóa API của Last.fm chưa được cấu hình"
+ }
+ },
+ "albumList": "Albums",
+ "about": "Về",
+ "playlists": "Danh sách phát",
+ "sharedPlaylists": "Danh sách phát được chia sẻ",
+ "librarySelector": {
+ "allLibraries": "Tất cả thư viện (%{count})",
+ "multipleLibraries": "",
+ "selectLibraries": "",
+ "none": "Không có"
+ }
+ },
+ "player": {
+ "playListsText": "Danh sách chờ",
+ "openText": "Mở",
+ "closeText": "Thoát",
+ "notContentText": "Không có bài hát",
+ "clickToPlayText": "Nhấp để phát",
+ "clickToPauseText": "Nhấp để tạm dừng",
+ "nextTrackText": "Track tiếp theo",
+ "previousTrackText": "Track trước đó",
+ "reloadText": "Làm mới",
+ "volumeText": "Âm lượng",
+ "toggleLyricText": "Bật lời bài hát",
+ "toggleMiniModeText": "Thu nhỏ",
+ "destroyText": "Xóa",
+ "downloadText": "Tải xuống",
+ "removeAudioListsText": "Xóa danh sách ",
+ "clickToDeleteText": "Nhấp để xóa %{name}",
+ "emptyLyricText": "Không có lời",
+ "playModeText": {
+ "order": "Theo thứ tự",
+ "orderLoop": "Lặp lại",
+ "singleLoop": "Lặp lại một lần",
+ "shufflePlay": "Phát ngẫu nhiên"
+ }
+ },
+ "about": {
+ "links": {
+ "homepage": "Trang chủ",
+ "source": "Mã nguồn",
+ "featureRequests": "Yêu cầu tính năng",
+ "lastInsightsCollection": "Lần thu thập dữ liệu gần nhất",
+ "insights": {
+ "disabled": "Đã tắt",
+ "waiting": "Đang chờ"
+ }
+ },
+ "tabs": {
+ "about": "",
+ "config": ""
+ },
+ "config": {
+ "configName": "",
+ "environmentVariable": "",
+ "currentValue": "",
+ "configurationFile": "",
+ "exportToml": "",
+ "exportSuccess": "",
+ "exportFailed": "",
+ "devFlagsHeader": "",
+ "devFlagsComment": ""
+ }
+ },
+ "activity": {
+ "title": "Hoạt động",
+ "totalScanned": "Tổng Folder đã quét",
+ "quickScan": "Quét nhanh",
+ "fullScan": "Quét toàn bộ",
+ "serverUptime": "Server Uptime",
+ "serverDown": "Ngoại tuyến",
+ "scanType": "",
+ "status": "",
+ "elapsedTime": ""
+ },
+ "help": {
+ "title": "Phím tắt của Navidrome",
+ "hotkeys": {
+ "show_help": "Hiện giúp đỡ",
+ "toggle_menu": "Bật thanh phát bên",
+ "toggle_play": "Phát / tạm dừng",
+ "prev_song": "Bài hát trước đó",
+ "next_song": "Bài hát sau đó",
+ "vol_up": "Tăng âm lượng",
+ "vol_down": "Giảm âm lượng",
+ "toggle_love": "Thêm track này vào yêu thích",
+ "current_song": "Đi đến bài hát hiện tại"
+ }
+ },
+ "nowPlaying": {
+ "title": "",
+ "empty": "",
+ "minutesAgo": ""
+ }
+}
\ No newline at end of file
From 6f4fa767724130bef58c186027528204a0a1c965 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Fri, 7 Nov 2025 18:20:39 -0500
Subject: [PATCH 029/102] fix(ui): update Galician, Dutch, Thai translations
from POEditor (#4416)
Co-authored-by: navidrome-bot
---
resources/i18n/gl.json | 148 +++++++++++++++++++++++++++++----
resources/i18n/nl.json | 156 ++++++++++++++++++++++++++++------
resources/i18n/th.json | 184 ++++++++++++++++++++++++++++++++++++++---
3 files changed, 433 insertions(+), 55 deletions(-)
diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json
index 8cde597cc..a6c3beb05 100644
--- a/resources/i18n/gl.json
+++ b/resources/i18n/gl.json
@@ -31,8 +31,12 @@
"mood": "Estado",
"participants": "Participantes adicionais",
"tags": "Etiquetas adicionais",
- "mappedTags": "",
- "rawTags": "Etiquetas en cru"
+ "mappedTags": "Etiquetas mapeadas",
+ "rawTags": "Etiquetas en cru",
+ "bitDepth": "Calidade de Bit",
+ "sampleRate": "Taxa de mostra",
+ "missing": "Falta",
+ "libraryName": "Biblioteca"
},
"actions": {
"addToQueue": "Ao final da cola",
@@ -41,7 +45,8 @@
"shuffleAll": "Remexer todo",
"download": "Descargar",
"playNext": "A continuación",
- "info": "Obter info"
+ "info": "Obter info",
+ "showInPlaylist": "Mostrar en Lista de reprodución"
}
},
"album": {
@@ -70,7 +75,10 @@
"releaseType": "Tipo",
"grouping": "Grupos",
"media": "Multimedia",
- "mood": "Estado"
+ "mood": "Estado",
+ "date": "Data de gravación",
+ "missing": "Falta",
+ "libraryName": "Biblioteca"
},
"actions": {
"playAll": "Reproducir",
@@ -102,7 +110,8 @@
"rating": "Valoración",
"genre": "Xénero",
"size": "Tamaño",
- "role": "Rol"
+ "role": "Rol",
+ "missing": "Falta"
},
"roles": {
"albumartist": "Artista do álbum |||| Artistas do álbum",
@@ -117,7 +126,13 @@
"mixer": "Mistura |||| Mistura",
"remixer": "Remezcla |||| Remezcla",
"djmixer": "Mezcla DJs |||| Mezcla DJs",
- "performer": "Intérprete |||| Intérpretes"
+ "performer": "Intérprete |||| Intérpretes",
+ "maincredit": "Artista do álbum ou Artista |||| Artistas do álbum ou Artistas"
+ },
+ "actions": {
+ "shuffle": "Barallar",
+ "radio": "Radio",
+ "topSongs": "Cancións destacadas"
}
},
"user": {
@@ -134,10 +149,12 @@
"currentPassword": "Contrasinal actual",
"newPassword": "Novo contrasinal",
"token": "Token",
- "lastAccessAt": "Último acceso"
+ "lastAccessAt": "Último acceso",
+ "libraries": "Bibliotecas"
},
"helperTexts": {
- "name": "Os cambios no nome aplicaranse a próxima vez que accedas"
+ "name": "Os cambios no nome aplicaranse a próxima vez que accedas",
+ "libraries": "Selecciona bibliotecas específicas para esta usuaria, ou deixa baleiro para usar as bibliotecas por defecto"
},
"notifications": {
"created": "Creouse a usuaria",
@@ -146,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Escribe o token de usuaria de ListenBrainz",
- "clickHereForToken": "Preme aquí para obter o token"
+ "clickHereForToken": "Preme aquí para obter o token",
+ "selectAllLibraries": "Seleccionar todas as bibliotecas",
+ "adminAutoLibraries": "As usuarias Admin teñen acceso por defecto a todas as bibliotecas"
+ },
+ "validation": {
+ "librariesRequired": "Debes seleccionar polo menos unha biblioteca para usuarias non admins"
}
},
"player": {
@@ -190,11 +212,17 @@
"addNewPlaylist": "Crear \"%{name}\"",
"export": "Exportar",
"makePublic": "Facela Pública",
- "makePrivate": "Facela Privada"
+ "makePrivate": "Facela Privada",
+ "saveQueue": "Salvar a Cola como Lista de reprodución",
+ "searchOrCreate": "Buscar listas ou escribe para crear nova…",
+ "pressEnterToCreate": "Preme Enter para crear nova lista",
+ "removeFromSelection": "Retirar da selección"
},
"message": {
"duplicate_song": "Engadir cancións duplicadas",
- "song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?"
+ "song_exist": "Hai duplicadas que serán engadidas á lista de reprodución. Desexas engadir as duplicadas ou omitilas?",
+ "noPlaylistsFound": "Sen listas de reprodución",
+ "noPlaylists": "Sen listas dispoñibles"
}
},
"radio": {
@@ -232,13 +260,68 @@
"fields": {
"path": "Ruta",
"size": "Tamaño",
- "updatedAt": "Desapareceu o"
+ "updatedAt": "Desapareceu o",
+ "libraryName": "Biblioteca"
},
"actions": {
- "remove": "Retirar"
+ "remove": "Retirar",
+ "remove_all": "Retirar todo"
},
"notifications": {
"removed": "Ficheiro(s) faltantes retirados"
+ },
+ "empty": "Sen ficheiros faltantes"
+ },
+ "library": {
+ "name": "Biblioteca |||| Bibliotecas",
+ "fields": {
+ "name": "Nome",
+ "path": "Ruta",
+ "remotePath": "Ruta remota",
+ "lastScanAt": "Último escaneado",
+ "songCount": "Cancións",
+ "albumCount": "Álbums",
+ "artistCount": "Artistas",
+ "totalSongs": "Cancións",
+ "totalAlbums": "Álbums",
+ "totalArtists": "Artistas",
+ "totalFolders": "Cartafoles",
+ "totalFiles": "Ficheiros",
+ "totalMissingFiles": "Ficheiros que faltan",
+ "totalSize": "Tamaño total",
+ "totalDuration": "Duración",
+ "defaultNewUsers": "Por defecto para novas usuarias",
+ "createdAt": "Creada",
+ "updatedAt": "Actualizada"
+ },
+ "sections": {
+ "basic": "Información básica",
+ "statistics": "Estatísticas"
+ },
+ "actions": {
+ "scan": "Escanear Biblioteca",
+ "manageUsers": "Xestionar acceso das usuarias",
+ "viewDetails": "Ver detalles"
+ },
+ "notifications": {
+ "created": "Biblioteca creada correctamente",
+ "updated": "Biblioteca actualizada correctamente",
+ "deleted": "Biblioteca eliminada correctamente",
+ "scanStarted": "Comezou o escaneo da biblioteca",
+ "scanCompleted": "Completouse o escaneado da biblioteca"
+ },
+ "validation": {
+ "nameRequired": "Requírese un nome para a biblioteca",
+ "pathRequired": "Requírese unha ruta para a biblioteca",
+ "pathNotDirectory": "A ruta á biblioteca ten que ser un directorio",
+ "pathNotFound": "Non se atopa a ruta á biblioteca",
+ "pathNotAccessible": "A ruta á biblioteca non é accesible",
+ "pathInvalid": "Ruta non válida á biblioteca"
+ },
+ "messages": {
+ "deleteConfirm": "Tes certeza de querer eliminar esta biblioteca? Isto eliminará todos os datos asociados e accesos de usuarias.",
+ "scanInProgress": "Escaneo en progreso…",
+ "noLibrariesAssigned": "Sen bibliotecas asignadas a esta usuaria"
}
}
},
@@ -419,7 +502,11 @@
"downloadDialogTitle": "Descargar %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Copiar ao portapapeis: Ctrl+C, Enter",
"remove_missing_title": "Retirar ficheiros que faltan",
- "remove_missing_content": "Tes certeza de querer retirar da base de datos os ficheiros que faltan? Isto retirará de xeito permanente todas a referencias a eles, incluíndo a conta de reproducións e valoracións."
+ "remove_missing_content": "Tes certeza de querer retirar da base de datos os ficheiros que faltan? Isto retirará de xeito permanente todas a referencias a eles, incluíndo a conta de reproducións e valoracións.",
+ "remove_all_missing_title": "Retirar todos os ficheiros que faltan",
+ "remove_all_missing_content": "Tes certeza de querer retirar da base de datos todos os ficheiros que faltan? Isto eliminará todas as referencias a eles, incluíndo o número de reproducións e valoracións.",
+ "noSimilarSongsFound": "Sen cancións parecidas",
+ "noTopSongsFound": "Sen cancións destacadas"
},
"menu": {
"library": "Biblioteca",
@@ -448,7 +535,13 @@
"albumList": "Álbums",
"about": "Acerca de",
"playlists": "Listas de reprodución",
- "sharedPlaylists": "Listas compartidas"
+ "sharedPlaylists": "Listas compartidas",
+ "librarySelector": {
+ "allLibraries": "Todas as bibliotecas (%{count})",
+ "multipleLibraries": "%{selected} de %{total} Bibliotecas",
+ "selectLibraries": "Seleccionar Bibliotecas",
+ "none": "Ningunha"
+ }
},
"player": {
"playListsText": "Reproducir cola",
@@ -485,6 +578,21 @@
"disabled": "Desactivado",
"waiting": "Agardando"
}
+ },
+ "tabs": {
+ "about": "Sobre",
+ "config": "Configuración"
+ },
+ "config": {
+ "configName": "Nome",
+ "environmentVariable": "Variable de entorno",
+ "currentValue": "Valor actual",
+ "configurationFile": "Ficheiro de configuración",
+ "exportToml": "Exportar configuración (TOML)",
+ "exportSuccess": "Configuración exportada ao portapapeis no formato TOML",
+ "exportFailed": "Fallou a copia da configuración",
+ "devFlagsHeader": "Configuracións de Desenvolvemento (suxeitas a cambio/retirada)",
+ "devFlagsComment": "Son axustes experimentais e poden retirarse en futuras versións"
}
},
"activity": {
@@ -493,7 +601,10 @@
"quickScan": "Escaneo rápido",
"fullScan": "Escaneo completo",
"serverUptime": "Servidor a funcionar",
- "serverDown": "SEN CONEXIÓN"
+ "serverDown": "SEN CONEXIÓN",
+ "scanType": "Tipo",
+ "status": "Erro de escaneado",
+ "elapsedTime": "Tempo transcurrido"
},
"help": {
"title": "Atallos de Navidrome",
@@ -508,5 +619,10 @@
"toggle_love": "Engadir canción a favoritas",
"current_song": "Ir á Canción actual "
}
+ },
+ "nowPlaying": {
+ "title": "En reprodución",
+ "empty": "Sen reprodución",
+ "minutesAgo": "hai %{smart_count} minuto |||| hai %{smart_count} minutos"
}
}
\ No newline at end of file
diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json
index 4737cb33a..b6da47380 100644
--- a/resources/i18n/nl.json
+++ b/resources/i18n/nl.json
@@ -5,7 +5,7 @@
"name": "Nummer |||| Nummers",
"fields": {
"albumArtist": "Album Artiest",
- "duration": "Lengte",
+ "duration": "Afspeelduur",
"trackNumber": "Nummer #",
"playCount": "Aantal keren afgespeeld",
"title": "Titel",
@@ -35,7 +35,8 @@
"rawTags": "Onbewerkte tags",
"bitDepth": "Bit diepte",
"sampleRate": "Sample waarde",
- "missing": "Ontbrekend"
+ "missing": "Ontbrekend",
+ "libraryName": "Bibliotheek"
},
"actions": {
"addToQueue": "Voeg toe aan wachtrij",
@@ -44,7 +45,8 @@
"shuffleAll": "Shuffle alles",
"download": "Downloaden",
"playNext": "Volgende",
- "info": "Meer info"
+ "info": "Meer info",
+ "showInPlaylist": "Toon in afspeellijst"
}
},
"album": {
@@ -55,7 +57,7 @@
"duration": "Afspeelduur",
"songCount": "Nummers",
"playCount": "Aantal keren afgespeeld",
- "name": "Naam",
+ "name": "Titel",
"genre": "Genre",
"compilation": "Compilatie",
"year": "Jaar",
@@ -65,9 +67,9 @@
"createdAt": "Datum toegevoegd",
"size": "Grootte",
"originalDate": "Origineel",
- "releaseDate": "Uitgegeven",
+ "releaseDate": "Uitgave",
"releases": "Uitgave |||| Uitgaven",
- "released": "Uitgegeven",
+ "released": "Uitgave",
"recordLabel": "Label",
"catalogNum": "Catalogus nummer",
"releaseType": "Type",
@@ -75,7 +77,8 @@
"media": "Media",
"mood": "Sfeer",
"date": "Opnamedatum",
- "missing": "Ontbrekend"
+ "missing": "Ontbrekend",
+ "libraryName": "Bibliotheek"
},
"actions": {
"playAll": "Afspelen",
@@ -123,7 +126,13 @@
"mixer": "Mixer |||| Mixers",
"remixer": "Remixer |||| Remixers",
"djmixer": "DJ Mixer |||| DJ Mixers",
- "performer": "Performer |||| Performers"
+ "performer": "Performer |||| Performers",
+ "maincredit": "Album Artiest of Artiest |||| Album Artiesten or Artiesten"
+ },
+ "actions": {
+ "shuffle": "Shuffle",
+ "radio": "Radio",
+ "topSongs": "Beste nummers"
}
},
"user": {
@@ -132,7 +141,7 @@
"userName": "Gebruikersnaam",
"isAdmin": "Is beheerder",
"lastLoginAt": "Laatst ingelogd op",
- "updatedAt": "Laatst gewijzigd op",
+ "updatedAt": "Laatst bijgewerkt op",
"name": "Naam",
"password": "Wachtwoord",
"createdAt": "Aangemaakt op",
@@ -140,19 +149,26 @@
"currentPassword": "Huidig wachtwoord",
"newPassword": "Nieuw wachtwoord",
"token": "Token",
- "lastAccessAt": "Meest recente toegang"
+ "lastAccessAt": "Meest recente toegang",
+ "libraries": "Bibliotheken"
},
"helperTexts": {
- "name": "Naamswijziging wordt pas zichtbaar bij de volgende login"
+ "name": "Naamswijziging wordt pas zichtbaar bij de volgende login",
+ "libraries": "Selecteer specifieke bibliotheken voor deze gebruiker, of laat leeg om de standaardbiblliotheken te gebruiken"
},
"notifications": {
"created": "Aangemaakt door gebruiker",
- "updated": "Gewijzigd door gebruiker",
- "deleted": "Gewist door gebruiker"
+ "updated": "Bijgewerkt door gebruiker",
+ "deleted": "Gebruiker verwijderd"
},
"message": {
"listenBrainzToken": "Vul je ListenBrainz gebruikers-token in.",
- "clickHereForToken": "Klik hier voor je token"
+ "clickHereForToken": "Klik hier voor je token",
+ "selectAllLibraries": "Selecteer alle bibliotheken",
+ "adminAutoLibraries": "Admin gebruikers hebben automatisch toegang tot alle bibliotheken"
+ },
+ "validation": {
+ "librariesRequired": "Minstens één bibliotheek moet geselecteerd worden voor niet-admin gebruikers"
}
},
"player": {
@@ -181,10 +197,10 @@
"name": "Afspeellijst |||| Afspeellijsten",
"fields": {
"name": "Titel",
- "duration": "Lengte",
+ "duration": "Afspeelduur",
"ownerName": "Eigenaar",
"public": "Publiek",
- "updatedAt": "Laatst gewijzigd op",
+ "updatedAt": "Laatst bijgewerkt op",
"createdAt": "Aangemaakt op",
"songCount": "Nummers",
"comment": "Commentaar",
@@ -197,11 +213,16 @@
"export": "Exporteer",
"makePublic": "Openbaar maken",
"makePrivate": "Privé maken",
- "saveQueue": "Bewaar wachtrij als playlist"
+ "saveQueue": "Bewaar wachtrij als playlist",
+ "searchOrCreate": "Zoek afspeellijsten of typ om een nieuwe te starten...",
+ "pressEnterToCreate": "Druk Enter om nieuwe afspeellijst te maken",
+ "removeFromSelection": "Verwijder van selectie"
},
"message": {
"duplicate_song": "Dubbele nummers toevoegen",
- "song_exist": "Er komen nummers dubbel in de afspeellijst. Wil je de dubbele nummers toevoegen of overslaan?"
+ "song_exist": "Er komen nummers dubbel in de afspeellijst. Wil je de dubbele nummers toevoegen of overslaan?",
+ "noPlaylistsFound": "Geen playlists gevonden",
+ "noPlaylists": "Geen playlists beschikbaar"
}
},
"radio": {
@@ -210,8 +231,8 @@
"name": "Naam",
"streamUrl": "Stream URL",
"homePageUrl": "Hoofdpagina URL",
- "updatedAt": "Geüpdate op",
- "createdAt": "Gecreëerd op"
+ "updatedAt": "Bijgewerkt op",
+ "createdAt": "Aangemaakt op"
},
"actions": {
"playNow": "Speel nu"
@@ -229,8 +250,8 @@
"visitCount": "Bezocht",
"format": "Formaat",
"maxBitRate": "Max. bitrate",
- "updatedAt": "Geüpdatet op",
- "createdAt": "Gecreëerd op",
+ "updatedAt": "Bijgewerkt op",
+ "createdAt": "Aangemaakt op",
"downloadable": "Downloads toestaan?"
}
},
@@ -239,7 +260,8 @@
"fields": {
"path": "Pad",
"size": "Grootte",
- "updatedAt": "Verdwenen op"
+ "updatedAt": "Verdwenen op",
+ "libraryName": "Bibliotheek"
},
"actions": {
"remove": "Verwijder",
@@ -249,6 +271,58 @@
"removed": "Ontbrekende bestanden verwijderd"
},
"empty": "Geen ontbrekende bestanden"
+ },
+ "library": {
+ "name": "Bibliotheek |||| Bibliotheken",
+ "fields": {
+ "name": "Naam",
+ "path": "Pad",
+ "remotePath": "Extern pad",
+ "lastScanAt": "Laatste scan",
+ "songCount": "Nummers",
+ "albumCount": "Albums",
+ "artistCount": "Artiesten",
+ "totalSongs": "Nummers",
+ "totalAlbums": "Albums",
+ "totalArtists": "Artiesten",
+ "totalFolders": "Mappen",
+ "totalFiles": "Bestanden",
+ "totalMissingFiles": "Ontbrekende bestanden",
+ "totalSize": "Totale bestandsgrootte",
+ "totalDuration": "Afspeelduur",
+ "defaultNewUsers": "Standaard voor nieuwe gebruikers",
+ "createdAt": "Aangemaakt",
+ "updatedAt": "Bijgewerkt"
+ },
+ "sections": {
+ "basic": "Basisinformatie",
+ "statistics": "Statistieken"
+ },
+ "actions": {
+ "scan": "Scan bibliotheek",
+ "manageUsers": "Beheer gebruikerstoegang",
+ "viewDetails": "Bekijk details"
+ },
+ "notifications": {
+ "created": "Bibliotheek succesvol aangemaakt",
+ "updated": "Bibliotheek succesvol bijgewerkt",
+ "deleted": "Bibliotheek succesvol verwijderd",
+ "scanStarted": "Bibliotheekscan is gestart",
+ "scanCompleted": "Bibliotheekscan is voltooid"
+ },
+ "validation": {
+ "nameRequired": "Bibliotheek naam is vereist",
+ "pathRequired": "Pad naar bibliotheek is vereist",
+ "pathNotDirectory": "Pad naar bibliotheek moet een map zijn",
+ "pathNotFound": "Pad naar bibliotheek niet gevonden",
+ "pathNotAccessible": "Pad naar bibliotheek is niet toegankelijk",
+ "pathInvalid": "Ongeldig pad naar bibliotheek"
+ },
+ "messages": {
+ "deleteConfirm": "Weet je zeker dat je deze bibliotheek wil verwijderen? Dit verwijdert ook alle gerelateerde data en gebruikerstoegang.",
+ "scanInProgress": "Scan is bezig...",
+ "noLibrariesAssigned": "Geen bibliotheken aan deze gebruiker toegewezen"
+ }
}
},
"ra": {
@@ -430,7 +504,9 @@
"remove_missing_title": "Verwijder ontbrekende bestanden",
"remove_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
"remove_all_missing_title": "Verwijder alle ontbrekende bestanden",
- "remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen."
+ "remove_all_missing_content": "Weet je zeker dat je alle ontbrekende bestanden van de database wil verwijderen? Dit wist permanent al hun referenties inclusief afspeel tellers en beoordelingen.",
+ "noSimilarSongsFound": "Geen vergelijkbare nummers gevonden",
+ "noTopSongsFound": "Geen beste nummers gevonden"
},
"menu": {
"library": "Bibliotheek",
@@ -459,7 +535,13 @@
"albumList": "Albums",
"about": "Over",
"playlists": "Afspeellijsten",
- "sharedPlaylists": "Gedeelde afspeellijsten"
+ "sharedPlaylists": "Gedeelde afspeellijsten",
+ "librarySelector": {
+ "allLibraries": "Alle bibliotheken (%{count})",
+ "multipleLibraries": "%{selected} van %{total} bibliotheken",
+ "selectLibraries": "Selecteer bibliotheken",
+ "none": "Geen"
+ }
},
"player": {
"playListsText": "Wachtrij",
@@ -468,7 +550,7 @@
"notContentText": "Geen muziek",
"clickToPlayText": "Klik om af te spelen",
"clickToPauseText": "Klik om te pauzeren",
- "nextTrackText": "Volgende",
+ "nextTrackText": "Volgend nummer",
"previousTrackText": "Vorige",
"reloadText": "Herladen",
"volumeText": "Volume",
@@ -496,11 +578,26 @@
"disabled": "Uitgeschakeld",
"waiting": "Wachten"
}
+ },
+ "tabs": {
+ "about": "Over",
+ "config": "Configuratie"
+ },
+ "config": {
+ "configName": "Config Naam",
+ "environmentVariable": "Omgevingsvariabele",
+ "currentValue": "Huidige waarde",
+ "configurationFile": "Configuratiebestand",
+ "exportToml": "Exporteer configuratie (TOML)",
+ "exportSuccess": "Configuratie geëxporteerd naar klembord in TOML formaat",
+ "exportFailed": "Kopiëren van configuratie mislukt",
+ "devFlagsHeader": "Ontwikkelaarsinstellingen (onder voorbehoud)",
+ "devFlagsComment": "Dit zijn experimentele instellingen en worden mogelijk in latere versies verwijderd"
}
},
"activity": {
"title": "Activiteit",
- "totalScanned": "Totaal gescande folders",
+ "totalScanned": "Totaal gescande mappen",
"quickScan": "Snelle scan",
"fullScan": "Volledige scan",
"serverUptime": "Server uptime",
@@ -522,5 +619,10 @@
"toggle_love": "Voeg toe aan favorieten",
"current_song": "Ga naar huidig nummer"
}
+ },
+ "nowPlaying": {
+ "title": "Speelt nu",
+ "empty": "Er wordt niets afgespeed",
+ "minutesAgo": "%{smart_count} minuut geleden |||| %{smart_count} minuten geleden"
}
}
\ No newline at end of file
diff --git a/resources/i18n/th.json b/resources/i18n/th.json
index 2f96f4958..65d51860f 100644
--- a/resources/i18n/th.json
+++ b/resources/i18n/th.json
@@ -26,7 +26,17 @@
"bpm": "BPM",
"playDate": "เล่นล่าสุด",
"channels": "ช่อง",
- "createdAt": "เพิ่มเมื่อ"
+ "createdAt": "เพิ่มเมื่อ",
+ "grouping": "จัดกลุ่ม",
+ "mood": "อารมณ์",
+ "participants": "ผู้มีส่วนร่วม",
+ "tags": "แทกเพิ่มเติม",
+ "mappedTags": "แมพแทก",
+ "rawTags": "แทกเริ่มต้น",
+ "bitDepth": "Bit depth",
+ "sampleRate": "แซมเปิ้ลเรต",
+ "missing": "หายไป",
+ "libraryName": "ห้องสมุด"
},
"actions": {
"addToQueue": "เพิ่มในคิว",
@@ -35,7 +45,8 @@
"shuffleAll": "สุ่มทั้งหมด",
"download": "ดาวน์โหลด",
"playNext": "เล่นถัดไป",
- "info": "ดูรายละเอียด"
+ "info": "ดูรายละเอียด",
+ "showInPlaylist": "แสดงในเพลย์ลิสต์"
}
},
"album": {
@@ -58,7 +69,16 @@
"originalDate": "วันที่เริ่ม",
"releaseDate": "เผยแพร่เมื่อ",
"releases": "เผยแพร่ |||| เผยแพร่",
- "released": "เผยแพร่เมื่อ"
+ "released": "เผยแพร่เมื่อ",
+ "recordLabel": "ป้าย",
+ "catalogNum": "หมายเลขแคตาล็อก",
+ "releaseType": "ประเภท",
+ "grouping": "จัดกลุ่ม",
+ "media": "มีเดีย",
+ "mood": "อารมณ์",
+ "date": "บันทึกเมื่อ",
+ "missing": "หายไป",
+ "libraryName": "ห้องสมุด"
},
"actions": {
"playAll": "เล่นทั้งหมด",
@@ -89,7 +109,30 @@
"playCount": "เล่นแล้ว",
"rating": "ความนิยม",
"genre": "ประเภท",
- "size": "ขนาด"
+ "size": "ขนาด",
+ "role": "Role",
+ "missing": "หายไป"
+ },
+ "roles": {
+ "albumartist": "ศิลปินอัลบั้ม |||| ศิลปินอัลบั้ม",
+ "artist": "ศิลปิน |||| ศิลปิน",
+ "composer": "ผู้แต่ง |||| ผู้แต่ง",
+ "conductor": "คอนดักเตอร์ |||| คอนดักเตอร์",
+ "lyricist": "เนื้อเพลง |||| เนื้อเพลง",
+ "arranger": "ผู้ดำเนินการ |||| ผู้ดำเนินการ",
+ "producer": "ผู้จัด |||| ผู้จัด",
+ "director": "ไดเรกเตอร์ |||| ไดเรกเตอร์",
+ "engineer": "วิศวกร |||| วิศวกร",
+ "mixer": "มิกเซอร์ |||| มิกเซอร์",
+ "remixer": "รีมิกเซอร์ |||| รีมิกเซอร์",
+ "djmixer": "ดีเจมิกเซอร์ |||| ดีเจมิกเซอร์",
+ "performer": "ผู้เล่น |||| ผู้เล่น",
+ "maincredit": "ศิลปิน |||| ศิลปิน"
+ },
+ "actions": {
+ "shuffle": "เล่นสุ่ม",
+ "radio": "วิทยุ",
+ "topSongs": "เพลงยอดนิยม"
}
},
"user": {
@@ -106,10 +149,12 @@
"currentPassword": "รหัสผ่านปัจจุบัน",
"newPassword": "รหัสผ่านใหม่",
"token": "โทเคน",
- "lastAccessAt": "เข้าใช้ล่าสุด"
+ "lastAccessAt": "เข้าใช้ล่าสุด",
+ "libraries": "ห้องสมุด"
},
"helperTexts": {
- "name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป"
+ "name": "การเปลี่ยนชื่อจะมีผลในการล็อกอินครั้งถัดไป",
+ "libraries": "เลือกห้องสมุดสำหรับผู้ใช้นี้หรือปล่อยว่างเพื่อใช้ห้องสมุดเริ่มต้น"
},
"notifications": {
"created": "สร้างชื่อผู้ใช้",
@@ -118,7 +163,12 @@
},
"message": {
"listenBrainzToken": "ใส่โทเคน ListenBrainz ของคุณ",
- "clickHereForToken": "กดที่นี่เพื่อรับโทเคนของคุณ"
+ "clickHereForToken": "กดที่นี่เพื่อรับโทเคนของคุณ",
+ "selectAllLibraries": "เลือกห้องสมุดทั้งหมด",
+ "adminAutoLibraries": "ผู้ดูแลเข้าถึงห้องสมุดทั้งหมดโดยอัตโนมัติ"
+ },
+ "validation": {
+ "librariesRequired": "ต้องเลือกห้องสมุด 1 ห้อง สำหรับผู้ใช้ที่ไม่ใช่ผู้ดูแล"
}
},
"player": {
@@ -162,11 +212,17 @@
"addNewPlaylist": "สร้าง \"%{name}\"",
"export": "ส่งออก",
"makePublic": "ทำเป็นสาธารณะ",
- "makePrivate": "ทำเป็นส่วนตัว"
+ "makePrivate": "ทำเป็นส่วนตัว",
+ "saveQueue": "บันทึกคิวลงเพลย์ลิสต์",
+ "searchOrCreate": "ค้นหาเพลย์ลิสต์หรือพิมพ์เพื่อสร้างใหม่",
+ "pressEnterToCreate": "กด Enter เพื่อสร้างเพลย์ลิสต์",
+ "removeFromSelection": "เอาออกจากที่เลือกไว้"
},
"message": {
"duplicate_song": "เพิ่มเพลงซ้ำ",
- "song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม"
+ "song_exist": "เพิ่มเพลงซ้ำกันในเพลย์ลิสต์ คุณจะเพิ่มเพลงต่อหรือข้าม",
+ "noPlaylistsFound": "ไม่พบเพลย์ลิสต์",
+ "noPlaylists": "ไม่มีเพลย์ลิสต์อยู่"
}
},
"radio": {
@@ -198,6 +254,75 @@
"createdAt": "สร้างเมื่อ",
"downloadable": "อนุญาตให้ดาวโหลด?"
}
+ },
+ "missing": {
+ "name": "ไฟล์ที่หายไป |||| ไฟล์ที่หายไป",
+ "fields": {
+ "path": "พาร์ท",
+ "size": "ขนาด",
+ "updatedAt": "หายไปจาก",
+ "libraryName": "ห้องสมุด"
+ },
+ "actions": {
+ "remove": "เอาออก",
+ "remove_all": "เอาออกทั้งหมด"
+ },
+ "notifications": {
+ "removed": "เอาไฟล์ที่หายไปออกแล้ว"
+ },
+ "empty": "ไม่มีไฟล์หาย"
+ },
+ "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": "สแกนห้องสมุด",
+ "manageUsers": "ตั้งค่าการเข้าถึง",
+ "viewDetails": "ดูรายละเอียด"
+ },
+ "notifications": {
+ "created": "สร้างห้องสมุดเรียบร้อย",
+ "updated": "อัพเดทห้องสมุดเรียบร้อย",
+ "deleted": "ลบห้องสมุดเพลงเรียบร้อยแล้ว",
+ "scanStarted": "เริ่มสแกนห้องสมุด",
+ "scanCompleted": "สแกนห้องสมุดเสร็จแล้ว"
+ },
+ "validation": {
+ "nameRequired": "ต้องใส่ชื่อห้องสมุดเพลง",
+ "pathRequired": "ต้องใส่พาร์ทของห้องสมุด",
+ "pathNotDirectory": "พาร์ทของห้องสมุดต้องเป็นแฟ้ม",
+ "pathNotFound": "ไม่เจอพาร์ทของห้องสมุด",
+ "pathNotAccessible": "ไม่สามารถเข้าพาร์ทของห้องสมุด",
+ "pathInvalid": "พาร์ทห้องสมุดไม่ถูก"
+ },
+ "messages": {
+ "deleteConfirm": "คุณแน่ใจว่าจะลบห้องสมุดนี้? นี่จะลบข้อมูลและการเข้าถึงของผู้ใช้ที่เกี่ยวข้องทั้งหมด",
+ "scanInProgress": "กำลังสแกน...",
+ "noLibrariesAssigned": "ไม่มีห้องสมุดสำหรับผู้ใช้นี้"
+ }
}
},
"ra": {
@@ -375,7 +500,13 @@
"shareSuccess": "คัดลอก URL ไปคลิปบอร์ด: %{url}",
"shareFailure": "คัดลอก URL %{url} ไปคลิปบอร์ดผิดพลาด",
"downloadDialogTitle": "ดาวโหลด %{resource} '%{name}' (%{size})",
- "shareCopyToClipboard": "คัดลอกไปคลิปบอร์ด: Ctrl+C, Enter"
+ "shareCopyToClipboard": "คัดลอกไปคลิปบอร์ด: Ctrl+C, Enter",
+ "remove_missing_title": "ลบรายการไฟล์ที่หายไป",
+ "remove_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
+ "remove_all_missing_title": "เอารายการไฟล์ที่หายไปออกทั้งหมด",
+ "remove_all_missing_content": "คุณแน่ใจว่าจะเอารายการไฟล์ที่หายไปออกจากดาต้าเบส นี่จะเป็นการลบข้อมูลอ้างอิงทั้งหมดของไฟล์ออกอย่างถาวร",
+ "noSimilarSongsFound": "ไม่มีเพลงคล้ายกัน",
+ "noTopSongsFound": "ไม่พบเพลงยอดนิยม"
},
"menu": {
"library": "ห้องสมุดเพลง",
@@ -404,7 +535,13 @@
"albumList": "อัลบั้ม",
"about": "เกี่ยวกับ",
"playlists": "เพลย์ลิสต์",
- "sharedPlaylists": "เพลย์ลิสต์ที่แบ่งปัน"
+ "sharedPlaylists": "เพลย์ลิสต์ที่แบ่งปัน",
+ "librarySelector": {
+ "allLibraries": "ห้องสมุด (%{count}) ห้อง",
+ "multipleLibraries": "%{selected} ของ %{total} ห้องสมุด",
+ "selectLibraries": "เลือกห้องสมุด",
+ "none": "ไม่มี"
+ }
},
"player": {
"playListsText": "คิวเล่น",
@@ -441,6 +578,21 @@
"disabled": "ปิดการทำงาน",
"waiting": "รอ"
}
+ },
+ "tabs": {
+ "about": "เกี่ยวกับ",
+ "config": "การตั้งค่า"
+ },
+ "config": {
+ "configName": "ชื่อการตั้งค่า",
+ "environmentVariable": "ค่าทั่วไป",
+ "currentValue": "ค่าปัจจุบัน",
+ "configurationFile": "ไฟล์การตั้งค่า",
+ "exportToml": "นำออกการตั้งค่า (TOML)",
+ "exportSuccess": "นำออกการตั้งค่าไปยังคลิปบอร์ดในรูปแบบ TOML แล้ว",
+ "exportFailed": "คัดลอกการตั้งค่าล้มเหลว",
+ "devFlagsHeader": "ปักธงการพัฒนา (อาจมีการเปลี่ยน/เอาออก)",
+ "devFlagsComment": "การตั้งค่านี้อยู่ในช่วงทดลองและอาจจะมีการเอาออกในเวอร์ชั่นหลัง"
}
},
"activity": {
@@ -449,7 +601,10 @@
"quickScan": "สแกนแบบเร็ว",
"fullScan": "สแกนทั้งหมด",
"serverUptime": "เซิร์ฟเวอร์ออนไลน์นาน",
- "serverDown": "ออฟไลน์"
+ "serverDown": "ออฟไลน์",
+ "scanType": "ประเภท",
+ "status": "สแกนผิดพลาด",
+ "elapsedTime": "เวลาที่ใช้"
},
"help": {
"title": "คีย์ลัด Navidrome",
@@ -464,5 +619,10 @@
"toggle_love": "เพิ่มเพลงนี้ไปยังรายการโปรด",
"current_song": "ไปยังเพลงปัจจุบัน"
}
+ },
+ "nowPlaying": {
+ "title": "กำลังเล่น",
+ "empty": "ไม่มีเพลงเล่น",
+ "minutesAgo": "%{smart_count} นาทีที่แล้ว |||| %{smart_count} นาทีที่แล้ว"
}
}
\ No newline at end of file
From 9bb933c0d67e90c22a58f96f067ca37f70c27bca Mon Sep 17 00:00:00 2001
From: Nagi <84936494+nagiqui@users.noreply.github.com>
Date: Sat, 8 Nov 2025 00:41:23 +0100
Subject: [PATCH 030/102] fix(ui): fix Playlist Italian translation(#4642)
In Italian, we usually use "Playlist" rather than "Scalette/a". "Scalette/a" refers to other functions or objects.
---
resources/i18n/it.json | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/resources/i18n/it.json b/resources/i18n/it.json
index 9d1c2bb74..11fadb46b 100644
--- a/resources/i18n/it.json
+++ b/resources/i18n/it.json
@@ -400,8 +400,8 @@
},
"albumList": "Album",
"about": "Info",
- "playlists": "Scalette",
- "sharedPlaylists": "Scalette Condivise"
+ "playlists": "Playlist",
+ "sharedPlaylists": "Playlist Condivise"
},
"player": {
"playListsText": "Coda",
@@ -457,4 +457,4 @@
"current_song": ""
}
}
-}
\ No newline at end of file
+}
From 69527085db7085d4bb2be96a6033fbec006fa29b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Sat, 8 Nov 2025 12:47:02 -0500
Subject: [PATCH 031/102] fix(ui): resolve transparent dropdown background in
Ligera theme (#4665)
Fixed the multi-library selector dropdown background in the Ligera theme by changing the palette.background.paper value from 'inherit' to bLight['500'] ('#ffffff'). This ensures the dropdown has a solid white background that properly overlays content, making the library selection options clearly readable.
Closes #4502
---
ui/src/themes/ligera.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ui/src/themes/ligera.js b/ui/src/themes/ligera.js
index 0ef1601a2..97dda93ab 100644
--- a/ui/src/themes/ligera.js
+++ b/ui/src/themes/ligera.js
@@ -70,7 +70,7 @@ export default {
},
background: {
default: '#f0f2f5',
- paper: 'inherit',
+ paper: bLight['500'],
},
text: {
secondary: '#232323',
From 5ce6e16d9645090cf97f79a3307867b52f18d5bd Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Sat, 8 Nov 2025 20:11:00 -0500
Subject: [PATCH 032/102] fix: album statistics not updating after deleting
missing files (#4668)
* feat: add album refresh functionality after deleting missing files
Implemented RefreshAlbums method in AlbumRepository to recalculate album attributes (size, duration, song count) from their constituent media files. This method processes albums in batches to maintain efficiency with large datasets.
Added integration in deleteMissingFiles to automatically refresh affected albums in the background after deleting missing media files, ensuring album statistics remain accurate. Includes comprehensive test coverage for various scenarios including single/multiple albums, empty batches, and large batch processing.
Signed-off-by: Deluan
* refactor: extract missing files deletion into reusable service layer
Extracted inline deletion logic from server/nativeapi/missing.go into a new core.MissingFiles service interface and implementation. This provides better separation of concerns and testability.
The MissingFiles service handles:
- Deletion of specific or all missing files via transaction
- Garbage collection after deletion
- Extraction of affected album IDs from missing files
- Background refresh of artist and album statistics
The deleteMissingFiles HTTP handler now simply delegates to the service, removing 70+ lines of inline logic. All deletion, transaction, and stat refresh logic is now centralized in core/missing_files.go.
Updated dependency injection to provide MissingFiles service to the native API router. Renamed receiver variable from 'n' to 'api' throughout native_api.go for consistency.
* refactor: consolidate maintenance operations into unified service
Consolidate MissingFiles and RefreshAlbums functionality into a new Maintenance service. This refactoring:
- Creates core.Maintenance interface combining DeleteMissingFiles, DeleteAllMissingFiles, and RefreshAlbums methods
- Moves RefreshAlbums logic from AlbumRepository persistence layer to core Maintenance service
- Removes MissingFiles interface and moves its implementation to maintenanceService
- Updates all references in wire providers, native API router, and handlers
- Removes RefreshAlbums interface method from AlbumRepository model
- Improves separation of concerns by centralizing maintenance operations in the core domain
This change provides a cleaner API and better organization of maintenance-related database operations.
* refactor: remove MissingFiles interface and update references
Remove obsolete MissingFiles interface and its references:
- Delete core/missing_files.go and core/missing_files_test.go
- Remove RefreshAlbums method from AlbumRepository interface and implementation
- Remove RefreshAlbums tests from AlbumRepository test suite
- Update wire providers to use NewMaintenance instead of NewMissingFiles
- Update native API router to use Maintenance service
- Update missing.go handler to use Maintenance interface
All functionality is now consolidated in the core.Maintenance service.
Signed-off-by: Deluan
* refactor: rename RefreshAlbums to refreshAlbums and update related calls
Signed-off-by: Deluan
* refactor: optimize album refresh logic and improve test coverage
Signed-off-by: Deluan
* refactor: simplify logging setup in tests with reusable LogHook function
Signed-off-by: Deluan
* refactor: add synchronization to logger and maintenance service for thread safety
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
---
cmd/wire_gen.go | 3 +-
core/maintenance.go | 226 ++++++++++++++
core/maintenance_test.go | 382 +++++++++++++++++++++++
core/wire_providers.go | 1 +
log/log.go | 14 +
server/nativeapi/config_test.go | 2 +-
server/nativeapi/library.go | 6 +-
server/nativeapi/library_test.go | 2 +-
server/nativeapi/missing.go | 59 ++--
server/nativeapi/native_api.go | 127 ++++----
server/nativeapi/native_api_song_test.go | 2 +-
tests/test_helpers.go | 23 ++
12 files changed, 740 insertions(+), 107 deletions(-)
create mode 100644 core/maintenance.go
create mode 100644 core/maintenance_test.go
diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go
index 187ab488d..bf13dc731 100644
--- a/cmd/wire_gen.go
+++ b/cmd/wire_gen.go
@@ -72,7 +72,8 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
watcher := scanner.GetWatcher(dataStore, scannerScanner)
library := core.NewLibrary(dataStore, scannerScanner, watcher, broker)
- router := nativeapi.New(dataStore, share, playlists, insights, library)
+ maintenance := core.NewMaintenance(dataStore)
+ router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance)
return router
}
diff --git a/core/maintenance.go b/core/maintenance.go
new file mode 100644
index 000000000..c2f65d74f
--- /dev/null
+++ b/core/maintenance.go
@@ -0,0 +1,226 @@
+package core
+
+import (
+ "context"
+ "fmt"
+ "slices"
+ "sync"
+ "time"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/utils/slice"
+)
+
+type Maintenance interface {
+ // DeleteMissingFiles deletes specific missing files by their IDs
+ DeleteMissingFiles(ctx context.Context, ids []string) error
+ // DeleteAllMissingFiles deletes all files marked as missing
+ DeleteAllMissingFiles(ctx context.Context) error
+}
+
+type maintenanceService struct {
+ ds model.DataStore
+ wg sync.WaitGroup
+}
+
+func NewMaintenance(ds model.DataStore) Maintenance {
+ return &maintenanceService{
+ ds: ds,
+ }
+}
+
+func (s *maintenanceService) DeleteMissingFiles(ctx context.Context, ids []string) error {
+ return s.deleteMissing(ctx, ids)
+}
+
+func (s *maintenanceService) DeleteAllMissingFiles(ctx context.Context) error {
+ return s.deleteMissing(ctx, nil)
+}
+
+// deleteMissing handles the deletion of missing files and triggers necessary cleanup operations
+func (s *maintenanceService) deleteMissing(ctx context.Context, ids []string) error {
+ // Track affected album IDs before deletion for refresh
+ affectedAlbumIDs, err := s.getAffectedAlbumIDs(ctx, ids)
+ if err != nil {
+ log.Warn(ctx, "Error tracking affected albums for refresh", err)
+ // Don't fail the operation, just log the warning
+ }
+
+ // Delete missing files within a transaction
+ err = s.ds.WithTx(func(tx model.DataStore) error {
+ if len(ids) == 0 {
+ _, err := tx.MediaFile(ctx).DeleteAllMissing()
+ return err
+ }
+ return tx.MediaFile(ctx).DeleteMissing(ids)
+ })
+ if err != nil {
+ log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
+ return err
+ }
+
+ // Run garbage collection to clean up orphaned records
+ if err := s.ds.GC(ctx); err != nil {
+ log.Error(ctx, "Error running GC after deleting missing tracks", err)
+ return err
+ }
+
+ // Refresh statistics in background
+ s.refreshStatsAsync(ctx, affectedAlbumIDs)
+
+ return nil
+}
+
+// refreshAlbums recalculates album attributes (size, duration, song count, etc.) from media files.
+// It uses batch queries to minimize database round-trips for efficiency.
+func (s *maintenanceService) refreshAlbums(ctx context.Context, albumIDs []string) error {
+ if len(albumIDs) == 0 {
+ return nil
+ }
+
+ log.Debug(ctx, "Refreshing albums", "count", len(albumIDs))
+
+ // Process in chunks to avoid query size limits
+ const chunkSize = 100
+ for chunk := range slice.CollectChunks(slices.Values(albumIDs), chunkSize) {
+ if err := s.refreshAlbumChunk(ctx, chunk); err != nil {
+ return fmt.Errorf("refreshing album chunk: %w", err)
+ }
+ }
+
+ log.Debug(ctx, "Successfully refreshed albums", "count", len(albumIDs))
+ return nil
+}
+
+// refreshAlbumChunk processes a single chunk of album IDs
+func (s *maintenanceService) refreshAlbumChunk(ctx context.Context, albumIDs []string) error {
+ albumRepo := s.ds.Album(ctx)
+ mfRepo := s.ds.MediaFile(ctx)
+
+ // Batch load existing albums
+ albums, err := albumRepo.GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"album.id": albumIDs},
+ })
+ if err != nil {
+ return fmt.Errorf("loading albums: %w", err)
+ }
+
+ // Create a map for quick lookup
+ albumMap := make(map[string]*model.Album, len(albums))
+ for i := range albums {
+ albumMap[albums[i].ID] = &albums[i]
+ }
+
+ // Batch load all media files for these albums
+ mediaFiles, err := mfRepo.GetAll(model.QueryOptions{
+ Filters: squirrel.Eq{"album_id": albumIDs},
+ Sort: "album_id, path",
+ })
+ if err != nil {
+ return fmt.Errorf("loading media files: %w", err)
+ }
+
+ // Group media files by album ID
+ filesByAlbum := make(map[string]model.MediaFiles)
+ for i := range mediaFiles {
+ albumID := mediaFiles[i].AlbumID
+ filesByAlbum[albumID] = append(filesByAlbum[albumID], mediaFiles[i])
+ }
+
+ // Recalculate each album from its media files
+ for albumID, oldAlbum := range albumMap {
+ mfs, hasTracks := filesByAlbum[albumID]
+ if !hasTracks {
+ // Album has no tracks anymore, skip (will be cleaned up by GC)
+ log.Debug(ctx, "Skipping album with no tracks", "albumID", albumID)
+ continue
+ }
+
+ // Recalculate album from media files
+ newAlbum := mfs.ToAlbum()
+
+ // Only update if something changed (avoid unnecessary writes)
+ if !oldAlbum.Equals(newAlbum) {
+ // Preserve original timestamps
+ newAlbum.UpdatedAt = time.Now()
+ newAlbum.CreatedAt = oldAlbum.CreatedAt
+
+ if err := albumRepo.Put(&newAlbum); err != nil {
+ log.Error(ctx, "Error updating album during refresh", "albumID", albumID, err)
+ // Continue with other albums instead of failing entirely
+ continue
+ }
+ log.Trace(ctx, "Refreshed album", "albumID", albumID, "name", newAlbum.Name)
+ }
+ }
+
+ return nil
+}
+
+// getAffectedAlbumIDs returns distinct album IDs from missing media files
+func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []string) ([]string, error) {
+ var filters squirrel.Sqlizer = squirrel.Eq{"missing": true}
+ if len(ids) > 0 {
+ filters = squirrel.And{
+ squirrel.Eq{"missing": true},
+ squirrel.Eq{"id": ids},
+ }
+ }
+
+ mfs, err := s.ds.MediaFile(ctx).GetAll(model.QueryOptions{
+ Filters: filters,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ // Extract unique album IDs
+ albumIDMap := make(map[string]struct{}, len(mfs))
+ for _, mf := range mfs {
+ if mf.AlbumID != "" {
+ albumIDMap[mf.AlbumID] = struct{}{}
+ }
+ }
+
+ albumIDs := make([]string, 0, len(albumIDMap))
+ for id := range albumIDMap {
+ albumIDs = append(albumIDs, id)
+ }
+
+ return albumIDs, nil
+}
+
+// refreshStatsAsync refreshes artist and album statistics in background goroutines
+func (s *maintenanceService) refreshStatsAsync(ctx context.Context, affectedAlbumIDs []string) {
+ // Refresh artist stats in background
+ s.wg.Add(1)
+ go func() {
+ defer s.wg.Done()
+ bgCtx := request.AddValues(context.Background(), ctx)
+ if _, err := s.ds.Artist(bgCtx).RefreshStats(true); err != nil {
+ log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
+ } else {
+ log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
+ }
+
+ // Refresh album stats in background if we have affected albums
+ if len(affectedAlbumIDs) > 0 {
+ if err := s.refreshAlbums(bgCtx, affectedAlbumIDs); err != nil {
+ log.Error(bgCtx, "Error refreshing album stats after deleting missing files", err)
+ } else {
+ log.Debug(bgCtx, "Successfully refreshed album stats after deleting missing files", "count", len(affectedAlbumIDs))
+ }
+ }
+ }()
+}
+
+// Wait waits for all background goroutines to complete.
+// WARNING: This method is ONLY for testing. Never call this in production code.
+// Calling Wait() in production will block until ALL background operations complete
+// and may cause race conditions with new operations starting.
+func (s *maintenanceService) wait() {
+ s.wg.Wait()
+}
diff --git a/core/maintenance_test.go b/core/maintenance_test.go
new file mode 100644
index 000000000..8e8796ffa
--- /dev/null
+++ b/core/maintenance_test.go
@@ -0,0 +1,382 @@
+package core
+
+import (
+ "context"
+ "errors"
+ "sync"
+
+ "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"
+ "github.com/sirupsen/logrus"
+)
+
+var _ = Describe("Maintenance", func() {
+ var ds *extendedDataStore
+ var mfRepo *extendedMediaFileRepo
+ var service Maintenance
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ ctx = request.WithUser(ctx, model.User{ID: "user1", IsAdmin: true})
+
+ ds = createTestDataStore()
+ mfRepo = ds.MockedMediaFile.(*extendedMediaFileRepo)
+ service = NewMaintenance(ds)
+ })
+
+ Describe("DeleteMissingFiles", func() {
+ Context("with specific IDs", func() {
+ It("deletes specific missing files and runs GC", func() {
+ // Setup: mock missing files with album IDs
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ {ID: "mf2", AlbumID: "album2", Missing: true},
+ })
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"})
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mfRepo.deleteMissingCalled).To(BeTrue())
+ Expect(mfRepo.deletedIDs).To(Equal([]string{"mf1", "mf2"}))
+ Expect(ds.gcCalled).To(BeTrue(), "GC should be called after deletion")
+ })
+
+ It("triggers artist stats refresh and album refresh after deletion", func() {
+ artistRepo := ds.MockedArtist.(*extendedArtistRepo)
+ // Setup: mock missing files with albums
+ albumRepo := ds.MockedAlbum.(*extendedAlbumRepo)
+ albumRepo.SetData(model.Albums{
+ {ID: "album1", Name: "Test Album", SongCount: 5},
+ })
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ {ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
+ {ID: "mf3", AlbumID: "album1", Missing: false, Size: 2000, Duration: 200},
+ })
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ Expect(err).ToNot(HaveOccurred())
+
+ // Wait for background goroutines to complete
+ service.(*maintenanceService).wait()
+
+ // RefreshStats should be called
+ Expect(artistRepo.IsRefreshStatsCalled()).To(BeTrue(), "Artist stats should be refreshed")
+
+ // Album should be updated with new calculated values
+ Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Album.Put() should be called to refresh album data")
+ })
+
+ It("returns error if deletion fails", func() {
+ mfRepo.deleteMissingError = errors.New("delete failed")
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("delete failed"))
+ })
+
+ It("continues even if album tracking fails", func() {
+ mfRepo.SetError(true)
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ // Should not fail, just log warning
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mfRepo.deleteMissingCalled).To(BeTrue())
+ })
+
+ It("returns error if GC fails", func() {
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ })
+
+ // Set GC to return error
+ ds.gcError = errors.New("gc failed")
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("gc failed"))
+ })
+ })
+
+ Context("album ID extraction", func() {
+ It("extracts unique album IDs from missing files", func() {
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ {ID: "mf2", AlbumID: "album1", Missing: true},
+ {ID: "mf3", AlbumID: "album2", Missing: true},
+ })
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2", "mf3"})
+
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("skips files without album IDs", func() {
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "", Missing: true},
+ {ID: "mf2", AlbumID: "album1", Missing: true},
+ })
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf2"})
+
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+ })
+
+ Describe("DeleteAllMissingFiles", func() {
+ It("deletes all missing files and runs GC", func() {
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ {ID: "mf2", AlbumID: "album2", Missing: true},
+ {ID: "mf3", AlbumID: "album3", Missing: true},
+ })
+
+ err := service.DeleteAllMissingFiles(ctx)
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ds.gcCalled).To(BeTrue(), "GC should be called after deletion")
+ })
+
+ It("returns error if deletion fails", func() {
+ mfRepo.SetError(true)
+
+ err := service.DeleteAllMissingFiles(ctx)
+
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("handles empty result gracefully", func() {
+ mfRepo.SetData(model.MediaFiles{})
+
+ err := service.DeleteAllMissingFiles(ctx)
+
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+
+ Describe("Album refresh logic", func() {
+ var albumRepo *extendedAlbumRepo
+
+ BeforeEach(func() {
+ albumRepo = ds.MockedAlbum.(*extendedAlbumRepo)
+ })
+
+ Context("when album has no tracks after deletion", func() {
+ It("skips the album without updating it", func() {
+ // Setup album with no remaining tracks
+ albumRepo.SetData(model.Albums{
+ {ID: "album1", Name: "Empty Album", SongCount: 1},
+ })
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ })
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ Expect(err).ToNot(HaveOccurred())
+
+ // Wait for background goroutines to complete
+ service.(*maintenanceService).wait()
+
+ // Album should NOT be updated because it has no tracks left
+ Expect(albumRepo.GetPutCallCount()).To(Equal(0), "Album with no tracks should not be updated")
+ })
+ })
+
+ Context("when Put fails for one album", func() {
+ It("continues processing other albums", func() {
+ albumRepo.SetData(model.Albums{
+ {ID: "album1", Name: "Album 1"},
+ {ID: "album2", Name: "Album 2"},
+ })
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ {ID: "mf2", AlbumID: "album1", Missing: false, Size: 1000, Duration: 180},
+ {ID: "mf3", AlbumID: "album2", Missing: true},
+ {ID: "mf4", AlbumID: "album2", Missing: false, Size: 2000, Duration: 200},
+ })
+
+ // Make Put fail on first call but succeed on subsequent calls
+ albumRepo.putError = errors.New("put failed")
+ albumRepo.failOnce = true
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1", "mf3"})
+
+ // Should not fail even if one album's Put fails
+ Expect(err).ToNot(HaveOccurred())
+
+ // Wait for background goroutines to complete
+ service.(*maintenanceService).wait()
+
+ // Put should have been called multiple times
+ Expect(albumRepo.GetPutCallCount()).To(BeNumerically(">", 0), "Put should be attempted")
+ })
+ })
+
+ Context("when media file loading fails", func() {
+ It("logs warning but continues when tracking affected albums fails", func() {
+ // Set up log capturing
+ hook, cleanup := tests.LogHook()
+ defer cleanup()
+
+ albumRepo.SetData(model.Albums{
+ {ID: "album1", Name: "Album 1"},
+ })
+ mfRepo.SetData(model.MediaFiles{
+ {ID: "mf1", AlbumID: "album1", Missing: true},
+ })
+ // Make GetAll fail when loading media files
+ mfRepo.SetError(true)
+
+ err := service.DeleteMissingFiles(ctx, []string{"mf1"})
+
+ // Deletion should succeed despite the tracking error
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mfRepo.deleteMissingCalled).To(BeTrue())
+
+ // Verify the warning was logged
+ Expect(hook.LastEntry()).ToNot(BeNil())
+ Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
+ Expect(hook.LastEntry().Message).To(Equal("Error tracking affected albums for refresh"))
+ })
+ })
+ })
+})
+
+// Test helper to create a mock DataStore with controllable behavior
+func createTestDataStore() *extendedDataStore {
+ // Create extended datastore with GC tracking
+ ds := &extendedDataStore{
+ MockDataStore: &tests.MockDataStore{},
+ }
+
+ // Create extended album repo with Put tracking
+ albumRepo := &extendedAlbumRepo{
+ MockAlbumRepo: tests.CreateMockAlbumRepo(),
+ }
+ ds.MockedAlbum = albumRepo
+
+ // Create extended artist repo with RefreshStats tracking
+ artistRepo := &extendedArtistRepo{
+ MockArtistRepo: tests.CreateMockArtistRepo(),
+ }
+ ds.MockedArtist = artistRepo
+
+ // Create extended media file repo with DeleteMissing support
+ mfRepo := &extendedMediaFileRepo{
+ MockMediaFileRepo: tests.CreateMockMediaFileRepo(),
+ }
+ ds.MockedMediaFile = mfRepo
+
+ return ds
+}
+
+// Extension of MockMediaFileRepo to add DeleteMissing method
+type extendedMediaFileRepo struct {
+ *tests.MockMediaFileRepo
+ deleteMissingCalled bool
+ deletedIDs []string
+ deleteMissingError error
+}
+
+func (m *extendedMediaFileRepo) DeleteMissing(ids []string) error {
+ m.deleteMissingCalled = true
+ m.deletedIDs = ids
+ if m.deleteMissingError != nil {
+ return m.deleteMissingError
+ }
+ // Actually delete from the mock data
+ for _, id := range ids {
+ delete(m.Data, id)
+ }
+ return nil
+}
+
+// Extension of MockAlbumRepo to track Put calls
+type extendedAlbumRepo struct {
+ *tests.MockAlbumRepo
+ mu sync.RWMutex
+ putCallCount int
+ lastPutData *model.Album
+ putError error
+ failOnce bool
+}
+
+func (m *extendedAlbumRepo) Put(album *model.Album) error {
+ m.mu.Lock()
+ m.putCallCount++
+ m.lastPutData = album
+
+ // Handle failOnce behavior
+ var err error
+ if m.putError != nil {
+ if m.failOnce {
+ err = m.putError
+ m.putError = nil // Clear error after first failure
+ m.mu.Unlock()
+ return err
+ }
+ err = m.putError
+ m.mu.Unlock()
+ return err
+ }
+ m.mu.Unlock()
+
+ return m.MockAlbumRepo.Put(album)
+}
+
+func (m *extendedAlbumRepo) GetPutCallCount() int {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.putCallCount
+}
+
+// Extension of MockArtistRepo to track RefreshStats calls
+type extendedArtistRepo struct {
+ *tests.MockArtistRepo
+ mu sync.RWMutex
+ refreshStatsCalled bool
+ refreshStatsError error
+}
+
+func (m *extendedArtistRepo) RefreshStats(allArtists bool) (int64, error) {
+ m.mu.Lock()
+ m.refreshStatsCalled = true
+ err := m.refreshStatsError
+ m.mu.Unlock()
+
+ if err != nil {
+ return 0, err
+ }
+ return m.MockArtistRepo.RefreshStats(allArtists)
+}
+
+func (m *extendedArtistRepo) IsRefreshStatsCalled() bool {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ return m.refreshStatsCalled
+}
+
+// Extension of MockDataStore to track GC calls
+type extendedDataStore struct {
+ *tests.MockDataStore
+ gcCalled bool
+ gcError error
+}
+
+func (ds *extendedDataStore) GC(ctx context.Context) error {
+ ds.gcCalled = true
+ if ds.gcError != nil {
+ return ds.gcError
+ }
+ return ds.MockDataStore.GC(ctx)
+}
diff --git a/core/wire_providers.go b/core/wire_providers.go
index ae365156a..16335645c 100644
--- a/core/wire_providers.go
+++ b/core/wire_providers.go
@@ -18,6 +18,7 @@ var Set = wire.NewSet(
NewShare,
NewPlaylists,
NewLibrary,
+ NewMaintenance,
agents.GetAgents,
external.NewProvider,
wire.Bind(new(external.Agents), new(*agents.Agents)),
diff --git a/log/log.go b/log/log.go
index 20119ab46..ea34e5dcb 100644
--- a/log/log.go
+++ b/log/log.go
@@ -11,6 +11,7 @@ import (
"runtime"
"sort"
"strings"
+ "sync"
"time"
"github.com/sirupsen/logrus"
@@ -70,6 +71,7 @@ type levelPath struct {
var (
currentLevel Level
+ loggerMu sync.RWMutex
defaultLogger = logrus.New()
logSourceLine = false
rootPath string
@@ -79,7 +81,9 @@ var (
// SetLevel sets the global log level used by the simple logger.
func SetLevel(l Level) {
currentLevel = l
+ loggerMu.Lock()
defaultLogger.Level = logrus.TraceLevel
+ loggerMu.Unlock()
logrus.SetLevel(logrus.Level(l))
}
@@ -125,6 +129,8 @@ func SetLogSourceLine(enabled bool) {
func SetRedacting(enabled bool) {
if enabled {
+ loggerMu.Lock()
+ defer loggerMu.Unlock()
defaultLogger.AddHook(redacted)
}
}
@@ -133,6 +139,8 @@ func SetOutput(w io.Writer) {
if runtime.GOOS == "windows" {
w = CRLFWriter(w)
}
+ loggerMu.Lock()
+ defer loggerMu.Unlock()
defaultLogger.SetOutput(w)
}
@@ -158,6 +166,8 @@ func NewContext(ctx context.Context, keyValuePairs ...interface{}) context.Conte
}
func SetDefaultLogger(l *logrus.Logger) {
+ loggerMu.Lock()
+ defer loggerMu.Unlock()
defaultLogger = l
}
@@ -204,6 +214,8 @@ func log(level Level, args ...interface{}) {
}
func Writer() io.Writer {
+ loggerMu.RLock()
+ defer loggerMu.RUnlock()
return defaultLogger.Writer()
}
@@ -314,6 +326,8 @@ func extractLogger(ctx interface{}) (*logrus.Entry, error) {
func createNewLogger() *logrus.Entry {
//logrus.SetFormatter(&logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true})
//l.Formatter = &logrus.TextFormatter{ForceColors: true, DisableTimestamp: false, FullTimestamp: true}
+ loggerMu.RLock()
+ defer loggerMu.RUnlock()
logger := logrus.NewEntry(defaultLogger)
return logger
}
diff --git a/server/nativeapi/config_test.go b/server/nativeapi/config_test.go
index 60f7c3394..d9c722955 100644
--- a/server/nativeapi/config_test.go
+++ b/server/nativeapi/config_test.go
@@ -29,7 +29,7 @@ var _ = Describe("Config API", func() {
conf.Server.DevUIShowConfig = true // Enable config endpoint for tests
ds = &tests.MockDataStore{}
auth.Init(ds)
- nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
+ nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
router = server.JWTVerifier(nativeRouter)
// Create test users
diff --git a/server/nativeapi/library.go b/server/nativeapi/library.go
index f081eca78..1636e1dbb 100644
--- a/server/nativeapi/library.go
+++ b/server/nativeapi/library.go
@@ -13,11 +13,11 @@ import (
)
// User-library association endpoints (admin only)
-func (n *Router) addUserLibraryRoute(r chi.Router) {
+func (api *Router) addUserLibraryRoute(r chi.Router) {
r.Route("/user/{id}/library", func(r chi.Router) {
r.Use(parseUserIDMiddleware)
- r.Get("/", getUserLibraries(n.libs))
- r.Put("/", setUserLibraries(n.libs))
+ r.Get("/", getUserLibraries(api.libs))
+ r.Put("/", setUserLibraries(api.libs))
})
}
diff --git a/server/nativeapi/library_test.go b/server/nativeapi/library_test.go
index 4e6d34582..950338492 100644
--- a/server/nativeapi/library_test.go
+++ b/server/nativeapi/library_test.go
@@ -30,7 +30,7 @@ var _ = Describe("Library API", func() {
DeferCleanup(configtest.SetupConfig())
ds = &tests.MockDataStore{}
auth.Init(ds)
- nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
+ nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
router = server.JWTVerifier(nativeRouter)
// Create test users
diff --git a/server/nativeapi/missing.go b/server/nativeapi/missing.go
index 0d311f492..2b455e622 100644
--- a/server/nativeapi/missing.go
+++ b/server/nativeapi/missing.go
@@ -8,9 +8,9 @@ import (
"github.com/Masterminds/squirrel"
"github.com/deluan/rest"
+ "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/utils/req"
)
@@ -63,45 +63,32 @@ func (r *missingRepository) EntityName() string {
return "missing_files"
}
-func deleteMissingFiles(ds model.DataStore, w http.ResponseWriter, r *http.Request) {
- ctx := r.Context()
- p := req.Params(r)
- ids, _ := p.Strings("id")
- err := ds.WithTx(func(tx model.DataStore) error {
+func deleteMissingFiles(maintenance core.Maintenance) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+
+ p := req.Params(r)
+ ids, _ := p.Strings("id")
+
+ var err error
if len(ids) == 0 {
- _, err := tx.MediaFile(ctx).DeleteAllMissing()
- return err
- }
- return tx.MediaFile(ctx).DeleteMissing(ids)
- })
- if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
- log.Warn(ctx, "Missing file not found", "id", ids[0])
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- if err != nil {
- log.Error(ctx, "Error deleting missing tracks from DB", "ids", ids, err)
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- err = ds.GC(ctx)
- if err != nil {
- log.Error(ctx, "Error running GC after deleting missing tracks", err)
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
-
- // Refresh artist stats in background after deleting missing files
- go func() {
- bgCtx := request.AddValues(context.Background(), r.Context())
- if _, err := ds.Artist(bgCtx).RefreshStats(true); err != nil {
- log.Error(bgCtx, "Error refreshing artist stats after deleting missing files", err)
+ err = maintenance.DeleteAllMissingFiles(ctx)
} else {
- log.Debug(bgCtx, "Successfully refreshed artist stats after deleting missing files")
+ err = maintenance.DeleteMissingFiles(ctx, ids)
}
- }()
- writeDeleteManyResponse(w, r, ids)
+ if len(ids) == 1 && errors.Is(err, model.ErrNotFound) {
+ log.Warn(ctx, "Missing file not found", "id", ids[0])
+ http.Error(w, "not found", http.StatusNotFound)
+ return
+ }
+ if err != nil {
+ http.Error(w, "failed to delete missing files", http.StatusInternalServerError)
+ return
+ }
+
+ writeDeleteManyResponse(w, r, ids)
+ }
}
var _ model.ResourceRepository = &missingRepository{}
diff --git a/server/nativeapi/native_api.go b/server/nativeapi/native_api.go
index 370bdbd1e..969650e0a 100644
--- a/server/nativeapi/native_api.go
+++ b/server/nativeapi/native_api.go
@@ -22,70 +22,71 @@ import (
type Router struct {
http.Handler
- ds model.DataStore
- share core.Share
- playlists core.Playlists
- insights metrics.Insights
- libs core.Library
+ ds model.DataStore
+ share core.Share
+ playlists core.Playlists
+ insights metrics.Insights
+ libs core.Library
+ maintenance core.Maintenance
}
-func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library) *Router {
- r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService}
+func New(ds model.DataStore, share core.Share, playlists core.Playlists, insights metrics.Insights, libraryService core.Library, maintenance core.Maintenance) *Router {
+ r := &Router{ds: ds, share: share, playlists: playlists, insights: insights, libs: libraryService, maintenance: maintenance}
r.Handler = r.routes()
return r
}
-func (n *Router) routes() http.Handler {
+func (api *Router) routes() http.Handler {
r := chi.NewRouter()
// Public
- n.RX(r, "/translation", newTranslationRepository, false)
+ api.RX(r, "/translation", newTranslationRepository, false)
// Protected
r.Group(func(r chi.Router) {
- r.Use(server.Authenticator(n.ds))
+ r.Use(server.Authenticator(api.ds))
r.Use(server.JWTRefresher)
- r.Use(server.UpdateLastAccessMiddleware(n.ds))
- n.R(r, "/user", model.User{}, true)
- n.R(r, "/song", model.MediaFile{}, false)
- n.R(r, "/album", model.Album{}, false)
- n.R(r, "/artist", model.Artist{}, false)
- n.R(r, "/genre", model.Genre{}, false)
- n.R(r, "/player", model.Player{}, true)
- n.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
- n.R(r, "/radio", model.Radio{}, true)
- n.R(r, "/tag", model.Tag{}, true)
+ r.Use(server.UpdateLastAccessMiddleware(api.ds))
+ api.R(r, "/user", model.User{}, true)
+ api.R(r, "/song", model.MediaFile{}, false)
+ api.R(r, "/album", model.Album{}, false)
+ api.R(r, "/artist", model.Artist{}, false)
+ api.R(r, "/genre", model.Genre{}, false)
+ api.R(r, "/player", model.Player{}, true)
+ api.R(r, "/transcoding", model.Transcoding{}, conf.Server.EnableTranscodingConfig)
+ api.R(r, "/radio", model.Radio{}, true)
+ api.R(r, "/tag", model.Tag{}, true)
if conf.Server.EnableSharing {
- n.RX(r, "/share", n.share.NewRepository, true)
+ api.RX(r, "/share", api.share.NewRepository, true)
}
- n.addPlaylistRoute(r)
- n.addPlaylistTrackRoute(r)
- n.addSongPlaylistsRoute(r)
- n.addQueueRoute(r)
- n.addMissingFilesRoute(r)
- n.addKeepAliveRoute(r)
- n.addInsightsRoute(r)
+ api.addPlaylistRoute(r)
+ api.addPlaylistTrackRoute(r)
+ api.addSongPlaylistsRoute(r)
+ api.addQueueRoute(r)
+ api.addMissingFilesRoute(r)
+ api.addKeepAliveRoute(r)
+ api.addInsightsRoute(r)
r.With(adminOnlyMiddleware).Group(func(r chi.Router) {
- n.addInspectRoute(r)
- n.addConfigRoute(r)
- n.addUserLibraryRoute(r)
- n.RX(r, "/library", n.libs.NewRepository, true)
+ api.addInspectRoute(r)
+ api.addConfigRoute(r)
+ api.addUserLibraryRoute(r)
+ api.RX(r, "/library", api.libs.NewRepository, true)
})
})
return r
}
-func (n *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
+func (api *Router) R(r chi.Router, pathPrefix string, model interface{}, persistable bool) {
constructor := func(ctx context.Context) rest.Repository {
- return n.ds.Resource(ctx, model)
+ return api.ds.Resource(ctx, model)
}
- n.RX(r, pathPrefix, constructor, persistable)
+ api.RX(r, pathPrefix, constructor, persistable)
}
-func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
+func (api *Router) RX(r chi.Router, pathPrefix string, constructor rest.RepositoryConstructor, persistable bool) {
r.Route(pathPrefix, func(r chi.Router) {
r.Get("/", rest.GetAll(constructor))
if persistable {
@@ -102,9 +103,9 @@ func (n *Router) RX(r chi.Router, pathPrefix string, constructor rest.Repository
})
}
-func (n *Router) addPlaylistRoute(r chi.Router) {
+func (api *Router) addPlaylistRoute(r chi.Router) {
constructor := func(ctx context.Context) rest.Repository {
- return n.ds.Resource(ctx, model.Playlist{})
+ return api.ds.Resource(ctx, model.Playlist{})
}
r.Route("/playlist", func(r chi.Router) {
@@ -114,7 +115,7 @@ func (n *Router) addPlaylistRoute(r chi.Router) {
rest.Post(constructor)(w, r)
return
}
- createPlaylistFromM3U(n.playlists)(w, r)
+ createPlaylistFromM3U(api.playlists)(w, r)
})
r.Route("/{id}", func(r chi.Router) {
@@ -126,55 +127,53 @@ func (n *Router) addPlaylistRoute(r chi.Router) {
})
}
-func (n *Router) addPlaylistTrackRoute(r chi.Router) {
+func (api *Router) addPlaylistTrackRoute(r chi.Router) {
r.Route("/playlist/{playlistId}/tracks", func(r chi.Router) {
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
- getPlaylist(n.ds)(w, r)
+ getPlaylist(api.ds)(w, r)
})
r.With(server.URLParamsMiddleware).Route("/", func(r chi.Router) {
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
- deleteFromPlaylist(n.ds)(w, r)
+ deleteFromPlaylist(api.ds)(w, r)
})
r.Post("/", func(w http.ResponseWriter, r *http.Request) {
- addToPlaylist(n.ds)(w, r)
+ addToPlaylist(api.ds)(w, r)
})
})
r.Route("/{id}", func(r chi.Router) {
r.Use(server.URLParamsMiddleware)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
- getPlaylistTrack(n.ds)(w, r)
+ getPlaylistTrack(api.ds)(w, r)
})
r.Put("/", func(w http.ResponseWriter, r *http.Request) {
- reorderItem(n.ds)(w, r)
+ reorderItem(api.ds)(w, r)
})
r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
- deleteFromPlaylist(n.ds)(w, r)
+ deleteFromPlaylist(api.ds)(w, r)
})
})
})
}
-func (n *Router) addSongPlaylistsRoute(r chi.Router) {
+func (api *Router) addSongPlaylistsRoute(r chi.Router) {
r.With(server.URLParamsMiddleware).Get("/song/{id}/playlists", func(w http.ResponseWriter, r *http.Request) {
- getSongPlaylists(n.ds)(w, r)
+ getSongPlaylists(api.ds)(w, r)
})
}
-func (n *Router) addQueueRoute(r chi.Router) {
+func (api *Router) addQueueRoute(r chi.Router) {
r.Route("/queue", func(r chi.Router) {
- r.Get("/", getQueue(n.ds))
- r.Post("/", saveQueue(n.ds))
- r.Put("/", updateQueue(n.ds))
- r.Delete("/", clearQueue(n.ds))
+ r.Get("/", getQueue(api.ds))
+ r.Post("/", saveQueue(api.ds))
+ r.Put("/", updateQueue(api.ds))
+ r.Delete("/", clearQueue(api.ds))
})
}
-func (n *Router) addMissingFilesRoute(r chi.Router) {
+func (api *Router) addMissingFilesRoute(r chi.Router) {
r.Route("/missing", func(r chi.Router) {
- n.RX(r, "/", newMissingRepository(n.ds), false)
- r.Delete("/", func(w http.ResponseWriter, r *http.Request) {
- deleteMissingFiles(n.ds, w, r)
- })
+ api.RX(r, "/", newMissingRepository(api.ds), false)
+ r.Delete("/", deleteMissingFiles(api.maintenance))
})
}
@@ -198,7 +197,7 @@ func writeDeleteManyResponse(w http.ResponseWriter, r *http.Request, ids []strin
}
}
-func (n *Router) addInspectRoute(r chi.Router) {
+func (api *Router) addInspectRoute(r chi.Router) {
if conf.Server.Inspect.Enabled {
r.Group(func(r chi.Router) {
if conf.Server.Inspect.MaxRequests > 0 {
@@ -207,26 +206,26 @@ func (n *Router) addInspectRoute(r chi.Router) {
conf.Server.Inspect.BacklogTimeout)
r.Use(middleware.ThrottleBacklog(conf.Server.Inspect.MaxRequests, conf.Server.Inspect.BacklogLimit, time.Duration(conf.Server.Inspect.BacklogTimeout)))
}
- r.Get("/inspect", inspect(n.ds))
+ r.Get("/inspect", inspect(api.ds))
})
}
}
-func (n *Router) addConfigRoute(r chi.Router) {
+func (api *Router) addConfigRoute(r chi.Router) {
if conf.Server.DevUIShowConfig {
r.Get("/config/*", getConfig)
}
}
-func (n *Router) addKeepAliveRoute(r chi.Router) {
+func (api *Router) addKeepAliveRoute(r chi.Router) {
r.Get("/keepalive/*", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"response":"ok", "id":"keepalive"}`))
})
}
-func (n *Router) addInsightsRoute(r chi.Router) {
+func (api *Router) addInsightsRoute(r chi.Router) {
r.Get("/insights/*", func(w http.ResponseWriter, r *http.Request) {
- last, success := n.insights.LastRun(r.Context())
+ last, success := api.insights.LastRun(r.Context())
if conf.Server.EnableInsightsCollector {
_, _ = w.Write([]byte(`{"id":"insights_status", "lastRun":"` + last.Format("2006-01-02 15:04:05") + `", "success":` + strconv.FormatBool(success) + `}`))
} else {
diff --git a/server/nativeapi/native_api_song_test.go b/server/nativeapi/native_api_song_test.go
index d7209a164..b52042643 100644
--- a/server/nativeapi/native_api_song_test.go
+++ b/server/nativeapi/native_api_song_test.go
@@ -95,7 +95,7 @@ var _ = Describe("Song Endpoints", func() {
mfRepo.SetData(testSongs)
// Create the native API router and wrap it with the JWTVerifier middleware
- nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService())
+ nativeRouter := New(ds, nil, nil, nil, core.NewMockLibraryService(), nil)
router = server.JWTVerifier(nativeRouter)
w = httptest.NewRecorder()
})
diff --git a/tests/test_helpers.go b/tests/test_helpers.go
index 1251c90cd..0a2cad4ad 100644
--- a/tests/test_helpers.go
+++ b/tests/test_helpers.go
@@ -6,7 +6,10 @@ import (
"path/filepath"
"github.com/navidrome/navidrome/db"
+ "github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model/id"
+ "github.com/sirupsen/logrus"
+ "github.com/sirupsen/logrus/hooks/test"
)
type testingT interface {
@@ -35,3 +38,23 @@ func ClearDB() error {
`)
return err
}
+
+// LogHook sets up a logrus test hook and configures the default logger to use it.
+// It returns the hook and a cleanup function to restore the default logger.
+// Example usage:
+//
+// hook, cleanup := LogHook()
+// defer cleanup()
+// // ... perform logging operations ...
+// Expect(hook.LastEntry()).ToNot(BeNil())
+// Expect(hook.LastEntry().Level).To(Equal(logrus.WarnLevel))
+// Expect(hook.LastEntry().Message).To(Equal("log message"))
+func LogHook() (*test.Hook, func()) {
+ l, hook := test.NewNullLogger()
+ log.SetLevel(log.LevelWarn)
+ log.SetDefaultLogger(l)
+ return hook, func() {
+ // Restore default logger after test
+ log.SetDefaultLogger(logrus.New())
+ }
+}
From 38ca65726a78e7b7e876f379b3a9d5739ffb3377 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Sat, 8 Nov 2025 21:04:20 -0500
Subject: [PATCH 033/102] chore(deps): update wazero to version 1.10.0 and
clean up go.mod
Signed-off-by: Deluan
---
go.mod | 10 +++-------
go.sum | 4 ++--
2 files changed, 5 insertions(+), 9 deletions(-)
diff --git a/go.mod b/go.mod
index bbe610710..140db0834 100644
--- a/go.mod
+++ b/go.mod
@@ -2,12 +2,8 @@ module github.com/navidrome/navidrome
go 1.25.4
-replace (
- // Fork to fix https://github.com/navidrome/navidrome/issues/3254
- github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
- // Using version from main that fixes https://github.com/navidrome/navidrome/issues/4396
- github.com/tetratelabs/wazero v1.9.0 => github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684
-)
+// Fork to fix https://github.com/navidrome/navidrome/issues/3254
+replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
require (
github.com/Masterminds/squirrel v1.5.4
@@ -60,7 +56,7 @@ require (
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
- github.com/tetratelabs/wazero v1.9.0
+ github.com/tetratelabs/wazero v1.10.0
github.com/unrolled/secure v1.17.0
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
go.uber.org/goleak v1.3.0
diff --git a/go.sum b/go.sum
index 059ddd19f..20d2c6abb 100644
--- a/go.sum
+++ b/go.sum
@@ -265,8 +265,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
-github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684 h1:ugT1JTRsK1Jhn95BWilCugyZ1Svsyxm9xSiflOa2e7E=
-github.com/tetratelabs/wazero v0.0.0-20251106165119-514cdb337684/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
+github.com/tetratelabs/wazero v1.10.0 h1:CXP3zneLDl6J4Zy8N/J+d5JsWKfrjE6GtvVK1fpnDlk=
+github.com/tetratelabs/wazero v1.10.0/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
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 ff583970f099df7c2bec649aa6a928756387dc1b Mon Sep 17 00:00:00 2001
From: Deluan
Date: Sat, 8 Nov 2025 21:05:12 -0500
Subject: [PATCH 034/102] chore(deps): update golang.org/x/sync to v0.18.0 and
golang.org/x/sys to v0.38.0
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 140db0834..dcc77d063 100644
--- a/go.mod
+++ b/go.mod
@@ -63,8 +63,8 @@ require (
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
golang.org/x/image v0.32.0
golang.org/x/net v0.46.0
- golang.org/x/sync v0.17.0
- golang.org/x/sys v0.37.0
+ golang.org/x/sync v0.18.0
+ golang.org/x/sys v0.38.0
golang.org/x/text v0.30.0
golang.org/x/time v0.14.0
google.golang.org/protobuf v1.36.10
diff --git a/go.sum b/go.sum
index 20d2c6abb..10feea900 100644
--- a/go.sum
+++ b/go.sum
@@ -332,8 +332,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
-golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
-golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
+golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180926160741-c2ed4eda69e7/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -350,8 +350,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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
-golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
+golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU=
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=
From c369224597cf2d221039e38ed176b0cd6dc9126e Mon Sep 17 00:00:00 2001
From: Deluan
Date: Sun, 9 Nov 2025 12:19:28 -0500
Subject: [PATCH 035/102] test: fix flaky CacheWarmer deduplication test
Fixed race condition in the 'deduplicates items in buffer' test where the
background worker goroutine could process and clear the buffer before the
test could verify its contents. Added fc.SetReady(false) to keep the cache
unavailable during the test, ensuring buffered items remain in memory for
verification. This matches the pattern already used in the 'adds multiple
items to buffer' test.
Signed-off-by: Deluan
---
core/artwork/cache_warmer_test.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/core/artwork/cache_warmer_test.go b/core/artwork/cache_warmer_test.go
index 4125d6de0..7ae3a16e0 100644
--- a/core/artwork/cache_warmer_test.go
+++ b/core/artwork/cache_warmer_test.go
@@ -90,6 +90,7 @@ var _ = Describe("CacheWarmer", func() {
})
It("deduplicates items in buffer", func() {
+ fc.SetReady(false) // Make cache unavailable so items stay in buffer
cw := NewCacheWarmer(aw, fc).(*cacheWarmer)
cw.PreCache(model.MustParseArtworkID("al-1"))
cw.PreCache(model.MustParseArtworkID("al-1"))
From 508670ecfb0d95088310ff7ef1b1e590e69b2f27 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Sun, 9 Nov 2025 12:41:25 -0500
Subject: [PATCH 036/102] Revert "feat(ui): add Vietnamese localization for the
application"
This reverts commit 9621a40f29a507b1e450da31a32134cdc7a9cf2a.
---
resources/i18n/vi.json | 628 -----------------------------------------
1 file changed, 628 deletions(-)
delete mode 100644 resources/i18n/vi.json
diff --git a/resources/i18n/vi.json b/resources/i18n/vi.json
deleted file mode 100644
index a93a65588..000000000
--- a/resources/i18n/vi.json
+++ /dev/null
@@ -1,628 +0,0 @@
-{
- "languageName": "Tiếng Việt",
- "resources": {
- "song": {
- "name": "Tên bài hát",
- "fields": {
- "albumArtist": "Nghệ sĩ trong album",
- "duration": "Thời lượng",
- "trackNumber": "#",
- "playCount": "Số lượt phát",
- "title": "Tên",
- "artist": "Nghệ sĩ",
- "album": "Album",
- "path": "Đường dẫn file",
- "genre": "Thể loại",
- "compilation": "Tuyển tập",
- "year": "Năm",
- "size": "Kích thước tệp",
- "updatedAt": "Cập nhật vào",
- "bitRate": "Số bit",
- "discSubtitle": "Tiêu đề phụ của đĩa",
- "starred": "Yêu thích",
- "comment": "Bình luận",
- "rating": "Đánh giá",
- "quality": "Chất lượng",
- "bpm": "BPM",
- "playDate": "Phát lần cuối",
- "channels": "Kênh",
- "createdAt": "Ngày thêm bài hát",
- "grouping": "Nhóm",
- "mood": "Tâm trạng",
- "participants": "Người tham gia bổ sung",
- "tags": "Tag bổ sung",
- "mappedTags": "Thẻ đã liên kết",
- "rawTags": "Thẻ gốc",
- "bitDepth": "",
- "sampleRate": "",
- "missing": "",
- "libraryName": ""
- },
- "actions": {
- "addToQueue": "Thêm bài hát vào hàng chờ",
- "playNow": "Phát ",
- "addToPlaylist": "Thêm vào danh sách",
- "shuffleAll": "Ngẫu nhiên Tất cả",
- "download": "Tải bài hát xuống",
- "playNext": "Phát tiếp theo",
- "info": "Lấy thông tin bài hát",
- "showInPlaylist": ""
- }
- },
- "album": {
- "name": "Tên album",
- "fields": {
- "albumArtist": "Nghệ sĩ trong album",
- "artist": "Nghệ sĩ",
- "duration": "Thời lượng",
- "songCount": "Số bài hát",
- "playCount": "Số lượt phát",
- "name": "Tên",
- "genre": "Thể loại",
- "compilation": "Tuyển tập",
- "year": "Năm",
- "updatedAt": "Cập nhật vào",
- "comment": "Bình luận",
- "rating": "Đánh giá",
- "createdAt": "Ngày thêm album",
- "size": "Kích cỡ",
- "originalDate": "Bản gốc",
- "releaseDate": "Ngày phát hành",
- "releases": "Bản phát hành |||| Các bản phát hành",
- "released": "Đã phát hành",
- "recordLabel": "Hãng đĩa",
- "catalogNum": "Số Catalog",
- "releaseType": "Loai",
- "grouping": "Nhóm",
- "media": "",
- "mood": "",
- "date": "",
- "missing": "",
- "libraryName": ""
- },
- "actions": {
- "playAll": "Phát",
- "playNext": "Tiếp theo",
- "addToQueue": "Thêm album vào hàng chờ",
- "shuffle": "phát ngẫu nhiên",
- "addToPlaylist": "Thêm vào danh sách phát",
- "download": "Tải Album xuống",
- "info": "Lấy thông tin album",
- "share": "Chia sẻ"
- },
- "lists": {
- "all": "Tất cả",
- "random": "Ngẫu nhiên",
- "recentlyAdded": "Thêm vào gần đây",
- "recentlyPlayed": "Đã phát gần đây",
- "mostPlayed": "Phát nhiều nhất",
- "starred": "Album Yêu thích",
- "topRated": "Được đánh giá cao nhất"
- }
- },
- "artist": {
- "name": "Nghệ sĩ",
- "fields": {
- "name": "Tên nghệ sĩ",
- "albumCount": "Số Album",
- "songCount": "Số bài hát",
- "playCount": "Số lượt phát",
- "rating": "Đánh giá",
- "genre": "Thể loại",
- "size": "Kích cỡ",
- "role": "",
- "missing": ""
- },
- "roles": {
- "albumartist": "",
- "artist": "",
- "composer": "",
- "conductor": "",
- "lyricist": "",
- "arranger": "",
- "producer": "",
- "director": "",
- "engineer": "",
- "mixer": "",
- "remixer": "",
- "djmixer": "",
- "performer": "",
- "maincredit": ""
- },
- "actions": {
- "shuffle": "",
- "radio": "",
- "topSongs": ""
- }
- },
- "user": {
- "name": "Người dùng",
- "fields": {
- "userName": "Tên người dùng",
- "isAdmin": "Quản trị viên",
- "lastLoginAt": "Lần đăng nhập cuối",
- "updatedAt": "Cập nhật lúc",
- "name": "Tên người dùng",
- "password": "Mật khẩu",
- "createdAt": "Tạo vào",
- "changePassword": "Đổi mật khẩu ?",
- "currentPassword": "Mật khẩu hiện tại",
- "newPassword": "Mật khẩu mới",
- "token": "Token",
- "lastAccessAt": "Lần truy cập cuối",
- "libraries": ""
- },
- "helperTexts": {
- "name": "Sự thay đổi về tên bạn sẽ có hiệu lực vào lần đăng nhập tiếp theo",
- "libraries": ""
- },
- "notifications": {
- "created": "Tạo bởi user",
- "updated": "Cập nhật bởi user",
- "deleted": "Xóa người dùng"
- },
- "message": {
- "listenBrainzToken": "Nhập token của MusicBrainz",
- "clickHereForToken": "",
- "selectAllLibraries": "",
- "adminAutoLibraries": ""
- },
- "validation": {
- "librariesRequired": ""
- }
- },
- "player": {
- "name": "Trình phát |||| Các trình phát",
- "fields": {
- "name": "Tên trình phát",
- "transcodingId": "Mã chuyển mã",
- "maxBitRate": "Bit Rate cao nhất",
- "client": "",
- "userName": "Tên người dùng",
- "lastSeen": "Lần cuối nhìn thấy",
- "reportRealPath": "Hiện đường dẫn thực",
- "scrobbleEnabled": ""
- }
- },
- "transcoding": {
- "name": "Chuyển đổi định dạng",
- "fields": {
- "name": "Tên cấu hình chuyển mã",
- "targetFormat": "Định dạng cuối",
- "defaultBitRate": "Số Bit mặc định",
- "command": "Câu lệnh"
- }
- },
- "playlist": {
- "name": "Danh sách phát |||| Các danh sách phát",
- "fields": {
- "name": "Tên",
- "duration": "Thời lượng",
- "ownerName": "Chủ sở hữu",
- "public": "Công khai",
- "updatedAt": "Cập nhật vào",
- "createdAt": "Tạo vào lúc",
- "songCount": "Số bài hát",
- "comment": "Bình luận",
- "sync": "Tự động thêm vào",
- "path": "Nhập từ"
- },
- "actions": {
- "selectPlaylist": "Chọn 1 danh sách phát",
- "addNewPlaylist": "Tạo \"%{name}\"",
- "export": "Xuất danh sách phát",
- "makePublic": "",
- "makePrivate": "",
- "saveQueue": "",
- "searchOrCreate": "",
- "pressEnterToCreate": "",
- "removeFromSelection": ""
- },
- "message": {
- "duplicate_song": "Thêm các bài hát trùng lặp",
- "song_exist": "Có một số bài hát trùng đang được thêm vào danh sách phát. Bạn muốn thêm các bài trùng hay bỏ qua chúng?",
- "noPlaylistsFound": "",
- "noPlaylists": ""
- }
- },
- "radio": {
- "name": "Radio |||| Radios",
- "fields": {
- "name": "Tên",
- "streamUrl": "Stream URL",
- "homePageUrl": "URL trang chủ",
- "updatedAt": "Cập nhật vào",
- "createdAt": "Tạo vào lúc"
- },
- "actions": {
- "playNow": "Phát ngay"
- }
- },
- "share": {
- "name": "Chia sẻ |||| Chia sẻ",
- "fields": {
- "username": "Chia sẻ bởi",
- "url": "URL",
- "description": "Phần mô tả",
- "contents": "Nội dung",
- "expiresAt": "Hết hạn",
- "lastVisitedAt": "Lần mở cuối ",
- "visitCount": "Lượt ",
- "format": "Định dạng",
- "maxBitRate": "Số Bit cao nhất",
- "updatedAt": "Cập nhật vào",
- "createdAt": "Tạo vào",
- "downloadable": "Cho phép tải xuống?"
- }
- },
- "missing": {
- "name": "",
- "fields": {
- "path": "",
- "size": "",
- "updatedAt": "",
- "libraryName": ""
- },
- "actions": {
- "remove": "",
- "remove_all": ""
- },
- "notifications": {
- "removed": ""
- },
- "empty": ""
- },
- "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": "",
- "manageUsers": "",
- "viewDetails": ""
- },
- "notifications": {
- "created": "",
- "updated": "",
- "deleted": "Xóa thư viện thành công",
- "scanStarted": "Bắt đầu quét thư viện",
- "scanCompleted": "Quét thư viện hoàn tất"
- },
- "validation": {
- "nameRequired": "",
- "pathRequired": "",
- "pathNotDirectory": "",
- "pathNotFound": "",
- "pathNotAccessible": "",
- "pathInvalid": ""
- },
- "messages": {
- "deleteConfirm": "",
- "scanInProgress": "Đang quét...",
- "noLibrariesAssigned": ""
- }
- }
- },
- "ra": {
- "auth": {
- "welcome1": "Cảm ơn bạn vì đã sử dụng Navidrome",
- "welcome2": "Để bắt đầu, hãy tạo một tài khoản quản trị viên.",
- "confirmPassword": "Xác nhận mật khẩu",
- "buttonCreateAdmin": "Tạo quản trị viên",
- "auth_check_error": "Hãy đăng nhập để tiếp tục",
- "user_menu": "Profile",
- "username": "Tên người dùng",
- "password": "Mật khẩu",
- "sign_in": "Đăng nhập",
- "sign_in_error": "Xác thực thất bại, hãy thử lại",
- "logout": "Đăng xuất",
- "insightsCollectionNote": "Navidrome thu thập dữ liệu sử dụng ẩn danh để giúp cải thiện dự án. Nhấp [here] để tìm hiểu thêm và tắt tính năng này nếu bạn muốn."
- },
- "validation": {
- "invalidChars": "Vui lòng chỉ sử dụng chữ cái và số",
- "passwordDoesNotMatch": "Mật khẩu không đúng",
- "required": "Yêu cầu",
- "minLength": "Ít nhất là %{min} ký tự",
- "maxLength": "Phải nhiều hơn hoặc bằng hoặc bằng %{max}.",
- "minValue": "Ít nhất là %{min}",
- "maxValue": "Phải nhỏ hơn hoặc bằng %{max}",
- "number": "Phải là một số",
- "email": "Phải là một email ",
- "oneOf": "Phải là một trong các lựa chọn sau: %{options}",
- "regex": "Phải khớp với định dạng cụ thể (regex): %{pattern}",
- "unique": "Phải đặc biệt",
- "url": "Phải là một URL hợp lệ"
- },
- "action": {
- "add_filter": "Thêm bộ lọc",
- "add": "Thêm",
- "back": "Quay lại",
- "bulk_actions": "Đã chọn 1 mục |||| Đã chọn %{smart_count} mục",
- "cancel": "Hủy",
- "clear_input_value": "Xóa thiết đặt",
- "clone": "Nhân bản",
- "confirm": "Xác nhận",
- "create": "Tạo",
- "delete": "Xóa",
- "edit": "Sửa",
- "export": "Xuất",
- "list": "Danh sách",
- "refresh": "Làm mới",
- "remove_filter": "Bỏ bộ lọc này",
- "remove": "Gỡ bỏ",
- "save": "Lưu lại",
- "search": "Tìm kiếm",
- "show": "Hiển thị",
- "sort": "Lọc",
- "undo": "Hoàn tác",
- "expand": "Mở rộng",
- "close": "Đóng",
- "open_menu": "Mở menu",
- "close_menu": "Đóng menu",
- "unselect": "Bỏ chọn",
- "skip": "Bỏ qua",
- "bulk_actions_mobile": "1 |||| %{smart_count}",
- "share": "Chia sẻ",
- "download": "Tải xuống"
- },
- "boolean": {
- "true": "Có",
- "false": "Không"
- },
- "page": {
- "create": "Tạo %{name}",
- "dashboard": "Trang chủ",
- "edit": "%{name} #%{id}",
- "error": "Có gì đó không ổn",
- "list": "%{name}",
- "loading": "Đang tải",
- "not_found": "Không tìm thấy",
- "show": "%{name} #%{id}",
- "empty": "Chưa có %{name}",
- "invite": "Bạn muốn thêm vào không ?"
- },
- "input": {
- "file": {
- "upload_several": "Thả một vài tệp để tải lên hoặc nhấp để chọn",
- "upload_single": "Thả một file để tải lên hoặc nhấp để chọn nó"
- },
- "image": {
- "upload_several": "Thả một vài ảnh để tải lên hoặc nhấp để chọn",
- "upload_single": "Thả một ảnh để tải lên hoặc nhấp để chọn nó"
- },
- "references": {
- "all_missing": "Không thể tìm thấy dữ liệu",
- "many_missing": "Ít nhất một mục được liên kết không còn tồn tại.",
- "single_missing": "Tham chiếu liên kết không còn khả dụng nữa."
- },
- "password": {
- "toggle_visible": "Ẩn mật khẩu",
- "toggle_hidden": "Hiện mật khẩu"
- }
- },
- "message": {
- "about": "Giới thiệu",
- "are_you_sure": "Bạn chắc chứ ?",
- "bulk_delete_content": "Bạn có chắc chắn muốn xóa %{name} này không? |||| Bạn có chắc chắn muốn xóa %{smart_count} mục này không??",
- "bulk_delete_title": "Xóa %{name} đã chọn |||| Xóa %{smart_count} mục %{name}",
- "delete_content": "Xác nhận xóa ?",
- "delete_title": "Xóa %{name} #%{id}",
- "details": "Chi tiết",
- "error": "Có lỗi xảy ra với client và yêu cầu của bạn không thành công.",
- "invalid_form": "Biểu mẫu không hợp lệ. Vui lòng kiểm tra lại các lỗi",
- "loading": "Trang đang được tải, hãy kiên nhận",
- "no": "Không",
- "not_found": "Có thể bạn đã nhập sai URL hoặc truy cập vào một liên kết không hợp lệ.",
- "yes": "Có",
- "unsaved_changes": "Một số thiết đặt chưa được lưu. Bạn muốn bỏ qua chúng không ?"
- },
- "navigation": {
- "no_results": "Không tìm thấy kết quả",
- "no_more_results": "Số trang %{page} nằm ngoài giới hạn. Hãy thử quay lại trang trước",
- "page_out_of_boundaries": "Trang %{page} không hợp lệ",
- "page_out_from_end": "Bạn đang ở trang cuối rồi",
- "page_out_from_begin": "Không thể quay về trước trang 1",
- "page_range_info": "%{offsetBegin}–%{offsetEnd} trong tổng số %{total}",
- "page_rows_per_page": "Số mục mỗi trang :",
- "next": "Tiếp theo",
- "prev": "Trước",
- "skip_nav": "Bỏ qua đến nội dung"
- },
- "notification": {
- "updated": "Mục đã được cập nhật |||| %{smart_count} mục đã cập nhật",
- "created": "Đã tạo mục mới",
- "deleted": "Đã xóa muc |||| %{smart_count} mục đã xóa",
- "bad_item": "Mục không đúng",
- "item_doesnt_exist": "Mục không tồn tại",
- "http_error": "Lỗi kết nối đến máy chủ",
- "data_provider_error": "Lỗi dataProvider. Kiểm tra Console để biết thêm chi tiết",
- "i18n_error": "Không thể tải bản dịch cho ngôn ngữ đã chọn",
- "canceled": "Hành động đã bị hủy",
- "logged_out": "Phiên của bạn đã kết thúc, vui lòng kết nối lại.",
- "new_version": "Có phiên bản mới! Hãy làm mới trang"
- },
- "toggleFieldsMenu": {
- "columnsToDisplay": "Các cột hiển thị",
- "layout": "Bố cục",
- "grid": "Lưới",
- "table": "Bảng"
- }
- },
- "message": {
- "note": "Lưu ý",
- "transcodingDisabled": "Việc thay đổi cấu hình chuyển mã (transcoding configuration) thông qua giao diện web đã bị vô hiệu hóa vì lý do bảo mật. Nếu bạn muốn chỉnh sửa hoặc thêm tùy chọn chuyển mã, hãy khởi động lại máy chủ kèm theo tùy chọn cấu hình %{config}",
- "transcodingEnabled": "Navidrome hiện đang chạy với tùy chọn cấu hình %{config}, cho phép thực thi lệnh hệ thống từ phần cài đặt chuyển mã (transcoding) trong giao diện web. Chúng tôi khuyến nghị bạn nên tắt tùy chọn này vì lý do bảo mật, và chỉ bật lại khi cần cấu hình các tùy chọn chuyển mã.",
- "songsAddedToPlaylist": "Đã thêm 1 bài hát vào danh sách phát |||| Đã thêm %{smart_count} bài hát vào danh sách phát",
- "noPlaylistsAvailable": "Không có danh sách phát",
- "delete_user_title": "Xóa người dùng '%{name}'",
- "delete_user_content": "Bạn có muốn xóa người dùng này và tất cả các dữ liệu của họ không ( bao gồm danh sách phát và các thiết đặt )?",
- "notifications_blocked": "Bạn đã tắt thông báo trong cài đặt trình duyệt",
- "notifications_not_available": "Trình duyệt này không hỗ trợ thông báo trên desktop hoặc bạn đang truy cập Navidrome qua http",
- "lastfmLinkSuccess": "",
- "lastfmLinkFailure": "",
- "lastfmUnlinkSuccess": "",
- "lastfmUnlinkFailure": "",
- "openIn": {
- "lastfm": "Mở trong Last.fm",
- "musicbrainz": "Mở trong MusicBrainz"
- },
- "lastfmLink": "Đọc thêm...",
- "listenBrainzLinkSuccess": "",
- "listenBrainzLinkFailure": "Không thể liên kết với ListenBrainz : %{error}",
- "listenBrainzUnlinkSuccess": "Đã bỏ liên kết với ListenBrainz và ",
- "listenBrainzUnlinkFailure": "Không thể liên kết với MusicBrainz",
- "downloadOriginalFormat": "Tải xuống ở định dạng gốc",
- "shareOriginalFormat": "Chia sẻ ở định dạng gốc",
- "shareDialogTitle": "Chia sẻ %{resource} '%{name}'",
- "shareBatchDialogTitle": "Chia sẻ 1 %{resource} |||| Chia sẻ %{smart_count} %{resource}",
- "shareSuccess": "URL đã sao chép vào bảng nhớ tạm : %{url}",
- "shareFailure": "Lỗi khi sao chép URL %{url} vào bảng nhớ tạm",
- "downloadDialogTitle": "Tải xuống %{resource} '%{name}' (%{size})",
- "shareCopyToClipboard": "Sao chép vào bảng nhớ tạm : Ctrl+C, Enter",
- "remove_missing_title": "",
- "remove_missing_content": "",
- "remove_all_missing_title": "",
- "remove_all_missing_content": "",
- "noSimilarSongsFound": "",
- "noTopSongsFound": ""
- },
- "menu": {
- "library": "Thư viện",
- "settings": "Cài đặt",
- "version": "Phiên bản",
- "theme": "Theme",
- "personal": {
- "name": "Cá nhân hóa",
- "options": {
- "theme": "Theme",
- "language": "Ngôn ngữ",
- "defaultView": "",
- "desktop_notifications": "Thông báo trên desktop",
- "lastfmScrobbling": "",
- "listenBrainzScrobbling": "",
- "replaygain": "Chế độ ReplayGain",
- "preAmp": "ReplayGain PreAmp (dB)",
- "gain": {
- "none": "Tắt",
- "album": "Dùng Album Gain",
- "track": "Dùng Track Gain"
- },
- "lastfmNotConfigured": "Khóa API của Last.fm chưa được cấu hình"
- }
- },
- "albumList": "Albums",
- "about": "Về",
- "playlists": "Danh sách phát",
- "sharedPlaylists": "Danh sách phát được chia sẻ",
- "librarySelector": {
- "allLibraries": "Tất cả thư viện (%{count})",
- "multipleLibraries": "",
- "selectLibraries": "",
- "none": "Không có"
- }
- },
- "player": {
- "playListsText": "Danh sách chờ",
- "openText": "Mở",
- "closeText": "Thoát",
- "notContentText": "Không có bài hát",
- "clickToPlayText": "Nhấp để phát",
- "clickToPauseText": "Nhấp để tạm dừng",
- "nextTrackText": "Track tiếp theo",
- "previousTrackText": "Track trước đó",
- "reloadText": "Làm mới",
- "volumeText": "Âm lượng",
- "toggleLyricText": "Bật lời bài hát",
- "toggleMiniModeText": "Thu nhỏ",
- "destroyText": "Xóa",
- "downloadText": "Tải xuống",
- "removeAudioListsText": "Xóa danh sách ",
- "clickToDeleteText": "Nhấp để xóa %{name}",
- "emptyLyricText": "Không có lời",
- "playModeText": {
- "order": "Theo thứ tự",
- "orderLoop": "Lặp lại",
- "singleLoop": "Lặp lại một lần",
- "shufflePlay": "Phát ngẫu nhiên"
- }
- },
- "about": {
- "links": {
- "homepage": "Trang chủ",
- "source": "Mã nguồn",
- "featureRequests": "Yêu cầu tính năng",
- "lastInsightsCollection": "Lần thu thập dữ liệu gần nhất",
- "insights": {
- "disabled": "Đã tắt",
- "waiting": "Đang chờ"
- }
- },
- "tabs": {
- "about": "",
- "config": ""
- },
- "config": {
- "configName": "",
- "environmentVariable": "",
- "currentValue": "",
- "configurationFile": "",
- "exportToml": "",
- "exportSuccess": "",
- "exportFailed": "",
- "devFlagsHeader": "",
- "devFlagsComment": ""
- }
- },
- "activity": {
- "title": "Hoạt động",
- "totalScanned": "Tổng Folder đã quét",
- "quickScan": "Quét nhanh",
- "fullScan": "Quét toàn bộ",
- "serverUptime": "Server Uptime",
- "serverDown": "Ngoại tuyến",
- "scanType": "",
- "status": "",
- "elapsedTime": ""
- },
- "help": {
- "title": "Phím tắt của Navidrome",
- "hotkeys": {
- "show_help": "Hiện giúp đỡ",
- "toggle_menu": "Bật thanh phát bên",
- "toggle_play": "Phát / tạm dừng",
- "prev_song": "Bài hát trước đó",
- "next_song": "Bài hát sau đó",
- "vol_up": "Tăng âm lượng",
- "vol_down": "Giảm âm lượng",
- "toggle_love": "Thêm track này vào yêu thích",
- "current_song": "Đi đến bài hát hiện tại"
- }
- },
- "nowPlaying": {
- "title": "",
- "empty": "",
- "minutesAgo": ""
- }
-}
\ No newline at end of file
From 53ff33866d85399b516efff3c09002e7c3b975b4 Mon Sep 17 00:00:00 2001
From: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
Date: Sun, 9 Nov 2025 17:52:05 +0000
Subject: [PATCH 037/102] feat(subsonic): implement indexBasedQueue extension
(#4244)
* redo this whole PR, but clearner now that better errata is in
* update play queue types
---
server/subsonic/api.go | 2 +
server/subsonic/bookmarks.go | 73 ++++++++++++++++++-
server/subsonic/opensubsonic.go | 1 +
server/subsonic/opensubsonic_test.go | 3 +-
... PlayQueue without data should match .JSON | 1 +
...s PlayQueue without data should match .XML | 2 +-
...yQueueByIndex with data should match .JSON | 22 ++++++
...ayQueueByIndex with data should match .XML | 5 ++
...eueByIndex without data should match .JSON | 12 +++
...ueueByIndex without data should match .XML | 3 +
server/subsonic/responses/responses.go | 22 ++++--
server/subsonic/responses/responses_test.go | 36 ++++++++-
12 files changed, 172 insertions(+), 10 deletions(-)
create mode 100644 server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON
create mode 100644 server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML
create mode 100644 server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON
create mode 100644 server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML
diff --git a/server/subsonic/api.go b/server/subsonic/api.go
index bb3d20e5c..d08d3eb5b 100644
--- a/server/subsonic/api.go
+++ b/server/subsonic/api.go
@@ -148,7 +148,9 @@ func (api *Router) routes() http.Handler {
h(r, "createBookmark", api.CreateBookmark)
h(r, "deleteBookmark", api.DeleteBookmark)
h(r, "getPlayQueue", api.GetPlayQueue)
+ h(r, "getPlayQueueByIndex", api.GetPlayQueueByIndex)
h(r, "savePlayQueue", api.SavePlayQueue)
+ h(r, "savePlayQueueByIndex", api.SavePlayQueueByIndex)
})
r.Group(func(r chi.Router) {
r.Use(getPlayer(api.players))
diff --git a/server/subsonic/bookmarks.go b/server/subsonic/bookmarks.go
index d7286c20c..b1e71b1c7 100644
--- a/server/subsonic/bookmarks.go
+++ b/server/subsonic/bookmarks.go
@@ -91,7 +91,7 @@ func (api *Router) GetPlayQueue(r *http.Request) (*responses.Subsonic, error) {
Current: currentID,
Position: pq.Position,
Username: user.UserName,
- Changed: &pq.UpdatedAt,
+ Changed: pq.UpdatedAt,
ChangedBy: pq.ChangedBy,
}
return response, nil
@@ -135,3 +135,74 @@ func (api *Router) SavePlayQueue(r *http.Request) (*responses.Subsonic, error) {
}
return newResponse(), nil
}
+
+func (api *Router) GetPlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
+ user, _ := request.UserFrom(r.Context())
+
+ repo := api.ds.PlayQueue(r.Context())
+ pq, err := repo.RetrieveWithMediaFiles(user.ID)
+ if err != nil && !errors.Is(err, model.ErrNotFound) {
+ return nil, err
+ }
+ if pq == nil || len(pq.Items) == 0 {
+ return newResponse(), nil
+ }
+
+ response := newResponse()
+
+ var index *int
+ if len(pq.Items) > 0 {
+ index = &pq.Current
+ }
+
+ response.PlayQueueByIndex = &responses.PlayQueueByIndex{
+ Entry: slice.MapWithArg(pq.Items, r.Context(), childFromMediaFile),
+ CurrentIndex: index,
+ Position: pq.Position,
+ Username: user.UserName,
+ Changed: pq.UpdatedAt,
+ ChangedBy: pq.ChangedBy,
+ }
+ return response, nil
+}
+
+func (api *Router) SavePlayQueueByIndex(r *http.Request) (*responses.Subsonic, error) {
+ p := req.Params(r)
+ ids, _ := p.Strings("id")
+
+ position := p.Int64Or("position", 0)
+
+ var err error
+ var currentIndex int
+
+ if len(ids) > 0 {
+ currentIndex, err = p.Int("currentIndex")
+ if err != nil || currentIndex < 0 || currentIndex >= len(ids) {
+ return nil, newError(responses.ErrorMissingParameter, "missing parameter index, err: %s", err)
+ }
+ }
+
+ items := slice.Map(ids, func(id string) model.MediaFile {
+ return model.MediaFile{ID: id}
+ })
+
+ user, _ := request.UserFrom(r.Context())
+ client, _ := request.ClientFrom(r.Context())
+
+ pq := &model.PlayQueue{
+ UserID: user.ID,
+ Current: currentIndex,
+ Position: position,
+ ChangedBy: client,
+ Items: items,
+ CreatedAt: time.Time{},
+ UpdatedAt: time.Time{},
+ }
+
+ repo := api.ds.PlayQueue(r.Context())
+ err = repo.Store(pq)
+ if err != nil {
+ return nil, err
+ }
+ return newResponse(), nil
+}
diff --git a/server/subsonic/opensubsonic.go b/server/subsonic/opensubsonic.go
index 17ce3c2b0..a364651c5 100644
--- a/server/subsonic/opensubsonic.go
+++ b/server/subsonic/opensubsonic.go
@@ -12,6 +12,7 @@ func (api *Router) GetOpenSubsonicExtensions(_ *http.Request) (*responses.Subson
{Name: "transcodeOffset", Versions: []int32{1}},
{Name: "formPost", Versions: []int32{1}},
{Name: "songLyrics", Versions: []int32{1}},
+ {Name: "indexBasedQueue", Versions: []int32{1}},
}
return response, nil
}
diff --git a/server/subsonic/opensubsonic_test.go b/server/subsonic/opensubsonic_test.go
index 3cc680afe..58dca682c 100644
--- a/server/subsonic/opensubsonic_test.go
+++ b/server/subsonic/opensubsonic_test.go
@@ -35,10 +35,11 @@ var _ = Describe("GetOpenSubsonicExtensions", func() {
err := json.Unmarshal(w.Body.Bytes(), &response)
Expect(err).NotTo(HaveOccurred())
Expect(*response.Subsonic.OpenSubsonicExtensions).To(SatisfyAll(
- HaveLen(3),
+ HaveLen(4),
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}}),
))
})
})
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON
index 88eebb276..70b10c059 100644
--- a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .JSON
@@ -6,6 +6,7 @@
"openSubsonic": true,
"playQueue": {
"username": "",
+ "changed": "0001-01-01T00:00:00Z",
"changedBy": ""
}
}
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML
index 5af3d9157..597781cbd 100644
--- a/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueue without data should match .XML
@@ -1,3 +1,3 @@
-
+
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON
new file mode 100644
index 000000000..efc032ca6
--- /dev/null
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .JSON
@@ -0,0 +1,22 @@
+{
+ "status": "ok",
+ "version": "1.16.1",
+ "type": "navidrome",
+ "serverVersion": "v0.55.0",
+ "openSubsonic": true,
+ "playQueueByIndex": {
+ "entry": [
+ {
+ "id": "1",
+ "isDir": false,
+ "title": "title",
+ "isVideo": false
+ }
+ ],
+ "currentIndex": 0,
+ "position": 243,
+ "username": "user1",
+ "changed": "0001-01-01T00:00:00Z",
+ "changedBy": "a_client"
+ }
+}
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML
new file mode 100644
index 000000000..1d31b334e
--- /dev/null
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex with data should match .XML
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON
new file mode 100644
index 000000000..ad49a35e5
--- /dev/null
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .JSON
@@ -0,0 +1,12 @@
+{
+ "status": "ok",
+ "version": "1.16.1",
+ "type": "navidrome",
+ "serverVersion": "v0.55.0",
+ "openSubsonic": true,
+ "playQueueByIndex": {
+ "username": "",
+ "changed": "0001-01-01T00:00:00Z",
+ "changedBy": ""
+ }
+}
diff --git a/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML
new file mode 100644
index 000000000..d99681f4c
--- /dev/null
+++ b/server/subsonic/responses/.snapshots/Responses PlayQueueByIndex without data should match .XML
@@ -0,0 +1,3 @@
+
+
+
diff --git a/server/subsonic/responses/responses.go b/server/subsonic/responses/responses.go
index ffda2aa43..0724d2fff 100644
--- a/server/subsonic/responses/responses.go
+++ b/server/subsonic/responses/responses.go
@@ -60,6 +60,7 @@ type Subsonic struct {
// OpenSubsonic extensions
OpenSubsonicExtensions *OpenSubsonicExtensions `xml:"openSubsonicExtensions,omitempty" json:"openSubsonicExtensions,omitempty"`
LyricsList *LyricsList `xml:"lyricsList,omitempty" json:"lyricsList,omitempty"`
+ PlayQueueByIndex *PlayQueueByIndex `xml:"playQueueByIndex,omitempty" json:"playQueueByIndex,omitempty"`
}
const (
@@ -439,12 +440,21 @@ type TopSongs struct {
}
type PlayQueue struct {
- Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
- Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
- Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
- Username string `xml:"username,attr" json:"username"`
- Changed *time.Time `xml:"changed,attr,omitempty" json:"changed,omitempty"`
- ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
+ Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
+ Current string `xml:"current,attr,omitempty" json:"current,omitempty"`
+ Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
+ Username string `xml:"username,attr" json:"username"`
+ Changed time.Time `xml:"changed,attr" json:"changed"`
+ ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
+}
+
+type PlayQueueByIndex struct {
+ Entry []Child `xml:"entry,omitempty" json:"entry,omitempty"`
+ CurrentIndex *int `xml:"currentIndex,attr,omitempty" json:"currentIndex,omitempty"`
+ Position int64 `xml:"position,attr,omitempty" json:"position,omitempty"`
+ Username string `xml:"username,attr" json:"username"`
+ Changed time.Time `xml:"changed,attr" json:"changed"`
+ ChangedBy string `xml:"changedBy,attr" json:"changedBy"`
}
type Bookmark struct {
diff --git a/server/subsonic/responses/responses_test.go b/server/subsonic/responses/responses_test.go
index 7238665cf..2ee8e080d 100644
--- a/server/subsonic/responses/responses_test.go
+++ b/server/subsonic/responses/responses_test.go
@@ -768,7 +768,7 @@ var _ = Describe("Responses", func() {
response.PlayQueue.Username = "user1"
response.PlayQueue.Current = "111"
response.PlayQueue.Position = 243
- response.PlayQueue.Changed = &time.Time{}
+ response.PlayQueue.Changed = time.Time{}
response.PlayQueue.ChangedBy = "a_client"
child := make([]Child, 1)
child[0] = Child{Id: "1", Title: "title", IsDir: false}
@@ -783,6 +783,40 @@ var _ = Describe("Responses", func() {
})
})
+ Describe("PlayQueueByIndex", func() {
+ BeforeEach(func() {
+ response.PlayQueueByIndex = &PlayQueueByIndex{}
+ })
+
+ Context("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())
+ })
+ })
+
+ Context("with data", func() {
+ BeforeEach(func() {
+ response.PlayQueueByIndex.Username = "user1"
+ response.PlayQueueByIndex.CurrentIndex = gg.P(0)
+ response.PlayQueueByIndex.Position = 243
+ response.PlayQueueByIndex.Changed = time.Time{}
+ response.PlayQueueByIndex.ChangedBy = "a_client"
+ child := make([]Child, 1)
+ child[0] = Child{Id: "1", Title: "title", IsDir: false}
+ response.PlayQueueByIndex.Entry = child
+ })
+ It("should match .XML", func() {
+ Expect(xml.MarshalIndent(response, "", " ")).To(MatchSnapshot())
+ })
+ It("should match .JSON", func() {
+ Expect(json.MarshalIndent(response, "", " ")).To(MatchSnapshot())
+ })
+ })
+ })
+
Describe("Shares", func() {
BeforeEach(func() {
response.Shares = &Shares{}
From 131c0c565cfd2f5c11939e05621cd4a671ec7ecb Mon Sep 17 00:00:00 2001
From: Rob Emery
Date: Sun, 9 Nov 2025 17:57:55 +0000
Subject: [PATCH 038/102] feat(insights): detecting packaging method (#3841)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Adding environmental variable so that navidrome can detect
if its running as an MSI install for insights
* Renaming to be ND_PACKAGE_TYPE so we can reuse this for the
.deb/.rpm stats as well
* Packaged implies a bool, this is a description so it should
be packaging or just package imo
* wixl currently doesn't support so I'm swapping out
to a file next-door to the configuration file, we should be
able to reuse this for deb/rpm as well
* Using a file we should be able to add support for linux like this
also
* MSI should copy the package into place for us, it's not a KeyPath
as older versions won't have it, so it's presence doesn't indicate
the installed status of the package
* OK this doesn't exist, need to find another way to do it
* package to .package and moving to the datadir
* fix(scanner): better log message when AutoImportPlaylists is disabled
Fix #3861
Signed-off-by: Deluan
* fix(scanner): support ID3v2 embedded images in WAV files
Fix #3867
Signed-off-by: Deluan
* feat(ui): show bitDepth in song info dialog
Signed-off-by: Deluan
* fix(server): don't break if the ND_CONFIGFILE does not exist
Signed-off-by: Deluan
* feat(docker): automatically loads a navidrome.toml file from /data, if available
Signed-off-by: Deluan
* feat(server): custom ArtistJoiner config (#3873)
* feat(server): custom ArtistJoiner config
Signed-off-by: Deluan
* refactor(ui): organize ArtistLinkField, add tests
Signed-off-by: Deluan
* feat(ui): use display artist
* feat(ui): use display artist
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
* chore: remove some BFR-related TODOs that are not valid anymore
Signed-off-by: Deluan
* chore: remove more outdated TODOs
Signed-off-by: Deluan
* fix(scanner): elapsed time for folder processing is wrong in the logs
Signed-off-by: Deluan
* Should be able to reuse this mechanism with deb and rpm, I think
it would be nice to know which specific one it is without guessing
based on /etc/debian_version or something; but it doesn't look like
that is exposed by goreleaser into an env or anything :/
* Need to reference the installed file and I think Id's don't require []
* Need to add into the root directory for this to work
* That was not deliberately removed
* feat: add RPM and DEB package configuration files for Navidrome
Signed-off-by: Deluan
* Don't need this as goreleaser will sort it out
---------
Signed-off-by: Deluan
Co-authored-by: Deluan Quintão
---
core/metrics/insights.go | 8 ++++++++
core/metrics/insights/data.go | 1 +
release/goreleaser.yml | 9 +++++++++
release/linux/.package.deb | 1 +
release/linux/.package.rpm | 1 +
release/wix/build_msi.sh | 3 +++
release/wix/navidrome.wxs | 7 +++++++
7 files changed, 30 insertions(+)
create mode 100644 release/linux/.package.deb
create mode 100644 release/linux/.package.rpm
diff --git a/core/metrics/insights.go b/core/metrics/insights.go
index f4f8738e7..010c24c28 100644
--- a/core/metrics/insights.go
+++ b/core/metrics/insights.go
@@ -6,6 +6,7 @@ import (
"encoding/json"
"math"
"net/http"
+ "os"
"path/filepath"
"runtime"
"runtime/debug"
@@ -160,6 +161,13 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Build.Settings, data.Build.GoVersion = buildInfo()
data.OS.Containerized = consts.InContainer
+ // Install info
+ packageFilename := filepath.Join(conf.Server.DataFolder, ".package")
+ packageFileData, err := os.ReadFile(packageFilename)
+ if err == nil {
+ data.OS.Package = string(packageFileData)
+ }
+
// OS info
data.OS.Type = runtime.GOOS
data.OS.Arch = runtime.GOARCH
diff --git a/core/metrics/insights/data.go b/core/metrics/insights/data.go
index 105a6218e..c46eb8743 100644
--- a/core/metrics/insights/data.go
+++ b/core/metrics/insights/data.go
@@ -16,6 +16,7 @@ type Data struct {
Containerized bool `json:"containerized"`
Arch string `json:"arch"`
NumCPU int `json:"numCPU"`
+ Package string `json:"package,omitempty"`
} `json:"os"`
Mem struct {
Alloc uint64 `json:"alloc"`
diff --git a/release/goreleaser.yml b/release/goreleaser.yml
index f71c38f31..30c0d6f3b 100644
--- a/release/goreleaser.yml
+++ b/release/goreleaser.yml
@@ -83,6 +83,15 @@ nfpms:
owner: navidrome
group: navidrome
+ - src: release/linux/.package.rpm # contents: "rpm"
+ dst: /var/lib/navidrome/.package
+ type: "config|noreplace"
+ packager: rpm
+ - src: release/linux/.package.deb # contents: "deb"
+ dst: /var/lib/navidrome/.package
+ type: "config|noreplace"
+ packager: deb
+
scripts:
preinstall: "release/linux/preinstall.sh"
postinstall: "release/linux/postinstall.sh"
diff --git a/release/linux/.package.deb b/release/linux/.package.deb
new file mode 100644
index 000000000..811c85f42
--- /dev/null
+++ b/release/linux/.package.deb
@@ -0,0 +1 @@
+deb
\ No newline at end of file
diff --git a/release/linux/.package.rpm b/release/linux/.package.rpm
new file mode 100644
index 000000000..7c88ef3c0
--- /dev/null
+++ b/release/linux/.package.rpm
@@ -0,0 +1 @@
+rpm
\ No newline at end of file
diff --git a/release/wix/build_msi.sh b/release/wix/build_msi.sh
index 9fc008446..7e595311e 100755
--- a/release/wix/build_msi.sh
+++ b/release/wix/build_msi.sh
@@ -49,6 +49,9 @@ cp "${DOWNLOAD_FOLDER}"/extracted_ffmpeg/${FFMPEG_FILE}/bin/ffmpeg.exe "$MSI_OUT
cp "$WORKSPACE"/LICENSE "$WORKSPACE"/README.md "$MSI_OUTPUT_DIR"
cp "$BINARY" "$MSI_OUTPUT_DIR"
+# package type indicator file
+echo "msi" > "$MSI_OUTPUT_DIR/.package"
+
# workaround for wixl WixVariable not working to override bmp locations
cp "$WORKSPACE"/release/wix/bmp/banner.bmp /usr/share/wixl-*/ext/ui/bitmaps/bannrbmp.bmp
cp "$WORKSPACE"/release/wix/bmp/dialogue.bmp /usr/share/wixl-*/ext/ui/bitmaps/dlgbmp.bmp
diff --git a/release/wix/navidrome.wxs b/release/wix/navidrome.wxs
index ec8b164e8..8ebba4632 100644
--- a/release/wix/navidrome.wxs
+++ b/release/wix/navidrome.wxs
@@ -69,6 +69,12 @@
+
+
+
+
+
+
@@ -81,6 +87,7 @@
+
From 73ec89e1afb762437df4ffef704330f789132620 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Wed, 12 Nov 2025 13:01:11 -0500
Subject: [PATCH 039/102] feat(ui): add SizeField to display total size in
LibraryList
Signed-off-by: Deluan
---
ui/src/library/LibraryList.jsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/ui/src/library/LibraryList.jsx b/ui/src/library/LibraryList.jsx
index c2d2f6295..932732b10 100644
--- a/ui/src/library/LibraryList.jsx
+++ b/ui/src/library/LibraryList.jsx
@@ -9,7 +9,7 @@ import {
BooleanField,
} from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
-import { List, DateField, useResourceRefresh } from '../common'
+import { List, DateField, useResourceRefresh, SizeField } from '../common'
const LibraryFilter = (props) => (
@@ -42,6 +42,7 @@ const LibraryList = (props) => {
+
Date: Wed, 12 Nov 2025 13:11:33 -0500
Subject: [PATCH 040/102] refactor(scanner): refactor legacyReleaseDate logic
and add tests for date mapping
Signed-off-by: Deluan
---
model/metadata/legacy_ids.go | 8 +-------
model/metadata/legacy_ids_test.go | 30 ----------------------------
model/metadata/map_mediafile_test.go | 17 ++++++++++++++++
3 files changed, 18 insertions(+), 37 deletions(-)
delete mode 100644 model/metadata/legacy_ids_test.go
diff --git a/model/metadata/legacy_ids.go b/model/metadata/legacy_ids.go
index 0a3bf0bf3..18a273550 100644
--- a/model/metadata/legacy_ids.go
+++ b/model/metadata/legacy_ids.go
@@ -23,7 +23,7 @@ func legacyTrackID(mf model.MediaFile, prependLibId bool) string {
}
func legacyAlbumID(mf model.MediaFile, md Metadata, prependLibId bool) string {
- releaseDate := legacyReleaseDate(md)
+ _, _, releaseDate := md.mapDates()
albumPath := strings.ToLower(fmt.Sprintf("%s\\%s", legacyMapAlbumArtistName(md), legacyMapAlbumName(md)))
if !conf.Server.Scanner.GroupAlbumReleases {
if len(releaseDate) != 0 {
@@ -55,9 +55,3 @@ func legacyMapAlbumName(md Metadata) string {
consts.UnknownAlbum,
)
}
-
-// Keep the TaggedLikePicard logic for backwards compatibility
-func legacyReleaseDate(md Metadata) string {
- _, _, releaseDate := md.mapDates()
- return string(releaseDate)
-}
diff --git a/model/metadata/legacy_ids_test.go b/model/metadata/legacy_ids_test.go
deleted file mode 100644
index b6d096763..000000000
--- a/model/metadata/legacy_ids_test.go
+++ /dev/null
@@ -1,30 +0,0 @@
-package metadata
-
-import (
- . "github.com/onsi/ginkgo/v2"
- . "github.com/onsi/gomega"
-)
-
-var _ = Describe("legacyReleaseDate", func() {
-
- DescribeTable("legacyReleaseDate",
- func(recordingDate, originalDate, releaseDate, expected string) {
- md := New("", Info{
- Tags: map[string][]string{
- "DATE": {recordingDate},
- "ORIGINALDATE": {originalDate},
- "RELEASEDATE": {releaseDate},
- },
- })
-
- result := legacyReleaseDate(md)
- Expect(result).To(Equal(expected))
- },
- Entry("regular mapping", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
- Entry("legacy mapping", "2020-05-15", "2019-02-10", "", "2020-05-15"),
- Entry("legacy mapping, originalYear < year", "2018-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
- Entry("legacy mapping, originalYear empty", "2020-05-15", "", "2021-01-01", "2021-01-01"),
- Entry("legacy mapping, releaseYear", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
- Entry("legacy mapping, same dates", "2020-05-15", "2020-05-15", "", "2020-05-15"),
- )
-})
diff --git a/model/metadata/map_mediafile_test.go b/model/metadata/map_mediafile_test.go
index ddda39bc2..e3adf3fae 100644
--- a/model/metadata/map_mediafile_test.go
+++ b/model/metadata/map_mediafile_test.go
@@ -75,6 +75,23 @@ var _ = Describe("ToMediaFile", func() {
Expect(mf.OriginalYear).To(Equal(1966))
Expect(mf.ReleaseYear).To(Equal(2014))
})
+ DescribeTable("legacyReleaseDate (TaggedLikePicard old behavior)",
+ func(recordingDate, originalDate, releaseDate, expected string) {
+ mf := toMediaFile(model.RawTags{
+ "DATE": {recordingDate},
+ "ORIGINALDATE": {originalDate},
+ "RELEASEDATE": {releaseDate},
+ })
+
+ Expect(mf.ReleaseDate).To(Equal(expected))
+ },
+ Entry("regular mapping", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
+ Entry("legacy mapping", "2020-05-15", "2019-02-10", "", "2020-05-15"),
+ Entry("legacy mapping, originalYear < year", "2018-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
+ Entry("legacy mapping, originalYear empty", "2020-05-15", "", "2021-01-01", "2021-01-01"),
+ Entry("legacy mapping, releaseYear", "2020-05-15", "2019-02-10", "2021-01-01", "2021-01-01"),
+ Entry("legacy mapping, same dates", "2020-05-15", "2020-05-15", "", "2020-05-15"),
+ )
})
Describe("Lyrics", func() {
From c3e8c67116ac71d7eb479cb5a3e8beff095ef80e Mon Sep 17 00:00:00 2001
From: Deluan
Date: Wed, 12 Nov 2025 13:23:18 -0500
Subject: [PATCH 041/102] feat(ui): update totalSize formatting to display two
decimal places
Signed-off-by: Deluan
---
ui/src/library/LibraryEdit.jsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/ui/src/library/LibraryEdit.jsx b/ui/src/library/LibraryEdit.jsx
index 3d981b076..7e89c892c 100644
--- a/ui/src/library/LibraryEdit.jsx
+++ b/ui/src/library/LibraryEdit.jsx
@@ -169,7 +169,7 @@ const LibraryEdit = (props) => {
resource={'library'}
source={'totalSize'}
label={translate('resources.library.fields.totalSize')}
- format={formatBytes}
+ format={(v) => formatBytes(v, 2)}
fullWidth
variant="outlined"
/>
From f939ad84f308692134206e4226d23bf401720635 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Wed, 12 Nov 2025 16:17:41 -0500
Subject: [PATCH 042/102] fix(ui): increase contrast of button text in the Dark
theme
Signed-off-by: Deluan
---
ui/src/themes/dark.js | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/ui/src/themes/dark.js b/ui/src/themes/dark.js
index 2f06b4337..15d8aa365 100644
--- a/ui/src/themes/dark.js
+++ b/ui/src/themes/dark.js
@@ -16,6 +16,11 @@ export default {
color: 'white',
},
},
+ MuiButton: {
+ textPrimary: {
+ color: '#fff',
+ },
+ },
NDLogin: {
systemNameLink: {
color: '#0085ff',
From 9b3bdc8a8b6c3cb11e96ff04c7c75f904ebc1da1 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Thu, 13 Nov 2025 18:05:00 -0500
Subject: [PATCH 043/102] fix(ui): adjust margins for bulk actions buttons in
Spotify-ish and Ligera
Signed-off-by: Deluan
---
ui/src/themes/ligera.js | 5 +++++
ui/src/themes/spotify.js | 5 +++++
2 files changed, 10 insertions(+)
diff --git a/ui/src/themes/ligera.js b/ui/src/themes/ligera.js
index 97dda93ab..363a379bc 100644
--- a/ui/src/themes/ligera.js
+++ b/ui/src/themes/ligera.js
@@ -448,6 +448,11 @@ export default {
backgroundColor: bLight['500'],
},
},
+ RaButton: {
+ button: {
+ margin: '0 5px 0 5px',
+ },
+ },
RaPaginationActions: {
button: {
backgroundColor: '#fff',
diff --git a/ui/src/themes/spotify.js b/ui/src/themes/spotify.js
index c40ed20aa..725831cc7 100644
--- a/ui/src/themes/spotify.js
+++ b/ui/src/themes/spotify.js
@@ -389,6 +389,11 @@ export default {
marginRight: '1rem',
},
},
+ RaButton: {
+ button: {
+ margin: '0 5px 0 5px',
+ },
+ },
RaPaginationActions: {
currentPageButton: {
border: '1px solid #b3b3b3',
From 2385c8a548f6d71e8b1acba503ae0161a9ddcc1e Mon Sep 17 00:00:00 2001
From: Deluan
Date: Thu, 13 Nov 2025 18:46:06 -0500
Subject: [PATCH 044/102] test: mock formatFullDate for consistent test results
---
ui/src/album/AlbumDetails.test.jsx | 18 ++++++++++++++++++
1 file changed, 18 insertions(+)
diff --git a/ui/src/album/AlbumDetails.test.jsx b/ui/src/album/AlbumDetails.test.jsx
index e03022677..484045444 100644
--- a/ui/src/album/AlbumDetails.test.jsx
+++ b/ui/src/album/AlbumDetails.test.jsx
@@ -14,6 +14,24 @@ vi.mock('@material-ui/core', async () => {
}
})
+// Mock formatFullDate to return deterministic results
+vi.mock('../utils', async () => {
+ const actual = await import('../utils')
+ return {
+ ...actual,
+ formatFullDate: (date) => {
+ if (!date) return ''
+ // Use en-CA locale for consistent test results
+ return new Date(date).toLocaleDateString('en-CA', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ timeZone: 'UTC',
+ })
+ },
+ }
+})
+
describe('Details component', () => {
describe('Desktop view', () => {
beforeEach(() => {
From a10f839221db06ee1dbec8585bd75d869ed46176 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Fri, 14 Nov 2025 12:19:10 -0500
Subject: [PATCH 045/102] fix(server): prefer cover.jpg over cover.1.jpg
(#4684)
* fix(reader): prioritize cover art selection by base filename without numeric suffixes
Signed-off-by: Deluan
* fix(reader): update image file comparison to use natural sorting and prioritize files without numeric suffixes
Signed-off-by: Deluan
* refactor(reader): simplify comparison, add case-sensitivity test case
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
---
core/artwork/reader_album.go | 28 ++++++++-
core/artwork/reader_album_test.go | 94 +++++++++++++++++++++++--------
go.mod | 1 +
go.sum | 4 +-
4 files changed, 98 insertions(+), 29 deletions(-)
diff --git a/core/artwork/reader_album.go b/core/artwork/reader_album.go
index 55d8b4352..cb4db97fe 100644
--- a/core/artwork/reader_album.go
+++ b/core/artwork/reader_album.go
@@ -1,6 +1,7 @@
package artwork
import (
+ "cmp"
"context"
"crypto/md5"
"fmt"
@@ -11,6 +12,7 @@ import (
"time"
"github.com/Masterminds/squirrel"
+ "github.com/maruel/natural"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/core/external"
@@ -116,8 +118,30 @@ func loadAlbumFoldersPaths(ctx context.Context, ds model.DataStore, albums ...mo
}
// Sort image files to ensure consistent selection of cover art
- // This prioritizes files from lower-numbered disc folders by sorting the paths
- slices.Sort(imgFiles)
+ // This prioritizes files without numeric suffixes (e.g., cover.jpg over cover.1.jpg)
+ // by comparing base filenames without extensions
+ slices.SortFunc(imgFiles, compareImageFiles)
return paths, imgFiles, &updatedAt, nil
}
+
+// 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.
+func compareImageFiles(a, b string) int {
+ // Case-insensitive comparison
+ a = strings.ToLower(a)
+ 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))
+
+ // Compare base names first, then full paths if equal
+ return cmp.Or(
+ natural.Compare(baseA, baseB),
+ natural.Compare(a, b),
+ )
+}
diff --git a/core/artwork/reader_album_test.go b/core/artwork/reader_album_test.go
index 2665632b9..fd5f8a2be 100644
--- a/core/artwork/reader_album_test.go
+++ b/core/artwork/reader_album_test.go
@@ -27,26 +27,7 @@ var _ = Describe("Album Artwork Reader", func() {
expectedAt = now.Add(5 * time.Minute)
// Set up the test folders with image files
- repo = &fakeFolderRepo{
- result: []model.Folder{
- {
- Path: "Artist/Album/Disc1",
- ImagesUpdatedAt: expectedAt,
- ImageFiles: []string{"cover.jpg", "back.jpg"},
- },
- {
- Path: "Artist/Album/Disc2",
- ImagesUpdatedAt: now,
- ImageFiles: []string{"cover.jpg"},
- },
- {
- Path: "Artist/Album/Disc10",
- ImagesUpdatedAt: now,
- ImageFiles: []string{"cover.jpg"},
- },
- },
- err: nil,
- }
+ repo = &fakeFolderRepo{}
ds = &fakeDataStore{
folderRepo: repo,
}
@@ -58,19 +39,82 @@ var _ = Describe("Album Artwork Reader", func() {
})
It("returns sorted image files", func() {
+ repo.result = []model.Folder{
+ {
+ Path: "Artist/Album/Disc1",
+ ImagesUpdatedAt: expectedAt,
+ ImageFiles: []string{"cover.jpg", "back.jpg", "cover.1.jpg"},
+ },
+ {
+ Path: "Artist/Album/Disc2",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.jpg"},
+ },
+ {
+ Path: "Artist/Album/Disc10",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.jpg"},
+ },
+ }
+
_, imgFiles, imagesUpdatedAt, err := loadAlbumFoldersPaths(ctx, ds, album)
Expect(err).ToNot(HaveOccurred())
Expect(*imagesUpdatedAt).To(Equal(expectedAt))
- // Check that image files are sorted alphabetically
- Expect(imgFiles).To(HaveLen(4))
+ // Check that image files are sorted by base name (without extension)
+ Expect(imgFiles).To(HaveLen(5))
- // The files should be sorted by full path
+ // 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/Disc10/cover.jpg")))
- Expect(imgFiles[3]).To(Equal(filepath.FromSlash("Artist/Album/Disc2/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")))
+ })
+
+ It("prioritizes files without numeric suffixes", func() {
+ // Test case for issue #4683: cover.jpg should come before cover.1.jpg
+ repo.result = []model.Folder{
+ {
+ Path: "Artist/Album",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"cover.1.jpg", "cover.jpg", "cover.2.jpg"},
+ },
+ }
+
+ _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).ToNot(HaveOccurred())
+ 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")))
+ })
+
+ It("handles case-insensitive sorting", func() {
+ // Test that Cover.jpg and cover.jpg are treated as equivalent
+ repo.result = []model.Folder{
+ {
+ Path: "Artist/Album",
+ ImagesUpdatedAt: now,
+ ImageFiles: []string{"Folder.jpg", "cover.jpg", "BACK.jpg"},
+ },
+ }
+
+ _, imgFiles, _, err := loadAlbumFoldersPaths(ctx, ds, album)
+
+ Expect(err).ToNot(HaveOccurred())
+ 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")))
})
})
})
diff --git a/go.mod b/go.mod
index dcc77d063..5a6a99070 100644
--- a/go.mod
+++ b/go.mod
@@ -39,6 +39,7 @@ require (
github.com/knqyf263/go-plugin v0.9.0
github.com/kr/pretty v0.3.1
github.com/lestrrat-go/jwx/v2 v2.1.6
+ github.com/maruel/natural v1.2.1
github.com/matoous/go-nanoid/v2 v2.1.0
github.com/mattn/go-sqlite3 v1.14.32
github.com/microcosm-cc/bluemonday v1.0.27
diff --git a/go.sum b/go.sum
index 10feea900..7cda0ce8d 100644
--- a/go.sum
+++ b/go.sum
@@ -162,8 +162,8 @@ github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVf
github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
-github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
-github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
+github.com/maruel/natural v1.2.1 h1:G/y4pwtTA07lbQsMefvsmEO0VN0NfqpxprxXDM4R/4o=
+github.com/maruel/natural v1.2.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
github.com/matoous/go-nanoid/v2 v2.1.0 h1:P64+dmq21hhWdtvZfEAofnvJULaRR1Yib0+PnU669bE=
github.com/matoous/go-nanoid/v2 v2.1.0/go.mod h1:KlbGNQ+FhrUNIHUxZdL63t7tl4LaPkZNpUULS8H4uVM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
From bca76069c314b21fbc8c6226514b622b851e5f3b Mon Sep 17 00:00:00 2001
From: Deluan
Date: Fri, 14 Nov 2025 13:15:50 -0500
Subject: [PATCH 046/102] fix(server): prioritize artist base image filenames
over numeric suffixes and add tests for sorting
Signed-off-by: Deluan
---
core/artwork/reader_artist.go | 14 +++++-
core/artwork/reader_artist_test.go | 73 ++++++++++++++++++++++++++----
2 files changed, 77 insertions(+), 10 deletions(-)
diff --git a/core/artwork/reader_artist.go b/core/artwork/reader_artist.go
index cb029a16e..da8141a2d 100644
--- a/core/artwork/reader_artist.go
+++ b/core/artwork/reader_artist.go
@@ -8,6 +8,7 @@ import (
"io/fs"
"os"
"path/filepath"
+ "slices"
"strings"
"time"
@@ -139,11 +140,22 @@ func findImageInFolder(ctx context.Context, folder, pattern string) (io.ReadClos
return nil, "", err
}
+ // Filter to valid image files
+ var imagePaths []string
for _, m := range matches {
if !model.IsImageFile(m) {
continue
}
- filePath := filepath.Join(folder, m)
+ imagePaths = append(imagePaths, m)
+ }
+
+ // Sort image files by prioritizing base filenames without numeric
+ // 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)
if err != nil {
log.Warn(ctx, "Could not open cover art file", "file", filePath, err)
diff --git a/core/artwork/reader_artist_test.go b/core/artwork/reader_artist_test.go
index 527b0849f..e6a0168f8 100644
--- a/core/artwork/reader_artist_test.go
+++ b/core/artwork/reader_artist_test.go
@@ -240,24 +240,79 @@ var _ = Describe("artistArtworkReader", func() {
Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
// Create multiple matching files
- Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.abc"), []byte("text file"), 0600)).To(Succeed())
Expect(os.WriteFile(filepath.Join(artistDir, "artist.png"), []byte("png image"), 0600)).To(Succeed())
- Expect(os.WriteFile(filepath.Join(artistDir, "artist.txt"), []byte("text file"), 0600)).To(Succeed())
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.jpg"), []byte("jpg image"), 0600)).To(Succeed())
testFunc = fromArtistFolder(ctx, artistDir, "artist.*")
})
- It("returns the first valid image file", func() {
+ It("returns the first valid image file in sorted order", func() {
reader, path, err := testFunc()
Expect(err).ToNot(HaveOccurred())
Expect(reader).ToNot(BeNil())
- // Should return an image file, not the text file
- Expect(path).To(SatisfyAny(
- ContainSubstring("artist.jpg"),
- ContainSubstring("artist.png"),
- ))
- Expect(path).ToNot(ContainSubstring("artist.txt"))
+ // Should return an image file,
+ // Files are sorted: jpg comes before png alphabetically.
+ // .abc comes first, but it's not an image.
+ Expect(path).To(ContainSubstring("artist.jpg"))
+ reader.Close()
+ })
+ })
+
+ When("prioritizing files without numeric suffixes", func() {
+ BeforeEach(func() {
+ // Test case for issue #4683: artist.jpg should come before artist.1.jpg
+ artistDir := filepath.Join(tempDir, "artist")
+ Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
+
+ // Create multiple matches with and without numeric suffixes
+ Expect(os.WriteFile(filepath.Join(artistDir, "artist.1.jpg"), []byte("artist 1"), 0600)).To(Succeed())
+ 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.*")
+ })
+
+ It("returns artist.jpg before artist.1.jpg and artist.2.jpg", func() {
+ reader, path, err := testFunc()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reader).ToNot(BeNil())
+ Expect(path).To(ContainSubstring("artist.jpg"))
+
+ // Verify it's the main file, not a numbered variant
+ data, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("artist main"))
+ reader.Close()
+ })
+ })
+
+ When("handling case-insensitive sorting", func() {
+ BeforeEach(func() {
+ // Test case to ensure case-insensitive natural sorting
+ artistDir := filepath.Join(tempDir, "artist")
+ Expect(os.MkdirAll(artistDir, 0755)).To(Succeed())
+
+ // Create files with mixed case names
+ Expect(os.WriteFile(filepath.Join(artistDir, "Folder.jpg"), []byte("folder"), 0600)).To(Succeed())
+ 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, "*.*")
+ })
+
+ It("sorts case-insensitively", func() {
+ reader, path, err := testFunc()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(reader).ToNot(BeNil())
+
+ // Should return artist.jpg first (case-insensitive: "artist" < "back" < "folder")
+ Expect(path).To(ContainSubstring("artist.jpg"))
+
+ data, err := io.ReadAll(reader)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(string(data)).To(Equal("artist"))
reader.Close()
})
})
From 28d5299ffc02498a63a8d1618a0d376631ef1f9b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Fri, 14 Nov 2025 22:15:43 -0500
Subject: [PATCH 047/102] feat(scanner): implement selective folder scanning
and file system watcher improvements (#4674)
* feat: Add selective folder scanning capability
Implement targeted scanning of specific library/folder pairs without
full recursion. This enables efficient rescanning of individual folders
when changes are detected, significantly reducing scan time for large
libraries.
Key changes:
- Add ScanTarget struct and ScanFolders API to Scanner interface
- Implement CLI flag --targets for specifying libraryID:folderPath pairs
- Add FolderRepository.GetByPaths() for batch folder info retrieval
- Create loadSpecificFolders() for non-recursive directory loading
- Scope GC operations to affected libraries only (with TODO for full impl)
- Add comprehensive tests for selective scanning behavior
The selective scan:
- Only processes specified folders (no subdirectory recursion)
- Maintains library isolation
- Runs full maintenance pipeline scoped to affected libraries
- Supports both full and quick scan modes
Examples:
navidrome scan --targets "1:Music/Rock,1:Music/Jazz"
navidrome scan --full --targets "2:Classical"
* feat(folder): replace GetByPaths with GetFolderUpdateInfo for improved folder updates retrieval
Signed-off-by: Deluan
* test: update parseTargets test to handle folder names with spaces
Signed-off-by: Deluan
* refactor(folder): remove unused LibraryPath struct and update GC logging message
Signed-off-by: Deluan
* refactor(folder): enhance external scanner to support target-specific scanning
Signed-off-by: Deluan
* refactor(scanner): simplify scanner methods
Signed-off-by: Deluan
* feat(watcher): implement folder scanning notifications with deduplication
Signed-off-by: Deluan
* refactor(watcher): add resolveFolderPath function for testability
Signed-off-by: Deluan
* feat(watcher): implement path ignoring based on .ndignore patterns
Signed-off-by: Deluan
* refactor(scanner): implement IgnoreChecker for managing .ndignore patterns
Signed-off-by: Deluan
* refactor(ignore_checker): rename scanner to lineScanner for clarity
Signed-off-by: Deluan
* refactor(scanner): enhance ScanTarget struct with String method for better target representation
Signed-off-by: Deluan
* fix(scanner): validate library ID to prevent negative values
Signed-off-by: Deluan
* refactor(scanner): simplify GC method by removing library ID parameter
Signed-off-by: Deluan
* feat(scanner): update folder scanning to include all descendants of specified folders
Signed-off-by: Deluan
* feat(subsonic): allow selective scan in the /startScan endpoint
Signed-off-by: Deluan
* refactor(scanner): update CallScan to handle specific library/folder pairs
Signed-off-by: Deluan
* refactor(scanner): streamline scanning logic by removing scanAll method
Signed-off-by: Deluan
* test: enhance mockScanner for thread safety and improve test reliability
Signed-off-by: Deluan
* refactor(scanner): move scanner.ScanTarget to model.ScanTarget
Signed-off-by: Deluan
* refactor: move scanner types to model,implement MockScanner
Signed-off-by: Deluan
* refactor(scanner): update scanner interface and implementations to use model.Scanner
Signed-off-by: Deluan
* refactor(folder_repository): normalize target path handling by using filepath.Clean
Signed-off-by: Deluan
* test(folder_repository): add comprehensive tests for folder retrieval and child exclusion
Signed-off-by: Deluan
* refactor(scanner): simplify selective scan logic using slice.Filter
Signed-off-by: Deluan
* refactor(scanner): streamline phase folder and album creation by removing unnecessary library parameter
Signed-off-by: Deluan
* refactor(scanner): move initialization logic from phase_1 to the scanner itself
Signed-off-by: Deluan
* refactor(tests): rename selective scan test file to scanner_selective_test.go
Signed-off-by: Deluan
* feat(configuration): add DevSelectiveWatcher configuration option
Signed-off-by: Deluan
* feat(watcher): enhance .ndignore handling for folder deletions and file changes
Signed-off-by: Deluan
* docs(scanner): comments
Signed-off-by: Deluan
* refactor(scanner): enhance walkDirTree to support target folder scanning
Signed-off-by: Deluan
* fix(scanner, watcher): handle errors when pushing ignore patterns for folders
Signed-off-by: Deluan
* Update scanner/phase_1_folders.go
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
* refactor(scanner): replace parseTargets function with direct call to scanner.ParseTargets
Signed-off-by: Deluan
* test(scanner): add tests for ScanBegin and ScanEnd functionality
Signed-off-by: Deluan
* fix(library): update PRAGMA optimize to check table sizes without ANALYZE
Signed-off-by: Deluan
* test(scanner): refactor tests
Signed-off-by: Deluan
* feat(ui): add selective scan options and update translations
Signed-off-by: Deluan
* feat(ui): add quick and full scan options for individual libraries
Signed-off-by: Deluan
* feat(ui): add Scan buttonsto the LibraryList
Signed-off-by: Deluan
* feat(scan): update scanning parameters from 'path' to 'target' for selective scans.
* refactor(scan): move ParseTargets function to model package
* test(scan): suppress unused return value from SetUserLibraries in tests
* feat(gc): enhance garbage collection to support selective library purging
Signed-off-by: Deluan
* fix(scanner): prevent race condition when scanning deleted folders
When the watcher detects changes in a folder that gets deleted before
the scanner runs (due to the 10-second delay), the scanner was
prematurely removing these folders from the tracking map, preventing
them from being marked as missing.
The issue occurred because `newFolderEntry` was calling `popLastUpdate`
before verifying the folder actually exists on the filesystem.
Changes:
- Move fs.Stat check before newFolderEntry creation in loadDir to
ensure deleted folders remain in lastUpdates for finalize() to handle
- Add early existence check in walkDirTree to skip non-existent target
folders with a warning log
- Add unit test verifying non-existent folders aren't removed from
lastUpdates prematurely
- Add integration test for deleted folder scenario with ScanFolders
Fixes the issue where deleting entire folders (e.g., /music/AC_DC)
wouldn't mark tracks as missing when using selective folder scanning.
* refactor(scan): streamline folder entry creation and update handling
Signed-off-by: Deluan
* feat(scan): add '@Recycle' (QNAP) to ignored directories list
Signed-off-by: Deluan
* fix(log): improve thread safety in logging level management
* test(scan): move unit tests for ParseTargets function
Signed-off-by: Deluan
* review
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: deluan
---
cmd/scan.go | 17 +-
cmd/wire_gen.go | 22 +-
cmd/wire_injectors.go | 3 +-
conf/configuration.go | 2 +
core/library.go | 13 +-
core/library_test.go | 52 +--
core/maintenance_test.go | 30 +-
log/log.go | 17 +-
model/datastore.go | 2 +-
model/folder.go | 2 +-
model/scanner.go | 81 ++++
model/scanner_test.go | 89 ++++
persistence/album_repository.go | 6 +-
persistence/folder_repository.go | 52 ++-
persistence/folder_repository_test.go | 213 ++++++++++
persistence/library_repository.go | 4 +-
persistence/library_repository_test.go | 58 +++
persistence/persistence.go | 12 +-
resources/i18n/pt-br.json | 12 +-
scanner/controller.go | 42 +-
scanner/controller_test.go | 3 +-
scanner/external.go | 34 +-
scanner/folder_entry.go | 8 +-
scanner/folder_entry_test.go | 25 +-
scanner/ignore_checker.go | 163 +++++++
scanner/ignore_checker_test.go | 313 ++++++++++++++
scanner/phase_1_folders.go | 78 ++--
scanner/phase_2_missing_tracks.go | 3 -
scanner/phase_3_refresh_albums.go | 7 +-
scanner/phase_3_refresh_albums_test.go | 4 +-
scanner/scanner.go | 121 +++++-
scanner/scanner_multilibrary_test.go | 2 +-
scanner/scanner_selective_test.go | 293 +++++++++++++
scanner/scanner_test.go | 66 ++-
scanner/walk_dir_tree.go | 114 +++--
scanner/walk_dir_tree_test.go | 244 ++++++++---
scanner/watcher.go | 121 +++++-
scanner/watcher_test.go | 491 ++++++++++++++++++++++
server/subsonic/api.go | 5 +-
server/subsonic/library_scanning.go | 50 ++-
server/subsonic/library_scanning_test.go | 396 +++++++++++++++++
tests/mock_data_store.go | 10 +-
tests/mock_scanner.go | 120 ++++++
ui/src/i18n/en.json | 12 +-
ui/src/layout/ActivityPanel.jsx | 3 +
ui/src/library/LibraryList.jsx | 5 +-
ui/src/library/LibraryListActions.jsx | 30 ++
ui/src/library/LibraryListBulkActions.jsx | 11 +
ui/src/library/LibraryScanButton.jsx | 77 ++++
ui/src/subsonic/index.js | 8 +-
utils/slice/slice.go | 11 +
utils/slice/slice_test.go | 38 ++
52 files changed, 3221 insertions(+), 374 deletions(-)
create mode 100644 model/scanner.go
create mode 100644 model/scanner_test.go
create mode 100644 persistence/folder_repository_test.go
create mode 100644 scanner/ignore_checker.go
create mode 100644 scanner/ignore_checker_test.go
create mode 100644 scanner/scanner_selective_test.go
create mode 100644 scanner/watcher_test.go
create mode 100644 server/subsonic/library_scanning_test.go
create mode 100644 tests/mock_scanner.go
create mode 100644 ui/src/library/LibraryListActions.jsx
create mode 100644 ui/src/library/LibraryListBulkActions.jsx
create mode 100644 ui/src/library/LibraryScanButton.jsx
diff --git a/cmd/scan.go b/cmd/scan.go
index d37ccd69f..41d281070 100644
--- a/cmd/scan.go
+++ b/cmd/scan.go
@@ -4,10 +4,12 @@ import (
"context"
"encoding/gob"
"os"
+ "strings"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/utils/pl"
@@ -17,11 +19,13 @@ import (
var (
fullScan bool
subprocess bool
+ targets string
)
func init() {
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
+ scanCmd.Flags().StringVarP(&targets, "targets", "t", "", "comma-separated list of libraryID:folderPath pairs (e.g., \"1:Music/Rock,1:Music/Jazz,2:Classical\")")
rootCmd.AddCommand(scanCmd)
}
@@ -68,7 +72,18 @@ func runScanner(ctx context.Context) {
ds := persistence.New(sqlDB)
pls := core.NewPlaylists(ds)
- progress, err := scanner.CallScan(ctx, ds, pls, fullScan)
+ // Parse targets if provided
+ var scanTargets []model.ScanTarget
+ if targets != "" {
+ var err error
+ scanTargets, err = model.ParseTargets(strings.Split(targets, ","))
+ if err != nil {
+ log.Fatal(ctx, "Failed to parse targets", err)
+ }
+ log.Info(ctx, "Scanning specific folders", "numTargets", len(scanTargets))
+ }
+
+ progress, err := scanner.CallScan(ctx, ds, pls, fullScan, scanTargets)
if err != nil {
log.Fatal(ctx, "Failed to scan", err)
}
diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go
index bf13dc731..d7b6a3ad2 100644
--- a/cmd/wire_gen.go
+++ b/cmd/wire_gen.go
@@ -69,9 +69,9 @@ func CreateNativeAPIRouter(ctx context.Context) *nativeapi.Router {
artworkArtwork := artwork.NewArtwork(dataStore, fileCache, fFmpeg, provider)
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
- scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
- watcher := scanner.GetWatcher(dataStore, scannerScanner)
- library := core.NewLibrary(dataStore, scannerScanner, watcher, broker)
+ modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
+ watcher := scanner.GetWatcher(dataStore, modelScanner)
+ library := core.NewLibrary(dataStore, modelScanner, watcher, broker)
maintenance := core.NewMaintenance(dataStore)
router := nativeapi.New(dataStore, share, playlists, insights, library, maintenance)
return router
@@ -95,10 +95,10 @@ func CreateSubsonicAPIRouter(ctx context.Context) *subsonic.Router {
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore)
- scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
+ modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
playTracker := scrobbler.GetPlayTracker(dataStore, broker, manager)
playbackServer := playback.GetInstance(dataStore)
- router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, scannerScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
+ router := subsonic.New(dataStore, artworkArtwork, mediaStreamer, archiver, players, provider, modelScanner, broker, playlists, playTracker, share, playbackServer, metricsMetrics)
return router
}
@@ -150,7 +150,7 @@ func CreatePrometheus() metrics.Metrics {
return metricsMetrics
}
-func CreateScanner(ctx context.Context) scanner.Scanner {
+func CreateScanner(ctx context.Context) model.Scanner {
sqlDB := db.Db()
dataStore := persistence.New(sqlDB)
fileCache := artwork.GetImageCache()
@@ -163,8 +163,8 @@ func CreateScanner(ctx context.Context) scanner.Scanner {
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore)
- scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
- return scannerScanner
+ modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
+ return modelScanner
}
func CreateScanWatcher(ctx context.Context) scanner.Watcher {
@@ -180,8 +180,8 @@ func CreateScanWatcher(ctx context.Context) scanner.Watcher {
cacheWarmer := artwork.NewCacheWarmer(artworkArtwork, fileCache)
broker := events.GetBroker()
playlists := core.NewPlaylists(dataStore)
- scannerScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
- watcher := scanner.GetWatcher(dataStore, scannerScanner)
+ modelScanner := scanner.New(ctx, dataStore, cacheWarmer, broker, playlists, metricsMetrics)
+ watcher := scanner.GetWatcher(dataStore, modelScanner)
return watcher
}
@@ -202,7 +202,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, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), wire.Bind(new(core.Scanner), new(scanner.Scanner)), 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, plugins.GetManager, metrics.GetPrometheusInstance, db.Db, wire.Bind(new(agents.PluginLoader), new(plugins.Manager)), wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)), wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)), 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 e8759ac53..595d406b9 100644
--- a/cmd/wire_injectors.go
+++ b/cmd/wire_injectors.go
@@ -45,7 +45,6 @@ var allProviders = wire.NewSet(
wire.Bind(new(agents.PluginLoader), new(plugins.Manager)),
wire.Bind(new(scrobbler.PluginLoader), new(plugins.Manager)),
wire.Bind(new(metrics.PluginLoader), new(plugins.Manager)),
- wire.Bind(new(core.Scanner), new(scanner.Scanner)),
wire.Bind(new(core.Watcher), new(scanner.Watcher)),
)
@@ -103,7 +102,7 @@ func CreatePrometheus() metrics.Metrics {
))
}
-func CreateScanner(ctx context.Context) scanner.Scanner {
+func CreateScanner(ctx context.Context) model.Scanner {
panic(wire.Build(
allProviders,
))
diff --git a/conf/configuration.go b/conf/configuration.go
index 7292c7dfe..a9fee00e4 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -125,6 +125,7 @@ type configOptions struct {
DevAlbumInfoTimeToLive time.Duration
DevExternalScanner bool
DevScannerThreads uint
+ DevSelectiveWatcher bool
DevInsightsInitialDelay time.Duration
DevEnablePlayerInsights bool
DevEnablePluginsInsights bool
@@ -600,6 +601,7 @@ func setViperDefaults() {
viper.SetDefault("devalbuminfotimetolive", consts.AlbumInfoTimeToLive)
viper.SetDefault("devexternalscanner", true)
viper.SetDefault("devscannerthreads", 5)
+ viper.SetDefault("devselectivewatcher", true)
viper.SetDefault("devinsightsinitialdelay", consts.InsightsInitialDelay)
viper.SetDefault("devenableplayerinsights", true)
viper.SetDefault("devenablepluginsinsights", true)
diff --git a/core/library.go b/core/library.go
index 7abd35c8f..f4f55ec5a 100644
--- a/core/library.go
+++ b/core/library.go
@@ -21,11 +21,6 @@ import (
"github.com/navidrome/navidrome/utils/slice"
)
-// Scanner interface for triggering scans
-type Scanner interface {
- ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
-}
-
// Watcher interface for managing file system watchers
type Watcher interface {
Watch(ctx context.Context, lib *model.Library) error
@@ -43,13 +38,13 @@ type Library interface {
type libraryService struct {
ds model.DataStore
- scanner Scanner
+ scanner model.Scanner
watcher Watcher
broker events.Broker
}
// NewLibrary creates a new Library service
-func NewLibrary(ds model.DataStore, scanner Scanner, watcher Watcher, broker events.Broker) Library {
+func NewLibrary(ds model.DataStore, scanner model.Scanner, watcher Watcher, broker events.Broker) Library {
return &libraryService{
ds: ds,
scanner: scanner,
@@ -155,7 +150,7 @@ type libraryRepositoryWrapper struct {
model.LibraryRepository
ctx context.Context
ds model.DataStore
- scanner Scanner
+ scanner model.Scanner
watcher Watcher
broker events.Broker
}
@@ -192,7 +187,7 @@ func (r *libraryRepositoryWrapper) Save(entity interface{}) (string, error) {
return strconv.Itoa(lib.ID), nil
}
-func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, cols ...string) error {
+func (r *libraryRepositoryWrapper) Update(id string, entity interface{}, _ ...string) error {
lib := entity.(*model.Library)
libID, err := strconv.Atoi(id)
if err != nil {
diff --git a/core/library_test.go b/core/library_test.go
index bfbb4300a..bf73a62b7 100644
--- a/core/library_test.go
+++ b/core/library_test.go
@@ -29,7 +29,7 @@ var _ = Describe("Library Service", func() {
var userRepo *tests.MockedUserRepo
var ctx context.Context
var tempDir string
- var scanner *mockScanner
+ var scanner *tests.MockScanner
var watcherManager *mockWatcherManager
var broker *mockEventBroker
@@ -43,7 +43,7 @@ var _ = Describe("Library Service", func() {
ds.MockedUser = userRepo
// Create a mock scanner that tracks calls
- scanner = &mockScanner{}
+ scanner = tests.NewMockScanner()
// Create a mock watcher manager
watcherManager = &mockWatcherManager{
libraryStates: make(map[int]model.Library),
@@ -616,11 +616,12 @@ var _ = Describe("Library Service", func() {
// Wait briefly for the goroutine to complete
Eventually(func() int {
- return scanner.len()
+ return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
- Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
+ calls := scanner.GetScanAllCalls()
+ Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
})
It("triggers scan when updating library path", func() {
@@ -641,11 +642,12 @@ var _ = Describe("Library Service", func() {
// Wait briefly for the goroutine to complete
Eventually(func() int {
- return scanner.len()
+ return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
- Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
+ calls := scanner.GetScanAllCalls()
+ Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
})
It("does not trigger scan when updating library without path change", func() {
@@ -661,7 +663,7 @@ var _ = Describe("Library Service", func() {
// Wait a bit to ensure no scan was triggered
Consistently(func() int {
- return scanner.len()
+ return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
@@ -674,7 +676,7 @@ var _ = Describe("Library Service", func() {
// Ensure no scan was triggered since creation failed
Consistently(func() int {
- return scanner.len()
+ return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
@@ -691,7 +693,7 @@ var _ = Describe("Library Service", func() {
// Ensure no scan was triggered since update failed
Consistently(func() int {
- return scanner.len()
+ return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
@@ -707,11 +709,12 @@ var _ = Describe("Library Service", func() {
// Wait briefly for the goroutine to complete
Eventually(func() int {
- return scanner.len()
+ return scanner.GetScanAllCallCount()
}, "1s", "10ms").Should(Equal(1))
// Verify scan was called with correct parameters
- Expect(scanner.ScanCalls[0].FullScan).To(BeFalse()) // Should be quick scan
+ calls := scanner.GetScanAllCalls()
+ Expect(calls[0].FullScan).To(BeFalse()) // Should be quick scan
})
It("does not trigger scan when library deletion fails", func() {
@@ -721,7 +724,7 @@ var _ = Describe("Library Service", func() {
// Ensure no scan was triggered since deletion failed
Consistently(func() int {
- return scanner.len()
+ return scanner.GetScanAllCallCount()
}, "100ms", "10ms").Should(Equal(0))
})
@@ -868,31 +871,6 @@ var _ = Describe("Library Service", func() {
})
})
-// mockScanner provides a simple mock implementation of core.Scanner for testing
-type mockScanner struct {
- ScanCalls []ScanCall
- mu sync.RWMutex
-}
-
-type ScanCall struct {
- FullScan bool
-}
-
-func (m *mockScanner) ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error) {
- m.mu.Lock()
- defer m.mu.Unlock()
- m.ScanCalls = append(m.ScanCalls, ScanCall{
- FullScan: fullScan,
- })
- return []string{}, nil
-}
-
-func (m *mockScanner) len() int {
- m.mu.RLock()
- defer m.mu.RUnlock()
- return len(m.ScanCalls)
-}
-
// mockWatcherManager provides a simple mock implementation of core.Watcher for testing
type mockWatcherManager struct {
StartedWatchers []model.Library
diff --git a/core/maintenance_test.go b/core/maintenance_test.go
index 8e8796ffa..09b442438 100644
--- a/core/maintenance_test.go
+++ b/core/maintenance_test.go
@@ -14,7 +14,7 @@ import (
)
var _ = Describe("Maintenance", func() {
- var ds *extendedDataStore
+ var ds *tests.MockDataStore
var mfRepo *extendedMediaFileRepo
var service Maintenance
var ctx context.Context
@@ -42,7 +42,7 @@ var _ = Describe("Maintenance", func() {
Expect(err).ToNot(HaveOccurred())
Expect(mfRepo.deleteMissingCalled).To(BeTrue())
Expect(mfRepo.deletedIDs).To(Equal([]string{"mf1", "mf2"}))
- Expect(ds.gcCalled).To(BeTrue(), "GC should be called after deletion")
+ Expect(ds.GCCalled).To(BeTrue(), "GC should be called after deletion")
})
It("triggers artist stats refresh and album refresh after deletion", func() {
@@ -97,7 +97,7 @@ var _ = Describe("Maintenance", func() {
})
// Set GC to return error
- ds.gcError = errors.New("gc failed")
+ ds.GCError = errors.New("gc failed")
err := service.DeleteMissingFiles(ctx, []string{"mf1"})
@@ -143,7 +143,7 @@ var _ = Describe("Maintenance", func() {
err := service.DeleteAllMissingFiles(ctx)
Expect(err).ToNot(HaveOccurred())
- Expect(ds.gcCalled).To(BeTrue(), "GC should be called after deletion")
+ Expect(ds.GCCalled).To(BeTrue(), "GC should be called after deletion")
})
It("returns error if deletion fails", func() {
@@ -253,11 +253,8 @@ var _ = Describe("Maintenance", func() {
})
// Test helper to create a mock DataStore with controllable behavior
-func createTestDataStore() *extendedDataStore {
- // Create extended datastore with GC tracking
- ds := &extendedDataStore{
- MockDataStore: &tests.MockDataStore{},
- }
+func createTestDataStore() *tests.MockDataStore {
+ ds := &tests.MockDataStore{}
// Create extended album repo with Put tracking
albumRepo := &extendedAlbumRepo{
@@ -365,18 +362,3 @@ func (m *extendedArtistRepo) IsRefreshStatsCalled() bool {
defer m.mu.RUnlock()
return m.refreshStatsCalled
}
-
-// Extension of MockDataStore to track GC calls
-type extendedDataStore struct {
- *tests.MockDataStore
- gcCalled bool
- gcError error
-}
-
-func (ds *extendedDataStore) GC(ctx context.Context) error {
- ds.gcCalled = true
- if ds.gcError != nil {
- return ds.gcError
- }
- return ds.MockDataStore.GC(ctx)
-}
diff --git a/log/log.go b/log/log.go
index ea34e5dcb..801fd7214 100644
--- a/log/log.go
+++ b/log/log.go
@@ -80,8 +80,8 @@ var (
// SetLevel sets the global log level used by the simple logger.
func SetLevel(l Level) {
- currentLevel = l
loggerMu.Lock()
+ currentLevel = l
defaultLogger.Level = logrus.TraceLevel
loggerMu.Unlock()
logrus.SetLevel(logrus.Level(l))
@@ -114,6 +114,8 @@ func levelFromString(l string) Level {
// SetLogLevels sets the log levels for specific paths in the codebase.
func SetLogLevels(levels map[string]string) {
+ loggerMu.Lock()
+ defer loggerMu.Unlock()
logLevels = nil
for k, v := range levels {
logLevels = append(logLevels, levelPath{path: k, level: levelFromString(v)})
@@ -172,6 +174,8 @@ func SetDefaultLogger(l *logrus.Logger) {
}
func CurrentLevel() Level {
+ loggerMu.RLock()
+ defer loggerMu.RUnlock()
return currentLevel
}
@@ -220,10 +224,15 @@ func Writer() io.Writer {
}
func shouldLog(requiredLevel Level, skip int) bool {
- if currentLevel >= requiredLevel {
+ loggerMu.RLock()
+ level := currentLevel
+ levels := logLevels
+ loggerMu.RUnlock()
+
+ if level >= requiredLevel {
return true
}
- if len(logLevels) == 0 {
+ if len(levels) == 0 {
return false
}
@@ -233,7 +242,7 @@ func shouldLog(requiredLevel Level, skip int) bool {
}
file = strings.TrimPrefix(file, rootPath)
- for _, lp := range logLevels {
+ for _, lp := range levels {
if strings.HasPrefix(file, lp.path) {
return lp.level >= requiredLevel
}
diff --git a/model/datastore.go b/model/datastore.go
index 4290e2134..536a37274 100644
--- a/model/datastore.go
+++ b/model/datastore.go
@@ -43,5 +43,5 @@ type DataStore interface {
WithTx(block func(tx DataStore) error, scope ...string) error
WithTxImmediate(block func(tx DataStore) error, scope ...string) error
- GC(ctx context.Context) error
+ GC(ctx context.Context, libraryIDs ...int) error
}
diff --git a/model/folder.go b/model/folder.go
index f715f8c11..7a769735e 100644
--- a/model/folder.go
+++ b/model/folder.go
@@ -85,7 +85,7 @@ type FolderRepository interface {
GetByPath(lib Library, path string) (*Folder, error)
GetAll(...QueryOptions) ([]Folder, error)
CountAll(...QueryOptions) (int64, error)
- GetLastUpdates(lib Library) (map[string]FolderUpdateInfo, error)
+ GetFolderUpdateInfo(lib Library, targetPaths ...string) (map[string]FolderUpdateInfo, error)
Put(*Folder) error
MarkMissing(missing bool, ids ...string) error
GetTouchedWithPlaylists() (FolderCursor, error)
diff --git a/model/scanner.go b/model/scanner.go
new file mode 100644
index 000000000..389c77f87
--- /dev/null
+++ b/model/scanner.go
@@ -0,0 +1,81 @@
+package model
+
+import (
+ "context"
+ "fmt"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// ScanTarget represents a specific folder within a library to be scanned.
+// NOTE: This struct is used as a map key, so it should only contain comparable types.
+type ScanTarget struct {
+ LibraryID int
+ FolderPath string // Relative path within the library, or "" for entire library
+}
+
+func (st ScanTarget) String() string {
+ return fmt.Sprintf("%d:%s", st.LibraryID, st.FolderPath)
+}
+
+// ScannerStatus holds information about the current scan status
+type ScannerStatus struct {
+ Scanning bool
+ LastScan time.Time
+ Count uint32
+ FolderCount uint32
+ LastError string
+ ScanType string
+ ElapsedTime time.Duration
+}
+
+type Scanner interface {
+ // ScanAll starts a scan of all libraries. This is a blocking operation.
+ ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
+ // ScanFolders scans specific library/folder pairs, recursing into subdirectories.
+ // If targets is nil, it scans all libraries. This is a blocking operation.
+ ScanFolders(ctx context.Context, fullScan bool, targets []ScanTarget) (warnings []string, err error)
+ Status(context.Context) (*ScannerStatus, error)
+}
+
+// ParseTargets parses scan targets strings into ScanTarget structs.
+// Example: []string{"1:Music/Rock", "2:Classical"}
+func ParseTargets(libFolders []string) ([]ScanTarget, error) {
+ targets := make([]ScanTarget, 0, len(libFolders))
+
+ for _, part := range libFolders {
+ part = strings.TrimSpace(part)
+ if part == "" {
+ continue
+ }
+
+ // Split by the first colon
+ colonIdx := strings.Index(part, ":")
+ if colonIdx == -1 {
+ return nil, fmt.Errorf("invalid target format: %q (expected libraryID:folderPath)", part)
+ }
+
+ libIDStr := part[:colonIdx]
+ folderPath := part[colonIdx+1:]
+
+ libID, err := strconv.Atoi(libIDStr)
+ if err != nil {
+ return nil, fmt.Errorf("invalid library ID %q: %w", libIDStr, err)
+ }
+ if libID <= 0 {
+ return nil, fmt.Errorf("invalid library ID %q", libIDStr)
+ }
+
+ targets = append(targets, ScanTarget{
+ LibraryID: libID,
+ FolderPath: folderPath,
+ })
+ }
+
+ if len(targets) == 0 {
+ return nil, fmt.Errorf("no valid targets found")
+ }
+
+ return targets, nil
+}
diff --git a/model/scanner_test.go b/model/scanner_test.go
new file mode 100644
index 000000000..8ca0c53fa
--- /dev/null
+++ b/model/scanner_test.go
@@ -0,0 +1,89 @@
+package model_test
+
+import (
+ "github.com/navidrome/navidrome/model"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("ParseTargets", func() {
+ It("parses multiple entries in slice", func() {
+ targets, err := model.ParseTargets([]string{"1:Music/Rock", "1:Music/Jazz", "2:Classical"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(3))
+ Expect(targets[0].LibraryID).To(Equal(1))
+ Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
+ Expect(targets[1].LibraryID).To(Equal(1))
+ Expect(targets[1].FolderPath).To(Equal("Music/Jazz"))
+ Expect(targets[2].LibraryID).To(Equal(2))
+ Expect(targets[2].FolderPath).To(Equal("Classical"))
+ })
+
+ It("handles empty folder paths", func() {
+ targets, err := model.ParseTargets([]string{"1:", "2:"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(2))
+ Expect(targets[0].FolderPath).To(Equal(""))
+ Expect(targets[1].FolderPath).To(Equal(""))
+ })
+
+ It("trims whitespace from entries", func() {
+ targets, err := model.ParseTargets([]string{" 1:Music/Rock", " 2:Classical "})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(2))
+ Expect(targets[0].LibraryID).To(Equal(1))
+ Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
+ Expect(targets[1].LibraryID).To(Equal(2))
+ Expect(targets[1].FolderPath).To(Equal("Classical"))
+ })
+
+ It("skips empty strings", func() {
+ targets, err := model.ParseTargets([]string{"1:Music/Rock", "", "2:Classical"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(2))
+ })
+
+ It("handles paths with colons", func() {
+ targets, err := model.ParseTargets([]string{"1:C:/Music/Rock", "2:/path:with:colons"})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(targets).To(HaveLen(2))
+ Expect(targets[0].FolderPath).To(Equal("C:/Music/Rock"))
+ Expect(targets[1].FolderPath).To(Equal("/path:with:colons"))
+ })
+
+ It("returns error for invalid format without colon", func() {
+ _, err := model.ParseTargets([]string{"1Music/Rock"})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("invalid target format"))
+ })
+
+ It("returns error for non-numeric library ID", func() {
+ _, err := model.ParseTargets([]string{"abc:Music/Rock"})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("invalid library ID"))
+ })
+
+ It("returns error for negative library ID", func() {
+ _, err := model.ParseTargets([]string{"-1:Music/Rock"})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("invalid library ID"))
+ })
+
+ It("returns error for zero library ID", func() {
+ _, err := model.ParseTargets([]string{"0:Music/Rock"})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("invalid library ID"))
+ })
+
+ It("returns error for empty input", func() {
+ _, err := model.ParseTargets([]string{})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("no valid targets found"))
+ })
+
+ It("returns error for all empty strings", func() {
+ _, err := model.ParseTargets([]string{"", " ", ""})
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("no valid targets found"))
+ })
+})
diff --git a/persistence/album_repository.go b/persistence/album_repository.go
index 6f9bb3b48..b1ce23e2b 100644
--- a/persistence/album_repository.go
+++ b/persistence/album_repository.go
@@ -337,8 +337,12 @@ on conflict (user_id, item_id, item_type) do update
return r.executeSQL(query)
}
-func (r *albumRepository) purgeEmpty() error {
+func (r *albumRepository) purgeEmpty(libraryIDs ...int) error {
del := Delete(r.tableName).Where("id not in (select distinct(album_id) from media_file)")
+ // If libraryIDs are specified, only purge albums from those libraries
+ if len(libraryIDs) > 0 {
+ del = del.Where(Eq{"library_id": libraryIDs})
+ }
c, err := r.executeSQL(del)
if err != nil {
return fmt.Errorf("purging empty albums: %w", err)
diff --git a/persistence/folder_repository.go b/persistence/folder_repository.go
index 96a9bae82..a586746a0 100644
--- a/persistence/folder_repository.go
+++ b/persistence/folder_repository.go
@@ -4,7 +4,10 @@ import (
"context"
"encoding/json"
"fmt"
+ "os"
+ "path/filepath"
"slices"
+ "strings"
"time"
. "github.com/Masterminds/squirrel"
@@ -91,8 +94,47 @@ func (r folderRepository) CountAll(opt ...model.QueryOptions) (int64, error) {
return r.count(query)
}
-func (r folderRepository) GetLastUpdates(lib model.Library) (map[string]model.FolderUpdateInfo, error) {
- sq := r.newSelect().Columns("id", "updated_at", "hash").Where(Eq{"library_id": lib.ID, "missing": false})
+func (r folderRepository) GetFolderUpdateInfo(lib model.Library, targetPaths ...string) (map[string]model.FolderUpdateInfo, error) {
+ where := And{
+ Eq{"library_id": lib.ID},
+ Eq{"missing": false},
+ }
+
+ // If specific paths are requested, include those folders and all their descendants
+ if len(targetPaths) > 0 {
+ // Collect folder IDs for exact target folders and path conditions for descendants
+ folderIDs := make([]string, 0, len(targetPaths))
+ pathConditions := make(Or, 0, len(targetPaths)*2)
+
+ for _, targetPath := range targetPaths {
+ if targetPath == "" || targetPath == "." {
+ // Root path - include everything in this library
+ pathConditions = Or{}
+ folderIDs = nil
+ break
+ }
+ // Clean the path to normalize it. Paths stored in the folder table do not have leading/trailing slashes.
+ cleanPath := strings.TrimPrefix(targetPath, string(os.PathSeparator))
+ cleanPath = filepath.Clean(cleanPath)
+
+ // Include the target folder itself by ID
+ folderIDs = append(folderIDs, model.FolderID(lib, cleanPath))
+
+ // Include all descendants: folders whose path field equals or starts with the target path
+ // Note: Folder.Path is the directory path, so children have path = targetPath
+ pathConditions = append(pathConditions, Eq{"path": cleanPath})
+ pathConditions = append(pathConditions, Like{"path": cleanPath + "/%"})
+ }
+
+ // Combine conditions: exact folder IDs OR descendant path patterns
+ if len(folderIDs) > 0 {
+ where = append(where, Or{Eq{"id": folderIDs}, pathConditions})
+ } else if len(pathConditions) > 0 {
+ where = append(where, pathConditions)
+ }
+ }
+
+ sq := r.newSelect().Columns("id", "updated_at", "hash").Where(where)
var res []struct {
ID string
UpdatedAt time.Time
@@ -149,7 +191,7 @@ func (r folderRepository) GetTouchedWithPlaylists() (model.FolderCursor, error)
}, nil
}
-func (r folderRepository) purgeEmpty() error {
+func (r folderRepository) purgeEmpty(libraryIDs ...int) error {
sq := Delete(r.tableName).Where(And{
Eq{"num_audio_files": 0},
Eq{"num_playlists": 0},
@@ -157,6 +199,10 @@ func (r folderRepository) purgeEmpty() error {
ConcatExpr("id not in (select parent_id from folder)"),
ConcatExpr("id not in (select folder_id from media_file)"),
})
+ // If libraryIDs are specified, only purge folders from those libraries
+ if len(libraryIDs) > 0 {
+ sq = sq.Where(Eq{"library_id": libraryIDs})
+ }
c, err := r.executeSQL(sq)
if err != nil {
return fmt.Errorf("purging empty folders: %w", err)
diff --git a/persistence/folder_repository_test.go b/persistence/folder_repository_test.go
new file mode 100644
index 000000000..6c24741c9
--- /dev/null
+++ b/persistence/folder_repository_test.go
@@ -0,0 +1,213 @@
+package persistence
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "github.com/pocketbase/dbx"
+)
+
+var _ = Describe("FolderRepository", func() {
+ var repo model.FolderRepository
+ var ctx context.Context
+ var conn *dbx.DB
+ var testLib, otherLib model.Library
+
+ BeforeEach(func() {
+ ctx = request.WithUser(log.NewContext(context.TODO()), model.User{ID: "userid"})
+ conn = GetDBXBuilder()
+ repo = newFolderRepository(ctx, conn)
+
+ // Use existing library ID 1 from test fixtures
+ libRepo := NewLibraryRepository(ctx, conn)
+ lib, err := libRepo.Get(1)
+ Expect(err).ToNot(HaveOccurred())
+ testLib = *lib
+
+ // Create a second library with its own folder to verify isolation
+ otherLib = model.Library{Name: "Other Library", Path: "/other/path"}
+ Expect(libRepo.Put(&otherLib)).To(Succeed())
+ })
+
+ AfterEach(func() {
+ // Clean up only test folders created by our tests (paths starting with "Test")
+ // This prevents interference with fixture data needed by other tests
+ _, _ = conn.NewQuery("DELETE FROM folder WHERE library_id = 1 AND path LIKE 'Test%'").Execute()
+ _, _ = conn.NewQuery(fmt.Sprintf("DELETE FROM library WHERE id = %d", otherLib.ID)).Execute()
+ })
+
+ Describe("GetFolderUpdateInfo", func() {
+ Context("with no target paths", func() {
+ It("returns all folders in the library", func() {
+ // Create test folders with unique names to avoid conflicts
+ folder1 := model.NewFolder(testLib, "TestGetLastUpdates/Folder1")
+ folder2 := model.NewFolder(testLib, "TestGetLastUpdates/Folder2")
+
+ err := repo.Put(folder1)
+ Expect(err).ToNot(HaveOccurred())
+ err = repo.Put(folder2)
+ Expect(err).ToNot(HaveOccurred())
+
+ otherFolder := model.NewFolder(otherLib, "TestOtherLib/Folder")
+ err = repo.Put(otherFolder)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Query all folders (no target paths) - should only return folders from testLib
+ results, err := repo.GetFolderUpdateInfo(testLib)
+ Expect(err).ToNot(HaveOccurred())
+ // Should include folders from testLib
+ Expect(results).To(HaveKey(folder1.ID))
+ Expect(results).To(HaveKey(folder2.ID))
+ // Should NOT include folders from other library
+ Expect(results).ToNot(HaveKey(otherFolder.ID))
+ })
+ })
+
+ Context("with specific target paths", func() {
+ It("returns folder info for existing folders", func() {
+ // Create test folders with unique names
+ folder1 := model.NewFolder(testLib, "TestSpecific/Rock")
+ folder2 := model.NewFolder(testLib, "TestSpecific/Jazz")
+ folder3 := model.NewFolder(testLib, "TestSpecific/Classical")
+
+ err := repo.Put(folder1)
+ Expect(err).ToNot(HaveOccurred())
+ err = repo.Put(folder2)
+ Expect(err).ToNot(HaveOccurred())
+ err = repo.Put(folder3)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Query specific paths
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestSpecific/Rock", "TestSpecific/Classical")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(2))
+
+ // Verify folder IDs are in results
+ Expect(results).To(HaveKey(folder1.ID))
+ Expect(results).To(HaveKey(folder3.ID))
+ Expect(results).ToNot(HaveKey(folder2.ID))
+
+ // Verify update info is populated
+ Expect(results[folder1.ID].UpdatedAt).ToNot(BeZero())
+ Expect(results[folder1.ID].Hash).To(Equal(folder1.Hash))
+ })
+
+ It("includes all child folders when querying parent", func() {
+ // Create a parent folder with multiple children
+ parent := model.NewFolder(testLib, "TestParent/Music")
+ child1 := model.NewFolder(testLib, "TestParent/Music/Rock/Queen")
+ child2 := model.NewFolder(testLib, "TestParent/Music/Jazz")
+ otherParent := model.NewFolder(testLib, "TestParent2/Music/Jazz")
+
+ Expect(repo.Put(parent)).To(Succeed())
+ Expect(repo.Put(child1)).To(Succeed())
+ Expect(repo.Put(child2)).To(Succeed())
+
+ // Query the parent folder - should return parent and all children
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestParent/Music")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(3))
+ Expect(results).To(HaveKey(parent.ID))
+ Expect(results).To(HaveKey(child1.ID))
+ Expect(results).To(HaveKey(child2.ID))
+ Expect(results).ToNot(HaveKey(otherParent.ID))
+ })
+
+ It("excludes children from other libraries", func() {
+ // Create parent in testLib
+ parent := model.NewFolder(testLib, "TestIsolation/Parent")
+ child := model.NewFolder(testLib, "TestIsolation/Parent/Child")
+
+ Expect(repo.Put(parent)).To(Succeed())
+ Expect(repo.Put(child)).To(Succeed())
+
+ // Create similar path in other library
+ otherParent := model.NewFolder(otherLib, "TestIsolation/Parent")
+ otherChild := model.NewFolder(otherLib, "TestIsolation/Parent/Child")
+
+ Expect(repo.Put(otherParent)).To(Succeed())
+ Expect(repo.Put(otherChild)).To(Succeed())
+
+ // Query should only return folders from testLib
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestIsolation/Parent")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(2))
+ Expect(results).To(HaveKey(parent.ID))
+ Expect(results).To(HaveKey(child.ID))
+ Expect(results).ToNot(HaveKey(otherParent.ID))
+ Expect(results).ToNot(HaveKey(otherChild.ID))
+ })
+
+ It("excludes missing children when querying parent", func() {
+ // Create parent and children, mark one as missing
+ parent := model.NewFolder(testLib, "TestMissingChild/Parent")
+ child1 := model.NewFolder(testLib, "TestMissingChild/Parent/Child1")
+ child2 := model.NewFolder(testLib, "TestMissingChild/Parent/Child2")
+ child2.Missing = true
+
+ Expect(repo.Put(parent)).To(Succeed())
+ Expect(repo.Put(child1)).To(Succeed())
+ Expect(repo.Put(child2)).To(Succeed())
+
+ // Query parent - should only return parent and non-missing child
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestMissingChild/Parent")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(2))
+ Expect(results).To(HaveKey(parent.ID))
+ Expect(results).To(HaveKey(child1.ID))
+ Expect(results).ToNot(HaveKey(child2.ID))
+ })
+
+ It("handles mix of existing and non-existing target paths", func() {
+ // Create folders for one path but not the other
+ existingParent := model.NewFolder(testLib, "TestMixed/Exists")
+ existingChild := model.NewFolder(testLib, "TestMixed/Exists/Child")
+
+ Expect(repo.Put(existingParent)).To(Succeed())
+ Expect(repo.Put(existingChild)).To(Succeed())
+
+ // Query both existing and non-existing paths
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestMixed/Exists", "TestMixed/DoesNotExist")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(HaveLen(2))
+ Expect(results).To(HaveKey(existingParent.ID))
+ Expect(results).To(HaveKey(existingChild.ID))
+ })
+
+ It("handles empty folder path as root", func() {
+ // Test querying for root folder without creating it (fixtures should have one)
+ rootFolderID := model.FolderID(testLib, ".")
+
+ results, err := repo.GetFolderUpdateInfo(testLib, "")
+ Expect(err).ToNot(HaveOccurred())
+ // Should return the root folder if it exists
+ if len(results) > 0 {
+ Expect(results).To(HaveKey(rootFolderID))
+ }
+ })
+
+ It("returns empty map for non-existent folders", func() {
+ results, err := repo.GetFolderUpdateInfo(testLib, "NonExistent/Path")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+ })
+
+ It("skips missing folders", func() {
+ // Create a folder and mark it as missing
+ folder := model.NewFolder(testLib, "TestMissing/Folder")
+ folder.Missing = true
+ err := repo.Put(folder)
+ Expect(err).ToNot(HaveOccurred())
+
+ results, err := repo.GetFolderUpdateInfo(testLib, "TestMissing/Folder")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(results).To(BeEmpty())
+ })
+ })
+ })
+})
diff --git a/persistence/library_repository.go b/persistence/library_repository.go
index 314b682bb..5621e1719 100644
--- a/persistence/library_repository.go
+++ b/persistence/library_repository.go
@@ -177,7 +177,9 @@ func (r *libraryRepository) ScanEnd(id int) error {
return err
}
// https://www.sqlite.org/pragma.html#pragma_optimize
- _, err = r.executeSQL(Expr("PRAGMA optimize=0x10012;"))
+ // Use mask 0x10000 to check table sizes without running ANALYZE
+ // Running ANALYZE can cause query planner issues with expression-based collation indexes
+ _, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;"))
return err
}
diff --git a/persistence/library_repository_test.go b/persistence/library_repository_test.go
index 6f4df1beb..3e3972bdb 100644
--- a/persistence/library_repository_test.go
+++ b/persistence/library_repository_test.go
@@ -142,4 +142,62 @@ var _ = Describe("LibraryRepository", func() {
Expect(libAfter.TotalSize).To(Equal(sizeRes.Sum))
Expect(libAfter.TotalDuration).To(Equal(durationRes.Sum))
})
+
+ Describe("ScanBegin and ScanEnd", func() {
+ var lib *model.Library
+
+ BeforeEach(func() {
+ lib = &model.Library{
+ ID: 0,
+ Name: "Test Scan Library",
+ Path: "/music/test-scan",
+ }
+ err := repo.Put(lib)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ DescribeTable("ScanBegin",
+ func(fullScan bool, expectedFullScanInProgress bool) {
+ err := repo.ScanBegin(lib.ID, fullScan)
+ Expect(err).ToNot(HaveOccurred())
+
+ updatedLib, err := repo.Get(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(updatedLib.LastScanStartedAt).ToNot(BeZero())
+ Expect(updatedLib.FullScanInProgress).To(Equal(expectedFullScanInProgress))
+ },
+ Entry("sets FullScanInProgress to true for full scan", true, true),
+ Entry("sets FullScanInProgress to false for quick scan", false, false),
+ )
+
+ Context("ScanEnd", func() {
+ BeforeEach(func() {
+ err := repo.ScanBegin(lib.ID, true)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("sets LastScanAt and clears FullScanInProgress and LastScanStartedAt", func() {
+ err := repo.ScanEnd(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ updatedLib, err := repo.Get(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(updatedLib.LastScanAt).ToNot(BeZero())
+ Expect(updatedLib.FullScanInProgress).To(BeFalse())
+ Expect(updatedLib.LastScanStartedAt).To(BeZero())
+ })
+
+ It("sets LastScanAt to be after LastScanStartedAt", func() {
+ libBefore, err := repo.Get(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ err = repo.ScanEnd(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+
+ libAfter, err := repo.Get(lib.ID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(libAfter.LastScanAt).To(BeTemporally(">=", libBefore.LastScanStartedAt))
+ })
+ })
+ })
})
diff --git a/persistence/persistence.go b/persistence/persistence.go
index ac607f85f..1de0bae61 100644
--- a/persistence/persistence.go
+++ b/persistence/persistence.go
@@ -157,7 +157,7 @@ func (s *SQLStore) WithTxImmediate(block func(tx model.DataStore) error, scope .
}, scope...)
}
-func (s *SQLStore) GC(ctx context.Context) error {
+func (s *SQLStore) GC(ctx context.Context, libraryIDs ...int) error {
trace := func(ctx context.Context, msg string, f func() error) func() error {
return func() error {
start := time.Now()
@@ -167,11 +167,17 @@ func (s *SQLStore) GC(ctx context.Context) error {
}
}
+ // If libraryIDs are provided, scope operations to those libraries where possible
+ scoped := len(libraryIDs) > 0
+ if scoped {
+ log.Debug(ctx, "GC: Running selective garbage collection", "libraryIDs", libraryIDs)
+ }
+
err := run.Sequentially(
- trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty() }),
+ trace(ctx, "purge empty albums", func() error { return s.Album(ctx).(*albumRepository).purgeEmpty(libraryIDs...) }),
trace(ctx, "purge empty artists", func() error { return s.Artist(ctx).(*artistRepository).purgeEmpty() }),
trace(ctx, "mark missing artists", func() error { return s.Artist(ctx).(*artistRepository).markMissing() }),
- trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty() }),
+ trace(ctx, "purge empty folders", func() error { return s.Folder(ctx).(*folderRepository).purgeEmpty(libraryIDs...) }),
trace(ctx, "clean album annotations", func() error { return s.Album(ctx).(*albumRepository).cleanAnnotations() }),
trace(ctx, "clean artist annotations", func() error { return s.Artist(ctx).(*artistRepository).cleanAnnotations() }),
trace(ctx, "clean media file annotations", func() error { return s.MediaFile(ctx).(*mediaFileRepository).cleanAnnotations() }),
diff --git a/resources/i18n/pt-br.json b/resources/i18n/pt-br.json
index 9c22d509f..3f095b025 100644
--- a/resources/i18n/pt-br.json
+++ b/resources/i18n/pt-br.json
@@ -300,6 +300,8 @@
},
"actions": {
"scan": "Scanear Biblioteca",
+ "quickScan": "Scan Rápido",
+ "fullScan": "Scan Completo",
"manageUsers": "Gerenciar Acesso do Usuário",
"viewDetails": "Ver Detalhes"
},
@@ -308,6 +310,9 @@
"updated": "Biblioteca atualizada com sucesso",
"deleted": "Biblioteca excluída com sucesso",
"scanStarted": "Scan da biblioteca iniciada",
+ "quickScanStarted": "Scan rápido iniciado",
+ "fullScanStarted": "Scan completo iniciado",
+ "scanError": "Erro ao iniciar o scan. Verifique os logs",
"scanCompleted": "Scan da biblioteca concluída"
},
"validation": {
@@ -598,11 +603,12 @@
"activity": {
"title": "Atividade",
"totalScanned": "Total de pastas scaneadas",
- "quickScan": "Scan rápido",
- "fullScan": "Scan completo",
+ "quickScan": "Rápido",
+ "fullScan": "Completo",
+ "selectiveScan": "Seletivo",
"serverUptime": "Uptime do servidor",
"serverDown": "DESCONECTADO",
- "scanType": "Tipo",
+ "scanType": "Último Scan",
"status": "Erro",
"elapsedTime": "Duração"
},
diff --git a/scanner/controller.go b/scanner/controller.go
index c1347077a..b42246a50 100644
--- a/scanner/controller.go
+++ b/scanner/controller.go
@@ -26,24 +26,8 @@ var (
ErrAlreadyScanning = errors.New("already scanning")
)
-type Scanner interface {
- // ScanAll starts a full scan of the music library. This is a blocking operation.
- ScanAll(ctx context.Context, fullScan bool) (warnings []string, err error)
- Status(context.Context) (*StatusInfo, error)
-}
-
-type StatusInfo struct {
- Scanning bool
- LastScan time.Time
- Count uint32
- FolderCount uint32
- LastError string
- ScanType string
- ElapsedTime time.Duration
-}
-
func New(rootCtx context.Context, ds model.DataStore, cw artwork.CacheWarmer, broker events.Broker,
- pls core.Playlists, m metrics.Metrics) Scanner {
+ pls core.Playlists, m metrics.Metrics) model.Scanner {
c := &controller{
rootCtx: rootCtx,
ds: ds,
@@ -65,9 +49,10 @@ func (s *controller) getScanner() scanner {
return &scannerImpl{ds: s.ds, cw: s.cw, pls: s.pls}
}
-// CallScan starts an in-process scan of the music library.
+// CallScan starts an in-process scan of specific library/folder pairs.
+// If targets is empty, it scans all libraries.
// This is meant to be called from the command line (see cmd/scan.go).
-func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool) (<-chan *ProgressInfo, error) {
+func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullScan bool, targets []model.ScanTarget) (<-chan *ProgressInfo, error) {
release, err := lockScan(ctx)
if err != nil {
return nil, err
@@ -79,7 +64,7 @@ func CallScan(ctx context.Context, ds model.DataStore, pls core.Playlists, fullS
go func() {
defer close(progress)
scanner := &scannerImpl{ds: ds, cw: artwork.NoopCacheWarmer(), pls: pls}
- scanner.scanAll(ctx, fullScan, progress)
+ scanner.scanFolders(ctx, fullScan, targets, progress)
}()
return progress, nil
}
@@ -99,8 +84,11 @@ type ProgressInfo struct {
ForceUpdate bool
}
+// scanner defines the interface for different scanner implementations.
+// This allows for swapping between in-process and external scanners.
type scanner interface {
- scanAll(ctx context.Context, fullScan bool, progress chan<- *ProgressInfo)
+ // scanFolders performs the actual scanning of folders. If targets is nil, it scans all libraries.
+ scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo)
}
type controller struct {
@@ -158,7 +146,7 @@ func (s *controller) getScanInfo(ctx context.Context) (scanType string, elapsed
return scanType, elapsed, lastErr
}
-func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
+func (s *controller) Status(ctx context.Context) (*model.ScannerStatus, error) {
lastScanTime, err := s.getLastScanTime(ctx)
if err != nil {
return nil, fmt.Errorf("getting last scan time: %w", err)
@@ -167,7 +155,7 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
scanType, elapsed, lastErr := s.getScanInfo(ctx)
if running.Load() {
- status := &StatusInfo{
+ status := &model.ScannerStatus{
Scanning: true,
LastScan: lastScanTime,
Count: s.count.Load(),
@@ -183,7 +171,7 @@ func (s *controller) Status(ctx context.Context) (*StatusInfo, error) {
if err != nil {
return nil, fmt.Errorf("getting library stats: %w", err)
}
- return &StatusInfo{
+ return &model.ScannerStatus{
Scanning: false,
LastScan: lastScanTime,
Count: uint32(count),
@@ -208,6 +196,10 @@ func (s *controller) getCounters(ctx context.Context) (int64, int64, error) {
}
func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]string, error) {
+ return s.ScanFolders(requestCtx, fullScan, nil)
+}
+
+func (s *controller) ScanFolders(requestCtx context.Context, fullScan bool, targets []model.ScanTarget) ([]string, error) {
release, err := lockScan(requestCtx)
if err != nil {
return nil, err
@@ -224,7 +216,7 @@ func (s *controller) ScanAll(requestCtx context.Context, fullScan bool) ([]strin
go func() {
defer close(progress)
scanner := s.getScanner()
- scanner.scanAll(ctx, fullScan, progress)
+ scanner.scanFolders(ctx, fullScan, targets, progress)
}()
// Wait for the scan to finish, sending progress events to all connected clients
diff --git a/scanner/controller_test.go b/scanner/controller_test.go
index e551e15b1..f5ccabc86 100644
--- a/scanner/controller_test.go
+++ b/scanner/controller_test.go
@@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/core/artwork"
"github.com/navidrome/navidrome/core/metrics"
"github.com/navidrome/navidrome/db"
+ "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/persistence"
"github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server/events"
@@ -20,7 +21,7 @@ import (
var _ = Describe("Controller", func() {
var ctx context.Context
var ds *tests.MockDataStore
- var ctrl scanner.Scanner
+ var ctrl model.Scanner
Describe("Status", func() {
BeforeEach(func() {
diff --git a/scanner/external.go b/scanner/external.go
index c4a29efa3..b6d7639be 100644
--- a/scanner/external.go
+++ b/scanner/external.go
@@ -8,10 +8,12 @@ import (
"io"
"os"
"os/exec"
+ "strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
- . "github.com/navidrome/navidrome/utils/gg"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/utils/slice"
)
// scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid
@@ -23,19 +25,41 @@ import (
// process will forward them to the caller.
type scannerExternal struct{}
-func (s *scannerExternal) scanAll(ctx context.Context, fullScan bool, progress chan<- *ProgressInfo) {
+func (s *scannerExternal) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
+ s.scan(ctx, fullScan, targets, progress)
+}
+
+func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
exe, err := os.Executable()
if err != nil {
progress <- &ProgressInfo{Error: fmt.Sprintf("failed to get executable path: %s", err)}
return
}
- log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe)
- cmd := exec.CommandContext(ctx, exe, "scan",
+
+ // Build command arguments
+ args := []string{
+ "scan",
"--nobanner", "--subprocess",
"--configfile", conf.Server.ConfigFile,
"--datafolder", conf.Server.DataFolder,
"--cachefolder", conf.Server.CacheFolder,
- If(fullScan, "--full", ""))
+ }
+
+ // Add targets if provided
+ if len(targets) > 0 {
+ targetsStr := strings.Join(slice.Map(targets, func(t model.ScanTarget) string { return t.String() }), ",")
+ args = append(args, "--targets", targetsStr)
+ log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targetsStr)
+ } else {
+ log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe)
+ }
+
+ // Add full scan flag if needed
+ if fullScan {
+ args = append(args, "--full")
+ }
+
+ cmd := exec.CommandContext(ctx, exe, args...)
in, out := io.Pipe()
defer in.Close()
diff --git a/scanner/folder_entry.go b/scanner/folder_entry.go
index fc68cb561..9d8d0c571 100644
--- a/scanner/folder_entry.go
+++ b/scanner/folder_entry.go
@@ -15,9 +15,7 @@ import (
"github.com/navidrome/navidrome/utils/chrono"
)
-func newFolderEntry(job *scanJob, path string) *folderEntry {
- id := model.FolderID(job.lib, path)
- info := job.popLastUpdate(id)
+func newFolderEntry(job *scanJob, id, path string, updTime time.Time, hash string) *folderEntry {
f := &folderEntry{
id: id,
job: job,
@@ -25,8 +23,8 @@ func newFolderEntry(job *scanJob, path string) *folderEntry {
audioFiles: make(map[string]fs.DirEntry),
imageFiles: make(map[string]fs.DirEntry),
albumIDMap: make(map[string]string),
- updTime: info.UpdatedAt,
- prevHash: info.Hash,
+ updTime: updTime,
+ prevHash: hash,
}
return f
}
diff --git a/scanner/folder_entry_test.go b/scanner/folder_entry_test.go
index c6d1b2ce4..0328c6653 100644
--- a/scanner/folder_entry_test.go
+++ b/scanner/folder_entry_test.go
@@ -40,9 +40,8 @@ var _ = Describe("folder_entry", func() {
UpdatedAt: time.Now().Add(-30 * time.Minute),
Hash: "previous-hash",
}
- job.lastUpdates[folderID] = updateInfo
- entry := newFolderEntry(job, path)
+ entry := newFolderEntry(job, folderID, path, updateInfo.UpdatedAt, updateInfo.Hash)
Expect(entry.id).To(Equal(folderID))
Expect(entry.job).To(Equal(job))
@@ -53,15 +52,10 @@ var _ = Describe("folder_entry", func() {
Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt))
Expect(entry.prevHash).To(Equal(updateInfo.Hash))
})
+ })
- It("creates a new folder entry with zero time when no previous update exists", func() {
- entry := newFolderEntry(job, path)
-
- Expect(entry.updTime).To(BeZero())
- Expect(entry.prevHash).To(BeEmpty())
- })
-
- It("removes the lastUpdate from the job after popping", func() {
+ Describe("createFolderEntry", func() {
+ It("removes the lastUpdate from the job after creation", func() {
folderID := model.FolderID(lib, path)
updateInfo := model.FolderUpdateInfo{
UpdatedAt: time.Now().Add(-30 * time.Minute),
@@ -69,8 +63,10 @@ var _ = Describe("folder_entry", func() {
}
job.lastUpdates[folderID] = updateInfo
- newFolderEntry(job, path)
+ entry := job.createFolderEntry(path)
+ Expect(entry.updTime).To(Equal(updateInfo.UpdatedAt))
+ Expect(entry.prevHash).To(Equal(updateInfo.Hash))
Expect(job.lastUpdates).ToNot(HaveKey(folderID))
})
})
@@ -79,7 +75,8 @@ var _ = Describe("folder_entry", func() {
var entry *folderEntry
BeforeEach(func() {
- entry = newFolderEntry(job, path)
+ folderID := model.FolderID(lib, path)
+ entry = newFolderEntry(job, folderID, path, time.Time{}, "")
})
Describe("hasNoFiles", func() {
@@ -458,7 +455,9 @@ var _ = Describe("folder_entry", func() {
Describe("integration scenarios", func() {
It("handles complete folder lifecycle", func() {
// Create new folder entry
- entry := newFolderEntry(job, "music/rock/album")
+ folderPath := "music/rock/album"
+ folderID := model.FolderID(lib, folderPath)
+ entry := newFolderEntry(job, folderID, folderPath, time.Time{}, "")
// Initially new and has no files
Expect(entry.isNew()).To(BeTrue())
diff --git a/scanner/ignore_checker.go b/scanner/ignore_checker.go
new file mode 100644
index 000000000..da74293fa
--- /dev/null
+++ b/scanner/ignore_checker.go
@@ -0,0 +1,163 @@
+package scanner
+
+import (
+ "bufio"
+ "context"
+ "io/fs"
+ "path"
+ "strings"
+
+ "github.com/navidrome/navidrome/consts"
+ "github.com/navidrome/navidrome/log"
+ ignore "github.com/sabhiram/go-gitignore"
+)
+
+// IgnoreChecker manages .ndignore patterns using a stack-based approach.
+// Use Push() to add patterns when entering a folder, Pop() when leaving,
+// and ShouldIgnore() to check if a path should be ignored.
+type IgnoreChecker struct {
+ fsys fs.FS
+ patternStack [][]string // Stack of patterns for each folder level
+ currentPatterns []string // Flattened current patterns
+ matcher *ignore.GitIgnore // Compiled matcher for current patterns
+}
+
+// newIgnoreChecker creates a new IgnoreChecker for the given filesystem.
+func newIgnoreChecker(fsys fs.FS) *IgnoreChecker {
+ return &IgnoreChecker{
+ fsys: fsys,
+ patternStack: make([][]string, 0),
+ }
+}
+
+// Push loads .ndignore patterns from the specified folder and adds them to the pattern stack.
+// Use this when entering a folder during directory tree traversal.
+func (ic *IgnoreChecker) Push(ctx context.Context, folder string) error {
+ patterns := ic.loadPatternsFromFolder(ctx, folder)
+ ic.patternStack = append(ic.patternStack, patterns)
+ ic.rebuildCurrentPatterns()
+ return nil
+}
+
+// Pop removes the most recent patterns from the stack.
+// Use this when leaving a folder during directory tree traversal.
+func (ic *IgnoreChecker) Pop() {
+ if len(ic.patternStack) > 0 {
+ ic.patternStack = ic.patternStack[:len(ic.patternStack)-1]
+ ic.rebuildCurrentPatterns()
+ }
+}
+
+// PushAllParents pushes patterns from root down to the target path.
+// This is a convenience method for when you need to check a specific path
+// without recursively walking the tree. It handles the common pattern of
+// pushing all parent directories from root to the target.
+// This method is optimized to compile patterns only once at the end.
+func (ic *IgnoreChecker) PushAllParents(ctx context.Context, targetPath string) error {
+ if targetPath == "." || targetPath == "" {
+ // Simple case: just push root
+ return ic.Push(ctx, ".")
+ }
+
+ // Load patterns for root
+ patterns := ic.loadPatternsFromFolder(ctx, ".")
+ ic.patternStack = append(ic.patternStack, patterns)
+
+ // Load patterns for each parent directory
+ currentPath := "."
+ parts := strings.Split(path.Clean(targetPath), "/")
+ for _, part := range parts {
+ if part == "." || part == "" {
+ continue
+ }
+ currentPath = path.Join(currentPath, part)
+ patterns = ic.loadPatternsFromFolder(ctx, currentPath)
+ ic.patternStack = append(ic.patternStack, patterns)
+ }
+
+ // Rebuild and compile patterns only once at the end
+ ic.rebuildCurrentPatterns()
+ return nil
+}
+
+// ShouldIgnore checks if the given path should be ignored based on the current patterns.
+// Returns true if the path matches any ignore pattern, false otherwise.
+func (ic *IgnoreChecker) ShouldIgnore(ctx context.Context, relPath string) bool {
+ // Handle root/empty path - never ignore
+ if relPath == "" || relPath == "." {
+ return false
+ }
+
+ // If no patterns loaded, nothing to ignore
+ if ic.matcher == nil {
+ return false
+ }
+
+ matches := ic.matcher.MatchesPath(relPath)
+ if matches {
+ log.Trace(ctx, "Scanner: Ignoring entry matching .ndignore", "path", relPath)
+ }
+ return matches
+}
+
+// loadPatternsFromFolder reads the .ndignore file in the specified folder and returns the patterns.
+// If the file doesn't exist, returns an empty slice.
+// If the file exists but is empty, returns a pattern to ignore everything ("**/*").
+func (ic *IgnoreChecker) loadPatternsFromFolder(ctx context.Context, folder string) []string {
+ ignoreFilePath := path.Join(folder, consts.ScanIgnoreFile)
+ var patterns []string
+
+ // Check if .ndignore file exists
+ if _, err := fs.Stat(ic.fsys, ignoreFilePath); err != nil {
+ // No .ndignore file in this folder
+ return patterns
+ }
+
+ // Read and parse the .ndignore file
+ ignoreFile, err := ic.fsys.Open(ignoreFilePath)
+ if err != nil {
+ log.Warn(ctx, "Scanner: Error opening .ndignore file", "path", ignoreFilePath, err)
+ return patterns
+ }
+ defer ignoreFile.Close()
+
+ lineScanner := bufio.NewScanner(ignoreFile)
+ for lineScanner.Scan() {
+ line := strings.TrimSpace(lineScanner.Text())
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue // Skip empty lines, whitespace-only lines, and comments
+ }
+ patterns = append(patterns, line)
+ }
+
+ if err := lineScanner.Err(); err != nil {
+ log.Warn(ctx, "Scanner: Error reading .ndignore file", "path", ignoreFilePath, err)
+ return patterns
+ }
+
+ // If the .ndignore file is empty, ignore everything
+ if len(patterns) == 0 {
+ log.Trace(ctx, "Scanner: .ndignore file is empty, ignoring everything", "path", folder)
+ patterns = []string{"**/*"}
+ }
+
+ return patterns
+}
+
+// rebuildCurrentPatterns flattens the pattern stack into currentPatterns and recompiles the matcher.
+func (ic *IgnoreChecker) rebuildCurrentPatterns() {
+ ic.currentPatterns = make([]string, 0)
+ for _, patterns := range ic.patternStack {
+ ic.currentPatterns = append(ic.currentPatterns, patterns...)
+ }
+ ic.compilePatterns()
+}
+
+// compilePatterns compiles the current patterns into a GitIgnore matcher.
+func (ic *IgnoreChecker) compilePatterns() {
+ if len(ic.currentPatterns) == 0 {
+ ic.matcher = nil
+ return
+ }
+ ic.matcher = ignore.CompileIgnoreLines(ic.currentPatterns...)
+}
diff --git a/scanner/ignore_checker_test.go b/scanner/ignore_checker_test.go
new file mode 100644
index 000000000..5378ed4fa
--- /dev/null
+++ b/scanner/ignore_checker_test.go
@@ -0,0 +1,313 @@
+package scanner
+
+import (
+ "context"
+ "testing/fstest"
+
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("IgnoreChecker", func() {
+ Describe("loadPatternsFromFolder", func() {
+ var ic *IgnoreChecker
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ })
+
+ Context("when .ndignore file does not exist", func() {
+ It("should return empty patterns", func() {
+ fsys := fstest.MapFS{}
+ ic = newIgnoreChecker(fsys)
+ patterns := ic.loadPatternsFromFolder(ctx, ".")
+ Expect(patterns).To(BeEmpty())
+ })
+ })
+
+ Context("when .ndignore file is empty", func() {
+ It("should return wildcard to ignore everything", func() {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("")},
+ }
+ ic = newIgnoreChecker(fsys)
+ patterns := ic.loadPatternsFromFolder(ctx, ".")
+ Expect(patterns).To(Equal([]string{"**/*"}))
+ })
+ })
+
+ DescribeTable("parsing .ndignore content",
+ func(content string, expectedPatterns []string) {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte(content)},
+ }
+ ic = newIgnoreChecker(fsys)
+ patterns := ic.loadPatternsFromFolder(ctx, ".")
+ Expect(patterns).To(Equal(expectedPatterns))
+ },
+ Entry("single pattern", "*.txt", []string{"*.txt"}),
+ Entry("multiple patterns", "*.txt\n*.log", []string{"*.txt", "*.log"}),
+ Entry("with comments", "# comment\n*.txt\n# another\n*.log", []string{"*.txt", "*.log"}),
+ Entry("with empty lines", "*.txt\n\n*.log\n\n", []string{"*.txt", "*.log"}),
+ Entry("mixed content", "# header\n\n*.txt\n# middle\n*.log\n\n", []string{"*.txt", "*.log"}),
+ Entry("only comments and empty lines", "# comment\n\n# another\n", []string{"**/*"}),
+ Entry("trailing newline", "*.txt\n*.log\n", []string{"*.txt", "*.log"}),
+ Entry("directory pattern", "temp/", []string{"temp/"}),
+ Entry("wildcard pattern", "**/*.mp3", []string{"**/*.mp3"}),
+ Entry("multiple wildcards", "**/*.mp3\n**/*.flac\n*.log", []string{"**/*.mp3", "**/*.flac", "*.log"}),
+ Entry("negation pattern", "!important.txt", []string{"!important.txt"}),
+ Entry("comment with hash not at start is pattern", "not#comment", []string{"not#comment"}),
+ Entry("whitespace-only lines skipped", "*.txt\n \n*.log\n\t\n", []string{"*.txt", "*.log"}),
+ Entry("patterns with whitespace trimmed", " *.txt \n\t*.log\t", []string{"*.txt", "*.log"}),
+ )
+ })
+
+ Describe("Push and Pop", func() {
+ var ic *IgnoreChecker
+ var fsys fstest.MapFS
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ fsys = fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("*.txt")},
+ "folder1/.ndignore": &fstest.MapFile{Data: []byte("*.mp3")},
+ "folder2/.ndignore": &fstest.MapFile{Data: []byte("*.flac")},
+ }
+ ic = newIgnoreChecker(fsys)
+ })
+
+ Context("Push", func() {
+ It("should add patterns to stack", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(1))
+ Expect(ic.currentPatterns).To(ContainElement("*.txt"))
+ })
+
+ It("should compile matcher after push", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.matcher).ToNot(BeNil())
+ })
+
+ It("should accumulate patterns from multiple levels", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ err = ic.Push(ctx, "folder1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(2))
+ Expect(ic.currentPatterns).To(ConsistOf("*.txt", "*.mp3"))
+ })
+
+ It("should handle push when no .ndignore exists", func() {
+ err := ic.Push(ctx, "nonexistent")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(1))
+ Expect(ic.currentPatterns).To(BeEmpty())
+ })
+ })
+
+ Context("Pop", func() {
+ It("should remove most recent patterns", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ err = ic.Push(ctx, "folder1")
+ Expect(err).ToNot(HaveOccurred())
+ ic.Pop()
+ Expect(len(ic.patternStack)).To(Equal(1))
+ Expect(ic.currentPatterns).To(Equal([]string{"*.txt"}))
+ })
+
+ It("should handle Pop on empty stack gracefully", func() {
+ Expect(func() { ic.Pop() }).ToNot(Panic())
+ Expect(ic.patternStack).To(BeEmpty())
+ })
+
+ It("should set matcher to nil when all patterns popped", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.matcher).ToNot(BeNil())
+ ic.Pop()
+ Expect(ic.matcher).To(BeNil())
+ })
+
+ It("should update matcher after pop", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ err = ic.Push(ctx, "folder1")
+ Expect(err).ToNot(HaveOccurred())
+ matcher1 := ic.matcher
+ ic.Pop()
+ matcher2 := ic.matcher
+ Expect(matcher1).ToNot(Equal(matcher2))
+ })
+ })
+
+ Context("multiple Push/Pop cycles", func() {
+ It("should maintain correct state through cycles", func() {
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.currentPatterns).To(Equal([]string{"*.txt"}))
+
+ err = ic.Push(ctx, "folder1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.currentPatterns).To(ConsistOf("*.txt", "*.mp3"))
+
+ ic.Pop()
+ Expect(ic.currentPatterns).To(Equal([]string{"*.txt"}))
+
+ err = ic.Push(ctx, "folder2")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.currentPatterns).To(ConsistOf("*.txt", "*.flac"))
+
+ ic.Pop()
+ Expect(ic.currentPatterns).To(Equal([]string{"*.txt"}))
+
+ ic.Pop()
+ Expect(ic.currentPatterns).To(BeEmpty())
+ })
+ })
+ })
+
+ Describe("PushAllParents", func() {
+ var ic *IgnoreChecker
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("root.txt")},
+ "folder1/.ndignore": &fstest.MapFile{Data: []byte("level1.txt")},
+ "folder1/folder2/.ndignore": &fstest.MapFile{Data: []byte("level2.txt")},
+ "folder1/folder2/folder3/.ndignore": &fstest.MapFile{Data: []byte("level3.txt")},
+ }
+ ic = newIgnoreChecker(fsys)
+ })
+
+ DescribeTable("loading parent patterns",
+ func(targetPath string, expectedStackDepth int, expectedPatterns []string) {
+ err := ic.PushAllParents(ctx, targetPath)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(expectedStackDepth))
+ Expect(ic.currentPatterns).To(ConsistOf(expectedPatterns))
+ },
+ Entry("root path", ".", 1, []string{"root.txt"}),
+ Entry("empty path", "", 1, []string{"root.txt"}),
+ Entry("single level", "folder1", 2, []string{"root.txt", "level1.txt"}),
+ Entry("two levels", "folder1/folder2", 3, []string{"root.txt", "level1.txt", "level2.txt"}),
+ Entry("three levels", "folder1/folder2/folder3", 4, []string{"root.txt", "level1.txt", "level2.txt", "level3.txt"}),
+ )
+
+ It("should only compile patterns once at the end", func() {
+ // This is more of a behavioral test - we verify the matcher is not nil after PushAllParents
+ err := ic.PushAllParents(ctx, "folder1/folder2")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.matcher).ToNot(BeNil())
+ })
+
+ It("should handle paths with dot", func() {
+ err := ic.PushAllParents(ctx, "./folder1")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(2))
+ })
+
+ Context("when some parent folders have no .ndignore", func() {
+ BeforeEach(func() {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("root.txt")},
+ "folder1/folder2/.ndignore": &fstest.MapFile{Data: []byte("level2.txt")},
+ }
+ ic = newIgnoreChecker(fsys)
+ })
+
+ It("should still push all parent levels", func() {
+ err := ic.PushAllParents(ctx, "folder1/folder2")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(ic.patternStack)).To(Equal(3)) // root, folder1 (empty), folder2
+ Expect(ic.currentPatterns).To(ConsistOf("root.txt", "level2.txt"))
+ })
+ })
+ })
+
+ Describe("ShouldIgnore", func() {
+ var ic *IgnoreChecker
+ var ctx context.Context
+
+ BeforeEach(func() {
+ ctx = context.Background()
+ })
+
+ Context("with no patterns loaded", func() {
+ It("should not ignore any path", func() {
+ fsys := fstest.MapFS{}
+ ic = newIgnoreChecker(fsys)
+ Expect(ic.ShouldIgnore(ctx, "anything.txt")).To(BeFalse())
+ Expect(ic.ShouldIgnore(ctx, "folder/file.mp3")).To(BeFalse())
+ })
+ })
+
+ Context("special paths", func() {
+ BeforeEach(func() {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("**/*")},
+ }
+ ic = newIgnoreChecker(fsys)
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("should never ignore root or empty paths", func() {
+ Expect(ic.ShouldIgnore(ctx, "")).To(BeFalse())
+ Expect(ic.ShouldIgnore(ctx, ".")).To(BeFalse())
+ })
+
+ It("should ignore all other paths with wildcard", func() {
+ Expect(ic.ShouldIgnore(ctx, "file.txt")).To(BeTrue())
+ Expect(ic.ShouldIgnore(ctx, "folder/file.mp3")).To(BeTrue())
+ })
+ })
+
+ DescribeTable("pattern matching",
+ func(pattern string, path string, shouldMatch bool) {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte(pattern)},
+ }
+ ic = newIgnoreChecker(fsys)
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(ic.ShouldIgnore(ctx, path)).To(Equal(shouldMatch))
+ },
+ Entry("glob match", "*.txt", "file.txt", true),
+ Entry("glob no match", "*.txt", "file.mp3", false),
+ Entry("directory pattern match", "tmp/", "tmp/file.txt", true),
+ Entry("directory pattern no match", "tmp/", "temporary/file.txt", false),
+ Entry("nested glob match", "**/*.log", "deep/nested/file.log", true),
+ Entry("nested glob no match", "**/*.log", "deep/nested/file.txt", false),
+ Entry("specific file match", "ignore.me", "ignore.me", true),
+ Entry("specific file no match", "ignore.me", "keep.me", false),
+ Entry("wildcard all", "**/*", "any/path/file.txt", true),
+ Entry("nested specific match", "temp/*", "temp/cache.db", true),
+ Entry("nested specific no match", "temp/*", "temporary/cache.db", false),
+ )
+
+ Context("with multiple patterns", func() {
+ BeforeEach(func() {
+ fsys := fstest.MapFS{
+ ".ndignore": &fstest.MapFile{Data: []byte("*.txt\n*.log\ntemp/")},
+ }
+ ic = newIgnoreChecker(fsys)
+ err := ic.Push(ctx, ".")
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ It("should match any of the patterns", func() {
+ Expect(ic.ShouldIgnore(ctx, "file.txt")).To(BeTrue())
+ Expect(ic.ShouldIgnore(ctx, "debug.log")).To(BeTrue())
+ Expect(ic.ShouldIgnore(ctx, "temp/cache")).To(BeTrue())
+ Expect(ic.ShouldIgnore(ctx, "music.mp3")).To(BeFalse())
+ })
+ })
+ })
+})
diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go
index e04f10c70..2f6b62b2d 100644
--- a/scanner/phase_1_folders.go
+++ b/scanner/phase_1_folders.go
@@ -26,58 +26,46 @@ import (
"github.com/navidrome/navidrome/utils/slice"
)
-func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer, libs []model.Library) *phaseFolders {
+func createPhaseFolders(ctx context.Context, state *scanState, ds model.DataStore, cw artwork.CacheWarmer) *phaseFolders {
var jobs []*scanJob
- var updatedLibs []model.Library
- for _, lib := range libs {
- if lib.LastScanStartedAt.IsZero() {
- err := ds.Library(ctx).ScanBegin(lib.ID, state.fullScan)
- if err != nil {
- log.Error(ctx, "Scanner: Error updating last scan started at", "lib", lib.Name, err)
- state.sendWarning(err.Error())
- continue
- }
- // Reload library to get updated state
- l, err := ds.Library(ctx).Get(lib.ID)
- if err != nil {
- log.Error(ctx, "Scanner: Error reloading library", "lib", lib.Name, err)
- state.sendWarning(err.Error())
- continue
- }
- lib = *l
- } else {
- log.Debug(ctx, "Scanner: Resuming previous scan", "lib", lib.Name, "lastScanStartedAt", lib.LastScanStartedAt, "fullScan", lib.FullScanInProgress)
+
+ // Create scan jobs for all libraries
+ for _, lib := range state.libraries {
+ // Get target folders for this library if selective scan
+ var targetFolders []string
+ if state.isSelectiveScan() {
+ targetFolders = state.targets[lib.ID]
}
- job, err := newScanJob(ctx, ds, cw, lib, state.fullScan)
+
+ job, err := newScanJob(ctx, ds, cw, lib, state.fullScan, targetFolders)
if err != nil {
log.Error(ctx, "Scanner: Error creating scan context", "lib", lib.Name, err)
state.sendWarning(err.Error())
continue
}
jobs = append(jobs, job)
- updatedLibs = append(updatedLibs, lib)
}
- // Update the state with the libraries that have been processed and have their scan timestamps set
- state.libraries = updatedLibs
-
return &phaseFolders{jobs: jobs, ctx: ctx, ds: ds, state: state}
}
type scanJob struct {
- lib model.Library
- fs storage.MusicFS
- cw artwork.CacheWarmer
- lastUpdates map[string]model.FolderUpdateInfo
- lock sync.Mutex
- numFolders atomic.Int64
+ lib model.Library
+ fs storage.MusicFS
+ cw artwork.CacheWarmer
+ lastUpdates map[string]model.FolderUpdateInfo // Holds last update info for all (DB) folders in this library
+ targetFolders []string // Specific folders to scan (including all descendants)
+ lock sync.Mutex
+ numFolders atomic.Int64
}
-func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, lib model.Library, fullScan bool) (*scanJob, error) {
- lastUpdates, err := ds.Folder(ctx).GetLastUpdates(lib)
+func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer, lib model.Library, fullScan bool, targetFolders []string) (*scanJob, error) {
+ // Get folder updates, optionally filtered to specific target folders
+ lastUpdates, err := ds.Folder(ctx).GetFolderUpdateInfo(lib, targetFolders...)
if err != nil {
return nil, fmt.Errorf("getting last updates: %w", err)
}
+
fileStore, err := storage.For(lib.Path)
if err != nil {
log.Error(ctx, "Error getting storage for library", "library", lib.Name, "path", lib.Path, err)
@@ -88,15 +76,17 @@ func newScanJob(ctx context.Context, ds model.DataStore, cw artwork.CacheWarmer,
log.Error(ctx, "Error getting fs for library", "library", lib.Name, "path", lib.Path, err)
return nil, fmt.Errorf("getting fs for library: %w", err)
}
- lib.FullScanInProgress = lib.FullScanInProgress || fullScan
return &scanJob{
- lib: lib,
- fs: fsys,
- cw: cw,
- lastUpdates: lastUpdates,
+ lib: lib,
+ fs: fsys,
+ cw: cw,
+ lastUpdates: lastUpdates,
+ targetFolders: targetFolders,
}, nil
}
+// popLastUpdate retrieves and removes the last update info for the given folder ID
+// This is used to track which folders have been found during the walk_dir_tree
func (j *scanJob) popLastUpdate(folderID string) model.FolderUpdateInfo {
j.lock.Lock()
defer j.lock.Unlock()
@@ -106,6 +96,15 @@ func (j *scanJob) popLastUpdate(folderID string) model.FolderUpdateInfo {
return lastUpdate
}
+// createFolderEntry creates a new folderEntry for the given path, using the last update info from the job
+// to populate the previous update time and hash. It also removes the folder from the job's lastUpdates map.
+// This is used to track which folders have been found during the walk_dir_tree.
+func (j *scanJob) createFolderEntry(path string) *folderEntry {
+ id := model.FolderID(j.lib, path)
+ info := j.popLastUpdate(id)
+ return newFolderEntry(j, id, path, info.UpdatedAt, info.Hash)
+}
+
// phaseFolders represents the first phase of the scanning process, which is responsible
// for scanning all libraries and importing new or updated files. This phase involves
// traversing the directory tree of each library, identifying new or modified media files,
@@ -144,7 +143,8 @@ func (p *phaseFolders) producer() ppl.Producer[*folderEntry] {
if utils.IsCtxDone(p.ctx) {
break
}
- outputChan, err := walkDirTree(p.ctx, job)
+
+ outputChan, err := walkDirTree(p.ctx, job, job.targetFolders...)
if err != nil {
log.Warn(p.ctx, "Scanner: Error scanning library", "lib", job.lib.Name, err)
}
diff --git a/scanner/phase_2_missing_tracks.go b/scanner/phase_2_missing_tracks.go
index a6c0e261e..de93ed6ee 100644
--- a/scanner/phase_2_missing_tracks.go
+++ b/scanner/phase_2_missing_tracks.go
@@ -69,9 +69,6 @@ func (p *phaseMissingTracks) produce(put func(tracks *missingTracks)) error {
}
}
for _, lib := range p.state.libraries {
- if lib.LastScanStartedAt.IsZero() {
- continue
- }
log.Debug(p.ctx, "Scanner: Checking missing tracks", "libraryId", lib.ID, "libraryName", lib.Name)
cursor, err := p.ds.MediaFile(p.ctx).GetMissingAndMatching(lib.ID)
if err != nil {
diff --git a/scanner/phase_3_refresh_albums.go b/scanner/phase_3_refresh_albums.go
index f51aa8f4b..33e0fed01 100644
--- a/scanner/phase_3_refresh_albums.go
+++ b/scanner/phase_3_refresh_albums.go
@@ -27,14 +27,13 @@ import (
type phaseRefreshAlbums struct {
ds model.DataStore
ctx context.Context
- libs model.Libraries
refreshed atomic.Uint32
skipped atomic.Uint32
state *scanState
}
-func createPhaseRefreshAlbums(ctx context.Context, state *scanState, ds model.DataStore, libs model.Libraries) *phaseRefreshAlbums {
- return &phaseRefreshAlbums{ctx: ctx, ds: ds, libs: libs, state: state}
+func createPhaseRefreshAlbums(ctx context.Context, state *scanState, ds model.DataStore) *phaseRefreshAlbums {
+ return &phaseRefreshAlbums{ctx: ctx, ds: ds, state: state}
}
func (p *phaseRefreshAlbums) description() string {
@@ -47,7 +46,7 @@ func (p *phaseRefreshAlbums) producer() ppl.Producer[*model.Album] {
func (p *phaseRefreshAlbums) produce(put func(album *model.Album)) error {
count := 0
- for _, lib := range p.libs {
+ for _, lib := range p.state.libraries {
cursor, err := p.ds.Album(p.ctx).GetTouchedAlbums(lib.ID)
if err != nil {
return fmt.Errorf("loading touched albums: %w", err)
diff --git a/scanner/phase_3_refresh_albums_test.go b/scanner/phase_3_refresh_albums_test.go
index dea2556f0..1f0baf428 100644
--- a/scanner/phase_3_refresh_albums_test.go
+++ b/scanner/phase_3_refresh_albums_test.go
@@ -32,8 +32,8 @@ var _ = Describe("phaseRefreshAlbums", func() {
{ID: 1, Name: "Library 1"},
{ID: 2, Name: "Library 2"},
}
- state = &scanState{}
- phase = createPhaseRefreshAlbums(ctx, state, ds, libs)
+ state = &scanState{libraries: libs}
+ phase = createPhaseRefreshAlbums(ctx, state, ds)
})
Describe("description", func() {
diff --git a/scanner/scanner.go b/scanner/scanner.go
index 04a5c2456..20f3f5da8 100644
--- a/scanner/scanner.go
+++ b/scanner/scanner.go
@@ -3,6 +3,8 @@ package scanner
import (
"context"
"fmt"
+ "maps"
+ "slices"
"sync/atomic"
"time"
@@ -15,6 +17,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/run"
+ "github.com/navidrome/navidrome/utils/slice"
)
type scannerImpl struct {
@@ -28,7 +31,8 @@ type scanState struct {
progress chan<- *ProgressInfo
fullScan bool
changesDetected atomic.Bool
- libraries model.Libraries // Store libraries list for consistency across phases
+ libraries model.Libraries // Store libraries list for consistency across phases
+ targets map[int][]string // Optional: map[libraryID][]folderPaths for selective scans
}
func (s *scanState) sendProgress(info *ProgressInfo) {
@@ -37,6 +41,10 @@ func (s *scanState) sendProgress(info *ProgressInfo) {
}
}
+func (s *scanState) isSelectiveScan() bool {
+ return len(s.targets) > 0
+}
+
func (s *scanState) sendWarning(msg string) {
s.sendProgress(&ProgressInfo{Warning: msg})
}
@@ -45,7 +53,7 @@ func (s *scanState) sendError(err error) {
s.sendProgress(&ProgressInfo{Error: err.Error()})
}
-func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<- *ProgressInfo) {
+func (s *scannerImpl) scanFolders(ctx context.Context, fullScan bool, targets []model.ScanTarget, progress chan<- *ProgressInfo) {
startTime := time.Now()
state := scanState{
@@ -59,38 +67,75 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
state.changesDetected.Store(true)
}
- libs, err := s.ds.Library(ctx).GetAll()
+ // Get libraries and optionally filter by targets
+ allLibs, err := s.ds.Library(ctx).GetAll()
if err != nil {
state.sendWarning(fmt.Sprintf("getting libraries: %s", err))
return
}
- state.libraries = libs
- log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(libs))
+ if len(targets) > 0 {
+ // Selective scan: filter libraries and build targets map
+ state.targets = make(map[int][]string)
+
+ for _, target := range targets {
+ folderPath := target.FolderPath
+ if folderPath == "" {
+ folderPath = "."
+ }
+ state.targets[target.LibraryID] = append(state.targets[target.LibraryID], folderPath)
+ }
+
+ // Filter libraries to only those in targets
+ state.libraries = slice.Filter(allLibs, func(lib model.Library) bool {
+ return len(state.targets[lib.ID]) > 0
+ })
+
+ log.Info(ctx, "Scanner: Starting selective scan", "fullScan", state.fullScan, "numLibraries", len(state.libraries), "numTargets", len(targets))
+ } else {
+ // Full library scan
+ state.libraries = allLibs
+ log.Info(ctx, "Scanner: Starting scan", "fullScan", state.fullScan, "numLibraries", len(state.libraries))
+ }
// Store scan type and start time
scanType := "quick"
if state.fullScan {
scanType = "full"
}
+ if state.isSelectiveScan() {
+ scanType += "-selective"
+ }
_ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, scanType)
_ = s.ds.Property(ctx).Put(consts.LastScanStartTimeKey, startTime.Format(time.RFC3339))
// if there was a full scan in progress, force a full scan
if !state.fullScan {
- for _, lib := range libs {
+ for _, lib := range state.libraries {
if lib.FullScanInProgress {
log.Info(ctx, "Scanner: Interrupted full scan detected", "lib", lib.Name)
state.fullScan = true
- _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full")
+ if state.isSelectiveScan() {
+ _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full-selective")
+ } else {
+ _ = s.ds.Property(ctx).Put(consts.LastScanTypeKey, "full")
+ }
break
}
}
}
+ // Prepare libraries for scanning (initialize LastScanStartedAt if needed)
+ err = s.prepareLibrariesForScan(ctx, &state)
+ if err != nil {
+ log.Error(ctx, "Scanner: Error preparing libraries for scan", err)
+ state.sendError(err)
+ return
+ }
+
err = run.Sequentially(
// Phase 1: Scan all libraries and import new/updated files
- runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds, s.cw, libs)),
+ runPhase[*folderEntry](ctx, 1, createPhaseFolders(ctx, &state, s.ds, s.cw)),
// Phase 2: Process missing files, checking for moves
runPhase[*missingTracks](ctx, 2, createPhaseMissingTracks(ctx, &state, s.ds)),
@@ -98,7 +143,7 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
// Phases 3 and 4 can be run in parallel
run.Parallel(
// Phase 3: Refresh all new/changed albums and update artists
- runPhase[*model.Album](ctx, 3, createPhaseRefreshAlbums(ctx, &state, s.ds, libs)),
+ runPhase[*model.Album](ctx, 3, createPhaseRefreshAlbums(ctx, &state, s.ds)),
// Phase 4: Import/update playlists
runPhase[*model.Folder](ctx, 4, createPhasePlaylists(ctx, &state, s.ds, s.pls, s.cw)),
@@ -131,7 +176,53 @@ func (s *scannerImpl) scanAll(ctx context.Context, fullScan bool, progress chan<
state.sendProgress(&ProgressInfo{ChangesDetected: true})
}
- log.Info(ctx, "Scanner: Finished scanning all libraries", "duration", time.Since(startTime))
+ if state.isSelectiveScan() {
+ log.Info(ctx, "Scanner: Finished scanning selected folders", "duration", time.Since(startTime), "numTargets", len(targets))
+ } else {
+ log.Info(ctx, "Scanner: Finished scanning all libraries", "duration", time.Since(startTime))
+ }
+}
+
+// prepareLibrariesForScan initializes the scan for all libraries in the state.
+// It calls ScanBegin for libraries that haven't started scanning yet (LastScanStartedAt is zero),
+// reloads them to get the updated state, and filters out any libraries that fail to initialize.
+func (s *scannerImpl) prepareLibrariesForScan(ctx context.Context, state *scanState) error {
+ var successfulLibs []model.Library
+
+ for _, lib := range state.libraries {
+ if lib.LastScanStartedAt.IsZero() {
+ // This is a new scan - mark it as started
+ err := s.ds.Library(ctx).ScanBegin(lib.ID, state.fullScan)
+ if err != nil {
+ log.Error(ctx, "Scanner: Error marking scan start", "lib", lib.Name, err)
+ state.sendWarning(err.Error())
+ continue
+ }
+
+ // Reload library to get updated state (timestamps, etc.)
+ reloadedLib, err := s.ds.Library(ctx).Get(lib.ID)
+ if err != nil {
+ log.Error(ctx, "Scanner: Error reloading library", "lib", lib.Name, err)
+ state.sendWarning(err.Error())
+ continue
+ }
+ lib = *reloadedLib
+ } else {
+ // This is a resumed scan
+ log.Debug(ctx, "Scanner: Resuming previous scan", "lib", lib.Name,
+ "lastScanStartedAt", lib.LastScanStartedAt, "fullScan", lib.FullScanInProgress)
+ }
+
+ successfulLibs = append(successfulLibs, lib)
+ }
+
+ if len(successfulLibs) == 0 {
+ return fmt.Errorf("no libraries available for scanning")
+ }
+
+ // Update state with only successfully initialized libraries
+ state.libraries = successfulLibs
+ return nil
}
func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error {
@@ -140,7 +231,15 @@ func (s *scannerImpl) runGC(ctx context.Context, state *scanState) func() error
return s.ds.WithTx(func(tx model.DataStore) error {
if state.changesDetected.Load() {
start := time.Now()
- err := tx.GC(ctx)
+
+ // For selective scans, extract library IDs to scope GC operations
+ var libraryIDs []int
+ if state.isSelectiveScan() {
+ libraryIDs = slices.Collect(maps.Keys(state.targets))
+ log.Debug(ctx, "Scanner: Running selective GC", "libraryIDs", libraryIDs)
+ }
+
+ err := tx.GC(ctx, libraryIDs...)
if err != nil {
log.Error(ctx, "Scanner: Error running GC", err)
return fmt.Errorf("running GC: %w", err)
diff --git a/scanner/scanner_multilibrary_test.go b/scanner/scanner_multilibrary_test.go
index f27ad52fc..66db62edf 100644
--- a/scanner/scanner_multilibrary_test.go
+++ b/scanner/scanner_multilibrary_test.go
@@ -32,7 +32,7 @@ var _ = Describe("Scanner - Multi-Library", Ordered, func() {
var ctx context.Context
var lib1, lib2 model.Library
var ds *tests.MockDataStore
- var s scanner.Scanner
+ var s model.Scanner
createFS := func(path string, files fstest.MapFS) storagetest.FakeFS {
fs := storagetest.FakeFS{}
diff --git a/scanner/scanner_selective_test.go b/scanner/scanner_selective_test.go
new file mode 100644
index 000000000..629826db4
--- /dev/null
+++ b/scanner/scanner_selective_test.go
@@ -0,0 +1,293 @@
+package scanner_test
+
+import (
+ "context"
+ "path/filepath"
+ "testing/fstest"
+
+ "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/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/navidrome/navidrome/utils/slice"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("ScanFolders", Ordered, func() {
+ var ctx context.Context
+ var lib model.Library
+ var ds model.DataStore
+ var s model.Scanner
+ var fsys storagetest.FakeFS
+
+ BeforeAll(func() {
+ ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
+ tmpDir := GinkgoT().TempDir()
+ conf.Server.DbPath = filepath.Join(tmpDir, "test-selective-scan.db?_journal_mode=WAL")
+ log.Warn("Using DB at " + conf.Server.DbPath)
+ db.Db().SetMaxOpenConns(1)
+ })
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.MusicFolder = "fake:///music"
+ conf.Server.DevExternalScanner = false
+
+ db.Init(ctx)
+ DeferCleanup(func() {
+ Expect(tests.ClearDB()).To(Succeed())
+ })
+
+ ds = persistence.New(db.Db())
+
+ // Create the admin user in the database to match the context
+ adminUser := model.User{
+ ID: "123",
+ UserName: "admin",
+ Name: "Admin User",
+ IsAdmin: true,
+ NewPassword: "password",
+ }
+ Expect(ds.User(ctx).Put(&adminUser)).To(Succeed())
+
+ s = scanner.New(ctx, ds, artwork.NoopCacheWarmer(), events.NoopBroker(),
+ core.NewPlaylists(ds), metrics.NewNoopInstance())
+
+ lib = model.Library{ID: 1, Name: "Fake Library", Path: "fake:///music"}
+ Expect(ds.Library(ctx).Put(&lib)).To(Succeed())
+
+ // Initialize fake filesystem
+ fsys = storagetest.FakeFS{}
+ storagetest.Register("fake", &fsys)
+ })
+
+ Describe("Adding tracks to the library", func() {
+ It("scans specified folders recursively including all subdirectories", func() {
+ rock := template(_t{"albumartist": "Rock Artist", "album": "Rock Album"})
+ jazz := template(_t{"albumartist": "Jazz Artist", "album": "Jazz Album"})
+ pop := template(_t{"albumartist": "Pop Artist", "album": "Pop Album"})
+ createFS(fstest.MapFS{
+ "rock/track1.mp3": rock(track(1, "Rock Track 1")),
+ "rock/track2.mp3": rock(track(2, "Rock Track 2")),
+ "rock/subdir/track3.mp3": rock(track(3, "Rock Track 3")),
+ "jazz/track4.mp3": jazz(track(1, "Jazz Track 1")),
+ "jazz/subdir/track5.mp3": jazz(track(2, "Jazz Track 2")),
+ "pop/track6.mp3": pop(track(1, "Pop Track 1")),
+ })
+
+ // Scan only the "rock" and "jazz" folders (including their subdirectories)
+ targets := []model.ScanTarget{
+ {LibraryID: lib.ID, FolderPath: "rock"},
+ {LibraryID: lib.ID, FolderPath: "jazz"},
+ }
+
+ warnings, err := s.ScanFolders(ctx, false, targets)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(warnings).To(BeEmpty())
+
+ // Verify all tracks in rock and jazz folders (including subdirectories) were imported
+ allFiles, err := ds.MediaFile(ctx).GetAll()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should have 5 tracks (all rock and jazz tracks including subdirectories)
+ Expect(allFiles).To(HaveLen(5))
+
+ // Get the file paths
+ paths := slice.Map(allFiles, func(mf model.MediaFile) string {
+ return filepath.ToSlash(mf.Path)
+ })
+
+ // Verify the correct files were scanned (including subdirectories)
+ Expect(paths).To(ContainElements(
+ "rock/track1.mp3",
+ "rock/track2.mp3",
+ "rock/subdir/track3.mp3",
+ "jazz/track4.mp3",
+ "jazz/subdir/track5.mp3",
+ ))
+
+ // Verify files in the pop folder were NOT scanned
+ Expect(paths).ToNot(ContainElement("pop/track6.mp3"))
+ })
+ })
+
+ Describe("Deleting folders", func() {
+ Context("when a child folder is deleted", func() {
+ var (
+ revolver, help func(...map[string]any) *fstest.MapFile
+ artistFolderID string
+ album1FolderID string
+ album2FolderID string
+ album1TrackIDs []string
+ album2TrackIDs []string
+ )
+
+ BeforeEach(func() {
+ // Setup template functions for creating test files
+ revolver = storagetest.Template(_t{"albumartist": "The Beatles", "album": "Revolver", "year": 1966})
+ help = storagetest.Template(_t{"albumartist": "The Beatles", "album": "Help!", "year": 1965})
+
+ // Initial filesystem with nested folders
+ fsys.SetFiles(fstest.MapFS{
+ "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")),
+ "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")),
+ "The Beatles/Help!/01 - Help!.mp3": help(storagetest.Track(1, "Help!")),
+ "The Beatles/Help!/02 - The Night Before.mp3": help(storagetest.Track(2, "The Night Before")),
+ })
+
+ // First scan - import everything
+ _, err := s.ScanAll(ctx, true)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify initial state - all folders exist
+ folders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(folders).To(HaveLen(4)) // root, Artist, Album1, Album2
+
+ // Store folder IDs for later verification
+ for _, f := range folders {
+ switch f.Name {
+ case "The Beatles":
+ artistFolderID = f.ID
+ case "Revolver":
+ album1FolderID = f.ID
+ case "Help!":
+ album2FolderID = f.ID
+ }
+ }
+
+ // Verify all tracks exist
+ allTracks, err := ds.MediaFile(ctx).GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(allTracks).To(HaveLen(4))
+
+ // Store track IDs for later verification
+ for _, t := range allTracks {
+ if t.Album == "Revolver" {
+ album1TrackIDs = append(album1TrackIDs, t.ID)
+ } else if t.Album == "Help!" {
+ album2TrackIDs = append(album2TrackIDs, t.ID)
+ }
+ }
+
+ // Verify no tracks are missing initially
+ for _, t := range allTracks {
+ Expect(t.Missing).To(BeFalse())
+ }
+ })
+
+ It("should mark child folder and its tracks as missing when parent is scanned", func() {
+ // Delete the child folder (Help!) from the filesystem
+ fsys.SetFiles(fstest.MapFS{
+ "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")),
+ "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")),
+ // "The Beatles/Help!" folder and its contents are DELETED
+ })
+
+ // Run selective scan on the parent folder (Artist)
+ // This simulates what the watcher does when a child folder is deleted
+ _, err := s.ScanFolders(ctx, false, []model.ScanTarget{
+ {LibraryID: lib.ID, FolderPath: "The Beatles"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify the deleted child folder is now marked as missing
+ deletedFolder, err := ds.Folder(ctx).Get(album2FolderID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(deletedFolder.Missing).To(BeTrue(), "Deleted child folder should be marked as missing")
+
+ // Verify the deleted folder's tracks are marked as missing
+ for _, trackID := range album2TrackIDs {
+ track, err := ds.MediaFile(ctx).Get(trackID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.Missing).To(BeTrue(), "Track in deleted folder should be marked as missing")
+ }
+
+ // Verify the parent folder is still present and not marked as missing
+ parentFolder, err := ds.Folder(ctx).Get(artistFolderID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(parentFolder.Missing).To(BeFalse(), "Parent folder should not be marked as missing")
+
+ // Verify the sibling folder and its tracks are still present and not missing
+ siblingFolder, err := ds.Folder(ctx).Get(album1FolderID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(siblingFolder.Missing).To(BeFalse(), "Sibling folder should not be marked as missing")
+
+ for _, trackID := range album1TrackIDs {
+ track, err := ds.MediaFile(ctx).Get(trackID)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(track.Missing).To(BeFalse(), "Track in sibling folder should not be marked as missing")
+ }
+ })
+
+ It("should mark deeply nested child folders as missing", func() {
+ // Add a deeply nested folder structure
+ fsys.SetFiles(fstest.MapFS{
+ "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")),
+ "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")),
+ "The Beatles/Help!/01 - Help!.mp3": help(storagetest.Track(1, "Help!")),
+ "The Beatles/Help!/02 - The Night Before.mp3": help(storagetest.Track(2, "The Night Before")),
+ "The Beatles/Help!/Bonus/01 - Bonus Track.mp3": help(storagetest.Track(99, "Bonus Track")),
+ "The Beatles/Help!/Bonus/Nested/01 - Deep Track.mp3": help(storagetest.Track(100, "Deep Track")),
+ })
+
+ // Rescan to import the new nested structure
+ _, err := s.ScanAll(ctx, true)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify nested folders were created
+ allFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"library_id": lib.ID}})
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(allFolders)).To(BeNumerically(">", 4), "Should have more folders with nested structure")
+
+ // Now delete the entire Help! folder including nested children
+ fsys.SetFiles(fstest.MapFS{
+ "The Beatles/Revolver/01 - Taxman.mp3": revolver(storagetest.Track(1, "Taxman")),
+ "The Beatles/Revolver/02 - Eleanor Rigby.mp3": revolver(storagetest.Track(2, "Eleanor Rigby")),
+ // All Help! subfolders are deleted
+ })
+
+ // Run selective scan on parent
+ _, err = s.ScanFolders(ctx, false, []model.ScanTarget{
+ {LibraryID: lib.ID, FolderPath: "The Beatles"},
+ })
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify all Help! folders (including nested ones) are marked as missing
+ missingFolders, err := ds.Folder(ctx).GetAll(model.QueryOptions{
+ Filters: squirrel.And{
+ squirrel.Eq{"library_id": lib.ID},
+ squirrel.Eq{"missing": true},
+ },
+ })
+ Expect(err).ToNot(HaveOccurred())
+ Expect(len(missingFolders)).To(BeNumerically(">", 0), "At least one folder should be marked as missing")
+
+ // Verify all tracks in deleted folders are marked as missing
+ allTracks, err := ds.MediaFile(ctx).GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ Expect(allTracks).To(HaveLen(6))
+
+ for _, track := range allTracks {
+ if track.Album == "Help!" {
+ Expect(track.Missing).To(BeTrue(), "All tracks in deleted Help! folder should be marked as missing")
+ } else if track.Album == "Revolver" {
+ Expect(track.Missing).To(BeFalse(), "Tracks in Revolver folder should not be marked as missing")
+ }
+ }
+ })
+ })
+ })
+})
diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go
index e7e354f21..873065aa3 100644
--- a/scanner/scanner_test.go
+++ b/scanner/scanner_test.go
@@ -34,19 +34,19 @@ type _t = map[string]any
var template = storagetest.Template
var track = storagetest.Track
+func createFS(files fstest.MapFS) storagetest.FakeFS {
+ fs := storagetest.FakeFS{}
+ fs.SetFiles(files)
+ storagetest.Register("fake", &fs)
+ return fs
+}
+
var _ = Describe("Scanner", Ordered, func() {
var ctx context.Context
var lib model.Library
var ds *tests.MockDataStore
var mfRepo *mockMediaFileRepo
- var s scanner.Scanner
-
- createFS := func(files fstest.MapFS) storagetest.FakeFS {
- fs := storagetest.FakeFS{}
- fs.SetFiles(files)
- storagetest.Register("fake", &fs)
- return fs
- }
+ var s model.Scanner
BeforeAll(func() {
ctx = request.WithUser(GinkgoT().Context(), model.User{ID: "123", IsAdmin: true})
@@ -478,6 +478,56 @@ var _ = Describe("Scanner", Ordered, func() {
Expect(mf.Missing).To(BeFalse())
})
+ It("marks tracks as missing when scanning a deleted folder with ScanFolders", func() {
+ By("Adding a third track to Revolver to have more test data")
+ fsys.Add("The Beatles/Revolver/03 - I'm Only Sleeping.mp3", revolver(track(3, "I'm Only Sleeping")))
+ Expect(runScanner(ctx, false)).To(Succeed())
+
+ By("Verifying initial state has 5 tracks")
+ Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{
+ Filters: squirrel.Eq{"missing": false},
+ })).To(Equal(int64(5)))
+
+ By("Removing the entire Revolver folder from filesystem")
+ fsys.Remove("The Beatles/Revolver/01 - Taxman.mp3")
+ fsys.Remove("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
+ fsys.Remove("The Beatles/Revolver/03 - I'm Only Sleeping.mp3")
+
+ By("Scanning the parent folder (simulating watcher behavior)")
+ targets := []model.ScanTarget{
+ {LibraryID: lib.ID, FolderPath: "The Beatles"},
+ }
+ _, err := s.ScanFolders(ctx, false, targets)
+ Expect(err).To(Succeed())
+
+ By("Checking all Revolver tracks are marked as missing")
+ mf, err := findByPath("The Beatles/Revolver/01 - Taxman.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.Missing).To(BeTrue())
+
+ mf, err = findByPath("The Beatles/Revolver/02 - Eleanor Rigby.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.Missing).To(BeTrue())
+
+ mf, err = findByPath("The Beatles/Revolver/03 - I'm Only Sleeping.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.Missing).To(BeTrue())
+
+ By("Checking the Help! tracks are not affected")
+ mf, err = findByPath("The Beatles/Help!/01 - Help!.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.Missing).To(BeFalse())
+
+ mf, err = findByPath("The Beatles/Help!/02 - The Night Before.mp3")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(mf.Missing).To(BeFalse())
+
+ By("Verifying only 2 non-missing tracks remain (Help! tracks)")
+ Expect(ds.MediaFile(ctx).CountAll(model.QueryOptions{
+ Filters: squirrel.Eq{"missing": false},
+ })).To(Equal(int64(2)))
+ })
+
It("does not override artist fields when importing an undertagged file", func() {
By("Making sure artist in the DB contains MBID and sort name")
aa, err := ds.Artist(ctx).GetAll(model.QueryOptions{
diff --git a/scanner/walk_dir_tree.go b/scanner/walk_dir_tree.go
index 63854d262..e6a694f2b 100644
--- a/scanner/walk_dir_tree.go
+++ b/scanner/walk_dir_tree.go
@@ -1,7 +1,6 @@
package scanner
import (
- "bufio"
"context"
"io/fs"
"maps"
@@ -11,37 +10,69 @@ import (
"strings"
"github.com/navidrome/navidrome/conf"
- "github.com/navidrome/navidrome/consts"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils"
- ignore "github.com/sabhiram/go-gitignore"
)
-func walkDirTree(ctx context.Context, job *scanJob) (<-chan *folderEntry, error) {
+// walkDirTree recursively walks the directory tree starting from the given targetFolders.
+// If no targetFolders are provided, it starts from the root folder (".").
+// It returns a channel of folderEntry pointers representing each folder found.
+func walkDirTree(ctx context.Context, job *scanJob, targetFolders ...string) (<-chan *folderEntry, error) {
results := make(chan *folderEntry)
+ folders := targetFolders
+ if len(targetFolders) == 0 {
+ // No specific folders provided, scan the root folder
+ folders = []string{"."}
+ }
go func() {
defer close(results)
- err := walkFolder(ctx, job, ".", nil, results)
- if err != nil {
- log.Error(ctx, "Scanner: There were errors reading directories from filesystem", "path", job.lib.Path, err)
- return
+ for _, folderPath := range folders {
+ if utils.IsCtxDone(ctx) {
+ return
+ }
+
+ // Check if target folder exists before walking it
+ // If it doesn't exist (e.g., deleted between watcher detection and scan execution),
+ // skip it so it remains in job.lastUpdates and gets handled in following steps
+ _, err := fs.Stat(job.fs, folderPath)
+ if err != nil {
+ log.Warn(ctx, "Scanner: Target folder does not exist.", "path", folderPath, err)
+ continue
+ }
+
+ // Create checker and push patterns from root to this folder
+ checker := newIgnoreChecker(job.fs)
+ err = checker.PushAllParents(ctx, folderPath)
+ if err != nil {
+ log.Error(ctx, "Scanner: Error pushing ignore patterns for target folder", "path", folderPath, err)
+ continue
+ }
+
+ // Recursively walk this folder and all its children
+ err = walkFolder(ctx, job, folderPath, checker, results)
+ if err != nil {
+ log.Error(ctx, "Scanner: Error walking target folder", "path", folderPath, err)
+ continue
+ }
}
- log.Debug(ctx, "Scanner: Finished reading folders", "lib", job.lib.Name, "path", job.lib.Path, "numFolders", job.numFolders.Load())
+ log.Debug(ctx, "Scanner: Finished reading target folders", "lib", job.lib.Name, "path", job.lib.Path, "numFolders", job.numFolders.Load())
}()
return results, nil
}
-func walkFolder(ctx context.Context, job *scanJob, currentFolder string, ignorePatterns []string, results chan<- *folderEntry) error {
- ignorePatterns = loadIgnoredPatterns(ctx, job.fs, currentFolder, ignorePatterns)
+func walkFolder(ctx context.Context, job *scanJob, currentFolder string, checker *IgnoreChecker, results chan<- *folderEntry) error {
+ // Push patterns for this folder onto the stack
+ _ = checker.Push(ctx, currentFolder)
+ defer checker.Pop() // Pop patterns when leaving this folder
- folder, children, err := loadDir(ctx, job, currentFolder, ignorePatterns)
+ folder, children, err := loadDir(ctx, job, currentFolder, checker)
if err != nil {
log.Warn(ctx, "Scanner: Error loading dir. Skipping", "path", currentFolder, err)
return nil
}
for _, c := range children {
- err := walkFolder(ctx, job, c, ignorePatterns, results)
+ err := walkFolder(ctx, job, c, checker, results)
if err != nil {
return err
}
@@ -59,50 +90,17 @@ func walkFolder(ctx context.Context, job *scanJob, currentFolder string, ignoreP
return nil
}
-func loadIgnoredPatterns(ctx context.Context, fsys fs.FS, currentFolder string, currentPatterns []string) []string {
- ignoreFilePath := path.Join(currentFolder, consts.ScanIgnoreFile)
- var newPatterns []string
- if _, err := fs.Stat(fsys, ignoreFilePath); err == nil {
- // Read and parse the .ndignore file
- ignoreFile, err := fsys.Open(ignoreFilePath)
- if err != nil {
- log.Warn(ctx, "Scanner: Error opening .ndignore file", "path", ignoreFilePath, err)
- // Continue with previous patterns
- } else {
- defer ignoreFile.Close()
- scanner := bufio.NewScanner(ignoreFile)
- for scanner.Scan() {
- line := scanner.Text()
- if line == "" || strings.HasPrefix(line, "#") {
- continue // Skip empty lines and comments
- }
- newPatterns = append(newPatterns, line)
- }
- if err := scanner.Err(); err != nil {
- log.Warn(ctx, "Scanner: Error reading .ignore file", "path", ignoreFilePath, err)
- }
- }
- // If the .ndignore file is empty, mimic the current behavior and ignore everything
- if len(newPatterns) == 0 {
- log.Trace(ctx, "Scanner: .ndignore file is empty, ignoring everything", "path", currentFolder)
- newPatterns = []string{"**/*"}
- } else {
- log.Trace(ctx, "Scanner: .ndignore file found ", "path", ignoreFilePath, "patterns", newPatterns)
- }
- }
- // Combine the patterns from the .ndignore file with the ones passed as argument
- combinedPatterns := append([]string{}, currentPatterns...)
- return append(combinedPatterns, newPatterns...)
-}
-
-func loadDir(ctx context.Context, job *scanJob, dirPath string, ignorePatterns []string) (folder *folderEntry, children []string, err error) {
- folder = newFolderEntry(job, dirPath)
-
+func loadDir(ctx context.Context, job *scanJob, dirPath string, checker *IgnoreChecker) (folder *folderEntry, children []string, err error) {
+ // Check if directory exists before creating the folder entry
+ // This is important to avoid removing the folder from lastUpdates if it doesn't exist
dirInfo, err := fs.Stat(job.fs, dirPath)
if err != nil {
log.Warn(ctx, "Scanner: Error stating dir", "path", dirPath, err)
return nil, nil, err
}
+
+ // Now that we know the folder exists, create the entry (which removes it from lastUpdates)
+ folder = job.createFolderEntry(dirPath)
folder.modTime = dirInfo.ModTime()
dir, err := job.fs.Open(dirPath)
@@ -117,12 +115,11 @@ func loadDir(ctx context.Context, job *scanJob, dirPath string, ignorePatterns [
return folder, children, err
}
- ignoreMatcher := ignore.CompileIgnoreLines(ignorePatterns...)
entries := fullReadDir(ctx, dirFile)
children = make([]string, 0, len(entries))
for _, entry := range entries {
entryPath := path.Join(dirPath, entry.Name())
- if len(ignorePatterns) > 0 && isScanIgnored(ctx, ignoreMatcher, entryPath) {
+ if checker.ShouldIgnore(ctx, entryPath) {
log.Trace(ctx, "Scanner: Ignoring entry", "path", entryPath)
continue
}
@@ -234,6 +231,7 @@ func isDirReadable(ctx context.Context, fsys fs.FS, dirPath string) bool {
var ignoredDirs = []string{
"$RECYCLE.BIN",
"#snapshot",
+ "@Recycle",
"@Recently-Snapshot",
".streams",
"lost+found",
@@ -254,11 +252,3 @@ func isDirIgnored(name string) bool {
func isEntryIgnored(name string) bool {
return strings.HasPrefix(name, ".") && !strings.HasPrefix(name, "..")
}
-
-func isScanIgnored(ctx context.Context, matcher *ignore.GitIgnore, entryPath string) bool {
- matches := matcher.MatchesPath(entryPath)
- if matches {
- log.Trace(ctx, "Scanner: Ignoring entry matching .ndignore: ", "path", entryPath)
- }
- return matches
-}
diff --git a/scanner/walk_dir_tree_test.go b/scanner/walk_dir_tree_test.go
index 1cab8a0b7..c9add0bd1 100644
--- a/scanner/walk_dir_tree_test.go
+++ b/scanner/walk_dir_tree_test.go
@@ -25,82 +25,196 @@ var _ = Describe("walk_dir_tree", func() {
ctx context.Context
)
- BeforeEach(func() {
- DeferCleanup(configtest.SetupConfig())
- ctx = GinkgoT().Context()
- fsys = &mockMusicFS{
- FS: fstest.MapFS{
- "root/a/.ndignore": {Data: []byte("ignored/*")},
- "root/a/f1.mp3": {},
- "root/a/f2.mp3": {},
- "root/a/ignored/bad.mp3": {},
- "root/b/cover.jpg": {},
- "root/c/f3": {},
- "root/d": {},
- "root/d/.ndignore": {},
- "root/d/f1.mp3": {},
- "root/d/f2.mp3": {},
- "root/d/f3.mp3": {},
- "root/e/original/f1.mp3": {},
- "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("original")},
+ Context("full library", func() {
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ ctx = GinkgoT().Context()
+ fsys = &mockMusicFS{
+ FS: fstest.MapFS{
+ "root/a/.ndignore": {Data: []byte("ignored/*")},
+ "root/a/f1.mp3": {},
+ "root/a/f2.mp3": {},
+ "root/a/ignored/bad.mp3": {},
+ "root/b/cover.jpg": {},
+ "root/c/f3": {},
+ "root/d": {},
+ "root/d/.ndignore": {},
+ "root/d/f1.mp3": {},
+ "root/d/f2.mp3": {},
+ "root/d/f3.mp3": {},
+ "root/e/original/f1.mp3": {},
+ "root/e/symlink": {Mode: fs.ModeSymlink, Data: []byte("original")},
+ },
+ }
+ job = &scanJob{
+ fs: fsys,
+ lib: model.Library{Path: "/music"},
+ }
+ })
+
+ // Helper function to call walkDirTree and collect folders from the results channel
+ getFolders := func() map[string]*folderEntry {
+ results, err := walkDirTree(ctx, job)
+ Expect(err).ToNot(HaveOccurred())
+
+ folders := map[string]*folderEntry{}
+ g := errgroup.Group{}
+ g.Go(func() error {
+ for folder := range results {
+ folders[folder.path] = folder
+ }
+ return nil
+ })
+ _ = g.Wait()
+ return folders
+ }
+
+ DescribeTable("symlink handling",
+ func(followSymlinks bool, expectedFolderCount int) {
+ conf.Server.Scanner.FollowSymlinks = followSymlinks
+ folders := getFolders()
+
+ Expect(folders).To(HaveLen(expectedFolderCount + 2)) // +2 for `.` and `root`
+
+ // Basic folder structure checks
+ Expect(folders["root/a"].audioFiles).To(SatisfyAll(
+ HaveLen(2),
+ HaveKey("f1.mp3"),
+ HaveKey("f2.mp3"),
+ ))
+ Expect(folders["root/a"].imageFiles).To(BeEmpty())
+ Expect(folders["root/b"].audioFiles).To(BeEmpty())
+ Expect(folders["root/b"].imageFiles).To(SatisfyAll(
+ HaveLen(1),
+ HaveKey("cover.jpg"),
+ ))
+ Expect(folders["root/c"].audioFiles).To(BeEmpty())
+ Expect(folders["root/c"].imageFiles).To(BeEmpty())
+ Expect(folders).ToNot(HaveKey("root/d"))
+
+ // Symlink specific checks
+ if followSymlinks {
+ Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1))
+ } else {
+ Expect(folders).ToNot(HaveKey("root/e/symlink"))
+ }
},
- }
- job = &scanJob{
- fs: fsys,
- lib: model.Library{Path: "/music"},
- }
+ Entry("with symlinks enabled", true, 7),
+ Entry("with symlinks disabled", false, 6),
+ )
})
- // Helper function to call walkDirTree and collect folders from the results channel
- getFolders := func() map[string]*folderEntry {
- results, err := walkDirTree(ctx, job)
- Expect(err).ToNot(HaveOccurred())
-
- folders := map[string]*folderEntry{}
- g := errgroup.Group{}
- g.Go(func() error {
- for folder := range results {
- folders[folder.path] = folder
+ Context("with target folders", func() {
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ ctx = GinkgoT().Context()
+ fsys = &mockMusicFS{
+ FS: fstest.MapFS{
+ "Artist/Album1/track1.mp3": {},
+ "Artist/Album1/track2.mp3": {},
+ "Artist/Album2/track1.mp3": {},
+ "Artist/Album2/track2.mp3": {},
+ "Artist/Album2/Sub/track3.mp3": {},
+ "OtherArtist/Album3/track1.mp3": {},
+ },
+ }
+ job = &scanJob{
+ fs: fsys,
+ lib: model.Library{Path: "/music"},
}
- return nil
})
- _ = g.Wait()
- return folders
- }
- DescribeTable("symlink handling",
- func(followSymlinks bool, expectedFolderCount int) {
- conf.Server.Scanner.FollowSymlinks = followSymlinks
- folders := getFolders()
+ It("should recursively walk all subdirectories of target folders", func() {
+ results, err := walkDirTree(ctx, job, "Artist")
+ Expect(err).ToNot(HaveOccurred())
- Expect(folders).To(HaveLen(expectedFolderCount + 2)) // +2 for `.` and `root`
+ folders := map[string]*folderEntry{}
+ g := errgroup.Group{}
+ g.Go(func() error {
+ for folder := range results {
+ folders[folder.path] = folder
+ }
+ return nil
+ })
+ _ = g.Wait()
- // Basic folder structure checks
- Expect(folders["root/a"].audioFiles).To(SatisfyAll(
- HaveLen(2),
- HaveKey("f1.mp3"),
- HaveKey("f2.mp3"),
+ // Should include the target folder and all its descendants
+ Expect(folders).To(SatisfyAll(
+ HaveKey("Artist"),
+ HaveKey("Artist/Album1"),
+ HaveKey("Artist/Album2"),
+ HaveKey("Artist/Album2/Sub"),
))
- Expect(folders["root/a"].imageFiles).To(BeEmpty())
- Expect(folders["root/b"].audioFiles).To(BeEmpty())
- Expect(folders["root/b"].imageFiles).To(SatisfyAll(
- HaveLen(1),
- HaveKey("cover.jpg"),
- ))
- Expect(folders["root/c"].audioFiles).To(BeEmpty())
- Expect(folders["root/c"].imageFiles).To(BeEmpty())
- Expect(folders).ToNot(HaveKey("root/d"))
- // Symlink specific checks
- if followSymlinks {
- Expect(folders["root/e/symlink"].audioFiles).To(HaveLen(1))
- } else {
- Expect(folders).ToNot(HaveKey("root/e/symlink"))
+ // Should not include folders outside the target
+ Expect(folders).ToNot(HaveKey("OtherArtist"))
+ Expect(folders).ToNot(HaveKey("OtherArtist/Album3"))
+
+ // Verify audio files are present
+ Expect(folders["Artist/Album1"].audioFiles).To(HaveLen(2))
+ Expect(folders["Artist/Album2"].audioFiles).To(HaveLen(2))
+ Expect(folders["Artist/Album2/Sub"].audioFiles).To(HaveLen(1))
+ })
+
+ It("should handle multiple target folders", func() {
+ results, err := walkDirTree(ctx, job, "Artist/Album1", "OtherArtist")
+ Expect(err).ToNot(HaveOccurred())
+
+ folders := map[string]*folderEntry{}
+ g := errgroup.Group{}
+ g.Go(func() error {
+ for folder := range results {
+ folders[folder.path] = folder
+ }
+ return nil
+ })
+ _ = g.Wait()
+
+ // Should include both target folders and their descendants
+ Expect(folders).To(SatisfyAll(
+ HaveKey("Artist/Album1"),
+ HaveKey("OtherArtist"),
+ HaveKey("OtherArtist/Album3"),
+ ))
+
+ // Should not include other folders
+ Expect(folders).ToNot(HaveKey("Artist"))
+ Expect(folders).ToNot(HaveKey("Artist/Album2"))
+ Expect(folders).ToNot(HaveKey("Artist/Album2/Sub"))
+ })
+
+ It("should skip non-existent target folders and preserve them in lastUpdates", func() {
+ // Setup job with lastUpdates for both existing and non-existing folders
+ job.lastUpdates = map[string]model.FolderUpdateInfo{
+ model.FolderID(job.lib, "Artist/Album1"): {},
+ model.FolderID(job.lib, "NonExistent/DeletedFolder"): {},
+ model.FolderID(job.lib, "OtherArtist/Album3"): {},
}
- },
- Entry("with symlinks enabled", true, 7),
- Entry("with symlinks disabled", false, 6),
- )
+
+ // Try to scan existing folder and non-existing folder
+ results, err := walkDirTree(ctx, job, "Artist/Album1", "NonExistent/DeletedFolder")
+ Expect(err).ToNot(HaveOccurred())
+
+ // Collect results
+ folders := map[string]struct{}{}
+ for folder := range results {
+ folders[folder.path] = struct{}{}
+ }
+
+ // Should only include the existing folder
+ Expect(folders).To(HaveKey("Artist/Album1"))
+ Expect(folders).ToNot(HaveKey("NonExistent/DeletedFolder"))
+
+ // The non-existent folder should still be in lastUpdates (not removed by popLastUpdate)
+ Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "NonExistent/DeletedFolder")))
+
+ // The existing folder should have been removed from lastUpdates
+ Expect(job.lastUpdates).ToNot(HaveKey(model.FolderID(job.lib, "Artist/Album1")))
+
+ // Folders not in targets should remain in lastUpdates
+ Expect(job.lastUpdates).To(HaveKey(model.FolderID(job.lib, "OtherArtist/Album3")))
+ })
+ })
})
Describe("helper functions", func() {
diff --git a/scanner/watcher.go b/scanner/watcher.go
index 37cfb5e22..ad9a06421 100644
--- a/scanner/watcher.go
+++ b/scanner/watcher.go
@@ -24,9 +24,9 @@ type Watcher interface {
type watcher struct {
mainCtx context.Context
ds model.DataStore
- scanner Scanner
+ scanner model.Scanner
triggerWait time.Duration
- watcherNotify chan model.Library
+ watcherNotify chan scanNotification
libraryWatchers map[int]*libraryWatcherInstance
mu sync.RWMutex
}
@@ -36,14 +36,19 @@ type libraryWatcherInstance struct {
cancel context.CancelFunc
}
+type scanNotification struct {
+ Library *model.Library
+ FolderPath string
+}
+
// GetWatcher returns the watcher singleton
-func GetWatcher(ds model.DataStore, s Scanner) Watcher {
+func GetWatcher(ds model.DataStore, s model.Scanner) Watcher {
return singleton.GetInstance(func() *watcher {
return &watcher{
ds: ds,
scanner: s,
triggerWait: conf.Server.Scanner.WatcherWait,
- watcherNotify: make(chan model.Library, 1),
+ watcherNotify: make(chan scanNotification, 1),
libraryWatchers: make(map[int]*libraryWatcherInstance),
}
})
@@ -68,11 +73,11 @@ func (w *watcher) Run(ctx context.Context) error {
// Main scan triggering loop
trigger := time.NewTimer(w.triggerWait)
trigger.Stop()
- waiting := false
+ targets := make(map[model.ScanTarget]struct{})
for {
select {
case <-trigger.C:
- log.Info("Watcher: Triggering scan")
+ log.Info("Watcher: Triggering scan for changed folders", "numTargets", len(targets))
status, err := w.scanner.Status(ctx)
if err != nil {
log.Error(ctx, "Watcher: Error retrieving Scanner status", err)
@@ -83,9 +88,23 @@ func (w *watcher) Run(ctx context.Context) error {
trigger.Reset(w.triggerWait * 3)
continue
}
- waiting = false
+
+ // Convert targets map to slice
+ targetSlice := make([]model.ScanTarget, 0, len(targets))
+ for target := range targets {
+ targetSlice = append(targetSlice, target)
+ }
+
+ // Clear targets for next batch
+ targets = make(map[model.ScanTarget]struct{})
+
go func() {
- _, err := w.scanner.ScanAll(ctx, false)
+ var err error
+ if conf.Server.DevSelectiveWatcher {
+ _, err = w.scanner.ScanFolders(ctx, false, targetSlice)
+ } else {
+ _, err = w.scanner.ScanAll(ctx, false)
+ }
if err != nil {
log.Error(ctx, "Watcher: Error scanning", err)
} else {
@@ -102,13 +121,20 @@ func (w *watcher) Run(ctx context.Context) error {
w.libraryWatchers = make(map[int]*libraryWatcherInstance)
w.mu.Unlock()
return nil
- case lib := <-w.watcherNotify:
- if !waiting {
- log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan",
- "libraryID", lib.ID, "name", lib.Name, "path", lib.Path)
- waiting = true
+ case notification := <-w.watcherNotify:
+ lib := notification.Library
+ folderPath := notification.FolderPath
+
+ // If already scheduled for scan, skip
+ target := model.ScanTarget{LibraryID: lib.ID, FolderPath: folderPath}
+ if _, exists := targets[target]; exists {
+ continue
}
+ targets[target] = struct{}{}
trigger.Reset(w.triggerWait)
+
+ log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan",
+ "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "folderPath", folderPath)
}
}
}
@@ -199,13 +225,18 @@ func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error {
log.Info(ctx, "Watcher started for library", "libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "absoluteLibPath", absLibPath)
+ return w.processLibraryEvents(ctx, lib, fsys, c, absLibPath)
+}
+
+// processLibraryEvents processes filesystem events for a library.
+func (w *watcher) processLibraryEvents(ctx context.Context, lib *model.Library, fsys storage.MusicFS, events <-chan string, absLibPath string) error {
for {
select {
case <-ctx.Done():
log.Debug(ctx, "Watcher stopped due to context cancellation", "libraryID", lib.ID, "name", lib.Name)
return nil
- case path := <-c:
- path, err = filepath.Rel(absLibPath, path)
+ case path := <-events:
+ path, err := filepath.Rel(absLibPath, path)
if err != nil {
log.Error(ctx, "Error getting relative path", "libraryID", lib.ID, "absolutePath", absLibPath, "path", path, err)
continue
@@ -215,12 +246,27 @@ func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error {
log.Trace(ctx, "Ignoring change", "libraryID", lib.ID, "path", path)
continue
}
-
log.Trace(ctx, "Detected change", "libraryID", lib.ID, "path", path, "absoluteLibPath", absLibPath)
+ // Check if the original path (before resolution) matches .ndignore patterns
+ // This is crucial for deleted folders - if a deleted folder matches .ndignore,
+ // we should ignore it BEFORE resolveFolderPath walks up to the parent
+ if w.shouldIgnoreFolderPath(ctx, fsys, path) {
+ log.Debug(ctx, "Ignoring change matching .ndignore pattern", "libraryID", lib.ID, "path", path)
+ continue
+ }
+
+ // Find the folder to scan - validate path exists as directory, walk up if needed
+ folderPath := resolveFolderPath(fsys, path)
+ // Double-check after resolution in case the resolved path is different and also matches patterns
+ if folderPath != path && w.shouldIgnoreFolderPath(ctx, fsys, folderPath) {
+ log.Trace(ctx, "Ignoring change in folder matching .ndignore pattern", "libraryID", lib.ID, "folderPath", folderPath)
+ continue
+ }
+
// Notify the main watcher of changes
select {
- case w.watcherNotify <- *lib:
+ case w.watcherNotify <- scanNotification{Library: lib, FolderPath: folderPath}:
default:
// Channel is full, notification already pending
}
@@ -228,6 +274,47 @@ func (w *watcher) watchLibrary(ctx context.Context, lib *model.Library) error {
}
}
+// resolveFolderPath takes a path (which may be a file or directory) and returns
+// the folder path to scan. If the path is a file, it walks up to find the parent
+// directory. Returns empty string if the path should scan the library root.
+func resolveFolderPath(fsys fs.FS, path string) string {
+ // Handle root paths immediately
+ if path == "." || path == "" {
+ return ""
+ }
+
+ folderPath := path
+ for {
+ info, err := fs.Stat(fsys, folderPath)
+ if err == nil && info.IsDir() {
+ // Found a valid directory
+ return folderPath
+ }
+ if folderPath == "." || folderPath == "" {
+ // Reached root, scan entire library
+ return ""
+ }
+ // Walk up the tree
+ dir, _ := filepath.Split(folderPath)
+ if dir == "" || dir == "." {
+ return ""
+ }
+ // Remove trailing slash
+ folderPath = filepath.Clean(dir)
+ }
+}
+
+// shouldIgnoreFolderPath checks if the given folderPath should be ignored based on .ndignore patterns
+// in the library. It pushes all parent folders onto the IgnoreChecker stack before checking.
+func (w *watcher) shouldIgnoreFolderPath(ctx context.Context, fsys storage.MusicFS, folderPath string) bool {
+ checker := newIgnoreChecker(fsys)
+ err := checker.PushAllParents(ctx, folderPath)
+ if err != nil {
+ log.Warn(ctx, "Watcher: Error pushing ignore patterns for folder", "path", folderPath, err)
+ }
+ return checker.ShouldIgnore(ctx, folderPath)
+}
+
func isIgnoredPath(_ context.Context, _ fs.FS, path string) bool {
baseDir, name := filepath.Split(path)
switch {
diff --git a/scanner/watcher_test.go b/scanner/watcher_test.go
new file mode 100644
index 000000000..01bfb2491
--- /dev/null
+++ b/scanner/watcher_test.go
@@ -0,0 +1,491 @@
+package scanner
+
+import (
+ "context"
+ "io/fs"
+ "path/filepath"
+ "testing/fstest"
+ "time"
+
+ "github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("Watcher", func() {
+ var ctx context.Context
+ var cancel context.CancelFunc
+ var mockScanner *tests.MockScanner
+ var mockDS *tests.MockDataStore
+ var w *watcher
+ var lib *model.Library
+
+ BeforeEach(func() {
+ DeferCleanup(configtest.SetupConfig())
+ conf.Server.Scanner.WatcherWait = 50 * time.Millisecond // Short wait for tests
+
+ ctx, cancel = context.WithCancel(context.Background())
+ DeferCleanup(cancel)
+
+ lib = &model.Library{
+ ID: 1,
+ Name: "Test Library",
+ Path: "/test/library",
+ }
+
+ // Set up mocks
+ mockScanner = tests.NewMockScanner()
+ mockDS = &tests.MockDataStore{}
+ mockLibRepo := &tests.MockLibraryRepo{}
+ mockLibRepo.SetData(model.Libraries{*lib})
+ mockDS.MockedLibrary = mockLibRepo
+
+ // Create a new watcher instance (not singleton) for testing
+ w = &watcher{
+ ds: mockDS,
+ scanner: mockScanner,
+ triggerWait: conf.Server.Scanner.WatcherWait,
+ watcherNotify: make(chan scanNotification, 10),
+ libraryWatchers: make(map[int]*libraryWatcherInstance),
+ mainCtx: ctx,
+ }
+ })
+
+ Describe("Target Collection and Deduplication", func() {
+ BeforeEach(func() {
+ // Start watcher in background
+ go func() {
+ _ = w.Run(ctx)
+ }()
+
+ // Give watcher time to initialize
+ time.Sleep(10 * time.Millisecond)
+ })
+
+ It("creates separate targets for different folders", 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))
+
+ // Verify two 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))
+ folderPaths[target.FolderPath] = true
+ }
+ Expect(folderPaths).To(HaveKey("artist1"))
+ Expect(folderPaths).To(HaveKey("artist2"))
+ })
+
+ It("handles different folder paths correctly", func() {
+ // Send notification for nested 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))
+
+ // Verify the target
+ calls := mockScanner.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].Targets).To(HaveLen(1))
+ Expect(calls[0].Targets[0].FolderPath).To(Equal("artist1/album1"))
+ })
+
+ It("deduplicates folder and file within same folder", func() {
+ // Send notification for a 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))
+
+ // Verify only one target despite multiple file/folder changes
+ calls := mockScanner.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].Targets).To(HaveLen(1))
+ Expect(calls[0].Targets[0].FolderPath).To(Equal("artist1/album1"))
+ })
+ })
+
+ Describe("Timer Behavior", func() {
+ BeforeEach(func() {
+ // Start watcher in background
+ go func() {
+ _ = w.Run(ctx)
+ }()
+
+ // Give watcher time to initialize
+ time.Sleep(10 * time.Millisecond)
+ })
+
+ It("resets timer on each change (debouncing)", 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))
+
+ // 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)
+
+ // 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
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 100*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+ })
+
+ It("triggers scan after quiet period", func() {
+ // Send notification
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: "artist1"}
+
+ // No scan immediately
+ Expect(mockScanner.GetScanFoldersCallCount()).To(Equal(0))
+
+ // Wait for quiet period
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+ })
+ })
+
+ Describe("Empty and Root Paths", func() {
+ BeforeEach(func() {
+ // Start watcher in background
+ go func() {
+ _ = w.Run(ctx)
+ }()
+
+ // Give watcher time to initialize
+ time.Sleep(10 * time.Millisecond)
+ })
+
+ It("handles empty folder path (library root)", func() {
+ // Send notification with empty folder path
+ w.watcherNotify <- scanNotification{Library: lib, FolderPath: ""}
+
+ // Wait for scan
+ Eventually(func() int {
+ return mockScanner.GetScanFoldersCallCount()
+ }, 200*time.Millisecond, 10*time.Millisecond).Should(Equal(1))
+
+ // Should scan the library root
+ calls := mockScanner.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].Targets).To(HaveLen(1))
+ Expect(calls[0].Targets[0].FolderPath).To(Equal(""))
+ })
+
+ 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))
+
+ // Should have only one target
+ calls := mockScanner.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].Targets).To(HaveLen(1))
+ })
+ })
+
+ Describe("Multiple Libraries", func() {
+ var lib2 *model.Library
+
+ BeforeEach(func() {
+ // Create second library
+ lib2 = &model.Library{
+ ID: 2,
+ Name: "Test Library 2",
+ Path: "/test/library2",
+ }
+
+ mockLibRepo := mockDS.MockedLibrary.(*tests.MockLibraryRepo)
+ mockLibRepo.SetData(model.Libraries{*lib, *lib2})
+
+ // Start watcher in background
+ go func() {
+ _ = w.Run(ctx)
+ }()
+
+ // Give watcher time to initialize
+ time.Sleep(10 * time.Millisecond)
+ })
+
+ 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))
+
+ // Verify library IDs are different
+ libraryIDs := make(map[int]bool)
+ for _, target := range calls[0].Targets {
+ libraryIDs[target.LibraryID] = true
+ }
+ Expect(libraryIDs).To(HaveKey(1))
+ Expect(libraryIDs).To(HaveKey(2))
+ })
+ })
+
+ Describe(".ndignore handling", func() {
+ var ctx context.Context
+ var cancel context.CancelFunc
+ var w *watcher
+ var mockFS *mockMusicFS
+ var lib *model.Library
+ var eventChan chan string
+ var absLibPath string
+
+ BeforeEach(func() {
+ ctx, cancel = context.WithCancel(GinkgoT().Context())
+ DeferCleanup(cancel)
+
+ // Set up library
+ var err error
+ absLibPath, err = filepath.Abs(".")
+ Expect(err).NotTo(HaveOccurred())
+
+ lib = &model.Library{
+ ID: 1,
+ Name: "Test Library",
+ Path: absLibPath,
+ }
+
+ // Create watcher with notification channel
+ w = &watcher{
+ watcherNotify: make(chan scanNotification, 10),
+ }
+
+ eventChan = make(chan string, 10)
+ })
+
+ // Helper to send an event - converts relative path to absolute
+ sendEvent := func(relativePath string) {
+ path := filepath.Join(absLibPath, relativePath)
+ eventChan <- path
+ }
+
+ // Helper to start the real event processing loop
+ startEventProcessing := func() {
+ go func() {
+ defer GinkgoRecover()
+ // Call the actual processLibraryEvents method - testing the real implementation!
+ _ = w.processLibraryEvents(ctx, lib, mockFS, eventChan, absLibPath)
+ }()
+ }
+
+ Context("when a folder matching .ndignore is deleted", func() {
+ BeforeEach(func() {
+ // Create filesystem with .ndignore containing _TEMP pattern
+ // The deleted folder (_TEMP) will NOT exist in the filesystem
+ mockFS = &mockMusicFS{
+ FS: fstest.MapFS{
+ "rock": &fstest.MapFile{Mode: fs.ModeDir},
+ "rock/.ndignore": &fstest.MapFile{Data: []byte("_TEMP\n")},
+ "rock/valid_album": &fstest.MapFile{Mode: fs.ModeDir},
+ "rock/valid_album/track.mp3": &fstest.MapFile{Data: []byte("audio")},
+ },
+ }
+ })
+
+ It("should NOT send scan notification when deleted folder matches .ndignore", func() {
+ startEventProcessing()
+
+ // Simulate deletion event for rock/_TEMP
+ sendEvent("rock/_TEMP")
+
+ // Wait a bit to ensure event is processed
+ time.Sleep(50 * time.Millisecond)
+
+ // No notification should have been sent
+ Consistently(eventChan, 100*time.Millisecond).Should(BeEmpty())
+ })
+
+ It("should send scan notification for valid folder deletion", func() {
+ startEventProcessing()
+
+ // Simulate deletion event for rock/other_folder (not in .ndignore and doesn't exist)
+ // Since it doesn't exist in mockFS, resolveFolderPath will walk up to "rock"
+ sendEvent("rock/other_folder")
+
+ // Should receive notification for parent folder
+ Eventually(w.watcherNotify, 200*time.Millisecond).Should(Receive(Equal(scanNotification{
+ Library: lib,
+ FolderPath: "rock",
+ })))
+ })
+ })
+
+ Context("with nested folder patterns", func() {
+ BeforeEach(func() {
+ mockFS = &mockMusicFS{
+ FS: fstest.MapFS{
+ "music": &fstest.MapFile{Mode: fs.ModeDir},
+ "music/.ndignore": &fstest.MapFile{Data: []byte("**/temp\n**/cache\n")},
+ "music/rock": &fstest.MapFile{Mode: fs.ModeDir},
+ "music/rock/artist": &fstest.MapFile{Mode: fs.ModeDir},
+ },
+ }
+ })
+
+ It("should NOT send notification when nested ignored folder is deleted", func() {
+ startEventProcessing()
+
+ // Simulate deletion of music/rock/artist/temp (matches **/temp)
+ sendEvent("music/rock/artist/temp")
+
+ // Wait to ensure event is processed
+ time.Sleep(50 * time.Millisecond)
+
+ // No notification should be sent
+ Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for nested ignored folder")
+ })
+
+ It("should send notification for non-ignored nested folder", func() {
+ startEventProcessing()
+
+ // Simulate change in music/rock/artist (doesn't match any pattern)
+ sendEvent("music/rock/artist")
+
+ // Should receive notification
+ Eventually(w.watcherNotify, 200*time.Millisecond).Should(Receive(Equal(scanNotification{
+ Library: lib,
+ FolderPath: "music/rock/artist",
+ })))
+ })
+ })
+
+ Context("with file events in ignored folders", func() {
+ BeforeEach(func() {
+ mockFS = &mockMusicFS{
+ FS: fstest.MapFS{
+ "rock": &fstest.MapFile{Mode: fs.ModeDir},
+ "rock/.ndignore": &fstest.MapFile{Data: []byte("_TEMP\n")},
+ },
+ }
+ })
+
+ It("should NOT send notification for file changes in ignored folders", func() {
+ startEventProcessing()
+
+ // Simulate file change in rock/_TEMP/file.mp3
+ sendEvent("rock/_TEMP/file.mp3")
+
+ // Wait to ensure event is processed
+ time.Sleep(50 * time.Millisecond)
+
+ // No notification should be sent
+ Expect(w.watcherNotify).To(BeEmpty(), "Expected no scan notification for file in ignored folder")
+ })
+ })
+ })
+})
+
+var _ = Describe("resolveFolderPath", func() {
+ var mockFS fs.FS
+
+ BeforeEach(func() {
+ // Create a mock filesystem with some directories and files
+ mockFS = fstest.MapFS{
+ "artist1": &fstest.MapFile{Mode: fs.ModeDir},
+ "artist1/album1": &fstest.MapFile{Mode: fs.ModeDir},
+ "artist1/album1/track1.mp3": &fstest.MapFile{Data: []byte("audio")},
+ "artist1/album1/track2.mp3": &fstest.MapFile{Data: []byte("audio")},
+ "artist1/album2": &fstest.MapFile{Mode: fs.ModeDir},
+ "artist1/album2/song.flac": &fstest.MapFile{Data: []byte("audio")},
+ "artist2": &fstest.MapFile{Mode: fs.ModeDir},
+ "artist2/cover.jpg": &fstest.MapFile{Data: []byte("image")},
+ }
+ })
+
+ It("returns directory path when given a directory", func() {
+ result := resolveFolderPath(mockFS, "artist1/album1")
+ Expect(result).To(Equal("artist1/album1"))
+ })
+
+ It("walks up to parent directory when given a file path", func() {
+ result := resolveFolderPath(mockFS, "artist1/album1/track1.mp3")
+ Expect(result).To(Equal("artist1/album1"))
+ })
+
+ It("walks up multiple levels if needed", func() {
+ result := resolveFolderPath(mockFS, "artist1/album1/nonexistent/file.mp3")
+ Expect(result).To(Equal("artist1/album1"))
+ })
+
+ It("returns empty string for non-existent paths at root", func() {
+ result := resolveFolderPath(mockFS, "nonexistent/path/file.mp3")
+ Expect(result).To(Equal(""))
+ })
+
+ It("returns empty string for dot path", func() {
+ result := resolveFolderPath(mockFS, ".")
+ Expect(result).To(Equal(""))
+ })
+
+ It("returns empty string for empty path", func() {
+ result := resolveFolderPath(mockFS, "")
+ Expect(result).To(Equal(""))
+ })
+
+ It("handles nested file paths correctly", func() {
+ result := resolveFolderPath(mockFS, "artist1/album2/song.flac")
+ Expect(result).To(Equal("artist1/album2"))
+ })
+
+ It("resolves to top-level directory", func() {
+ result := resolveFolderPath(mockFS, "artist2/cover.jpg")
+ Expect(result).To(Equal("artist2"))
+ })
+})
diff --git a/server/subsonic/api.go b/server/subsonic/api.go
index d08d3eb5b..f0e73c3d2 100644
--- a/server/subsonic/api.go
+++ b/server/subsonic/api.go
@@ -18,7 +18,6 @@ import (
"github.com/navidrome/navidrome/core/scrobbler"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/scanner"
"github.com/navidrome/navidrome/server"
"github.com/navidrome/navidrome/server/events"
"github.com/navidrome/navidrome/server/subsonic/responses"
@@ -39,7 +38,7 @@ type Router struct {
players core.Players
provider external.Provider
playlists core.Playlists
- scanner scanner.Scanner
+ scanner model.Scanner
broker events.Broker
scrobbler scrobbler.PlayTracker
share core.Share
@@ -48,7 +47,7 @@ type Router struct {
}
func New(ds model.DataStore, artwork artwork.Artwork, streamer core.MediaStreamer, archiver core.Archiver,
- players core.Players, provider external.Provider, scanner scanner.Scanner, broker events.Broker,
+ players core.Players, provider external.Provider, scanner model.Scanner, broker events.Broker,
playlists core.Playlists, scrobbler scrobbler.PlayTracker, share core.Share, playback playback.PlaybackServer,
metrics metrics.Metrics,
) *Router {
diff --git a/server/subsonic/library_scanning.go b/server/subsonic/library_scanning.go
index b6ccb9ae6..c9dd64968 100644
--- a/server/subsonic/library_scanning.go
+++ b/server/subsonic/library_scanning.go
@@ -1,10 +1,13 @@
package subsonic
import (
+ "fmt"
"net/http"
+ "slices"
"time"
"github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/server/subsonic/responses"
"github.com/navidrome/navidrome/utils/req"
@@ -44,15 +47,56 @@ func (api *Router) StartScan(r *http.Request) (*responses.Subsonic, error) {
p := req.Params(r)
fullScan := p.BoolOr("fullScan", false)
+ // Parse optional target parameters for selective scanning
+ var targets []model.ScanTarget
+ if targetParams, err := p.Strings("target"); err == nil && len(targetParams) > 0 {
+ targets, err = model.ParseTargets(targetParams)
+ if err != nil {
+ return nil, newError(responses.ErrorGeneric, fmt.Sprintf("Invalid target parameter: %v", err))
+ }
+
+ // Validate all libraries in targets exist and user has access to them
+ userLibraries, err := api.ds.User(ctx).GetUserLibraries(loggedUser.ID)
+ if err != nil {
+ return nil, newError(responses.ErrorGeneric, "Internal error")
+ }
+
+ // Check each target library
+ for _, target := range targets {
+ if !slices.ContainsFunc(userLibraries, func(lib model.Library) bool { return lib.ID == target.LibraryID }) {
+ return nil, newError(responses.ErrorDataNotFound, fmt.Sprintf("Library with ID %d not found", target.LibraryID))
+ }
+ }
+
+ // Special case: if single library with empty path and it's the only library in DB, call ScanAll
+ if len(targets) == 1 && targets[0].FolderPath == "" {
+ allLibs, err := api.ds.Library(ctx).GetAll()
+ if err != nil {
+ return nil, newError(responses.ErrorGeneric, "Internal error")
+ }
+ if len(allLibs) == 1 {
+ targets = nil // This will trigger ScanAll below
+ }
+ }
+ }
+
go func() {
start := time.Now()
- log.Info(ctx, "Triggering manual scan", "fullScan", fullScan, "user", loggedUser.UserName)
- _, err := api.scanner.ScanAll(ctx, fullScan)
+ var err error
+
+ if len(targets) > 0 {
+ log.Info(ctx, "Triggering on-demand scan", "fullScan", fullScan, "targets", len(targets), "user", loggedUser.UserName)
+ _, err = api.scanner.ScanFolders(ctx, fullScan, targets)
+ } else {
+ log.Info(ctx, "Triggering on-demand scan", "fullScan", fullScan, "user", loggedUser.UserName)
+ _, err = api.scanner.ScanAll(ctx, fullScan)
+ }
+
if err != nil {
log.Error(ctx, "Error scanning", err)
return
}
- log.Info(ctx, "Manual scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start))
+ log.Info(ctx, "On-demand scan complete", "user", loggedUser.UserName, "elapsed", time.Since(start))
}()
return api.GetScanStatus(r)
diff --git a/server/subsonic/library_scanning_test.go b/server/subsonic/library_scanning_test.go
new file mode 100644
index 000000000..d8eba296b
--- /dev/null
+++ b/server/subsonic/library_scanning_test.go
@@ -0,0 +1,396 @@
+package subsonic
+
+import (
+ "context"
+ "errors"
+ "net/http/httptest"
+
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+ "github.com/navidrome/navidrome/server/subsonic/responses"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("LibraryScanning", func() {
+ var api *Router
+ var ms *tests.MockScanner
+
+ BeforeEach(func() {
+ ms = tests.NewMockScanner()
+ api = &Router{scanner: ms}
+ })
+
+ 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{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with no parameters
+ r := httptest.NewRequest("GET", "/rest/startScan", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanAll was called (eventually, since it's in a goroutine)
+ Eventually(func() int {
+ return ms.GetScanAllCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanAllCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].FullScan).To(BeFalse())
+ })
+
+ It("triggers a full scan with fullScan=true", func() {
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with fullScan parameter
+ r := httptest.NewRequest("GET", "/rest/startScan?fullScan=true", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanAll was called with fullScan=true
+ Eventually(func() int {
+ return ms.GetScanAllCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanAllCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].FullScan).To(BeTrue())
+ })
+
+ It("triggers a selective scan with single target parameter", func() {
+ // Setup mocks
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
+ mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with single target parameter
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Rock", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanFolders was called with correct targets
+ Eventually(func() int {
+ return ms.GetScanFoldersCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ targets := calls[0].Targets
+ Expect(targets).To(HaveLen(1))
+ Expect(targets[0].LibraryID).To(Equal(1))
+ Expect(targets[0].FolderPath).To(Equal("Music/Rock"))
+ })
+
+ It("triggers a selective scan with multiple target parameters", func() {
+ // Setup mocks
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
+ mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with multiple target parameters
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Reggae&target=2:Classical/Bach", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanFolders was called with correct targets
+ Eventually(func() int {
+ return ms.GetScanFoldersCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ targets := calls[0].Targets
+ Expect(targets).To(HaveLen(2))
+ Expect(targets[0].LibraryID).To(Equal(1))
+ Expect(targets[0].FolderPath).To(Equal("Music/Reggae"))
+ Expect(targets[1].LibraryID).To(Equal(2))
+ Expect(targets[1].FolderPath).To(Equal("Classical/Bach"))
+ })
+
+ It("triggers a selective full scan with target and fullScan parameters", func() {
+ // Setup mocks
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1})
+ mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with target and fullScan parameters
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1:Music/Jazz&fullScan=true", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanFolders was called with fullScan=true
+ Eventually(func() int {
+ return ms.GetScanFoldersCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ Expect(calls[0].FullScan).To(BeTrue())
+ targets := calls[0].Targets
+ Expect(targets).To(HaveLen(1))
+ })
+
+ It("returns error for invalid target format", func() {
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with invalid target format (missing colon)
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1MusicRock", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should return 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.ErrorGeneric))
+ })
+
+ It("returns error for invalid library ID in target", func() {
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with invalid library ID
+ r := httptest.NewRequest("GET", "/rest/startScan?target=0:Music/Rock", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should return 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.ErrorGeneric))
+ })
+
+ It("returns error when library does not exist", func() {
+ // Setup mocks - user has access to library 1 and 2 only
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
+ mockDS := &tests.MockDataStore{MockedUser: mockUserRepo}
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with library ID that doesn't exist
+ r := httptest.NewRequest("GET", "/rest/startScan?target=999:Music/Rock", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should return ErrorDataNotFound
+ 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.ErrorDataNotFound))
+ })
+
+ It("calls ScanAll when single library with empty path and only one library exists", func() {
+ // Setup mocks - single library in DB
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1})
+ mockLibraryRepo := &tests.MockLibraryRepo{}
+ mockLibraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Music Library", Path: "/music"},
+ })
+ mockDS := &tests.MockDataStore{
+ MockedUser: mockUserRepo,
+ MockedLibrary: mockLibraryRepo,
+ }
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with single library and empty path
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1:", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanAll was called instead of ScanFolders
+ Eventually(func() int {
+ return ms.GetScanAllCallCount()
+ }).Should(BeNumerically(">", 0))
+ Expect(ms.GetScanFoldersCallCount()).To(Equal(0))
+ })
+
+ It("calls ScanFolders when single library with empty path but multiple libraries exist", func() {
+ // Setup mocks - multiple libraries in DB
+ mockUserRepo := tests.CreateMockUserRepo()
+ _ = mockUserRepo.SetUserLibraries("admin-id", []int{1, 2})
+ mockLibraryRepo := &tests.MockLibraryRepo{}
+ mockLibraryRepo.SetData(model.Libraries{
+ {ID: 1, Name: "Music Library", Path: "/music"},
+ {ID: 2, Name: "Audiobooks", Path: "/audiobooks"},
+ })
+ mockDS := &tests.MockDataStore{
+ MockedUser: mockUserRepo,
+ MockedLibrary: mockLibraryRepo,
+ }
+ api.ds = mockDS
+
+ // Create admin user
+ ctx := request.WithUser(context.Background(), model.User{
+ ID: "admin-id",
+ IsAdmin: true,
+ })
+
+ // Create request with single library and empty path
+ r := httptest.NewRequest("GET", "/rest/startScan?target=1:", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.StartScan(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+
+ // Verify ScanFolders was called (not ScanAll)
+ Eventually(func() int {
+ return ms.GetScanFoldersCallCount()
+ }).Should(BeNumerically(">", 0))
+ calls := ms.GetScanFoldersCalls()
+ Expect(calls).To(HaveLen(1))
+ targets := calls[0].Targets
+ Expect(targets).To(HaveLen(1))
+ Expect(targets[0].LibraryID).To(Equal(1))
+ Expect(targets[0].FolderPath).To(Equal(""))
+ })
+ })
+
+ Describe("GetScanStatus", func() {
+ It("returns scan status", func() {
+ // Setup mock scanner status
+ ms.SetStatusResponse(&model.ScannerStatus{
+ Scanning: false,
+ Count: 100,
+ FolderCount: 10,
+ })
+
+ // Create request
+ ctx := context.Background()
+ r := httptest.NewRequest("GET", "/rest/getScanStatus", nil)
+ r = r.WithContext(ctx)
+
+ // Call endpoint
+ response, err := api.GetScanStatus(r)
+
+ // Should succeed
+ Expect(err).ToNot(HaveOccurred())
+ Expect(response).ToNot(BeNil())
+ Expect(response.ScanStatus).ToNot(BeNil())
+ Expect(response.ScanStatus.Scanning).To(BeFalse())
+ Expect(response.ScanStatus.Count).To(Equal(int64(100)))
+ Expect(response.ScanStatus.FolderCount).To(Equal(int64(10)))
+ })
+ })
+})
diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go
index 56f68a74b..ba586ab53 100644
--- a/tests/mock_data_store.go
+++ b/tests/mock_data_store.go
@@ -28,6 +28,10 @@ type MockDataStore struct {
MockedRadio model.RadioRepository
scrobbleBufferMu sync.Mutex
repoMu sync.Mutex
+
+ // GC tracking
+ GCCalled bool
+ GCError error
}
func (db *MockDataStore) Library(ctx context.Context) model.LibraryRepository {
@@ -258,6 +262,10 @@ func (db *MockDataStore) Resource(ctx context.Context, m any) model.ResourceRepo
}
}
-func (db *MockDataStore) GC(context.Context) error {
+func (db *MockDataStore) GC(context.Context, ...int) error {
+ db.GCCalled = true
+ if db.GCError != nil {
+ return db.GCError
+ }
return nil
}
diff --git a/tests/mock_scanner.go b/tests/mock_scanner.go
new file mode 100644
index 000000000..52396723f
--- /dev/null
+++ b/tests/mock_scanner.go
@@ -0,0 +1,120 @@
+package tests
+
+import (
+ "context"
+ "sync"
+
+ "github.com/navidrome/navidrome/model"
+)
+
+// MockScanner implements scanner.Scanner for testing with proper synchronization
+type MockScanner struct {
+ mu sync.Mutex
+ scanAllCalls []ScanAllCall
+ scanFoldersCalls []ScanFoldersCall
+ scanningStatus bool
+ statusResponse *model.ScannerStatus
+}
+
+type ScanAllCall struct {
+ FullScan bool
+}
+
+type ScanFoldersCall struct {
+ FullScan bool
+ Targets []model.ScanTarget
+}
+
+func NewMockScanner() *MockScanner {
+ return &MockScanner{
+ scanAllCalls: make([]ScanAllCall, 0),
+ scanFoldersCalls: make([]ScanFoldersCall, 0),
+ }
+}
+
+func (m *MockScanner) ScanAll(_ context.Context, fullScan bool) ([]string, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ m.scanAllCalls = append(m.scanAllCalls, ScanAllCall{FullScan: fullScan})
+
+ return nil, nil
+}
+
+func (m *MockScanner) ScanFolders(_ context.Context, fullScan bool, targets []model.ScanTarget) ([]string, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ // Make a copy of targets to avoid race conditions
+ targetsCopy := make([]model.ScanTarget, len(targets))
+ copy(targetsCopy, targets)
+
+ m.scanFoldersCalls = append(m.scanFoldersCalls, ScanFoldersCall{
+ FullScan: fullScan,
+ Targets: targetsCopy,
+ })
+
+ return nil, nil
+}
+
+func (m *MockScanner) Status(_ context.Context) (*model.ScannerStatus, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ if m.statusResponse != nil {
+ return m.statusResponse, nil
+ }
+
+ return &model.ScannerStatus{
+ Scanning: m.scanningStatus,
+ }, nil
+}
+
+func (m *MockScanner) GetScanAllCallCount() int {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return len(m.scanAllCalls)
+}
+
+func (m *MockScanner) GetScanAllCalls() []ScanAllCall {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ // Return a copy to avoid race conditions
+ calls := make([]ScanAllCall, len(m.scanAllCalls))
+ copy(calls, m.scanAllCalls)
+ return calls
+}
+
+func (m *MockScanner) GetScanFoldersCallCount() int {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return len(m.scanFoldersCalls)
+}
+
+func (m *MockScanner) GetScanFoldersCalls() []ScanFoldersCall {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ // Return a copy to avoid race conditions
+ calls := make([]ScanFoldersCall, len(m.scanFoldersCalls))
+ copy(calls, m.scanFoldersCalls)
+ return calls
+}
+
+func (m *MockScanner) Reset() {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.scanAllCalls = make([]ScanAllCall, 0)
+ m.scanFoldersCalls = make([]ScanFoldersCall, 0)
+}
+
+func (m *MockScanner) SetScanning(scanning bool) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.scanningStatus = scanning
+}
+
+func (m *MockScanner) SetStatusResponse(status *model.ScannerStatus) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.statusResponse = status
+}
diff --git a/ui/src/i18n/en.json b/ui/src/i18n/en.json
index 4a9039a67..9ef65d668 100644
--- a/ui/src/i18n/en.json
+++ b/ui/src/i18n/en.json
@@ -302,6 +302,8 @@
},
"actions": {
"scan": "Scan Library",
+ "quickScan": "Quick Scan",
+ "fullScan": "Full Scan",
"manageUsers": "Manage User Access",
"viewDetails": "View Details"
},
@@ -310,6 +312,9 @@
"updated": "Library updated successfully",
"deleted": "Library deleted successfully",
"scanStarted": "Library scan started",
+ "quickScanStarted": "Quick scan started",
+ "fullScanStarted": "Full scan started",
+ "scanError": "Error starting scan. Check logs",
"scanCompleted": "Library scan completed"
},
"validation": {
@@ -600,11 +605,12 @@
"activity": {
"title": "Activity",
"totalScanned": "Total Folders Scanned",
- "quickScan": "Quick Scan",
- "fullScan": "Full Scan",
+ "quickScan": "Quick",
+ "fullScan": "Full",
+ "selectiveScan": "Selective",
"serverUptime": "Server Uptime",
"serverDown": "OFFLINE",
- "scanType": "Type",
+ "scanType": "Last Scan",
"status": "Scan Error",
"elapsedTime": "Elapsed Time"
},
diff --git a/ui/src/layout/ActivityPanel.jsx b/ui/src/layout/ActivityPanel.jsx
index 18af8dc93..6d5d32d31 100644
--- a/ui/src/layout/ActivityPanel.jsx
+++ b/ui/src/layout/ActivityPanel.jsx
@@ -113,6 +113,9 @@ const ActivityPanel = () => {
return translate('activity.fullScan')
case 'quick':
return translate('activity.quickScan')
+ case 'full-selective':
+ case 'quick-selective':
+ return translate('activity.selectiveScan')
default:
return ''
}
diff --git a/ui/src/library/LibraryList.jsx b/ui/src/library/LibraryList.jsx
index 932732b10..f3032cbd1 100644
--- a/ui/src/library/LibraryList.jsx
+++ b/ui/src/library/LibraryList.jsx
@@ -10,6 +10,8 @@ import {
} from 'react-admin'
import { useMediaQuery } from '@material-ui/core'
import { List, DateField, useResourceRefresh, SizeField } from '../common'
+import LibraryListBulkActions from './LibraryListBulkActions'
+import LibraryListActions from './LibraryListActions'
const LibraryFilter = (props) => (
@@ -26,8 +28,9 @@ const LibraryList = (props) => {
{...props}
sort={{ field: 'name', order: 'ASC' }}
exporter={false}
- bulkActionButtons={false}
+ bulkActionButtons={!isXsmall && }
filters={ }
+ actions={ }
>
{isXsmall ? (
{
+ return (
+
+ {filters &&
+ cloneElement(filters, {
+ resource,
+ showFilter,
+ displayedFilters,
+ filterValues,
+ context: 'button',
+ })}
+
+
+
+ )
+}
+
+export default LibraryListActions
diff --git a/ui/src/library/LibraryListBulkActions.jsx b/ui/src/library/LibraryListBulkActions.jsx
new file mode 100644
index 000000000..8862a4f51
--- /dev/null
+++ b/ui/src/library/LibraryListBulkActions.jsx
@@ -0,0 +1,11 @@
+import React from 'react'
+import LibraryScanButton from './LibraryScanButton'
+
+const LibraryListBulkActions = (props) => (
+ <>
+
+
+ >
+)
+
+export default LibraryListBulkActions
diff --git a/ui/src/library/LibraryScanButton.jsx b/ui/src/library/LibraryScanButton.jsx
new file mode 100644
index 000000000..50d90e615
--- /dev/null
+++ b/ui/src/library/LibraryScanButton.jsx
@@ -0,0 +1,77 @@
+import React, { useState } from 'react'
+import PropTypes from 'prop-types'
+import {
+ Button,
+ useNotify,
+ useRefresh,
+ useTranslate,
+ useUnselectAll,
+} from 'react-admin'
+import { useSelector } from 'react-redux'
+import SyncIcon from '@material-ui/icons/Sync'
+import CachedIcon from '@material-ui/icons/Cached'
+import subsonic from '../subsonic'
+
+const LibraryScanButton = ({ fullScan, selectedIds, className }) => {
+ const [loading, setLoading] = useState(false)
+ const notify = useNotify()
+ const refresh = useRefresh()
+ const translate = useTranslate()
+ const unselectAll = useUnselectAll()
+ const scanStatus = useSelector((state) => state.activity.scanStatus)
+
+ const handleClick = async () => {
+ setLoading(true)
+ try {
+ // Build scan options
+ const options = { fullScan }
+
+ // If specific libraries are selected, scan only those
+ // Format: "libraryID:" to scan entire library (no folder path specified)
+ if (selectedIds && selectedIds.length > 0) {
+ options.target = selectedIds.map((id) => `${id}:`)
+ }
+
+ await subsonic.startScan(options)
+ const notificationKey = fullScan
+ ? 'resources.library.notifications.fullScanStarted'
+ : 'resources.library.notifications.quickScanStarted'
+ notify(notificationKey, 'info')
+ refresh()
+
+ // Unselect all items after successful scan
+ unselectAll('library')
+ } catch (error) {
+ notify('resources.library.notifications.scanError', 'warning')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const isDisabled = loading || scanStatus.scanning
+
+ const label = fullScan
+ ? translate('resources.library.actions.fullScan')
+ : translate('resources.library.actions.quickScan')
+
+ const icon = fullScan ? :
+
+ return (
+
+ {icon}
+
+ )
+}
+
+LibraryScanButton.propTypes = {
+ fullScan: PropTypes.bool.isRequired,
+ selectedIds: PropTypes.array,
+ className: PropTypes.string,
+}
+
+export default LibraryScanButton
diff --git a/ui/src/subsonic/index.js b/ui/src/subsonic/index.js
index ad7a391e0..cfcc01043 100644
--- a/ui/src/subsonic/index.js
+++ b/ui/src/subsonic/index.js
@@ -23,7 +23,13 @@ const url = (command, id, options) => {
delete options.ts
}
Object.keys(options).forEach((k) => {
- params.append(k, options[k])
+ const value = options[k]
+ // Handle array parameters by appending each value separately
+ if (Array.isArray(value)) {
+ value.forEach((v) => params.append(k, v))
+ } else {
+ params.append(k, value)
+ }
})
}
return `/rest/${command}?${params.toString()}`
diff --git a/utils/slice/slice.go b/utils/slice/slice.go
index 1d7c64f50..b1f50afcc 100644
--- a/utils/slice/slice.go
+++ b/utils/slice/slice.go
@@ -171,3 +171,14 @@ func SeqFunc[I, O any](s []I, f func(I) O) iter.Seq[O] {
}
}
}
+
+// Filter returns a new slice containing only the elements of s for which filterFunc returns true
+func Filter[T any](s []T, filterFunc func(T) bool) []T {
+ var result []T
+ for _, item := range s {
+ if filterFunc(item) {
+ result = append(result, item)
+ }
+ }
+ return result
+}
diff --git a/utils/slice/slice_test.go b/utils/slice/slice_test.go
index c6d4be1e0..65e5f0934 100644
--- a/utils/slice/slice_test.go
+++ b/utils/slice/slice_test.go
@@ -172,4 +172,42 @@ var _ = Describe("Slice Utils", func() {
Expect(result).To(ConsistOf("2", "4", "6", "8"))
})
})
+
+ Describe("Filter", func() {
+ It("returns empty slice for an empty input", func() {
+ filterFunc := func(v int) bool { return v > 0 }
+ result := slice.Filter([]int{}, filterFunc)
+ Expect(result).To(BeEmpty())
+ })
+
+ It("returns all elements when filter matches all", func() {
+ filterFunc := func(v int) bool { return v > 0 }
+ result := slice.Filter([]int{1, 2, 3, 4}, filterFunc)
+ Expect(result).To(HaveExactElements(1, 2, 3, 4))
+ })
+
+ It("returns empty slice when filter matches none", func() {
+ filterFunc := func(v int) bool { return v > 10 }
+ result := slice.Filter([]int{1, 2, 3, 4}, filterFunc)
+ Expect(result).To(BeEmpty())
+ })
+
+ It("returns only matching elements", func() {
+ filterFunc := func(v int) bool { return v%2 == 0 }
+ result := slice.Filter([]int{1, 2, 3, 4, 5, 6}, filterFunc)
+ Expect(result).To(HaveExactElements(2, 4, 6))
+ })
+
+ It("works with string slices", func() {
+ filterFunc := func(s string) bool { return len(s) > 3 }
+ result := slice.Filter([]string{"a", "abc", "abcd", "ab", "abcde"}, filterFunc)
+ Expect(result).To(HaveExactElements("abcd", "abcde"))
+ })
+
+ It("preserves order of elements", func() {
+ filterFunc := func(v int) bool { return v%2 == 1 }
+ result := slice.Filter([]int{9, 8, 7, 6, 5, 4, 3, 2, 1}, filterFunc)
+ Expect(result).To(HaveExactElements(9, 7, 5, 3, 1))
+ })
+ })
})
From 0161a0958c3e2ab7e296bb35e43df97e51babe6f Mon Sep 17 00:00:00 2001
From: Deluan
Date: Sat, 15 Nov 2025 17:31:37 -0500
Subject: [PATCH 048/102] fix(ui): add CreateButton back to LibraryListActions
Signed-off-by: Deluan
---
ui/src/library/LibraryListActions.jsx | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
diff --git a/ui/src/library/LibraryListActions.jsx b/ui/src/library/LibraryListActions.jsx
index f6f1ca90d..f4d0913df 100644
--- a/ui/src/library/LibraryListActions.jsx
+++ b/ui/src/library/LibraryListActions.jsx
@@ -1,5 +1,5 @@
import React, { cloneElement } from 'react'
-import { sanitizeListRestProps, TopToolbar } from 'react-admin'
+import { sanitizeListRestProps, TopToolbar, CreateButton } from 'react-admin'
import LibraryScanButton from './LibraryScanButton'
const LibraryListActions = ({
@@ -23,6 +23,7 @@ const LibraryListActions = ({
})}
+
)
}
From 395a36e10f2d3f4af8cccbfa81b0da1e556a0d36 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Sat, 15 Nov 2025 17:42:28 -0500
Subject: [PATCH 049/102] fix(ui): fix library selection state for
single-library users (#4686)
* fix: validate library selection state for single-library users
Fixes issues where users with a single library see no content when
selectedLibraries in localStorage contains library IDs they no longer
have access to (e.g., after removing libraries or switching accounts).
Changes:
- libraryReducer: Validate selectedLibraries when SET_USER_LIBRARIES
is dispatched, filtering out invalid IDs and resetting to empty for
single-library users (empty means 'all accessible libraries')
- wrapperDataProvider: Add defensive validation in getSelectedLibraries
to check against current user libraries before applying filters
- Add comprehensive test coverage for reducer validation logic
Fixes #4553, #4508, #4569
* style: format code with prettier
---
ui/src/dataProvider/wrapperDataProvider.js | 16 +-
ui/src/reducers/libraryReducer.js | 37 +++-
ui/src/reducers/libraryReducer.test.js | 186 +++++++++++++++++++++
3 files changed, 230 insertions(+), 9 deletions(-)
create mode 100644 ui/src/reducers/libraryReducer.test.js
diff --git a/ui/src/dataProvider/wrapperDataProvider.js b/ui/src/dataProvider/wrapperDataProvider.js
index 8b4a0cb62..268d3668d 100644
--- a/ui/src/dataProvider/wrapperDataProvider.js
+++ b/ui/src/dataProvider/wrapperDataProvider.js
@@ -12,7 +12,21 @@ const isAdmin = () => {
const getSelectedLibraries = () => {
try {
const state = JSON.parse(localStorage.getItem('state'))
- return state?.library?.selectedLibraries || []
+ const selectedLibraries = state?.library?.selectedLibraries || []
+ const userLibraries = state?.library?.userLibraries || []
+
+ // Validate selected libraries against current user libraries
+ const userLibraryIds = userLibraries.map((lib) => lib.id)
+ const validatedSelection = selectedLibraries.filter((id) =>
+ userLibraryIds.includes(id),
+ )
+
+ // If user has only one library, return empty array (no filter needed)
+ if (userLibraryIds.length === 1) {
+ return []
+ }
+
+ return validatedSelection
} catch (err) {
return []
}
diff --git a/ui/src/reducers/libraryReducer.js b/ui/src/reducers/libraryReducer.js
index 7cda10bcf..ef613260f 100644
--- a/ui/src/reducers/libraryReducer.js
+++ b/ui/src/reducers/libraryReducer.js
@@ -8,18 +8,39 @@ const initialState = {
export const libraryReducer = (previousState = initialState, payload) => {
const { type, data } = payload
switch (type) {
- case SET_USER_LIBRARIES:
+ case SET_USER_LIBRARIES: {
+ const newUserLibraryIds = data.map((lib) => lib.id)
+
+ // Validate and filter selected libraries to only include IDs that exist in new user libraries
+ const validatedSelection = previousState.selectedLibraries.filter((id) =>
+ newUserLibraryIds.includes(id),
+ )
+
+ // Determine the final selection:
+ // 1. If first time setting libraries (no previous user libraries), select all
+ // 2. If user now has only one library, reset to empty (no filter needed)
+ // 3. Otherwise, use validated selection (may be empty if all previous selections were invalid)
+ let finalSelection
+ if (
+ previousState.selectedLibraries.length === 0 &&
+ previousState.userLibraries.length === 0
+ ) {
+ // First time: select all libraries
+ finalSelection = newUserLibraryIds
+ } else if (newUserLibraryIds.length === 1) {
+ // Single library: reset selection (empty means "all accessible")
+ finalSelection = []
+ } else {
+ // Multiple libraries: use validated selection
+ finalSelection = validatedSelection
+ }
+
return {
...previousState,
userLibraries: data,
- // If this is the first time setting user libraries and no selection exists,
- // default to all libraries
- selectedLibraries:
- previousState.selectedLibraries.length === 0 &&
- previousState.userLibraries.length === 0
- ? data.map((lib) => lib.id)
- : previousState.selectedLibraries,
+ selectedLibraries: finalSelection,
}
+ }
case SET_SELECTED_LIBRARIES:
return {
...previousState,
diff --git a/ui/src/reducers/libraryReducer.test.js b/ui/src/reducers/libraryReducer.test.js
new file mode 100644
index 000000000..b962c1036
--- /dev/null
+++ b/ui/src/reducers/libraryReducer.test.js
@@ -0,0 +1,186 @@
+import { describe, it, expect } from 'vitest'
+import { libraryReducer } from './libraryReducer'
+import { SET_SELECTED_LIBRARIES, SET_USER_LIBRARIES } from '../actions'
+
+describe('libraryReducer', () => {
+ const mockLibraries = [
+ { id: '1', name: 'Music Library' },
+ { id: '2', name: 'Podcasts' },
+ { id: '3', name: 'Audiobooks' },
+ ]
+
+ const initialState = {
+ userLibraries: [],
+ selectedLibraries: [],
+ }
+
+ describe('SET_USER_LIBRARIES', () => {
+ it('should set user libraries and select all on first load', () => {
+ const action = {
+ type: SET_USER_LIBRARIES,
+ data: mockLibraries,
+ }
+
+ const result = libraryReducer(initialState, action)
+
+ expect(result.userLibraries).toEqual(mockLibraries)
+ expect(result.selectedLibraries).toEqual(['1', '2', '3'])
+ })
+
+ it('should reset selection to empty when user has only one library', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2'],
+ }
+
+ const action = {
+ type: SET_USER_LIBRARIES,
+ data: [mockLibraries[0]], // Only one library now
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result.userLibraries).toEqual([mockLibraries[0]])
+ expect(result.selectedLibraries).toEqual([]) // Reset for single library
+ })
+
+ it('should filter out invalid library IDs from selection', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2', '3'],
+ }
+
+ const action = {
+ type: SET_USER_LIBRARIES,
+ data: [mockLibraries[0], mockLibraries[1]], // Only libraries 1 and 2 remain
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result.userLibraries).toEqual([mockLibraries[0], mockLibraries[1]])
+ expect(result.selectedLibraries).toEqual(['1', '2']) // Library 3 removed
+ })
+
+ it('should keep valid selection when libraries change', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1'],
+ }
+
+ const action = {
+ type: SET_USER_LIBRARIES,
+ data: mockLibraries, // Same libraries
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result.userLibraries).toEqual(mockLibraries)
+ expect(result.selectedLibraries).toEqual(['1']) // Selection preserved
+ })
+
+ it('should handle selection becoming empty after filtering invalid IDs', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2'],
+ }
+
+ const newLibraries = [{ id: '4', name: 'New Library' }]
+ const action = {
+ type: SET_USER_LIBRARIES,
+ data: newLibraries,
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result.userLibraries).toEqual(newLibraries)
+ expect(result.selectedLibraries).toEqual([]) // All selected IDs were invalid
+ })
+
+ it('should handle transition from multiple to single library with invalid selection', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['2', '3'], // User had libraries 2 and 3 selected
+ }
+
+ const action = {
+ type: SET_USER_LIBRARIES,
+ data: [mockLibraries[0]], // Now only has access to library 1
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result.userLibraries).toEqual([mockLibraries[0]])
+ expect(result.selectedLibraries).toEqual([]) // Reset for single library
+ })
+
+ it('should handle empty library list', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2'],
+ }
+
+ const action = {
+ type: SET_USER_LIBRARIES,
+ data: [],
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result.userLibraries).toEqual([])
+ expect(result.selectedLibraries).toEqual([]) // All selections filtered out
+ })
+ })
+
+ describe('SET_SELECTED_LIBRARIES', () => {
+ it('should update selected libraries', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1'],
+ }
+
+ const action = {
+ type: SET_SELECTED_LIBRARIES,
+ data: ['2', '3'],
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result.selectedLibraries).toEqual(['2', '3'])
+ expect(result.userLibraries).toEqual(mockLibraries) // Unchanged
+ })
+
+ it('should allow setting empty selection', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1', '2'],
+ }
+
+ const action = {
+ type: SET_SELECTED_LIBRARIES,
+ data: [],
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result.selectedLibraries).toEqual([])
+ })
+ })
+
+ describe('unknown action', () => {
+ it('should return previous state for unknown action', () => {
+ const previousState = {
+ userLibraries: mockLibraries,
+ selectedLibraries: ['1'],
+ }
+
+ const action = {
+ type: 'UNKNOWN_ACTION',
+ data: null,
+ }
+
+ const result = libraryReducer(previousState, action)
+
+ expect(result).toBe(previousState) // Same reference
+ })
+ })
+})
From 0f1ede25817b837af6d4a39078de74560a102880 Mon Sep 17 00:00:00 2001
From: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
Date: Sun, 16 Nov 2025 17:54:28 +0000
Subject: [PATCH 050/102] fix(scanner): specify exact table to use for missing
mediafile filter (#4689)
In `getAffectedAlbumIDs`, when one or more IDs is added, it adds a filter `"id": ids`.
This filter is ambiguous though, because the `getAll` query joins with library table, which _also_ has an `id` field.
Clarify this by adding the table name to the filter.
Note that this was not caught in testing, as it only uses mock db.
---
core/maintenance.go | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/core/maintenance.go b/core/maintenance.go
index c2f65d74f..750fd3a9e 100644
--- a/core/maintenance.go
+++ b/core/maintenance.go
@@ -166,7 +166,7 @@ func (s *maintenanceService) getAffectedAlbumIDs(ctx context.Context, ids []stri
if len(ids) > 0 {
filters = squirrel.And{
squirrel.Eq{"missing": true},
- squirrel.Eq{"id": ids},
+ squirrel.Eq{"media_file.id": ids},
}
}
From 489d5c7760e770b43e4a323aa709be787a991826 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Sun, 16 Nov 2025 13:41:22 -0500
Subject: [PATCH 051/102] test: update make test-race target to use PKG
variable for improved flexibility
Signed-off-by: Deluan
---
Makefile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Makefile b/Makefile
index df8155f56..2a60b7165 100644
--- a/Makefile
+++ b/Makefile
@@ -54,7 +54,7 @@ testall: test-race test-i18n test-js ##@Development Run Go and JS tests
.PHONY: testall
test-race: ##@Development Run Go tests with race detector
- go test -tags netgo -race -shuffle=on ./...
+ go test -tags netgo -race -shuffle=on $(PKG)
.PHONY: test-race
test-js: ##@Development Run JS tests
From 32e1313fc6ddf7100af094d14df13d47735a44bf Mon Sep 17 00:00:00 2001
From: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
Date: Sun, 16 Nov 2025 18:46:32 +0000
Subject: [PATCH 052/102] ci: bump plugin compilation timeout for regressions
(#4690)
---
plugins/manager_test.go | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/plugins/manager_test.go b/plugins/manager_test.go
index 207908ebc..8b361f8b3 100644
--- a/plugins/manager_test.go
+++ b/plugins/manager_test.go
@@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
+ "time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/core/agents"
@@ -22,8 +23,11 @@ var _ = Describe("Plugin Manager", func() {
// but, as this is an integration test, we can't use configtest.SetupConfig() as it causes
// data races.
originalPluginsFolder := conf.Server.Plugins.Folder
+ originalTimeout := conf.Server.DevPluginCompilationTimeout
+ conf.Server.DevPluginCompilationTimeout = 2 * time.Minute
DeferCleanup(func() {
conf.Server.Plugins.Folder = originalPluginsFolder
+ conf.Server.DevPluginCompilationTimeout = originalTimeout
})
conf.Server.Plugins.Enabled = true
conf.Server.Plugins.Folder = testDataDir
From 6fb228bc1044e4f97ccac31e9c753a05fbef84c8 Mon Sep 17 00:00:00 2001
From: Dongeun <28642090+dongeunm@users.noreply.github.com>
Date: Thu, 20 Nov 2025 02:42:33 +0800
Subject: [PATCH 053/102] fix(ui): fix translation display for library list
terms (#4712)
---
ui/src/library/LibraryList.jsx | 12 ++++--------
1 file changed, 4 insertions(+), 8 deletions(-)
diff --git a/ui/src/library/LibraryList.jsx b/ui/src/library/LibraryList.jsx
index f3032cbd1..aa1294882 100644
--- a/ui/src/library/LibraryList.jsx
+++ b/ui/src/library/LibraryList.jsx
@@ -42,15 +42,11 @@ const LibraryList = (props) => {
-
-
-
+
+
+
-
+
)}
From 3d1946e31c3df26cb123a13b7064b941302123cc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Wed, 19 Nov 2025 20:17:01 -0500
Subject: [PATCH 054/102] fix(plugins): avoid Chi RouteContext pollution by
using http.NewRequest (#4713)
Signed-off-by: Deluan
---
plugins/host_subsonicapi.go | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/plugins/host_subsonicapi.go b/plugins/host_subsonicapi.go
index d3008798a..937dd044f 100644
--- a/plugins/host_subsonicapi.go
+++ b/plugins/host_subsonicapi.go
@@ -93,8 +93,12 @@ func (s *subsonicAPIServiceImpl) Call(ctx context.Context, req *subsonicapi.Call
RawQuery: query.Encode(),
}
- // Create HTTP request with internal authentication
- httpReq, err := http.NewRequestWithContext(ctx, "GET", finalURL.String(), nil)
+ // Create HTTP request with a fresh context to avoid Chi RouteContext pollution.
+ // Using http.NewRequest (instead of http.NewRequestWithContext) ensures the internal
+ // SubsonicAPI call doesn't inherit routing information from the parent handler,
+ // which would cause Chi to invoke the wrong handler. Authentication context is
+ // explicitly added in the next step via request.WithInternalAuth.
+ httpReq, err := http.NewRequest("GET", finalURL.String(), nil)
if err != nil {
return &subsonicapi.CallResponse{
Error: fmt.Sprintf("failed to create HTTP request: %v", err),
From c873466e5b33a5782e62ee25bafe31c92e636f21 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Wed, 19 Nov 2025 20:24:13 -0500
Subject: [PATCH 055/102] fix(scanner): reset watcher trigger timer for
debounce on notification receipt
Signed-off-by: Deluan
---
scanner/watcher.go | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/scanner/watcher.go b/scanner/watcher.go
index ad9a06421..3efebaacc 100644
--- a/scanner/watcher.go
+++ b/scanner/watcher.go
@@ -122,6 +122,9 @@ func (w *watcher) Run(ctx context.Context) error {
w.mu.Unlock()
return nil
case notification := <-w.watcherNotify:
+ // Reset the trigger timer for debounce
+ trigger.Reset(w.triggerWait)
+
lib := notification.Library
folderPath := notification.FolderPath
@@ -131,7 +134,6 @@ func (w *watcher) Run(ctx context.Context) error {
continue
}
targets[target] = struct{}{}
- trigger.Reset(w.triggerWait)
log.Debug(ctx, "Watcher: Detected changes. Waiting for more changes before triggering scan",
"libraryID", lib.ID, "name", lib.Name, "path", lib.Path, "folderPath", folderPath)
From 353aff2c88e287e9cc40d4f5266b1b8dd757960e Mon Sep 17 00:00:00 2001
From: Deluan
Date: Wed, 19 Nov 2025 20:49:29 -0500
Subject: [PATCH 056/102] fix(lastfm): ignore artist placeholder image.
Fix #4702
Signed-off-by: Deluan
---
core/agents/lastfm/agent.go | 27 +++++---
core/agents/lastfm/agent_test.go | 69 +++++++++++++++++++
tests/fixtures/lastfm.artist.page.html | 7 ++
.../fixtures/lastfm.artist.page.ignored.html | 7 ++
.../fixtures/lastfm.artist.page.no_meta.html | 6 ++
5 files changed, 106 insertions(+), 10 deletions(-)
create mode 100644 tests/fixtures/lastfm.artist.page.html
create mode 100644 tests/fixtures/lastfm.artist.page.ignored.html
create mode 100644 tests/fixtures/lastfm.artist.page.no_meta.html
diff --git a/core/agents/lastfm/agent.go b/core/agents/lastfm/agent.go
index d01b496ec..fafa6afec 100644
--- a/core/agents/lastfm/agent.go
+++ b/core/agents/lastfm/agent.go
@@ -38,6 +38,7 @@ type lastfmAgent struct {
secret string
lang string
client *client
+ httpClient httpDoer
getInfoMutex sync.Mutex
}
@@ -56,6 +57,7 @@ func lastFMConstructor(ds model.DataStore) *lastfmAgent {
Timeout: consts.DefaultHttpClientTimeOut,
}
chc := cache.NewHTTPClient(hc, consts.DefaultHttpClientTimeOut)
+ l.httpClient = chc
l.client = newClient(l.apiKey, l.secret, l.lang, chc)
return l
}
@@ -190,13 +192,13 @@ func (l *lastfmAgent) GetArtistTopSongs(ctx context.Context, id, artistName, mbi
return res, nil
}
-var artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
+var (
+ artistOpenGraphQuery = cascadia.MustCompile(`html > head > meta[property="og:image"]`)
+ artistIgnoredImage = "2a96cbd8b46e442fc41c2b86b821562f" // Last.fm artist placeholder image name
+)
func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string) ([]agents.ExternalImage, error) {
log.Debug(ctx, "Getting artist images from Last.fm", "name", name)
- hc := http.Client{
- Timeout: consts.DefaultHttpClientTimeOut,
- }
a, err := l.callArtistGetInfo(ctx, name)
if err != nil {
return nil, fmt.Errorf("get artist info: %w", err)
@@ -205,7 +207,7 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
if err != nil {
return nil, fmt.Errorf("create artist image request: %w", err)
}
- resp, err := hc.Do(req)
+ resp, err := l.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("get artist url: %w", err)
}
@@ -222,11 +224,16 @@ func (l *lastfmAgent) GetArtistImages(ctx context.Context, _, name, mbid string)
return res, nil
}
for _, attr := range n.Attr {
- if attr.Key == "content" {
- res = []agents.ExternalImage{
- {URL: attr.Val},
- }
- break
+ if attr.Key != "content" {
+ continue
+ }
+ if strings.Contains(attr.Val, artistIgnoredImage) {
+ log.Debug(ctx, "Artist image is ignored default image", "name", name, "url", attr.Val)
+ return res, nil
+ }
+
+ res = []agents.ExternalImage{
+ {URL: attr.Val},
}
}
return res, nil
diff --git a/core/agents/lastfm/agent_test.go b/core/agents/lastfm/agent_test.go
index 4476d592f..18e7facf2 100644
--- a/core/agents/lastfm/agent_test.go
+++ b/core/agents/lastfm/agent_test.go
@@ -393,4 +393,73 @@ var _ = Describe("lastfmAgent", func() {
})
})
})
+
+ Describe("GetArtistImages", func() {
+ var agent *lastfmAgent
+ var apiClient *tests.FakeHttpClient
+ var httpClient *tests.FakeHttpClient
+
+ BeforeEach(func() {
+ apiClient = &tests.FakeHttpClient{}
+ httpClient = &tests.FakeHttpClient{}
+ client := newClient("API_KEY", "SECRET", "pt", apiClient)
+ agent = lastFMConstructor(ds)
+ agent.client = client
+ agent.httpClient = httpClient
+ })
+
+ It("returns the artist image from the page", func() {
+ fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
+ apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
+
+ fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.html")
+ httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
+
+ images, err := agent.GetArtistImages(ctx, "123", "U2", "")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(images).To(HaveLen(1))
+ Expect(images[0].URL).To(Equal("https://lastfm.freetls.fastly.net/i/u/ar0/818148bf682d429dc21b59a73ef6f68e.png"))
+ })
+
+ It("returns empty list if image is the ignored default image", func() {
+ fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
+ apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
+
+ fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.ignored.html")
+ httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
+
+ images, err := agent.GetArtistImages(ctx, "123", "U2", "")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(images).To(BeEmpty())
+ })
+
+ It("returns empty list if page has no meta tags", func() {
+ fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
+ apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
+
+ fScraper, _ := os.Open("tests/fixtures/lastfm.artist.page.no_meta.html")
+ httpClient.Res = http.Response{Body: fScraper, StatusCode: 200}
+
+ images, err := agent.GetArtistImages(ctx, "123", "U2", "")
+ Expect(err).ToNot(HaveOccurred())
+ Expect(images).To(BeEmpty())
+ })
+
+ It("returns error if API call fails", func() {
+ apiClient.Err = errors.New("api error")
+ _, err := agent.GetArtistImages(ctx, "123", "U2", "")
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("get artist info"))
+ })
+
+ It("returns error if scraper call fails", func() {
+ fApi, _ := os.Open("tests/fixtures/lastfm.artist.getinfo.json")
+ apiClient.Res = http.Response{Body: fApi, StatusCode: 200}
+
+ httpClient.Err = errors.New("scraper error")
+ _, err := agent.GetArtistImages(ctx, "123", "U2", "")
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("get artist url"))
+ })
+ })
})
diff --git a/tests/fixtures/lastfm.artist.page.html b/tests/fixtures/lastfm.artist.page.html
new file mode 100644
index 000000000..1922e313b
--- /dev/null
+++ b/tests/fixtures/lastfm.artist.page.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/fixtures/lastfm.artist.page.ignored.html b/tests/fixtures/lastfm.artist.page.ignored.html
new file mode 100644
index 000000000..96eda2377
--- /dev/null
+++ b/tests/fixtures/lastfm.artist.page.ignored.html
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/tests/fixtures/lastfm.artist.page.no_meta.html b/tests/fixtures/lastfm.artist.page.no_meta.html
new file mode 100644
index 000000000..aa7b9c934
--- /dev/null
+++ b/tests/fixtures/lastfm.artist.page.no_meta.html
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
From 0c3012bbbdf232e3aeffd461ee05422e6f83829d Mon Sep 17 00:00:00 2001
From: Deluan
Date: Wed, 19 Nov 2025 22:05:46 -0500
Subject: [PATCH 057/102] chore(deps): update Go dependencies to latest
versions
Signed-off-by: Deluan
---
go.mod | 20 ++++++++++----------
go.sum | 40 ++++++++++++++++++++--------------------
2 files changed, 30 insertions(+), 30 deletions(-)
diff --git a/go.mod b/go.mod
index 5a6a99070..d80c900e9 100644
--- a/go.mod
+++ b/go.mod
@@ -57,16 +57,16 @@ require (
github.com/spf13/cobra v1.10.1
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
- github.com/tetratelabs/wazero v1.10.0
+ github.com/tetratelabs/wazero v1.10.1
github.com/unrolled/secure v1.17.0
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342
go.uber.org/goleak v1.3.0
- golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546
- golang.org/x/image v0.32.0
- golang.org/x/net v0.46.0
+ golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6
+ golang.org/x/image v0.33.0
+ golang.org/x/net v0.47.0
golang.org/x/sync v0.18.0
golang.org/x/sys v0.38.0
- golang.org/x/text v0.30.0
+ golang.org/x/text v0.31.0
golang.org/x/time v0.14.0
google.golang.org/protobuf v1.36.10
gopkg.in/yaml.v3 v3.0.1
@@ -90,7 +90,7 @@ require (
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
- github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d // indirect
+ github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 // 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
@@ -128,10 +128,10 @@ require (
go.uber.org/multierr v1.11.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
- golang.org/x/crypto v0.43.0 // indirect
- golang.org/x/mod v0.29.0 // indirect
- golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect
- golang.org/x/tools v0.38.0 // indirect
+ golang.org/x/crypto v0.45.0 // indirect
+ golang.org/x/mod v0.30.0 // indirect
+ golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect
+ golang.org/x/tools v0.39.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce // indirect
)
diff --git a/go.sum b/go.sum
index 7cda0ce8d..77c0cbb40 100644
--- a/go.sum
+++ b/go.sum
@@ -99,8 +99,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-20251007162407-5df77e3f7d1d h1:KJIErDwbSHjnp/SGzE5ed8Aol7JsKiI5X7yWKAtzhM0=
-github.com/google/pprof v0.0.0-20251007162407-5df77e3f7d1d/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
+github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8 h1:3DsUAV+VNEQa2CUVLxCY3f87278uWfIDhJnbdvDjvmE=
+github.com/google/pprof v0.0.0-20251114195745-4902fdda35c8/go.mod h1:I6V7YzU0XDpsHqbsyrghnFZLO1gwK6NPTNvmetQIk9U=
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=
@@ -265,8 +265,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
-github.com/tetratelabs/wazero v1.10.0 h1:CXP3zneLDl6J4Zy8N/J+d5JsWKfrjE6GtvVK1fpnDlk=
-github.com/tetratelabs/wazero v1.10.0/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
+github.com/tetratelabs/wazero v1.10.1 h1:2DugeJf6VVk58KTPszlNfeeN8AhhpwcZqkJj2wwFuH8=
+github.com/tetratelabs/wazero v1.10.1/go.mod h1:DRm5twOQ5Gr1AoEdSi0CLjDQF1J9ZAuyqFIjl1KKfQU=
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=
@@ -298,20 +298,20 @@ 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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
-golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
-golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
-golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
+golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
+golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
+golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 h1:zfMcR1Cs4KNuomFFgGefv5N0czO2XZpUbxGUy8i8ug0=
+golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
-golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
-golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
+golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
+golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
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.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
-golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
+golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
+golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
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=
@@ -323,8 +323,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.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
-golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
+golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
+golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
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=
@@ -353,8 +353,8 @@ golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
-golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU=
-golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=
+golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 h1:E2/AqCUMZGgd73TQkxUMcMla25GB9i/5HOdLr+uH7Vo=
+golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=
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=
@@ -373,8 +373,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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
-golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
+golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
+golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -384,8 +384,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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
-golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
+golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
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.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
From 36fa869329ca4922635abcd4446bb5f9aebaae7f Mon Sep 17 00:00:00 2001
From: Deluan
Date: Thu, 20 Nov 2025 09:27:42 -0500
Subject: [PATCH 058/102] feat(scanner): improve error messages for cleanup
operations in annotations, bookmarks, and tags
Signed-off-by: Deluan
---
persistence/sql_annotations.go | 2 +-
persistence/sql_bookmarks.go | 4 ++--
persistence/tag_repository.go | 4 ++--
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go
index 6691b553c..98ade6e21 100644
--- a/persistence/sql_annotations.go
+++ b/persistence/sql_annotations.go
@@ -119,7 +119,7 @@ func (r sqlRepository) cleanAnnotations() error {
del := Delete(annotationTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
c, err := r.executeSQL(del)
if err != nil {
- return fmt.Errorf("error cleaning up annotations: %w", err)
+ return fmt.Errorf("error cleaning up %s annotations: %w", r.tableName, err)
}
if c > 0 {
log.Debug(r.ctx, "Clean-up annotations", "table", r.tableName, "totalDeleted", c)
diff --git a/persistence/sql_bookmarks.go b/persistence/sql_bookmarks.go
index 52c4b8e9c..9164aed9d 100644
--- a/persistence/sql_bookmarks.go
+++ b/persistence/sql_bookmarks.go
@@ -148,10 +148,10 @@ func (r sqlRepository) cleanBookmarks() error {
del := Delete(bookmarkTable).Where(Eq{"item_type": r.tableName}).Where("item_id not in (select id from " + r.tableName + ")")
c, err := r.executeSQL(del)
if err != nil {
- return fmt.Errorf("error cleaning up bookmarks: %w", err)
+ return fmt.Errorf("error cleaning up %s bookmarks: %w", r.tableName, err)
}
if c > 0 {
- log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c)
+ log.Debug(r.ctx, "Clean-up bookmarks", "totalDeleted", c, "itemType", r.tableName)
}
return nil
}
diff --git a/persistence/tag_repository.go b/persistence/tag_repository.go
index b224450ab..5bb8b3832 100644
--- a/persistence/tag_repository.go
+++ b/persistence/tag_repository.go
@@ -88,10 +88,10 @@ func (r *tagRepository) purgeUnused() error {
`)
c, err := r.executeSQL(del)
if err != nil {
- return fmt.Errorf("error purging unused tags: %w", err)
+ return fmt.Errorf("error purging %s unused tags: %w", r.tableName, err)
}
if c > 0 {
- log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c)
+ log.Debug(r.ctx, "Purged unused tags", "totalDeleted", c, "table", r.tableName)
}
return err
}
From 5c1662250179bff1e8996decf83934bda2adca7e Mon Sep 17 00:00:00 2001
From: Deluan
Date: Thu, 20 Nov 2025 10:38:40 -0500
Subject: [PATCH 059/102] chore(makefile): update golangci-lint version to
v2.6.2
See comment https://github.com/navidrome/navidrome/commit/0c71842b12295dabfd3e14bfb5c8175312dde5fd#commitcomment-170969373
Signed-off-by: Deluan
---
go.mod | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/go.mod b/go.mod
index d80c900e9..f680bda51 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module github.com/navidrome/navidrome
-go 1.25.4
+go 1.25
// Fork to fix https://github.com/navidrome/navidrome/issues/3254
replace github.com/dhowden/tag v0.0.0-20240417053706-3d75831295e8 => github.com/deluan/tag v0.0.0-20241002021117-dfe5e6ea396d
From 152f57e6424081164a61f5d5729923927b7fe91c Mon Sep 17 00:00:00 2001
From: Deluan
Date: Thu, 20 Nov 2025 10:38:54 -0500
Subject: [PATCH 060/102] chore(deps): update golangci-lint version to v2.6.2
Signed-off-by: Deluan
---
Makefile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Makefile b/Makefile
index 2a60b7165..1de789c11 100644
--- a/Makefile
+++ b/Makefile
@@ -16,7 +16,7 @@ DOCKER_TAG ?= deluan/navidrome:develop
# Taglib version to use in cross-compilation, from https://github.com/navidrome/cross-taglib
CROSS_TAGLIB_VERSION ?= 2.1.1-1
-GOLANGCI_LINT_VERSION ?= v2.5.0
+GOLANGCI_LINT_VERSION ?= v2.6.2
UI_SRC_FILES := $(shell find ui -type f -not -path "ui/build/*" -not -path "ui/node_modules/*")
From 255ed1f8e2285c6dd1938c71225726b8e4765f21 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Fri, 21 Nov 2025 15:09:24 -0500
Subject: [PATCH 061/102] feat(deezer): Add artist bio, top tracks, related
artists and language support (#4720)
* feat(deezer): add functions to fetch related artists, biographies, and top tracks for an artist
Signed-off-by: Deluan
* feat(deezer): add language support for Deezer API client
Signed-off-by: Deluan
* fix(deezer): Use GraphQL API for translated biographies
The previous implementation scraped the __DZR_APP_STATE__ from HTML,
which only contained English content. The actual biography displayed
on Deezer's website comes from their GraphQL API at pipe.deezer.com,
which properly respects the Accept-Language header and returns
translated content.
This change:
- Switches from HTML scraping to the GraphQL API
- Uses Accept-Language header instead of URL path for language
- Updates tests to match the new implementation
- Removes unused HTML fixture file
Signed-off-by: Deluan
* refactor(deezer): move JWT token handling to a separate file for better organization
Signed-off-by: Deluan
* feat(deezer): enhance JWT token handling with expiration validation
Signed-off-by: Deluan
* refactor(deezer): change log level for unknown agent warnings from Warn to Debug
Signed-off-by: Deluan
* fix(deezer): reduce JWT token expiration buffer from 10 minutes to 1 minute
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
---
conf/configuration.go | 4 +-
core/agents/agents.go | 2 +-
core/agents/deezer/client.go | 141 ++++++++++-
core/agents/deezer/client_auth.go | 101 ++++++++
core/agents/deezer/client_auth_test.go | 293 ++++++++++++++++++++++
core/agents/deezer/client_test.go | 135 +++++++++-
core/agents/deezer/deezer.go | 53 +++-
core/agents/deezer/responses.go | 35 +++
core/agents/deezer/responses_test.go | 31 +++
tests/fixtures/deezer.artist.bio.json | 9 +
tests/fixtures/deezer.artist.related.json | 1 +
tests/fixtures/deezer.artist.top.json | 1 +
12 files changed, 796 insertions(+), 10 deletions(-)
create mode 100644 core/agents/deezer/client_auth.go
create mode 100644 core/agents/deezer/client_auth_test.go
create mode 100644 tests/fixtures/deezer.artist.bio.json
create mode 100644 tests/fixtures/deezer.artist.related.json
create mode 100644 tests/fixtures/deezer.artist.top.json
diff --git a/conf/configuration.go b/conf/configuration.go
index a9fee00e4..0ad81492a 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -176,7 +176,8 @@ type spotifyOptions struct {
}
type deezerOptions struct {
- Enabled bool
+ Enabled bool
+ Language string
}
type listenBrainzOptions struct {
@@ -566,6 +567,7 @@ func setViperDefaults() {
viper.SetDefault("spotify.id", "")
viper.SetDefault("spotify.secret", "")
viper.SetDefault("deezer.enabled", true)
+ viper.SetDefault("deezer.language", "en")
viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
diff --git a/core/agents/agents.go b/core/agents/agents.go
index 4ec324b71..cb10d2c4c 100644
--- a/core/agents/agents.go
+++ b/core/agents/agents.go
@@ -87,7 +87,7 @@ func (a *Agents) getEnabledAgentNames() []enabledAgent {
} else if isPlugin {
validAgents = append(validAgents, enabledAgent{name: name, isPlugin: true})
} else {
- log.Warn("Unknown agent ignored", "name", name)
+ log.Debug("Unknown agent ignored", "name", name)
}
}
return validAgents
diff --git a/core/agents/deezer/client.go b/core/agents/deezer/client.go
index e75526d80..32d93bad6 100644
--- a/core/agents/deezer/client.go
+++ b/core/agents/deezer/client.go
@@ -1,6 +1,7 @@
package deezer
import (
+ bytes "bytes"
"context"
"encoding/json"
"errors"
@@ -9,11 +10,14 @@ import (
"net/http"
"net/url"
"strconv"
+ "strings"
+ "github.com/microcosm-cc/bluemonday"
"github.com/navidrome/navidrome/log"
)
const apiBaseURL = "https://api.deezer.com"
+const authBaseURL = "https://auth.deezer.com"
var (
ErrNotFound = errors.New("deezer: not found")
@@ -25,10 +29,15 @@ type httpDoer interface {
type client struct {
httpDoer httpDoer
+ language string
+ jwt jwtToken
}
-func newClient(hc httpDoer) *client {
- return &client{hc}
+func newClient(hc httpDoer, language string) *client {
+ return &client{
+ httpDoer: hc,
+ language: language,
+ }
}
func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]Artist, error) {
@@ -53,7 +62,7 @@ func (c *client) searchArtists(ctx context.Context, name string, limit int) ([]A
return results.Data, nil
}
-func (c *client) makeRequest(req *http.Request, response interface{}) error {
+func (c *client) makeRequest(req *http.Request, response any) error {
log.Trace(req.Context(), fmt.Sprintf("Sending Deezer %s request", req.Method), "url", req.URL)
resp, err := c.httpDoer.Do(req)
if err != nil {
@@ -81,3 +90,129 @@ func (c *client) parseError(data []byte) error {
}
return fmt.Errorf("deezer error(%d): %s", deezerError.Error.Code, deezerError.Error.Message)
}
+
+func (c *client) getRelatedArtists(ctx context.Context, artistID int) ([]Artist, error) {
+ req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/related", apiBaseURL, artistID), nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var results RelatedArtists
+ err = c.makeRequest(req, &results)
+ if err != nil {
+ return nil, err
+ }
+
+ return results.Data, nil
+}
+
+func (c *client) getTopTracks(ctx context.Context, artistID int, limit int) ([]Track, error) {
+ params := url.Values{}
+ params.Add("limit", strconv.Itoa(limit))
+ req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("%s/artist/%d/top", apiBaseURL, artistID), nil)
+ if err != nil {
+ return nil, err
+ }
+ req.URL.RawQuery = params.Encode()
+
+ var results TopTracks
+ err = c.makeRequest(req, &results)
+ if err != nil {
+ return nil, err
+ }
+
+ return results.Data, nil
+}
+
+const pipeAPIURL = "https://pipe.deezer.com/api"
+
+var strictPolicy = bluemonday.StrictPolicy()
+
+func (c *client) getArtistBio(ctx context.Context, artistID int) (string, error) {
+ jwt, err := c.getJWT(ctx)
+ if err != nil {
+ return "", fmt.Errorf("deezer: failed to get JWT: %w", err)
+ }
+
+ query := map[string]any{
+ "operationName": "ArtistBio",
+ "variables": map[string]any{
+ "artistId": strconv.Itoa(artistID),
+ },
+ "query": `query ArtistBio($artistId: String!) {
+ artist(artistId: $artistId) {
+ bio {
+ full
+ }
+ }
+ }`,
+ }
+
+ body, err := json.Marshal(query)
+ if err != nil {
+ return "", err
+ }
+
+ req, err := http.NewRequestWithContext(ctx, "POST", pipeAPIURL, bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept-Language", c.language)
+ req.Header.Set("Authorization", "Bearer "+jwt)
+
+ log.Trace(ctx, "Fetching Deezer artist biography via GraphQL", "artistId", artistID, "language", c.language)
+ resp, err := c.httpDoer.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return "", fmt.Errorf("deezer: failed to fetch biography: %s", resp.Status)
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+
+ type graphQLResponse struct {
+ Data struct {
+ Artist struct {
+ Bio struct {
+ Full string `json:"full"`
+ } `json:"bio"`
+ } `json:"artist"`
+ } `json:"data"`
+ Errors []struct {
+ Message string `json:"message"`
+ }
+ }
+
+ var result graphQLResponse
+ if err := json.Unmarshal(data, &result); err != nil {
+ return "", fmt.Errorf("deezer: failed to parse GraphQL response: %w", err)
+ }
+
+ if len(result.Errors) > 0 {
+ var errs []error
+ for m := range result.Errors {
+ errs = append(errs, errors.New(result.Errors[m].Message))
+ }
+ err := errors.Join(errs...)
+ return "", fmt.Errorf("deezer: GraphQL error: %w", err)
+ }
+
+ if result.Data.Artist.Bio.Full == "" {
+ return "", errors.New("deezer: biography not found")
+ }
+
+ return cleanBio(result.Data.Artist.Bio.Full), nil
+}
+
+func cleanBio(bio string) string {
+ bio = strings.ReplaceAll(bio, "
", "\n")
+ return strictPolicy.Sanitize(bio)
+}
diff --git a/core/agents/deezer/client_auth.go b/core/agents/deezer/client_auth.go
new file mode 100644
index 000000000..c88c2bcb6
--- /dev/null
+++ b/core/agents/deezer/client_auth.go
@@ -0,0 +1,101 @@
+package deezer
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v2/jwt"
+ "github.com/navidrome/navidrome/log"
+)
+
+type jwtToken struct {
+ token string
+ expiresAt time.Time
+ mu sync.RWMutex
+}
+
+func (j *jwtToken) get() (string, bool) {
+ j.mu.RLock()
+ defer j.mu.RUnlock()
+ if time.Now().Before(j.expiresAt) {
+ return j.token, true
+ }
+ return "", false
+}
+
+func (j *jwtToken) set(token string, expiresIn time.Duration) {
+ j.mu.Lock()
+ defer j.mu.Unlock()
+ j.token = token
+ j.expiresAt = time.Now().Add(expiresIn)
+}
+
+func (c *client) getJWT(ctx context.Context) (string, error) {
+ // Check if we have a valid cached token
+ if token, valid := c.jwt.get(); valid {
+ return token, nil
+ }
+
+ // Fetch a new anonymous token
+ req, err := http.NewRequestWithContext(ctx, "GET", authBaseURL+"/login/anonymous?jo=p&rto=c", nil)
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Accept", "application/json")
+
+ resp, err := c.httpDoer.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ return "", fmt.Errorf("deezer: failed to get JWT token: %s", resp.Status)
+ }
+
+ data, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return "", err
+ }
+
+ type authResponse struct {
+ JWT string `json:"jwt"`
+ }
+
+ var result authResponse
+ if err := json.Unmarshal(data, &result); err != nil {
+ return "", fmt.Errorf("deezer: failed to parse auth response: %w", err)
+ }
+
+ if result.JWT == "" {
+ return "", errors.New("deezer: no JWT token in response")
+ }
+
+ // Parse JWT to get actual expiration time
+ token, err := jwt.ParseString(result.JWT, jwt.WithVerify(false), jwt.WithValidate(false))
+ if err != nil {
+ return "", fmt.Errorf("deezer: failed to parse JWT token: %w", err)
+ }
+
+ // Calculate TTL with a 1-minute buffer for clock skew and network delays
+ expiresAt := token.Expiration()
+ if expiresAt.IsZero() {
+ return "", errors.New("deezer: JWT token has no expiration time")
+ }
+
+ ttl := time.Until(expiresAt) - 1*time.Minute
+ if ttl <= 0 {
+ return "", errors.New("deezer: JWT token already expired or expires too soon")
+ }
+
+ c.jwt.set(result.JWT, ttl)
+ log.Trace(ctx, "Fetched new Deezer JWT token", "expiresAt", expiresAt, "ttl", ttl)
+
+ return result.JWT, nil
+}
diff --git a/core/agents/deezer/client_auth_test.go b/core/agents/deezer/client_auth_test.go
new file mode 100644
index 000000000..b0c2d195d
--- /dev/null
+++ b/core/agents/deezer/client_auth_test.go
@@ -0,0 +1,293 @@
+package deezer
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "net/http"
+ "sync"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v2/jwt"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("JWT Authentication", func() {
+ var httpClient *fakeHttpClient
+ var client *client
+ var ctx context.Context
+
+ BeforeEach(func() {
+ httpClient = &fakeHttpClient{}
+ client = newClient(httpClient, "en")
+ ctx = context.Background()
+ })
+
+ Describe("getJWT", func() {
+ Context("with a valid JWT response", func() {
+ It("successfully fetches and caches a JWT token", func() {
+ testJWT := createTestJWT(5 * time.Minute)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
+ })
+
+ token, err := client.getJWT(ctx)
+ Expect(err).To(BeNil())
+ Expect(token).To(Equal(testJWT))
+ })
+
+ It("returns the cached token on subsequent calls", func() {
+ testJWT := createTestJWT(5 * time.Minute)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
+ })
+
+ // First call should fetch from API
+ token1, err := client.getJWT(ctx)
+ Expect(err).To(BeNil())
+ Expect(token1).To(Equal(testJWT))
+ Expect(httpClient.lastRequest.URL.Path).To(Equal("/login/anonymous"))
+
+ // Second call should return cached token without hitting API
+ httpClient.lastRequest = nil // Clear last request to verify no new request is made
+ token2, err := client.getJWT(ctx)
+ Expect(err).To(BeNil())
+ Expect(token2).To(Equal(testJWT))
+ Expect(httpClient.lastRequest).To(BeNil()) // No new request made
+ })
+
+ It("parses the JWT expiration time correctly", func() {
+ expectedExpiration := time.Now().Add(5 * time.Minute)
+ testToken, err := jwt.NewBuilder().
+ Expiration(expectedExpiration).
+ Build()
+ Expect(err).To(BeNil())
+ testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
+ Expect(err).To(BeNil())
+
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
+ })
+
+ token, err := client.getJWT(ctx)
+ Expect(err).To(BeNil())
+ Expect(token).ToNot(BeEmpty())
+
+ // Verify the token is cached until close to expiration
+ // The cache should expire 1 minute before the JWT expires
+ expectedCacheExpiry := expectedExpiration.Add(-1 * time.Minute)
+ Expect(client.jwt.expiresAt).To(BeTemporally("~", expectedCacheExpiry, 2*time.Second))
+ })
+ })
+
+ Context("with JWT tokens that expire soon", func() {
+ It("rejects tokens that expire in less than 1 minute", func() {
+ // Create a token that expires in 30 seconds (less than 1-minute buffer)
+ testJWT := createTestJWT(30 * time.Second)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
+ })
+
+ _, err := client.getJWT(ctx)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
+ })
+
+ It("rejects already expired tokens", func() {
+ // Create a token that expired 1 minute ago
+ testJWT := createTestJWT(-1 * time.Minute)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
+ })
+
+ _, err := client.getJWT(ctx)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
+ })
+
+ It("accepts tokens that expire in more than 1 minute", func() {
+ // Create a token that expires in 2 minutes (just over the 1-minute buffer)
+ testJWT := createTestJWT(2 * time.Minute)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, testJWT))),
+ })
+
+ token, err := client.getJWT(ctx)
+ Expect(err).To(BeNil())
+ Expect(token).ToNot(BeEmpty())
+ })
+ })
+
+ Context("with invalid responses", func() {
+ It("handles HTTP error responses", func() {
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 500,
+ Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
+ })
+
+ _, err := client.getJWT(ctx)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("failed to get JWT token"))
+ })
+
+ It("handles malformed JSON responses", func() {
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{invalid json}`)),
+ })
+
+ _, err := client.getJWT(ctx)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("failed to parse auth response"))
+ })
+
+ It("handles responses with empty JWT field", func() {
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{"jwt":""}`)),
+ })
+
+ _, err := client.getJWT(ctx)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(Equal("deezer: no JWT token in response"))
+ })
+
+ It("handles invalid JWT tokens", func() {
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(`{"jwt":"not-a-valid-jwt"}`)),
+ })
+
+ _, err := client.getJWT(ctx)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("failed to parse JWT token"))
+ })
+
+ It("rejects JWT tokens without expiration", func() {
+ // Create a JWT without expiration claim
+ testToken, err := jwt.NewBuilder().
+ Claim("custom", "value").
+ Build()
+ Expect(err).To(BeNil())
+
+ // Verify token has no expiration
+ Expect(testToken.Expiration().IsZero()).To(BeTrue())
+
+ testJWT, err := jwt.Sign(testToken, jwt.WithInsecureNoSignature())
+ Expect(err).To(BeNil())
+
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, string(testJWT)))),
+ })
+
+ _, err = client.getJWT(ctx)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(Equal("deezer: JWT token has no expiration time"))
+ })
+ })
+
+ Context("token caching behavior", func() {
+ It("fetches a new token when the cached token expires", func() {
+ // First token expires in 5 minutes
+ firstJWT := createTestJWT(5 * time.Minute)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, firstJWT))),
+ })
+
+ token1, err := client.getJWT(ctx)
+ Expect(err).To(BeNil())
+ Expect(token1).To(Equal(firstJWT))
+
+ // Manually expire the cached token
+ client.jwt.expiresAt = time.Now().Add(-1 * time.Second)
+
+ // Second token with different expiration (10 minutes)
+ secondJWT := createTestJWT(10 * time.Minute)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s"}`, secondJWT))),
+ })
+
+ token2, err := client.getJWT(ctx)
+ Expect(err).To(BeNil())
+ Expect(token2).To(Equal(secondJWT))
+ Expect(token2).ToNot(Equal(token1))
+ })
+ })
+ })
+
+ Describe("jwtToken cache", func() {
+ var cache *jwtToken
+
+ BeforeEach(func() {
+ cache = &jwtToken{}
+ })
+
+ It("returns false for expired tokens", func() {
+ cache.set("test-token", -1*time.Second) // Already expired
+ token, valid := cache.get()
+ Expect(valid).To(BeFalse())
+ Expect(token).To(BeEmpty())
+ })
+
+ It("returns true for valid tokens", func() {
+ cache.set("test-token", 4*time.Minute)
+ token, valid := cache.get()
+ Expect(valid).To(BeTrue())
+ Expect(token).To(Equal("test-token"))
+ })
+
+ It("is thread-safe for concurrent access", func() {
+ wg := sync.WaitGroup{}
+
+ // Writer goroutine
+ wg.Go(func() {
+ for i := 0; i < 100; i++ {
+ cache.set(fmt.Sprintf("token-%d", i), 1*time.Hour)
+ time.Sleep(1 * time.Millisecond)
+ }
+ })
+
+ // Reader goroutine
+ wg.Go(func() {
+ for i := 0; i < 100; i++ {
+ cache.get()
+ time.Sleep(1 * time.Millisecond)
+ }
+ })
+
+ // Wait for both goroutines to complete
+ wg.Wait()
+
+ // Verify final state is valid
+ token, valid := cache.get()
+ Expect(valid).To(BeTrue())
+ Expect(token).To(HavePrefix("token-"))
+ })
+ })
+})
+
+// createTestJWT creates a valid JWT token for testing purposes
+func createTestJWT(expiresIn time.Duration) string {
+ token, err := jwt.NewBuilder().
+ Expiration(time.Now().Add(expiresIn)).
+ Build()
+ if err != nil {
+ panic(fmt.Sprintf("failed to create test JWT: %v", err))
+ }
+ signed, err := jwt.Sign(token, jwt.WithInsecureNoSignature())
+ if err != nil {
+ panic(fmt.Sprintf("failed to sign test JWT: %v", err))
+ }
+ return string(signed)
+}
diff --git a/core/agents/deezer/client_test.go b/core/agents/deezer/client_test.go
index 5e47460d4..7e4f7a49f 100644
--- a/core/agents/deezer/client_test.go
+++ b/core/agents/deezer/client_test.go
@@ -2,10 +2,11 @@ package deezer
import (
"bytes"
- "context"
+ "fmt"
"io"
"net/http"
"os"
+ "time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
@@ -17,7 +18,7 @@ var _ = Describe("client", func() {
BeforeEach(func() {
httpClient = &fakeHttpClient{}
- client = newClient(httpClient)
+ client = newClient(httpClient, "en")
})
Describe("ArtistImages", func() {
@@ -26,7 +27,7 @@ var _ = Describe("client", func() {
Expect(err).To(BeNil())
httpClient.mock("https://api.deezer.com/search/artist", http.Response{Body: f, StatusCode: 200})
- artists, err := client.searchArtists(context.TODO(), "Michael Jackson", 20)
+ artists, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(BeNil())
Expect(artists).To(HaveLen(17))
Expect(artists[0].Name).To(Equal("Michael Jackson"))
@@ -39,10 +40,136 @@ var _ = Describe("client", func() {
Body: io.NopCloser(bytes.NewBufferString(`{"data":[],"total":0}`)),
})
- _, err := client.searchArtists(context.TODO(), "Michael Jackson", 20)
+ _, err := client.searchArtists(GinkgoT().Context(), "Michael Jackson", 20)
Expect(err).To(MatchError(ErrNotFound))
})
})
+
+ Describe("ArtistBio", func() {
+ BeforeEach(func() {
+ // Mock the JWT token endpoint with a valid JWT that expires in 5 minutes
+ testJWT := createTestJWT(5 * time.Minute)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
+ })
+ })
+
+ It("returns artist bio from a successful request", func() {
+ f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
+ Expect(err).To(BeNil())
+ httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
+
+ bio, err := client.getArtistBio(GinkgoT().Context(), 27)
+ Expect(err).To(BeNil())
+ Expect(bio).To(ContainSubstring("Schoolmates Thomas and Guy-Manuel"))
+ Expect(bio).ToNot(ContainSubstring(""))
+ Expect(bio).ToNot(ContainSubstring("
"))
+ })
+
+ It("uses the configured language", func() {
+ client = newClient(httpClient, "fr")
+ // Mock JWT token for the new client instance with a valid JWT
+ testJWT := createTestJWT(5 * time.Minute)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, testJWT))),
+ })
+ f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
+ Expect(err).To(BeNil())
+ httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
+
+ _, err = client.getArtistBio(GinkgoT().Context(), 27)
+ Expect(err).To(BeNil())
+ Expect(httpClient.lastRequest.Header.Get("Accept-Language")).To(Equal("fr"))
+ })
+
+ It("includes the JWT token in the request", func() {
+ f, err := os.Open("tests/fixtures/deezer.artist.bio.json")
+ Expect(err).To(BeNil())
+ httpClient.mock("https://pipe.deezer.com/api", http.Response{Body: f, StatusCode: 200})
+
+ _, err = client.getArtistBio(GinkgoT().Context(), 27)
+ Expect(err).To(BeNil())
+ // Verify that the Authorization header has the Bearer token format
+ authHeader := httpClient.lastRequest.Header.Get("Authorization")
+ Expect(authHeader).To(HavePrefix("Bearer "))
+ Expect(len(authHeader)).To(BeNumerically(">", 20)) // JWT tokens are longer than 20 chars
+ })
+
+ It("handles GraphQL errors", func() {
+ errorResponse := `{
+ "data": {
+ "artist": {
+ "bio": {
+ "full": ""
+ }
+ }
+ },
+ "errors": [
+ {
+ "message": "Artist not found"
+ },
+ {
+ "message": "Invalid artist ID"
+ }
+ ]
+ }`
+ httpClient.mock("https://pipe.deezer.com/api", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(errorResponse)),
+ })
+
+ _, err := client.getArtistBio(GinkgoT().Context(), 999)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("GraphQL error"))
+ Expect(err.Error()).To(ContainSubstring("Artist not found"))
+ Expect(err.Error()).To(ContainSubstring("Invalid artist ID"))
+ })
+
+ It("handles empty biography", func() {
+ emptyBioResponse := `{
+ "data": {
+ "artist": {
+ "bio": {
+ "full": ""
+ }
+ }
+ }
+ }`
+ httpClient.mock("https://pipe.deezer.com/api", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(emptyBioResponse)),
+ })
+
+ _, err := client.getArtistBio(GinkgoT().Context(), 27)
+ Expect(err).To(MatchError("deezer: biography not found"))
+ })
+
+ It("handles JWT token fetch failure", func() {
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 500,
+ Body: io.NopCloser(bytes.NewBufferString(`{"error":"Internal server error"}`)),
+ })
+
+ _, err := client.getArtistBio(GinkgoT().Context(), 27)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("failed to get JWT"))
+ })
+
+ It("handles JWT token that expires too soon", func() {
+ // Create a JWT that expires in 30 seconds (less than the 1-minute buffer)
+ expiredJWT := createTestJWT(30 * time.Second)
+ httpClient.mock("https://auth.deezer.com/login/anonymous", http.Response{
+ StatusCode: 200,
+ Body: io.NopCloser(bytes.NewBufferString(fmt.Sprintf(`{"jwt":"%s","refresh_token":""}`, expiredJWT))),
+ })
+
+ _, err := client.getArtistBio(GinkgoT().Context(), 27)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("JWT token already expired or expires too soon"))
+ })
+ })
})
type fakeHttpClient struct {
diff --git a/core/agents/deezer/deezer.go b/core/agents/deezer/deezer.go
index 8cabfbcfb..8f3e505ec 100644
--- a/core/agents/deezer/deezer.go
+++ b/core/agents/deezer/deezer.go
@@ -12,6 +12,7 @@ import (
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/utils/cache"
+ "github.com/navidrome/navidrome/utils/slice"
)
const deezerAgentName = "deezer"
@@ -32,7 +33,7 @@ func deezerConstructor(dataStore model.DataStore) agents.Interface {
Timeout: consts.DefaultHttpClientTimeOut,
}
cachedHttpClient := cache.NewHTTPClient(httpClient, consts.DefaultHttpClientTimeOut)
- agent.client = newClient(cachedHttpClient)
+ agent.client = newClient(cachedHttpClient, conf.Server.Deezer.Language)
return agent
}
@@ -88,6 +89,56 @@ func (s *deezerAgent) searchArtist(ctx context.Context, name string) (*Artist, e
return &artists[0], err
}
+func (s *deezerAgent) GetSimilarArtists(ctx context.Context, _, name, _ string, limit int) ([]agents.Artist, error) {
+ artist, err := s.searchArtist(ctx, name)
+ if err != nil {
+ return nil, err
+ }
+
+ related, err := s.client.getRelatedArtists(ctx, artist.ID)
+ if err != nil {
+ return nil, err
+ }
+
+ res := slice.Map(related, func(r Artist) agents.Artist {
+ return agents.Artist{
+ Name: r.Name,
+ }
+ })
+ if len(res) > limit {
+ res = res[:limit]
+ }
+ return res, nil
+}
+
+func (s *deezerAgent) GetArtistTopSongs(ctx context.Context, _, artistName, _ string, count int) ([]agents.Song, error) {
+ artist, err := s.searchArtist(ctx, artistName)
+ if err != nil {
+ return nil, err
+ }
+
+ tracks, err := s.client.getTopTracks(ctx, artist.ID, count)
+ if err != nil {
+ return nil, err
+ }
+
+ res := slice.Map(tracks, func(r Track) agents.Song {
+ return agents.Song{
+ Name: r.Title,
+ }
+ })
+ return res, nil
+}
+
+func (s *deezerAgent) GetArtistBiography(ctx context.Context, _, name, _ string) (string, error) {
+ artist, err := s.searchArtist(ctx, name)
+ if err != nil {
+ return "", err
+ }
+
+ return s.client.getArtistBio(ctx, artist.ID)
+}
+
func init() {
conf.AddHook(func() {
if conf.Server.Deezer.Enabled {
diff --git a/core/agents/deezer/responses.go b/core/agents/deezer/responses.go
index 112fe28ec..266c44c62 100644
--- a/core/agents/deezer/responses.go
+++ b/core/agents/deezer/responses.go
@@ -29,3 +29,38 @@ type Error struct {
Code int `json:"code"`
} `json:"error"`
}
+
+type RelatedArtists struct {
+ Data []Artist `json:"data"`
+ Total int `json:"total"`
+}
+
+type TopTracks struct {
+ Data []Track `json:"data"`
+ Total int `json:"total"`
+ Next string `json:"next"`
+}
+
+type Track struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ Link string `json:"link"`
+ Duration int `json:"duration"`
+ Rank int `json:"rank"`
+ Preview string `json:"preview"`
+ Artist Artist `json:"artist"`
+ Album Album `json:"album"`
+ Contributors []Artist `json:"contributors"`
+}
+
+type Album struct {
+ ID int `json:"id"`
+ Title string `json:"title"`
+ Cover string `json:"cover"`
+ CoverSmall string `json:"cover_small"`
+ CoverMedium string `json:"cover_medium"`
+ CoverBig string `json:"cover_big"`
+ CoverXl string `json:"cover_xl"`
+ Tracklist string `json:"tracklist"`
+ Type string `json:"type"`
+}
diff --git a/core/agents/deezer/responses_test.go b/core/agents/deezer/responses_test.go
index 95a7f43f4..a9de5c5fb 100644
--- a/core/agents/deezer/responses_test.go
+++ b/core/agents/deezer/responses_test.go
@@ -35,4 +35,35 @@ var _ = Describe("Responses", func() {
Expect(errorResp.Error.Message).To(Equal("Missing parameters: q"))
})
})
+
+ Describe("Related Artists", func() {
+ It("parses the related artists response correctly", func() {
+ var resp RelatedArtists
+ body, err := os.ReadFile("tests/fixtures/deezer.artist.related.json")
+ Expect(err).To(BeNil())
+ err = json.Unmarshal(body, &resp)
+ Expect(err).To(BeNil())
+
+ Expect(resp.Data).To(HaveLen(20))
+ justice := resp.Data[0]
+ Expect(justice.Name).To(Equal("Justice"))
+ Expect(justice.ID).To(Equal(6404))
+ })
+ })
+
+ Describe("Top Tracks", func() {
+ It("parses the top tracks response correctly", func() {
+ var resp TopTracks
+ body, err := os.ReadFile("tests/fixtures/deezer.artist.top.json")
+ Expect(err).To(BeNil())
+ err = json.Unmarshal(body, &resp)
+ Expect(err).To(BeNil())
+
+ Expect(resp.Data).To(HaveLen(5))
+ track := resp.Data[0]
+ Expect(track.Title).To(Equal("Instant Crush (feat. Julian Casablancas)"))
+ Expect(track.ID).To(Equal(67238732))
+ Expect(track.Album.Title).To(Equal("Random Access Memories"))
+ })
+ })
})
diff --git a/tests/fixtures/deezer.artist.bio.json b/tests/fixtures/deezer.artist.bio.json
new file mode 100644
index 000000000..80e439bae
--- /dev/null
+++ b/tests/fixtures/deezer.artist.bio.json
@@ -0,0 +1,9 @@
+{
+ "data": {
+ "artist": {
+ "bio": {
+ "full": "Schoolmates Thomas and Guy-Manuel began their career in 1992 with the indie rock trio Darlin' (named after The Beach Boys song) but were scathingly dismissed by Melody Maker magazine as \"daft punk.\" Turning to house-inspired electronica, they used the put down as a name for their DJ-ing partnership and became a hugely successful and influential dance act.
"
+ }
+ }
+ }
+}
diff --git a/tests/fixtures/deezer.artist.related.json b/tests/fixtures/deezer.artist.related.json
new file mode 100644
index 000000000..2a55b303e
--- /dev/null
+++ b/tests/fixtures/deezer.artist.related.json
@@ -0,0 +1 @@
+{"data":[{"id":6404,"name":"Justice","link":"https:\/\/www.deezer.com\/artist\/6404","picture":"https:\/\/api.deezer.com\/artist\/6404\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/e5bf29cb99852f92a0079b184ace9479\/1000x1000-000000-80-0-0.jpg","nb_album":41,"nb_fan":774236,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/6404\/top?limit=50","type":"artist"},{"id":2049,"name":"Cassius","link":"https:\/\/www.deezer.com\/artist\/2049","picture":"https:\/\/api.deezer.com\/artist\/2049\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/7ed91fa11a9785a82e63fd9058821d8a\/1000x1000-000000-80-0-0.jpg","nb_album":25,"nb_fan":127692,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2049\/top?limit=50","type":"artist"},{"id":2318,"name":"Etienne de Cr\u00e9cy","link":"https:\/\/www.deezer.com\/artist\/2318","picture":"https:\/\/api.deezer.com\/artist\/2318\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/b9efa1b51be3c2a506006a77517967f7\/1000x1000-000000-80-0-0.jpg","nb_album":58,"nb_fan":104626,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2318\/top?limit=50","type":"artist"},{"id":72041,"name":"Yuksek","link":"https:\/\/www.deezer.com\/artist\/72041","picture":"https:\/\/api.deezer.com\/artist\/72041\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5e0fd4c6b670682861abcc825eb3db50\/1000x1000-000000-80-0-0.jpg","nb_album":102,"nb_fan":115772,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/72041\/top?limit=50","type":"artist"},{"id":81,"name":"The Chemical Brothers","link":"https:\/\/www.deezer.com\/artist\/81","picture":"https:\/\/api.deezer.com\/artist\/81\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/5294e68e9ef4f0359237935a4b8388f2\/1000x1000-000000-80-0-0.jpg","nb_album":83,"nb_fan":1433333,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/81\/top?limit=50","type":"artist"},{"id":3771,"name":"Mr. Oizo","link":"https:\/\/www.deezer.com\/artist\/3771","picture":"https:\/\/api.deezer.com\/artist\/3771\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/589305cb56ea3555b1f94341955bf875\/1000x1000-000000-80-0-0.jpg","nb_album":31,"nb_fan":172085,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/3771\/top?limit=50","type":"artist"},{"id":9905,"name":"Alex Gopher","link":"https:\/\/www.deezer.com\/artist\/9905","picture":"https:\/\/api.deezer.com\/artist\/9905\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c4676bdbf3259decd69491523a1ace8f\/1000x1000-000000-80-0-0.jpg","nb_album":46,"nb_fan":10430,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/9905\/top?limit=50","type":"artist"},{"id":7914,"name":"Demon","link":"https:\/\/www.deezer.com\/artist\/7914","picture":"https:\/\/api.deezer.com\/artist\/7914\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/c7c1f4d1f1cf6bdf04a6fc54ea65ef78\/1000x1000-000000-80-0-0.jpg","nb_album":21,"nb_fan":9286,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7914\/top?limit=50","type":"artist"},{"id":8937,"name":"SebastiAn","link":"https:\/\/www.deezer.com\/artist\/8937","picture":"https:\/\/api.deezer.com\/artist\/8937\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0151acdea8a8d5f8e3aefabf6f034575\/1000x1000-000000-80-0-0.jpg","nb_album":48,"nb_fan":74884,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/8937\/top?limit=50","type":"artist"},{"id":2508,"name":"Digitalism","link":"https:\/\/www.deezer.com\/artist\/2508","picture":"https:\/\/api.deezer.com\/artist\/2508\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/21e56c7147508853dcdfd9997cd8d271\/1000x1000-000000-80-0-0.jpg","nb_album":79,"nb_fan":158628,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2508\/top?limit=50","type":"artist"},{"id":11703,"name":"Alan Braxe","link":"https:\/\/www.deezer.com\/artist\/11703","picture":"https:\/\/api.deezer.com\/artist\/11703\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0de64f7a63728f09520f987249630d7e\/1000x1000-000000-80-0-0.jpg","nb_album":25,"nb_fan":12595,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11703\/top?limit=50","type":"artist"},{"id":574,"name":"Para One","link":"https:\/\/www.deezer.com\/artist\/574","picture":"https:\/\/api.deezer.com\/artist\/574\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/2560ff01d72f5e4a768e55892ad066e4\/1000x1000-000000-80-0-0.jpg","nb_album":40,"nb_fan":30828,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/574\/top?limit=50","type":"artist"},{"id":4397,"name":"Kojak","link":"https:\/\/www.deezer.com\/artist\/4397","picture":"https:\/\/api.deezer.com\/artist\/4397\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/817caeb7fa22eb511371ac260680d5fa\/1000x1000-000000-80-0-0.jpg","nb_album":55,"nb_fan":1522,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/4397\/top?limit=50","type":"artist"},{"id":12439,"name":"Busy P","link":"https:\/\/www.deezer.com\/artist\/12439","picture":"https:\/\/api.deezer.com\/artist\/12439\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/6483408c3e46e8fa8872a805dfe6d5e0\/1000x1000-000000-80-0-0.jpg","nb_album":12,"nb_fan":65585,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/12439\/top?limit=50","type":"artist"},{"id":11656979,"name":"Mr Flash","link":"https:\/\/www.deezer.com\/artist\/11656979","picture":"https:\/\/api.deezer.com\/artist\/11656979\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f953922c8caa74c5b48feaa07622b1ee\/1000x1000-000000-80-0-0.jpg","nb_album":7,"nb_fan":769,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11656979\/top?limit=50","type":"artist"},{"id":76,"name":"Fatboy Slim","link":"https:\/\/www.deezer.com\/artist\/76","picture":"https:\/\/api.deezer.com\/artist\/76\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/f6ea7bd64ec1902feff17935fdfea263\/1000x1000-000000-80-0-0.jpg","nb_album":76,"nb_fan":1231355,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/76\/top?limit=50","type":"artist"},{"id":11265,"name":"Lifelike","link":"https:\/\/www.deezer.com\/artist\/11265","picture":"https:\/\/api.deezer.com\/artist\/11265\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/04f88199a69a646f6253808b58753629\/1000x1000-000000-80-0-0.jpg","nb_album":38,"nb_fan":8316,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/11265\/top?limit=50","type":"artist"},{"id":2048,"name":"Groove Armada","link":"https:\/\/www.deezer.com\/artist\/2048","picture":"https:\/\/api.deezer.com\/artist\/2048\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/0acbb71c44e4ecdefd102630f4cfc808\/1000x1000-000000-80-0-0.jpg","nb_album":92,"nb_fan":173879,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/2048\/top?limit=50","type":"artist"},{"id":71708,"name":"Surkin","link":"https:\/\/www.deezer.com\/artist\/71708","picture":"https:\/\/api.deezer.com\/artist\/71708\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/d2137c57fbdc9aa275b93e78b619b477\/1000x1000-000000-80-0-0.jpg","nb_album":15,"nb_fan":23101,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/71708\/top?limit=50","type":"artist"},{"id":166713,"name":"Fred Falke","link":"https:\/\/www.deezer.com\/artist\/166713","picture":"https:\/\/api.deezer.com\/artist\/166713\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/883821e7c5325cfa07fc660884a40624\/1000x1000-000000-80-0-0.jpg","nb_album":67,"nb_fan":9688,"radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/166713\/top?limit=50","type":"artist"}],"total":20}
\ No newline at end of file
diff --git a/tests/fixtures/deezer.artist.top.json b/tests/fixtures/deezer.artist.top.json
new file mode 100644
index 000000000..e3f22a1aa
--- /dev/null
+++ b/tests/fixtures/deezer.artist.top.json
@@ -0,0 +1 @@
+{"data":[{"id":67238732,"readable":true,"title":"Instant Crush (feat. Julian Casablancas)","title_short":"Instant Crush","title_version":"(feat. Julian Casablancas)","link":"https:\/\/www.deezer.com\/track\/67238732","duration":337,"rank":944042,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/d\/6\/b\/0\/d6bc80aadfa1d7625d59a6620f229371.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/d\/6\/b\/0\/d6bc80aadfa1d7625d59a6620f229371.mp3*~data=user_id=0,application_id=42~hmac=66213cecf953c7ef8b4d89e3539a1355d318679c5ab54cac2007d4effa6c3bf4","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":295821,"name":"Julian Casablancas","link":"https:\/\/www.deezer.com\/artist\/295821","share":"https:\/\/www.deezer.com\/artist\/295821?utm_source=deezer&utm_content=artist-295821&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/295821\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/74e78538aefe2a6a49a851e569fc9f19\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/295821\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"311bba0fc112d15f72c8b5a65f0456c1","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6575789,"title":"Random Access Memories","cover":"https:\/\/api.deezer.com\/album\/6575789\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/1000x1000-000000-80-0-0.jpg","md5_image":"311bba0fc112d15f72c8b5a65f0456c1","tracklist":"https:\/\/api.deezer.com\/album\/6575789\/tracks","type":"album"},"type":"track"},{"id":3135553,"readable":true,"title":"One More Time","title_short":"One More Time","title_version":"","link":"https:\/\/www.deezer.com\/track\/3135553","duration":320,"rank":888570,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/f\/8\/c\/0\/f8c5dc3837912dba37c9a1ab3170cc3f.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/f\/8\/c\/0\/f8c5dc3837912dba37c9a1ab3170cc3f.mp3*~data=user_id=0,application_id=42~hmac=0824ec7ad045b82c04904fcd5f2a8ec2175acbe3d1649030d457023fdef45620","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"5718f7c81c27e0b2417e2a4c45224f8a","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":302127,"title":"Discovery","cover":"https:\/\/api.deezer.com\/album\/302127\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/5718f7c81c27e0b2417e2a4c45224f8a\/1000x1000-000000-80-0-0.jpg","md5_image":"5718f7c81c27e0b2417e2a4c45224f8a","tracklist":"https:\/\/api.deezer.com\/album\/302127\/tracks","type":"album"},"type":"track"},{"id":66609426,"readable":true,"title":"Get Lucky (Radio Edit - feat. Pharrell Williams and Nile Rodgers)","title_short":"Get Lucky","title_version":"(Radio Edit - feat. Pharrell Williams and Nile Rodgers)","link":"https:\/\/www.deezer.com\/track\/66609426","duration":248,"rank":952197,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/1\/b\/f\/0\/1bf80a82992903ff685ba1b7275223f8.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/1\/b\/f\/0\/1bf80a82992903ff685ba1b7275223f8.mp3*~data=user_id=0,application_id=42~hmac=c6dfe58571df62f41e7b326dd9afebf87015541c06a521ebc88fc18671d8d06d","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":103,"name":"Pharrell Williams","link":"https:\/\/www.deezer.com\/artist\/103","share":"https:\/\/www.deezer.com\/artist\/103?utm_source=deezer&utm_content=artist-103&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/103\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/103\/top?limit=50","type":"artist","role":"Main"},{"id":7207,"name":"Nile Rodgers","link":"https:\/\/www.deezer.com\/artist\/7207","share":"https:\/\/www.deezer.com\/artist\/7207?utm_source=deezer&utm_content=artist-7207&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/7207\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7207\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"bc49adb87758e0c8c4e508a9c5cce85d","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6516139,"title":"Get Lucky (Radio Edit - feat. Pharrell Williams and Nile Rodgers)","cover":"https:\/\/api.deezer.com\/album\/6516139\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/bc49adb87758e0c8c4e508a9c5cce85d\/1000x1000-000000-80-0-0.jpg","md5_image":"bc49adb87758e0c8c4e508a9c5cce85d","tracklist":"https:\/\/api.deezer.com\/album\/6516139\/tracks","type":"album"},"type":"track"},{"id":67238735,"readable":true,"title":"Get Lucky (feat. Pharrell Williams and Nile Rodgers)","title_short":"Get Lucky","title_version":"(feat. Pharrell Williams and Nile Rodgers)","link":"https:\/\/www.deezer.com\/track\/67238735","duration":367,"rank":873875,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/c\/8\/a\/0\/c8a61130657a2cf58e3ac751e7950617.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/c\/8\/a\/0\/c8a61130657a2cf58e3ac751e7950617.mp3*~data=user_id=0,application_id=42~hmac=92002e6bade5ff82dd44751e8998beaa60844210df1d73b8f1bf7dafb02dc5c3","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"},{"id":103,"name":"Pharrell Williams","link":"https:\/\/www.deezer.com\/artist\/103","share":"https:\/\/www.deezer.com\/artist\/103?utm_source=deezer&utm_content=artist-103&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/103\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/1267b8781c5bff065a20dca4a3c9fda7\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/103\/top?limit=50","type":"artist","role":"Main"},{"id":7207,"name":"Nile Rodgers","link":"https:\/\/www.deezer.com\/artist\/7207","share":"https:\/\/www.deezer.com\/artist\/7207?utm_source=deezer&utm_content=artist-7207&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/7207\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/64f826f318c84ce50ff538c01f62f1ff\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/7207\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"311bba0fc112d15f72c8b5a65f0456c1","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":6575789,"title":"Random Access Memories","cover":"https:\/\/api.deezer.com\/album\/6575789\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/311bba0fc112d15f72c8b5a65f0456c1\/1000x1000-000000-80-0-0.jpg","md5_image":"311bba0fc112d15f72c8b5a65f0456c1","tracklist":"https:\/\/api.deezer.com\/album\/6575789\/tracks","type":"album"},"type":"track"},{"id":3129775,"readable":true,"title":"Around the World","title_short":"Around the World","title_version":"","link":"https:\/\/www.deezer.com\/track\/3129775","duration":429,"rank":829911,"explicit_lyrics":false,"explicit_content_lyrics":0,"explicit_content_cover":0,"preview":"https:\/\/cdnt-preview.dzcdn.net\/api\/1\/1\/a\/4\/7\/0\/a47dbed01e6d9b0ac4e39a134f745ca2.mp3?hdnea=exp=1763672105~acl=\/api\/1\/1\/a\/4\/7\/0\/a47dbed01e6d9b0ac4e39a134f745ca2.mp3*~data=user_id=0,application_id=42~hmac=9b7aa12b647cabd3219779e0270e51e639dc326442071fceb6d723c331059a67","contributors":[{"id":27,"name":"Daft Punk","link":"https:\/\/www.deezer.com\/artist\/27","share":"https:\/\/www.deezer.com\/artist\/27?utm_source=deezer&utm_content=artist-27&utm_term=0_1763671205&utm_medium=web","picture":"https:\/\/api.deezer.com\/artist\/27\/image","picture_small":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/56x56-000000-80-0-0.jpg","picture_medium":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/250x250-000000-80-0-0.jpg","picture_big":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/500x500-000000-80-0-0.jpg","picture_xl":"https:\/\/cdn-images.dzcdn.net\/images\/artist\/638e69b9caaf9f9f3f8826febea7b543\/1000x1000-000000-80-0-0.jpg","radio":true,"tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist","role":"Main"}],"md5_image":"b870579c8650cd59b1cce656dde2ef17","artist":{"id":27,"name":"Daft Punk","tracklist":"https:\/\/api.deezer.com\/artist\/27\/top?limit=50","type":"artist"},"album":{"id":301775,"title":"Homework","cover":"https:\/\/api.deezer.com\/album\/301775\/image","cover_small":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/56x56-000000-80-0-0.jpg","cover_medium":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/250x250-000000-80-0-0.jpg","cover_big":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/500x500-000000-80-0-0.jpg","cover_xl":"https:\/\/cdn-images.dzcdn.net\/images\/cover\/b870579c8650cd59b1cce656dde2ef17\/1000x1000-000000-80-0-0.jpg","md5_image":"b870579c8650cd59b1cce656dde2ef17","tracklist":"https:\/\/api.deezer.com\/album\/301775\/tracks","type":"album"},"type":"track"}],"total":100,"next":"https:\/\/api.deezer.com\/artist\/27\/top?index=5"}
\ No newline at end of file
From 67c4e249570c1928f3559a694427a6ce34adda67 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Fri, 21 Nov 2025 15:26:30 -0500
Subject: [PATCH 062/102] fix(scanner): defer artwork PreCache calls until
after transaction commits
The CacheWarmer was failing with data not found errors because PreCache was being called inside the database transaction before the data was committed. The CacheWarmer runs in a separate goroutine with its own database context and could not access the uncommitted data due to transaction isolation.
Changed the persistChanges method in phase_1_folders.go to collect artwork IDs during the transaction and only call PreCache after the transaction successfully commits. This ensures the artwork data is visible to the CacheWarmer when it attempts to retrieve and cache the images.
The fix eliminates the data not found errors and allows the cache warmer to properly pre-cache album and artist artwork during library scanning.
Signed-off-by: Deluan
---
scanner/phase_1_folders.go | 15 +++++++++++++--
1 file changed, 13 insertions(+), 2 deletions(-)
diff --git a/scanner/phase_1_folders.go b/scanner/phase_1_folders.go
index 2f6b62b2d..329029951 100644
--- a/scanner/phase_1_folders.go
+++ b/scanner/phase_1_folders.go
@@ -324,6 +324,9 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
defer p.measure(entry)()
p.state.changesDetected.Store(true)
+ // Collect artwork IDs to pre-cache after the transaction commits
+ var artworkIDs []model.ArtworkID
+
err := p.ds.WithTx(func(tx model.DataStore) error {
// Instantiate all repositories just once per folder
folderRepo := tx.Folder(p.ctx)
@@ -362,7 +365,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
return err
}
if entry.artists[i].Name != consts.UnknownArtist && entry.artists[i].Name != consts.VariousArtists {
- entry.job.cw.PreCache(entry.artists[i].CoverArtID())
+ artworkIDs = append(artworkIDs, entry.artists[i].CoverArtID())
}
}
@@ -374,7 +377,7 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
return err
}
if entry.albums[i].Name != consts.UnknownAlbum {
- entry.job.cw.PreCache(entry.albums[i].CoverArtID())
+ artworkIDs = append(artworkIDs, entry.albums[i].CoverArtID())
}
}
@@ -411,6 +414,14 @@ func (p *phaseFolders) persistChanges(entry *folderEntry) (*folderEntry, error)
if err != nil {
log.Error(p.ctx, "Scanner: Error persisting changes to DB", "folder", entry.path, err)
}
+
+ // Pre-cache artwork after the transaction commits successfully
+ if err == nil {
+ for _, artID := range artworkIDs {
+ entry.job.cw.PreCache(artID)
+ }
+ }
+
return entry, err
}
From f6b2ab57262c0c6c411a1002be8cc31c75f270b6 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Fri, 21 Nov 2025 22:23:38 -0500
Subject: [PATCH 063/102] feat(ui): add loading state to artist action buttons
for improved user experience
Signed-off-by: Deluan
---
ui/src/artist/ArtistActions.jsx | 46 +++++++++++++++++++++++----------
1 file changed, 33 insertions(+), 13 deletions(-)
diff --git a/ui/src/artist/ArtistActions.jsx b/ui/src/artist/ArtistActions.jsx
index c33ee892b..8eebe6499 100644
--- a/ui/src/artist/ArtistActions.jsx
+++ b/ui/src/artist/ArtistActions.jsx
@@ -1,7 +1,7 @@
import React from 'react'
import PropTypes from 'prop-types'
import { useDispatch } from 'react-redux'
-import { useMediaQuery } from '@material-ui/core'
+import { useMediaQuery, CircularProgress } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import {
Button,
@@ -45,6 +45,12 @@ const useStyles = makeStyles((theme) => ({
},
}))
+const LoadingButton = ({ loading, icon, ...rest }) => (
+
+ {loading ? : icon}
+
+)
+
const ArtistActions = ({ className, record, ...rest }) => {
const dispatch = useDispatch()
const translate = useTranslate()
@@ -52,34 +58,45 @@ const ArtistActions = ({ className, record, ...rest }) => {
const notify = useNotify()
const classes = useStyles()
const isMobile = useMediaQuery((theme) => theme.breakpoints.down('xs'))
+ const [loadingAction, setLoadingAction] = React.useState(null)
+ const isLoading = !!loadingAction
const handlePlay = React.useCallback(async () => {
+ setLoadingAction('play')
try {
await playTopSongs(dispatch, notify, record.name)
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error fetching top songs for artist:', e)
notify('ra.page.error', 'warning')
+ } finally {
+ setLoadingAction(null)
}
}, [dispatch, notify, record])
const handleShuffle = React.useCallback(async () => {
+ setLoadingAction('shuffle')
try {
await playShuffle(dataProvider, dispatch, record.id)
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error fetching songs for shuffle:', e)
notify('ra.page.error', 'warning')
+ } finally {
+ setLoadingAction(null)
}
}, [dataProvider, dispatch, record, notify])
const handleRadio = React.useCallback(async () => {
+ setLoadingAction('radio')
try {
await playSimilar(dispatch, notify, record.id)
} catch (e) {
// eslint-disable-next-line no-console
console.error('Error starting radio for artist:', e)
notify('ra.page.error', 'warning')
+ } finally {
+ setLoadingAction(null)
}
}, [dispatch, notify, record])
@@ -88,30 +105,33 @@ const ArtistActions = ({ className, record, ...rest }) => {
className={`${className} ${classes.toolbar}`}
{...sanitizeListRestProps(rest)}
>
-
-
-
- }
+ />
+
-
-
- }
+ />
+
-
-
+ disabled={isLoading}
+ loading={loadingAction === 'radio'}
+ icon={ }
+ />
)
}
From 2451e9e7aeca3040ce463c86351352b61121ca6f Mon Sep 17 00:00:00 2001
From: Stephan Wahlen <44159957+metalheim@users.noreply.github.com>
Date: Sat, 22 Nov 2025 17:23:02 +0100
Subject: [PATCH 064/102] feat(ui): add AMusic (Apple Music inspired) theme
(#4723)
* first show at AMuisc Theme
* prettier
* fix Duplicate key 'MuiButton'
* fix file name
* Update amusic.js
* Add styles for NDAlbumGridView in amusic.js
* Fix MuiToolbar background property in amusic.js
* Fix syntax error in amusic.js background property
* run prettier
* fix banded table styling and more
* more styling to player
- fix some appearances of green in queue
- match queue styling to rest of theme
- round albumart in player and prevent rotation
* fix queue panel background and border
to make it stand out more against the background
* fix stray comma
and lint+prettier
* queue hover still green
and player preview image not rounded properly
* Update amusic.css.js
* more mobile color fixes
* artist page
* prettier
* rounded art in albumgridview
* small tweaks to colors and radiuses
* artist and album heading
* external links colors
* unify font colors + albumgrid corner radius
* get rid of queue hover green
* unify colors in player
same red shades as primary
* mobile player floating panel background shade of green
* unify border colors
and attempt to get album cover corner radius working
* final touches
* Update amusic.css.js
* fix invisible button color fir muibutton
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
* fix css syntax on player queue color overrides
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
* remove unused MuiTableHead
* sort theme list in index.js alphabetically
* remove unused properties
* Revert "fix css syntax on player queue color overrides"
This reverts commit 503bba321d958aed5251667c58214822ceb70f59.
---------
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
---
ui/src/themes/amusic.css.js | 89 ++++++++++++++++
ui/src/themes/amusic.js | 197 ++++++++++++++++++++++++++++++++++++
ui/src/themes/index.js | 2 +
3 files changed, 288 insertions(+)
create mode 100644 ui/src/themes/amusic.css.js
create mode 100644 ui/src/themes/amusic.js
diff --git a/ui/src/themes/amusic.css.js b/ui/src/themes/amusic.css.js
new file mode 100644
index 000000000..05709dc1e
--- /dev/null
+++ b/ui/src/themes/amusic.css.js
@@ -0,0 +1,89 @@
+const stylesheet = `
+.react-jinke-music-player-main svg:active, .react-jinke-music-player-main svg:hover {
+ color: #D60017
+}
+.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: #ff4e6b
+}
+.react-jinke-music-player-main ::-webkit-scrollbar-thumb,
+.react-jinke-music-player-mobile-progress .rc-slider-handle,
+.react-jinke-music-player-mobile-progress .rc-slider-track {
+ background-color: #ff4e6b
+}
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active {
+ box-shadow: 0 0 2px #ff4e6b
+}
+.audio-lists-panel-content .audio-item.playing,
+.react-jinke-music-player-main .audio-item.playing svg,
+.react-jinke-music-player-main .group player-delete {
+ color: #ff4e6b
+}
+.audio-lists-panel-content .audio-item:hover,
+.audio-lists-panel-content .audio-item:hover svg
+.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: #D60017
+}
+.react-jinke-music-player-main .audio-item.playing .player-singer {
+ color: #ff4e6b !important
+}
+.react-jinke-music-player-main .lyric-btn,
+.react-jinke-music-player-main .lyric-btn-active svg{
+ color: #ff4e6b !important
+}
+.react-jinke-music-player-main .lyric-btn-active {
+ color: #D60017 !important
+}
+.react-jinke-music-player-main .loading svg {
+ color: #ff4e6b !important
+}
+.react-jinke-music-player .music-player-controller .music-player-controller-setting{
+ background: #ff4e6b4d
+}
+.react-jinke-music-player-main .music-player-lyric{
+ color: #ff4e6b !important;
+ text-shadow: -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 1px 1px 0 #000
+}
+.react-jinke-music-player-main .music-player-panel,
+.react-jinke-music-player-mobile,
+.ril__outer{
+ background-color: #1f1f1f;
+ border: 1px solid #fff1;
+}
+.ril__toolbar{
+ background-color: #1d1d1d
+}
+.ril__toolbarItem{
+ font-size: 100%;
+ color: #eee
+}
+.audio-lists-panel{
+ background-color: #1f1f1f;
+ border: 1px solid #fff1;
+ border-radius: 6px 6px 0 0;
+}
+.react-jinke-music-player-main .music-player-panel .panel-content .img-rotate,
+.react-jinke-music-player-mobile .react-jinke-music-player-mobile-cover img.cover,
+.react-jinke-music-player-mobile-cover {
+ border-radius: 6px !important;
+ animation-duration: 0s !important
+}
+.react-jinke-music-player-main .music-player-panel .panel-content .img-content{
+ width: 60px;
+ height: 60px
+}
+.react-jinke-music-player-main .songTitle{
+ color: #eee
+}
+.react-jinke-music-player .music-player-controller{
+ color: #ff4e6b
+}
+.audio-lists-panel-mobile .audio-item:not(.audio-lists-panel-sortable-highlight-bg){
+ background: unset
+}
+.lastfm-icon,
+.musicbrainz-icon{
+ color: #eee
+}
+`
+export default stylesheet
diff --git a/ui/src/themes/amusic.js b/ui/src/themes/amusic.js
new file mode 100644
index 000000000..598b7b7fa
--- /dev/null
+++ b/ui/src/themes/amusic.js
@@ -0,0 +1,197 @@
+import stylesheet from './amusic.css.js'
+
+export default {
+ themeName: 'AMusic',
+ typography: {
+ fontFamily:
+ '-apple-system, BlinkMacSystemFont, Apple Color Emoji, SF Pro, SF Pro Icons, Helvetica Neue, Helvetica, Arial, sans-serif',
+ h6: {
+ fontSize: '1rem', // AppBar title
+ },
+ h5: {
+ fontSize: '2em',
+ fontWeight: '600',
+ },
+ },
+ palette: {
+ primary: {
+ main: '#ff4e6b',
+ },
+ secondary: {
+ main: '#D60017',
+ contrastText: '#eee',
+ },
+ background: {
+ default: '#1a1a1a',
+ paper: '#1a1a1a',
+ },
+ type: 'dark',
+ },
+ overrides: {
+ MuiFormGroup: {
+ root: {
+ color: 'white',
+ },
+ },
+ MuiAppBar: {
+ positionFixed: {
+ backgroundColor: '#1d1d1d !important',
+ boxShadow: 'none',
+ borderBottom: '1px solid #fff1',
+ },
+ colorSecondary: {
+ color: '#eee',
+ },
+ },
+ MuiDrawer: {
+ root: {
+ background: '#1d1d1d',
+ borderRight: '1px solid #fff1',
+ },
+ },
+ MuiToolbar: {
+ root: {
+ background: 'transparent !important',
+ },
+ },
+ MuiCardMedia: {
+ img: {
+ borderRadius: '10px',
+ boxShadow: '5px 5px 20px #111',
+ },
+ },
+ MuiButton: {
+ root: {
+ background: '#D60017',
+ color: '#fff',
+ borderRadius: '6px',
+ paddingRight: '0.5rem',
+ paddingLeft: '0.5rem',
+ marginLeft: '0.5rem',
+ marginBottom: '0.5rem',
+ textTransform: 'capitalize',
+ fontWeight: 600,
+ },
+ textPrimary: {
+ color: '#eee',
+ },
+ textSecondary: {
+ color: '#eee',
+ backgroundColor: '#ff4e6b',
+ },
+ textSizeSmall: {
+ fontSize: '0.8rem',
+ paddingRight: '0.5rem',
+ paddingLeft: '0.5rem',
+ },
+ label: {
+ paddingRight: '1rem',
+ paddingLeft: '0.7rem',
+ },
+ },
+ MuiListItemIcon: {
+ root: {
+ color: '#ff4e6b',
+ },
+ },
+ MuiChip: {
+ root: {
+ borderRadius: '6px',
+ },
+ },
+ MuiIconButton: {
+ root: {
+ color: '#ff4e6b',
+ },
+ },
+ MuiTableBody: {
+ root: {
+ '&>tr:nth-child(odd)': {
+ background: 'rgba(255, 255, 255, 0.025)',
+ },
+ },
+ },
+ MuiTableRow: {
+ root: {
+ background: 'transparent',
+ },
+ },
+ MuiTableCell: {
+ root: {
+ borderBottom: '0 none !important',
+ padding: '10px !important',
+ color: '#b3b3b3 !important',
+ },
+ head: {
+ color: '#b3b3b3 !important',
+ },
+ },
+ MuiMenuItem: {
+ root: {
+ fontSize: '0.875rem',
+ borderRadius: '10px',
+ color: '#eee',
+ },
+ },
+ NDAlbumGridView: {
+ albumName: {
+ color: '#eee',
+ },
+ albumSubtitle: {
+ color: '#ccc',
+ },
+ albumPlayButton: {
+ color: '#ff4e6b !important',
+ },
+ albumArtistName: {
+ color: '#ff4e6b !important',
+ },
+ cover: {
+ borderRadius: '10px !important',
+ },
+ },
+ NDLogin: {
+ systemNameLink: {
+ color: '#D60017',
+ },
+ welcome: {
+ color: '#eee',
+ },
+ card: {
+ minWidth: 300,
+ backgroundColor: '#1d1d1d',
+ },
+ },
+ MuiPaper: {
+ elevation1: {
+ boxShadow: 'none',
+ },
+ root: {
+ color: '#eee',
+ },
+ },
+ NDMobileArtistDetails: {
+ bgContainer: {
+ background: '#1a1a1a',
+ },
+ artistName: {
+ fontWeight: '600',
+ fontSize: '2em',
+ },
+ },
+ NDDesktopArtistDetails: {
+ artistName: {
+ fontWeight: '600',
+ fontSize: '2em',
+ },
+ artistDetail: {
+ padding: 'unset',
+ paddingBottom: '1rem',
+ },
+ },
+ },
+ player: {
+ theme: 'dark',
+ stylesheet,
+ },
+}
diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js
index 0234d416b..ea4a2472a 100644
--- a/ui/src/themes/index.js
+++ b/ui/src/themes/index.js
@@ -10,6 +10,7 @@ import NordTheme from './nord'
import GruvboxDarkTheme from './gruvboxDark'
import CatppuccinMacchiatoTheme from './catppuccinMacchiato'
import NuclearTheme from './nuclear'
+import AmusicTheme from './amusic'
export default {
// Classic default themes
@@ -17,6 +18,7 @@ export default {
DarkTheme,
// New themes should be added here, in alphabetic order
+ AmusicTheme,
CatppuccinMacchiatoTheme,
ElectricPurpleTheme,
ExtraDarkTheme,
From ee51bd9281c36a50fabafd95f6915cea4c0e5fe9 Mon Sep 17 00:00:00 2001
From: Xavier Araque
Date: Sat, 22 Nov 2025 19:41:59 +0100
Subject: [PATCH 065/102] feat(ui): add SquiddiesGlass Theme (#4632)
* feat: Add SquiddiesGlass Theme
* feat: fix commnets by gemini-code-assist in PR
* feat: fix Prettier format
* feat: fix play button, and text mobile
* feat: fix play button, and text mobile, prettier
* feat: fix chip, title artist
* fix: loading albbun, play button color
* prettier
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
Co-authored-by: Xavier Araque
Co-authored-by: Deluan
---
ui/src/themes/SquiddiesGlass.css.js | 175 ++++++++
ui/src/themes/SquiddiesGlass.js | 608 ++++++++++++++++++++++++++++
ui/src/themes/index.js | 2 +
3 files changed, 785 insertions(+)
create mode 100644 ui/src/themes/SquiddiesGlass.css.js
create mode 100644 ui/src/themes/SquiddiesGlass.js
diff --git a/ui/src/themes/SquiddiesGlass.css.js b/ui/src/themes/SquiddiesGlass.css.js
new file mode 100644
index 000000000..2c8e4f1d6
--- /dev/null
+++ b/ui/src/themes/SquiddiesGlass.css.js
@@ -0,0 +1,175 @@
+const stylesheet = `
+
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle {
+ background: #c231ab
+}
+.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track,
+.react-jinke-music-player-mobile-progress .rc-slider-track {
+ background: linear-gradient(to left, #c231ab, #380eff)
+}
+
+.react-jinke-music-player-mobile {
+ background-color: #171717 !important;
+}
+
+.react-jinke-music-player-mobile-progress .rc-slider-handle {
+ background: #c231ab;
+ height: 20px;
+ width: 20px;
+ margin-top: -9px;
+}
+
+.react-jinke-music-player-main ::-webkit-scrollbar-thumb {
+ background-color: #c231ab;
+}
+
+.react-jinke-music-player-pause-icon {
+ background-color: #c231ab;
+ border-radius: 50%;
+ outline: auto;
+ color: white;
+}
+.react-jinke-music-player-main .music-player-panel .panel-content .player-content {
+ z-index: 99999;
+}
+.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-btn svg {
+ border-radius: 50%;
+ outline: auto;
+ color: white;
+}
+.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-btn svg:hover {
+ background-color: #c231ab;
+ border-radius: 50%;
+ outline: auto;
+ color: white;
+}
+
+.react-jinke-music-player-main svg:hover {
+ color: #c231ab;
+}
+
+.react-jinke-music-player .music-player-controller {
+ color: #c231ab;
+ border: 1px solid #e14ac2;
+}
+
+.react-jinke-music-player .music-player-controller.music-player-playing:before {
+ border: 1px solid rgba(194, 49, 171, 0.3);
+}
+
+.react-jinke-music-player .music-player .destroy-btn {
+ background-color: #c2c1c2;
+ top: -7px;
+ border-radius: 50%;
+ display: flex;
+}
+
+.react-jinke-music-player .music-player .destroy-btn svg {
+ font-size: 20px;
+}
+
+@media screen and (max-width: 767px) {
+ .react-jinke-music-player .music-player .destroy-btn {
+ right: -12px;
+ }
+}
+
+.react-jinke-music-player-mobile-header-right {
+ right: 0;
+ top: 0;
+}
+
+@media screen and (max-width: 767px) {
+ .react-jinke-music-player-main svg {
+ font-size: 32px;
+ }
+}
+
+@keyframes gradientFlow {
+ 0% { background-position: 0% 50%; }
+ 50% { background-position: 100% 50%; }
+ 100% { background-position: 0% 50%; }
+}
+
+.RaBulkActionsToolbar .MuiButton-label {
+ color: white;
+}
+
+a[aria-current="page"] {
+ color: #c231ab !important;
+ font-weight: bold;
+}
+
+a[aria-current="page"] .MuiListItemIcon-root {
+ color: #c231ab !important;
+}
+
+.panel-content {
+ position: relative;
+ overflow: hidden;
+ background: linear-gradient(90deg, #311f2f, #0a0912, #2f0c28);
+ background-size: 300% 300%;
+ animation: gradientFlow 10s ease-in-out infinite;
+}
+
+/* Equalizer bars */
+.panel-content::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: repeating-linear-gradient(
+ 90deg,
+ rgba(255, 255, 255, 0.05) 0px,
+ rgba(255, 255, 255, 0.05) 2px,
+ transparent 1px,
+ transparent 3px
+ );
+ animation: equalizer 1.8s infinite ease-in-out;
+ filter: blur(1px);
+ opacity: 0.5;
+}
+
+@keyframes backgroundFlow {
+ 0% {
+ background-position: 0% 50%;
+ }
+ 50% {
+ background-position: 100% 50%;
+ }
+ 100% {
+ background-position: 0% 50%;
+ }
+}
+
+/* Vertical movement, equalizer type */
+@keyframes equalizer {
+ 0%, 100% {
+ transform: scaleY(1);
+ opacity: 0.2;
+ }
+ 25% {
+ transform: scaleY(1.4);
+ opacity: 0.9;
+ }
+ 50% {
+ transform: scaleY(0.7);
+ opacity: 0.2;
+ }
+ 75% {
+ transform: scaleY(1.2);
+ opacity: 0.8;
+ }
+}
+
+@keyframes pulse {
+ 0% { opacity: 0.5; }
+ 100% { opacity: 1; }
+}
+
+@keyframes spin {
+ from { transform: rotate(0deg); }
+ to { transform: rotate(360deg); }
+}
+`
+
+export default stylesheet
diff --git a/ui/src/themes/SquiddiesGlass.js b/ui/src/themes/SquiddiesGlass.js
new file mode 100644
index 000000000..5c3844074
--- /dev/null
+++ b/ui/src/themes/SquiddiesGlass.js
@@ -0,0 +1,608 @@
+import stylesheet from './SquiddiesGlass.css.js'
+
+/**
+ * Color constants used throughout the Squiddies Glass theme.
+ * Provides a consistent color palette with pink, gray, purple, and basic colors.
+ * @type {Object}
+ */
+const colors = {
+ pink: {
+ 100: '#fbe3f4',
+ 200: '#f5b9e3',
+ 300: '#ec7cd6',
+ 400: '#e14ac2',
+ 500: '#c231ab', // base
+ 600: '#a31a92',
+ 700: '#8b0f7e',
+ 800: '#7a006d',
+ 900: '#670066',
+ },
+ gray: {
+ 50: '#c2c1c2',
+ 100: '#b3b3b3', // light gray
+ 200: '#282828', // medium dark
+ 300: '#1d1d1d', // darker
+ 400: '#181818', // even darker
+ 500: '#171717', // darkest
+ },
+ purple: {
+ 400: '#524590',
+ 500: '#4d3249',
+ 600: '#6d1c5e',
+ },
+ black: '#000',
+ white: '#fff',
+ dark: '#121212',
+}
+
+/**
+ * Shared style object for music list action buttons.
+ * Defines common styling for buttons in music lists, including hover effects and responsive scaling.
+ * @type {Object}
+ */
+const musicListActions = {
+ padding: '1rem 0',
+ alignItems: 'center',
+ '@global': {
+ button: {
+ border: '1px solid transparent',
+ backgroundColor: 'inherit',
+ color: colors.gray[100],
+ '&:hover': {
+ border: `1px solid ${colors.gray[100]}`,
+ backgroundColor: 'inherit !important',
+ },
+ },
+ 'button:first-child:not(:only-child)': {
+ '@media screen and (max-width: 720px)': {
+ transform: 'scale(1.3)',
+ margin: '1em',
+ '&:hover': {
+ transform: 'scale(1.2) !important',
+ },
+ },
+ transform: 'scale(1.3)',
+ margin: '1em',
+ minWidth: 0,
+ padding: 5,
+ transition: 'transform .3s ease',
+ background: colors.pink[500],
+ color: `${colors.black} !important`,
+ borderRadius: 500,
+ border: 0,
+ '&:hover': {
+ transform: 'scale(1.2)',
+ backgroundColor: `${colors.pink[500]} !important`,
+ border: 0,
+ },
+ },
+ 'button:only-child': {
+ marginTop: '0.3em',
+ },
+ 'button:first-child>span:first-child': {
+ padding: 0,
+ color: `${colors.black} !important`,
+ },
+ 'button:first-child>span:first-child>span': {
+ display: 'none',
+ },
+ 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
+ {
+ color: colors.gray[100],
+ },
+ },
+}
+
+/**
+ * Squiddies Glass theme configuration object.
+ * Defines the complete theme structure including typography, palette, component overrides, and player settings.
+ * @type {Object}
+ */
+export default {
+ /**
+ * The name of the theme.
+ * @type {string}
+ */
+ themeName: 'Squiddies Glass',
+
+ /**
+ * Typography settings for the theme.
+ * Specifies font family and heading sizes.
+ * @type {Object}
+ */
+ typography: {
+ fontFamily: "system-ui, 'Helvetica Neue', Helvetica, Arial, sans-serif",
+ h6: {
+ fontSize: '1rem', // AppBar title
+ },
+ },
+
+ /**
+ * Color palette configuration.
+ * Defines primary, secondary, and background colors for the theme.
+ * @type {Object}
+ */
+ palette: {
+ primary: {
+ light: colors.pink[300],
+ main: colors.pink[500],
+ },
+ secondary: {
+ main: colors.white,
+ contrastText: colors.white,
+ },
+ background: {
+ default: colors.dark,
+ paper: colors.dark,
+ },
+ type: 'dark',
+ },
+
+ /**
+ * Component overrides for Material-UI and custom Navidrome components.
+ * Customizes the appearance and behavior of various UI components.
+ * @type {Object}
+ */
+ overrides: {
+ // Material-UI Components
+ MuiAppBar: {
+ positionFixed: {
+ backgroundColor: `${colors.black} !important`,
+ boxShadow: 'none',
+ },
+ },
+ MuiButton: {
+ root: {
+ background: colors.pink[500],
+ color: colors.white,
+ border: '1px solid transparent',
+ borderRadius: 500,
+ '&:hover': {
+ background: `${colors.pink[900]} !important`,
+ },
+ },
+ textSecondary: {
+ border: `1px solid ${colors.gray[100]}`,
+ background: colors.black,
+ '&:hover': {
+ border: `1px solid ${colors.white} !important`,
+ background: `${colors.black} !important`,
+ },
+ },
+ label: {
+ color: colors.white,
+ paddingRight: '1rem',
+ paddingLeft: '0.7rem',
+ },
+ },
+ MuiCardMedia: {
+ root: {
+ position: 'relative',
+ overflow: 'hidden',
+ boxShadow: `0 2px 32px rgba(0,0,0,0.5), 0px 1px 5px rgba(0,0,0,0.1)`,
+ },
+ },
+ MuiDivider: {
+ root: {
+ margin: '.75rem 0',
+ },
+ },
+ MuiDrawer: {
+ root: {
+ background: colors.gray[500],
+ paddingTop: '10px',
+ },
+ },
+ MuiFormGroup: {
+ root: {
+ color: colors.pink[500],
+ },
+ },
+ MuiMenuItem: {
+ root: {
+ fontSize: '0.875rem',
+ },
+ },
+ MuiTableCell: {
+ root: {
+ borderBottom: `1px solid ${colors.gray[300]}`,
+ padding: '10px !important',
+ color: `${colors.gray[100]} !important`,
+ '& img': {
+ filter:
+ 'brightness(0) saturate(100%) invert(36%) sepia(93%) saturate(7463%) hue-rotate(289deg) brightness(95%) contrast(102%);',
+ },
+ '& img + span': {
+ color: colors.pink[500],
+ },
+ },
+ head: {
+ borderBottom: `1px solid ${colors.gray[200]}`,
+ fontSize: '0.75rem',
+ textTransform: 'uppercase',
+ letterSpacing: 1.2,
+ },
+ },
+ MuiTableRow: {
+ root: {
+ padding: '10px 0',
+ transition: 'background-color .3s ease',
+ '&:hover': {
+ backgroundColor: `${colors.gray[300]} !important`,
+ },
+ '@global': {
+ 'td:nth-child(4)': {
+ color: `${colors.white} !important`,
+ },
+ },
+ },
+ },
+
+ // React Admin Components
+ RaBulkActionsToolbar: {
+ topToolbar: {
+ gap: '8px',
+ },
+ },
+ RaFilter: {
+ form: {
+ '& .MuiOutlinedInput-input:-webkit-autofill': {
+ '-webkit-box-shadow': `0 0 0 100px ${colors.gray[50]} inset`,
+ '-webkit-text-fill-color': colors.white,
+ },
+ },
+ },
+ RaFilterButton: {
+ root: {
+ marginRight: '1rem',
+ },
+ },
+ RaLayout: {
+ content: {
+ padding: '0 !important',
+ background: `linear-gradient(${colors.dark}, ${colors.gray[500]})`,
+ borderTopRightRadius: '8px',
+ borderTopLeftRadius: '8px',
+ },
+ contentWithSidebar: {
+ gap: '2px',
+ },
+ },
+ RaList: {
+ content: {
+ backgroundColor: 'inherit',
+ },
+ bulkActionsDisplayed: {
+ marginTop: '-20px',
+ },
+ },
+ RaListToolbar: {
+ toolbar: {
+ padding: '0 .55rem !important',
+ },
+ },
+ RaPaginationActions: {
+ currentPageButton: {
+ border: `1px solid ${colors.gray[100]}`,
+ },
+ button: {
+ backgroundColor: 'inherit',
+ minWidth: 48,
+ margin: '0 4px',
+ border: `1px solid ${colors.gray[200]}`,
+ '@global': {
+ '> .MuiButton-label': {
+ padding: 0,
+ },
+ },
+ },
+ actions: {
+ '@global': {
+ '.next-page': {
+ marginLeft: 8,
+ marginRight: 8,
+ },
+ '.previous-page': {
+ marginRight: 8,
+ },
+ },
+ },
+ },
+ RaSearchInput: {
+ input: {
+ paddingLeft: '.9rem',
+ border: 0,
+ '& .MuiInputBase-root': {
+ backgroundColor: `${colors.white} !important`,
+ borderRadius: '20px !important',
+ color: colors.black,
+ border: '0px',
+ '& fieldset': {
+ borderColor: colors.white,
+ },
+ '&:hover fieldset': {
+ borderColor: colors.white,
+ },
+ '&.Mui-focused fieldset': {
+ borderColor: colors.white,
+ },
+ '& svg': {
+ color: `${colors.black} !important`,
+ },
+ '& .MuiOutlinedInput-input:-webkit-autofill': {
+ borderRadius: '20px 0px 0px 20px',
+ '-webkit-box-shadow': `0 0 0 100px ${colors.gray[50]} inset`,
+ '-webkit-text-fill-color': colors.black,
+ },
+ },
+ },
+ },
+ RaSidebar: {
+ root: {
+ height: 'initial',
+ borderTopRightRadius: '8px',
+ borderTopLeftRadius: '8px',
+ },
+ },
+
+ // Navidrome Custom Components
+ NDAlbumDetails: {
+ root: {
+ boxShadow: 'none',
+ background: `linear-gradient(45deg, ${colors.purple[500]}, ${colors.purple[400]}, ${colors.purple[600]})`,
+ backgroundSize: '200% 200%',
+ animation: 'gradientFlow 8s ease-in-out infinite',
+ position: 'relative',
+ '&:before': {
+ content: '""',
+ position: 'absolute',
+ top: '0',
+ left: '0',
+ width: '100%',
+ height: '100%',
+ background: `linear-gradient(to bottom, transparent, ${colors.dark})`,
+ },
+ },
+ cardContents: {
+ alignItems: 'flex-start',
+ },
+ coverParent: {
+ zIndex: '99999',
+ position: 'relative',
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ '&::before': {
+ content: '""',
+ position: 'absolute',
+ inset: '0',
+ width: '100%',
+ height: '100%',
+ borderRadius: '50%',
+ animation: 'pulse 1.5s ease-in-out infinite alternate',
+ zIndex: -1,
+ },
+ '&::after': {
+ content: '""',
+ position: 'absolute',
+ inset: '0',
+ zIndex: '-1',
+ borderRadius: '50%',
+ background:
+ 'repeating-conic-gradient(from 0deg, rgba(255,255,255,0.08) 0deg, rgba(255,255,255,0.08) 0.5deg, rgba(0,0,0,1) 1deg)',
+ filter: 'contrast(999) sepia(1)',
+ boxShadow:
+ 'inset 0 0 25px rgba(255,255,255,0.05), inset 0 0 95px rgba(0,0,0,0.9)',
+ animation: 'spin 6s linear infinite',
+ },
+ },
+ details: {
+ zIndex: '99999',
+ },
+ recordName: {
+ fontSize: 'calc(1rem + 1.5vw)',
+ fontWeight: 900,
+ },
+ recordArtist: {
+ fontSize: '1.5rem',
+ fontWeight: 700,
+ textShadow: '0 2px 16px rgba(0, 0, 0, 0.3)',
+ },
+ recordMeta: {
+ fontSize: '.875rem',
+ color: `rgba(${colors.white}, 0.8)`,
+ },
+ content: {
+ paddingBottom: '0px !important',
+ paddingTop: '0px',
+ },
+ },
+ RaSingleFieldList: {
+ root: {
+ '& a:first-of-type > .MuiChip-root': {
+ marginLeft: '0px',
+ },
+ '& a > .MuiChip-root': {
+ backgroundColor: colors.pink[500],
+ fontSize: '0.6rem',
+ height: '20px',
+ '& .MuiChip-label': {
+ color: colors.white,
+ paddingLeft: '5px',
+ paddingRight: '5px',
+ },
+ },
+ },
+ },
+ MuiGridListTile: {
+ tile: {
+ '&:hover': {
+ boxShadow: '0 2px 32px rgba(0,0,0,0.5), 0px 1px 5px rgba(0,0,0,0.1)',
+ },
+ },
+ },
+ NDAlbumGridView: {
+ tileBar: {
+ background:
+ 'linear-gradient(to top, rgba(0, 0, 0, 0.7) 0%, rgba(0, 0, 0, 0.4) 50%, rgba(0, 0, 0, 0) 100%)',
+ marginBottom: '2px',
+ },
+ albumName: {
+ marginTop: '0.5rem',
+ fontWeight: 700,
+ textTransform: 'none',
+ color: colors.white,
+ },
+ albumSubtitle: {
+ color: colors.gray[100],
+ },
+ albumContainer: {
+ backgroundColor: colors.gray[400],
+ borderRadius: '.5rem',
+ padding: '.75rem',
+ transition: 'background-color .3s ease',
+ '&:hover': {
+ backgroundColor: colors.gray[200],
+ },
+ },
+ albumPlayButton: {
+ color: colors.black,
+ backgroundColor: colors.pink[500],
+ borderRadius: '50%',
+ boxShadow: '0 8px 8px rgb(0 0 0 / 30%)',
+ padding: '0.35rem',
+ transition: 'padding .3s ease',
+ '&:hover': {
+ background: `${colors.pink[500]} !important`,
+ padding: '0.45rem',
+ },
+ },
+ },
+ NDAlbumShow: {
+ albumActions: musicListActions,
+ },
+ NDArtistShow: {
+ actions: {
+ padding: '2rem 0',
+ alignItems: 'center',
+ overflow: 'visible',
+ minHeight: '120px',
+ '@global': {
+ button: {
+ border: '1px solid transparent',
+ backgroundColor: 'inherit',
+ color: colors.gray[100],
+ margin: '0 0.5rem',
+ '&:hover': {
+ border: `1px solid ${colors.gray[100]}`,
+ backgroundColor: 'inherit !important',
+ },
+ },
+ // Hide shuffle button label (first button)
+ 'button:first-child>span:first-child>span': {
+ display: 'none',
+ },
+ // Style shuffle button (first button)
+ 'button:first-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',
+ background: colors.pink[500],
+ color: colors.white,
+ borderRadius: 500,
+ border: 0,
+ '&:hover': {
+ transform: 'scale(2.1)',
+ backgroundColor: `${colors.pink[500]} !important`,
+ border: 0,
+ },
+ },
+ 'button:first-child>span:first-child': {
+ padding: 0,
+ color: `${colors.black} !important`,
+ },
+ 'button>span:first-child>span, button:not(:first-child)>span:first-child>svg':
+ {
+ color: colors.gray[100],
+ },
+ },
+ },
+ actionsContainer: {
+ overflow: 'visible',
+ },
+ },
+ NDAudioPlayer: {
+ audioTitle: {
+ color: colors.white,
+ fontSize: '1.5rem',
+ '& span:nth-child(3)': {
+ fontSize: '0.8rem',
+ },
+ },
+ songTitle: {
+ fontWeight: 900,
+ },
+ songInfo: {
+ fontSize: '0.9rem',
+ color: colors.gray[100],
+ },
+ },
+ NDCollapsibleComment: {
+ commentBlock: {
+ fontSize: '.875rem',
+ color: `rgba(${colors.white}, 0.8)`,
+ },
+ },
+ NDLogin: {
+ main: {
+ boxShadow: `inset 0 0 0 2000px rgba(${colors.black}, .75)`,
+ },
+ systemNameLink: {
+ color: colors.white,
+ },
+ card: {
+ border: `1px solid ${colors.gray[200]}`,
+ },
+ avatar: {
+ marginBottom: 0,
+ },
+ },
+ NDPlaylistDetails: {
+ container: {
+ background: `linear-gradient(${colors.gray[300]}, transparent)`,
+ borderRadius: 0,
+ paddingTop: '2.5rem !important',
+ boxShadow: 'none',
+ },
+ title: {
+ fontSize: 'calc(1.5rem + 1.5vw)',
+ fontWeight: 700,
+ color: colors.white,
+ },
+ details: {
+ fontSize: '.875rem',
+ color: `rgba(${colors.white}, 0.8)`,
+ },
+ },
+ NDPlaylistShow: {
+ playlistActions: musicListActions,
+ },
+ },
+
+ /**
+ * Player configuration settings.
+ * Specifies the player theme and associated stylesheet.
+ * @type {Object}
+ */
+ player: {
+ theme: 'dark',
+ stylesheet,
+ },
+}
diff --git a/ui/src/themes/index.js b/ui/src/themes/index.js
index ea4a2472a..5f9060383 100644
--- a/ui/src/themes/index.js
+++ b/ui/src/themes/index.js
@@ -11,6 +11,7 @@ import GruvboxDarkTheme from './gruvboxDark'
import CatppuccinMacchiatoTheme from './catppuccinMacchiato'
import NuclearTheme from './nuclear'
import AmusicTheme from './amusic'
+import SquiddiesGlassTheme from './SquiddiesGlass'
export default {
// Classic default themes
@@ -29,4 +30,5 @@ export default {
NordTheme,
NuclearTheme,
SpotifyTheme,
+ SquiddiesGlassTheme,
}
From c21aee736006d20def0bc018bc90e901cf9e9797 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Sat, 22 Nov 2025 20:14:44 -0500
Subject: [PATCH 066/102] fix(config): enables quoted `;` as values in ini
files
Signed-off-by: Deluan
---
conf/configuration.go | 7 ++++++-
conf/configuration_test.go | 1 +
conf/testdata/cfg.ini | 5 +++--
conf/testdata/cfg.json | 3 +++
conf/testdata/cfg.toml | 2 ++
conf/testdata/cfg.yaml | 2 ++
6 files changed, 17 insertions(+), 3 deletions(-)
diff --git a/conf/configuration.go b/conf/configuration.go
index 0ad81492a..8be005591 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -617,7 +617,12 @@ func init() {
func InitConfig(cfgFile string) {
codecRegistry := viper.NewCodecRegistry()
- _ = codecRegistry.RegisterCodec("ini", ini.Codec{})
+ _ = codecRegistry.RegisterCodec("ini", ini.Codec{
+ LoadOptions: ini.LoadOptions{
+ UnescapeValueDoubleQuotes: true,
+ UnescapeValueCommentSymbols: true,
+ },
+ })
viper.SetOptions(viper.WithCodecRegistry(codecRegistry))
cfgFile = getConfigFile(cfgFile)
diff --git a/conf/configuration_test.go b/conf/configuration_test.go
index 5b54e4975..88454d204 100644
--- a/conf/configuration_test.go
+++ b/conf/configuration_test.go
@@ -39,6 +39,7 @@ var _ = Describe("Configuration", func() {
Expect(conf.Server.MusicFolder).To(Equal(fmt.Sprintf("/%s/music", format)))
Expect(conf.Server.UIWelcomeMessage).To(Equal("Welcome " + format))
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
+ Expect(conf.Server.Tags["artist"].Split).To(Equal([]string{";"}))
// The config file used should be the one we created
Expect(conf.Server.ConfigFile).To(Equal(filename))
diff --git a/conf/testdata/cfg.ini b/conf/testdata/cfg.ini
index cec7d3c70..e0062ff0e 100644
--- a/conf/testdata/cfg.ini
+++ b/conf/testdata/cfg.ini
@@ -1,6 +1,7 @@
[default]
MusicFolder = /ini/music
-UIWelcomeMessage = Welcome ini
+UIWelcomeMessage = 'Welcome ini' ; Just a comment to test the LoadOptions
[Tags]
-Custom.Aliases = ini,test
\ No newline at end of file
+Custom.Aliases = ini,test
+artist.Split = ";" # Should be able to read ; as a separator
\ No newline at end of file
diff --git a/conf/testdata/cfg.json b/conf/testdata/cfg.json
index 37cf74f08..127103a53 100644
--- a/conf/testdata/cfg.json
+++ b/conf/testdata/cfg.json
@@ -2,6 +2,9 @@
"musicFolder": "/json/music",
"uiWelcomeMessage": "Welcome json",
"Tags": {
+ "artist": {
+ "split": ";"
+ },
"custom": {
"aliases": [
"json",
diff --git a/conf/testdata/cfg.toml b/conf/testdata/cfg.toml
index 1dc852b18..d94d786e2 100644
--- a/conf/testdata/cfg.toml
+++ b/conf/testdata/cfg.toml
@@ -1,5 +1,7 @@
musicFolder = "/toml/music"
uiWelcomeMessage = "Welcome toml"
+Tags.artist.Split = ';'
+
[Tags.custom]
aliases = ["toml", "test"]
diff --git a/conf/testdata/cfg.yaml b/conf/testdata/cfg.yaml
index 38b98d4aa..66e12c4eb 100644
--- a/conf/testdata/cfg.yaml
+++ b/conf/testdata/cfg.yaml
@@ -1,6 +1,8 @@
musicFolder: "/yaml/music"
uiWelcomeMessage: "Welcome yaml"
Tags:
+ artist:
+ split: [";"]
custom:
aliases:
- yaml
From 12d08985855353681c02d9eb9448cd69dc5f69bf Mon Sep 17 00:00:00 2001
From: Deluan
Date: Sat, 22 Nov 2025 21:36:44 -0500
Subject: [PATCH 067/102] chore(docker): remove GODEBUG=asyncpreemptoff=1 flag,
as it should not be needed on Go 1.15+
Signed-off-by: Deluan
---
Dockerfile | 1 -
1 file changed, 1 deletion(-)
diff --git a/Dockerfile b/Dockerfile
index fb1cf997b..6568ce9d2 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -137,7 +137,6 @@ ENV ND_MUSICFOLDER=/music
ENV ND_DATAFOLDER=/data
ENV ND_CONFIGFILE=/data/navidrome.toml
ENV ND_PORT=4533
-ENV GODEBUG="asyncpreemptoff=1"
RUN touch /.nddockerenv
EXPOSE ${ND_PORT}
From c40f12e65bc4390543098e3c4204c6e9d7656b90 Mon Sep 17 00:00:00 2001
From: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
Date: Sun, 23 Nov 2025 19:16:10 -0800
Subject: [PATCH 068/102] fix(scanner): Use repeated arg instead of comma split
(#4727)
---
cmd/scan.go | 9 ++++-----
scanner/external.go | 9 ++++-----
2 files changed, 8 insertions(+), 10 deletions(-)
diff --git a/cmd/scan.go b/cmd/scan.go
index 41d281070..e587b8931 100644
--- a/cmd/scan.go
+++ b/cmd/scan.go
@@ -4,7 +4,6 @@ import (
"context"
"encoding/gob"
"os"
- "strings"
"github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/db"
@@ -19,13 +18,13 @@ import (
var (
fullScan bool
subprocess bool
- targets string
+ targets []string
)
func init() {
scanCmd.Flags().BoolVarP(&fullScan, "full", "f", false, "check all subfolders, ignoring timestamps")
scanCmd.Flags().BoolVarP(&subprocess, "subprocess", "", false, "run as subprocess (internal use)")
- scanCmd.Flags().StringVarP(&targets, "targets", "t", "", "comma-separated list of libraryID:folderPath pairs (e.g., \"1:Music/Rock,1:Music/Jazz,2:Classical\")")
+ scanCmd.Flags().StringArrayVarP(&targets, "target", "t", []string{}, "list of libraryID:folderPath pairs, can be repeated (e.g., \"-t 1:Music/Rock -t 1:Music/Jazz -t 2:Classical\")")
rootCmd.AddCommand(scanCmd)
}
@@ -74,9 +73,9 @@ func runScanner(ctx context.Context) {
// Parse targets if provided
var scanTargets []model.ScanTarget
- if targets != "" {
+ if len(targets) > 0 {
var err error
- scanTargets, err = model.ParseTargets(strings.Split(targets, ","))
+ scanTargets, err = model.ParseTargets(targets)
if err != nil {
log.Fatal(ctx, "Failed to parse targets", err)
}
diff --git a/scanner/external.go b/scanner/external.go
index b6d7639be..f5a117e48 100644
--- a/scanner/external.go
+++ b/scanner/external.go
@@ -8,12 +8,10 @@ import (
"io"
"os"
"os/exec"
- "strings"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/utils/slice"
)
// scannerExternal is a scanner that runs an external process to do the scanning. It is used to avoid
@@ -47,9 +45,10 @@ func (s *scannerExternal) scan(ctx context.Context, fullScan bool, targets []mod
// Add targets if provided
if len(targets) > 0 {
- targetsStr := strings.Join(slice.Map(targets, func(t model.ScanTarget) string { return t.String() }), ",")
- args = append(args, "--targets", targetsStr)
- log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targetsStr)
+ for _, target := range targets {
+ args = append(args, "-t", target.String())
+ }
+ log.Debug(ctx, "Spawning external scanner process with targets", "fullScan", fullScan, "path", exe, "targets", targets)
} else {
log.Debug(ctx, "Spawning external scanner process", "fullScan", fullScan, "path", exe)
}
From a6a682b385a973bf75edf174cc0eab018596796c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 24 Nov 2025 13:18:34 -0500
Subject: [PATCH 069/102] chore(deps): bump actions/checkout from 5 to 6 in
/.github/workflows (#4730)
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)
---
updated-dependencies:
- dependency-name: actions/checkout
dependency-version: '6'
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/pipeline.yml | 18 +++++++++---------
.github/workflows/update-translations.yml | 2 +-
2 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml
index 0767346fa..851e04c8e 100644
--- a/.github/workflows/pipeline.yml
+++ b/.github/workflows/pipeline.yml
@@ -25,7 +25,7 @@ jobs:
git_tag: ${{ steps.git-version.outputs.GIT_TAG }}
git_sha: ${{ steps.git-version.outputs.GIT_SHA }}
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
@@ -63,7 +63,7 @@ jobs:
name: Lint Go code
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Download TagLib
uses: ./.github/actions/download-taglib
@@ -93,7 +93,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
- uses: actions/checkout@v5
+ uses: actions/checkout@v6
- name: Download TagLib
uses: ./.github/actions/download-taglib
@@ -114,7 +114,7 @@ jobs:
env:
NODE_OPTIONS: "--max_old_space_size=4096"
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
@@ -145,7 +145,7 @@ jobs:
name: Lint i18n files
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- run: |
set -e
for file in resources/i18n/*.json; do
@@ -191,7 +191,7 @@ jobs:
PLATFORM=$(echo ${{ matrix.platform }} | tr '/' '_')
echo "PLATFORM=$PLATFORM" >> $GITHUB_ENV
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Prepare Docker Buildx
uses: ./.github/actions/prepare-docker
@@ -264,7 +264,7 @@ jobs:
env:
REGISTRY_IMAGE: ghcr.io/${{ github.repository }}
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Download digests
uses: actions/download-artifact@v6
@@ -318,7 +318,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- uses: actions/download-artifact@v6
with:
@@ -352,7 +352,7 @@ jobs:
outputs:
package_list: ${{ steps.set-package-list.outputs.package_list }}
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
diff --git a/.github/workflows/update-translations.yml b/.github/workflows/update-translations.yml
index 69ca1cc94..cc120cb8d 100644
--- a/.github/workflows/update-translations.yml
+++ b/.github/workflows/update-translations.yml
@@ -8,7 +8,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.repository_owner == 'navidrome' }}
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v6
- name: Get updated translations
id: poeditor
env:
From 228211f925503e7d477a23443f5376367e61500b Mon Sep 17 00:00:00 2001
From: Deluan
Date: Mon, 24 Nov 2025 21:16:28 -0500
Subject: [PATCH 070/102] test: add smart playlist tag criteria tests for issue
#4728
Add integration tests verifying the workaround for checking if a tag has any
value in smart playlists. The tests confirm that using 'contains' with an empty
string generates SQL that matches any non-empty tag value (value LIKE '%%'),
which is the recommended workaround for issue #4728.
Tests added:
- Verify contains with empty string matches tracks with tag values
- Verify notContains with empty string excludes tracks with tag values
Also updated test context to use GinkgoT().Context() instead of context.TODO().
---
persistence/playlist_repository_test.go | 118 +++++++++++++++++++++++-
1 file changed, 116 insertions(+), 2 deletions(-)
diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go
index 7fad93b1e..5f9af2a33 100644
--- a/persistence/playlist_repository_test.go
+++ b/persistence/playlist_repository_test.go
@@ -1,7 +1,6 @@
package persistence
import (
- "context"
"time"
"github.com/navidrome/navidrome/conf"
@@ -11,13 +10,14 @@ import (
"github.com/navidrome/navidrome/model/request"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
+ "github.com/pocketbase/dbx"
)
var _ = Describe("PlaylistRepository", func() {
var repo model.PlaylistRepository
BeforeEach(func() {
- ctx := log.NewContext(context.TODO())
+ ctx := log.NewContext(GinkgoT().Context())
ctx = request.WithUser(ctx, model.User{ID: "userid", UserName: "userid", IsAdmin: true})
repo = NewPlaylistRepository(ctx, GetDBXBuilder())
})
@@ -252,4 +252,118 @@ var _ = Describe("PlaylistRepository", func() {
Expect(tracks[3].MediaFileID).To(Equal("2001")) // Disc 2, Track 11
})
})
+
+ 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())
+ })
+ })
})
From 3294bcacfc089fdbfae3de65a6a69ae8fcfe6daa Mon Sep 17 00:00:00 2001
From: zacaj
Date: Mon, 24 Nov 2025 23:18:05 -0500
Subject: [PATCH 071/102] feat: add Rated At field - #4653 (#4660)
* feat(model): add Rated At field - #4653
Signed-off-by: zacaj
* fix(ui): ignore empty dates in rating/love tooltips - #4653
* refactor(ui): add isDateSet util function
Signed-off-by: zacaj
* feat: add tests for isDateSet and rated_at sort mappings
Added comprehensive tests for isDateSet and urlValidate functions in
ui/src/utils/validations.test.js covering falsy values, Go zero date handling,
valid date strings, Date objects, and edge cases.
Added rated_at sort mapping to album, artist, and mediafile repositories,
following the same pattern as starred_at (sorting by rating first, then by
timestamp). This enables proper sorting by rating date in the UI.
---------
Signed-off-by: zacaj
Co-authored-by: zacaj
Co-authored-by: Deluan
---
...51109010105_add_annotation_rating_date.sql | 7 ++
model/annotation.go | 1 +
model/criteria/fields.go | 1 +
persistence/album_repository.go | 1 +
persistence/artist_repository.go | 1 +
persistence/mediafile_repository.go | 1 +
persistence/playlist_repository.go | 1 +
persistence/playlist_track_repository.go | 1 +
persistence/sql_annotations.go | 4 +-
ui/src/common/DateField.jsx | 3 +-
ui/src/common/LoveButton.jsx | 8 +-
ui/src/common/RatingField.jsx | 10 ++-
ui/src/utils/validations.js | 13 ++++
ui/src/utils/validations.test.js | 73 +++++++++++++++++++
14 files changed, 121 insertions(+), 4 deletions(-)
create mode 100644 db/migrations/20251109010105_add_annotation_rating_date.sql
create mode 100644 ui/src/utils/validations.test.js
diff --git a/db/migrations/20251109010105_add_annotation_rating_date.sql b/db/migrations/20251109010105_add_annotation_rating_date.sql
new file mode 100644
index 000000000..9dac46a5e
--- /dev/null
+++ b/db/migrations/20251109010105_add_annotation_rating_date.sql
@@ -0,0 +1,7 @@
+-- +goose Up
+-- +goose StatementBegin
+ALTER TABLE annotation ADD COLUMN rated_at datetime;
+-- +goose StatementEnd
+
+-- +goose Down
+
\ No newline at end of file
diff --git a/model/annotation.go b/model/annotation.go
index 2ec72c1b7..fbff5f178 100644
--- a/model/annotation.go
+++ b/model/annotation.go
@@ -6,6 +6,7 @@ type Annotations struct {
PlayCount int64 `structs:"play_count" json:"playCount,omitempty"`
PlayDate *time.Time `structs:"play_date" json:"playDate,omitempty" `
Rating int `structs:"rating" json:"rating,omitempty" `
+ RatedAt *time.Time `structs:"rated_at" json:"ratedAt,omitempty" `
Starred bool `structs:"starred" json:"starred,omitempty" `
StarredAt *time.Time `structs:"starred_at" json:"starredAt,omitempty"`
}
diff --git a/model/criteria/fields.go b/model/criteria/fields.go
index 70719cd6f..5381ae597 100644
--- a/model/criteria/fields.go
+++ b/model/criteria/fields.go
@@ -44,6 +44,7 @@ var fieldMap = map[string]*mappedField{
"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)"},
"mbz_album_id": {field: "media_file.mbz_album_id"},
diff --git a/persistence/album_repository.go b/persistence/album_repository.go
index b1ce23e2b..dab255784 100644
--- a/persistence/album_repository.go
+++ b/persistence/album_repository.go
@@ -106,6 +106,7 @@ func NewAlbumRepository(ctx context.Context, db dbx.Builder) model.AlbumReposito
"random": "random",
"recently_added": recentlyAddedSort(),
"starred_at": "starred, starred_at",
+ "rated_at": "rating, rated_at",
})
return r
}
diff --git a/persistence/artist_repository.go b/persistence/artist_repository.go
index 6d08c27db..c9e38a1ee 100644
--- a/persistence/artist_repository.go
+++ b/persistence/artist_repository.go
@@ -141,6 +141,7 @@ func NewArtistRepository(ctx context.Context, db dbx.Builder) model.ArtistReposi
r.setSortMappings(map[string]string{
"name": "order_artist_name",
"starred_at": "starred, starred_at",
+ "rated_at": "rating, rated_at",
"song_count": "stats->>'total'->>'m'",
"album_count": "stats->>'total'->>'a'",
"size": "stats->>'total'->>'s'",
diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go
index e7883947a..8f32accc6 100644
--- a/persistence/mediafile_repository.go
+++ b/persistence/mediafile_repository.go
@@ -84,6 +84,7 @@ func NewMediaFileRepository(ctx context.Context, db dbx.Builder) model.MediaFile
"created_at": "media_file.created_at",
"recently_added": mediaFileRecentlyAddedSort(),
"starred_at": "starred, starred_at",
+ "rated_at": "rating, rated_at",
})
return r
}
diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go
index 046284e1f..a94f95a78 100644
--- a/persistence/playlist_repository.go
+++ b/persistence/playlist_repository.go
@@ -388,6 +388,7 @@ func (r *playlistRepository) loadTracks(sel SelectBuilder, id string) (model.Pla
"coalesce(play_count, 0) as play_count",
"play_date",
"coalesce(rating, 0) as rating",
+ "rated_at",
"f.*",
"playlist_tracks.*",
"library.path as library_path",
diff --git a/persistence/playlist_track_repository.go b/persistence/playlist_track_repository.go
index b3f9e0c07..666f227e2 100644
--- a/persistence/playlist_track_repository.go
+++ b/persistence/playlist_track_repository.go
@@ -97,6 +97,7 @@ func (r *playlistTrackRepository) Read(id string) (interface{}, error) {
"coalesce(rating, 0) as rating",
"starred_at",
"play_date",
+ "rated_at",
"f.*",
"playlist_tracks.*",
).
diff --git a/persistence/sql_annotations.go b/persistence/sql_annotations.go
index 98ade6e21..108e9be94 100644
--- a/persistence/sql_annotations.go
+++ b/persistence/sql_annotations.go
@@ -28,6 +28,7 @@ func (r sqlRepository) withAnnotation(query SelectBuilder, idField string) Selec
"coalesce(rating, 0) as rating",
"starred_at",
"play_date",
+ "rated_at",
)
if conf.Server.AlbumPlayCountMode == consts.AlbumPlayCountModeNormalized && r.tableName == "album" {
query = query.Columns(
@@ -77,7 +78,8 @@ func (r sqlRepository) SetStar(starred bool, ids ...string) error {
}
func (r sqlRepository) SetRating(rating int, itemID string) error {
- return r.annUpsert(map[string]interface{}{"rating": rating}, itemID)
+ ratedAt := time.Now()
+ return r.annUpsert(map[string]interface{}{"rating": rating, "rated_at": ratedAt}, itemID)
}
func (r sqlRepository) IncPlayCount(itemID string, ts time.Time) error {
diff --git a/ui/src/common/DateField.jsx b/ui/src/common/DateField.jsx
index fab15b53c..dce24a2b9 100644
--- a/ui/src/common/DateField.jsx
+++ b/ui/src/common/DateField.jsx
@@ -1,10 +1,11 @@
import React from 'react'
+import { isDateSet } from '../utils/validations'
import { DateField as RADateField } from 'react-admin'
export const DateField = (props) => {
const { record, source } = props
const value = record?.[source]
- if (value === '0001-01-01T00:00:00Z' || value === null) return null
+ if (!isDateSet(value)) return null
return
}
diff --git a/ui/src/common/LoveButton.jsx b/ui/src/common/LoveButton.jsx
index f42d92ff4..c940acf12 100644
--- a/ui/src/common/LoveButton.jsx
+++ b/ui/src/common/LoveButton.jsx
@@ -7,6 +7,7 @@ import { makeStyles } from '@material-ui/core/styles'
import { useToggleLove } from './useToggleLove'
import { useRecordContext } from 'react-admin'
import config from '../config'
+import { isDateSet } from '../utils/validations'
const useStyles = makeStyles({
love: {
@@ -46,8 +47,13 @@ export const LoveButton = ({
{record.starred ? (
diff --git a/ui/src/common/RatingField.jsx b/ui/src/common/RatingField.jsx
index b29c1eee8..f92b0d948 100644
--- a/ui/src/common/RatingField.jsx
+++ b/ui/src/common/RatingField.jsx
@@ -2,6 +2,7 @@ import React, { useCallback } from 'react'
import PropTypes from 'prop-types'
import Rating from '@material-ui/lab/Rating'
import { makeStyles } from '@material-ui/core/styles'
+import { isDateSet } from '../utils/validations'
import StarBorderIcon from '@material-ui/icons/StarBorder'
import clsx from 'clsx'
import { useRating } from './useRating'
@@ -45,7 +46,14 @@ export const RatingField = ({
)
return (
- stopPropagation(e)}>
+ stopPropagation(e)}
+ title={
+ isDateSet(record.ratedAt)
+ ? new Date(record.ratedAt).toLocaleString()
+ : undefined
+ }
+ >
{
return 'ra.validation.url'
}
}
+
+export function isDateSet(date) {
+ if (!date) {
+ return false
+ }
+ if (typeof date === 'string') {
+ return date !== '0001-01-01T00:00:00Z'
+ }
+ if (date instanceof Date) {
+ return date.toISOString() !== '0001-01-01T00:00:00Z'
+ }
+ return !!date
+}
diff --git a/ui/src/utils/validations.test.js b/ui/src/utils/validations.test.js
new file mode 100644
index 000000000..10f67d186
--- /dev/null
+++ b/ui/src/utils/validations.test.js
@@ -0,0 +1,73 @@
+import { isDateSet, urlValidate } from './validations'
+
+describe('urlValidate', () => {
+ it('returns undefined for valid URLs', () => {
+ expect(urlValidate('https://example.com')).toBeUndefined()
+ expect(urlValidate('http://localhost:3000')).toBeUndefined()
+ expect(urlValidate('ftp://files.example.com')).toBeUndefined()
+ })
+
+ it('returns undefined for empty values', () => {
+ expect(urlValidate('')).toBeUndefined()
+ expect(urlValidate(null)).toBeUndefined()
+ expect(urlValidate(undefined)).toBeUndefined()
+ })
+
+ it('returns error for invalid URLs', () => {
+ expect(urlValidate('not-a-url')).toEqual('ra.validation.url')
+ expect(urlValidate('example.com')).toEqual('ra.validation.url')
+ expect(urlValidate('://missing-protocol')).toEqual('ra.validation.url')
+ })
+})
+
+describe('isDateSet', () => {
+ describe('with falsy values', () => {
+ it('returns false for null', () => {
+ expect(isDateSet(null)).toBe(false)
+ })
+
+ it('returns false for undefined', () => {
+ expect(isDateSet(undefined)).toBe(false)
+ })
+
+ it('returns false for empty string', () => {
+ expect(isDateSet('')).toBe(false)
+ })
+ })
+
+ describe('with Go zero date string', () => {
+ it('returns false for Go zero date', () => {
+ expect(isDateSet('0001-01-01T00:00:00Z')).toBe(false)
+ })
+ })
+
+ describe('with valid date strings', () => {
+ it('returns true for ISO date strings', () => {
+ expect(isDateSet('2024-01-15T10:30:00Z')).toBe(true)
+ expect(isDateSet('2023-12-25T00:00:00Z')).toBe(true)
+ })
+
+ it('returns true for other date formats', () => {
+ expect(isDateSet('2024-01-15')).toBe(true)
+ })
+ })
+
+ describe('with Date objects', () => {
+ it('returns true for valid Date objects', () => {
+ expect(isDateSet(new Date())).toBe(true)
+ expect(isDateSet(new Date('2024-01-15T10:30:00Z'))).toBe(true)
+ })
+
+ // Note: Date objects representing Go zero date would return true because
+ // toISOString() adds milliseconds (0001-01-01T00:00:00.000Z).
+ // In practice, dates from the API come as strings, not Date objects,
+ // so this edge case doesn't occur.
+ })
+
+ describe('with other truthy values', () => {
+ it('returns true for non-date truthy values', () => {
+ expect(isDateSet(123)).toBe(true)
+ expect(isDateSet({})).toBe(true)
+ })
+ })
+})
From dc07dc413daf5da43dfed4fffec9c8db320bf928 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 24 Nov 2025 23:36:19 -0500
Subject: [PATCH 072/102] chore(deps): bump golangci/golangci-lint-action in
/.github/workflows (#4673)
Bumps [golangci/golangci-lint-action](https://github.com/golangci/golangci-lint-action) from 8 to 9.
- [Release notes](https://github.com/golangci/golangci-lint-action/releases)
- [Commits](https://github.com/golangci/golangci-lint-action/compare/v8...v9)
---
updated-dependencies:
- dependency-name: golangci/golangci-lint-action
dependency-version: '9'
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.github/workflows/pipeline.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml
index 851e04c8e..3352cfa4b 100644
--- a/.github/workflows/pipeline.yml
+++ b/.github/workflows/pipeline.yml
@@ -71,7 +71,7 @@ jobs:
version: ${{ env.CROSS_TAGLIB_VERSION }}
- name: golangci-lint
- uses: golangci/golangci-lint-action@v8
+ uses: golangci/golangci-lint-action@v9
with:
version: latest
problem-matchers: true
From ca83ebbb53d536cac1c15d6f41101d4ca1b9269f Mon Sep 17 00:00:00 2001
From: Deluan
Date: Tue, 25 Nov 2025 19:48:53 -0500
Subject: [PATCH 073/102] feat: add DevOptimizeDB flag to control SQLite
optimization
Added a new DevOptimizeDB configuration flag (default true) that controls
whether SQLite PRAGMA OPTIMIZE and ANALYZE commands are executed. This allows
disabling database optimization operations for debugging or testing purposes.
The flag guards optimization commands in:
- db/db.go: Initial connection, post-migration, and shutdown optimization
- persistence/library_repository.go: Post-scan optimization
- db/migrations/migration.go: ANALYZE during forced full rescans
Set ND_DEVOPTIMIZEDB=false to disable all database optimization commands.
---
conf/configuration.go | 4 +++-
db/db.go | 15 ++++++++++-----
db/migrations/migration.go | 11 +++++++----
persistence/library_repository.go | 4 +++-
4 files changed, 23 insertions(+), 11 deletions(-)
diff --git a/conf/configuration.go b/conf/configuration.go
index 8be005591..cca19945a 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -131,6 +131,7 @@ type configOptions struct {
DevEnablePluginsInsights bool
DevPluginCompilationTimeout time.Duration
DevExternalArtistFetchMultiplier float64
+ DevOptimizeDB bool
}
type scannerOptions struct {
@@ -427,7 +428,7 @@ func validatePurgeMissingOption() error {
}
}
if !valid {
- err := fmt.Errorf("Invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
+ err := fmt.Errorf("invalid Scanner.PurgeMissing value: '%s'. Must be one of: %v", Server.Scanner.PurgeMissing, allowedValues)
log.Error(err.Error())
Server.Scanner.PurgeMissing = consts.PurgeMissingNever
return err
@@ -609,6 +610,7 @@ func setViperDefaults() {
viper.SetDefault("devenablepluginsinsights", true)
viper.SetDefault("devplugincompilationtimeout", time.Minute)
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
+ viper.SetDefault("devoptimizedb", true)
}
func init() {
diff --git a/db/db.go b/db/db.go
index cb1ebd9e3..71bc082b2 100644
--- a/db/db.go
+++ b/db/db.go
@@ -45,10 +45,12 @@ func Db() *sql.DB {
if err != nil {
log.Fatal("Error opening database", err)
}
- _, err = db.Exec("PRAGMA optimize=0x10002")
- if err != nil {
- log.Error("Error applying PRAGMA optimize", err)
- return nil
+ if conf.Server.DevOptimizeDB {
+ _, err = db.Exec("PRAGMA optimize=0x10002")
+ if err != nil {
+ log.Error("Error applying PRAGMA optimize", err)
+ return nil
+ }
}
return db
})
@@ -99,7 +101,7 @@ func Init(ctx context.Context) func() {
log.Fatal(ctx, "Failed to apply new migrations", err)
}
- if hasSchemaChanges {
+ if hasSchemaChanges && conf.Server.DevOptimizeDB {
log.Debug(ctx, "Applying PRAGMA optimize after schema changes")
_, err = db.ExecContext(ctx, "PRAGMA optimize")
if err != nil {
@@ -114,6 +116,9 @@ func Init(ctx context.Context) func() {
// Optimize runs PRAGMA optimize on each connection in the pool
func Optimize(ctx context.Context) {
+ if !conf.Server.DevOptimizeDB {
+ return
+ }
numConns := Db().Stats().OpenConnections
if numConns == 0 {
log.Debug(ctx, "No open connections to optimize")
diff --git a/db/migrations/migration.go b/db/migrations/migration.go
index 8d8f8a91e..fde6f5817 100644
--- a/db/migrations/migration.go
+++ b/db/migrations/migration.go
@@ -7,6 +7,7 @@ import (
"strings"
"sync"
+ "github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/consts"
)
@@ -21,11 +22,13 @@ func notice(tx *sql.Tx, msg string) {
// Call this in migrations that requires a full rescan
func forceFullRescan(tx *sql.Tx) error {
// If a full scan is required, most probably the query optimizer is outdated, so we run `analyze`.
- _, err := tx.Exec(`ANALYZE;`)
- if err != nil {
- return err
+ if conf.Server.DevOptimizeDB {
+ _, err := tx.Exec(`ANALYZE;`)
+ if err != nil {
+ return err
+ }
}
- _, err = tx.Exec(fmt.Sprintf(`
+ _, err := tx.Exec(fmt.Sprintf(`
INSERT OR REPLACE into property (id, value) values ('%s', '1');
`, consts.FullScanAfterMigrationFlagKey))
return err
diff --git a/persistence/library_repository.go b/persistence/library_repository.go
index 5621e1719..9349f3c4c 100644
--- a/persistence/library_repository.go
+++ b/persistence/library_repository.go
@@ -179,7 +179,9 @@ func (r *libraryRepository) ScanEnd(id int) error {
// https://www.sqlite.org/pragma.html#pragma_optimize
// Use mask 0x10000 to check table sizes without running ANALYZE
// Running ANALYZE can cause query planner issues with expression-based collation indexes
- _, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;"))
+ if conf.Server.DevOptimizeDB {
+ _, err = r.executeSQL(Expr("PRAGMA optimize=0x10000;"))
+ }
return err
}
From 1024d61a5e2bb23efa007f36fd52bbe8c29893ff Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Thu, 27 Nov 2025 07:58:39 -0500
Subject: [PATCH 074/102] fix: apply library filter to smart playlist track
generation (#4739)
Smart playlists were including tracks from all libraries regardless of the
user's library access permissions. This resulted in ghost tracks that users
could not see or play, while the playlist showed incorrect song counts.
Added applyLibraryFilter to the refreshSmartPlaylist function to ensure only
tracks from libraries the user has access to are included when populating
smart playlist tracks. Added regression test to verify the fix.
Closes #4738
---
persistence/playlist_repository.go | 5 +
persistence/playlist_repository_test.go | 129 ++++++++++++++++++++++++
2 files changed, 134 insertions(+)
diff --git a/persistence/playlist_repository.go b/persistence/playlist_repository.go
index a94f95a78..3fdd19af2 100644
--- a/persistence/playlist_repository.go
+++ b/persistence/playlist_repository.go
@@ -264,6 +264,11 @@ func (r *playlistRepository) refreshSmartPlaylist(pls *model.Playlist) bool {
"annotation.item_id = media_file.id" +
" AND annotation.item_type = 'media_file'" +
" AND annotation.user_id = '" + usr.ID + "')")
+
+ // Only include media files from libraries the user has access to
+ sq = r.applyLibraryFilter(sq, "media_file")
+
+ // Apply the criteria rules
sq = r.addCriteria(sq, rules)
insSql := Insert("playlist_tracks").Columns("id", "playlist_id", "media_file_id").Select(sq)
_, err = r.executeSQL(insSql)
diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go
index 5f9af2a33..a84e4b044 100644
--- a/persistence/playlist_repository_test.go
+++ b/persistence/playlist_repository_test.go
@@ -366,4 +366,133 @@ var _ = Describe("PlaylistRepository", func() {
Expect(foundWithoutGrouping).To(BeTrue())
})
})
+
+ Describe("Smart Playlists Library Filtering", func() {
+ var mfRepo model.MediaFileRepository
+ var testPlaylistID string
+ var lib2ID int
+ var restrictedUserID string
+
+ BeforeEach(func() {
+ db := GetDBXBuilder()
+
+ // Generate unique IDs for this test run
+ restrictedUserID = "restricted-user-" + time.Now().Format("20060102150405.000")
+
+ // Create a second library
+ _, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES ('Library 2', '/music/lib2', datetime('now'), datetime('now'))")
+ 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: "/music/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: "/music/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 2b30ed1520905eeba6e95f513108159e9ae52366 Mon Sep 17 00:00:00 2001
From: Stephan Wahlen <44159957+metalheim@users.noreply.github.com>
Date: Fri, 28 Nov 2025 14:52:26 +0100
Subject: [PATCH 075/102] fix(ui): Amusic theme improvements (#4731)
* fix low contrast in "delete missing files" button
* make login screen a bit nicer
* style modal similar to rest of ui
* Add custom styles for Ra Pagination
* Refactor styles in amusic.js
Removed albumSubtitle color and updated styles for albumPlayButton and albumArtistName
* Add NDDeleteLibraryButton and NDDeleteUserButton styles
low contrast
* low contrast text on delete buttons
* playbutton color back to pink without background
---
ui/src/themes/amusic.css.js | 8 +++-----
ui/src/themes/amusic.js | 41 ++++++++++++++++++++++++++++++-------
2 files changed, 37 insertions(+), 12 deletions(-)
diff --git a/ui/src/themes/amusic.css.js b/ui/src/themes/amusic.css.js
index 05709dc1e..9430a6c00 100644
--- a/ui/src/themes/amusic.css.js
+++ b/ui/src/themes/amusic.css.js
@@ -47,17 +47,15 @@ const stylesheet = `
.react-jinke-music-player-main .music-player-panel,
.react-jinke-music-player-mobile,
.ril__outer{
- background-color: #1f1f1f;
+ background-color: #1a1a1a;
border: 1px solid #fff1;
}
-.ril__toolbar{
- background-color: #1d1d1d
-}
.ril__toolbarItem{
font-size: 100%;
color: #eee
}
-.audio-lists-panel{
+.audio-lists-panel,
+.ril__toolbar{
background-color: #1f1f1f;
border: 1px solid #fff1;
border-radius: 6px 6px 0 0;
diff --git a/ui/src/themes/amusic.js b/ui/src/themes/amusic.js
index 598b7b7fa..4181d1780 100644
--- a/ui/src/themes/amusic.js
+++ b/ui/src/themes/amusic.js
@@ -137,22 +137,19 @@ export default {
albumName: {
color: '#eee',
},
- albumSubtitle: {
- color: '#ccc',
- },
albumPlayButton: {
- color: '#ff4e6b !important',
+ color: '#ff4e6b',
},
albumArtistName: {
- color: '#ff4e6b !important',
+ color: '#ccc',
},
cover: {
- borderRadius: '10px !important',
+ borderRadius: '6px',
},
},
NDLogin: {
systemNameLink: {
- color: '#D60017',
+ color: '#ff4e6b',
},
welcome: {
color: '#eee',
@@ -161,6 +158,9 @@ export default {
minWidth: 300,
backgroundColor: '#1d1d1d',
},
+ icon: {
+ filter: 'hue-rotate(115deg)',
+ },
},
MuiPaper: {
elevation1: {
@@ -169,6 +169,9 @@ export default {
root: {
color: '#eee',
},
+ rounded: {
+ borderRadius: '6px',
+ },
},
NDMobileArtistDetails: {
bgContainer: {
@@ -189,6 +192,30 @@ export default {
paddingBottom: '1rem',
},
},
+ RaDeleteWithConfirmButton: {
+ deleteButton: {
+ color: 'unset',
+ },
+ },
+ RaPaginationActions: {
+ currentPageButton: {
+ border: '2px solid #D60017',
+ background: 'transparent',
+ },
+ button: {
+ border: '2px solid #D60017',
+ },
+ actions: {
+ '@global': {
+ '.next-page': {
+ border: '0 none',
+ },
+ '.previous-page': {
+ border: '0 none',
+ },
+ },
+ },
+ },
},
player: {
theme: 'dark',
From a87b6a50a607f18d3784028b9fc368116cd51144 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Fri, 28 Nov 2025 16:11:13 -0500
Subject: [PATCH 076/102] test: use unique library name and path in tests
Avoid UNIQUE constraint conflicts on library.name and library.path when
running tests in parallel. Both playlist_repository_test.go and
tag_library_filtering_test.go now generate timestamp-based unique
suffixes for library names and paths to ensure test isolation.
Signed-off-by: Deluan
---
persistence/playlist_repository_test.go | 11 +++++++----
persistence/tag_library_filtering_test.go | 10 +++++++---
2 files changed, 14 insertions(+), 7 deletions(-)
diff --git a/persistence/playlist_repository_test.go b/persistence/playlist_repository_test.go
index a84e4b044..05a36352f 100644
--- a/persistence/playlist_repository_test.go
+++ b/persistence/playlist_repository_test.go
@@ -372,15 +372,18 @@ var _ = Describe("PlaylistRepository", func() {
var testPlaylistID string
var lib2ID int
var restrictedUserID string
+ var uniqueLibPath string
BeforeEach(func() {
db := GetDBXBuilder()
// Generate unique IDs for this test run
- restrictedUserID = "restricted-user-" + time.Now().Format("20060102150405.000")
+ uniqueSuffix := time.Now().Format("20060102150405.000")
+ restrictedUserID = "restricted-user-" + uniqueSuffix
+ uniqueLibPath = "/music/lib2-" + uniqueSuffix
- // Create a second library
- _, err := db.DB().Exec("INSERT INTO library (name, path, created_at, updated_at) VALUES ('Library 2', '/music/lib2', datetime('now'), datetime('now'))")
+ // 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())
@@ -420,7 +423,7 @@ var _ = Describe("PlaylistRepository", func() {
ArtistID: "1",
Album: "Test Album",
AlbumID: "101",
- Path: "/music/lib2/song.mp3",
+ Path: uniqueLibPath + "/song.mp3",
LibraryID: lib2ID,
Participants: model.Participants{},
Tags: model.Tags{},
diff --git a/persistence/tag_library_filtering_test.go b/persistence/tag_library_filtering_test.go
index ab0d57d52..77b91847a 100644
--- a/persistence/tag_library_filtering_test.go
+++ b/persistence/tag_library_filtering_test.go
@@ -2,6 +2,7 @@ package persistence
import (
"context"
+ "time"
"github.com/deluan/rest"
"github.com/navidrome/navidrome/conf/configtest"
@@ -45,6 +46,9 @@ var _ = Describe("Tag Library Filtering", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
+ // Generate unique path suffix to avoid conflicts with other tests
+ uniqueSuffix := time.Now().Format("20060102150405.000")
+
// Clean up database
db := GetDBXBuilder()
_, err := db.NewQuery("DELETE FROM library_tag").Execute()
@@ -57,12 +61,12 @@ var _ = Describe("Tag Library Filtering", func() {
_, err = db.NewQuery("DELETE FROM library WHERE id > 1").Execute()
Expect(err).ToNot(HaveOccurred())
- // Create test libraries
+ // Create test libraries with unique names and paths to avoid conflicts with other tests
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})").
- Bind(dbx.Params{"id": libraryID2, "name": "Library 2", "path": "/music/lib2"}).Execute()
+ Bind(dbx.Params{"id": libraryID2, "name": "Library 2-" + uniqueSuffix, "path": "/music/lib2-" + uniqueSuffix}).Execute()
Expect(err).ToNot(HaveOccurred())
_, err = db.NewQuery("INSERT INTO library (id, name, path) VALUES ({:id}, {:name}, {:path})").
- Bind(dbx.Params{"id": libraryID3, "name": "Library 3", "path": "/music/lib3"}).Execute()
+ Bind(dbx.Params{"id": libraryID3, "name": "Library 3-" + uniqueSuffix, "path": "/music/lib3-" + uniqueSuffix}).Execute()
Expect(err).ToNot(HaveOccurred())
// Give admin access to all libraries
From 99132355425343a5a534248f29b64a456c69aa28 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Fri, 28 Nov 2025 17:08:34 -0500
Subject: [PATCH 077/102] fix(server): improve error message for encrypted TLS
private keys (#4742)
Added TLS certificate validation that detects encrypted (password-protected)
private keys and provides a clear error message with instructions on how to
decrypt them using openssl. This addresses user confusion when Go's standard
library fails with the cryptic 'tls: failed to parse private key' error.
Changes:
- Added validateTLSCertificates function to validate certs before server start
- Added isEncryptedPEM helper to detect both PKCS#8 and legacy encrypted keys
- Added comprehensive tests for TLS validation including encrypted key detection
- Added integration test that starts server with TLS and verifies HTTPS works
- Added test certificates (valid for 100 years) with SAN for localhost
Signed-off-by: Deluan
---
server/server.go | 75 ++++++++-
server/server_test.go | 150 ++++++++++++++++++
server/testdata/test_cert.pem | 23 +++
server/testdata/test_cert_encrypted.pem | 22 +++
server/testdata/test_key.pem | 28 ++++
server/testdata/test_key_encrypted.pem | 30 ++++
server/testdata/test_key_encrypted_legacy.pem | 30 ++++
7 files changed, 352 insertions(+), 6 deletions(-)
create mode 100644 server/testdata/test_cert.pem
create mode 100644 server/testdata/test_cert_encrypted.pem
create mode 100644 server/testdata/test_key.pem
create mode 100644 server/testdata/test_key_encrypted.pem
create mode 100644 server/testdata/test_key_encrypted_legacy.pem
diff --git a/server/server.go b/server/server.go
index 49391e2b6..39475a225 100644
--- a/server/server.go
+++ b/server/server.go
@@ -1,8 +1,11 @@
package server
import (
+ "bytes"
"cmp"
"context"
+ "crypto/tls"
+ "encoding/pem"
"errors"
"fmt"
"net"
@@ -69,6 +72,13 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string,
// Determine if TLS is enabled
tlsEnabled := tlsCert != "" && tlsKey != ""
+ // Validate TLS certificates before starting the server
+ if tlsEnabled {
+ if err := validateTLSCertificates(tlsCert, tlsKey); err != nil {
+ return err
+ }
+ }
+
// Create a listener based on the address type (either Unix socket or TCP)
var listener net.Listener
var err error
@@ -89,17 +99,17 @@ func (s *Server) Run(ctx context.Context, addr string, port int, tlsCert string,
// Start the server in a new goroutine and send an error signal to errC if there's an error
errC := make(chan error)
go func() {
+ var err error
if tlsEnabled {
// Start the HTTPS server
log.Info("Starting server with TLS (HTTPS) enabled", "tlsCert", tlsCert, "tlsKey", tlsKey)
- if err := server.ServeTLS(listener, tlsCert, tlsKey); !errors.Is(err, http.ErrServerClosed) {
- errC <- err
- }
+ err = server.ServeTLS(listener, tlsCert, tlsKey)
} else {
// Start the HTTP server
- if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) {
- errC <- err
- }
+ err = server.Serve(listener)
+ }
+ if !errors.Is(err, http.ErrServerClosed) {
+ errC <- err
}
}()
@@ -249,3 +259,56 @@ func AbsoluteURL(r *http.Request, u string, params url.Values) string {
}
return buildUrl.String()
}
+
+// validateTLSCertificates validates the TLS certificate and key files before starting the server.
+// It provides detailed error messages for common issues like encrypted private keys.
+func validateTLSCertificates(certFile, keyFile string) error {
+ // Read the key file to check for encryption
+ keyData, err := os.ReadFile(keyFile)
+ if err != nil {
+ return fmt.Errorf("reading TLS key file: %w", err)
+ }
+
+ // Parse PEM blocks and check for encryption
+ block, _ := pem.Decode(keyData)
+ if block == nil {
+ return errors.New("TLS key file does not contain a valid PEM block")
+ }
+
+ // Check for encrypted private key indicators
+ if isEncryptedPEM(block, keyData) {
+ return errors.New("TLS private key is encrypted (password-protected). " +
+ "Navidrome does not support encrypted private keys. " +
+ "Please decrypt your key using: openssl pkey -in -out ")
+ }
+
+ // Try to load the certificate pair to validate it
+ _, err = tls.LoadX509KeyPair(certFile, keyFile)
+ if err != nil {
+ return fmt.Errorf("loading TLS certificate/key pair: %w", err)
+ }
+
+ return nil
+}
+
+// isEncryptedPEM checks if a PEM block represents an encrypted private key.
+func isEncryptedPEM(block *pem.Block, rawData []byte) bool {
+ // Check for PKCS#8 encrypted format (BEGIN ENCRYPTED PRIVATE KEY)
+ if block.Type == "ENCRYPTED PRIVATE KEY" {
+ return true
+ }
+
+ // Check for legacy encrypted format with Proc-Type header
+ if block.Headers != nil {
+ if procType, ok := block.Headers["Proc-Type"]; ok && strings.Contains(procType, "ENCRYPTED") {
+ return true
+ }
+ }
+
+ // Also check raw data for DEK-Info header (in case pem.Decode doesn't parse headers correctly)
+ if bytes.Contains(rawData, []byte("DEK-Info:")) || bytes.Contains(rawData, []byte("Proc-Type: 4,ENCRYPTED")) {
+ return true
+ }
+
+ return false
+}
diff --git a/server/server_test.go b/server/server_test.go
index f9a43a802..5ca03bf7e 100644
--- a/server/server_test.go
+++ b/server/server_test.go
@@ -1,13 +1,20 @@
package server
import (
+ "context"
+ "crypto/tls"
+ "crypto/x509"
+ "fmt"
"io/fs"
"net/http"
"net/url"
"os"
"path/filepath"
+ "time"
"github.com/navidrome/navidrome/conf"
+ "github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/tests"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
@@ -107,3 +114,146 @@ var _ = Describe("createUnixSocketFile", func() {
})
})
})
+
+var _ = Describe("TLS support", func() {
+ Describe("validateTLSCertificates", func() {
+ const testDataDir = "server/testdata"
+
+ When("certificate and key are valid and unencrypted", func() {
+ It("returns nil", func() {
+ certFile := filepath.Join(testDataDir, "test_cert.pem")
+ keyFile := filepath.Join(testDataDir, "test_key.pem")
+ err := validateTLSCertificates(certFile, keyFile)
+ Expect(err).ToNot(HaveOccurred())
+ })
+ })
+
+ When("private key is encrypted with PKCS#8 format", func() {
+ It("returns an error with helpful message", func() {
+ certFile := filepath.Join(testDataDir, "test_cert_encrypted.pem")
+ keyFile := filepath.Join(testDataDir, "test_key_encrypted.pem")
+ err := validateTLSCertificates(certFile, keyFile)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("encrypted"))
+ Expect(err.Error()).To(ContainSubstring("openssl"))
+ })
+ })
+
+ When("private key is encrypted with legacy format (Proc-Type header)", func() {
+ It("returns an error with helpful message", func() {
+ certFile := filepath.Join(testDataDir, "test_cert.pem")
+ keyFile := filepath.Join(testDataDir, "test_key_encrypted_legacy.pem")
+ err := validateTLSCertificates(certFile, keyFile)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("encrypted"))
+ Expect(err.Error()).To(ContainSubstring("openssl"))
+ })
+ })
+
+ When("key file does not exist", func() {
+ It("returns an error", func() {
+ certFile := filepath.Join(testDataDir, "test_cert.pem")
+ keyFile := filepath.Join(testDataDir, "nonexistent.pem")
+ err := validateTLSCertificates(certFile, keyFile)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("reading TLS key file"))
+ })
+ })
+
+ When("key file does not contain valid PEM", func() {
+ It("returns an error", func() {
+ // Create a temp file with invalid PEM content
+ tmpFile, err := os.CreateTemp("", "invalid_key*.pem")
+ Expect(err).ToNot(HaveOccurred())
+ DeferCleanup(func() {
+ _ = os.Remove(tmpFile.Name())
+ })
+ _, err = tmpFile.WriteString("not a valid PEM file")
+ Expect(err).ToNot(HaveOccurred())
+ _ = tmpFile.Close()
+
+ certFile := filepath.Join(testDataDir, "test_cert.pem")
+ err = validateTLSCertificates(certFile, tmpFile.Name())
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("valid PEM block"))
+ })
+ })
+
+ When("certificate file does not exist", func() {
+ It("returns an error from tls.LoadX509KeyPair", func() {
+ certFile := filepath.Join(testDataDir, "nonexistent_cert.pem")
+ keyFile := filepath.Join(testDataDir, "test_key.pem")
+ err := validateTLSCertificates(certFile, keyFile)
+ Expect(err).To(HaveOccurred())
+ Expect(err.Error()).To(ContainSubstring("loading TLS certificate/key pair"))
+ })
+ })
+ })
+
+ Describe("Server TLS", func() {
+ const testDataDir = "server/testdata"
+
+ When("server is started with valid TLS certificates", func() {
+ It("accepts HTTPS connections", func() {
+ DeferCleanup(configtest.SetupConfig())
+
+ // Create server with mock dependencies
+ ds := &tests.MockDataStore{}
+ server := New(ds, nil, nil)
+
+ // Load the test certificate to create a trusted CA pool
+ certFile := filepath.Join(testDataDir, "test_cert.pem")
+ keyFile := filepath.Join(testDataDir, "test_key.pem")
+ caCert, err := os.ReadFile(certFile)
+ Expect(err).ToNot(HaveOccurred())
+
+ caCertPool := x509.NewCertPool()
+ caCertPool.AppendCertsFromPEM(caCert)
+
+ // Create an HTTPS client that trusts our test certificate
+ httpClient := &http.Client{
+ Timeout: 5 * time.Second,
+ Transport: &http.Transport{
+ TLSClientConfig: &tls.Config{
+ RootCAs: caCertPool,
+ MinVersion: tls.VersionTLS12,
+ },
+ },
+ }
+
+ // Start the server in a goroutine
+ ctx, cancel := context.WithCancel(GinkgoT().Context())
+ defer cancel()
+
+ errChan := make(chan error, 1)
+ go func() {
+ errChan <- server.Run(ctx, "127.0.0.1", 14534, certFile, keyFile)
+ }()
+
+ Eventually(func() error {
+ // Make an HTTPS request to the server
+ resp, err := httpClient.Get("https://127.0.0.1:14534/ping")
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("unexpected status code: %d", resp.StatusCode)
+ }
+ return nil
+ }, 2*time.Second, 100*time.Millisecond).Should(Succeed())
+
+ // Stop the server
+ cancel()
+
+ // Wait for server to stop (with timeout)
+ select {
+ case <-errChan:
+ // Server stopped
+ case <-time.After(2 * time.Second):
+ Fail("Server did not stop in time")
+ }
+ })
+ })
+ })
+})
diff --git a/server/testdata/test_cert.pem b/server/testdata/test_cert.pem
new file mode 100644
index 000000000..1dfa573d6
--- /dev/null
+++ b/server/testdata/test_cert.pem
@@ -0,0 +1,23 @@
+-----BEGIN CERTIFICATE-----
+MIIDwzCCAqugAwIBAgIUXqdUxUOo8kmsDe71iTR+Vr7btP8wDQYJKoZIhvcNAQEL
+BQAwYjELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx
+EjAQBgNVBAoMCU5hdmlkcm9tZTENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJbG9j
+YWxob3N0MCAXDTI1MTEyODE5NTkxNVoYDzIxMjUxMTA0MTk1OTE1WjBiMQswCQYD
+VQQGEwJVUzENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDESMBAGA1UECgwJ
+TmF2aWRyb21lMQ0wCwYDVQQLDARUZXN0MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCkB/TQgl5ei5KRSHt5OJim8rKS
+MzRlkK4BjSEM4D9ESbebdpEVjX48QuBYACrCvgvVp7mQGF5anl8Hm89trvd8ooVQ
+x9IPQQ6gRKM+4gLrt9FHvFGGzZQS8UTQXN5oBi11E+8/Vs47HLUNXC2TRtRLCMyK
+LYXQIXbhdp9anImlt+IHUxIQUchK6Zkld/gCm56X1bbzN/Zq91PQLpx2FZ0eZTjN
+KaNgztLa+K/BDnTuk3iTTs9GEp6VCvqQE/6fk/UN/tkk2dLwKIFvPVR/YeAhVdz/
+OHC4L3B36QN3+VQ2yDjsp1PVAPX07UnzXO3Oj7uGYnMQxwprGMEubm3nADDxAgMB
+AAGjbzBtMB0GA1UdDgQWBBRAZHUVuLyzc0CfuZR9ApqMbawIqzAfBgNVHSMEGDAW
+gBRAZHUVuLyzc0CfuZR9ApqMbawIqzAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQT
+MBGCCWxvY2FsaG9zdIcEfwAAATANBgkqhkiG9w0BAQsFAAOCAQEAmDLXcPx9LNHs
+GxQIE6Q5BXbVO7c8qrWmJf5FK5VWaifNZ9U+IBi+VlB4jCLK/OkwsviN/jOnwRYx
+owjq0QG0YdRT4uD9fEMrAj+EwbnrQYZQvT0yGEWA+KW5TW08wt+/qnGJDwEgbjYJ
+HTdICVMhs/e8Ex48fAgO8WSsdTDekOrhuwzIfeJ1LU4ZptLsD2ePFxuzutdIuW51
+/mspQGsjXqZ1qnLsavLXh/lds2g602rTpYBNZVjV9WiOvaQS8vviOxBN6f+9vgRz
+a8SEbHqBG6jeyVqVZ7MjxcYxaIkxeBwMyMwgb+wwDfVXo2FZzX2TVeB7ZppI+IKv
+TXYurWPYsQ==
+-----END CERTIFICATE-----
diff --git a/server/testdata/test_cert_encrypted.pem b/server/testdata/test_cert_encrypted.pem
new file mode 100644
index 000000000..6f8de623a
--- /dev/null
+++ b/server/testdata/test_cert_encrypted.pem
@@ -0,0 +1,22 @@
+-----BEGIN CERTIFICATE-----
+MIIDpzCCAo+gAwIBAgIUEa7gEJYwJqYEJjTY7otQ+oUyELwwDQYJKoZIhvcNAQEL
+BQAwYjELMAkGA1UEBhMCVVMxDTALBgNVBAgMBFRlc3QxDTALBgNVBAcMBFRlc3Qx
+EjAQBgNVBAoMCU5hdmlkcm9tZTENMAsGA1UECwwEVGVzdDESMBAGA1UEAwwJbG9j
+YWxob3N0MCAXDTI1MTEyODE5NTI0OVoYDzIxMjUxMTA0MTk1MjQ5WjBiMQswCQYD
+VQQGEwJVUzENMAsGA1UECAwEVGVzdDENMAsGA1UEBwwEVGVzdDESMBAGA1UECgwJ
+TmF2aWRyb21lMQ0wCwYDVQQLDARUZXN0MRIwEAYDVQQDDAlsb2NhbGhvc3QwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDBHgqJ1d9EnNxqoSZ6xXrIz/mV
+Y0nWJW16/qIAvCdovSeTZhG9iqG8dUqcuu2BdD9MMHndJ2oFn3iD8EJR92dH8KBA
+8xOmtZ0BEEWgXPBivywZVd1ChIflEWj6m5wwLNjb57SPpUiwaLxBQB8ByEaAAZE/
+bLqvHI3vW/4s5apky17SPIqmkmqEYlRcg97tlRXsPuwoAVM9cvLMMEqtIR1CB/72
+gboY2Gi2r/plLF/Rg3Dom6QljMWi57XXWJFwGYSXaZuM0gvn04e3oLu+1E+WMoq/
+9rExWij2DlsmXd/RiScliFp6R4H84wQUyqrAUNytvgRO+oVnRjEA0l3oCYdRAgMB
+AAGjUzBRMB0GA1UdDgQWBBQQKpB1UaKm98FnBdl8uKdRscrVTzAfBgNVHSMEGDAW
+gBQQKpB1UaKm98FnBdl8uKdRscrVTzAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3
+DQEBCwUAA4IBAQBP07l+2LmpFtcxqMGmsiNYwFuHpQCxJd4YRZHjLX7O+oJExMgR
+2yP4mpMKurgKOv7unTDLwvjQRa6ZTYJCsYtvC6hbyqlGc7AfNTu6DKz8r35/2/V5
+hPsG5lNb91HhvHE839mLAvpi02LoFH2Sr8BR7s6qxfNKYcP8PUOJQXltJ6yAa8YJ
+syeXQQ3RIyGsJANeaC06S3UdkBM5H5BLfIHnHu3GybJjwL51va4WCdHe8QV6GI0g
+RDiThDVkBSXAr136vnMdlrYCxMoxY56itJ0zbYg2ELQKU9o1w/ZJQo9uvmy9jCoZ
+Hy1L5a2vUDbsdONdvRkYZRHqMpG4bdD8D3j2
+-----END CERTIFICATE-----
diff --git a/server/testdata/test_key.pem b/server/testdata/test_key.pem
new file mode 100644
index 000000000..bac61f4a4
--- /dev/null
+++ b/server/testdata/test_key.pem
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCkB/TQgl5ei5KR
+SHt5OJim8rKSMzRlkK4BjSEM4D9ESbebdpEVjX48QuBYACrCvgvVp7mQGF5anl8H
+m89trvd8ooVQx9IPQQ6gRKM+4gLrt9FHvFGGzZQS8UTQXN5oBi11E+8/Vs47HLUN
+XC2TRtRLCMyKLYXQIXbhdp9anImlt+IHUxIQUchK6Zkld/gCm56X1bbzN/Zq91PQ
+Lpx2FZ0eZTjNKaNgztLa+K/BDnTuk3iTTs9GEp6VCvqQE/6fk/UN/tkk2dLwKIFv
+PVR/YeAhVdz/OHC4L3B36QN3+VQ2yDjsp1PVAPX07UnzXO3Oj7uGYnMQxwprGMEu
+bm3nADDxAgMBAAECggEABqJFvesP2v4FEvgd+kSWM+ZL34rPmy3zQ5/MDuPA20ep
+89EjQ/5hdRl1TknPcOnTu7PZVuENa9fM2xdrl7GEU9eU0bQLJE/KwiOUgJYObS8V
+eTO+DlghHXUBhfXDjux1CS+htOuTUqOyFNS+CR9Lta8o6ou1xjmcP7kW78i17mxF
+TuH5SZlS8W9PFLXHCInbMtqGFaT2ss09kvoPk2FDvHfxEdy6M9tKkguz02g+4bqI
+aAMp2N7AOfmRpC0HvVa1ZfZo5Z8/KMoNcIm3pV9DEVM369J9EzhnMNpkGben90aT
+FqO2JNsy52wmXFZUc9xe8uPdfDahALCkBGncLyLNmQKBgQDZREjocjdzOoPSlCdx
+mRNe9suHz2FpUpsHCPOCotG63hFVKpah/ZvpHSsQx5rXs/mawDTmzGY9GQiBrSvg
+OhfHIyT3NOhVaNcMxTqJX7rs7OG8D0MBacD9ASSeZ89MUn8q1EHZr5qxLtXl5Ikw
+mHtiGRdiKGFFrG9H0zncbGhy7QKBgQDBRhQ9RAasTdmUiNQly9GVFkXto4T/9UHx
+rVU44htCI2IVZUMTGlNfclfxpByDrzyA56rMzN9SAkiIp4nPpMDs5hayXaaPoojs
+CPzV7r2OjemZ6CTeQ1ODImRL8L/E3jJSgWd6YYoHSQ5hjEX4yT6ft0u0tZUfdMKd
+VENWIJ/hlQKBgQCo2hXjeOi5R8+tN3EUKwhP9HOnX7dv+D/9jqpZa5qdpPpJeyjI
+SmYCHKYci1Q+sWOaLiiu+km20B65UVFZGSzjmd+fs+GghzMifKGKo/iNK2ggFKhZ
+j8vplRrVdQ45XZ/xNDbdLEmHzEN2QE+Skd7KFYADzCgU0vdFFdbRBPuD3QKBgGIq
+fQctMRJ9LCE0akSURGwr9vKflmMHKCpfdqTAu0WZgS0K1Mm0GlqlUiPKzizYaauz
+f14sRNV7kWnPZsDPlqn8p9SKmpnj3RW97uWeMCtiyx6/+VHm8ljts/GaY1zT2s1r
+KqrPNfNDWQmU3MljNeqbh9lOTWK/xEVy0gzB31MNAoGAQNWrZvVdAbL95XW6STUu
+JmQlqJTlluuqS0Rrd/uVEQwW0Vd1dZjRQcFAFiSiCQWTbtId5gFZd6hiIQl53Xz0
+5cd+9mcyA/TaoCJYbMOFYsKbZMCBhefsovJlVQXedqJrIY6BdeGlet4GTAH5Qyl0
+ytEIUnvn5YmmbI7PDz80XpU=
+-----END PRIVATE KEY-----
diff --git a/server/testdata/test_key_encrypted.pem b/server/testdata/test_key_encrypted.pem
new file mode 100644
index 000000000..0ac715890
--- /dev/null
+++ b/server/testdata/test_key_encrypted.pem
@@ -0,0 +1,30 @@
+-----BEGIN ENCRYPTED PRIVATE KEY-----
+MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQPH9PYzryCI3smm81
+J8rm+QICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEI+9XxNfKSiMYIVB
+UfcGfncEggTQVw7tPslGy3mlofCNnhBSnMViv9kj6M11smD6Y8vHG0k9Kq+6g+Dx
+mQE9ILrSZBzM0uS3y484u+vkdqlT4KehhjIx0IiezurOcM45UdTAwLFLPzeEDlHI
+lOWQ3gOTB3J5AxiUQOa6QsDIM7AZilidQG0BxQYWyRBA5B8evJwJoAvdzzA9wGSm
+2YdNm3tA6rU5U8cVG+qTJP9pjbtRx0medC/CBZdxGkrWBQH+aySfahJdU8X1JI2e
+SY4WJRw1rLCow+DnHjZS/IVHFJivJSRYvnvw8fwjOMVtkf+dAVctKlb1Fj9X+RdG
+T1sq3i6zwFLE/RRz4qM4DKZ6UaD9wRFLow8FmNWVuJJiPgCLx2rrNMe32quS/kQP
+iOsXAUeA/Yg1fdMCJORxl0nWDmLYcNtBghCmS1lyk+t+AKWwJudrds5tQQe8ha2t
+Q41is+tDKwGDC1wt4WXJvBhgAJzuqFtr30H0M1eBhwwDdaDd9v0Zr3r8V49WZM2c
+i3qkwPPYkQD+pOcR12xBV8ptvDxaUl7RGlVqnEWHagT51BaIaXQ9teUrG6UPt8o2
+LELJXF6CiwkbN6Y9sYx5XiKrIGxVhlQSZ1nB3XSFRHbu6e7VHPjnVwUeeg87J2Am
+MEwqDzPU5sjKRn84+M91Y4uFAIeinaOJAQ0/tZVrf1iSeCMQyMUhW/8m7JPfG19F
+NbJSPRXQuKmYKbWfXcMW2UFbp0zDs7s7p4zzbfde9IbVdq/o2nv3ZrNbrLak6O7y
+FVt9q/xG4Tty6hSK6xtqtNZWcmfiMcTlk1Qcz2STvScbXtqgcgR6WUZfkLuzi09I
+EDYFnzU5JNSY3U3VTv2hAPeU4xjTNM6kjF7L9JFGvdjH8Ko9UdxG9RZMd8xhBM/n
+hxdzdVba4bDDz2z+0A2blSObrPrNsKr/3ZbnfuUiSs5NmqmUOifZ1t1PqGGO2Y5S
+/cDKtrPk226hGomsUBfHtiIJPG1VRl4UaZiduqK3GGhtF491KU1mAfYzueok3TPq
+JhLtLDIvEaFgmOmitFzROI/ifm6s4ssUvcvtbjwJumbjkU38OxYZFwbhwbe268G2
+vgspJamlEGJNdGDzrCFQlA2+A9kazCttztikfh5QGV6WFfkc3Bt1XTPL51vtliQy
+MS2gUnJUY2fuYCfz8rxLH1kQmyYsHQz5rUYyBkeDffrG9MzarmzSJXR63FRzVMf1
+LQ7BSzei7dF6+J4KVCxjbGWF3GUGmGeOP5g5vJ3xb3YPJNJLT4Vai103pay59TGP
+tESM2Vn0gJEvYApi707noFH5uFTW1cp7lloF41ddIUkL/QO7j+sjvBww+4DqBB7J
+BmvLMnswa23yw9egYRG5jOXyCgIr+1rnNcph1HGJsvxvgJ2gwwo5NKCG8SC6LcZQ
+fbDjX+ssmobLE3ktN03FZPMp32/ciexzuZoamfyiPXh7xE++ckifNEKJlNhx+kCG
+mSR2wh+UGigQkgp/JxOzl6C4fhUbrEZr17oBqGim2p8h+GE0zD5JSHcn1rP86gGU
+8JG/ilG4I8uMxUwhGj7amrWXUlJBd1by7e1EAL+utCo14/Tx3otB9/JtqY+lm9Ey
+1ptPhMRQxvDNWrCmYM2kyrGghdNfEMir6GKDWI6PY9cwAFv/PLOxr1c=
+-----END ENCRYPTED PRIVATE KEY-----
diff --git a/server/testdata/test_key_encrypted_legacy.pem b/server/testdata/test_key_encrypted_legacy.pem
new file mode 100644
index 000000000..4b9215cdf
--- /dev/null
+++ b/server/testdata/test_key_encrypted_legacy.pem
@@ -0,0 +1,30 @@
+-----BEGIN RSA PRIVATE KEY-----
+Proc-Type: 4,ENCRYPTED
+DEK-Info: AES-256-CBC,3C969050EAB73F121B7F0E6B75C42525
+
+V6pSaAsrn9CQNo4p88QshJLbg8zkQJEom81dPbYSVqQSZa9YlPtpLZ9YtuLj/Ay0
+TScEKIj/gzQ32wNl6nhcSNIL9yy+X11r5gNv1kIHkecf+EbDW20VOiJsfD+6LUyW
+hA96AIbPOwc76iCuvsKHPKU9MlEmjGipmk/C2RQLHCZJ3WkiDRgCM8KQ7vKhfACT
+w908yj4cB1e/P0JPq8t/3F7kPJ+6SVM1vMEffHl0otQR3rAyrK8QikwJ0K9qX62d
+cqchTVlEyyZBYovR8DrRRUDbsXS5j1ZmX3NQpvTSTFowr+33fMrY+4Oz8sdR4yx1
+CQc0A0sHHxSEIr2xu4KzczwOYVJN8PVdU0pgvFj9KEm66N6EY5CSFIBHyO/ycOt9
+U+wpkRjf3zS6ZaUU0NKdOcop4YX33i99/tZF2RNR1i7ETLYph+/LCf09286Bi3u/
+UCCuWedyECPdz0c6j0s27Fdfc/HEK90OEzeWh/fc+H2gJZhqJYK9V47HPTQNNMnB
+U1a6FsJlrKE3E6nfSnTLxrSx9m/XTV7HV+HkgX+q8VhN7Q2VHUqkPzE7ZOPYpZ+A
+dQzsm1TmEMxym6osYqFzQScXR1NZasrV2MTQ2J16dUgCdGAM2YMUD9JaoJR+u77M
+WAjYzDiRg84rLr/KbJPAwHbsfo2KpiapJGSBBEDhz4W1/LOrFhsjaqIMSy4yZDGm
+1KqXGHIlqmuHI7v4fD8vuzhj7GUujRx85HSZWakE/uc6s5WrhkSeVKYJWPfpsxTv
+dT3oLOGJ+nRzWxM3aFtuJghX0nIGdKxT4EAUNXz0/vLT3OP1QCZR+oELrriFzmtj
++O30bGH2SAFZEQJ/uTQg6celoNh89IzH4DJkcn67hqpX6mUiU9CrIr/eR9C/en8Q
+smTbbC1C1pDUaCwR26Z+zgM90amh4yfOFKK2geO2Kj+TmwFHUvi6ZnSzMzCvty3t
++wdIrUtf55Lw51JCpLGl70mg4b/zBj5hqBkU2YvAAnz/htjfH/wrD6ZAF1TCdlRO
+gyODrJjGRnLd/v0XLk0wp+RkAjBcSlRlkUvZY5BtugL7dIdwiNGGQPcOni9IVeG0
+6vDUEQnDOLYDj4d/JcckTLuHdrP+SW+0RQl2HK5+/w1hScGXN4O48gccu7yR/MN8
+DmpCg5rD/nq8sxJosmSt07GrN36KppYt8LCXQbSg3NG2Ad715caS2C+0Qtdm5MPD
+rM1UyTXQYSJXgUN9yZS/pmzlguCywnnvsBPU6j3ljZwcoD41QJ/1OU09/W6sIMQR
+IAiM35JHiLJiccFgxSE1qx5F1UZqX4P47jF0Wzi/sE/DYXg5qw2DoauqXNzqnumH
+71UDGK1V6wQIV7UCZDa0WUfFzu470XpuFb8VmMOuHSQxkZESc9cz8k/ueAuO438Q
+jnlkF1Ge2EEPuaK2zeaTj/lGyYA1AUfHRRgt/EMUQSBntmhlpnwVPYTVvYtHO2N5
+wp7/y39KirnlTl99i3XiOJ4WF4gIU2IaSlqMo4+e/A32h2JFi9QfNyfItXe6Fm1X
+d0j2XGHzwMfHEFKdWyrgtVZwc38/1d6xWYAhs02b2basV/0AQhFTaKf5Z268eBNJ
+-----END RSA PRIVATE KEY-----
From e36fef869278ec75f7a8b5e9c51ac02ab600de71 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Fri, 28 Nov 2025 19:38:28 -0500
Subject: [PATCH 078/102] fix: retry insights collection when no admin user
available (#4746)
Previously, the insights collector would only try to get an admin user once
at startup. If no admin user existed (e.g., fresh database before first user
registration), insights collection would silently fail forever.
This change moves the admin context creation inside the collection loop so it
retries on each interval. It also updates log messages in WithAdminUser to
remove the Scanner prefix since this function is now used by other components.
Signed-off-by: Deluan
---
core/auth/auth.go | 4 ++--
core/metrics/insights.go | 12 ++++++++++--
2 files changed, 12 insertions(+), 4 deletions(-)
diff --git a/core/auth/auth.go b/core/auth/auth.go
index fd2b670a4..ddd12767b 100644
--- a/core/auth/auth.go
+++ b/core/auth/auth.go
@@ -113,9 +113,9 @@ func WithAdminUser(ctx context.Context, ds model.DataStore) context.Context {
if err != nil {
c, err := ds.User(ctx).CountAll()
if c == 0 && err == nil {
- log.Debug(ctx, "Scanner: No admin user yet!", err)
+ log.Debug(ctx, "No admin user yet!", err)
} else {
- log.Error(ctx, "Scanner: No admin user found!", err)
+ log.Error(ctx, "No admin user found!", err)
}
u = &model.User{}
}
diff --git a/core/metrics/insights.go b/core/metrics/insights.go
index 010c24c28..820e6d7b6 100644
--- a/core/metrics/insights.go
+++ b/core/metrics/insights.go
@@ -22,6 +22,7 @@ import (
"github.com/navidrome/navidrome/core/metrics/insights"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
"github.com/navidrome/navidrome/plugins/schema"
"github.com/navidrome/navidrome/utils/singleton"
)
@@ -64,9 +65,16 @@ func GetInstance(ds model.DataStore, pluginLoader PluginLoader) Insights {
}
func (c *insightsCollector) Run(ctx context.Context) {
- ctx = auth.WithAdminUser(ctx, c.ds)
for {
- c.sendInsights(ctx)
+ // Refresh admin context on each iteration to handle cases where
+ // admin user wasn't available on previous runs
+ insightsCtx := auth.WithAdminUser(ctx, c.ds)
+ u, _ := request.UserFrom(insightsCtx)
+ if !u.IsAdmin {
+ log.Trace(insightsCtx, "No admin user available, skipping insights collection")
+ } else {
+ c.sendInsights(insightsCtx)
+ }
select {
case <-time.After(consts.InsightsUpdateInterval):
continue
From 6a7381aa5ad1775f416567c555ba73cd327b6462 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Sat, 29 Nov 2025 11:44:24 -0500
Subject: [PATCH 079/102] test: prevent environment variables from overriding
config file values in tests
Added a loadEnvVars parameter to InitConfig to control whether environment
variables should be loaded via viper.AutomaticEnv(). In tests, environment
variables (like ND_MUSICFOLDER) were overriding values from config test files,
causing tests to fail when these variables were set in the developer's
environment. Now tests can pass loadEnvVars=false to isolate from the
environment while production code continues to use loadEnvVars=true.
Signed-off-by: Deluan
---
cmd/root.go | 2 +-
conf/configuration.go | 12 +++++++-----
conf/configuration_test.go | 2 +-
3 files changed, 9 insertions(+), 7 deletions(-)
diff --git a/cmd/root.go b/cmd/root.go
index 9618b16e6..4a1305cad 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -346,7 +346,7 @@ func startPluginManager(ctx context.Context) func() error {
// TODO: Implement some struct tags to map flags to viper
func init() {
cobra.OnInitialize(func() {
- conf.InitConfig(cfgFile)
+ conf.InitConfig(cfgFile, true)
})
rootCmd.PersistentFlags().StringVarP(&cfgFile, "configfile", "c", "", `config file (default "./navidrome.toml")`)
diff --git a/conf/configuration.go b/conf/configuration.go
index cca19945a..f6b1c4cb7 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -617,7 +617,7 @@ func init() {
setViperDefaults()
}
-func InitConfig(cfgFile string) {
+func InitConfig(cfgFile string, loadEnvVars bool) {
codecRegistry := viper.NewCodecRegistry()
_ = codecRegistry.RegisterCodec("ini", ini.Codec{
LoadOptions: ini.LoadOptions{
@@ -638,10 +638,12 @@ func InitConfig(cfgFile string) {
}
_ = viper.BindEnv("port")
- viper.SetEnvPrefix("ND")
- replacer := strings.NewReplacer(".", "_")
- viper.SetEnvKeyReplacer(replacer)
- viper.AutomaticEnv()
+ if loadEnvVars {
+ viper.SetEnvPrefix("ND")
+ replacer := strings.NewReplacer(".", "_")
+ viper.SetEnvKeyReplacer(replacer)
+ viper.AutomaticEnv()
+ }
err := viper.ReadInConfig()
if viper.ConfigFileUsed() != "" && err != nil {
diff --git a/conf/configuration_test.go b/conf/configuration_test.go
index 88454d204..15d12795e 100644
--- a/conf/configuration_test.go
+++ b/conf/configuration_test.go
@@ -31,7 +31,7 @@ var _ = Describe("Configuration", func() {
filename := filepath.Join("testdata", "cfg."+format)
// Initialize config with the test file
- conf.InitConfig(filename)
+ conf.InitConfig(filename, false)
// Load the configuration (with noConfigDump=true)
conf.Load(true)
From 64a9260174cd7c7c27f2b82c9ebaa4657afaeea6 Mon Sep 17 00:00:00 2001
From: floatlesss <117862164+floatlesss@users.noreply.github.com>
Date: Sat, 29 Nov 2025 17:54:46 +0000
Subject: [PATCH 080/102] fix(ui): allow scrolling in shareplayer queue by
adding delay #4748
fix(shareplayer): allow-scrolling-in-shareplayer - #4747
---
ui/src/share/SharePlayer.jsx | 1 +
1 file changed, 1 insertion(+)
diff --git a/ui/src/share/SharePlayer.jsx b/ui/src/share/SharePlayer.jsx
index 2c50275ed..a3a15e50a 100644
--- a/ui/src/share/SharePlayer.jsx
+++ b/ui/src/share/SharePlayer.jsx
@@ -53,6 +53,7 @@ const SharePlayer = () => {
remove: false,
spaceBar: true,
volumeFade: { fadeIn: 200, fadeOut: 200 },
+ sortableOptions: { delay: 200, delayOnTouchOnly: true },
}
return (
Date: Sun, 30 Nov 2025 11:26:59 -0500
Subject: [PATCH 081/102] fix(insights): add missing filesystem types to
fsTypeMap
---
core/metrics/insights_linux.go | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/core/metrics/insights_linux.go b/core/metrics/insights_linux.go
index dbf3c277c..f972140c4 100644
--- a/core/metrics/insights_linux.go
+++ b/core/metrics/insights_linux.go
@@ -42,6 +42,7 @@ type MountInfo struct {
var fsTypeMap = map[int64]string{
0x5346414f: "afs",
+ 0x187: "autofs",
0x61756673: "aufs",
0x9123683E: "btrfs",
0xc36400: "ceph",
@@ -55,9 +56,11 @@ var fsTypeMap = map[int64]string{
0x6a656a63: "fakeowner", // FS inside a container
0x65735546: "fuse",
0x4244: "hfs",
+ 0x482b: "hfs+",
0x9660: "iso9660",
0x3153464a: "jfs",
0x00006969: "nfs",
+ 0x5346544e: "ntfs", // NTFS_SB_MAGIC
0x7366746e: "ntfs",
0x794c7630: "overlayfs",
0x9fa0: "proc",
@@ -69,8 +72,15 @@ var fsTypeMap = map[int64]string{
0x01021997: "v9fs",
0x786f4256: "vboxsf",
0x4d44: "vfat",
+ 0xca451a4e: "virtiofs",
0x58465342: "xfs",
0x2FC12FC1: "zfs",
+
+ // Signed/unsigned conversion issues (negative hex values converted to uint32)
+ -0x6edc97c2: "btrfs", // 0x9123683e
+ -0x1acb2be: "smb2", // 0xfe534d42
+ -0xacb2be: "cifs", // 0xff534d42
+ -0xd0adff0: "f2fs", // 0xf2f52010
}
func getFilesystemType(path string) (string, error) {
From f14692c1f08c216846021e846c553a8c49c1a69a Mon Sep 17 00:00:00 2001
From: Deluan
Date: Sun, 30 Nov 2025 21:58:45 -0500
Subject: [PATCH 082/102] test: remove racy buffer length assertion in
scrobbler test
Removed the buffer.Length() check that was causing intermittent test failures.
The background goroutine started by newBufferedScrobbler can process and
dequeue scrobble entries before the test assertion runs, leading to a race
condition where the observed length is 0 instead of 1. The Eventually block
that follows already verifies the scrobble was processed correctly.
Signed-off-by: Deluan
---
core/scrobbler/buffered_scrobbler_test.go | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go
index c1440046d..063e0de8c 100644
--- a/core/scrobbler/buffered_scrobbler_test.go
+++ b/core/scrobbler/buffered_scrobbler_test.go
@@ -51,9 +51,10 @@ var _ = Describe("BufferedScrobbler", func() {
Expect(scr.ScrobbleCalled.Load()).To(BeFalse())
Expect(bs.Scrobble(ctx, "user1", scrobble)).To(Succeed())
- Expect(buffer.Length()).To(Equal(int64(1)))
- // Wait for the scrobble to be sent
+ // Wait for the background goroutine to process the scrobble.
+ // We don't check buffer.Length() here because the background goroutine
+ // may dequeue the entry before we can observe it.
Eventually(scr.ScrobbleCalled.Load).Should(BeTrue())
lastScrobble := scr.LastScrobble.Load()
From 33d9ce6eccaa6caf97418b891c0e1af4a67d3235 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Mon, 1 Dec 2025 17:33:53 -0500
Subject: [PATCH 083/102] feat: add configurable transcoding cancellation
(#4411)
* feat: add configurable transcoding cancellation
Implemented EnableTranscodingCancellation configuration option to control whether
FFmpeg transcoding processes can be interrupted when client requests are cancelled.
This addresses resource management issues on low-power hardware where transcoding
processes would accumulate and cause CPU spikes.
Key changes:
- Added EnableTranscodingCancellation bool to configuration (default: false)
- Added CLI flag --enabletranscodingcancellation and TOML/env support
- Modified FFmpeg package to always use exec.CommandContext for consistency
- Implemented conditional context handling in NewTranscodingCache function
- When enabled: uses request context directly (allows cancellation)
- When disabled: uses background context with request metadata preserved
- Added comprehensive tests for both FFmpeg and transcoding layers
- Maintained backward compatibility with existing behavior as default
The implementation follows proper layered architecture with policy decisions
at the media streaming layer and execution utilities remaining focused on
their core responsibilities.
Signed-off-by: Deluan
* test: refactor FFmpeg context cancellation tests for improved clarity and reliability
Signed-off-by: Deluan
* test: reset FFmpeg initialization
Signed-off-by: Deluan
* test: improve FFmpeg context cancellation tests for cross-platform compatibility
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
---
cmd/root.go | 2 +
conf/configuration.go | 2 +
core/ffmpeg/ffmpeg.go | 6 +--
core/ffmpeg/ffmpeg_test.go | 98 ++++++++++++++++++++++++++++++++++++++
core/media_streamer.go | 15 +++++-
5 files changed, 119 insertions(+), 4 deletions(-)
diff --git a/cmd/root.go b/cmd/root.go
index 4a1305cad..5e91ecd5f 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -374,6 +374,7 @@ func init() {
rootCmd.Flags().Duration("scaninterval", viper.GetDuration("scaninterval"), "how frequently to scan for changes in your music library")
rootCmd.Flags().String("uiloginbackgroundurl", viper.GetString("uiloginbackgroundurl"), "URL to a backaground image used in the Login page")
rootCmd.Flags().Bool("enabletranscodingconfig", viper.GetBool("enabletranscodingconfig"), "enables transcoding configuration in the UI")
+ rootCmd.Flags().Bool("enabletranscodingcancellation", viper.GetBool("enabletranscodingcancellation"), "enables transcoding context cancellation")
rootCmd.Flags().String("transcodingcachesize", viper.GetString("transcodingcachesize"), "size of transcoding cache")
rootCmd.Flags().String("imagecachesize", viper.GetString("imagecachesize"), "size of image (art work) cache. set to 0 to disable cache")
rootCmd.Flags().String("albumplaycountmode", viper.GetString("albumplaycountmode"), "how to compute playcount for albums. absolute (default) or normalized")
@@ -397,6 +398,7 @@ func init() {
_ = viper.BindPFlag("prometheus.metricspath", rootCmd.Flags().Lookup("prometheus.metricspath"))
_ = viper.BindPFlag("enabletranscodingconfig", rootCmd.Flags().Lookup("enabletranscodingconfig"))
+ _ = viper.BindPFlag("enabletranscodingcancellation", rootCmd.Flags().Lookup("enabletranscodingcancellation"))
_ = viper.BindPFlag("transcodingcachesize", rootCmd.Flags().Lookup("transcodingcachesize"))
_ = viper.BindPFlag("imagecachesize", rootCmd.Flags().Lookup("imagecachesize"))
}
diff --git a/conf/configuration.go b/conf/configuration.go
index f6b1c4cb7..f8e4a8084 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -41,6 +41,7 @@ type configOptions struct {
UIWelcomeMessage string
MaxSidebarPlaylists int
EnableTranscodingConfig bool
+ EnableTranscodingCancellation bool
EnableDownloads bool
EnableExternalServices bool
EnableInsightsCollector bool
@@ -492,6 +493,7 @@ 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)
diff --git a/core/ffmpeg/ffmpeg.go b/core/ffmpeg/ffmpeg.go
index 2e0d5a4b7..d134077ce 100644
--- a/core/ffmpeg/ffmpeg.go
+++ b/core/ffmpeg/ffmpeg.go
@@ -112,7 +112,7 @@ func (e *ffmpeg) start(ctx context.Context, args []string) (io.ReadCloser, error
log.Trace(ctx, "Executing ffmpeg command", "cmd", args)
j := &ffCmd{args: args}
j.PipeReader, j.out = io.Pipe()
- err := j.start()
+ err := j.start(ctx)
if err != nil {
return nil, err
}
@@ -127,8 +127,8 @@ type ffCmd struct {
cmd *exec.Cmd
}
-func (j *ffCmd) start() error {
- cmd := exec.Command(j.args[0], j.args[1:]...) // #nosec
+func (j *ffCmd) start(ctx context.Context) error {
+ cmd := exec.CommandContext(ctx, j.args[0], j.args[1:]...) // #nosec
cmd.Stdout = j.out
if log.IsGreaterOrEqualTo(log.LevelTrace) {
cmd.Stderr = os.Stderr
diff --git a/core/ffmpeg/ffmpeg_test.go b/core/ffmpeg/ffmpeg_test.go
index 7e67a2a6a..debe0b51e 100644
--- a/core/ffmpeg/ffmpeg_test.go
+++ b/core/ffmpeg/ffmpeg_test.go
@@ -1,7 +1,11 @@
package ffmpeg
import (
+ "context"
+ "runtime"
+ sync "sync"
"testing"
+ "time"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/tests"
@@ -65,4 +69,98 @@ var _ = Describe("ffmpeg", func() {
Expect(args).To(Equal([]string{"/usr/bin/with spaces/ffmpeg.exe", "-i", "one.mp3", "-f", "ffmetadata"}))
})
})
+
+ Describe("FFmpeg", func() {
+ Context("when FFmpeg is available", func() {
+ var ff FFmpeg
+
+ BeforeEach(func() {
+ ffOnce = sync.Once{}
+ ff = New()
+ // Skip if FFmpeg is not available
+ if !ff.IsAvailable() {
+ Skip("FFmpeg not available on this system")
+ }
+ })
+
+ It("should interrupt transcoding when context is cancelled", func() {
+ ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
+ defer cancel()
+
+ // Use a command that generates audio indefinitely
+ // -f lavfi uses FFmpeg's built-in audio source
+ // -t 0 means no time limit (runs forever)
+ command := "ffmpeg -f lavfi -i sine=frequency=1000:duration=0 -f mp3 -"
+
+ // The input file is not used here, but we need to provide a valid path to the Transcode function
+ stream, err := ff.Transcode(ctx, command, "tests/fixtures/test.mp3", 128, 0)
+ Expect(err).ToNot(HaveOccurred())
+ defer stream.Close()
+
+ // Read some data first to ensure FFmpeg is running
+ buf := make([]byte, 1024)
+ _, err = stream.Read(buf)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Cancel the context
+ cancel()
+
+ // Next read should fail due to cancelled context
+ _, err = stream.Read(buf)
+ Expect(err).To(HaveOccurred())
+ })
+
+ It("should handle immediate context cancellation", func() {
+ ctx, cancel := context.WithCancel(GinkgoT().Context())
+ cancel() // Cancel immediately
+
+ // This should fail immediately
+ _, err := ff.Transcode(ctx, "ffmpeg -i %s -f mp3 -", "tests/fixtures/test.mp3", 128, 0)
+ Expect(err).To(MatchError(context.Canceled))
+ })
+ })
+
+ Context("with mock process behavior", func() {
+ var longRunningCmd string
+ BeforeEach(func() {
+ // Use a long-running command for testing cancellation
+ switch runtime.GOOS {
+ case "windows":
+ // Use PowerShell's Start-Sleep
+ ffmpegPath = "powershell"
+ longRunningCmd = "powershell -Command Start-Sleep -Seconds 10"
+ default:
+ // Use sleep on Unix-like systems
+ ffmpegPath = "sleep"
+ longRunningCmd = "sleep 10"
+ }
+ })
+
+ It("should terminate the underlying process when context is cancelled", func() {
+ ff := New()
+ ctx, cancel := context.WithTimeout(GinkgoT().Context(), 5*time.Second)
+ defer cancel()
+
+ // Start a process that will run for a while
+ stream, err := ff.Transcode(ctx, longRunningCmd, "tests/fixtures/test.mp3", 0, 0)
+ Expect(err).ToNot(HaveOccurred())
+ defer stream.Close()
+
+ // Give the process time to start
+ time.Sleep(50 * time.Millisecond)
+
+ // Cancel the context
+ cancel()
+
+ // Try to read from the stream, which should fail
+ buf := make([]byte, 100)
+ _, err = stream.Read(buf)
+ Expect(err).To(HaveOccurred(), "Expected stream to be closed due to process termination")
+
+ // Verify the stream is closed by attempting another read
+ _, err = stream.Read(buf)
+ Expect(err).To(HaveOccurred())
+ })
+ })
+ })
})
diff --git a/core/media_streamer.go b/core/media_streamer.go
index b3593c4eb..c741ed476 100644
--- a/core/media_streamer.go
+++ b/core/media_streamer.go
@@ -204,7 +204,20 @@ func NewTranscodingCache() TranscodingCache {
log.Error(ctx, "Error loading transcoding command", "format", job.format, err)
return nil, os.ErrInvalid
}
- out, err := job.ms.transcoder.Transcode(ctx, t.Command, job.filePath, job.bitRate, job.offset)
+
+ // Choose the appropriate context based on EnableTranscodingCancellation configuration.
+ // This is where we decide whether transcoding processes should be cancellable or not.
+ var transcodingCtx context.Context
+ if conf.Server.EnableTranscodingCancellation {
+ // Use the request context directly, allowing cancellation when client disconnects
+ 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)
+ }
+
+ out, err := job.ms.transcoder.Transcode(transcodingCtx, t.Command, job.filePath, job.bitRate, job.offset)
if err != nil {
log.Error(ctx, "Error starting transcoder", "id", job.mf.ID, err)
return nil, os.ErrInvalid
From 0faf744e32112fd0caef9e0c0f0b531081e86dfa Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Mon, 1 Dec 2025 22:21:54 -0500
Subject: [PATCH 084/102] refactor: make NowPlaying dispatch asynchronous with
worker pool (#4757)
* feat: make NowPlaying dispatch asynchronous with worker pool
Implemented asynchronous NowPlaying dispatch using a queue worker pattern similar to cacheWarmer. Instead of dispatching NowPlaying updates synchronously during the HTTP request, they are now queued and processed by background workers at controlled intervals.
Key changes:
- Added nowPlayingEntry struct to represent queued entries
- Added npQueue map (keyed by playerId), npMu mutex, and npSignal channel to playTracker
- Implemented enqueueNowPlaying() to add entries to the queue
- Implemented nowPlayingWorker() that polls every 100ms, drains queue, and processes entries
- Changed NowPlaying() to queue dispatch instead of calling synchronously
- Renamed dispatchNowPlaying() to dispatchNowPlayingAsync() and updated it to use background context
Benefits:
- HTTP handlers return immediately without waiting for scrobbler responses
- Deduplication by key: rapid calls (seeking) only dispatch latest state
- Fire-and-forget: one-shot attempts with logged failures
- Backpressure-free: worker processes at its own pace
- Tests updated to use Eventually() assertions for async dispatch
Signed-off-by: Deluan
* fix(play_tracker): increase timeout duration for signal handling
Signed-off-by: Deluan
* refactor(play_tracker): simplify queue processing by directly assigning entries
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
---
core/scrobbler/buffered_scrobbler_test.go | 6 +-
core/scrobbler/play_tracker.go | 71 +++++++++++++-
core/scrobbler/play_tracker_test.go | 111 ++++++++++++++--------
3 files changed, 147 insertions(+), 41 deletions(-)
diff --git a/core/scrobbler/buffered_scrobbler_test.go b/core/scrobbler/buffered_scrobbler_test.go
index 063e0de8c..9fbca6f71 100644
--- a/core/scrobbler/buffered_scrobbler_test.go
+++ b/core/scrobbler/buffered_scrobbler_test.go
@@ -38,9 +38,9 @@ var _ = Describe("BufferedScrobbler", func() {
It("forwards NowPlaying calls", func() {
track := &model.MediaFile{ID: "123", Title: "Test Track"}
Expect(bs.NowPlaying(ctx, "user1", track, 0)).To(Succeed())
- Expect(scr.NowPlayingCalled).To(BeTrue())
- Expect(scr.UserID).To(Equal("user1"))
- Expect(scr.Track).To(Equal(track))
+ Expect(scr.GetNowPlayingCalled()).To(BeTrue())
+ Expect(scr.GetUserID()).To(Equal("user1"))
+ Expect(scr.GetTrack()).To(Equal(track))
})
It("enqueues scrobbles to buffer", func() {
diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go
index 3b71a2100..d7ab0e6cf 100644
--- a/core/scrobbler/play_tracker.go
+++ b/core/scrobbler/play_tracker.go
@@ -31,6 +31,12 @@ type Submission struct {
Timestamp time.Time
}
+type nowPlayingEntry struct {
+ userId string
+ track *model.MediaFile
+ position int
+}
+
type PlayTracker interface {
NowPlaying(ctx context.Context, playerId string, playerName string, trackId string, position int) error
GetNowPlaying(ctx context.Context) ([]NowPlayingInfo, error)
@@ -52,6 +58,11 @@ type playTracker struct {
pluginScrobblers map[string]Scrobbler
pluginLoader PluginLoader
mu sync.RWMutex
+ npQueue map[string]nowPlayingEntry
+ npMu sync.Mutex
+ npSignal chan struct{}
+ shutdown chan struct{}
+ workerDone chan struct{}
}
func GetPlayTracker(ds model.DataStore, broker events.Broker, pluginManager PluginLoader) PlayTracker {
@@ -71,6 +82,10 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
builtinScrobblers: make(map[string]Scrobbler),
pluginScrobblers: make(map[string]Scrobbler),
pluginLoader: pluginManager,
+ npQueue: make(map[string]nowPlayingEntry),
+ npSignal: make(chan struct{}, 1),
+ shutdown: make(chan struct{}),
+ workerDone: make(chan struct{}),
}
if conf.Server.EnableNowPlaying {
m.OnExpiration(func(_ string, _ NowPlayingInfo) {
@@ -90,9 +105,16 @@ func newPlayTracker(ds model.DataStore, broker events.Broker, pluginManager Plug
p.builtinScrobblers[name] = s
}
log.Debug("List of builtin scrobblers enabled", "names", enabled)
+ go p.nowPlayingWorker()
return p
}
+// stopNowPlayingWorker stops the background worker. This is primarily for testing.
+func (p *playTracker) stopNowPlayingWorker() {
+ close(p.shutdown)
+ <-p.workerDone // Wait for worker to finish
+}
+
// pluginNamesMatchScrobblers returns true if the set of pluginNames matches the keys in pluginScrobblers
func pluginNamesMatchScrobblers(pluginNames []string, scrobblers map[string]Scrobbler) bool {
if len(pluginNames) != len(scrobblers) {
@@ -198,11 +220,58 @@ func (p *playTracker) NowPlaying(ctx context.Context, playerId string, playerNam
}
player, _ := request.PlayerFrom(ctx)
if player.ScrobbleEnabled {
- p.dispatchNowPlaying(ctx, user.ID, mf, position)
+ p.enqueueNowPlaying(playerId, user.ID, mf, position)
}
return nil
}
+func (p *playTracker) enqueueNowPlaying(playerId string, userId string, track *model.MediaFile, position int) {
+ p.npMu.Lock()
+ defer p.npMu.Unlock()
+ p.npQueue[playerId] = nowPlayingEntry{
+ 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(context.Background(), 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)
diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go
index 7b4785bb5..f300f7796 100644
--- a/core/scrobbler/play_tracker_test.go
+++ b/core/scrobbler/play_tracker_test.go
@@ -24,15 +24,26 @@ import (
// Moved to top-level scope to avoid linter issues
type mockPluginLoader struct {
+ mu sync.RWMutex
names []string
scrobblers map[string]Scrobbler
}
func (m *mockPluginLoader) PluginNames(service string) []string {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
return m.names
}
+func (m *mockPluginLoader) SetNames(names []string) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.names = names
+}
+
func (m *mockPluginLoader) LoadScrobbler(name string) (Scrobbler, bool) {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
s, ok := m.scrobblers[name]
return s, ok
}
@@ -46,7 +57,7 @@ var _ = Describe("PlayTracker", func() {
var album model.Album
var artist1 model.Artist
var artist2 model.Artist
- var fake fakeScrobbler
+ var fake *fakeScrobbler
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
@@ -54,16 +65,16 @@ var _ = Describe("PlayTracker", func() {
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
- fake = fakeScrobbler{Authorized: true}
+ fake = &fakeScrobbler{Authorized: true}
Register("fake", func(model.DataStore) Scrobbler {
- return &fake
+ return fake
})
Register("disabled", func(model.DataStore) Scrobbler {
return nil
})
eventBroker = &fakeEventBroker{}
tracker = newPlayTracker(ds, eventBroker, nil)
- tracker.(*playTracker).builtinScrobblers["fake"] = &fake // Bypass buffering for tests
+ tracker.(*playTracker).builtinScrobblers["fake"] = fake // Bypass buffering for tests
track = model.MediaFile{
ID: "123",
@@ -86,6 +97,11 @@ var _ = Describe("PlayTracker", func() {
_ = ds.Album(ctx).(*tests.MockAlbumRepo).Put(&album)
})
+ AfterEach(func() {
+ // Stop the worker goroutine to prevent data races between tests
+ tracker.(*playTracker).stopNowPlayingWorker()
+ })
+
It("does not register disabled scrobblers", func() {
Expect(tracker.(*playTracker).builtinScrobblers).To(HaveKey("fake"))
Expect(tracker.(*playTracker).builtinScrobblers).ToNot(HaveKey("disabled"))
@@ -95,10 +111,10 @@ var _ = Describe("PlayTracker", func() {
It("sends track to agent", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeTrue())
- Expect(fake.UserID).To(Equal("u-1"))
- Expect(fake.Track.ID).To(Equal("123"))
- Expect(fake.Track.Participants).To(Equal(track.Participants))
+ 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
@@ -106,7 +122,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeFalse())
+ 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})
@@ -114,7 +130,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeFalse())
+ Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("does not send track to agent if artist is unknown", func() {
track.Artist = consts.UnknownArtist
@@ -122,7 +138,7 @@ var _ = Describe("PlayTracker", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeFalse())
+ Expect(fake.GetNowPlayingCalled()).To(BeFalse())
})
It("stores position when greater than zero", func() {
@@ -130,11 +146,12 @@ var _ = Describe("PlayTracker", func() {
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))
- Expect(fake.Position).To(Equal(pos))
})
It("sends event with count", func() {
@@ -210,7 +227,7 @@ var _ = Describe("PlayTracker", func() {
Expect(err).ToNot(HaveOccurred())
Expect(fake.ScrobbleCalled.Load()).To(BeTrue())
- Expect(fake.UserID).To(Equal("u-1"))
+ Expect(fake.GetUserID()).To(Equal("u-1"))
lastScrobble := fake.LastScrobble.Load()
Expect(lastScrobble.TimeStamp).To(BeTemporally("~", ts, 1*time.Second))
Expect(lastScrobble.ID).To(Equal("123"))
@@ -278,45 +295,46 @@ var _ = Describe("PlayTracker", func() {
Describe("Plugin scrobbler logic", func() {
var pluginLoader *mockPluginLoader
- var pluginFake fakeScrobbler
+ var pluginFake *fakeScrobbler
BeforeEach(func() {
- pluginFake = fakeScrobbler{Authorized: true}
+ pluginFake = &fakeScrobbler{Authorized: true}
pluginLoader = &mockPluginLoader{
names: []string{"plugin1"},
- scrobblers: map[string]Scrobbler{"plugin1": &pluginFake},
+ scrobblers: map[string]Scrobbler{"plugin1": pluginFake},
}
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.(*playTracker).builtinScrobblers["fake"] = fake
+ tracker.(*playTracker).pluginScrobblers["plugin1"] = pluginFake
})
It("registers and uses plugin scrobbler for NowPlaying", func() {
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Expect(pluginFake.NowPlayingCalled).To(BeTrue())
+ 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)
- Expect(pluginFake.NowPlayingCalled).To(BeTrue())
- pluginFake.NowPlayingCalled = false
+ Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
+ pluginFake.nowPlayingCalled.Store(false)
// Remove plugin
- pluginLoader.names = []string{}
+ pluginLoader.SetNames([]string{})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
- Expect(pluginFake.NowPlayingCalled).To(BeFalse())
+ // Should not be called since plugin was removed
+ Consistently(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeFalse())
})
It("calls both builtin and plugin scrobblers for NowPlaying", func() {
- fake.NowPlayingCalled = false
- pluginFake.NowPlayingCalled = false
+ fake.nowPlayingCalled.Store(false)
+ pluginFake.nowPlayingCalled.Store(false)
err := tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
Expect(err).ToNot(HaveOccurred())
- Expect(fake.NowPlayingCalled).To(BeTrue())
- Expect(pluginFake.NowPlayingCalled).To(BeTrue())
+ Eventually(func() bool { return fake.GetNowPlayingCalled() }).Should(BeTrue())
+ Eventually(func() bool { return pluginFake.GetNowPlayingCalled() }).Should(BeTrue())
})
It("calls plugin scrobbler for Submit", func() {
@@ -359,7 +377,7 @@ var _ = Describe("PlayTracker", func() {
It("calls Stop on scrobblers when removing them", func() {
// Change the plugin names to simulate a plugin being removed
- mockPlugin.names = []string{}
+ mockPlugin.SetNames([]string{})
// Call refreshPluginScrobblers which should detect the removed plugin
pTracker.refreshPluginScrobblers()
@@ -375,32 +393,51 @@ var _ = Describe("PlayTracker", func() {
type fakeScrobbler struct {
Authorized bool
- NowPlayingCalled bool
+ nowPlayingCalled atomic.Bool
ScrobbleCalled atomic.Bool
- UserID string
- Track *model.MediaFile
- Position int
+ userID atomic.Pointer[string]
+ track atomic.Pointer[model.MediaFile]
+ position atomic.Int32
LastScrobble atomic.Pointer[Scrobble]
Error error
}
+func (f *fakeScrobbler) GetNowPlayingCalled() bool {
+ return f.nowPlayingCalled.Load()
+}
+
+func (f *fakeScrobbler) GetUserID() string {
+ if p := f.userID.Load(); p != nil {
+ return *p
+ }
+ return ""
+}
+
+func (f *fakeScrobbler) GetTrack() *model.MediaFile {
+ return f.track.Load()
+}
+
+func (f *fakeScrobbler) GetPosition() int {
+ return int(f.position.Load())
+}
+
func (f *fakeScrobbler) IsAuthorized(ctx context.Context, userId string) bool {
return f.Error == nil && f.Authorized
}
func (f *fakeScrobbler) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
- f.NowPlayingCalled = true
+ f.nowPlayingCalled.Store(true)
if f.Error != nil {
return f.Error
}
- f.UserID = userId
- f.Track = track
- f.Position = position
+ f.userID.Store(&userId)
+ f.track.Store(track)
+ f.position.Store(int32(position))
return nil
}
func (f *fakeScrobbler) Scrobble(ctx context.Context, userId string, s Scrobble) error {
- f.UserID = userId
+ f.userID.Store(&userId)
f.LastScrobble.Store(&s)
f.ScrobbleCalled.Store(true)
if f.Error != nil {
From 3ac2c6b6edb70052685be38e63b15c061c49bd05 Mon Sep 17 00:00:00 2001
From: floatlesss <117862164+floatlesss@users.noreply.github.com>
Date: Tue, 2 Dec 2025 13:39:36 +0000
Subject: [PATCH 085/102] fix: upgrade TagLib in devcontainer (#4750)
* Signed-off-by: floatlesss <117862164+floatlesss@users.noreply.github.com>
fix(vscodedevcontainer): fix-taglib-build-issues - #4749
* Apply Gemini suggested changes
Signed-off-by: floatlesss <117862164+floatlesss@users.noreply.github.com>
* chore: install TagLib in devcontainer Dockerfile
Move TagLib installation from postCreateCommand script into the devcontainer Dockerfile to leverage Docker layer caching and simplify setup.\n\nChanges:\n- Install cross-taglib v2.1.1-1 directly in Dockerfile using TARGETARCH for multi-arch support (amd64/arm64).\n- Remove redundant libtag1-dev apt dependency; keep ffmpeg only.\n- Add CROSS_TAGLIB_VERSION as a build arg for consistency with CI/Makefile.\n- Remove postCreateCommand from devcontainer.json and delete install-taglib.sh script.\n\nWhy:\n- Avoid re-downloading TagLib on each container create; benefit from cached image layers.\n- Reduce redundancy and potential version mismatch between apt libtag and cross-taglib.\n- Keep devcontainer aligned with production build approach and CI settings.
---------
Signed-off-by: floatlesss <117862164+floatlesss@users.noreply.github.com>
Co-authored-by: Deluan
---
.devcontainer/Dockerfile | 15 +++++++++++----
.devcontainer/devcontainer.json | 7 +++----
2 files changed, 14 insertions(+), 8 deletions(-)
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 4fc7a5b73..6cf7a5e4e 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -9,12 +9,19 @@ ARG INSTALL_NODE="true"
ARG NODE_VERSION="lts/*"
RUN if [ "${INSTALL_NODE}" = "true" ]; then su vscode -c "source /usr/local/share/nvm/nvm.sh && nvm install ${NODE_VERSION} 2>&1"; fi
-# [Optional] Uncomment this section to install additional OS packages.
+# Install additional OS packages
RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
- && apt-get -y install --no-install-recommends libtag1-dev ffmpeg
+ && apt-get -y install --no-install-recommends ffmpeg
-# [Optional] Uncomment the next line to use go get to install anything else you need
-# RUN go get -x
+# Install TagLib from cross-taglib releases
+ARG CROSS_TAGLIB_VERSION="2.1.1-1"
+ARG TARGETARCH
+RUN DOWNLOAD_ARCH="linux-${TARGETARCH}" \
+ && wget -q "https://github.com/navidrome/cross-taglib/releases/download/v${CROSS_TAGLIB_VERSION}/taglib-${DOWNLOAD_ARCH}.tar.gz" -O /tmp/cross-taglib.tar.gz \
+ && tar -xzf /tmp/cross-taglib.tar.gz -C /usr --strip-components=1 \
+ && mv /usr/include/taglib/* /usr/include/ \
+ && rmdir /usr/include/taglib \
+ && rm /tmp/cross-taglib.tar.gz /usr/provenance.json
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g " 2>&1
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index ff58994db..0519f25fc 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -7,7 +7,8 @@
"VARIANT": "1.25",
// Options
"INSTALL_NODE": "true",
- "NODE_VERSION": "v24"
+ "NODE_VERSION": "v24",
+ "CROSS_TAGLIB_VERSION": "2.1.1-1"
}
},
"workspaceMount": "",
@@ -54,12 +55,10 @@
4533,
4633
],
- // Use 'postCreateCommand' to run commands after the container is created.
- // "postCreateCommand": "make setup-dev",
// Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
"remoteUser": "vscode",
"remoteEnv": {
"ND_MUSICFOLDER": "./music",
"ND_DATAFOLDER": "./data"
}
-}
+}
\ No newline at end of file
From ff5ebe1829f58968862988ddd98059fe47b732f3 Mon Sep 17 00:00:00 2001
From: ChekeredList71 <66330496+ChekeredList71@users.noreply.github.com>
Date: Tue, 2 Dec 2025 17:27:12 +0100
Subject: [PATCH 086/102] fix(ui): new Hungarian strings and updates (#4703)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
added: "quickscan", "fullscan"
updated:
- "manageUsers": `access` translates to `hozzáférés` in this context, not `elérés` (~reachableness)
- "quickscan", "fullscan", "scantype": updated to match new strings
---
resources/i18n/hu.json | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/resources/i18n/hu.json b/resources/i18n/hu.json
index a2037eb54..cbdd57109 100644
--- a/resources/i18n/hu.json
+++ b/resources/i18n/hu.json
@@ -300,7 +300,9 @@
},
"actions": {
"scan": "Könyvtár szkennelése",
- "manageUsers": "Elérés kezelése",
+ "quickScan": "Gyors szkennelés",
+ "fullScan": "Teljes szkennelés",
+ "manageUsers": "Hozzáférés kezelése",
"viewDetails": "Részletek"
},
"notifications": {
@@ -598,11 +600,12 @@
"activity": {
"title": "Aktivitás",
"totalScanned": "Összes beolvasott mappa:",
- "quickScan": "Gyors szkennelés",
- "fullScan": "Teljes szkennelés",
+ "quickScan": "Gyors",
+ "fullScan": "Teljes",
+ "selectiveScan": "Szelektív",
"serverUptime": "Szerver üzemidő",
"serverDown": "OFFLINE",
- "scanType": "Típus",
+ "scanType": "Legutóbbi szkennelés",
"status": "Szkennelési hiba",
"elapsedTime": "Eltelt idő"
},
From 5c43025ce125078178655f3403759676e605e8a0 Mon Sep 17 00:00:00 2001
From: Xabi <888924+xabirequejo@users.noreply.github.com>
Date: Tue, 2 Dec 2025 17:31:02 +0100
Subject: [PATCH 087/102] fix(ui): update Basque translation to include library
related strings that were missing (#4670)
* Update eu.json
Added Library strings
* Update eu.json, now with missing comma
There was a comma missing.
* Update eu.json, typo
Fixes a typo.
---
resources/i18n/eu.json | 74 ++++++++++++++++++++++++++++++++++++++++--
1 file changed, 71 insertions(+), 3 deletions(-)
diff --git a/resources/i18n/eu.json b/resources/i18n/eu.json
index cb5927a74..0c968e2c4 100644
--- a/resources/i18n/eu.json
+++ b/resources/i18n/eu.json
@@ -12,6 +12,7 @@
"artist": "Artista",
"album": "Albuma",
"path": "Fitxategiaren bidea",
+ "libraryName": "Liburutegia",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
@@ -58,6 +59,7 @@
"playCount": "Erreprodukzioak",
"size": "Fitxategiaren tamaina",
"name": "Izena",
+ "libraryName": "Liburutegia",
"genre": "Generoa",
"compilation": "Konpilazioa",
"year": "Urtea",
@@ -147,19 +149,26 @@
"currentPassword": "Uneko pasahitza",
"newPassword": "Pasahitz berria",
"token": "Tokena",
- "lastAccessAt": "Azken sarbidea"
+ "lastAccessAt": "Azken sarbidea",
+ "libraries": "Liburutegiak"
},
"helperTexts": {
- "name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira"
+ "name": "Aldaketak saioa hasten duzun hurrengoan islatuko dira",
+ "libraries": "Hautatu erabiltzaile honentzat liburutegi jakinak, edo utzi hutsik defektuzko liburutegiak erabiltzeko"
},
"notifications": {
"created": "Erabiltzailea sortu da",
"updated": "Erabiltzailea eguneratu da",
"deleted": "Erabiltzailea ezabatu da"
},
+ "validation": {
+ "librariesRequired": "Gutxienez liburutegi bat hautatu behar da administratzaile ez diren erabiltzaileentzat"
+ },
"message": {
"listenBrainzToken": "Idatzi zure ListenBrainz erabiltzailearen tokena",
- "clickHereForToken": "Egin klik hemen tokena lortzeko"
+ "clickHereForToken": "Egin klik hemen tokena lortzeko",
+ "selectAllLibraries": "Hautatu liburutegi guztiak",
+ "adminAutoLibraries": "Administratzaileek automatikoki dute liburutegi guztietara sarbidea"
}
},
"player": {
@@ -254,6 +263,7 @@
"fields": {
"path": "Bidea",
"size": "Tamaina",
+ "libraryName": "Liburutegia",
"updatedAt": "Desagertze-data:"
},
"actions": {
@@ -263,6 +273,58 @@
"notifications": {
"removed": "Aurkitzen ez ziren fitxategiak kendu dira"
}
+ },
+ "library": {
+ "name": "Liburutegia |||| Liburutegiak",
+ "fields": {
+ "name": "Izena",
+ "path": "Fitxategiaren bidea",
+ "remotePath": "Urruneko bidea",
+ "lastScanAt": "Azken araketa",
+ "songCount": "Abestiak",
+ "albumCount": "Albumak",
+ "artistCount": "Artistak",
+ "totalSongs": "Abestiak",
+ "totalAlbums": "Albumak",
+ "totalArtists": "Artistak",
+ "totalFolders": "Karpetak",
+ "totalFiles": "Fitxategiak",
+ "totalMissingFiles": "Fitxategiak faltan",
+ "totalSize": "Tamaina guztira",
+ "totalDuration": "Iraupena",
+ "defaultNewUsers": "Defektuz erabiltzaile berrientzat",
+ "createdAt": "Sortze-data",
+ "updatedAt": "Eguneratze-data"
+ },
+ "sections": {
+ "basic": "Oinarrizko informazioa",
+ "statistics": "Estatistikak"
+ },
+ "actions": {
+ "scan": "Arakatu liburutegia",
+ "manageUsers": "Kudeatu erabiltzaileen sarbidea",
+ "viewDetails": "Ikusi xehetasunak"
+ },
+ "notifications": {
+ "created": "Liburutegia ondo sortu da",
+ "updated": "Liburutegia ondo eguneratu da",
+ "deleted": "Liburutegia ondo ezabatu da",
+ "scanStarted": "Liburutegiaren araketa hasi da",
+ "scanCompleted": "Liburutegiaren araketa amaitu da"
+ },
+ "validation": {
+ "nameRequired": "Liburutegiaren izena beharrezkoa da",
+ "pathRequired": "Liburutegiaren bidea beharrezkoa da",
+ "pathNotDirectory": "Liburutegiaren bidea direktorio bat izan behar da",
+ "pathNotFound": "Ez da liburutegiaren bidea aurkitu",
+ "pathNotAccessible": "Liburutegiaren bidea ez dago eskuragai",
+ "pathInvalid": "Liburutegiaren bidea ez da baliozkoa"
+ },
+ "messages": {
+ "deleteConfirm": "Ziur liburutegia ezabatu nahi duzula? Erlazionatutako datu guztiak eta erabiltzaileen sarbidea kenduko ditu.",
+ "scanInProgress": "Araketa abian da…",
+ "noLibrariesAssigned": "Ez da liburutegirik egokitu erabiltzaile honentzat"
+ }
}
},
"ra": {
@@ -450,6 +512,12 @@
},
"menu": {
"library": "Liburutegia",
+ "librarySelector": {
+ "allLibraries": "Liburutegi guztiak (%{count})",
+ "multipleLibraries": "%{total} liburutegitik %{selected} hautatuta",
+ "selectLibraries": "Hautatu liburutegiak",
+ "none": "Bat ere ez"
+ },
"settings": "Ezarpenak",
"version": "Bertsioa",
"theme": "Itxura",
From 654607ea532bf6cf721f9aca8ddca904ed383199 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Tue, 2 Dec 2025 11:38:26 -0500
Subject: [PATCH 088/102] fix(ui): update Danish, German, Greek, Spanish,
French, Japanese, Polish, Russian, Swedish, Thai, Ukrainian translations from
POEditor (#4687)
Co-authored-by: navidrome-bot
---
resources/i18n/da.json | 16 +++-
resources/i18n/de.json | 12 ++-
resources/i18n/el.json | 12 ++-
resources/i18n/es.json | 120 ++++++++++++-----------
resources/i18n/fr.json | 12 ++-
resources/i18n/ja.json | 210 ++++++++++++++++++++++++++++++++---------
resources/i18n/pl.json | 12 ++-
resources/i18n/ru.json | 14 ++-
resources/i18n/sv.json | 12 ++-
resources/i18n/th.json | 12 ++-
resources/i18n/uk.json | 12 ++-
11 files changed, 313 insertions(+), 131 deletions(-)
diff --git a/resources/i18n/da.json b/resources/i18n/da.json
index 105a20732..550c8841a 100644
--- a/resources/i18n/da.json
+++ b/resources/i18n/da.json
@@ -83,7 +83,7 @@
"actions": {
"playAll": "Afspil",
"playNext": "Afspil næste",
- "addToQueue": "Afspil senere",
+ "addToQueue": "Føj til kø",
"shuffle": "Bland",
"addToPlaylist": "Føj til afspilningsliste",
"download": "Download",
@@ -301,14 +301,19 @@
"actions": {
"scan": "Scanningsbibliotek",
"manageUsers": "Administrer brugeradgang",
- "viewDetails": "Se detaljer"
+ "viewDetails": "Se detaljer",
+ "quickScan": "hurtig skanning",
+ "fullScan": "Fuld skanning"
},
"notifications": {
"created": "Bibliotek oprettet",
"updated": "Biblioteket er blevet opdateret",
"deleted": "Biblioteket er blevet slettet",
"scanStarted": "Biblioteksscanning startet",
- "scanCompleted": "Biblioteksscanning fuldført"
+ "scanCompleted": "Biblioteksscanning fuldført",
+ "quickScanStarted": "hurtig skanning startet",
+ "fullScanStarted": "Fuld skanning startet",
+ "scanError": "Kan ikke starte skanning. Tjek loggen"
},
"validation": {
"nameRequired": "Biblioteksnavn er påkrævet",
@@ -549,7 +554,7 @@
"closeText": "Luk",
"notContentText": "Ingen musik",
"clickToPlayText": "Tryk for at afspille",
- "clickToPauseText": "Tryk for at pause",
+ "clickToPauseText": "Tryk for at sætte på pause",
"nextTrackText": "Næste nummer",
"previousTrackText": "Forrige nummer",
"reloadText": "Genindlæs",
@@ -604,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Type",
"status": "Scanningsfejl",
- "elapsedTime": "Medgået tid"
+ "elapsedTime": "Medgået tid",
+ "selectiveScan": "Selektiv"
},
"help": {
"title": "Navidrome genvejstaster",
diff --git a/resources/i18n/de.json b/resources/i18n/de.json
index c9c7fa7f5..22e2fab44 100644
--- a/resources/i18n/de.json
+++ b/resources/i18n/de.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Bibliothek scannen",
"manageUsers": "Zugriff verwalten",
- "viewDetails": "Details ansehen"
+ "viewDetails": "Details ansehen",
+ "quickScan": "Schneller Scan",
+ "fullScan": "Kompletter Scan"
},
"notifications": {
"created": "Bibliothek erfolgreich erstellt",
"updated": "Bibliothek erfolgreich geändert",
"deleted": "Bibliothek erfolgreich gelöscht",
"scanStarted": "Bibliothek Scan gestartet",
- "scanCompleted": "Bibliothek Scan vollständig"
+ "scanCompleted": "Bibliothek Scan vollständig",
+ "quickScanStarted": "Schneller Scan gestartet",
+ "fullScanStarted": "Kompletter Scan gestartet",
+ "scanError": "Fehler beim Starten des Scans. Logs prüfen"
},
"validation": {
"nameRequired": "Bibliotheksname ist Pflichtfeld",
@@ -604,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Typ",
"status": "Scan Fehler",
- "elapsedTime": "Laufzeit"
+ "elapsedTime": "Laufzeit",
+ "selectiveScan": "Selektiver Scan"
},
"help": {
"title": "Navidrome Hotkeys",
diff --git a/resources/i18n/el.json b/resources/i18n/el.json
index 0d9ee05c5..4dd58e9cc 100644
--- a/resources/i18n/el.json
+++ b/resources/i18n/el.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Σάρωση βιβλιοθήκης",
"manageUsers": "Διαχείριση πρόσβασης χρήστη",
- "viewDetails": "Προβολή λεπτομερειών"
+ "viewDetails": "Προβολή λεπτομερειών",
+ "quickScan": "Γρήγορη σάρωση",
+ "fullScan": "Πλήρης σάρωση"
},
"notifications": {
"created": "Η βιβλιοθήκη δημιουργήθηκε με επιτυχία",
"updated": "Η βιβλιοθήκη ενημερώθηκε με επιτυχία",
"deleted": "Η βιβλιοθήκη διαγράφηκε με επιτυχία",
"scanStarted": "Ξεκίνησε η σάρωση της βιβλιοθήκης",
- "scanCompleted": "Η σάρωση της βιβλιοθήκης ολοκληρώθηκε"
+ "scanCompleted": "Η σάρωση της βιβλιοθήκης ολοκληρώθηκε",
+ "quickScanStarted": "Η Γρήγορη Σάρωση ξεκίνησε",
+ "fullScanStarted": "Η πλήρης σάρωση ξεκίνησε",
+ "scanError": "Σφάλμα κατά την έναρξη της σάρωσης. Ελέγξτε τα αρχεία καταγραφής."
},
"validation": {
"nameRequired": "Απαιτείται όνομα βιβλιοθήκης",
@@ -604,7 +609,8 @@
"serverDown": "ΕΚΤΟΣ ΣΥΝΔΕΣΗΣ",
"scanType": "Τύπος",
"status": "Σφάλμα σάρωσης",
- "elapsedTime": "Χρόνος που πέρασε"
+ "elapsedTime": "Χρόνος που πέρασε",
+ "selectiveScan": "Εκλεκτικός"
},
"help": {
"title": "Συντομεύσεις του Navidrome",
diff --git a/resources/i18n/es.json b/resources/i18n/es.json
index 4c53b8986..c620d773f 100644
--- a/resources/i18n/es.json
+++ b/resources/i18n/es.json
@@ -36,7 +36,7 @@
"bitDepth": "Profundidad de bits",
"sampleRate": "Frecuencia de muestreo",
"missing": "Faltante",
- "libraryName": ""
+ "libraryName": "Biblioteca"
},
"actions": {
"addToQueue": "Reproducir después",
@@ -78,7 +78,7 @@
"mood": "Estado de ánimo",
"date": "Fecha de grabación",
"missing": "Faltante",
- "libraryName": ""
+ "libraryName": "Biblioteca"
},
"actions": {
"playAll": "Reproducir",
@@ -127,12 +127,12 @@
"remixer": "Remixer",
"djmixer": "DJ Mixer",
"performer": "Intérprete",
- "maincredit": ""
+ "maincredit": "Artista del álbum o Artista |||| Artistas del álbum o Artistas"
},
"actions": {
"shuffle": "Aleatorio",
"radio": "Radio",
- "topSongs": ""
+ "topSongs": "Más destacadas"
}
},
"user": {
@@ -150,11 +150,11 @@
"newPassword": "Nueva contraseña",
"token": "Token",
"lastAccessAt": "Último acceso",
- "libraries": ""
+ "libraries": "Bibliotecas"
},
"helperTexts": {
"name": "Los cambios a tu nombre se verán en el próximo inicio de sesión",
- "libraries": ""
+ "libraries": "Selecciona bibliotecas específicas para este usuario o déjalo vacío para usar las bibliotecas por defecto"
},
"notifications": {
"created": "Usuario creado",
@@ -164,11 +164,11 @@
"message": {
"listenBrainzToken": "Escribe tu token de usuario de ListenBrainz",
"clickHereForToken": "Click aquí para obtener tu token",
- "selectAllLibraries": "",
- "adminAutoLibraries": ""
+ "selectAllLibraries": "Seleccionar todas las bibliotecas",
+ "adminAutoLibraries": "Los usuarios administradores tienen acceso a todas las bibliotecas automáticamente"
},
"validation": {
- "librariesRequired": ""
+ "librariesRequired": "Se debe seleccionar al menos una biblioteca para los usuarios que no sean administradores"
}
},
"player": {
@@ -261,7 +261,7 @@
"path": "Ruta",
"size": "Tamaño",
"updatedAt": "Actualizado el",
- "libraryName": ""
+ "libraryName": "Biblioteca"
},
"actions": {
"remove": "Eliminar",
@@ -273,55 +273,60 @@
"empty": "No hay archivos perdidos"
},
"library": {
- "name": "",
+ "name": "Biblioteca |||| Bibliotecas",
"fields": {
- "name": "",
- "path": "",
- "remotePath": "",
- "lastScanAt": "",
- "songCount": "",
- "albumCount": "",
- "artistCount": "",
- "totalSongs": "",
- "totalAlbums": "",
- "totalArtists": "",
- "totalFolders": "",
- "totalFiles": "",
- "totalMissingFiles": "",
- "totalSize": "",
- "totalDuration": "",
- "defaultNewUsers": "",
- "createdAt": "",
- "updatedAt": ""
+ "name": "Nombre",
+ "path": "Ruta",
+ "remotePath": "Ruta remota",
+ "lastScanAt": "Último escaneo",
+ "songCount": "Canciones",
+ "albumCount": "Álbumes",
+ "artistCount": "Artistas",
+ "totalSongs": "Canciones",
+ "totalAlbums": "Álbumes",
+ "totalArtists": "Artistas",
+ "totalFolders": "Carpetas",
+ "totalFiles": "Archivos",
+ "totalMissingFiles": "Archivos faltantes",
+ "totalSize": "Tamaño total",
+ "totalDuration": "Duración",
+ "defaultNewUsers": "Valor por defecto para los nuevos usuarios",
+ "createdAt": "Creado",
+ "updatedAt": "Actualizado"
},
"sections": {
- "basic": "",
- "statistics": ""
+ "basic": "Información básica",
+ "statistics": "Estadísticas"
},
"actions": {
- "scan": "",
- "manageUsers": "",
- "viewDetails": ""
+ "scan": "Escanear biblioteca",
+ "manageUsers": "Gestionar el acceso de usarios",
+ "viewDetails": "Ver detalles",
+ "quickScan": "Escaneo rápido",
+ "fullScan": "Escaneo completo"
},
"notifications": {
- "created": "",
- "updated": "",
- "deleted": "",
- "scanStarted": "",
- "scanCompleted": ""
+ "created": "La biblioteca se creó correctamente",
+ "updated": "La biblioteca se actualizó correctamente",
+ "deleted": "La biblioteca se eliminó correctamente",
+ "scanStarted": "El escaneo de la biblioteca ha comenzado",
+ "scanCompleted": "El escaneo de la biblioteca se completó",
+ "quickScanStarted": "Escaneo rápido ha comenzado",
+ "fullScanStarted": "Escaneo completo ha comenzado",
+ "scanError": "Error al iniciar el escaneo. Revisa los registros"
},
"validation": {
- "nameRequired": "",
- "pathRequired": "",
- "pathNotDirectory": "",
- "pathNotFound": "",
- "pathNotAccessible": "",
- "pathInvalid": ""
+ "nameRequired": "El nombre de la biblioteca es obligatorio",
+ "pathRequired": "La ruta de la biblioteca es obligatoria",
+ "pathNotDirectory": "La ruta de la biblioteca debe ser un directorio",
+ "pathNotFound": "Ruta de la biblioteca no encontrada",
+ "pathNotAccessible": "La ruta de la biblioteca no es accesible",
+ "pathInvalid": "Ruta de la biblioteca no válida"
},
"messages": {
- "deleteConfirm": "",
- "scanInProgress": "",
- "noLibrariesAssigned": ""
+ "deleteConfirm": "¿Estás seguro/a de que quieres eliminar esta biblioteca? Esto eliminará todos los datos asociados y el acceso de les usuaries.",
+ "scanInProgress": "Escaneo en curso...",
+ "noLibrariesAssigned": "No hay bibliotecas asignadas a este usuario"
}
}
},
@@ -506,7 +511,7 @@
"remove_all_missing_title": "Eliminar todos los archivos perdidos",
"remove_all_missing_content": "¿Realmente desea eliminar todos los archivos faltantes de la base de datos? Esto eliminará permanentemente cualquier referencia a ellos, incluidas sus reproducciones y valoraciones.",
"noSimilarSongsFound": "No se encontraron canciones similares",
- "noTopSongsFound": ""
+ "noTopSongsFound": "No se encontraron canciones destacadas"
},
"menu": {
"library": "Biblioteca",
@@ -537,10 +542,10 @@
"playlists": "Playlists",
"sharedPlaylists": "Playlists Compartidas",
"librarySelector": {
- "allLibraries": "",
- "multipleLibraries": "",
- "selectLibraries": "",
- "none": ""
+ "allLibraries": "Todas las bibliotecas (%{count})",
+ "multipleLibraries": "%{selected} de %{total} bibliotecas",
+ "selectLibraries": "Seleccionar bibliotecas",
+ "none": "Ninguno"
}
},
"player": {
@@ -604,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Tipo",
"status": "Error de escaneo",
- "elapsedTime": "Tiempo transcurrido"
+ "elapsedTime": "Tiempo transcurrido",
+ "selectiveScan": "Selectivo"
},
"help": {
"title": "Atajos de teclado de Navidrome",
@@ -621,8 +627,8 @@
}
},
"nowPlaying": {
- "title": "",
- "empty": "",
- "minutesAgo": ""
+ "title": "En reproducción",
+ "empty": "Nada en reproducción",
+ "minutesAgo": "Hace %{smart_count} minuto |||| Hace %{smart_count} minutos"
}
}
\ No newline at end of file
diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json
index af3a8dd31..070e63977 100644
--- a/resources/i18n/fr.json
+++ b/resources/i18n/fr.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Scanner la bibliothèque",
"manageUsers": "Gérer les accès utilisateurs",
- "viewDetails": "Voir les détails"
+ "viewDetails": "Voir les détails",
+ "quickScan": "Scan Rapide",
+ "fullScan": "Scan Complet"
},
"notifications": {
"created": "Bibliothèque créée avec succès",
"updated": "Bibliothèque mise à jour avec succès",
"deleted": "Bibliothèque supprimée avec succès",
"scanStarted": "Le scan de la bibliothèque a commencé",
- "scanCompleted": "Le scan de la bibliothèque est terminé"
+ "scanCompleted": "Le scan de la bibliothèque est terminé",
+ "quickScanStarted": "Scan rapide démarré",
+ "fullScanStarted": "Scan complet démarré",
+ "scanError": "Une erreur est survenue en démarrant le scan. Veuillez regarder les logs"
},
"validation": {
"nameRequired": "La bibliothèque doit obligatoirement avoir un nom",
@@ -604,7 +609,8 @@
"serverDown": "HORS LIGNE",
"scanType": "Type",
"status": "Erreur de scan",
- "elapsedTime": "Temps écoulé"
+ "elapsedTime": "Temps écoulé",
+ "selectiveScan": "Sélectif"
},
"help": {
"title": "Raccourcis Navidrome",
diff --git a/resources/i18n/ja.json b/resources/i18n/ja.json
index fbf8cefd2..29975b92b 100644
--- a/resources/i18n/ja.json
+++ b/resources/i18n/ja.json
@@ -27,12 +27,16 @@
"playDate": "最後の再生",
"channels": "チャンネル",
"createdAt": "追加日",
- "grouping": "",
- "mood": "",
- "participants": "",
- "tags": "",
- "mappedTags": "",
- "rawTags": ""
+ "grouping": "グループ分け",
+ "mood": "ムード",
+ "participants": "追加参加者",
+ "tags": "追加タグ",
+ "mappedTags": "マッピング済みタグ",
+ "rawTags": "未処理タグ",
+ "bitDepth": "ビット深度",
+ "sampleRate": "サンプリングレート",
+ "missing": "不明",
+ "libraryName": "ライブラリ"
},
"actions": {
"addToQueue": "最後に再生",
@@ -41,7 +45,8 @@
"shuffleAll": "全曲シャッフル",
"download": "ダウンロード",
"playNext": "次に再生",
- "info": "詳細"
+ "info": "詳細",
+ "showInPlaylist": "含まれるプレイリスト"
}
},
"album": {
@@ -65,12 +70,15 @@
"releaseDate": "リリース日",
"releases": "リリース",
"released": "リリース",
- "recordLabel": "",
- "catalogNum": "",
- "releaseType": "",
- "grouping": "",
- "media": "",
- "mood": ""
+ "recordLabel": "ラベル",
+ "catalogNum": "カタログ番号",
+ "releaseType": "タイプ",
+ "grouping": "グループ分け",
+ "media": "メディア",
+ "mood": "ムード",
+ "date": "録音日",
+ "missing": "不明",
+ "libraryName": "ライブラリ"
},
"actions": {
"playAll": "再生",
@@ -102,22 +110,29 @@
"rating": "レート",
"genre": "ジャンル",
"size": "サイズ",
- "role": ""
+ "role": "役割",
+ "missing": "不明"
},
"roles": {
- "albumartist": "",
- "artist": "",
- "composer": "",
- "conductor": "",
- "lyricist": "",
- "arranger": "",
- "producer": "",
- "director": "",
- "engineer": "",
- "mixer": "",
- "remixer": "",
- "djmixer": "",
- "performer": ""
+ "albumartist": "アルバムアーティスト",
+ "artist": "アーティスト",
+ "composer": "作曲家",
+ "conductor": "指揮者",
+ "lyricist": "作詞家",
+ "arranger": "編曲者",
+ "producer": "プロデューサー",
+ "director": "ディレクター",
+ "engineer": "エンジニア",
+ "mixer": "ミキサー",
+ "remixer": "リミキサー",
+ "djmixer": "DJ ミキサー",
+ "performer": "演奏者",
+ "maincredit": "アルバムアーティストもしくはアーティスト"
+ },
+ "actions": {
+ "shuffle": "シャッフル",
+ "radio": "ラジオ",
+ "topSongs": "トップソング"
}
},
"user": {
@@ -134,10 +149,12 @@
"currentPassword": "現在のパスワード",
"newPassword": "新しいパスワード",
"token": "トークン",
- "lastAccessAt": "最終アクセス"
+ "lastAccessAt": "最終アクセス",
+ "libraries": "ライブラリ"
},
"helperTexts": {
- "name": "名前の変更は次回ログイン以降反映されます"
+ "name": "名前の変更は次回ログイン以降反映されます",
+ "libraries": "このユーザーに対して特定ライブラリを選択するか、デフォルトのライブラリを使用する場合は空欄のままにします"
},
"notifications": {
"created": "ユーザーが作成されました",
@@ -146,7 +163,12 @@
},
"message": {
"listenBrainzToken": "ListenBrainzユーザートークンを入力",
- "clickHereForToken": "ここをクリックしトークンを入手"
+ "clickHereForToken": "ここをクリックしトークンを入手",
+ "selectAllLibraries": "全てのライブラリを選択",
+ "adminAutoLibraries": "管理者ユーザーは自動的にすべてのライブラリにアクセスできます"
+ },
+ "validation": {
+ "librariesRequired": "管理者以外のユーザーには少なくとも1つのライブラリを選択する必要があります"
}
},
"player": {
@@ -190,11 +212,17 @@
"addNewPlaylist": "'%{name}' を作成",
"export": "エクスポート",
"makePublic": "公開する",
- "makePrivate": "非公開にする"
+ "makePrivate": "非公開にする",
+ "saveQueue": "キューをプレイリストに保存",
+ "searchOrCreate": "プレイリストを検索または入力して新規作成...",
+ "pressEnterToCreate": "Enterキーを押して新しいプレイリストを作成",
+ "removeFromSelection": "選択から削除"
},
"message": {
"duplicate_song": "重複する曲を追加",
- "song_exist": "既にプレイリストに存在する曲です。追加しますか?"
+ "song_exist": "既にプレイリストに存在する曲です。追加しますか?",
+ "noPlaylistsFound": "プレイリストが見つかりません",
+ "noPlaylists": "利用可能なプレイリストはありません"
}
},
"radio": {
@@ -228,17 +256,77 @@
}
},
"missing": {
- "name": "",
+ "name": "欠落したファイル",
"fields": {
- "path": "",
- "size": "",
- "updatedAt": ""
+ "path": "パス",
+ "size": "サイズ",
+ "updatedAt": "欠落日",
+ "libraryName": "ライブラリ"
},
"actions": {
- "remove": ""
+ "remove": "削除",
+ "remove_all": "全て削除"
},
"notifications": {
- "removed": ""
+ "removed": "欠落ファイルが削除されました"
+ },
+ "empty": "ファイルの欠落はありません"
+ },
+ "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": "ライブラリをスキャン",
+ "manageUsers": "ユーザーアクセス管理",
+ "viewDetails": "詳細を表示",
+ "quickScan": "クイックスキャン",
+ "fullScan": "フルスキャン"
+ },
+ "notifications": {
+ "created": "ライブラリが正常に作成されました",
+ "updated": "ライブラリが正常に更新されました",
+ "deleted": "ライブラリが正常に削除されました",
+ "scanStarted": "スキャンを開始しました",
+ "scanCompleted": "スキャンが完了しました",
+ "quickScanStarted": "クイックスキャンを開始しました",
+ "fullScanStarted": "フルスキャンを開始しました",
+ "scanError": "スキャン開始中にエラーが発生。ログを確認してください"
+ },
+ "validation": {
+ "nameRequired": "ライブラリの名前が必要です",
+ "pathRequired": "ライブラリのパスが必要です",
+ "pathNotDirectory": "ライブラリパスはディレクトリである必要があります",
+ "pathNotFound": "ライブラリのパスが見つかりません",
+ "pathNotAccessible": "ライブラリパスへアクセスできません",
+ "pathInvalid": "無効なライブラリパス"
+ },
+ "messages": {
+ "deleteConfirm": "このライブラリを削除しますか?関連する全てのデータとユーザーアクセスが削除されます。",
+ "scanInProgress": "スキャン中...",
+ "noLibrariesAssigned": "このユーザーに割り当てられているライブラリはありません"
}
}
},
@@ -418,8 +506,12 @@
"shareFailure": "コピーに失敗しました %{url}",
"downloadDialogTitle": "ダウンロード %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "クリップボードへコピー: Ctrl+C, Enter",
- "remove_missing_title": "",
- "remove_missing_content": ""
+ "remove_missing_title": "欠落ファイルを削除",
+ "remove_missing_content": "選択した欠落ファイルをデータベースから削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が完全に削除されます。",
+ "remove_all_missing_title": "全ての欠落ファイルを削除",
+ "remove_all_missing_content": "データベースから欠落ファイルをすべて削除してもよろしいですか?これにより、再生数や評価を含むそれらのファイルへの参照が永久に削除されます。",
+ "noSimilarSongsFound": "類似の曲が見つかりませんでした",
+ "noTopSongsFound": "トップソングが見つかりません"
},
"menu": {
"library": "ライブラリ",
@@ -448,7 +540,13 @@
"albumList": "アルバム",
"about": "詳細",
"playlists": "プレイリスト",
- "sharedPlaylists": "共有プレイリスト"
+ "sharedPlaylists": "共有プレイリスト",
+ "librarySelector": {
+ "allLibraries": "全てのライブラリ( %{count} )",
+ "multipleLibraries": "%{selected} 個 / %{total} 個のライブラリ",
+ "selectLibraries": "ライブラリを選択",
+ "none": "無し"
+ }
},
"player": {
"playListsText": "再生リスト",
@@ -485,15 +583,34 @@
"disabled": "無効",
"waiting": "待機中"
}
+ },
+ "tabs": {
+ "about": "詳細",
+ "config": "設定"
+ },
+ "config": {
+ "configName": "設定名",
+ "environmentVariable": "環境変数",
+ "currentValue": "現在値",
+ "configurationFile": "設定ファイル",
+ "exportToml": "設定をエクスポート(TOML)",
+ "exportSuccess": "設定をTOML形式でクリップボードへエクスポートしました",
+ "exportFailed": "設定のコピーに失敗しました",
+ "devFlagsHeader": "開発フラグ(変更・削除の可能性あり)",
+ "devFlagsComment": "これらは実験的な設定であり、将来のバージョンで削除される可能性があります"
}
},
"activity": {
"title": "活動",
"totalScanned": "スキャン済みフォルダー",
- "quickScan": "クイックスキャン",
- "fullScan": "フルスキャン",
+ "quickScan": "クイック",
+ "fullScan": "フル",
"serverUptime": "サーバー稼働時間",
- "serverDown": "サーバーオフライン"
+ "serverDown": "サーバーオフライン",
+ "scanType": "最終スキャン",
+ "status": "スキャンエラー",
+ "elapsedTime": "経過時間",
+ "selectiveScan": "選択的スキャン"
},
"help": {
"title": "ホットキー",
@@ -508,5 +625,10 @@
"toggle_love": "星の付け外し",
"current_song": "現在の曲へ移動"
}
+ },
+ "nowPlaying": {
+ "title": "再生中",
+ "empty": "何も再生されていません",
+ "minutesAgo": "%{smart_count} 分前 |||| %{smart_count} 分前"
}
}
\ No newline at end of file
diff --git a/resources/i18n/pl.json b/resources/i18n/pl.json
index 4d78c7599..a9d6db88f 100644
--- a/resources/i18n/pl.json
+++ b/resources/i18n/pl.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Skanuj Bibliotekę",
"manageUsers": "Zarządzaj Dostępami Użytkownika",
- "viewDetails": "Zobacz Szczegóły"
+ "viewDetails": "Zobacz Szczegóły",
+ "quickScan": "Szybkie Skanowanie",
+ "fullScan": "Pełne Skanowanie"
},
"notifications": {
"created": "Biblioteka utworzona prawidłowo",
"updated": "Biblioteka zaktualizowana prawidłowo",
"deleted": "Biblioteka usunięta prawidłowo",
"scanStarted": "Rozpoczęto skan biblioteki",
- "scanCompleted": "Zakończono skan biblioteki"
+ "scanCompleted": "Zakończono skan biblioteki",
+ "quickScanStarted": "Szybkie skanowanie rozpoczęte",
+ "fullScanStarted": "Pełne skanowanie rozpoczęte",
+ "scanError": "Błąd podczas startu skanowania. Sprawdź logi"
},
"validation": {
"nameRequired": "Nazwa biblioteki jest wymagana",
@@ -604,7 +609,8 @@
"serverDown": "NIEDOSTĘPNY",
"scanType": "Typ",
"status": "Błąd Skanowania",
- "elapsedTime": "Upłynięty Czas"
+ "elapsedTime": "Upłynięty Czas",
+ "selectiveScan": "Selektywne"
},
"help": {
"title": "Skróty Klawiszowe Navidrome",
diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json
index e29996275..2d7ffd249 100644
--- a/resources/i18n/ru.json
+++ b/resources/i18n/ru.json
@@ -301,20 +301,25 @@
"actions": {
"scan": "Сканировать библиотеку",
"manageUsers": "Управление доступом пользователей",
- "viewDetails": "Просмотреть подробности"
+ "viewDetails": "Просмотреть подробности",
+ "quickScan": "Быстрое сканирование",
+ "fullScan": "Полное сканирование"
},
"notifications": {
"created": "Библиотека успешно создана",
"updated": "Библиотека успешно обновлена",
"deleted": "Библиотека успешно удалена",
"scanStarted": "Сканирование библиотеки начато",
- "scanCompleted": "Сканирование библиотеки закончено"
+ "scanCompleted": "Сканирование библиотеки закончено",
+ "quickScanStarted": "Быстрое сканирование началось",
+ "fullScanStarted": "Началось полное сканирование",
+ "scanError": "Ошибка при запуске сканирования. Проверьте логи"
},
"validation": {
"nameRequired": "Имя библиотеки обязательно",
"pathRequired": "Путь к библиотеке обязателен",
"pathNotDirectory": "Путь к библиотеке должен быть директорией",
- "pathNotFound": "Путь к библиотеке не найдено",
+ "pathNotFound": "Путь к библиотеке не найден",
"pathNotAccessible": "Путь к библиотеке недоступен",
"pathInvalid": "Неверный путь к библиотеке"
},
@@ -604,7 +609,8 @@
"serverDown": "Оффлайн",
"scanType": "Тип",
"status": "Ошибка сканирования",
- "elapsedTime": "Прошедшее время"
+ "elapsedTime": "Прошедшее время",
+ "selectiveScan": "Избирательный"
},
"help": {
"title": "Горячие клавиши Navidrome",
diff --git a/resources/i18n/sv.json b/resources/i18n/sv.json
index 521f997a8..30bf89ec4 100644
--- a/resources/i18n/sv.json
+++ b/resources/i18n/sv.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Scanna bibliotek",
"manageUsers": "Hantera användaråtkomst",
- "viewDetails": "Se detaljer"
+ "viewDetails": "Se detaljer",
+ "quickScan": "Snabbscan",
+ "fullScan": "Komplett scan"
},
"notifications": {
"created": "Biblioteket har skapats",
"updated": "Biblioteket har uppdaterats",
"deleted": "Biblioteket har raderats",
"scanStarted": "Biblioteksscan startad",
- "scanCompleted": "Biblioteksscan avslutad"
+ "scanCompleted": "Biblioteksscan avslutad",
+ "quickScanStarted": "Snabbscan startad",
+ "fullScanStarted": "Komplett scan startad",
+ "scanError": "Fel vid start av scan. Se loggarna"
},
"validation": {
"nameRequired": "Biblioteksnamn krävs",
@@ -604,7 +609,8 @@
"serverDown": "OFFLINE",
"scanType": "Typ",
"status": "Fel vid scanning",
- "elapsedTime": "Spelad tid"
+ "elapsedTime": "Spelad tid",
+ "selectiveScan": "Urval"
},
"help": {
"title": "Navidrome kortkommandon",
diff --git a/resources/i18n/th.json b/resources/i18n/th.json
index 65d51860f..833a68ab9 100644
--- a/resources/i18n/th.json
+++ b/resources/i18n/th.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "สแกนห้องสมุด",
"manageUsers": "ตั้งค่าการเข้าถึง",
- "viewDetails": "ดูรายละเอียด"
+ "viewDetails": "ดูรายละเอียด",
+ "quickScan": "สแกนแบบเร็ว",
+ "fullScan": "สแกนแบบเต็ม"
},
"notifications": {
"created": "สร้างห้องสมุดเรียบร้อย",
"updated": "อัพเดทห้องสมุดเรียบร้อย",
"deleted": "ลบห้องสมุดเพลงเรียบร้อยแล้ว",
"scanStarted": "เริ่มสแกนห้องสมุด",
- "scanCompleted": "สแกนห้องสมุดเสร็จแล้ว"
+ "scanCompleted": "สแกนห้องสมุดเสร็จแล้ว",
+ "quickScanStarted": "เริ่มสแกนแบบเร็ว",
+ "fullScanStarted": "เริ่มสแกนแบบเต็ม",
+ "scanError": "การเริ่มสแกนผิดพลาด ดูในบันทึก"
},
"validation": {
"nameRequired": "ต้องใส่ชื่อห้องสมุดเพลง",
@@ -604,7 +609,8 @@
"serverDown": "ออฟไลน์",
"scanType": "ประเภท",
"status": "สแกนผิดพลาด",
- "elapsedTime": "เวลาที่ใช้"
+ "elapsedTime": "เวลาที่ใช้",
+ "selectiveScan": "เลือก"
},
"help": {
"title": "คีย์ลัด Navidrome",
diff --git a/resources/i18n/uk.json b/resources/i18n/uk.json
index c500a7457..2c74c890a 100644
--- a/resources/i18n/uk.json
+++ b/resources/i18n/uk.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Сканувати бібліотеку",
"manageUsers": "Керування доступом користувачів",
- "viewDetails": "Переглянути подробиці"
+ "viewDetails": "Переглянути подробиці",
+ "quickScan": "Швидке сканування",
+ "fullScan": "Повне сканування"
},
"notifications": {
"created": "Бібліотеку успішно створено",
"updated": "Бібліотеку успішно оновлено",
"deleted": "Бібліотеку успішно видалено",
"scanStarted": "Сканування бібліотеки розпочато",
- "scanCompleted": "Сканування бібліотеки закінчено"
+ "scanCompleted": "Сканування бібліотеки закінчено",
+ "quickScanStarted": "Швидке сканування виконується",
+ "fullScanStarted": "Повне сканування виконується",
+ "scanError": "Помилка при виконанні сканування. Перевірте лоґи"
},
"validation": {
"nameRequired": "Ім'я бібліотеки обов'язкове",
@@ -604,7 +609,8 @@
"serverDown": "Оффлайн",
"scanType": "Тип",
"status": "Помилка сканування",
- "elapsedTime": "Пройдений час"
+ "elapsedTime": "Пройдений час",
+ "selectiveScan": "Вибірковий"
},
"help": {
"title": "Гарячі клавіші Navidrome",
From 917726c166739e093853095aac4d2ce208053fc3 Mon Sep 17 00:00:00 2001
From: crazygolem
Date: Tue, 2 Dec 2025 18:01:48 +0100
Subject: [PATCH 089/102] feat: rename "reverse proxy authentication" to
"external authentication" (#4418)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* Rename external auth options
ReverseProxyWhitelist was regularly confusing users that enabled it for
non-authenticating reverse proxy setups.
The new option name makes it clear that it's related to authentication, not
just reverse proxies.
* small refactor
Signed-off-by: Deluan
* add test
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
Co-authored-by: Deluan Quintão
---
conf/configuration.go | 25 +++++++++++++++++++++----
conf/configuration_test.go | 3 +++
conf/testdata/cfg.ini | 1 +
conf/testdata/cfg.json | 1 +
conf/testdata/cfg.toml | 1 +
conf/testdata/cfg.yaml | 1 +
core/metrics/insights.go | 2 +-
log/log.go | 4 ++--
server/auth.go | 18 +++++++++---------
server/auth_test.go | 10 +++++-----
server/middlewares.go | 2 +-
server/subsonic/middlewares.go | 2 +-
server/subsonic/middlewares_test.go | 8 ++++----
13 files changed, 51 insertions(+), 27 deletions(-)
diff --git a/conf/configuration.go b/conf/configuration.go
index f8e4a8084..77e9c94a5 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -87,8 +87,7 @@ type configOptions struct {
AuthRequestLimit int
AuthWindowLength time.Duration
PasswordEncryptionKey string
- ReverseProxyUserHeader string
- ReverseProxyWhitelist string
+ ExtAuth extAuthOptions
Plugins pluginsOptions
PluginConfig map[string]map[string]string
HTTPSecurityHeaders secureOptions `json:",omitzero"`
@@ -230,6 +229,11 @@ type pluginsOptions struct {
CacheSize string
}
+type extAuthOptions struct {
+ TrustedSources string
+ UserHeader string
+}
+
var (
Server = &configOptions{}
hooks []func()
@@ -248,6 +252,10 @@ func LoadFromFile(confFile string) {
func Load(noConfigDump bool) {
parseIniFileConfiguration()
+ // Map deprecated options to their new names for backwards compatibility
+ mapDeprecatedOption("ReverseProxyWhitelist", "ExtAuth.TrustedSources")
+ mapDeprecatedOption("ReverseProxyUserHeader", "ExtAuth.UserHeader")
+
err := viper.Unmarshal(&Server)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, "FATAL: Error parsing config:", err)
@@ -351,6 +359,7 @@ func Load(noConfigDump bool) {
logDeprecatedOptions("Scanner.GenreSeparators")
logDeprecatedOptions("Scanner.GroupAlbumReleases")
logDeprecatedOptions("DevEnableBufferedScrobble") // Deprecated: Buffered scrobbling is now always enabled and this option is ignored
+ logDeprecatedOptions("ReverseProxyWhitelist", "ReverseProxyUserHeader")
// Call init hooks
for _, hook := range hooks {
@@ -370,6 +379,14 @@ func logDeprecatedOptions(options ...string) {
}
}
+// mapDeprecatedOption is used to provide backwards compatibility for deprecated options. It should be called after
+// the config has been read by viper, but before unmarshalling it into the Config struct.
+func mapDeprecatedOption(legacyName, newName string) {
+ if viper.IsSet(legacyName) {
+ viper.Set(newName, viper.Get(legacyName))
+ }
+}
+
// parseIniFileConfiguration is used to parse the config file when it is in INI format. For INI files, it
// would require a nested structure, so instead we unmarshal it to a map and then merge the nested [default]
// section into the root level.
@@ -538,8 +555,8 @@ func setViperDefaults() {
viper.SetDefault("authrequestlimit", 5)
viper.SetDefault("authwindowlength", 20*time.Second)
viper.SetDefault("passwordencryptionkey", "")
- viper.SetDefault("reverseproxyuserheader", "Remote-User")
- viper.SetDefault("reverseproxywhitelist", "")
+ viper.SetDefault("extauth.userheader", "Remote-User")
+ viper.SetDefault("extauth.trustedsources", "")
viper.SetDefault("prometheus.enabled", false)
viper.SetDefault("prometheus.metricspath", consts.PrometheusDefaultPath)
viper.SetDefault("prometheus.password", "")
diff --git a/conf/configuration_test.go b/conf/configuration_test.go
index 15d12795e..06973456f 100644
--- a/conf/configuration_test.go
+++ b/conf/configuration_test.go
@@ -41,6 +41,9 @@ var _ = Describe("Configuration", func() {
Expect(conf.Server.Tags["custom"].Aliases).To(Equal([]string{format, "test"}))
Expect(conf.Server.Tags["artist"].Split).To(Equal([]string{";"}))
+ // Check deprecated option mapping
+ Expect(conf.Server.ExtAuth.UserHeader).To(Equal("X-Auth-User"))
+
// The config file used should be the one we created
Expect(conf.Server.ConfigFile).To(Equal(filename))
},
diff --git a/conf/testdata/cfg.ini b/conf/testdata/cfg.ini
index e0062ff0e..cc8b2a4a5 100644
--- a/conf/testdata/cfg.ini
+++ b/conf/testdata/cfg.ini
@@ -1,6 +1,7 @@
[default]
MusicFolder = /ini/music
UIWelcomeMessage = 'Welcome ini' ; Just a comment to test the LoadOptions
+ReverseProxyUserHeader = 'X-Auth-User'
[Tags]
Custom.Aliases = ini,test
diff --git a/conf/testdata/cfg.json b/conf/testdata/cfg.json
index 127103a53..28fb039d2 100644
--- a/conf/testdata/cfg.json
+++ b/conf/testdata/cfg.json
@@ -1,6 +1,7 @@
{
"musicFolder": "/json/music",
"uiWelcomeMessage": "Welcome json",
+ "reverseProxyUserHeader": "X-Auth-User",
"Tags": {
"artist": {
"split": ";"
diff --git a/conf/testdata/cfg.toml b/conf/testdata/cfg.toml
index d94d786e2..589e2a100 100644
--- a/conf/testdata/cfg.toml
+++ b/conf/testdata/cfg.toml
@@ -1,5 +1,6 @@
musicFolder = "/toml/music"
uiWelcomeMessage = "Welcome toml"
+ReverseProxyUserHeader = "X-Auth-User"
Tags.artist.Split = ';'
diff --git a/conf/testdata/cfg.yaml b/conf/testdata/cfg.yaml
index 66e12c4eb..e44d2ebbb 100644
--- a/conf/testdata/cfg.yaml
+++ b/conf/testdata/cfg.yaml
@@ -1,5 +1,6 @@
musicFolder: "/yaml/music"
uiWelcomeMessage: "Welcome yaml"
+reverseProxyUserHeader: "X-Auth-User"
Tags:
artist:
split: [";"]
diff --git a/core/metrics/insights.go b/core/metrics/insights.go
index 820e6d7b6..411bc9ac1 100644
--- a/core/metrics/insights.go
+++ b/core/metrics/insights.go
@@ -223,7 +223,7 @@ var staticData = sync.OnceValue(func() insights.Data {
data.Config.ScanSchedule = conf.Server.Scanner.Schedule
data.Config.ScanWatcherWait = uint64(math.Trunc(conf.Server.Scanner.WatcherWait.Seconds()))
data.Config.ScanOnStartup = conf.Server.Scanner.ScanOnStartup
- data.Config.ReverseProxyConfigured = conf.Server.ReverseProxyWhitelist != ""
+ data.Config.ReverseProxyConfigured = conf.Server.ExtAuth.TrustedSources != ""
data.Config.HasCustomPID = conf.Server.PID.Track != "" || conf.Server.PID.Album != ""
data.Config.HasCustomTags = len(conf.Server.Tags) > 0
diff --git a/log/log.go b/log/log.go
index 801fd7214..24f3dff6e 100644
--- a/log/log.go
+++ b/log/log.go
@@ -29,8 +29,8 @@ var redacted = &Hook{
"(Secret:\")[\\w]*",
"(Spotify.*ID:\")[\\w]*",
"(PasswordEncryptionKey:[\\s]*\")[^\"]*",
- "(ReverseProxyUserHeader:[\\s]*\")[^\"]*",
- "(ReverseProxyWhitelist:[\\s]*\")[^\"]*",
+ "(UserHeader:[\\s]*\")[^\"]*",
+ "(TrustedSources:[\\s]*\")[^\"]*",
"(MetricsPath:[\\s]*\")[^\"]*",
"(DevAutoCreateAdminPassword:[\\s]*\")[^\"]*",
"(DevAutoLoginUsername:[\\s]*\")[^\"]*",
diff --git a/server/auth.go b/server/auth.go
index ed43974dd..8588549ab 100644
--- a/server/auth.go
+++ b/server/auth.go
@@ -193,24 +193,24 @@ func UsernameFromToken(r *http.Request) string {
return token.Subject()
}
-func UsernameFromReverseProxyHeader(r *http.Request) string {
- if conf.Server.ReverseProxyWhitelist == "" {
+func UsernameFromExtAuthHeader(r *http.Request) string {
+ if conf.Server.ExtAuth.TrustedSources == "" {
return ""
}
reverseProxyIp, ok := request.ReverseProxyIpFrom(r.Context())
if !ok {
- log.Error("ReverseProxyWhitelist enabled but no proxy IP found in request context. Please report this error.")
+ log.Error("ExtAuth enabled but no proxy IP found in request context. Please report this error.")
return ""
}
- if !validateIPAgainstList(reverseProxyIp, conf.Server.ReverseProxyWhitelist) {
- log.Warn(r.Context(), "IP is not whitelisted for reverse proxy login", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
+ if !validateIPAgainstList(reverseProxyIp, conf.Server.ExtAuth.TrustedSources) {
+ log.Warn(r.Context(), "IP is not whitelisted for external authentication", "proxy-ip", reverseProxyIp, "client-ip", r.RemoteAddr)
return ""
}
- username := r.Header.Get(conf.Server.ReverseProxyUserHeader)
+ username := r.Header.Get(conf.Server.ExtAuth.UserHeader)
if username == "" {
return ""
}
- log.Trace(r, "Found username in ReverseProxyUserHeader", "username", username)
+ log.Trace(r, "Found username in ExtAuth.UserHeader", "username", username)
return username
}
@@ -256,7 +256,7 @@ func authenticateRequest(ds model.DataStore, r *http.Request, findUsernameFns ..
func Authenticator(ds model.DataStore) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromReverseProxyHeader)
+ ctx, err := authenticateRequest(ds, r, UsernameFromConfig, UsernameFromToken, UsernameFromExtAuthHeader)
if err != nil {
_ = rest.RespondWithError(w, http.StatusUnauthorized, "Not authenticated")
return
@@ -291,7 +291,7 @@ func JWTRefresher(next http.Handler) http.Handler {
func handleLoginFromHeaders(ds model.DataStore, r *http.Request) map[string]interface{} {
username := UsernameFromConfig(r)
if username == "" {
- username = UsernameFromReverseProxyHeader(r)
+ username = UsernameFromExtAuthHeader(r)
if username == "" {
return nil
}
diff --git a/server/auth_test.go b/server/auth_test.go
index 06ca2ea39..633299096 100644
--- a/server/auth_test.go
+++ b/server/auth_test.go
@@ -80,7 +80,7 @@ var _ = Describe("Auth", func() {
req.Header.Add("Remote-User", "janedoe")
resp = httptest.NewRecorder()
conf.Server.UILoginBackgroundURL = ""
- conf.Server.ReverseProxyWhitelist = "192.168.0.0/16,2001:4860:4860::/48"
+ conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16,2001:4860:4860::/48"
})
It("sets auth data if IPv4 matches whitelist", func() {
@@ -155,7 +155,7 @@ var _ = Describe("Auth", func() {
It("does not set auth data when listening on unix socket without whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test"
- conf.Server.ReverseProxyWhitelist = ""
+ conf.Server.ExtAuth.TrustedSources = ""
// No ReverseProxyIp in request context
serveIndex(ds, fs, nil)(resp, req)
@@ -176,7 +176,7 @@ var _ = Describe("Auth", func() {
It("sets auth data when listening on unix socket with correct whitelist", func() {
conf.Server.Address = "unix:/tmp/navidrome-test"
- conf.Server.ReverseProxyWhitelist = conf.Server.ReverseProxyWhitelist + ",@"
+ conf.Server.ExtAuth.TrustedSources = conf.Server.ExtAuth.TrustedSources + ",@"
req = req.WithContext(request.WithReverseProxyIp(req.Context(), "@"))
serveIndex(ds, fs, nil)(resp, req)
@@ -302,8 +302,8 @@ var _ = Describe("Auth", func() {
ds = &tests.MockDataStore{}
req = httptest.NewRequest("GET", "/", nil)
req = req.WithContext(request.WithReverseProxyIp(req.Context(), trustedIP))
- conf.Server.ReverseProxyWhitelist = "192.168.0.0/16"
- conf.Server.ReverseProxyUserHeader = "Remote-User"
+ conf.Server.ExtAuth.TrustedSources = "192.168.0.0/16"
+ conf.Server.ExtAuth.UserHeader = "Remote-User"
})
It("makes the first user an admin", func() {
diff --git a/server/middlewares.go b/server/middlewares.go
index 2afe09a5a..21f897931 100644
--- a/server/middlewares.go
+++ b/server/middlewares.go
@@ -168,7 +168,7 @@ func clientUniqueIDMiddleware(next http.Handler) http.Handler {
// realIPMiddleware applies middleware.RealIP, and additionally saves the request's original RemoteAddr to the request's
// context if navidrome is behind a trusted reverse proxy.
func realIPMiddleware(next http.Handler) http.Handler {
- if conf.Server.ReverseProxyWhitelist != "" {
+ if conf.Server.ExtAuth.TrustedSources != "" {
return chi.Chain(
reqToCtx(request.ReverseProxyIp, func(r *http.Request) any { return r.RemoteAddr }),
middleware.RealIP,
diff --git a/server/subsonic/middlewares.go b/server/subsonic/middlewares.go
index af1ba448f..d984bac42 100644
--- a/server/subsonic/middlewares.go
+++ b/server/subsonic/middlewares.go
@@ -56,7 +56,7 @@ func fromInternalOrProxyAuth(r *http.Request) (string, bool) {
return username, true
}
- return server.UsernameFromReverseProxyHeader(r), false
+ return server.UsernameFromExtAuthHeader(r), false
}
func checkRequiredParameters(next http.Handler) http.Handler {
diff --git a/server/subsonic/middlewares_test.go b/server/subsonic/middlewares_test.go
index a30d5b3af..aba14a0aa 100644
--- a/server/subsonic/middlewares_test.go
+++ b/server/subsonic/middlewares_test.go
@@ -95,8 +95,8 @@ var _ = Describe("Middlewares", func() {
})
It("passes when all required params are available (reverse-proxy case)", func() {
- conf.Server.ReverseProxyWhitelist = "127.0.0.234/32"
- conf.Server.ReverseProxyUserHeader = "Remote-User"
+ conf.Server.ExtAuth.TrustedSources = "127.0.0.234/32"
+ conf.Server.ExtAuth.UserHeader = "Remote-User"
r := newGetRequest("v=1.15", "c=test")
r.Header.Add("Remote-User", "user")
@@ -254,8 +254,8 @@ var _ = Describe("Middlewares", func() {
When("using reverse proxy authentication", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
- conf.Server.ReverseProxyWhitelist = "192.168.1.1/24"
- conf.Server.ReverseProxyUserHeader = "Remote-User"
+ conf.Server.ExtAuth.TrustedSources = "192.168.1.1/24"
+ conf.Server.ExtAuth.UserHeader = "Remote-User"
})
It("passes authentication with correct IP and header", func() {
From 13f6eb9a117e737747734bdc1a747ad972835c04 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Tue, 2 Dec 2025 13:08:30 -0500
Subject: [PATCH 090/102] feat: make Unicode handling in external API calls
configurable (#4277)
* feat: make Unicode handling in external API calls configurable
- Add DevPreserveUnicodeInExternalCalls config option (default: false)
- Refactor external provider to use NameForExternal() method on auxArtist
- Remove redundant Name field from auxArtist struct
- Update all external API calls (image, URL, biography, similar, top songs, MBID) to use configurable Unicode handling
- Add comprehensive tests for both Unicode-preserving and normalized behaviors
- Refactor tests to use constants and improved structure with BeforeEach blocks
Fixes issue where Spotify integration failed to find artist images for artists with Unicode characters (e.g., en dash) in their names.
Signed-off-by: Deluan
* address comments
Signed-off-by: Deluan
* avoid calling str.Clean multiple times
Signed-off-by: Deluan
* refactor: apply Unicode handling pattern to auxAlbum
Extended the configurable Unicode handling to album names, matching the
pattern already implemented for artist names. This ensures consistent behavior
when DevPreserveUnicodeInExternalCalls is enabled for both artist and album
external API calls.
Changes:
- Removed Name field from auxAlbum struct, added Name() method with Unicode logic
- Updated getAlbum, UpdateAlbumInfo, populateAlbumInfo, and AlbumImage functions
- Added comprehensive tests for album Unicode handling (preserve and normalize)
- Fixed typo in artist image test description
---------
Signed-off-by: Deluan
---
conf/configuration.go | 54 ++++++-------
core/external/provider.go | 89 +++++++++++++---------
core/external/provider_albumimage_test.go | 63 +++++++++++++++
core/external/provider_artistimage_test.go | 61 +++++++++++++++
4 files changed, 205 insertions(+), 62 deletions(-)
diff --git a/conf/configuration.go b/conf/configuration.go
index 77e9c94a5..9d7b1a3d0 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -106,32 +106,33 @@ type configOptions struct {
Agents string
// DevFlags. These are used to enable/disable debugging and incomplete features
- DevLogLevels map[string]string `json:",omitempty"`
- DevLogSourceLine bool
- DevEnableProfiler bool
- DevAutoCreateAdminPassword string
- DevAutoLoginUsername string
- DevActivityPanel bool
- DevActivityPanelUpdateRate time.Duration
- DevSidebarPlaylists bool
- DevShowArtistPage bool
- DevUIShowConfig bool
- DevNewEventStream bool
- DevOffsetOptimize int
- DevArtworkMaxRequests int
- DevArtworkThrottleBacklogLimit int
- DevArtworkThrottleBacklogTimeout time.Duration
- DevArtistInfoTimeToLive time.Duration
- DevAlbumInfoTimeToLive time.Duration
- DevExternalScanner bool
- DevScannerThreads uint
- DevSelectiveWatcher bool
- DevInsightsInitialDelay time.Duration
- DevEnablePlayerInsights bool
- DevEnablePluginsInsights bool
- DevPluginCompilationTimeout time.Duration
- DevExternalArtistFetchMultiplier float64
- DevOptimizeDB bool
+ DevLogLevels map[string]string `json:",omitempty"`
+ DevLogSourceLine bool
+ DevEnableProfiler bool
+ DevAutoCreateAdminPassword string
+ DevAutoLoginUsername string
+ DevActivityPanel bool
+ DevActivityPanelUpdateRate time.Duration
+ DevSidebarPlaylists bool
+ DevShowArtistPage bool
+ DevUIShowConfig bool
+ DevNewEventStream bool
+ DevOffsetOptimize int
+ DevArtworkMaxRequests int
+ DevArtworkThrottleBacklogLimit int
+ DevArtworkThrottleBacklogTimeout time.Duration
+ DevArtistInfoTimeToLive time.Duration
+ DevAlbumInfoTimeToLive time.Duration
+ DevExternalScanner bool
+ DevScannerThreads uint
+ DevSelectiveWatcher bool
+ DevInsightsInitialDelay time.Duration
+ DevEnablePlayerInsights bool
+ DevEnablePluginsInsights bool
+ DevPluginCompilationTimeout time.Duration
+ DevExternalArtistFetchMultiplier float64
+ DevOptimizeDB bool
+ DevPreserveUnicodeInExternalCalls bool
}
type scannerOptions struct {
@@ -630,6 +631,7 @@ func setViperDefaults() {
viper.SetDefault("devplugincompilationtimeout", time.Minute)
viper.SetDefault("devexternalartistfetchmultiplier", 1.5)
viper.SetDefault("devoptimizedb", true)
+ viper.SetDefault("devpreserveunicodeinexternalcalls", false)
}
func init() {
diff --git a/core/external/provider.go b/core/external/provider.go
index 8e9a458c1..413c7e0c4 100644
--- a/core/external/provider.go
+++ b/core/external/provider.go
@@ -51,12 +51,28 @@ type provider struct {
type auxAlbum struct {
model.Album
- Name string
+}
+
+// Name returns the appropriate album name for external API calls
+// based on the DevPreserveUnicodeInExternalCalls configuration option
+func (a *auxAlbum) Name() string {
+ if conf.Server.DevPreserveUnicodeInExternalCalls {
+ return a.Album.Name
+ }
+ return str.Clear(a.Album.Name)
}
type auxArtist struct {
model.Artist
- Name string
+}
+
+// Name returns the appropriate artist name for external API calls
+// based on the DevPreserveUnicodeInExternalCalls configuration option
+func (a *auxArtist) Name() string {
+ if conf.Server.DevPreserveUnicodeInExternalCalls {
+ return a.Artist.Name
+ }
+ return str.Clear(a.Artist.Name)
}
type Agents interface {
@@ -88,7 +104,6 @@ func (e *provider) getAlbum(ctx context.Context, id string) (auxAlbum, error) {
switch v := entity.(type) {
case *model.Album:
album.Album = *v
- album.Name = str.Clear(v.Name)
case *model.MediaFile:
return e.getAlbum(ctx, v.AlbumID)
default:
@@ -106,8 +121,9 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
}
updatedAt := V(album.ExternalInfoUpdatedAt)
+ albumName := album.Name()
if updatedAt.IsZero() {
- log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", album.Name)
+ log.Debug(ctx, "AlbumInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", albumName)
album, err = e.populateAlbumInfo(ctx, album)
if err != nil {
return nil, err
@@ -116,7 +132,7 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
// If info is expired, trigger a populateAlbumInfo in the background
if time.Since(updatedAt) > conf.Server.DevAlbumInfoTimeToLive {
- log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", album.Name)
+ log.Debug("Found expired cached AlbumInfo, refreshing in the background", "updatedAt", album.ExternalInfoUpdatedAt, "name", albumName)
e.albumQueue.enqueue(&album)
}
@@ -125,12 +141,13 @@ func (e *provider) UpdateAlbumInfo(ctx context.Context, id string) (*model.Album
func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAlbum, error) {
start := time.Now()
- info, err := e.ag.GetAlbumInfo(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
+ albumName := album.Name()
+ info, err := e.ag.GetAlbumInfo(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
if errors.Is(err, agents.ErrNotFound) {
return album, nil
}
if err != nil {
- log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", album.Name, "artist", album.AlbumArtist,
+ log.Error("Error refreshing AlbumInfo", "id", album.ID, "name", albumName, "artist", album.AlbumArtist,
"elapsed", time.Since(start), err)
return album, err
}
@@ -142,7 +159,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
album.Description = info.Description
}
- images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
+ images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
if err == nil && len(images) > 0 {
sort.Slice(images, func(i, j int) bool {
return images[i].Size > images[j].Size
@@ -161,7 +178,7 @@ func (e *provider) populateAlbumInfo(ctx context.Context, album auxAlbum) (auxAl
err = e.ds.Album(ctx).UpdateExternalInfo(&album.Album)
if err != nil {
- log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", album.Name,
+ log.Error(ctx, "Error trying to update album external information", "id", album.ID, "name", albumName,
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "AlbumInfo collected", "album", album, "elapsed", time.Since(start))
@@ -181,7 +198,6 @@ func (e *provider) getArtist(ctx context.Context, id string) (auxArtist, error)
switch v := entity.(type) {
case *model.Artist:
artist.Artist = *v
- artist.Name = str.Clear(v.Name)
case *model.MediaFile:
return e.getArtist(ctx, v.ArtistID)
case *model.Album:
@@ -210,8 +226,9 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
// If we don't have any info, retrieves it now
updatedAt := V(artist.ExternalInfoUpdatedAt)
+ artistName := artist.Name()
if updatedAt.IsZero() {
- log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artist.Name)
+ log.Debug(ctx, "ArtistInfo not cached. Retrieving it now", "updatedAt", updatedAt, "id", id, "name", artistName)
artist, err = e.populateArtistInfo(ctx, artist)
if err != nil {
return auxArtist{}, err
@@ -220,7 +237,7 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
// If info is expired, trigger a populateArtistInfo in the background
if time.Since(updatedAt) > conf.Server.DevArtistInfoTimeToLive {
- log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artist.Name)
+ log.Debug("Found expired cached ArtistInfo, refreshing in the background", "updatedAt", updatedAt, "name", artistName)
e.artistQueue.enqueue(&artist)
}
return artist, nil
@@ -229,8 +246,9 @@ func (e *provider) refreshArtistInfo(ctx context.Context, id string) (auxArtist,
func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (auxArtist, error) {
start := time.Now()
// Get MBID first, if it is not yet available
+ artistName := artist.Name()
if artist.MbzArtistID == "" {
- mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artist.Name)
+ mbid, err := e.ag.GetArtistMBID(ctx, artist.ID, artistName)
if mbid != "" && err == nil {
artist.MbzArtistID = mbid
}
@@ -246,14 +264,14 @@ func (e *provider) populateArtistInfo(ctx context.Context, artist auxArtist) (au
_ = g.Wait()
if utils.IsCtxDone(ctx) {
- log.Warn(ctx, "ArtistInfo update canceled", "elapsed", "id", artist.ID, "name", artist.Name, time.Since(start), ctx.Err())
+ log.Warn(ctx, "ArtistInfo update canceled", "id", artist.ID, "name", artistName, "elapsed", time.Since(start), ctx.Err())
return artist, ctx.Err()
}
artist.ExternalInfoUpdatedAt = P(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", artist.Name,
+ log.Error(ctx, "Error trying to update artist external information", "id", artist.ID, "name", artistName,
"elapsed", time.Since(start), err)
} else {
log.Trace(ctx, "ArtistInfo collected", "artist", artist, "elapsed", time.Since(start))
@@ -281,7 +299,7 @@ func (e *provider) ArtistRadio(ctx context.Context, id string, count int) (model
}
topCount := max(count, 20)
- topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Name: a.Name, Artist: a}, topCount)
+ topSongs, err := e.getMatchingTopSongs(ctx, e.ag, &auxArtist{Artist: a}, topCount)
if err != nil {
log.Warn(ctx, "Error getting artist's top songs", "artist", a.Name, err)
return nil
@@ -344,22 +362,23 @@ func (e *provider) AlbumImage(ctx context.Context, id string) (*url.URL, error)
return nil, err
}
- images, err := e.ag.GetAlbumImages(ctx, album.Name, album.AlbumArtist, album.MbzAlbumID)
+ albumName := album.Name()
+ images, err := e.ag.GetAlbumImages(ctx, albumName, album.AlbumArtist, album.MbzAlbumID)
if err != nil {
switch {
case errors.Is(err, agents.ErrNotFound):
- log.Trace(ctx, "Album not found in agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
+ log.Trace(ctx, "Album not found in agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
return nil, model.ErrNotFound
case errors.Is(err, context.Canceled):
log.Debug(ctx, "GetAlbumImages call canceled", err)
default:
- log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", album.Name, "artist", album.AlbumArtist, err)
+ log.Warn(ctx, "Error getting album images from agent", "albumID", id, "name", albumName, "artist", album.AlbumArtist, err)
}
return nil, err
}
if len(images) == 0 {
- log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", album.Name, "artist", album.AlbumArtist)
+ log.Warn(ctx, "Agent returned no images without error", "albumID", id, "name", albumName, "artist", album.AlbumArtist)
return nil, model.ErrNotFound
}
@@ -401,9 +420,10 @@ func (e *provider) TopSongs(ctx context.Context, artistName string, count int) (
}
func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistTopSongsRetriever, artist *auxArtist, count int) (model.MediaFiles, error) {
- songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artist.Name, artist.MbzArtistID, count)
+ artistName := artist.Name()
+ songs, err := agent.GetArtistTopSongs(ctx, artist.ID, artistName, artist.MbzArtistID, count)
if err != nil {
- return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artist.Name, err)
+ return nil, fmt.Errorf("failed to get top songs for artist %s: %w", artistName, err)
}
mbidMatches, err := e.loadTracksByMBID(ctx, songs)
@@ -415,13 +435,13 @@ func (e *provider) getMatchingTopSongs(ctx context.Context, agent agents.ArtistT
return nil, fmt.Errorf("failed to load tracks by title: %w", err)
}
- log.Trace(ctx, "Top Songs loaded", "name", artist.Name, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
+ log.Trace(ctx, "Top Songs loaded", "name", artistName, "numSongs", len(songs), "numMBIDMatches", len(mbidMatches), "numTitleMatches", len(titleMatches))
mfs := e.selectTopSongs(songs, mbidMatches, titleMatches, count)
if len(mfs) == 0 {
- log.Debug(ctx, "No matching top songs found", "name", artist.Name)
+ log.Debug(ctx, "No matching top songs found", "name", artistName)
} else {
- log.Debug(ctx, "Found matching top songs", "name", artist.Name, "numSongs", len(mfs))
+ log.Debug(ctx, "Found matching top songs", "name", artistName, "numSongs", len(mfs))
}
return mfs, nil
@@ -518,7 +538,7 @@ func (e *provider) selectTopSongs(songs []agents.Song, byMBID, byTitle map[strin
}
func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriever, artist *auxArtist) {
- artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name, artist.MbzArtistID)
+ artisURL, err := agent.GetArtistURL(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return
}
@@ -526,7 +546,7 @@ func (e *provider) callGetURL(ctx context.Context, agent agents.ArtistURLRetriev
}
func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiographyRetriever, artist *auxArtist) {
- bio, err := agent.GetArtistBiography(ctx, artist.ID, str.Clear(artist.Name), artist.MbzArtistID)
+ bio, err := agent.GetArtistBiography(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return
}
@@ -536,7 +556,7 @@ func (e *provider) callGetBiography(ctx context.Context, agent agents.ArtistBiog
}
func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRetriever, artist *auxArtist) {
- images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name, artist.MbzArtistID)
+ images, err := agent.GetArtistImages(ctx, artist.ID, artist.Name(), artist.MbzArtistID)
if err != nil {
return
}
@@ -555,13 +575,14 @@ func (e *provider) callGetImage(ctx context.Context, agent agents.ArtistImageRet
func (e *provider) callGetSimilar(ctx context.Context, agent agents.ArtistSimilarRetriever, artist *auxArtist,
limit int, includeNotPresent bool) {
- similar, err := agent.GetSimilarArtists(ctx, artist.ID, artist.Name, artist.MbzArtistID, limit)
+ artistName := artist.Name()
+ similar, err := agent.GetSimilarArtists(ctx, artist.ID, artistName, artist.MbzArtistID, limit)
if len(similar) == 0 || err != nil {
return
}
start := time.Now()
sa, err := e.mapSimilarArtists(ctx, similar, limit, includeNotPresent)
- log.Debug(ctx, "Mapped Similar Artists", "artist", artist.Name, "numSimilar", len(sa), "elapsed", time.Since(start))
+ log.Debug(ctx, "Mapped Similar Artists", "artist", artistName, "numSimilar", len(sa), "elapsed", time.Since(start))
if err != nil {
return
}
@@ -635,11 +656,7 @@ func (e *provider) findArtistByName(ctx context.Context, artistName string) (*au
if len(artists) == 0 {
return nil, model.ErrNotFound
}
- artist := &auxArtist{
- Artist: artists[0],
- Name: str.Clear(artists[0].Name),
- }
- return artist, nil
+ return &auxArtist{Artist: artists[0]}, nil
}
func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int, includeNotPresent bool) error {
@@ -655,7 +672,7 @@ func (e *provider) loadSimilar(ctx context.Context, artist *auxArtist, count int
Filters: squirrel.Eq{"artist.id": ids},
})
if err != nil {
- log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name, err)
+ log.Error("Error loading similar artists", "id", artist.ID, "name", artist.Name(), err)
return err
}
diff --git a/core/external/provider_albumimage_test.go b/core/external/provider_albumimage_test.go
index 9b682462d..8a81b4f4d 100644
--- a/core/external/provider_albumimage_test.go
+++ b/core/external/provider_albumimage_test.go
@@ -260,6 +260,69 @@ var _ = Describe("Provider - AlbumImage", func() {
mockMediaFileRepo.AssertCalled(GinkgoT(), "Get", "not-found")
mockAlbumAgent.AssertNotCalled(GinkgoT(), "GetAlbumImages", mock.Anything, mock.Anything, mock.Anything)
})
+
+ Context("Unicode handling in album names", func() {
+ var albumWithEnDash *model.Album
+ var expectedURL *url.URL
+
+ const (
+ originalAlbumName = "Raising Hell–Deluxe" // Album name with en dash
+ normalizedAlbumName = "Raising Hell-Deluxe" // Normalized version with hyphen
+ )
+
+ BeforeEach(func() {
+ // Test with en dash (–) in album name
+ albumWithEnDash = &model.Album{ID: "album-endash", Name: originalAlbumName, AlbumArtistID: "artist-1"}
+ mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
+ mockAlbumRepo.Mock = mock.Mock{} // Reset default expectations
+ mockArtistRepo.On("Get", "album-endash").Return(nil, model.ErrNotFound).Once()
+ mockAlbumRepo.On("Get", "album-endash").Return(albumWithEnDash, nil).Once()
+
+ expectedURL, _ = url.Parse("http://example.com/album.jpg")
+
+ // Mock the album agent to return an image for the album
+ mockAlbumAgent.On("GetAlbumImages", ctx, mock.AnythingOfType("string"), "", "").
+ Return([]agents.ExternalImage{
+ {URL: "http://example.com/album.jpg", Size: 1000},
+ }, nil).Once()
+ })
+
+ When("DevPreserveUnicodeInExternalCalls is true", func() {
+ BeforeEach(func() {
+ conf.Server.DevPreserveUnicodeInExternalCalls = true
+ })
+
+ It("preserves Unicode characters in album names", func() {
+ // Act
+ imgURL, err := provider.AlbumImage(ctx, "album-endash")
+
+ // Assert
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgURL).To(Equal(expectedURL))
+ mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
+ // This is the key assertion: ensure the original Unicode name is used
+ mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, originalAlbumName, "", "")
+ })
+ })
+
+ When("DevPreserveUnicodeInExternalCalls is false", func() {
+ BeforeEach(func() {
+ conf.Server.DevPreserveUnicodeInExternalCalls = false
+ })
+
+ It("normalizes Unicode characters", func() {
+ // Act
+ imgURL, err := provider.AlbumImage(ctx, "album-endash")
+
+ // Assert
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgURL).To(Equal(expectedURL))
+ mockAlbumRepo.AssertCalled(GinkgoT(), "Get", "album-endash")
+ // This assertion ensures the normalized name is used (en dash → hyphen)
+ mockAlbumAgent.AssertCalled(GinkgoT(), "GetAlbumImages", ctx, normalizedAlbumName, "", "")
+ })
+ })
+ })
})
// mockAlbumInfoAgent implementation
diff --git a/core/external/provider_artistimage_test.go b/core/external/provider_artistimage_test.go
index 96341836a..11290bb66 100644
--- a/core/external/provider_artistimage_test.go
+++ b/core/external/provider_artistimage_test.go
@@ -265,6 +265,67 @@ var _ = Describe("Provider - ArtistImage", func() {
mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-1")
mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-1", "Artist One", "")
})
+
+ Context("Unicode handling in artist names", func() {
+ var artistWithEnDash *model.Artist
+ var expectedURL *url.URL
+
+ const (
+ originalArtistName = "Run–D.M.C." // Artist name with en dash
+ normalizedArtistName = "Run-D.M.C." // Normalized version with hyphen
+ )
+
+ BeforeEach(func() {
+ // Test with en dash (–) in artist name like "Run–D.M.C."
+ artistWithEnDash = &model.Artist{ID: "artist-endash", Name: originalArtistName}
+ mockArtistRepo.Mock = mock.Mock{} // Reset default expectations
+ mockArtistRepo.On("Get", "artist-endash").Return(artistWithEnDash, nil).Once()
+
+ expectedURL, _ = url.Parse("http://example.com/rundmc.jpg")
+
+ // Mock the image agent to return an image for the artist
+ mockImageAgent.On("GetArtistImages", ctx, "artist-endash", mock.AnythingOfType("string"), "").
+ Return([]agents.ExternalImage{
+ {URL: "http://example.com/rundmc.jpg", Size: 1000},
+ }, nil).Once()
+
+ })
+
+ When("DevPreserveUnicodeInExternalCalls is true", func() {
+ BeforeEach(func() {
+ conf.Server.DevPreserveUnicodeInExternalCalls = true
+ })
+ It("preserves Unicode characters in artist names", func() {
+ // Act
+ imgURL, err := provider.ArtistImage(ctx, "artist-endash")
+
+ // Assert
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgURL).To(Equal(expectedURL))
+ mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
+ // This is the key assertion: ensure the original Unicode name is used
+ mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", originalArtistName, "")
+ })
+ })
+
+ When("DevPreserveUnicodeInExternalCalls is false", func() {
+ BeforeEach(func() {
+ conf.Server.DevPreserveUnicodeInExternalCalls = false
+ })
+
+ It("normalizes Unicode characters", func() {
+ // Act
+ imgURL, err := provider.ArtistImage(ctx, "artist-endash")
+
+ // Assert
+ Expect(err).ToNot(HaveOccurred())
+ Expect(imgURL).To(Equal(expectedURL))
+ mockArtistRepo.AssertCalled(GinkgoT(), "Get", "artist-endash")
+ // This assertion ensures the normalized name is used (en dash → hyphen)
+ mockImageAgent.AssertCalled(GinkgoT(), "GetArtistImages", ctx, "artist-endash", normalizedArtistName, "")
+ })
+ })
+ })
})
// mockArtistImageAgent implementation using testify/mock
From 142a3136d4eb870ead577da193ac9ec54d1a26f4 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Tue, 2 Dec 2025 14:24:15 -0500
Subject: [PATCH 091/102] fix: log warning when no config file is found
Always log the configuration source at startup: shows an INFO message with the
config file path when found, or a WARN message explaining how to specify one
when not found. This helps users understand why CLI commands may fail when
run outside of systemd (where --configfile is typically specified).
Closes #4758
---
conf/configuration.go | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/conf/configuration.go b/conf/configuration.go
index 9d7b1a3d0..58785952c 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -340,9 +340,16 @@ func Load(noConfigDump bool) {
Server.BaseScheme = u.Scheme
}
+ // Log configuration source
+ if Server.ConfigFile != "" {
+ log.Info("Loaded configuration", "file", Server.ConfigFile)
+ } else {
+ log.Warn("No configuration file found. Using default values. To specify a config file, use the --configfile flag or set the ND_CONFIGFILE environment variable.")
+ }
+
// Print current configuration if log level is Debug
if log.IsGreaterOrEqualTo(log.LevelDebug) && !noConfigDump {
- prettyConf := pretty.Sprintf("Loaded configuration from '%s': %# v", Server.ConfigFile, Server)
+ prettyConf := pretty.Sprintf("Configuration: %# v", Server)
if Server.EnableLogRedacting {
prettyConf = log.Redact(prettyConf)
}
From 9f0d3f3cf4dc4222415c32b9580e1ad2c8369bd1 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Tue, 2 Dec 2025 16:14:01 -0500
Subject: [PATCH 092/102] fix(ui): sync body background color with theme
Set document.body.style.backgroundColor to match the current theme's background
color whenever the theme changes. This fixes the white background that appeared
during pull-to-refresh gestures on mobile or overscroll on desktop, where the
browser reveals the area behind the app content.
The background color is determined by the theme's palette.background.default
value if defined, otherwise falls back to Material-UI defaults (#303030 for
dark themes, #fafafa for light themes).
Signed-off-by: Deluan
---
ui/src/themes/useCurrentTheme.js | 6 ++++
ui/src/themes/useCurrentTheme.test.jsx | 44 ++++++++++++++++++++++++++
2 files changed, 50 insertions(+)
diff --git a/ui/src/themes/useCurrentTheme.js b/ui/src/themes/useCurrentTheme.js
index 9793d1e15..0d986033d 100644
--- a/ui/src/themes/useCurrentTheme.js
+++ b/ui/src/themes/useCurrentTheme.js
@@ -42,6 +42,12 @@ const useCurrentTheme = () => {
document.head.removeChild(style)
}
}
+
+ // Set body background color to match theme (fixes white background on pull-to-refresh)
+ const isDark = theme.palette?.type === 'dark'
+ const bgColor =
+ theme.palette?.background?.default || (isDark ? '#303030' : '#fafafa')
+ document.body.style.backgroundColor = bgColor
}, [theme])
return theme
diff --git a/ui/src/themes/useCurrentTheme.test.jsx b/ui/src/themes/useCurrentTheme.test.jsx
index 03775d34f..65c3be8c6 100644
--- a/ui/src/themes/useCurrentTheme.test.jsx
+++ b/ui/src/themes/useCurrentTheme.test.jsx
@@ -15,6 +15,10 @@ function createMatchMedia(theme) {
})
}
+beforeEach(() => {
+ document.body.style.backgroundColor = ''
+})
+
describe('useCurrentTheme', () => {
describe('with user preference theme as light', () => {
beforeAll(() => {
@@ -117,4 +121,44 @@ describe('useCurrentTheme', () => {
expect(result.current.themeName).toMatch('Spotify-ish')
})
})
+ describe('body background color', () => {
+ beforeAll(() => {
+ window.matchMedia = createMatchMedia('dark')
+ })
+ it('sets body background for dark theme', () => {
+ renderHook(() => useCurrentTheme(), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ })
+ // Dark theme uses MUI default dark background
+ expect(document.body.style.backgroundColor).toBe('rgb(48, 48, 48)')
+ })
+ it('sets body background for light theme', () => {
+ renderHook(() => useCurrentTheme(), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ })
+ // Light theme uses MUI default light background
+ expect(document.body.style.backgroundColor).toBe('rgb(250, 250, 250)')
+ })
+ it('sets body background for theme with custom background', () => {
+ renderHook(() => useCurrentTheme(), {
+ wrapper: ({ children }) => (
+
+ {children}
+
+ ),
+ })
+ // Spotify theme has explicit background.default: #121212
+ expect(document.body.style.backgroundColor).toBe('rgb(18, 18, 18)')
+ })
+ })
})
From 1f1a174542f89b996473ae78a915d2542df67a78 Mon Sep 17 00:00:00 2001
From: Deluan
Date: Tue, 2 Dec 2025 17:00:13 -0500
Subject: [PATCH 093/102] fix(insights): add Parallels Shared Folders
filesystem type to fsTypeMap
Signed-off-by: Deluan
---
core/metrics/insights_linux.go | 1 +
1 file changed, 1 insertion(+)
diff --git a/core/metrics/insights_linux.go b/core/metrics/insights_linux.go
index f972140c4..f37c945c1 100644
--- a/core/metrics/insights_linux.go
+++ b/core/metrics/insights_linux.go
@@ -75,6 +75,7 @@ var fsTypeMap = map[int64]string{
0xca451a4e: "virtiofs",
0x58465342: "xfs",
0x2FC12FC1: "zfs",
+ 0x7c7c6673: "prlfs", // Parallels Shared Folders
// Signed/unsigned conversion issues (negative hex values converted to uint32)
-0x6edc97c2: "btrfs", // 0x9123683e
From 5bc26de0e7704a92cb0c934f19599a48b5a5d684 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 2 Dec 2025 20:45:08 -0500
Subject: [PATCH 094/102] chore(deps-dev): bump js-yaml from 4.1.0 to 4.1.1 in
/ui (#4715)
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.0 to 4.1.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.0...4.1.1)
---
updated-dependencies:
- dependency-name: js-yaml
dependency-version: 4.1.1
dependency-type: indirect
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
ui/package-lock.json | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/ui/package-lock.json b/ui/package-lock.json
index c0901a73d..59f4cbefe 100644
--- a/ui/package-lock.json
+++ b/ui/package-lock.json
@@ -7173,10 +7173,11 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
},
"node_modules/js-yaml": {
- "version": "4.1.0",
- "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
- "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
+ "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
From 86f929499e1879d2c0775c4af9b79560e9e300fc Mon Sep 17 00:00:00 2001
From: Deluan
Date: Wed, 3 Dec 2025 14:36:47 -0500
Subject: [PATCH 095/102] fix(ui): improve playlist bulk action button contrast
on dark themes
The bulk action buttons (Make Public, Make Private, Delete) on the playlists list were displaying with poor text contrast when using dark themes like AMusic. The buttons had pinkish text (theme's primary color) on a dark red background, making them difficult to read.
This fix applies the same styling pattern used for song bulk actions by adding a makeStyles hook that sets white text color for dark themes. This ensures proper contrast between the button text and background while maintaining correct styling on light themes.
Tested on AMusic (dark) and Light themes to verify contrast improvement and backward compatibility.
Signed-off-by: Deluan
---
ui/src/playlist/PlaylistList.jsx | 32 +++++++++++++++++++++++++-------
1 file changed, 25 insertions(+), 7 deletions(-)
diff --git a/ui/src/playlist/PlaylistList.jsx b/ui/src/playlist/PlaylistList.jsx
index 920b3ebe5..c1856675b 100644
--- a/ui/src/playlist/PlaylistList.jsx
+++ b/ui/src/playlist/PlaylistList.jsx
@@ -16,6 +16,7 @@ import {
usePermissions,
} from 'react-admin'
import Switch from '@material-ui/core/Switch'
+import { makeStyles } from '@material-ui/core/styles'
import { useMediaQuery } from '@material-ui/core'
import {
DurationField,
@@ -28,6 +29,12 @@ import {
import PlaylistListActions from './PlaylistListActions'
import ChangePublicStatusButton from './ChangePublicStatusButton'
+const useStyles = makeStyles((theme) => ({
+ button: {
+ color: theme.palette.type === 'dark' ? 'white' : undefined,
+ },
+}))
+
const PlaylistFilter = (props) => {
const { permissions } = usePermissions()
return (
@@ -112,13 +119,24 @@ const ToggleAutoImport = ({ resource, source }) => {
) : null
}
-const PlaylistListBulkActions = (props) => (
- <>
-
-
-
- >
-)
+const PlaylistListBulkActions = (props) => {
+ const classes = useStyles()
+ return (
+ <>
+
+
+
+ >
+ )
+}
const PlaylistList = (props) => {
const isXsmall = useMediaQuery((theme) => theme.breakpoints.down('xs'))
From b7c4128b1bdea95a4b8e9f5b5a2764bb88d1013b Mon Sep 17 00:00:00 2001
From: maya doshi
Date: Wed, 3 Dec 2025 15:55:25 -0500
Subject: [PATCH 096/102] fix(server): Lastfm.ScrobbleFirstArtistOnly also only
scrobbles the first artist of the album (#4762)
* feat(server): add option Lastfm.ScrobbleFirstAlbumArtistOnly to send only the first album artist
* fix: remove config parameter scrobbleFirstAlbumArtist
* test: add NowPlaying test for ScrobbleFirstArtistOnly
Add a test case for the NowPlaying function when ScrobbleFirstArtistOnly is enabled. This ensures that only the first artist from the Participants list is sent to Last.fm for both artist and album artist fields, matching the existing test coverage for the Scrobble function.
* refactor: consolidate getArtistForScrobble and getAlbumArtistForScrobble
Merge the separate getArtistForScrobble and getAlbumArtistForScrobble functions into a single parameterized function. This eliminates code duplication and makes the scrobble artist handling logic more maintainable. The function now accepts a role parameter and display name, allowing it to handle both artist and album artist extraction based on the ScrobbleFirstArtistOnly configuration.
---------
Co-authored-by: Deluan
---
core/agents/lastfm/agent.go | 16 ++++++++--------
core/agents/lastfm/agent_test.go | 22 ++++++++++++++++++++++
2 files changed, 30 insertions(+), 8 deletions(-)
diff --git a/core/agents/lastfm/agent.go b/core/agents/lastfm/agent.go
index fafa6afec..e3e53b234 100644
--- a/core/agents/lastfm/agent.go
+++ b/core/agents/lastfm/agent.go
@@ -290,11 +290,11 @@ func (l *lastfmAgent) callArtistGetTopTracks(ctx context.Context, artistName str
return t.Track, nil
}
-func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile) string {
- if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[model.RoleArtist]) > 0 {
- return track.Participants[model.RoleArtist][0].Name
+func (l *lastfmAgent) getArtistForScrobble(track *model.MediaFile, role model.Role, displayName string) string {
+ if conf.Server.LastFM.ScrobbleFirstArtistOnly && len(track.Participants[role]) > 0 {
+ return track.Participants[role][0].Name
}
- return track.Artist
+ return displayName
}
func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *model.MediaFile, position int) error {
@@ -304,13 +304,13 @@ func (l *lastfmAgent) NowPlaying(ctx context.Context, userId string, track *mode
}
err = l.client.updateNowPlaying(ctx, sk, ScrobbleInfo{
- artist: l.getArtistForScrobble(track),
+ artist: l.getArtistForScrobble(track, model.RoleArtist, track.Artist),
track: track.Title,
album: track.Album,
trackNumber: track.TrackNumber,
mbid: track.MbzRecordingID,
duration: int(track.Duration),
- albumArtist: track.AlbumArtist,
+ albumArtist: l.getArtistForScrobble(track, model.RoleAlbumArtist, track.AlbumArtist),
})
if err != nil {
log.Warn(ctx, "Last.fm client.updateNowPlaying returned error", "track", track.Title, err)
@@ -330,13 +330,13 @@ func (l *lastfmAgent) Scrobble(ctx context.Context, userId string, s scrobbler.S
return nil
}
err = l.client.scrobble(ctx, sk, ScrobbleInfo{
- artist: l.getArtistForScrobble(&s.MediaFile),
+ artist: l.getArtistForScrobble(&s.MediaFile, model.RoleArtist, s.Artist),
track: s.Title,
album: s.Album,
trackNumber: s.TrackNumber,
mbid: s.MbzRecordingID,
duration: int(s.Duration),
- albumArtist: s.AlbumArtist,
+ albumArtist: l.getArtistForScrobble(&s.MediaFile, model.RoleAlbumArtist, s.AlbumArtist),
timestamp: s.TimeStamp,
})
if err == nil {
diff --git a/core/agents/lastfm/agent_test.go b/core/agents/lastfm/agent_test.go
index 18e7facf2..fc6238408 100644
--- a/core/agents/lastfm/agent_test.go
+++ b/core/agents/lastfm/agent_test.go
@@ -201,6 +201,10 @@ var _ = Describe("lastfmAgent", func() {
{Artist: model.Artist{ID: "ar-1", Name: "First Artist"}},
{Artist: model.Artist{ID: "ar-2", Name: "Second Artist"}},
},
+ model.RoleAlbumArtist: []model.Participant{
+ {Artist: model.Artist{ID: "ar-1", Name: "First Album Artist"}},
+ {Artist: model.Artist{ID: "ar-2", Name: "Second Album Artist"}},
+ },
},
}
})
@@ -229,6 +233,23 @@ var _ = Describe("lastfmAgent", func() {
err := agent.NowPlaying(ctx, "user-2", track, 0)
Expect(err).To(MatchError(scrobbler.ErrNotAuthorized))
})
+
+ When("ScrobbleFirstArtistOnly is true", func() {
+ BeforeEach(func() {
+ conf.Server.LastFM.ScrobbleFirstArtistOnly = true
+ })
+
+ It("uses only the first artist", func() {
+ httpClient.Res = http.Response{Body: io.NopCloser(bytes.NewBufferString("{}")), StatusCode: 200}
+
+ err := agent.NowPlaying(ctx, "user-1", track, 0)
+
+ Expect(err).ToNot(HaveOccurred())
+ sentParams := httpClient.SavedRequest.URL.Query()
+ Expect(sentParams.Get("artist")).To(Equal("First Artist"))
+ Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
+ })
+ })
})
Describe("scrobble", func() {
@@ -267,6 +288,7 @@ var _ = Describe("lastfmAgent", func() {
Expect(err).ToNot(HaveOccurred())
sentParams := httpClient.SavedRequest.URL.Query()
Expect(sentParams.Get("artist")).To(Equal("First Artist"))
+ Expect(sentParams.Get("albumArtist")).To(Equal("First Album Artist"))
})
})
From 96392f3af01be5fe4f00cb3aeb8050906d006ab1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Wed, 3 Dec 2025 18:24:11 -0500
Subject: [PATCH 097/102] ci: improve docker manifest push reliability and
isolation (#4764)
* ci: improve docker manifest push reliability and isolation
Split Docker manifest push into separate GHCR and Docker Hub jobs to improve pipeline reliability and resilience:
- Separated push-manifest job into push-manifest-ghcr and push-manifest-dockerhub for independent execution
- Filter tags per registry using jq to prevent cross-registry push attempts
- Add automatic retry logic (3 attempts with 30s delay) for Docker Hub push using nick-fields/retry action
- Make Docker Hub job continue-on-error to prevent Docker Hub intermittent failures from failing the entire pipeline
- Add dedicated cleanup-digests job that only requires GHCR job success
- GHCR is now the critical path and will fail the pipeline if it fails, while Docker Hub failures are tolerated with retries
This addresses the recurring 400 Bad Request errors from Docker Hub registry that were causing pipeline failures even when ghcr.io push succeeded.
* fix(ci): use ghcr.io as source for docker hub manifest creation
The docker buildx imagetools create command needs to reference the source images from where they exist (ghcr.io) rather than from Docker Hub. The digests uploaded during the build step are stored on ghcr.io, so we need to pull from there and tag to Docker Hub.
* fix(ci): simplify Docker manifest push job names for clarity
* fix(ci): add permissions for Docker manifest push jobs
* fix(ci): update permissions for GHCR manifest push to write
* fix(ci): update Docker Hub image tagging in manifest creation
* fix(ci): update permissions for GHCR manifest push to read contents and write packages
* Revert "fix(ci): update Docker Hub image tagging in manifest creation"
This reverts commit b5f04d9c8b40a9f7d9c5952e5ca57c42dadfc20a.
---
.github/workflows/pipeline.yml | 64 ++++++++++++++++++++++++++--------
1 file changed, 50 insertions(+), 14 deletions(-)
diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml
index 3352cfa4b..43c39f4df 100644
--- a/.github/workflows/pipeline.yml
+++ b/.github/workflows/pipeline.yml
@@ -256,8 +256,11 @@ jobs:
if-no-files-found: error
retention-days: 1
- push-manifest:
- name: Push Docker manifest
+ push-manifest-ghcr:
+ name: Push to GHCR
+ permissions:
+ contents: read
+ packages: write
runs-on: ubuntu-latest
needs: [build, check-push-enabled]
if: needs.check-push-enabled.outputs.is_enabled == 'true'
@@ -278,32 +281,65 @@ jobs:
id: docker
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
- hub_repository: ${{ vars.DOCKER_HUB_REPO }}
- hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
- hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: Create manifest list and push to ghcr.io
working-directory: /tmp/digests
run: |
- docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
+ docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io"))) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *)
- - name: Create manifest list and push to Docker Hub
- working-directory: /tmp/digests
- if: vars.DOCKER_HUB_REPO != ''
- run: |
- docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
- $(printf '${{ vars.DOCKER_HUB_REPO }}@sha256:%s ' *)
-
- name: Inspect image in ghcr.io
run: |
docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.docker.outputs.version }}
+ push-manifest-dockerhub:
+ name: Push to Docker Hub
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ needs: [build, check-push-enabled]
+ if: needs.check-push-enabled.outputs.is_enabled == 'true' && vars.DOCKER_HUB_REPO != ''
+ continue-on-error: true
+ steps:
+ - uses: actions/checkout@v6
+
+ - name: Download digests
+ uses: actions/download-artifact@v6
+ with:
+ path: /tmp/digests
+ pattern: digests-*
+ merge-multiple: true
+
+ - name: Prepare Docker Buildx
+ uses: ./.github/actions/prepare-docker
+ id: docker
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ hub_repository: ${{ vars.DOCKER_HUB_REPO }}
+ hub_username: ${{ secrets.DOCKER_HUB_USERNAME }}
+ hub_password: ${{ secrets.DOCKER_HUB_PASSWORD }}
+
+ - name: Create manifest list and push to Docker Hub
+ uses: nick-fields/retry@v3
+ with:
+ timeout_minutes: 5
+ max_attempts: 3
+ retry_wait_seconds: 30
+ command: |
+ cd /tmp/digests
+ docker buildx imagetools create $(jq -cr '.tags | map(select(startswith("ghcr.io") | not)) | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
+ $(printf 'ghcr.io/${{ github.repository }}@sha256:%s ' *)
+
- name: Inspect image in Docker Hub
- if: vars.DOCKER_HUB_REPO != ''
run: |
docker buildx imagetools inspect ${{ vars.DOCKER_HUB_REPO }}:${{ steps.docker.outputs.version }}
+ cleanup-digests:
+ name: Cleanup digest artifacts
+ runs-on: ubuntu-latest
+ needs: [push-manifest-ghcr, push-manifest-dockerhub]
+ if: always() && needs.push-manifest-ghcr.result == 'success'
+ steps:
- name: Delete unnecessary digest artifacts
env:
GH_TOKEN: ${{ github.token }}
From eaf77957164c7f628fb4b8e85b15ca33c553b8b2 Mon Sep 17 00:00:00 2001
From: Kendall Garner <17521368+kgarner7@users.noreply.github.com>
Date: Wed, 3 Dec 2025 16:58:33 -0800
Subject: [PATCH 098/102] feat(cli): add user administration (#4754)
* feat(cli): add user administration
* clean go.mod, address comments
* fix lint, I hope
* bump compilation timeoit in adapter_media_agent_test
* address initial comments
* feedback 2
* update user commands to use context to allow proper cancellation
Signed-off-by: Deluan
* enforce admin user requirement in context for command execution
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
Co-authored-by: Deluan
---
cmd/pls.go | 35 +-
cmd/user.go | 477 ++++++++++++++++++++++++++++
cmd/utils.go | 42 +++
go.mod | 1 +
go.sum | 2 +
model/user.go | 2 +
plugins/adapter_media_agent_test.go | 2 +
7 files changed, 535 insertions(+), 26 deletions(-)
create mode 100644 cmd/user.go
create mode 100644 cmd/utils.go
diff --git a/cmd/pls.go b/cmd/pls.go
index fc0f22fba..9b94c9e8f 100644
--- a/cmd/pls.go
+++ b/cmd/pls.go
@@ -10,11 +10,8 @@ import (
"strconv"
"github.com/Masterminds/squirrel"
- "github.com/navidrome/navidrome/core/auth"
- "github.com/navidrome/navidrome/db"
"github.com/navidrome/navidrome/log"
"github.com/navidrome/navidrome/model"
- "github.com/navidrome/navidrome/persistence"
"github.com/spf13/cobra"
)
@@ -52,7 +49,7 @@ var (
Short: "Export playlists",
Long: "Export Navidrome playlists to M3U files",
Run: func(cmd *cobra.Command, args []string) {
- runExporter()
+ runExporter(cmd.Context())
},
}
@@ -60,15 +57,13 @@ var (
Use: "list",
Short: "List playlists",
Run: func(cmd *cobra.Command, args []string) {
- runList()
+ runList(cmd.Context())
},
}
)
-func runExporter() {
- sqlDB := db.Db()
- ds := persistence.New(sqlDB)
- ctx := auth.WithAdminUser(context.Background(), ds)
+func runExporter(ctx context.Context) {
+ ds, ctx := getAdminContext(ctx)
playlist, err := ds.Playlist(ctx).GetWithTracks(playlistID, true, false)
if err != nil && !errors.Is(err, model.ErrNotFound) {
log.Fatal("Error retrieving playlist", "name", playlistID, err)
@@ -100,31 +95,19 @@ func runExporter() {
}
}
-func runList() {
+func runList(ctx context.Context) {
if outputFormat != "csv" && outputFormat != "json" {
log.Fatal("Invalid output format. Must be one of csv, json", "format", outputFormat)
}
- sqlDB := db.Db()
- ds := persistence.New(sqlDB)
- ctx := auth.WithAdminUser(context.Background(), ds)
-
+ ds, ctx := getAdminContext(ctx)
options := model.QueryOptions{Sort: "owner_name"}
if userID != "" {
- user, err := ds.User(ctx).FindByUsername(userID)
-
- if err != nil && !errors.Is(err, model.ErrNotFound) {
- log.Fatal("Error retrieving user by name", "name", userID, err)
+ user, err := getUser(ctx, userID, ds)
+ if err != nil {
+ log.Fatal(ctx, "Error retrieving user", "username or id", userID)
}
-
- if errors.Is(err, model.ErrNotFound) {
- user, err = ds.User(ctx).Get(userID)
- if err != nil {
- log.Fatal("Error retrieving user by id", "id", userID, err)
- }
- }
-
options.Filters = squirrel.Eq{"owner_id": user.ID}
}
diff --git a/cmd/user.go b/cmd/user.go
new file mode 100644
index 000000000..1abf157b7
--- /dev/null
+++ b/cmd/user.go
@@ -0,0 +1,477 @@
+package cmd
+
+import (
+ "context"
+ "encoding/csv"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "strconv"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/spf13/cobra"
+ "golang.org/x/term"
+)
+
+var (
+ email string
+ libraryIds []int
+ name string
+
+ removeEmail bool
+ removeName bool
+ setAdmin bool
+ setPassword bool
+ setRegularUser bool
+)
+
+func init() {
+ rootCmd.AddCommand(userRoot)
+
+ userCreateCommand.Flags().StringVarP(&userID, "username", "u", "", "username")
+
+ userCreateCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
+ userCreateCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries. If empty, the user can access all libraries. This is incompatible with admin, as admin can always access all libraries")
+
+ userCreateCommand.Flags().BoolVarP(&setAdmin, "admin", "a", false, "If set, make the user an admin. This user will have access to every library")
+ userCreateCommand.Flags().StringVar(&name, "name", "", "New user's name (this is separate from username used to log in)")
+
+ _ = userCreateCommand.MarkFlagRequired("username")
+
+ userRoot.AddCommand(userCreateCommand)
+
+ userDeleteCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
+ _ = userDeleteCommand.MarkFlagRequired("user")
+ userRoot.AddCommand(userDeleteCommand)
+
+ userEditCommand.Flags().StringVarP(&userID, "user", "u", "", "username or id")
+
+ userEditCommand.Flags().BoolVar(&setAdmin, "set-admin", false, "If set, make the user an admin")
+ userEditCommand.Flags().BoolVar(&setRegularUser, "set-regular", false, "If set, make the user a non-admin")
+ userEditCommand.MarkFlagsMutuallyExclusive("set-admin", "set-regular")
+
+ userEditCommand.Flags().BoolVar(&removeEmail, "remove-email", false, "If set, clear the user's email")
+ userEditCommand.Flags().StringVarP(&email, "email", "e", "", "New user email")
+ userEditCommand.MarkFlagsMutuallyExclusive("email", "remove-email")
+
+ userEditCommand.Flags().BoolVar(&removeName, "remove-name", false, "If set, clear the user's name")
+ userEditCommand.Flags().StringVar(&name, "name", "", "New user name (this is separate from username used to log in)")
+ userEditCommand.MarkFlagsMutuallyExclusive("name", "remove-name")
+
+ userEditCommand.Flags().BoolVar(&setPassword, "set-password", false, "If set, the user's new password will be prompted on the CLI")
+
+ userEditCommand.Flags().IntSliceVarP(&libraryIds, "library-ids", "i", []int{}, "Comma-separated list of library IDs. Set the user's accessible libraries by id")
+
+ _ = userEditCommand.MarkFlagRequired("user")
+ userRoot.AddCommand(userEditCommand)
+
+ userListCommand.Flags().StringVarP(&outputFormat, "format", "f", "csv", "output format [supported values: csv, json]")
+ userRoot.AddCommand(userListCommand)
+}
+
+var (
+ userRoot = &cobra.Command{
+ Use: "user",
+ Short: "Administer users",
+ Long: "Create, delete, list, or update users",
+ }
+
+ userCreateCommand = &cobra.Command{
+ Use: "create",
+ Aliases: []string{"c"},
+ Short: "Create a new user",
+ Run: func(cmd *cobra.Command, args []string) {
+ runCreateUser(cmd.Context())
+ },
+ }
+
+ userDeleteCommand = &cobra.Command{
+ Use: "delete",
+ Aliases: []string{"d"},
+ Short: "Deletes an existing user",
+ Run: func(cmd *cobra.Command, args []string) {
+ runDeleteUser(cmd.Context())
+ },
+ }
+
+ userEditCommand = &cobra.Command{
+ Use: "edit",
+ Aliases: []string{"e"},
+ Short: "Edit a user",
+ Long: "Edit the password, admin status, and/or library access",
+ Run: func(cmd *cobra.Command, args []string) {
+ runUserEdit(cmd.Context())
+ },
+ }
+
+ userListCommand = &cobra.Command{
+ Use: "list",
+ Short: "List users",
+ Run: func(cmd *cobra.Command, args []string) {
+ runUserList(cmd.Context())
+ },
+ }
+)
+
+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
+
+ 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
+
+ if err != nil {
+ log.Fatal("Error getting password confirmation", err)
+ }
+
+ // clear the line.
+ fmt.Println()
+
+ pass := string(password)
+ confirm := string(confirmation)
+
+ if pass == "" {
+ return ""
+ }
+
+ if pass == confirm {
+ return pass
+ }
+
+ fmt.Println("Password and password confirmation do not match")
+ }
+}
+
+func libraryError(libraries model.Libraries) error {
+ ids := make([]int, len(libraries))
+ for idx, library := range libraries {
+ ids[idx] = library.ID
+ }
+ return fmt.Errorf("not all available libraries found. Requested ids: %v, Found libraries: %v", libraryIds, ids)
+}
+
+func runCreateUser(ctx context.Context) {
+ password := promptPassword()
+ if password == "" {
+ log.Fatal("Empty password provided, user creation cancelled")
+ }
+
+ user := model.User{
+ UserName: userID,
+ Email: email,
+ Name: name,
+ IsAdmin: setAdmin,
+ NewPassword: password,
+ }
+
+ if user.Name == "" {
+ user.Name = userID
+ }
+
+ ds, ctx := getAdminContext(ctx)
+
+ err := ds.WithTx(func(tx model.DataStore) error {
+ existingUser, err := tx.User(ctx).FindByUsername(userID)
+ if existingUser != nil {
+ return fmt.Errorf("existing user '%s'", userID)
+ }
+
+ if err != nil && !errors.Is(err, model.ErrNotFound) {
+ return fmt.Errorf("failed to check existing username: %w", err)
+ }
+
+ if len(libraryIds) > 0 && !setAdmin {
+ user.Libraries, err = tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
+ if err != nil {
+ return err
+ }
+
+ if len(user.Libraries) != len(libraryIds) {
+ return libraryError(user.Libraries)
+ }
+ } else {
+ user.Libraries, err = tx.Library(ctx).GetAll()
+ if err != nil {
+ return err
+ }
+ }
+
+ err = tx.User(ctx).Put(&user)
+ if err != nil {
+ return err
+ }
+
+ updatedIds := make([]int, len(user.Libraries))
+ for idx, lib := range user.Libraries {
+ updatedIds[idx] = lib.ID
+ }
+
+ err = tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
+ return err
+ })
+
+ if err != nil {
+ log.Fatal(ctx, err)
+ }
+
+ log.Info(ctx, "Successfully created user", "id", user.ID, "username", user.UserName)
+}
+
+func runDeleteUser(ctx context.Context) {
+ ds, ctx := getAdminContext(ctx)
+
+ var err error
+ var user *model.User
+
+ err = ds.WithTx(func(tx model.DataStore) error {
+ count, err := tx.User(ctx).CountAll()
+ if err != nil {
+ return err
+ }
+
+ if count == 1 {
+ return errors.New("refusing to delete the last user")
+ }
+
+ user, err = getUser(ctx, userID, tx)
+ if err != nil {
+ return err
+ }
+
+ return tx.User(ctx).Delete(user.ID)
+ })
+
+ if err != nil {
+ log.Fatal(ctx, "Failed to delete user", err)
+ }
+
+ log.Info(ctx, "Deleted user", "username", user.UserName)
+}
+
+func runUserEdit(ctx context.Context) {
+ ds, ctx := getAdminContext(ctx)
+
+ var err error
+ var user *model.User
+ changes := []string{}
+
+ err = ds.WithTx(func(tx model.DataStore) error {
+ var newLibraries model.Libraries
+
+ user, err = getUser(ctx, userID, tx)
+ if err != nil {
+ return err
+ }
+
+ if len(libraryIds) > 0 && !setAdmin {
+ libraries, err := tx.Library(ctx).GetAll(model.QueryOptions{Filters: squirrel.Eq{"id": libraryIds}})
+
+ if err != nil {
+ return err
+ }
+
+ if len(libraries) != len(libraryIds) {
+ return libraryError(libraries)
+ }
+
+ newLibraries = libraries
+ changes = append(changes, "updated library ids")
+ }
+
+ if setAdmin && !user.IsAdmin {
+ libraries, err := tx.Library(ctx).GetAll()
+ if err != nil {
+ return err
+ }
+
+ user.IsAdmin = true
+ user.Libraries = libraries
+ changes = append(changes, "set admin")
+
+ newLibraries = libraries
+ }
+
+ if setRegularUser && user.IsAdmin {
+ user.IsAdmin = false
+ changes = append(changes, "set regular user")
+ }
+
+ if setPassword {
+ password := promptPassword()
+
+ if password != "" {
+ user.NewPassword = password
+ changes = append(changes, "updated password")
+ }
+ }
+
+ if email != "" && email != user.Email {
+ user.Email = email
+ changes = append(changes, "updated email")
+ } else if removeEmail && user.Email != "" {
+ user.Email = ""
+ changes = append(changes, "removed email")
+ }
+
+ if name != "" && name != user.Name {
+ user.Name = name
+ changes = append(changes, "updated name")
+ } else if removeName && user.Name != "" {
+ user.Name = ""
+ changes = append(changes, "removed name")
+ }
+
+ if len(changes) == 0 {
+ return nil
+ }
+
+ err := tx.User(ctx).Put(user)
+ if err != nil {
+ return err
+ }
+
+ if len(newLibraries) > 0 {
+ updatedIds := make([]int, len(newLibraries))
+ for idx, lib := range newLibraries {
+ updatedIds[idx] = lib.ID
+ }
+
+ err := tx.User(ctx).SetUserLibraries(user.ID, updatedIds)
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+
+ if err != nil {
+ log.Fatal(ctx, "Failed to update user", err)
+ }
+
+ if len(changes) == 0 {
+ log.Info(ctx, "No changes for user", "user", user.UserName)
+ } else {
+ log.Info(ctx, "Updated user", "user", user.UserName, "changes", strings.Join(changes, ", "))
+ }
+}
+
+type displayLibrary struct {
+ ID int `json:"id"`
+ Path string `json:"path"`
+}
+
+type displayUser struct {
+ Id string `json:"id"`
+ Username string `json:"username"`
+ Name string `json:"name"`
+ Email string `json:"email"`
+ Admin bool `json:"admin"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+ LastAccess *time.Time `json:"lastAccess"`
+ LastLogin *time.Time `json:"lastLogin"`
+ Libraries []displayLibrary `json:"libraries"`
+}
+
+func runUserList(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)
+
+ users, err := ds.User(ctx).ReadAll()
+ if err != nil {
+ log.Fatal(ctx, "Failed to retrieve users", err)
+ }
+
+ userList := users.(model.Users)
+
+ if outputFormat == "csv" {
+ w := csv.NewWriter(os.Stdout)
+ _ = w.Write([]string{
+ "user id",
+ "username",
+ "user's name",
+ "user email",
+ "admin",
+ "created at",
+ "updated at",
+ "last access",
+ "last login",
+ "libraries",
+ })
+ for _, user := range userList {
+ paths := make([]string, len(user.Libraries))
+
+ for idx, library := range user.Libraries {
+ paths[idx] = fmt.Sprintf("%d:%s", library.ID, library.Path)
+ }
+
+ var lastAccess, lastLogin string
+
+ if user.LastAccessAt != nil {
+ lastAccess = user.LastAccessAt.Format(time.RFC3339Nano)
+ } else {
+ lastAccess = "never"
+ }
+
+ if user.LastLoginAt != nil {
+ lastLogin = user.LastLoginAt.Format(time.RFC3339Nano)
+ } else {
+ lastLogin = "never"
+ }
+
+ _ = w.Write([]string{
+ user.ID,
+ user.UserName,
+ user.Name,
+ user.Email,
+ strconv.FormatBool(user.IsAdmin),
+ user.CreatedAt.Format(time.RFC3339Nano),
+ user.UpdatedAt.Format(time.RFC3339Nano),
+ lastAccess,
+ lastLogin,
+ fmt.Sprintf("'%s'", strings.Join(paths, "|")),
+ })
+ }
+ w.Flush()
+ } else {
+ users := make([]displayUser, len(userList))
+ for idx, user := range userList {
+ paths := make([]displayLibrary, len(user.Libraries))
+
+ for idx, library := range user.Libraries {
+ paths[idx].ID = library.ID
+ paths[idx].Path = library.Path
+ }
+
+ users[idx].Id = user.ID
+ users[idx].Username = user.UserName
+ users[idx].Name = user.Name
+ users[idx].Email = user.Email
+ users[idx].Admin = user.IsAdmin
+ users[idx].CreatedAt = user.CreatedAt
+ users[idx].UpdatedAt = user.UpdatedAt
+ users[idx].LastAccess = user.LastAccessAt
+ users[idx].LastLogin = user.LastLoginAt
+ users[idx].Libraries = paths
+ }
+
+ j, _ := json.Marshal(users)
+ fmt.Printf("%s\n", j)
+ }
+}
diff --git a/cmd/utils.go b/cmd/utils.go
new file mode 100644
index 000000000..81d646cf1
--- /dev/null
+++ b/cmd/utils.go
@@ -0,0 +1,42 @@
+package cmd
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ "github.com/navidrome/navidrome/core/auth"
+ "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"
+)
+
+func getAdminContext(ctx context.Context) (model.DataStore, context.Context) {
+ sqlDB := db.Db()
+ ds := persistence.New(sqlDB)
+ ctx = auth.WithAdminUser(ctx, ds)
+ u, _ := request.UserFrom(ctx)
+ if !u.IsAdmin {
+ log.Fatal(ctx, "There must be at least one admin user to run this command.")
+ }
+ return ds, ctx
+}
+
+func getUser(ctx context.Context, id string, ds model.DataStore) (*model.User, error) {
+ user, err := ds.User(ctx).FindByUsername(id)
+
+ if err != nil && !errors.Is(err, model.ErrNotFound) {
+ return nil, fmt.Errorf("finding user by name: %w", err)
+ }
+
+ if errors.Is(err, model.ErrNotFound) {
+ user, err = ds.User(ctx).Get(id)
+ if err != nil {
+ return nil, fmt.Errorf("finding user by id: %w", err)
+ }
+ }
+
+ return user, nil
+}
diff --git a/go.mod b/go.mod
index f680bda51..2abf5f3a1 100644
--- a/go.mod
+++ b/go.mod
@@ -66,6 +66,7 @@ require (
golang.org/x/net v0.47.0
golang.org/x/sync v0.18.0
golang.org/x/sys v0.38.0
+ golang.org/x/term v0.37.0
golang.org/x/text v0.31.0
golang.org/x/time v0.14.0
google.golang.org/protobuf v1.36.10
diff --git a/go.sum b/go.sum
index 77c0cbb40..04e986f94 100644
--- a/go.sum
+++ b/go.sum
@@ -363,6 +363,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.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
+golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
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=
diff --git a/model/user.go b/model/user.go
index aabedc096..c590ba260 100644
--- a/model/user.go
+++ b/model/user.go
@@ -42,7 +42,9 @@ func (u User) HasLibraryAccess(libraryID int) bool {
type Users []User
type UserRepository interface {
+ ResourceRepository
CountAll(...QueryOptions) (int64, error)
+ Delete(id string) error
Get(id string) (*User, error)
Put(*User) error
UpdateLastLoginAt(id string) error
diff --git a/plugins/adapter_media_agent_test.go b/plugins/adapter_media_agent_test.go
index 70b5d275a..e04baf832 100644
--- a/plugins/adapter_media_agent_test.go
+++ b/plugins/adapter_media_agent_test.go
@@ -3,6 +3,7 @@ package plugins
import (
"context"
"errors"
+ "time"
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
@@ -23,6 +24,7 @@ var _ = Describe("Adapter Media Agent", func() {
// Ensure plugins folder is set to testdata
DeferCleanup(configtest.SetupConfig())
conf.Server.Plugins.Folder = testDataDir
+ conf.Server.DevPluginCompilationTimeout = 2 * time.Minute
mgr = createManager(nil, metrics.NewNoopInstance())
mgr.ScanPlugins()
From bfd219e708aeb515b140bed555290c1bdd399575 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Fri, 5 Dec 2025 19:36:06 -0500
Subject: [PATCH 099/102] fix(ui): update Esperanto, Finnish, Galician, Dutch,
Norwegian, Turkish translations from POEditor (#4760)
Co-authored-by: navidrome-bot
---
resources/i18n/eo.json | 194 +++++++++++++++++++++++++++++---------
resources/i18n/fi.json | 12 ++-
resources/i18n/gl.json | 12 ++-
resources/i18n/nl.json | 12 ++-
resources/i18n/no.json | 209 ++++++++++++++++++++++++++++++++---------
resources/i18n/tr.json | 12 ++-
6 files changed, 350 insertions(+), 101 deletions(-)
diff --git a/resources/i18n/eo.json b/resources/i18n/eo.json
index bdf143969..7a13c471d 100644
--- a/resources/i18n/eo.json
+++ b/resources/i18n/eo.json
@@ -27,15 +27,16 @@
"playDate": "Laste Ludita",
"channels": "Kanaloj",
"createdAt": "Dato de aligo",
- "grouping": "",
+ "grouping": "Grupo",
"mood": "Humoro",
- "participants": "",
+ "participants": "Aldonaj partoprenantoj",
"tags": "Aldonaj Etikedoj",
"mappedTags": "Mapigitaj etikedoj",
"rawTags": "Krudaj etikedoj",
- "bitDepth": "",
- "sampleRate": "",
- "missing": ""
+ "bitDepth": "Bitprofundo",
+ "sampleRate": "Elprena rapido",
+ "missing": "Mankaj",
+ "libraryName": "Biblioteko"
},
"actions": {
"addToQueue": "Ludi Poste",
@@ -44,7 +45,8 @@
"shuffleAll": "Miksu Ĉiujn",
"download": "Elŝuti",
"playNext": "Ludu Poste",
- "info": "Akiri Informon"
+ "info": "Akiri Informon",
+ "showInPlaylist": "Montri en Ludlisto"
}
},
"album": {
@@ -68,14 +70,15 @@
"releaseDate": "Publikiĝis",
"releases": "Publikiĝo |||| Publikiĝoj",
"released": "Publikiĝis",
- "recordLabel": "",
- "catalogNum": "",
+ "recordLabel": "Eldonejo",
+ "catalogNum": "Kataloga Numero",
"releaseType": "Tipo",
- "grouping": "",
- "media": "",
+ "grouping": "Grupo",
+ "media": "Aŭdvidaĵo",
"mood": "Humoro",
- "date": "",
- "missing": ""
+ "date": "Registraĵa Dato",
+ "missing": "Mankaj",
+ "libraryName": "Biblioteko"
},
"actions": {
"playAll": "Ludi",
@@ -107,8 +110,8 @@
"rating": "Takso",
"genre": "Ĝenro",
"size": "Grando",
- "role": "",
- "missing": ""
+ "role": "Rolo",
+ "missing": "Mankaj"
},
"roles": {
"albumartist": "Albuma Artisto |||| Albumaj Artistoj",
@@ -117,13 +120,19 @@
"conductor": "Dirigento |||| Dirigentoj",
"lyricist": "Kantoteksisto |||| Kantotekstistoj",
"arranger": "Aranĝisto |||| Aranĝistoj",
- "producer": "",
- "director": "",
- "engineer": "",
+ "producer": "Produktisto |||| Produktistoj",
+ "director": "Direktoro |||| Direktoroj",
+ "engineer": "Inĝeniero |||| Inĝenieroj",
"mixer": "Miksisto |||| Miksistoj",
"remixer": "Remiksisto |||| Remiksistoj",
- "djmixer": "",
- "performer": ""
+ "djmixer": "Dĵ-a Miksisto |||| Dĵ-a Miksistoj",
+ "performer": "Plenumisto |||| Plenumistoj",
+ "maincredit": "Albuma Artisto aŭ Artisto |||| Albumaj Artistoj aŭ Artistoj"
+ },
+ "actions": {
+ "shuffle": "Miksi",
+ "radio": "Radio",
+ "topSongs": "Plej Luditaj Kantoj"
}
},
"user": {
@@ -140,10 +149,12 @@
"currentPassword": "Nuna Pasvorto",
"newPassword": "Nova Pasvorto",
"token": "Ĵetono",
- "lastAccessAt": "Lasta Atingo"
+ "lastAccessAt": "Lasta Atingo",
+ "libraries": "Bibliotekoj"
},
"helperTexts": {
- "name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto"
+ "name": "Ŝanĝoj de via nomo nur ĝisdatiĝs je via sekvanta ensaluto",
+ "libraries": "Elekti specifajn bibliotekojn por ĉi tiu uzanto, aŭ lasi malplena por uzi defaŭltajn bibliotekojn"
},
"notifications": {
"created": "Uzanto farita",
@@ -152,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Enigi vian uzantan ĵetonon de ListenBrainz.",
- "clickHereForToken": "Alkakli ĉi tie por akiri vian ĵetonon"
+ "clickHereForToken": "Alkakli ĉi tie por akiri vian ĵetonon",
+ "selectAllLibraries": "Elekti ĉiujn bibliotekojn",
+ "adminAutoLibraries": "Administrantoj aŭtomate havas aliron al ĉiuj bibliotekoj"
+ },
+ "validation": {
+ "librariesRequired": "Almenaŭ unu biblioteko devas esti elektita por neadministrantoj"
}
},
"player": {
@@ -197,11 +213,16 @@
"export": "Eksporti",
"makePublic": "Publikigi",
"makePrivate": "Malpublikigi",
- "saveQueue": ""
+ "saveQueue": "Konservi Ludvicon al Ludlisto",
+ "searchOrCreate": "Serĉi ludlistojn aŭ tajpi por krei novan...",
+ "pressEnterToCreate": "Premu je Enter por krei novan ludliston",
+ "removeFromSelection": "Forigi de elekto"
},
"message": {
"duplicate_song": "Aldoni duobligitajn kantojn",
- "song_exist": "Estas duoblaĵoj kiuj aldoniĝas al la kantolisto. Ĉu vi ŝatus aldoni la duoblaĵojn aŭ pasigi ilin?"
+ "song_exist": "Estas duoblaĵoj kiuj aldoniĝas al la kantolisto. Ĉu vi ŝatus aldoni la duoblaĵojn aŭ pasigi ilin?",
+ "noPlaylistsFound": "Neniuj ludlistoj trovitaj",
+ "noPlaylists": "Neniuj ludlistoj haveblaj"
}
},
"radio": {
@@ -235,20 +256,78 @@
}
},
"missing": {
- "name": "",
+ "name": "Manka Dosiero |||| Mankaj Dosieroj",
"fields": {
- "path": "",
- "size": "",
- "updatedAt": ""
+ "path": "Vojo",
+ "size": "Grando",
+ "updatedAt": "Malaperis je",
+ "libraryName": "Biblioteko"
},
"actions": {
- "remove": "",
- "remove_all": ""
+ "remove": "Forigi",
+ "remove_all": "Forigi Ĉiujn"
},
"notifications": {
- "removed": ""
+ "removed": "Manka(j) dosiero(j) forigite"
},
- "empty": ""
+ "empty": "Neniuj Mankaj Dosieroj"
+ },
+ "library": {
+ "name": "Biblioteko |||| Bibliotekoj",
+ "fields": {
+ "name": "Nomo",
+ "path": "Vojo",
+ "remotePath": "Fora Vojo",
+ "lastScanAt": "Plej Lasta Skano",
+ "songCount": "Kantoj",
+ "albumCount": "Albumoj",
+ "artistCount": "Artistoj",
+ "totalSongs": "Kantoj",
+ "totalAlbums": "Albumoj",
+ "totalArtists": "Artistoj",
+ "totalFolders": "Dosierujoj",
+ "totalFiles": "Dosieroj",
+ "totalMissingFiles": "Mankaj Dosieroj",
+ "totalSize": "Totala Grando",
+ "totalDuration": "Daŭro",
+ "defaultNewUsers": "Defaŭlto por Novaj Uzantoj",
+ "createdAt": "Farite je",
+ "updatedAt": "Ĝisdatiĝis je"
+ },
+ "sections": {
+ "basic": "Bazaj Informoj",
+ "statistics": "Statistikaĵoj"
+ },
+ "actions": {
+ "scan": "Skani Bibliotekon",
+ "manageUsers": "Agordi Uzantan Aliron",
+ "viewDetails": "Montri Informojn",
+ "quickScan": "Rapida Skano",
+ "fullScan": "Plena Skano"
+ },
+ "notifications": {
+ "created": "Biblioteko kreiĝis sukcese",
+ "updated": "Biblioteko ĝisdatiĝis sukcese",
+ "deleted": "Biblioteko foriĝis sukcese",
+ "scanStarted": "Biblioteka skano komenciĝis",
+ "scanCompleted": "Biblioteka skano finiĝis",
+ "quickScanStarted": "Rapida skano komenciĝis",
+ "fullScanStarted": "Plena skano komenciĝis",
+ "scanError": "Eraro de skana komenco. Kontrolu la protokolojn"
+ },
+ "validation": {
+ "nameRequired": "Biblioteka nomo estas necesa",
+ "pathRequired": "Biblioteka vojo estas necesa",
+ "pathNotDirectory": "Biblioteka vojo devas esti dosierujo",
+ "pathNotFound": "Biblioteka vojo ne trovite",
+ "pathNotAccessible": "Biblioteka vojo ne estas alirebla",
+ "pathInvalid": "Nevalida biblioteka vojo"
+ },
+ "messages": {
+ "deleteConfirm": "Ĉu vi certas, ke vi volas forigi ĉi tiun bibliotekon? Ĉi tio forigos ĉiujn rilatajn datumojn kaj uzantan aliron.",
+ "scanInProgress": "Skano progresas...",
+ "noLibrariesAssigned": "Neniuj bibliotekoj asignitaj por ĉi tiu uzanto"
+ }
}
},
"ra": {
@@ -427,10 +506,12 @@
"shareFailure": "Eraro de kopio de ligilo %{url} al la tondujo",
"downloadDialogTitle": "Elŝuti %{resource} '%{name}' (%{size})",
"shareCopyToClipboard": "Kopii al la tondujo: Ctrl+C, Enter",
- "remove_missing_title": "",
+ "remove_missing_title": "Forigi mankajn dosierojn",
"remove_missing_content": "Ĉu vi certas, ke vi volas forigi la elektitajn mankajn dosierojn de la datumbazo? Ĉi tio forigos eterne ĉiujn referencojn de ili, inkluzive iliajn ludkvantojn kaj taksojn.",
- "remove_all_missing_title": "",
- "remove_all_missing_content": ""
+ "remove_all_missing_title": "Forigi ĉiujn mankajn dosierojn",
+ "remove_all_missing_content": "Ĉu vi certas, ke vi volas forigi ĉiujn mankajn dosierojn de la datumbazo? Ĉi tio permanante forigos ĉiujn referencojn al ili, inkluzive iliajn ludnombrojn kaj taksojn.",
+ "noSimilarSongsFound": "Neniuj similaj kantoj trovitaj",
+ "noTopSongsFound": "Neniuj plej luditaj kantoj trovitaj"
},
"menu": {
"library": "Biblioteko",
@@ -453,13 +534,19 @@
"album": "Uzi Albuman Songajnon",
"track": "Uzi Kantan Songajnon"
},
- "lastfmNotConfigured": ""
+ "lastfmNotConfigured": "API-ŝlosilo de Last.fm ne agordita"
}
},
"albumList": "Albumoj",
"about": "Pri",
"playlists": "Ludlistoj",
- "sharedPlaylists": "Diskonigitaj Ludistoj"
+ "sharedPlaylists": "Diskonigitaj Ludistoj",
+ "librarySelector": {
+ "allLibraries": "Ĉiuj Bibliotekoj (%{count})",
+ "multipleLibraries": "%{selected} el %{total} Bibliotekoj",
+ "selectLibraries": "Elekti Bibliotekojn",
+ "none": "Neniu"
+ }
},
"player": {
"playListsText": "Atendovico",
@@ -491,11 +578,26 @@
"homepage": "Hejmpaĝo",
"source": "Fontkodo",
"featureRequests": "Trajta peto",
- "lastInsightsCollection": "",
+ "lastInsightsCollection": "Plej lasta kolekto de datumoj",
"insights": {
"disabled": "Malebligita",
- "waiting": ""
+ "waiting": "Atendante"
}
+ },
+ "tabs": {
+ "about": "Pri",
+ "config": "Agordo"
+ },
+ "config": {
+ "configName": "Agorda Nomo",
+ "environmentVariable": "Medivariablo",
+ "currentValue": "Nuna Valoro",
+ "configurationFile": "Agorda Dosiero",
+ "exportToml": "Eksporti Agordojn (TOML)",
+ "exportSuccess": "Agordoj eksportiĝis al la tondujo en TOML-a formato",
+ "exportFailed": "Malsukcesis kopii agordojn",
+ "devFlagsHeader": "Programadaj Flagoj (povas ŝanĝiĝi/foriĝi)",
+ "devFlagsComment": "Ĉi tiuj estas eksperimentaj agordoj kaj eble foriĝos en estontaj versioj"
}
},
"activity": {
@@ -505,9 +607,10 @@
"fullScan": "Plena Skanado",
"serverUptime": "Servila daŭro de funkciado",
"serverDown": "SENKONEKTA",
- "scanType": "",
- "status": "",
- "elapsedTime": ""
+ "scanType": "Plej Lasta Skano",
+ "status": "Skana Eraro",
+ "elapsedTime": "Pasinta Tempo",
+ "selectiveScan": "Selektema"
},
"help": {
"title": "Navidrome klavkomando",
@@ -519,8 +622,13 @@
"next_song": "Sekva kanto",
"vol_up": "Pli volumo",
"vol_down": "Malpli volumo",
- "toggle_love": "Baskuli la stelon de nuna kanto",
+ "toggle_love": "Aldoni ĉi tiun kanton al plej ŝatataj",
"current_song": "Iri al Nuna Kanto"
}
+ },
+ "nowPlaying": {
+ "title": "Nun Ludanta",
+ "empty": "Nenio ludas",
+ "minutesAgo": "Antaŭ %{smart_count} minuto |||| Antaŭ %{smart_count} minutoj"
}
}
\ No newline at end of file
diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json
index e5ecea2ce..897c3e310 100644
--- a/resources/i18n/fi.json
+++ b/resources/i18n/fi.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Skannaa kirjasto",
"manageUsers": "Hallitse käyttäjien pääsyä",
- "viewDetails": "Näytä tiedot"
+ "viewDetails": "Näytä tiedot",
+ "quickScan": "Nopea skannaus",
+ "fullScan": "Täysi skannaus"
},
"notifications": {
"created": "Kirjasto luotu onnistuneesti",
"updated": "Kirjasto päivitetty onnistuneesti",
"deleted": "Kirjasto poistettu onnistuneesti",
"scanStarted": "Kirjaston skannaus aloitettu",
- "scanCompleted": "Kirjaston skannaus valmistunut"
+ "scanCompleted": "Kirjaston skannaus valmistunut",
+ "quickScanStarted": "Nopea skannaus aloitettu",
+ "fullScanStarted": "Täysi skannaus aloitettu",
+ "scanError": "Virhe skannauksen käynnistyksessä. Tarkista lokit"
},
"validation": {
"nameRequired": "Kirjaston nimi vaaditaan",
@@ -604,7 +609,8 @@
"serverDown": "SAMMUTETTU",
"scanType": "Tyyppi",
"status": "Skannausvirhe",
- "elapsedTime": "Kulunut aika"
+ "elapsedTime": "Kulunut aika",
+ "selectiveScan": "Valikoiva"
},
"help": {
"title": "Navidrome pikapainikkeet",
diff --git a/resources/i18n/gl.json b/resources/i18n/gl.json
index a6c3beb05..a5f7ce0ce 100644
--- a/resources/i18n/gl.json
+++ b/resources/i18n/gl.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Escanear Biblioteca",
"manageUsers": "Xestionar acceso das usuarias",
- "viewDetails": "Ver detalles"
+ "viewDetails": "Ver detalles",
+ "quickScan": "Escaneado rápido",
+ "fullScan": "Escaneado completo"
},
"notifications": {
"created": "Biblioteca creada correctamente",
"updated": "Biblioteca actualizada correctamente",
"deleted": "Biblioteca eliminada correctamente",
"scanStarted": "Comezou o escaneo da biblioteca",
- "scanCompleted": "Completouse o escaneado da biblioteca"
+ "scanCompleted": "Completouse o escaneado da biblioteca",
+ "quickScanStarted": "Iniciado o escaneado rápido",
+ "fullScanStarted": "Iniciado o escaneado completo",
+ "scanError": "Erro ao escanear. Comproba o rexistro"
},
"validation": {
"nameRequired": "Requírese un nome para a biblioteca",
@@ -604,7 +609,8 @@
"serverDown": "SEN CONEXIÓN",
"scanType": "Tipo",
"status": "Erro de escaneado",
- "elapsedTime": "Tempo transcurrido"
+ "elapsedTime": "Tempo transcurrido",
+ "selectiveScan": "Selectivo"
},
"help": {
"title": "Atallos de Navidrome",
diff --git a/resources/i18n/nl.json b/resources/i18n/nl.json
index b6da47380..059d243cb 100644
--- a/resources/i18n/nl.json
+++ b/resources/i18n/nl.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Scan bibliotheek",
"manageUsers": "Beheer gebruikerstoegang",
- "viewDetails": "Bekijk details"
+ "viewDetails": "Bekijk details",
+ "quickScan": "Snelle scan",
+ "fullScan": "Volledige scan"
},
"notifications": {
"created": "Bibliotheek succesvol aangemaakt",
"updated": "Bibliotheek succesvol bijgewerkt",
"deleted": "Bibliotheek succesvol verwijderd",
"scanStarted": "Bibliotheekscan is gestart",
- "scanCompleted": "Bibliotheekscan is voltooid"
+ "scanCompleted": "Bibliotheekscan is voltooid",
+ "quickScanStarted": "Snelle scan gestart",
+ "fullScanStarted": "Volledige scan gestart",
+ "scanError": "Fout bij start van scan. Check de logs"
},
"validation": {
"nameRequired": "Bibliotheek naam is vereist",
@@ -604,7 +609,8 @@
"serverDown": "Offline",
"scanType": "Type",
"status": "Scan fout",
- "elapsedTime": "Verlopen tijd"
+ "elapsedTime": "Verlopen tijd",
+ "selectiveScan": "Selectief"
},
"help": {
"title": "Navidrome sneltoetsen",
diff --git a/resources/i18n/no.json b/resources/i18n/no.json
index 84198fca7..3b75bab25 100644
--- a/resources/i18n/no.json
+++ b/resources/i18n/no.json
@@ -18,8 +18,6 @@
"size": "Filstørrelse",
"updatedAt": "Oppdatert",
"bitRate": "Bit rate",
- "bitDepth": "Bit depth",
- "channels": "Kanaler",
"discSubtitle": "Disk Undertittel",
"starred": "Favoritt",
"comment": "Kommentar",
@@ -27,13 +25,18 @@
"quality": "Kvalitet",
"bpm": "BPM",
"playDate": "Sist Avspilt",
+ "channels": "Kanaler",
"createdAt": "Lagt til",
"grouping": "Gruppering",
"mood": "Stemning",
"participants": "Ytterlige deltakere",
"tags": "Ytterlige Tags",
"mappedTags": "Kartlagte tags",
- "rawTags": "Rå tags"
+ "rawTags": "Rå tags",
+ "bitDepth": "Bit depth",
+ "sampleRate": "",
+ "missing": "",
+ "libraryName": ""
},
"actions": {
"addToQueue": "Avspill senere",
@@ -42,7 +45,8 @@
"shuffleAll": "Shuffle Alle",
"download": "Last ned",
"playNext": "Avspill neste",
- "info": "Få Info"
+ "info": "Få Info",
+ "showInPlaylist": ""
}
},
"album": {
@@ -53,36 +57,38 @@
"duration": "Tid",
"songCount": "Sanger",
"playCount": "Avspillinger",
- "size": "Størrelse",
"name": "Navn",
"genre": "Sjanger",
"compilation": "Samling",
"year": "År",
- "date": "Inspillingsdato",
- "originalDate": "Original",
- "releaseDate": "Utgitt",
- "releases": "Utgivelse |||| Utgivelser",
- "released": "Utgitt",
"updatedAt": "Oppdatert",
"comment": "Kommentar",
"rating": "Rangering",
"createdAt": "Lagt Til",
+ "size": "Størrelse",
+ "originalDate": "Original",
+ "releaseDate": "Utgitt",
+ "releases": "Utgivelse |||| Utgivelser",
+ "released": "Utgitt",
"recordLabel": "Plateselskap",
"catalogNum": "Katalognummer",
"releaseType": "Type",
"grouping": "Gruppering",
"media": "Media",
- "mood": "Stemning"
+ "mood": "Stemning",
+ "date": "Inspillingsdato",
+ "missing": "",
+ "libraryName": ""
},
"actions": {
"playAll": "Avspill",
"playNext": "Avspill Neste",
"addToQueue": "Avspill Senere",
- "share": "Del",
"shuffle": "Shuffle",
"addToPlaylist": "Legg til i spilleliste",
"download": "Last ned",
- "info": "Få Info"
+ "info": "Få Info",
+ "share": "Del"
},
"lists": {
"all": "Alle",
@@ -100,11 +106,12 @@
"name": "Navn",
"albumCount": "Album Antall",
"songCount": "Song Antall",
- "size": "Størrelse",
"playCount": "Avspillinger",
"rating": "Rangering",
"genre": "Sjanger",
- "role": "Rolle"
+ "size": "Størrelse",
+ "role": "Rolle",
+ "missing": ""
},
"roles": {
"albumartist": "Album Artist |||| Album Artister",
@@ -119,7 +126,13 @@
"mixer": "Mixer |||| Mixers",
"remixer": "Remixer |||| Remixers",
"djmixer": "DJ Mixer |||| DJ Mixers",
- "performer": "Performer |||| Performers"
+ "performer": "Performer |||| Performers",
+ "maincredit": ""
+ },
+ "actions": {
+ "shuffle": "",
+ "radio": "",
+ "topSongs": ""
}
},
"user": {
@@ -128,7 +141,6 @@
"userName": "Brukernavn",
"isAdmin": "Admin",
"lastLoginAt": "Sist Pålogging",
- "lastAccessAt": "Sist Tilgang",
"updatedAt": "Oppdatert",
"name": "Navn",
"password": "Passord",
@@ -136,10 +148,13 @@
"changePassword": "Bytt Passord?",
"currentPassword": "Nåværende Passord",
"newPassword": "Nytt Passord",
- "token": "Token"
+ "token": "Token",
+ "lastAccessAt": "Sist Tilgang",
+ "libraries": ""
},
"helperTexts": {
- "name": "Navnendringer vil ikke være synlig før neste pålogging"
+ "name": "Navnendringer vil ikke være synlig før neste pålogging",
+ "libraries": ""
},
"notifications": {
"created": "Bruker opprettet",
@@ -148,7 +163,12 @@
},
"message": {
"listenBrainzToken": "Fyll inn din ListenBrainz bruker token.",
- "clickHereForToken": "Klikk her for å hente din token"
+ "clickHereForToken": "Klikk her for å hente din token",
+ "selectAllLibraries": "",
+ "adminAutoLibraries": ""
+ },
+ "validation": {
+ "librariesRequired": ""
}
},
"player": {
@@ -192,11 +212,17 @@
"addNewPlaylist": "Opprett \"%{name}\"",
"export": "Eksporter",
"makePublic": "Gjør Offentlig",
- "makePrivate": "Gjør Privat"
+ "makePrivate": "Gjør Privat",
+ "saveQueue": "",
+ "searchOrCreate": "",
+ "pressEnterToCreate": "",
+ "removeFromSelection": ""
},
"message": {
"duplicate_song": "Legg til Duplikater",
- "song_exist": "Duplikater har blitt lagt til i spillelisten. Ønsker du å legge til duplikater eller hoppe over de?"
+ "song_exist": "Duplikater har blitt lagt til i spillelisten. Ønsker du å legge til duplikater eller hoppe over de?",
+ "noPlaylistsFound": "",
+ "noPlaylists": ""
}
},
"radio": {
@@ -218,7 +244,6 @@
"username": "Delt Av",
"url": "URL",
"description": "Beskrivelse",
- "downloadable": "Tillat Nedlastinger?",
"contents": "Innhold",
"expiresAt": "Utløper",
"lastVisitedAt": "Sist Besøkt",
@@ -226,24 +251,82 @@
"format": "Format",
"maxBitRate": "Maks. Bit Rate",
"updatedAt": "Oppdatert",
- "createdAt": "Opprettet"
- },
- "notifications": {},
- "actions": {}
+ "createdAt": "Opprettet",
+ "downloadable": "Tillat Nedlastinger?"
+ }
},
"missing": {
"name": "Manglende Fil|||| Manglende Filer",
- "empty": "Ingen Manglende Filer",
"fields": {
"path": "Filsti",
"size": "Størrelse",
- "updatedAt": "Ble borte"
+ "updatedAt": "Ble borte",
+ "libraryName": ""
},
"actions": {
- "remove": "Fjern"
+ "remove": "Fjern",
+ "remove_all": ""
},
"notifications": {
"removed": "Manglende fil(er) fjernet"
+ },
+ "empty": "Ingen Manglende Filer"
+ },
+ "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": "",
+ "manageUsers": "",
+ "viewDetails": "",
+ "quickScan": "",
+ "fullScan": ""
+ },
+ "notifications": {
+ "created": "",
+ "updated": "",
+ "deleted": "Biblioteket slettet",
+ "scanStarted": "Skanning startet",
+ "scanCompleted": "",
+ "quickScanStarted": "",
+ "fullScanStarted": "",
+ "scanError": "Error starte skanning. Sjekk loggene"
+ },
+ "validation": {
+ "nameRequired": "",
+ "pathRequired": "",
+ "pathNotDirectory": "",
+ "pathNotFound": "",
+ "pathNotAccessible": "",
+ "pathInvalid": ""
+ },
+ "messages": {
+ "deleteConfirm": "",
+ "scanInProgress": "",
+ "noLibrariesAssigned": ""
}
}
},
@@ -282,7 +365,6 @@
"add": "Legg Til",
"back": "Tilbake",
"bulk_actions": "1 element valgt |||| %{smart_count} elementer valgt",
- "bulk_actions_mobile": "1 |||| %{smart_count}",
"cancel": "Avbryt",
"clear_input_value": "Nullstill verdi",
"clone": "Klone",
@@ -306,6 +388,7 @@
"close_menu": "Lukk meny",
"unselect": "Avvelg",
"skip": "Hopp over",
+ "bulk_actions_mobile": "1 |||| %{smart_count}",
"share": "Del",
"download": "Last Ned"
},
@@ -400,31 +483,35 @@
"noPlaylistsAvailable": "Ingen tilgjengelig",
"delete_user_title": "Slett bruker '%{name}'",
"delete_user_content": "Er du sikker på at du vil slette denne brukeren og all tilhørlig data (inkludert spillelister og preferanser)?",
- "remove_missing_title": "Fjern manglende filer",
- "remove_missing_content": "Er du sikker på at du ønsker å fjerne de valgte manglende filene fra databasen? Dette vil permanent fjerne alle referanser til de, inkludert antall avspillinger og rangeringer.",
"notifications_blocked": "Du har blokkert notifikasjoner for denne nettsiden i din nettleser.",
"notifications_not_available": "Denne nettleseren støtter ikke skrivebordsnotifikasjoner, eller så er du ikke tilkoblet Navidrome via https.",
"lastfmLinkSuccess": "Last.fm er tilkoblet og scrobbling er aktivert",
"lastfmLinkFailure": "Last.fm kunne ikke koble til",
"lastfmUnlinkSuccess": "Last.fm er avkoblet og scrobbling er deaktivert",
"lastfmUnlinkFailure": "Last.fm kunne ikke avkobles",
- "listenBrainzLinkSuccess": "ListenBrainz er koblet til og scrobbling er aktivert som bruker: %{user}",
- "listenBrainzLinkFailure": "ListenBrainz kunne ikke koble til: %{error}",
- "listenBrainzUnlinkSuccess": "ListenBrainz er avkoblet og scrobbling er deaktivert",
- "listenBrainzUnlinkFailure": "ListenBrainz kunne ikke avkobles",
"openIn": {
"lastfm": "Åpne i Last.fm",
"musicbrainz": "Åpne i MusicBrainz"
},
"lastfmLink": "Les Mer...",
+ "listenBrainzLinkSuccess": "ListenBrainz er koblet til og scrobbling er aktivert som bruker: %{user}",
+ "listenBrainzLinkFailure": "ListenBrainz kunne ikke koble til: %{error}",
+ "listenBrainzUnlinkSuccess": "ListenBrainz er avkoblet og scrobbling er deaktivert",
+ "listenBrainzUnlinkFailure": "ListenBrainz kunne ikke avkobles",
+ "downloadOriginalFormat": "Last ned i originalformat",
"shareOriginalFormat": "Del i originalformat",
"shareDialogTitle": "Del %{resource} '%{name}'",
"shareBatchDialogTitle": "Del 1 %{resource} |||| Del %{smart_count} %{resource}",
- "shareCopyToClipboard": "Kopier til utklippstavle: Ctrl+C, Enter",
"shareSuccess": "URL kopiert til utklippstavle: %{url}",
"shareFailure": "Error ved kopiering av URL %{url} til utklippstavle",
"downloadDialogTitle": "Last ned %{resource} '%{name}' (%{size})",
- "downloadOriginalFormat": "Last ned i originalformat"
+ "shareCopyToClipboard": "Kopier til utklippstavle: Ctrl+C, Enter",
+ "remove_missing_title": "Fjern manglende filer",
+ "remove_missing_content": "Er du sikker på at du ønsker å fjerne de valgte manglende filene fra databasen? Dette vil permanent fjerne alle referanser til de, inkludert antall avspillinger og rangeringer.",
+ "remove_all_missing_title": "",
+ "remove_all_missing_content": "",
+ "noSimilarSongsFound": "",
+ "noTopSongsFound": ""
},
"menu": {
"library": "Bibliotek",
@@ -438,7 +525,6 @@
"language": "Språk",
"defaultView": "Standardvisning",
"desktop_notifications": "Skrivebordsnotifikasjoner",
- "lastfmNotConfigured": "Last.fm API-Key er ikke konfigurert",
"lastfmScrobbling": "Scrobble til Last.fm",
"listenBrainzScrobbling": "Scrobble til ListenBrainz",
"replaygain": "ReplayGain Mode",
@@ -447,13 +533,20 @@
"none": "Deaktivert",
"album": "Bruk Album Gain",
"track": "Bruk Track Gain"
- }
+ },
+ "lastfmNotConfigured": "Last.fm API-Key er ikke konfigurert"
}
},
"albumList": "Album",
+ "about": "Om",
"playlists": "Spillelister",
"sharedPlaylists": "Delte Spillelister",
- "about": "Om"
+ "librarySelector": {
+ "allLibraries": "",
+ "multipleLibraries": "",
+ "selectLibraries": "",
+ "none": ""
+ }
},
"player": {
"playListsText": "Spill Av Kø",
@@ -490,6 +583,21 @@
"disabled": "Deaktivert",
"waiting": "Venter"
}
+ },
+ "tabs": {
+ "about": "",
+ "config": ""
+ },
+ "config": {
+ "configName": "",
+ "environmentVariable": "",
+ "currentValue": "",
+ "configurationFile": "",
+ "exportToml": "",
+ "exportSuccess": "",
+ "exportFailed": "",
+ "devFlagsHeader": "",
+ "devFlagsComment": ""
}
},
"activity": {
@@ -498,7 +606,11 @@
"quickScan": "Hurtigskann",
"fullScan": "Full Skann",
"serverUptime": "Server Oppetid",
- "serverDown": "OFFLINE"
+ "serverDown": "OFFLINE",
+ "scanType": "",
+ "status": "",
+ "elapsedTime": "",
+ "selectiveScan": "Utvalgt"
},
"help": {
"title": "Navidrome Hurtigtaster",
@@ -508,10 +620,15 @@
"toggle_play": "Avspill / Pause",
"prev_song": "Forrige Sang",
"next_song": "Neste Sang",
- "current_song": "Gå til Nåværende Sang",
"vol_up": "Volum Opp",
"vol_down": "Volum Ned",
- "toggle_love": "Legg til spor i favoritter"
+ "toggle_love": "Legg til spor i favoritter",
+ "current_song": "Gå til Nåværende Sang"
}
+ },
+ "nowPlaying": {
+ "title": "",
+ "empty": "",
+ "minutesAgo": ""
}
-}
+}
\ No newline at end of file
diff --git a/resources/i18n/tr.json b/resources/i18n/tr.json
index 7c1a82c08..d1fdb2ed4 100644
--- a/resources/i18n/tr.json
+++ b/resources/i18n/tr.json
@@ -301,14 +301,19 @@
"actions": {
"scan": "Kütüphaneyi Tara",
"manageUsers": "Kullanıcı Erişimini Yönet",
- "viewDetails": "Ayrıntıları Görüntüle"
+ "viewDetails": "Ayrıntıları Görüntüle",
+ "quickScan": "Hızlı Tarama",
+ "fullScan": "Tam Tarama"
},
"notifications": {
"created": "Kütüphane başarıyla oluşturuldu",
"updated": "Kütüphane başarıyla güncellendi",
"deleted": "Kütüphane başarıyla silindi",
"scanStarted": "Kütüphane taraması başladı",
- "scanCompleted": "Kütüphane taraması tamamlandı"
+ "scanCompleted": "Kütüphane taraması tamamlandı",
+ "quickScanStarted": "Hızlı tarama başlatıldı",
+ "fullScanStarted": "Tam tarama başlatıldı",
+ "scanError": "Tarama başlatılırken hata oluştu. Günlükleri kontrol edin."
},
"validation": {
"nameRequired": "Kütüphane adı gereklidir",
@@ -604,7 +609,8 @@
"serverDown": "ÇEVRİMDIŞI",
"scanType": "Tür",
"status": "Tarama Hatası",
- "elapsedTime": "Geçen Süre"
+ "elapsedTime": "Geçen Süre",
+ "selectiveScan": "Seçmeli"
},
"help": {
"title": "Navidrome Kısayolları",
From a521c74a599c25864919b6de761eca29edb7a3cf Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Sat, 6 Dec 2025 11:07:18 -0500
Subject: [PATCH 100/102] feat(server): track scrobble/linstens history (#4770)
* feat(scrobble): implement scrobble repository and record scrobble history
Signed-off-by: Deluan
* feat(scrobble): add configuration option to enable scrobble history
Signed-off-by: Deluan
* test(scrobble): enhance scrobble history tests for repository recording
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
---
.gitignore | 3 +-
conf/configuration.go | 4 +-
core/scrobbler/play_tracker.go | 8 +-
core/scrobbler/play_tracker_test.go | 40 ++++++++-
.../20251206013022_create_scrobbles_table.sql | 20 +++++
model/datastore.go | 1 +
model/scrobble.go | 13 +++
persistence/persistence.go | 4 +
persistence/scrobble_repository.go | 34 ++++++++
persistence/scrobble_repository_test.go | 84 +++++++++++++++++++
tests/mock_data_store.go | 14 +++-
tests/mock_scrobble_repo.go | 24 ++++++
12 files changed, 241 insertions(+), 8 deletions(-)
create mode 100644 db/migrations/20251206013022_create_scrobbles_table.sql
create mode 100644 model/scrobble.go
create mode 100644 persistence/scrobble_repository.go
create mode 100644 persistence/scrobble_repository_test.go
create mode 100644 tests/mock_scrobble_repo.go
diff --git a/.gitignore b/.gitignore
index 74d7ee46f..03852f9ab 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,4 +31,5 @@ AGENTS.md
.github/git-commit-instructions.md
*.exe
*.test
-*.wasm
\ No newline at end of file
+*.wasm
+openspec/
\ No newline at end of file
diff --git a/conf/configuration.go b/conf/configuration.go
index 58785952c..1a01d22c3 100644
--- a/conf/configuration.go
+++ b/conf/configuration.go
@@ -102,7 +102,8 @@ type configOptions struct {
Spotify spotifyOptions `json:",omitzero"`
Deezer deezerOptions `json:",omitzero"`
ListenBrainz listenBrainzOptions `json:",omitzero"`
- Tags map[string]TagConf `json:",omitempty"`
+ EnableScrobbleHistory bool
+ Tags map[string]TagConf `json:",omitempty"`
Agents string
// DevFlags. These are used to enable/disable debugging and incomplete features
@@ -598,6 +599,7 @@ func setViperDefaults() {
viper.SetDefault("deezer.language", "en")
viper.SetDefault("listenbrainz.enabled", true)
viper.SetDefault("listenbrainz.baseurl", "https://api.listenbrainz.org/1/")
+ viper.SetDefault("enablescrobblehistory", true)
viper.SetDefault("httpsecurityheaders.customframeoptionsvalue", "DENY")
viper.SetDefault("backup.path", "")
viper.SetDefault("backup.schedule", "")
diff --git a/core/scrobbler/play_tracker.go b/core/scrobbler/play_tracker.go
index d7ab0e6cf..bac9d220b 100644
--- a/core/scrobbler/play_tracker.go
+++ b/core/scrobbler/play_tracker.go
@@ -345,8 +345,14 @@ func (p *playTracker) incPlay(ctx context.Context, track *model.MediaFile, times
}
for _, artist := range track.Participants[model.RoleArtist] {
err = tx.Artist(ctx).IncPlayCount(artist.ID, timestamp)
+ if err != nil {
+ return err
+ }
}
- return err
+ if conf.Server.EnableScrobbleHistory {
+ return tx.Scrobble(ctx).RecordScrobble(track.ID, timestamp)
+ }
+ return nil
})
}
diff --git a/core/scrobbler/play_tracker_test.go b/core/scrobbler/play_tracker_test.go
index f300f7796..6f66276c3 100644
--- a/core/scrobbler/play_tracker_test.go
+++ b/core/scrobbler/play_tracker_test.go
@@ -61,7 +61,7 @@ var _ = Describe("PlayTracker", func() {
BeforeEach(func() {
DeferCleanup(configtest.SetupConfig())
- ctx = context.Background()
+ ctx = GinkgoT().Context()
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
@@ -177,9 +177,9 @@ var _ = Describe("PlayTracker", func() {
track2 := track
track2.ID = "456"
_ = ds.MediaFile(ctx).Put(&track2)
- ctx = request.WithUser(context.Background(), model.User{UserName: "user-1"})
+ ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-1"})
_ = tracker.NowPlaying(ctx, "player-1", "player-one", "123", 0)
- ctx = request.WithUser(context.Background(), model.User{UserName: "user-2"})
+ ctx = request.WithUser(GinkgoT().Context(), model.User{UserName: "user-2"})
_ = tracker.NowPlaying(ctx, "player-2", "player-two", "456", 0)
playing, err := tracker.GetNowPlaying(ctx)
@@ -291,6 +291,38 @@ var _ = Describe("PlayTracker", func() {
Expect(artist1.PlayCount).To(Equal(int64(1)))
Expect(artist2.PlayCount).To(Equal(int64(1)))
})
+
+ Context("Scrobble History", func() {
+ It("records scrobble in repository", func() {
+ conf.Server.EnableScrobbleHistory = true
+ ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
+ ts := time.Now()
+
+ err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
+
+ Expect(err).ToNot(HaveOccurred())
+
+ mockDS := ds.(*tests.MockDataStore)
+ mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo)
+ Expect(mockScrobble.RecordedScrobbles).To(HaveLen(1))
+ Expect(mockScrobble.RecordedScrobbles[0].MediaFileID).To(Equal("123"))
+ Expect(mockScrobble.RecordedScrobbles[0].UserID).To(Equal("u-1"))
+ Expect(mockScrobble.RecordedScrobbles[0].SubmissionTime).To(Equal(ts))
+ })
+
+ It("does not record scrobble when history is disabled", func() {
+ conf.Server.EnableScrobbleHistory = false
+ ctx = request.WithUser(ctx, model.User{ID: "u-1", UserName: "user-1"})
+ ts := time.Now()
+
+ err := tracker.Submit(ctx, []Submission{{TrackID: "123", Timestamp: ts}})
+
+ Expect(err).ToNot(HaveOccurred())
+ mockDS := ds.(*tests.MockDataStore)
+ mockScrobble := mockDS.Scrobble(ctx).(*tests.MockScrobbleRepo)
+ Expect(mockScrobble.RecordedScrobbles).To(HaveLen(0))
+ })
+ })
})
Describe("Plugin scrobbler logic", func() {
@@ -352,7 +384,7 @@ var _ = Describe("PlayTracker", func() {
var mockedBS *mockBufferedScrobbler
BeforeEach(func() {
- ctx = context.Background()
+ ctx = GinkgoT().Context()
ctx = request.WithUser(ctx, model.User{ID: "u-1"})
ctx = request.WithPlayer(ctx, model.Player{ScrobbleEnabled: true})
ds = &tests.MockDataStore{}
diff --git a/db/migrations/20251206013022_create_scrobbles_table.sql b/db/migrations/20251206013022_create_scrobbles_table.sql
new file mode 100644
index 000000000..9791c48e3
--- /dev/null
+++ b/db/migrations/20251206013022_create_scrobbles_table.sql
@@ -0,0 +1,20 @@
+-- +goose Up
+-- +goose StatementBegin
+CREATE TABLE scrobbles(
+ media_file_id VARCHAR(255) NOT NULL
+ REFERENCES media_file(id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE,
+ user_id VARCHAR(255) NOT NULL
+ REFERENCES user(id)
+ ON DELETE CASCADE
+ ON UPDATE CASCADE,
+ submission_time INTEGER NOT NULL
+);
+CREATE INDEX scrobbles_date ON scrobbles (submission_time);
+-- +goose StatementEnd
+
+-- +goose Down
+-- +goose StatementBegin
+DROP TABLE scrobbles;
+-- +goose StatementEnd
diff --git a/model/datastore.go b/model/datastore.go
index 536a37274..601fab2d3 100644
--- a/model/datastore.go
+++ b/model/datastore.go
@@ -38,6 +38,7 @@ type DataStore interface {
User(ctx context.Context) UserRepository
UserProps(ctx context.Context) UserPropsRepository
ScrobbleBuffer(ctx context.Context) ScrobbleBufferRepository
+ Scrobble(ctx context.Context) ScrobbleRepository
Resource(ctx context.Context, model interface{}) ResourceRepository
diff --git a/model/scrobble.go b/model/scrobble.go
new file mode 100644
index 000000000..e1567abc3
--- /dev/null
+++ b/model/scrobble.go
@@ -0,0 +1,13 @@
+package model
+
+import "time"
+
+type Scrobble struct {
+ MediaFileID string
+ UserID string
+ SubmissionTime time.Time
+}
+
+type ScrobbleRepository interface {
+ RecordScrobble(mediaFileID string, submissionTime time.Time) error
+}
diff --git a/persistence/persistence.go b/persistence/persistence.go
index 1de0bae61..9599de179 100644
--- a/persistence/persistence.go
+++ b/persistence/persistence.go
@@ -89,6 +89,10 @@ func (s *SQLStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBufferRepos
return NewScrobbleBufferRepository(ctx, s.getDBXBuilder())
}
+func (s *SQLStore) Scrobble(ctx context.Context) model.ScrobbleRepository {
+ return NewScrobbleRepository(ctx, s.getDBXBuilder())
+}
+
func (s *SQLStore) Resource(ctx context.Context, m interface{}) model.ResourceRepository {
switch m.(type) {
case model.User:
diff --git a/persistence/scrobble_repository.go b/persistence/scrobble_repository.go
new file mode 100644
index 000000000..dda98b763
--- /dev/null
+++ b/persistence/scrobble_repository.go
@@ -0,0 +1,34 @@
+package persistence
+
+import (
+ "context"
+ "time"
+
+ . "github.com/Masterminds/squirrel"
+ "github.com/navidrome/navidrome/model"
+ "github.com/pocketbase/dbx"
+)
+
+type scrobbleRepository struct {
+ sqlRepository
+}
+
+func NewScrobbleRepository(ctx context.Context, db dbx.Builder) model.ScrobbleRepository {
+ r := &scrobbleRepository{}
+ r.ctx = ctx
+ r.db = db
+ r.tableName = "scrobbles"
+ return r
+}
+
+func (r *scrobbleRepository) RecordScrobble(mediaFileID string, submissionTime time.Time) error {
+ userID := loggedUser(r.ctx).ID
+ values := map[string]interface{}{
+ "media_file_id": mediaFileID,
+ "user_id": userID,
+ "submission_time": submissionTime.Unix(),
+ }
+ insert := Insert(r.tableName).SetMap(values)
+ _, err := r.executeSQL(insert)
+ return err
+}
diff --git a/persistence/scrobble_repository_test.go b/persistence/scrobble_repository_test.go
new file mode 100644
index 000000000..d43848d03
--- /dev/null
+++ b/persistence/scrobble_repository_test.go
@@ -0,0 +1,84 @@
+package persistence
+
+import (
+ "context"
+ "time"
+
+ "github.com/navidrome/navidrome/log"
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/id"
+ "github.com/navidrome/navidrome/model/request"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+ "github.com/pocketbase/dbx"
+)
+
+var _ = Describe("ScrobbleRepository", func() {
+ var repo model.ScrobbleRepository
+ var rawRepo sqlRepository
+ var ctx context.Context
+ var fileID string
+ var userID string
+
+ BeforeEach(func() {
+ fileID = id.NewRandom()
+ userID = id.NewRandom()
+ ctx = request.WithUser(log.NewContext(GinkgoT().Context()), model.User{ID: userID, UserName: "johndoe", IsAdmin: true})
+ db := GetDBXBuilder()
+ repo = NewScrobbleRepository(ctx, db)
+
+ rawRepo = sqlRepository{
+ ctx: ctx,
+ tableName: "scrobbles",
+ db: db,
+ }
+ })
+
+ AfterEach(func() {
+ _, _ = rawRepo.db.Delete("scrobbles", dbx.HashExp{"media_file_id": fileID}).Execute()
+ _, _ = rawRepo.db.Delete("media_file", dbx.HashExp{"id": fileID}).Execute()
+ _, _ = rawRepo.db.Delete("user", dbx.HashExp{"id": userID}).Execute()
+ })
+
+ Describe("RecordScrobble", func() {
+ It("records a scrobble event", func() {
+ submissionTime := time.Now().UTC()
+
+ // Insert User
+ _, err := rawRepo.db.Insert("user", dbx.Params{
+ "id": userID,
+ "user_name": "user",
+ "password": "pw",
+ "created_at": time.Now(),
+ "updated_at": time.Now(),
+ }).Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ // Insert MediaFile
+ _, err = rawRepo.db.Insert("media_file", dbx.Params{
+ "id": fileID,
+ "path": "path",
+ "created_at": time.Now(),
+ "updated_at": time.Now(),
+ }).Execute()
+ Expect(err).ToNot(HaveOccurred())
+
+ err = repo.RecordScrobble(fileID, submissionTime)
+ Expect(err).ToNot(HaveOccurred())
+
+ // Verify insertion
+ var scrobble struct {
+ MediaFileID string `db:"media_file_id"`
+ UserID string `db:"user_id"`
+ SubmissionTime int64 `db:"submission_time"`
+ }
+ err = rawRepo.db.Select("*").From("scrobbles").
+ Where(dbx.HashExp{"media_file_id": fileID, "user_id": userID}).
+ One(&scrobble)
+ Expect(err).ToNot(HaveOccurred())
+ Expect(scrobble.MediaFileID).To(Equal(fileID))
+ Expect(scrobble.UserID).To(Equal(userID))
+ Expect(scrobble.SubmissionTime).To(Equal(submissionTime.Unix()))
+ })
+ })
+})
diff --git a/tests/mock_data_store.go b/tests/mock_data_store.go
index ba586ab53..8ac7b58ad 100644
--- a/tests/mock_data_store.go
+++ b/tests/mock_data_store.go
@@ -25,6 +25,7 @@ type MockDataStore struct {
MockedTranscoding model.TranscodingRepository
MockedUserProps model.UserPropsRepository
MockedScrobbleBuffer model.ScrobbleBufferRepository
+ MockedScrobble model.ScrobbleRepository
MockedRadio model.RadioRepository
scrobbleBufferMu sync.Mutex
repoMu sync.Mutex
@@ -208,12 +209,23 @@ func (db *MockDataStore) ScrobbleBuffer(ctx context.Context) model.ScrobbleBuffe
if db.RealDS != nil {
db.MockedScrobbleBuffer = db.RealDS.ScrobbleBuffer(ctx)
} else {
- db.MockedScrobbleBuffer = CreateMockedScrobbleBufferRepo()
+ db.MockedScrobbleBuffer = &MockedScrobbleBufferRepo{}
}
}
return db.MockedScrobbleBuffer
}
+func (db *MockDataStore) Scrobble(ctx context.Context) model.ScrobbleRepository {
+ if db.MockedScrobble == nil {
+ if db.RealDS != nil {
+ db.MockedScrobble = db.RealDS.Scrobble(ctx)
+ } else {
+ db.MockedScrobble = &MockScrobbleRepo{ctx: ctx}
+ }
+ }
+ return db.MockedScrobble
+}
+
func (db *MockDataStore) Radio(ctx context.Context) model.RadioRepository {
if db.MockedRadio == nil {
if db.RealDS != nil {
diff --git a/tests/mock_scrobble_repo.go b/tests/mock_scrobble_repo.go
new file mode 100644
index 000000000..34561c257
--- /dev/null
+++ b/tests/mock_scrobble_repo.go
@@ -0,0 +1,24 @@
+package tests
+
+import (
+ "context"
+ "time"
+
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/model/request"
+)
+
+type MockScrobbleRepo struct {
+ RecordedScrobbles []model.Scrobble
+ ctx context.Context
+}
+
+func (m *MockScrobbleRepo) RecordScrobble(fileID string, submissionTime time.Time) error {
+ user, _ := request.UserFrom(m.ctx)
+ m.RecordedScrobbles = append(m.RecordedScrobbles, model.Scrobble{
+ MediaFileID: fileID,
+ UserID: user.ID,
+ SubmissionTime: submissionTime,
+ })
+ return nil
+}
From f6ac99e0818871cbc3b62da29652913bbb368d62 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Sat, 6 Dec 2025 11:08:24 -0500
Subject: [PATCH 101/102] fix(ui): update Bulgarian, Finnish translations from
POEditor (#4773)
Co-authored-by: navidrome-bot
---
resources/i18n/bg.json | 1072 +++++++++++++++++++++++-----------------
resources/i18n/fi.json | 16 +-
2 files changed, 631 insertions(+), 457 deletions(-)
diff --git a/resources/i18n/bg.json b/resources/i18n/bg.json
index ea97d1d1b..dfe3f27ed 100644
--- a/resources/i18n/bg.json
+++ b/resources/i18n/bg.json
@@ -1,460 +1,634 @@
{
- "languageName": "Български",
- "resources": {
- "song": {
- "name": "Песен |||| Песни",
- "fields": {
- "albumArtist": "Изпълнител албум",
- "duration": "Време",
- "trackNumber": "#",
- "playCount": "Пускания",
- "title": "Заглавие",
- "artist": "Изпълнител",
- "album": "Албум",
- "path": "Път до файл",
- "genre": "Жанр",
- "compilation": "Компилация",
- "year": "Година",
- "size": "Размер на файла",
- "updatedAt": "Актуализирана",
- "bitRate": "Битрейт",
- "discSubtitle": "Субтитри на диска",
- "starred": "Любима",
- "comment": "Коментар",
- "rating": "Рейтинг",
- "quality": "Качество",
- "bpm": "BPM",
- "playDate": "Последно слушана",
- "channels": "Канала",
- "createdAt": "Добавено на"
- },
- "actions": {
- "addToQueue": "Пусни по-късно",
- "playNow": "Пусни сега",
- "addToPlaylist": "Добави към плейлист",
- "shuffleAll": "Разбъркай всички",
- "download": "Свали",
- "playNext": "Следваща",
- "info": "Информация"
- }
- },
- "album": {
- "name": "Албум |||| Албуми",
- "fields": {
- "albumArtist": "Изпълнител албум",
- "artist": "Изпълнител",
- "duration": "Време",
- "songCount": "Песни",
- "playCount": "Пускания",
- "name": "Име",
- "genre": "Жанр",
- "compilation": "Компилация",
- "year": "Година",
- "updatedAt": "Актуализиран",
- "comment": "Коментар",
- "rating": "Рейтинг",
- "createdAt": "Добавено на",
- "size": "Размер",
- "originalDate": "Оригинал",
- "releaseDate": "Издаден",
- "releases": "Издание |||| Издания",
- "released": "Издаден"
- },
- "actions": {
- "playAll": "Пусни",
- "playNext": "Пусни следваща",
- "addToQueue": "Пусни по-късно",
- "shuffle": "Разбъркай",
- "addToPlaylist": "Добави към плейлист",
- "download": "Свали",
- "info": "Информация",
- "share": "Сподели"
- },
- "lists": {
- "all": "Всички",
- "random": "Случайни",
- "recentlyAdded": "Последно добавени",
- "recentlyPlayed": "Последно слушани",
- "mostPlayed": "Най-слушани",
- "starred": "Любими",
- "topRated": "Най-висок рейтинг"
- }
- },
- "artist": {
- "name": "Изпълнител |||| Изпълнители",
- "fields": {
- "name": "Име",
- "albumCount": "Брой албуми",
- "songCount": "Брой песни",
- "playCount": "Пускания",
- "rating": "Рейтинг",
- "genre": "Жанр",
- "size": "Размер"
- }
- },
- "user": {
- "name": "Потребител |||| Потребители",
- "fields": {
- "userName": "Потребителско име",
- "isAdmin": "Администратор",
- "lastLoginAt": "Последен вход",
- "updatedAt": "Актуализиран",
- "name": "Име",
- "password": "Парола",
- "createdAt": "Създаден на",
- "changePassword": "Промяна на паролата?",
- "currentPassword": "Текуща парола",
- "newPassword": "Нова парола",
- "token": "Токен"
- },
- "helperTexts": {
- "name": "Промените в името ще бъдат отразени при следващото влизане"
- },
- "notifications": {
- "created": "Потребителят е създаден",
- "updated": "Потребителят е актуализиран",
- "deleted": "Потребителят е изтрит"
- },
- "message": {
- "listenBrainzToken": "Въведете Вашия токен за ListenBrainz.",
- "clickHereForToken": "Кликнете тук, за да получите Вашия токен"
- }
- },
- "player": {
- "name": "Плейър |||| Плейъри",
- "fields": {
- "name": "Име",
- "transcodingId": "Транскодиране",
- "maxBitRate": "Макс. битрейт",
- "client": "Клиент",
- "userName": "Потребителско име",
- "lastSeen": "Последно видян",
- "reportRealPath": "Докладвай реален път",
- "scrobbleEnabled": "Изпрати Scrobbles към външни услуги"
- }
- },
- "transcoding": {
- "name": "Транскодиране |||| Транскодинг",
- "fields": {
- "name": "Име",
- "targetFormat": "Целеви формат",
- "defaultBitRate": "Битрейт по подразбиране",
- "command": "Команда"
- }
- },
- "playlist": {
- "name": "Плейлист |||| Плейлисти",
- "fields": {
- "name": "Име",
- "duration": "Продължителност",
- "ownerName": "Собственик",
- "public": "Публичен",
- "updatedAt": "Актуализиран",
- "createdAt": "Създаден на",
- "songCount": "Песни",
- "comment": "Коментар",
- "sync": "Автоматично импортиране",
- "path": "Импортиране от"
- },
- "actions": {
- "selectPlaylist": "Изберете плейлист:",
- "addNewPlaylist": "Създай \"%{name}\"",
- "export": "Експорт",
- "makePublic": "Направи публичен",
- "makePrivate": "Направи личен"
- },
- "message": {
- "duplicate_song": "Добави дублирани песни",
- "song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?"
- }
- },
- "radio": {
- "name": "Радиостанция |||| Радиостанции",
- "fields": {
- "name": "Име",
- "streamUrl": "Стрийм адрес",
- "homePageUrl": "Начална страница адрес",
- "updatedAt": "Актуализиранa на",
- "createdAt": "Създаденa на"
- },
- "actions": {
- "playNow": "Възпроизвеждане сега"
- }
- },
- "share": {
- "name": "Сподели |||| Споделени",
- "fields": {
- "username": "Споделено от",
- "url": "Адрес",
- "description": "Описание",
- "contents": "Съдържание",
- "expiresAt": "Изтича",
- "lastVisitedAt": "Последно посетен",
- "visitCount": "Посещения",
- "format": "Формат",
- "maxBitRate": "Макс. Bit Rate",
- "updatedAt": "Актуализирана на",
- "createdAt": "Създадена на",
- "downloadable": "Разреши изтегляния?"
- }
- }
+ "languageName": "Български",
+ "resources": {
+ "song": {
+ "name": "Песен |||| Песни",
+ "fields": {
+ "albumArtist": "Изпълнител албум",
+ "duration": "Време",
+ "trackNumber": "#",
+ "playCount": "Пускания",
+ "title": "Заглавие",
+ "artist": "Изпълнител",
+ "album": "Албум",
+ "path": "Път до файл",
+ "genre": "Жанр",
+ "compilation": "Компилация",
+ "year": "Година",
+ "size": "Размер на файла",
+ "updatedAt": "Актуализирана",
+ "bitRate": "Битрейт",
+ "discSubtitle": "Субтитри на диска",
+ "starred": "Любима",
+ "comment": "Коментар",
+ "rating": "Рейтинг",
+ "quality": "Качество",
+ "bpm": "BPM",
+ "playDate": "Последно слушана",
+ "channels": "Канала",
+ "createdAt": "Добавено на",
+ "grouping": "Групиране",
+ "mood": "Настроение",
+ "participants": "Допълнителни участници",
+ "tags": "Допълнителни етикети",
+ "mappedTags": "",
+ "rawTags": "",
+ "bitDepth": "Битова дълбочина",
+ "sampleRate": "",
+ "missing": "Липсва",
+ "libraryName": ""
+ },
+ "actions": {
+ "addToQueue": "Пусни по-късно",
+ "playNow": "Пусни сега",
+ "addToPlaylist": "Добави към плейлист",
+ "shuffleAll": "Разбъркай всички",
+ "download": "Свали",
+ "playNext": "Следваща",
+ "info": "Информация",
+ "showInPlaylist": ""
+ }
},
- "ra": {
- "auth": {
- "welcome1": "Благодаря, че инсталирахте Navidrome!",
- "welcome2": "За да започнете, създайте администраторски профил",
- "confirmPassword": "Потвърдете паролата",
- "buttonCreateAdmin": "Създaй администратор",
- "auth_check_error": "Моля, влезте за да продължите",
- "user_menu": "Профил",
- "username": "Потребителско име",
- "password": "Парола",
- "sign_in": "Вход",
- "sign_in_error": "Грешка при удостоверяването. Моля, опитайте отново",
- "logout": "Изход"
- },
- "validation": {
- "invalidChars": "Моля, използвайте само букви и цифри",
- "passwordDoesNotMatch": "Паролата не съвпада",
- "required": "Задължително",
- "minLength": "Трябва да съдържа поне %{min} знака",
- "maxLength": "Трябва да съдържа %{max} знака или по-малко",
- "minValue": "Трябва да е поне %{min}",
- "maxValue": "Трябва да бъде %{max} или по-малко",
- "number": "Трябва да е число",
- "email": "Трябва да е валиден имейл",
- "oneOf": "Трябва да е едно от: %{options}",
- "regex": "Трябва да съответства на конкретен формат (regexp): %{pattern}",
- "unique": "Трябва да е уникално",
- "url": "Трябва да бъде валиден адрес"
- },
- "action": {
- "add_filter": "Добави филтър",
- "add": "Добави",
- "back": "Назад",
- "bulk_actions": "Избран е 1 елемент |||| Избрани са %{smart_count} елемента",
- "cancel": "Отмени",
- "clear_input_value": "Изчисти въведеното",
- "clone": "Клонирай",
- "confirm": "Потвърди",
- "create": "Създай",
- "delete": "Изтрий",
- "edit": "Редактирай",
- "export": "Експорт",
- "list": "Списък",
- "refresh": "Обнови",
- "remove_filter": "Премахни този филтър",
- "remove": "Премахни",
- "save": "Запази",
- "search": "Търси",
- "show": "Покажи",
- "sort": "Сортирай",
- "undo": "Отмени",
- "expand": "Разгърни",
- "close": "Затвори",
- "open_menu": "Отвори меню",
- "close_menu": "Затвори меню",
- "unselect": "Премахни избора",
- "skip": "Пропусни",
- "bulk_actions_mobile": "1 |||| %{smart_count}",
- "share": "Споделяне",
- "download": "Сваляне"
- },
- "boolean": {
- "true": "Да",
- "false": "Не"
- },
- "page": {
- "create": "Създаване на %{name}",
- "dashboard": "Табло",
- "edit": "%{name} #%{id}",
- "error": "Нещо се обърка",
- "list": "%{name}",
- "loading": "Зареждане",
- "not_found": "Не е намерен",
- "show": "%{name} #%{id}",
- "empty": "Все още няма %{name}.",
- "invite": "Желаете ли да добавите?"
- },
- "input": {
- "file": {
- "upload_several": "Пуснете файл за да качите, или кликнете за да изберете.",
- "upload_single": "Пуснете файл за да качите, или кликнете за да изберете."
- },
- "image": {
- "upload_several": "Пуснете снимки за качване, или кликнете, за да изберете.",
- "upload_single": "Пуснете снимка за качване, или кликнете за да изберете."
- },
- "references": {
- "all_missing": "Не намирам свързаните данни.",
- "many_missing": "Изглежда, че поне една от свързаните препратки, вече не е налична.",
- "single_missing": "Изглежда, че връзката вече не е налична."
- },
- "password": {
- "toggle_visible": "Скрий паролата",
- "toggle_hidden": "Покажи паролата"
- }
- },
- "message": {
- "about": "Относно",
- "are_you_sure": "Сигурни ли сте?",
- "bulk_delete_content": "Наистина ли желаете да изтриете това %{name}? |||| Наистина ли желаете да изтриете тези %{smart_count} елементи?",
- "bulk_delete_title": "Изтрий %{name} |||| Изтрий %{smart_count} %{name}",
- "delete_content": "Наистина ли желаете да изтриете този елемент?",
- "delete_title": "Изтрий %{name} #%{id}",
- "details": "Описание",
- "error": "Възникна грешка с клиента и заявката Ви не може да бъде изпълнена.",
- "invalid_form": "Формата не е валидна. Моля, проверете за грешки",
- "loading": "Страницата се зарежда, моля изчакайте",
- "no": "Не",
- "not_found": "Или сте въвели грешен URL адрес, или сте следвали грешна връзка.",
- "yes": "Да",
- "unsaved_changes": "Някои от промените не бяха запазени. Сигурни ли сте, че желаете да ги игнорирате?"
- },
- "navigation": {
- "no_results": "Няма намерени резултати",
- "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": "Елемента на страница:",
- "next": "Следваща",
- "prev": "Предишна",
- "skip_nav": "Премини към съдържанието"
- },
- "notification": {
- "updated": "Елементът е актуализиран |||| %{smart_count} елемента са актуализирани",
- "created": "Елементът е създаден",
- "deleted": "Елементът е изтрит |||| %{smart_count} елемента са изтрити",
- "bad_item": "Неправилен елемент",
- "item_doesnt_exist": "Елементът не съществува",
- "http_error": "Грешка в комуникацията със сървъра",
- "data_provider_error": "Грешка в доставчика на данни. Проверете конзолата за подробности.",
- "i18n_error": "Не мога да заредя преводите за посочения език",
- "canceled": "Действието е отменено",
- "logged_out": "Вашата сесия приключи. Моля, влезте отново.",
- "new_version": "Налична е нова версия! Моля, опреснете този прозорец."
- },
- "toggleFieldsMenu": {
- "columnsToDisplay": "Колони за показване",
- "layout": "Оформление",
- "grid": "Решетка",
- "table": "Таблица"
- }
+ "album": {
+ "name": "Албум |||| Албуми",
+ "fields": {
+ "albumArtist": "Изпълнител албум",
+ "artist": "Изпълнител",
+ "duration": "Време",
+ "songCount": "Песни",
+ "playCount": "Пускания",
+ "name": "Име",
+ "genre": "Жанр",
+ "compilation": "Компилация",
+ "year": "Година",
+ "updatedAt": "Актуализиран",
+ "comment": "Коментар",
+ "rating": "Рейтинг",
+ "createdAt": "Добавено на",
+ "size": "Размер",
+ "originalDate": "Оригинал",
+ "releaseDate": "Издаден",
+ "releases": "Издание |||| Издания",
+ "released": "Издаден",
+ "recordLabel": "Лейбъл",
+ "catalogNum": "Каталожен номер",
+ "releaseType": "Тип",
+ "grouping": "Групиране",
+ "media": "Медия",
+ "mood": "Настроение",
+ "date": "Дата на запис",
+ "missing": "Липсва",
+ "libraryName": ""
+ },
+ "actions": {
+ "playAll": "Пусни",
+ "playNext": "Пусни следваща",
+ "addToQueue": "Пусни по-късно",
+ "shuffle": "Разбъркай",
+ "addToPlaylist": "Добави към плейлист",
+ "download": "Свали",
+ "info": "Информация",
+ "share": "Сподели"
+ },
+ "lists": {
+ "all": "Всички",
+ "random": "Случайни",
+ "recentlyAdded": "Последно добавени",
+ "recentlyPlayed": "Последно слушани",
+ "mostPlayed": "Най-слушани",
+ "starred": "Любими",
+ "topRated": "Най-висок рейтинг"
+ }
},
- "message": {
- "note": "ЗАБЕЛЕЖКА",
- "transcodingDisabled": "Промяната на конфигурацията за транскодиране през уеб интерфейса е забранена от съображения за сигурност. Ако желаете да промените (редактирате или добавите) опциите за транскодиране, рестартирайте сървъра с конфигурационната опция %{config}.",
- "transcodingEnabled": "Navidrome в момента работи с %{config}, което прави възможно стартирането на системни команди от настройките за транскодиране с помощта на уеб интерфейса. Препоръчваме да го деактивирате от съображения за сигурност и да го активирате само при конфигуриране на опциите за транскодиране.",
- "songsAddedToPlaylist": "Добавена 1 песен към плейлиста |||| Добавени %{smart_count} песни към плейлиста",
- "noPlaylistsAvailable": "Няма налични",
- "delete_user_title": "Изтрий потребителя '%{name}'",
- "delete_user_content": "Наистина ли желаете да изтриете този потребител и всичките му данни (включително плейлисти и предпочитания)?",
- "notifications_blocked": "В настройките на браузъра сте блокирали известията за този сайт",
- "notifications_not_available": "Този браузър не поддържа известия на работния плот или нямате достъп до Navidrome през https",
- "lastfmLinkSuccess": "Връзката с Last.fm е успешна! Scrobbling е активиран",
- "lastfmLinkFailure": "Last.fm не можа да бъде свързан",
- "lastfmUnlinkSuccess": "Връзката с Last.fm е прекъсната! Scrobbling е деактивиран",
- "lastfmUnlinkFailure": "Last.fm връзката не можа да бъде премахната",
- "openIn": {
- "lastfm": "Отвори в Last.fm",
- "musicbrainz": "Отвори в MusicBrainz"
- },
- "lastfmLink": "Прочетете още...",
- "listenBrainzLinkSuccess": "Връзката с ListenBrainz е успешна! Scrobbling е активиран от името на потребителя: %{user}",
- "listenBrainzLinkFailure": "ListenBrainz не можа да бъде свързан: %{error}",
- "listenBrainzUnlinkSuccess": "Връзката с ListenBrainz е прекъсната! Scrobbling е деактивиран",
- "listenBrainzUnlinkFailure": "Връзката с ListenBrainz не можа да бъде прекратена",
- "downloadOriginalFormat": "Свали в оригиналния формат",
- "shareOriginalFormat": "Сподели в оригинален формат",
- "shareDialogTitle": "Сподели %{resource} '%{name}'",
- "shareBatchDialogTitle": "Сподели 1 %{resource} |||| Сподели %{smart_count} %{resource}",
- "shareSuccess": "Адресът е копиран в клипборда: %{url}",
- "shareFailure": "Грешка при копиране на адрес %{url} в клипборда",
- "downloadDialogTitle": "Сваляне %{resource} '%{name}' (%{size})",
- "shareCopyToClipboard": "Копиране в клипборда: Ctrl+C, Enter"
+ "artist": {
+ "name": "Изпълнител |||| Изпълнители",
+ "fields": {
+ "name": "Име",
+ "albumCount": "Брой албуми",
+ "songCount": "Брой песни",
+ "playCount": "Пускания",
+ "rating": "Рейтинг",
+ "genre": "Жанр",
+ "size": "Размер",
+ "role": "Роля",
+ "missing": "Липсва"
+ },
+ "roles": {
+ "albumartist": "Изпълнител на албума |||| Изпълнители на албума",
+ "artist": "Изпълнител |||| Изпълнители",
+ "composer": "Композитор |||| Композитори",
+ "conductor": "Диригент |||| Диригенти",
+ "lyricist": "Текстописец |||| Текстописци",
+ "arranger": "Аранжор |||| Аранжори",
+ "producer": "Продуцент |||| Продуценти",
+ "director": "Директор |||| Директори",
+ "engineer": "Инженер |||| Инженери",
+ "mixer": "Миксер |||| Миксери",
+ "remixer": "Ремиксер |||| Ремиксери",
+ "djmixer": "DJ миксер |||| DJ миксери",
+ "performer": "Изпълнител |||| Изпълнители",
+ "maincredit": ""
+ },
+ "actions": {
+ "shuffle": "",
+ "radio": "",
+ "topSongs": ""
+ }
},
- "menu": {
- "library": "Библиотека",
- "settings": "Настройки",
- "version": "Версия",
- "theme": "Тема",
- "personal": {
- "name": "Лични",
- "options": {
- "theme": "Тема",
- "language": "Език",
- "defaultView": "Изглед по подразбиране",
- "desktop_notifications": "Известия на работния плот",
- "lastfmScrobbling": "Scrobble към Last.fm",
- "listenBrainzScrobbling": "Scrobble към ListenBrainz",
- "replaygain": "Режим ReplayGain",
- "preAmp": "ReplayGain PreAmp (dB)",
- "gain": {
- "none": "Изключен",
- "album": "Използвай Album Gain",
- "track": "Използвай Track Gain"
- }
- }
- },
- "albumList": "Албуми",
- "about": "Относно",
- "playlists": "Плейлисти",
- "sharedPlaylists": "Споделени плейлисти"
+ "user": {
+ "name": "Потребител |||| Потребители",
+ "fields": {
+ "userName": "Потребителско име",
+ "isAdmin": "Администратор",
+ "lastLoginAt": "Последен вход",
+ "updatedAt": "Актуализиран",
+ "name": "Име",
+ "password": "Парола",
+ "createdAt": "Създаден на",
+ "changePassword": "Промяна на паролата?",
+ "currentPassword": "Текуща парола",
+ "newPassword": "Нова парола",
+ "token": "Токен",
+ "lastAccessAt": "Последен достъп",
+ "libraries": ""
+ },
+ "helperTexts": {
+ "name": "Промените в името ще бъдат отразени при следващото влизане",
+ "libraries": ""
+ },
+ "notifications": {
+ "created": "Потребителят е създаден",
+ "updated": "Потребителят е актуализиран",
+ "deleted": "Потребителят е изтрит"
+ },
+ "message": {
+ "listenBrainzToken": "Въведете Вашия токен за ListenBrainz.",
+ "clickHereForToken": "Кликнете тук, за да получите Вашия токен",
+ "selectAllLibraries": "",
+ "adminAutoLibraries": ""
+ },
+ "validation": {
+ "librariesRequired": ""
+ }
},
"player": {
- "playListsText": "Списък с песни",
- "openText": "Отвори",
- "closeText": "Затвори",
- "notContentText": "Няма песни",
- "clickToPlayText": "Пускане",
- "clickToPauseText": "Пауза",
- "nextTrackText": "Следваща песен",
- "previousTrackText": "Предишна песен",
- "reloadText": "Презареди",
- "volumeText": "Сила на звука",
- "toggleLyricText": "Текст на песен",
- "toggleMiniModeText": "Минимизирай",
- "destroyText": "Унищожи",
- "downloadText": "Свали",
- "removeAudioListsText": "Изтриване на плейлисти",
- "clickToDeleteText": "Кликнете, за да изтриете %{name}",
- "emptyLyricText": "Няма текст",
- "playModeText": {
- "order": "По ред",
- "orderLoop": "Повтаряй всички",
- "singleLoop": "Повтаряй същата",
- "shufflePlay": "Разбъркай"
- }
+ "name": "Плейър |||| Плейъри",
+ "fields": {
+ "name": "Име",
+ "transcodingId": "Транскодиране",
+ "maxBitRate": "Макс. битрейт",
+ "client": "Клиент",
+ "userName": "Потребителско име",
+ "lastSeen": "Последно видян",
+ "reportRealPath": "Докладвай реален път",
+ "scrobbleEnabled": "Изпрати Scrobbles към външни услуги"
+ }
},
- "about": {
- "links": {
- "homepage": "Начална страница",
- "source": "Програмен код",
- "featureRequests": "Заявете функционалност"
- }
+ "transcoding": {
+ "name": "Транскодиране |||| Транскодинг",
+ "fields": {
+ "name": "Име",
+ "targetFormat": "Целеви формат",
+ "defaultBitRate": "Битрейт по подразбиране",
+ "command": "Команда"
+ }
},
- "activity": {
- "title": "Действия",
- "totalScanned": "Сканирани папки",
- "quickScan": "Бързо сканиране",
- "fullScan": "Пълно сканиране",
- "serverUptime": "Сървърът работи",
- "serverDown": "ОФЛАЙН"
+ "playlist": {
+ "name": "Плейлист |||| Плейлисти",
+ "fields": {
+ "name": "Име",
+ "duration": "Продължителност",
+ "ownerName": "Собственик",
+ "public": "Публичен",
+ "updatedAt": "Актуализиран",
+ "createdAt": "Създаден на",
+ "songCount": "Песни",
+ "comment": "Коментар",
+ "sync": "Автоматично импортиране",
+ "path": "Импортиране от"
+ },
+ "actions": {
+ "selectPlaylist": "Изберете плейлист:",
+ "addNewPlaylist": "Създай \"%{name}\"",
+ "export": "Експорт",
+ "makePublic": "Направи публичен",
+ "makePrivate": "Направи личен",
+ "saveQueue": "",
+ "searchOrCreate": "",
+ "pressEnterToCreate": "",
+ "removeFromSelection": ""
+ },
+ "message": {
+ "duplicate_song": "Добави дублирани песни",
+ "song_exist": "Към плейлиста се добавят дублиращи. Желаете ли да ги добавите или предпочитате да ги пропуснете?",
+ "noPlaylistsFound": "",
+ "noPlaylists": ""
+ }
},
- "help": {
- "title": "Бързи клавиши на Navidrome",
- "hotkeys": {
- "show_help": "Показва този помощен текст",
- "toggle_menu": "Превключване на страничната меню лента",
- "toggle_play": "Пусни / Пауза",
- "prev_song": "Предишна песен",
- "next_song": "Следваща песен",
- "vol_up": "Увеличи звука",
- "vol_down": "Намали звука",
- "toggle_love": "Добави песента към любими",
- "current_song": "Премини към текущата песен"
- }
+ "radio": {
+ "name": "Радиостанция |||| Радиостанции",
+ "fields": {
+ "name": "Име",
+ "streamUrl": "Стрийм адрес",
+ "homePageUrl": "Начална страница адрес",
+ "updatedAt": "Актуализиранa на",
+ "createdAt": "Създаденa на"
+ },
+ "actions": {
+ "playNow": "Възпроизвеждане сега"
+ }
+ },
+ "share": {
+ "name": "Сподели |||| Споделени",
+ "fields": {
+ "username": "Споделено от",
+ "url": "Адрес",
+ "description": "Описание",
+ "contents": "Съдържание",
+ "expiresAt": "Изтича",
+ "lastVisitedAt": "Последно посетен",
+ "visitCount": "Посещения",
+ "format": "Формат",
+ "maxBitRate": "Макс. Bit Rate",
+ "updatedAt": "Актуализирана на",
+ "createdAt": "Създадена на",
+ "downloadable": "Разреши изтегляния?"
+ }
+ },
+ "missing": {
+ "name": "Липсващ файл |||| Липсващи файлове",
+ "fields": {
+ "path": "Път",
+ "size": "Размер",
+ "updatedAt": "Изчезнал на",
+ "libraryName": ""
+ },
+ "actions": {
+ "remove": "Премахни",
+ "remove_all": "Премахни всички"
+ },
+ "notifications": {
+ "removed": "Липсващите файлове са премахнати"
+ },
+ "empty": "Няма липсващи файлове"
+ },
+ "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": "",
+ "manageUsers": "",
+ "viewDetails": "",
+ "quickScan": "",
+ "fullScan": ""
+ },
+ "notifications": {
+ "created": "",
+ "updated": "",
+ "deleted": "",
+ "scanStarted": "",
+ "scanCompleted": "",
+ "quickScanStarted": "",
+ "fullScanStarted": "",
+ "scanError": ""
+ },
+ "validation": {
+ "nameRequired": "",
+ "pathRequired": "",
+ "pathNotDirectory": "",
+ "pathNotFound": "",
+ "pathNotAccessible": "",
+ "pathInvalid": ""
+ },
+ "messages": {
+ "deleteConfirm": "",
+ "scanInProgress": "",
+ "noLibrariesAssigned": ""
+ }
}
+ },
+ "ra": {
+ "auth": {
+ "welcome1": "Благодаря, че инсталирахте Navidrome!",
+ "welcome2": "За да започнете, създайте администраторски профил",
+ "confirmPassword": "Потвърдете паролата",
+ "buttonCreateAdmin": "Създaй администратор",
+ "auth_check_error": "Моля, влезте за да продължите",
+ "user_menu": "Профил",
+ "username": "Потребителско име",
+ "password": "Парола",
+ "sign_in": "Вход",
+ "sign_in_error": "Грешка при удостоверяването. Моля, опитайте отново",
+ "logout": "Изход",
+ "insightsCollectionNote": "Navidrome събира анонимни данни, за да помогне\nподобряването на проекта. Кликнете [тук], за да\nнаучите повече и да се откажете, ако желаете"
+ },
+ "validation": {
+ "invalidChars": "Моля, използвайте само букви и цифри",
+ "passwordDoesNotMatch": "Паролата не съвпада",
+ "required": "Задължително",
+ "minLength": "Трябва да съдържа поне %{min} знака",
+ "maxLength": "Трябва да съдържа %{max} знака или по-малко",
+ "minValue": "Трябва да е поне %{min}",
+ "maxValue": "Трябва да бъде %{max} или по-малко",
+ "number": "Трябва да е число",
+ "email": "Трябва да е валиден имейл",
+ "oneOf": "Трябва да е едно от: %{options}",
+ "regex": "Трябва да съответства на конкретен формат (regexp): %{pattern}",
+ "unique": "Трябва да е уникално",
+ "url": "Трябва да бъде валиден адрес"
+ },
+ "action": {
+ "add_filter": "Добави филтър",
+ "add": "Добави",
+ "back": "Назад",
+ "bulk_actions": "Избран е 1 елемент |||| Избрани са %{smart_count} елемента",
+ "cancel": "Отмени",
+ "clear_input_value": "Изчисти въведеното",
+ "clone": "Клонирай",
+ "confirm": "Потвърди",
+ "create": "Създай",
+ "delete": "Изтрий",
+ "edit": "Редактирай",
+ "export": "Експорт",
+ "list": "Списък",
+ "refresh": "Обнови",
+ "remove_filter": "Премахни този филтър",
+ "remove": "Премахни",
+ "save": "Запази",
+ "search": "Търси",
+ "show": "Покажи",
+ "sort": "Сортирай",
+ "undo": "Отмени",
+ "expand": "Разгърни",
+ "close": "Затвори",
+ "open_menu": "Отвори меню",
+ "close_menu": "Затвори меню",
+ "unselect": "Премахни избора",
+ "skip": "Пропусни",
+ "bulk_actions_mobile": "1 |||| %{smart_count}",
+ "share": "Споделяне",
+ "download": "Сваляне"
+ },
+ "boolean": {
+ "true": "Да",
+ "false": "Не"
+ },
+ "page": {
+ "create": "Създаване на %{name}",
+ "dashboard": "Табло",
+ "edit": "%{name} #%{id}",
+ "error": "Нещо се обърка",
+ "list": "%{name}",
+ "loading": "Зареждане",
+ "not_found": "Не е намерен",
+ "show": "%{name} #%{id}",
+ "empty": "Все още няма %{name}.",
+ "invite": "Желаете ли да добавите?"
+ },
+ "input": {
+ "file": {
+ "upload_several": "Пуснете файл за да качите, или кликнете за да изберете.",
+ "upload_single": "Пуснете файл за да качите, или кликнете за да изберете."
+ },
+ "image": {
+ "upload_several": "Пуснете снимки за качване, или кликнете, за да изберете.",
+ "upload_single": "Пуснете снимка за качване, или кликнете за да изберете."
+ },
+ "references": {
+ "all_missing": "Не намирам свързаните данни.",
+ "many_missing": "Изглежда, че поне една от свързаните препратки, вече не е налична.",
+ "single_missing": "Изглежда, че връзката вече не е налична."
+ },
+ "password": {
+ "toggle_visible": "Скрий паролата",
+ "toggle_hidden": "Покажи паролата"
+ }
+ },
+ "message": {
+ "about": "Относно",
+ "are_you_sure": "Сигурни ли сте?",
+ "bulk_delete_content": "Наистина ли желаете да изтриете това %{name}? |||| Наистина ли желаете да изтриете тези %{smart_count} елементи?",
+ "bulk_delete_title": "Изтрий %{name} |||| Изтрий %{smart_count} %{name}",
+ "delete_content": "Наистина ли желаете да изтриете този елемент?",
+ "delete_title": "Изтрий %{name} #%{id}",
+ "details": "Описание",
+ "error": "Възникна грешка с клиента и заявката Ви не може да бъде изпълнена.",
+ "invalid_form": "Формата не е валидна. Моля, проверете за грешки",
+ "loading": "Страницата се зарежда, моля изчакайте",
+ "no": "Не",
+ "not_found": "Или сте въвели грешен URL адрес, или сте следвали грешна връзка.",
+ "yes": "Да",
+ "unsaved_changes": "Някои от промените не бяха запазени. Сигурни ли сте, че желаете да ги игнорирате?"
+ },
+ "navigation": {
+ "no_results": "Няма намерени резултати",
+ "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": "Елемента на страница:",
+ "next": "Следваща",
+ "prev": "Предишна",
+ "skip_nav": "Премини към съдържанието"
+ },
+ "notification": {
+ "updated": "Елементът е актуализиран |||| %{smart_count} елемента са актуализирани",
+ "created": "Елементът е създаден",
+ "deleted": "Елементът е изтрит |||| %{smart_count} елемента са изтрити",
+ "bad_item": "Неправилен елемент",
+ "item_doesnt_exist": "Елементът не съществува",
+ "http_error": "Грешка в комуникацията със сървъра",
+ "data_provider_error": "Грешка в доставчика на данни. Проверете конзолата за подробности.",
+ "i18n_error": "Не мога да заредя преводите за посочения език",
+ "canceled": "Действието е отменено",
+ "logged_out": "Вашата сесия приключи. Моля, влезте отново.",
+ "new_version": "Налична е нова версия! Моля, опреснете този прозорец."
+ },
+ "toggleFieldsMenu": {
+ "columnsToDisplay": "Колони за показване",
+ "layout": "Оформление",
+ "grid": "Решетка",
+ "table": "Таблица"
+ }
+ },
+ "message": {
+ "note": "ЗАБЕЛЕЖКА",
+ "transcodingDisabled": "Промяната на конфигурацията за транскодиране през уеб интерфейса е забранена от съображения за сигурност. Ако желаете да промените (редактирате или добавите) опциите за транскодиране, рестартирайте сървъра с конфигурационната опция %{config}.",
+ "transcodingEnabled": "Navidrome в момента работи с %{config}, което прави възможно стартирането на системни команди от настройките за транскодиране с помощта на уеб интерфейса. Препоръчваме да го деактивирате от съображения за сигурност и да го активирате само при конфигуриране на опциите за транскодиране.",
+ "songsAddedToPlaylist": "Добавена 1 песен към плейлиста |||| Добавени %{smart_count} песни към плейлиста",
+ "noPlaylistsAvailable": "Няма налични",
+ "delete_user_title": "Изтрий потребителя '%{name}'",
+ "delete_user_content": "Наистина ли желаете да изтриете този потребител и всичките му данни (включително плейлисти и предпочитания)?",
+ "notifications_blocked": "В настройките на браузъра сте блокирали известията за този сайт",
+ "notifications_not_available": "Този браузър не поддържа известия на работния плот или нямате достъп до Navidrome през https",
+ "lastfmLinkSuccess": "Връзката с Last.fm е успешна! Scrobbling е активиран",
+ "lastfmLinkFailure": "Last.fm не можа да бъде свързан",
+ "lastfmUnlinkSuccess": "Връзката с Last.fm е прекъсната! Scrobbling е деактивиран",
+ "lastfmUnlinkFailure": "Last.fm връзката не можа да бъде премахната",
+ "openIn": {
+ "lastfm": "Отвори в Last.fm",
+ "musicbrainz": "Отвори в MusicBrainz"
+ },
+ "lastfmLink": "Прочетете още...",
+ "listenBrainzLinkSuccess": "Връзката с ListenBrainz е успешна! Scrobbling е активиран от името на потребителя: %{user}",
+ "listenBrainzLinkFailure": "ListenBrainz не можа да бъде свързан: %{error}",
+ "listenBrainzUnlinkSuccess": "Връзката с ListenBrainz е прекъсната! Scrobbling е деактивиран",
+ "listenBrainzUnlinkFailure": "Връзката с ListenBrainz не можа да бъде прекратена",
+ "downloadOriginalFormat": "Свали в оригиналния формат",
+ "shareOriginalFormat": "Сподели в оригинален формат",
+ "shareDialogTitle": "Сподели %{resource} '%{name}'",
+ "shareBatchDialogTitle": "Сподели 1 %{resource} |||| Сподели %{smart_count} %{resource}",
+ "shareSuccess": "Адресът е копиран в клипборда: %{url}",
+ "shareFailure": "Грешка при копиране на адрес %{url} в клипборда",
+ "downloadDialogTitle": "Сваляне %{resource} '%{name}' (%{size})",
+ "shareCopyToClipboard": "Копиране в клипборда: Ctrl+C, Enter",
+ "remove_missing_title": "Премахни липсващите файлове",
+ "remove_missing_content": "Сигурни ли сте, че желаете да премахнете избраните липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.",
+ "remove_all_missing_title": "Премахни всички липсващи файлове",
+ "remove_all_missing_content": "Сигурни ли сте, че желаете да премахнете всички липсващи файлове от базата данни? Това ще премахне завинаги всички препратки към тях, включително броя на възпроизвежданията и оценките им.",
+ "noSimilarSongsFound": "",
+ "noTopSongsFound": ""
+ },
+ "menu": {
+ "library": "Библиотека",
+ "settings": "Настройки",
+ "version": "Версия",
+ "theme": "Тема",
+ "personal": {
+ "name": "Лични",
+ "options": {
+ "theme": "Тема",
+ "language": "Език",
+ "defaultView": "Изглед по подразбиране",
+ "desktop_notifications": "Известия на работния плот",
+ "lastfmScrobbling": "Scrobble към Last.fm",
+ "listenBrainzScrobbling": "Scrobble към ListenBrainz",
+ "replaygain": "Режим ReplayGain",
+ "preAmp": "ReplayGain PreAmp (dB)",
+ "gain": {
+ "none": "Изключен",
+ "album": "Използвай Album Gain",
+ "track": "Използвай Track Gain"
+ },
+ "lastfmNotConfigured": "API ключът на Last.fm не е конфигуриран"
+ }
+ },
+ "albumList": "Албуми",
+ "about": "Относно",
+ "playlists": "Плейлисти",
+ "sharedPlaylists": "Споделени плейлисти",
+ "librarySelector": {
+ "allLibraries": "",
+ "multipleLibraries": "",
+ "selectLibraries": "",
+ "none": ""
+ }
+ },
+ "player": {
+ "playListsText": "Списък с песни",
+ "openText": "Отвори",
+ "closeText": "Затвори",
+ "notContentText": "Няма песни",
+ "clickToPlayText": "Пускане",
+ "clickToPauseText": "Пауза",
+ "nextTrackText": "Следваща песен",
+ "previousTrackText": "Предишна песен",
+ "reloadText": "Презареди",
+ "volumeText": "Сила на звука",
+ "toggleLyricText": "Текст на песен",
+ "toggleMiniModeText": "Минимизирай",
+ "destroyText": "Унищожи",
+ "downloadText": "Свали",
+ "removeAudioListsText": "Изтриване на плейлисти",
+ "clickToDeleteText": "Кликнете, за да изтриете %{name}",
+ "emptyLyricText": "Няма текст",
+ "playModeText": {
+ "order": "По ред",
+ "orderLoop": "Повтаряй всички",
+ "singleLoop": "Повтаряй същата",
+ "shufflePlay": "Разбъркай"
+ }
+ },
+ "about": {
+ "links": {
+ "homepage": "Начална страница",
+ "source": "Програмен код",
+ "featureRequests": "Заявете функционалност",
+ "lastInsightsCollection": "",
+ "insights": {
+ "disabled": "Деактивиран",
+ "waiting": "Изчакване"
+ }
+ },
+ "tabs": {
+ "about": "Относно",
+ "config": "Конфигурация"
+ },
+ "config": {
+ "configName": "Име на конфигурация",
+ "environmentVariable": "Променлива на средата",
+ "currentValue": "Текуща стойност",
+ "configurationFile": "",
+ "exportToml": "Експортиране на конфигурация (TOML)",
+ "exportSuccess": "Конфигурация, експортирана в клипборда във формат TOML",
+ "exportFailed": "Неуспешно копиране на конфигурация",
+ "devFlagsHeader": "",
+ "devFlagsComment": ""
+ }
+ },
+ "activity": {
+ "title": "Действия",
+ "totalScanned": "Сканирани папки",
+ "quickScan": "Бързо сканиране",
+ "fullScan": "Пълно сканиране",
+ "serverUptime": "Сървърът работи",
+ "serverDown": "ОФЛАЙН",
+ "scanType": "Последно сканиране",
+ "status": "Грешка при сканиране",
+ "elapsedTime": "Изминало време",
+ "selectiveScan": ""
+ },
+ "help": {
+ "title": "Бързи клавиши на Navidrome",
+ "hotkeys": {
+ "show_help": "Показва този помощен текст",
+ "toggle_menu": "Превключване на страничната меню лента",
+ "toggle_play": "Пусни / Пауза",
+ "prev_song": "Предишна песен",
+ "next_song": "Следваща песен",
+ "vol_up": "Увеличи звука",
+ "vol_down": "Намали звука",
+ "toggle_love": "Добави песента към любими",
+ "current_song": "Премини към текущата песен"
+ }
+ },
+ "nowPlaying": {
+ "title": "",
+ "empty": "",
+ "minutesAgo": ""
+ }
}
\ No newline at end of file
diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json
index 897c3e310..fc2793389 100644
--- a/resources/i18n/fi.json
+++ b/resources/i18n/fi.json
@@ -31,7 +31,7 @@
"mood": "Tunnelma",
"participants": "Lisäosallistujat",
"tags": "Lisätunnisteet",
- "mappedTags": "Mäpättyt tunnisteet",
+ "mappedTags": "Mäpätyt tunnisteet",
"rawTags": "Raakatunnisteet",
"bitDepth": "Bittisyvyys",
"sampleRate": "Näytteenottotaajuus",
@@ -324,7 +324,7 @@
"pathInvalid": "Virheellinen kirjaston polku"
},
"messages": {
- "deleteConfirm": "Oletko varma, että haluat poistaa tämän kirjaston? Tämä poistaa kaikki liittyvät tiedot ja käyttäjien pääsyn.",
+ "deleteConfirm": "Haluatko varmasti poistaa tämän kirjaston? Kaikki siihen liittyvät tiedot ja käyttäjien pääsy poistetaan.",
"scanInProgress": "Skannaus käynnissä...",
"noLibrariesAssigned": "Tälle käyttäjälle ei ole määritetty kirjastoja"
}
@@ -341,7 +341,7 @@
"username": "Käyttäjänimi",
"password": "Salasana",
"sign_in": "Kirjaudu",
- "sign_in_error": "Autentikointi epäonnistui. Yritä uudelleen",
+ "sign_in_error": "Kirjautuminen epäonnistui. Yritä uudelleen",
"logout": "Kirjaudu ulos",
"insightsCollectionNote": "Navidrome kerää anonyymejä käyttötietoja auttaakseen parantamaan\nprojektia. Paina [tästä] saadaksesi lisätietoa\nja halutessasi kieltäytyä"
},
@@ -351,7 +351,7 @@
"required": "Pakollinen",
"minLength": "Pitää vähintään olla %{min} merkkiä",
"maxLength": "Saa olla enintään %{max} merkkiä",
- "minValue": "pitää olla vähintään %{min}",
+ "minValue": "Pitää olla vähintään %{min}",
"maxValue": "Saa olla enentään %{max}",
"number": "Pitää olla numero",
"email": "Pitää olla oikea sähköpostiosoite",
@@ -445,7 +445,7 @@
},
"navigation": {
"no_results": "Ei tuloksia",
- "no_more_results": "Sivunumero %{page} on rajojen ulkopuolella. Kokeile edellinen sivu.",
+ "no_more_results": "Sivunumeroa %{page} ei löydy. Yritä edellistä sivua.",
"page_out_of_boundaries": "Sivunumero %{page} on rajojen ulkopuolella",
"page_out_from_end": "Viimeinen sivu, ei voi edetä",
"page_out_from_begin": "Ensimmäinen sivu, ei voi palata",
@@ -527,7 +527,7 @@
"desktop_notifications": "Työpöytäilmoitukset",
"lastfmScrobbling": "Kuuntelutottumuksen lähetys Last.fm-palveluun",
"listenBrainzScrobbling": "Kuuntelutottumuksen lähetys ListenBrainz-palveluun",
- "replaygain": "RepleyGain -tila",
+ "replaygain": "ReplayGain -tila",
"preAmp": "ReplayGain esivahvistus (dB)",
"gain": {
"none": "Pois käytöstä",
@@ -559,7 +559,7 @@
"previousTrackText": "Edellinen kappale",
"reloadText": "Päivitä",
"volumeText": "Äänenvoimakkuus",
- "toggleLyricText": "Toggle lyric",
+ "toggleLyricText": "Näytä/piilota sanat",
"toggleMiniModeText": "Minimoi",
"destroyText": "Poista",
"downloadText": "Lataa",
@@ -618,7 +618,7 @@
"show_help": "Näytä tämä apuvalikko",
"toggle_menu": "Menuvalikko päälle ja pois",
"toggle_play": "Toista / Tauko",
- "prev_song": "Esellinen kappale",
+ "prev_song": "Edellinen kappale",
"next_song": "Seuraava kappale",
"vol_up": "Kovemmalle",
"vol_down": "Hiljemmalle",
From cc3cca607749dc086480f1af078fbbeb3fac2bdb Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Deluan=20Quint=C3=A3o?=
Date: Sat, 6 Dec 2025 12:05:38 -0500
Subject: [PATCH 102/102] fix(scanner): handle cross-library relative paths in
playlists (#4659)
* fix: handle cross-library relative paths in playlists
Playlists can now reference songs in other libraries using relative paths.
Previously, relative paths like '../Songs/abc.mp3' would not resolve correctly
when pointing to files in a different library than the playlist file.
The fix resolves relative paths to absolute paths first, then checks which
library they belong to using the library regex. This allows playlists to
reference files across library boundaries while maintaining backward
compatibility with existing single-library relative paths.
Fixes #4617
* fix: enhance playlist path normalization for cross-library support
Signed-off-by: Deluan
* refactor: improve handling of relative paths in playlists for cross-library compatibility
Signed-off-by: Deluan
* fix: ensure longest library path matches first to resolve prefix conflicts in playlists
Signed-off-by: Deluan
* test: refactor tests isolation
Signed-off-by: Deluan
* fix: enhance handling of library-qualified paths and improve cross-library playlist support
Signed-off-by: Deluan
* refactor: simplify mocks
Signed-off-by: Deluan
* fix: lint
Signed-off-by: Deluan
* fix: improve path resolution for cross-library playlists and enhance error handling
Signed-off-by: Deluan
* refactor
Signed-off-by: Deluan
* refactor: remove unnecessary path validation fallback
Remove validatePathInLibrary function and its fallback logic in
resolveRelativePath. The library matcher should always find the correct
library, including the playlist's own library. If this fails, we now
return an invalid resolution instead of attempting a fallback validation.
This simplifies the code by removing redundant validation logic that
was masking test setup issues. Also fixes test mock configuration to
properly set up library paths that match folder LibraryPath values.
* refactor: consolidate path resolution logic
Collapse resolveRelativePath and resolveAbsolutePath into a unified
resolvePath function, extracting common library matching logic into a
new findInLibraries helper method.
This eliminates duplicate code (~20 lines) while maintaining clear
separation of concerns: resolvePath handles path normalization
(relative vs absolute), and findInLibraries handles library matching.
Update tests to call resolvePath directly with appropriate parameters,
maintaining full test coverage for both absolute and relative path
scenarios.
Signed-off-by: Deluan
* docs: add FindByPaths comment
Signed-off-by: Deluan
* fix: enhance Unicode normalization for path comparisons in playlists. Fixes 4663
Signed-off-by: Deluan
---------
Signed-off-by: Deluan
---
core/playlists.go | 213 +++++++++++----
core/playlists_internal_test.go | 406 ++++++++++++++++++++++++++++
core/playlists_test.go | 359 ++++++++++++++++++++----
persistence/mediafile_repository.go | 35 ++-
4 files changed, 893 insertions(+), 120 deletions(-)
create mode 100644 core/playlists_internal_test.go
diff --git a/core/playlists.go b/core/playlists.go
index f98179f88..ed90cc23b 100644
--- a/core/playlists.go
+++ b/core/playlists.go
@@ -1,6 +1,7 @@
package core
import (
+ "cmp"
"context"
"encoding/json"
"errors"
@@ -9,7 +10,7 @@ import (
"net/url"
"os"
"path/filepath"
- "regexp"
+ "slices"
"strings"
"time"
@@ -194,22 +195,35 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
}
filteredLines = append(filteredLines, line)
}
- paths, err := s.normalizePaths(ctx, pls, folder, filteredLines)
+ resolvedPaths, err := s.resolvePaths(ctx, folder, filteredLines)
if err != nil {
- log.Warn(ctx, "Error normalizing paths in playlist", "playlist", pls.Name, err)
+ log.Warn(ctx, "Error resolving paths in playlist", "playlist", pls.Name, err)
continue
}
- found, err := mediaFileRepository.FindByPaths(paths)
+
+ // Normalize to NFD for filesystem compatibility (macOS). Database stores paths in NFD.
+ // See https://github.com/navidrome/navidrome/issues/4663
+ resolvedPaths = slice.Map(resolvedPaths, func(path string) string {
+ return strings.ToLower(norm.NFD.String(path))
+ })
+
+ found, err := mediaFileRepository.FindByPaths(resolvedPaths)
if err != nil {
log.Warn(ctx, "Error reading files from DB", "playlist", pls.Name, err)
continue
}
+ // Build lookup map with library-qualified keys, normalized for comparison
existing := make(map[string]int, len(found))
for idx := range found {
- existing[normalizePathForComparison(found[idx].Path)] = idx
+ // Normalize to lowercase for case-insensitive comparison
+ // Key format: "libraryID:path"
+ key := fmt.Sprintf("%d:%s", found[idx].LibraryID, strings.ToLower(found[idx].Path))
+ existing[key] = idx
}
- for _, path := range paths {
- idx, ok := existing[normalizePathForComparison(path)]
+
+ // Find media files in the order of the resolved paths, to keep playlist order
+ for _, path := range resolvedPaths {
+ idx, ok := existing[path]
if ok {
mfs = append(mfs, found[idx])
} else {
@@ -226,69 +240,150 @@ func (s *playlists) parseM3U(ctx context.Context, pls *model.Playlist, folder *m
return nil
}
-// normalizePathForComparison normalizes a file path to NFC form and converts to lowercase
-// for consistent comparison. This fixes Unicode normalization issues on macOS where
-// Apple Music creates playlists with NFC-encoded paths but the filesystem uses NFD.
-func normalizePathForComparison(path string) string {
- return strings.ToLower(norm.NFC.String(path))
+// pathResolution holds the result of resolving a playlist path to a library-relative path.
+type pathResolution struct {
+ absolutePath string
+ libraryPath string
+ libraryID int
+ valid bool
}
-// TODO This won't work for multiple libraries
-func (s *playlists) normalizePaths(ctx context.Context, pls *model.Playlist, folder *model.Folder, lines []string) ([]string, error) {
- libRegex, err := s.compileLibraryPaths(ctx)
+// ToQualifiedString converts the path resolution to a library-qualified string with forward slashes.
+// Format: "libraryID:relativePath" with forward slashes for path separators.
+func (r pathResolution) ToQualifiedString() (string, error) {
+ if !r.valid {
+ return "", fmt.Errorf("invalid path resolution")
+ }
+ relativePath, err := filepath.Rel(r.libraryPath, r.absolutePath)
if err != nil {
- return nil, err
+ return "", err
}
-
- res := make([]string, 0, len(lines))
- for idx, line := range lines {
- var libPath string
- var filePath string
-
- if folder != nil && !filepath.IsAbs(line) {
- libPath = folder.LibraryPath
- filePath = filepath.Join(folder.AbsolutePath(), line)
- } else {
- cleanLine := filepath.Clean(line)
- if libPath = libRegex.FindString(cleanLine); libPath != "" {
- filePath = cleanLine
- }
- }
-
- if libPath != "" {
- if rel, err := filepath.Rel(libPath, filePath); err == nil {
- res = append(res, rel)
- } else {
- log.Debug(ctx, "Error getting relative path", "playlist", pls.Name, "path", line, "libPath", libPath,
- "filePath", filePath, err)
- }
- } else {
- log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
- }
- }
- return slice.Map(res, filepath.ToSlash), nil
+ // Convert path separators to forward slashes
+ return fmt.Sprintf("%d:%s", r.libraryID, filepath.ToSlash(relativePath)), nil
}
-func (s *playlists) compileLibraryPaths(ctx context.Context) (*regexp.Regexp, error) {
- libs, err := s.ds.Library(ctx).GetAll()
- if err != nil {
- return nil, err
- }
+// libraryMatcher holds sorted libraries with cleaned paths for efficient path matching.
+type libraryMatcher struct {
+ libraries model.Libraries
+ cleanedPaths []string
+}
- // Create regex patterns for each library path
- patterns := make([]string, len(libs))
+// 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) {
+ // 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 0, ""
+}
+
+// newLibraryMatcher creates a libraryMatcher with libraries sorted by path length (longest first).
+// This ensures correct matching when library paths are prefixes of each other.
+// Example: /music-classical must be checked before /music
+// Otherwise, /music-classical/track.mp3 would match /music instead of /music-classical
+func newLibraryMatcher(libs model.Libraries) *libraryMatcher {
+ // Sort libraries by path length (descending) to ensure longest paths match first.
+ slices.SortFunc(libs, func(i, j model.Library) int {
+ return cmp.Compare(len(j.Path), len(i.Path)) // Reverse order for descending
+ })
+
+ // Pre-clean all library paths once for efficient matching
+ cleanedPaths := make([]string, len(libs))
for i, lib := range libs {
- cleanPath := filepath.Clean(lib.Path)
- escapedPath := regexp.QuoteMeta(cleanPath)
- patterns[i] = fmt.Sprintf("^%s(?:/|$)", escapedPath)
+ cleanedPaths[i] = filepath.Clean(lib.Path)
}
- // Combine all patterns into a single regex
- combinedPattern := strings.Join(patterns, "|")
- re, err := regexp.Compile(combinedPattern)
+ return &libraryMatcher{
+ libraries: libs,
+ cleanedPaths: cleanedPaths,
+ }
+}
+
+// pathResolver handles path resolution logic for playlist imports.
+type pathResolver struct {
+ matcher *libraryMatcher
+}
+
+// newPathResolver creates a pathResolver with libraries loaded from the datastore.
+func newPathResolver(ctx context.Context, ds model.DataStore) (*pathResolver, error) {
+ libs, err := ds.Library(ctx).GetAll()
if err != nil {
- return nil, fmt.Errorf("compiling library paths `%s`: %w", combinedPattern, err)
+ return nil, err
}
- return re, nil
+ matcher := newLibraryMatcher(libs)
+ return &pathResolver{matcher: matcher}, nil
+}
+
+// resolvePath determines the absolute path and library path for a playlist entry.
+// For absolute paths, it uses them directly.
+// For relative paths, it resolves them relative to the playlist's folder location.
+// Example: playlist at /music/playlists/test.m3u with line "../songs/abc.mp3"
+//
+// resolves to /music/songs/abc.mp3
+func (r *pathResolver) resolvePath(line string, folder *model.Folder) pathResolution {
+ var absolutePath string
+ if folder != nil && !filepath.IsAbs(line) {
+ // Resolve relative path to absolute path based on playlist location
+ absolutePath = filepath.Clean(filepath.Join(folder.AbsolutePath(), line))
+ } else {
+ // Use absolute path directly after cleaning
+ absolutePath = filepath.Clean(line)
+ }
+
+ return r.findInLibraries(absolutePath)
+}
+
+// findInLibraries matches an absolute path against all known libraries and returns
+// a pathResolution with the library information. Returns an invalid resolution if
+// the path is not found in any library.
+func (r *pathResolver) findInLibraries(absolutePath string) pathResolution {
+ libID, libPath := r.matcher.findLibraryForPath(absolutePath)
+ if libID == 0 {
+ return pathResolution{valid: false}
+ }
+ return pathResolution{
+ absolutePath: absolutePath,
+ libraryPath: libPath,
+ libraryID: libID,
+ valid: true,
+ }
+}
+
+// resolvePaths converts playlist file paths to library-qualified paths (format: "libraryID:relativePath").
+// For relative paths, it resolves them to absolute paths first, then determines which
+// library they belong to. This allows playlists to reference files across library boundaries.
+func (s *playlists) resolvePaths(ctx context.Context, folder *model.Folder, lines []string) ([]string, error) {
+ resolver, err := newPathResolver(ctx, s.ds)
+ if err != nil {
+ return nil, err
+ }
+
+ results := make([]string, 0, len(lines))
+ for idx, line := range lines {
+ resolution := resolver.resolvePath(line, folder)
+
+ if !resolution.valid {
+ log.Warn(ctx, "Path in playlist not found in any library", "path", line, "line", idx)
+ continue
+ }
+
+ qualifiedPath, err := resolution.ToQualifiedString()
+ if err != nil {
+ log.Debug(ctx, "Error getting library-qualified path", "path", line,
+ "libPath", resolution.libraryPath, "filePath", resolution.absolutePath, err)
+ continue
+ }
+
+ results = append(results, qualifiedPath)
+ }
+
+ return results, nil
}
func (s *playlists) updatePlaylist(ctx context.Context, newPls *model.Playlist) error {
diff --git a/core/playlists_internal_test.go b/core/playlists_internal_test.go
new file mode 100644
index 000000000..88e36cc3a
--- /dev/null
+++ b/core/playlists_internal_test.go
@@ -0,0 +1,406 @@
+package core
+
+import (
+ "context"
+
+ "github.com/navidrome/navidrome/model"
+ "github.com/navidrome/navidrome/tests"
+ . "github.com/onsi/ginkgo/v2"
+ . "github.com/onsi/gomega"
+)
+
+var _ = Describe("libraryMatcher", func() {
+ var ds *tests.MockDataStore
+ var mockLibRepo *tests.MockLibraryRepo
+ ctx := context.Background()
+
+ BeforeEach(func() {
+ mockLibRepo = &tests.MockLibraryRepo{}
+ ds = &tests.MockDataStore{
+ MockedLibrary: mockLibRepo,
+ }
+ })
+
+ // Helper function to create a libraryMatcher from the mock datastore
+ createMatcher := func(ds model.DataStore) *libraryMatcher {
+ libs, err := ds.Library(ctx).GetAll()
+ Expect(err).ToNot(HaveOccurred())
+ return newLibraryMatcher(libs)
+ }
+
+ Describe("Longest library path matching", func() {
+ It("matches the longest library path when multiple libraries share a prefix", func() {
+ // Setup libraries with prefix conflicts
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/music"},
+ {ID: 2, Path: "/music-classical"},
+ {ID: 3, Path: "/music-classical/opera"},
+ })
+
+ matcher := createMatcher(ds)
+
+ // Test that longest path matches first and returns correct library ID
+ testCases := []struct {
+ path string
+ expectedLibID int
+ expectedLibPath string
+ }{
+ {"/music-classical/opera/track.mp3", 3, "/music-classical/opera"},
+ {"/music-classical/track.mp3", 2, "/music-classical"},
+ {"/music/track.mp3", 1, "/music"},
+ {"/music-classical/opera/subdir/file.mp3", 3, "/music-classical/opera"},
+ }
+
+ for _, tc := range testCases {
+ libID, libPath := matcher.findLibraryForPath(tc.path)
+ Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d, but got %d", tc.path, tc.expectedLibID, libID)
+ Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s, but got %s", tc.path, tc.expectedLibPath, libPath)
+ }
+ })
+
+ It("handles libraries with similar prefixes but different structures", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/home/user/music"},
+ {ID: 2, Path: "/home/user/music-backup"},
+ })
+
+ matcher := createMatcher(ds)
+
+ // Test that music-backup library is matched correctly
+ libID, libPath := matcher.findLibraryForPath("/home/user/music-backup/track.mp3")
+ Expect(libID).To(Equal(2))
+ Expect(libPath).To(Equal("/home/user/music-backup"))
+
+ // Test that music library is still matched correctly
+ libID, libPath = matcher.findLibraryForPath("/home/user/music/track.mp3")
+ Expect(libID).To(Equal(1))
+ Expect(libPath).To(Equal("/home/user/music"))
+ })
+
+ It("matches path that is exactly the library root", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/music"},
+ {ID: 2, Path: "/music-classical"},
+ })
+
+ matcher := createMatcher(ds)
+
+ // Exact library path should match
+ libID, libPath := matcher.findLibraryForPath("/music-classical")
+ Expect(libID).To(Equal(2))
+ Expect(libPath).To(Equal("/music-classical"))
+ })
+
+ It("handles complex nested library structures", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/media"},
+ {ID: 2, Path: "/media/audio"},
+ {ID: 3, Path: "/media/audio/classical"},
+ {ID: 4, Path: "/media/audio/classical/baroque"},
+ })
+
+ matcher := createMatcher(ds)
+
+ testCases := []struct {
+ path string
+ expectedLibID int
+ expectedLibPath string
+ }{
+ {"/media/audio/classical/baroque/bach/track.mp3", 4, "/media/audio/classical/baroque"},
+ {"/media/audio/classical/mozart/track.mp3", 3, "/media/audio/classical"},
+ {"/media/audio/rock/track.mp3", 2, "/media/audio"},
+ {"/media/video/movie.mp4", 1, "/media"},
+ }
+
+ for _, tc := range testCases {
+ libID, libPath := matcher.findLibraryForPath(tc.path)
+ Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
+ Expect(libPath).To(Equal(tc.expectedLibPath), "Path %s should match library path %s", tc.path, tc.expectedLibPath)
+ }
+ })
+ })
+
+ Describe("Edge cases", func() {
+ It("handles empty library list", func() {
+ mockLibRepo.SetData([]model.Library{})
+
+ matcher := createMatcher(ds)
+ Expect(matcher).ToNot(BeNil())
+
+ // Should not match anything
+ libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
+ Expect(libID).To(Equal(0))
+ Expect(libPath).To(BeEmpty())
+ })
+
+ It("handles single library", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/music"},
+ })
+
+ matcher := createMatcher(ds)
+
+ libID, libPath := matcher.findLibraryForPath("/music/track.mp3")
+ Expect(libID).To(Equal(1))
+ Expect(libPath).To(Equal("/music"))
+ })
+
+ It("handles libraries with special characters in paths", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/music[test]"},
+ {ID: 2, Path: "/music(backup)"},
+ })
+
+ matcher := createMatcher(ds)
+ Expect(matcher).ToNot(BeNil())
+
+ // Special characters should match literally
+ libID, libPath := matcher.findLibraryForPath("/music[test]/track.mp3")
+ Expect(libID).To(Equal(1))
+ Expect(libPath).To(Equal("/music[test]"))
+ })
+ })
+
+ Describe("Path matching order", func() {
+ It("ensures longest paths match first", func() {
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/a"},
+ {ID: 2, Path: "/ab"},
+ {ID: 3, Path: "/abc"},
+ })
+
+ matcher := createMatcher(ds)
+
+ // Verify that longer paths match correctly (not cut off by shorter prefix)
+ testCases := []struct {
+ path string
+ expectedLibID int
+ }{
+ {"/abc/file.mp3", 3},
+ {"/ab/file.mp3", 2},
+ {"/a/file.mp3", 1},
+ }
+
+ for _, tc := range testCases {
+ libID, _ := matcher.findLibraryForPath(tc.path)
+ Expect(libID).To(Equal(tc.expectedLibID), "Path %s should match library ID %d", tc.path, tc.expectedLibID)
+ }
+ })
+ })
+})
+
+var _ = Describe("pathResolver", func() {
+ var ds *tests.MockDataStore
+ var mockLibRepo *tests.MockLibraryRepo
+ var resolver *pathResolver
+ ctx := context.Background()
+
+ BeforeEach(func() {
+ mockLibRepo = &tests.MockLibraryRepo{}
+ ds = &tests.MockDataStore{
+ MockedLibrary: mockLibRepo,
+ }
+
+ // Setup test libraries
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: "/music"},
+ {ID: 2, Path: "/music-classical"},
+ {ID: 3, Path: "/podcasts"},
+ })
+
+ var err error
+ resolver, err = newPathResolver(ctx, ds)
+ Expect(err).ToNot(HaveOccurred())
+ })
+
+ Describe("resolvePath", func() {
+ It("resolves absolute paths", func() {
+ resolution := resolver.resolvePath("/music/artist/album/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(1))
+ Expect(resolution.libraryPath).To(Equal("/music"))
+ Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
+ })
+
+ It("resolves relative paths when folder is provided", func() {
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ resolution := resolver.resolvePath("../artist/album/track.mp3", folder)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(1))
+ Expect(resolution.absolutePath).To(Equal("/music/artist/album/track.mp3"))
+ })
+
+ It("returns invalid resolution for paths outside any library", func() {
+ resolution := resolver.resolvePath("/outside/library/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeFalse())
+ })
+ })
+
+ Describe("resolvePath", func() {
+ Context("With absolute paths", func() {
+ It("resolves path within a library", func() {
+ resolution := resolver.resolvePath("/music/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(1))
+ Expect(resolution.libraryPath).To(Equal("/music"))
+ Expect(resolution.absolutePath).To(Equal("/music/track.mp3"))
+ })
+
+ It("resolves path to the longest matching library", func() {
+ resolution := resolver.resolvePath("/music-classical/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(2))
+ Expect(resolution.libraryPath).To(Equal("/music-classical"))
+ })
+
+ It("returns invalid resolution for path outside libraries", func() {
+ resolution := resolver.resolvePath("/videos/movie.mp4", nil)
+
+ Expect(resolution.valid).To(BeFalse())
+ })
+
+ It("cleans the path before matching", func() {
+ resolution := resolver.resolvePath("/music//artist/../artist/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.absolutePath).To(Equal("/music/artist/track.mp3"))
+ })
+ })
+
+ Context("With relative paths", func() {
+ It("resolves relative path within same library", func() {
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ resolution := resolver.resolvePath("../songs/track.mp3", folder)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(1))
+ Expect(resolution.absolutePath).To(Equal("/music/songs/track.mp3"))
+ })
+
+ It("resolves relative path to different library", func() {
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ // Path goes up and into a different library
+ resolution := resolver.resolvePath("../../podcasts/episode.mp3", folder)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(3))
+ Expect(resolution.libraryPath).To(Equal("/podcasts"))
+ })
+
+ It("uses matcher to find correct library for resolved path", func() {
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ // This relative path resolves to music-classical library
+ resolution := resolver.resolvePath("../../music-classical/track.mp3", folder)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(2))
+ Expect(resolution.libraryPath).To(Equal("/music-classical"))
+ })
+
+ It("returns invalid for relative paths escaping all libraries", func() {
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ resolution := resolver.resolvePath("../../../../etc/passwd", folder)
+
+ Expect(resolution.valid).To(BeFalse())
+ })
+ })
+ })
+
+ Describe("Cross-library resolution scenarios", func() {
+ It("handles playlist in library A referencing file in library B", func() {
+ // Playlist is in /music/playlists
+ folder := &model.Folder{
+ Path: "playlists",
+ LibraryPath: "/music",
+ LibraryID: 1,
+ }
+
+ // Relative path that goes to /podcasts library
+ resolution := resolver.resolvePath("../../podcasts/show/episode.mp3", folder)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(3), "Should resolve to podcasts library")
+ Expect(resolution.libraryPath).To(Equal("/podcasts"))
+ })
+
+ It("prefers longer library paths when resolving", func() {
+ // Ensure /music-classical is matched instead of /music
+ resolution := resolver.resolvePath("/music-classical/baroque/track.mp3", nil)
+
+ Expect(resolution.valid).To(BeTrue())
+ Expect(resolution.libraryID).To(Equal(2), "Should match /music-classical, not /music")
+ })
+ })
+})
+
+var _ = Describe("pathResolution", func() {
+ Describe("ToQualifiedString", func() {
+ It("converts valid resolution to qualified string with forward slashes", func() {
+ resolution := pathResolution{
+ absolutePath: "/music/artist/album/track.mp3",
+ libraryPath: "/music",
+ libraryID: 1,
+ valid: true,
+ }
+
+ qualifiedStr, err := resolution.ToQualifiedString()
+
+ Expect(err).ToNot(HaveOccurred())
+ Expect(qualifiedStr).To(Equal("1:artist/album/track.mp3"))
+ })
+
+ It("handles Windows-style paths by converting to forward slashes", func() {
+ resolution := pathResolution{
+ absolutePath: "/music/artist/album/track.mp3",
+ libraryPath: "/music",
+ libraryID: 2,
+ valid: true,
+ }
+
+ qualifiedStr, err := resolution.ToQualifiedString()
+
+ Expect(err).ToNot(HaveOccurred())
+ // Should always use forward slashes regardless of OS
+ Expect(qualifiedStr).To(ContainSubstring("2:"))
+ Expect(qualifiedStr).ToNot(ContainSubstring("\\"))
+ })
+
+ It("returns error for invalid resolution", func() {
+ resolution := pathResolution{valid: false}
+
+ _, err := resolution.ToQualifiedString()
+
+ Expect(err).To(HaveOccurred())
+ })
+ })
+})
diff --git a/core/playlists_test.go b/core/playlists_test.go
index fb42f9c9f..6aa8aac9a 100644
--- a/core/playlists_test.go
+++ b/core/playlists_test.go
@@ -1,4 +1,4 @@
-package core
+package core_test
import (
"context"
@@ -9,6 +9,7 @@ import (
"github.com/navidrome/navidrome/conf"
"github.com/navidrome/navidrome/conf/configtest"
+ "github.com/navidrome/navidrome/core"
"github.com/navidrome/navidrome/model"
"github.com/navidrome/navidrome/model/criteria"
"github.com/navidrome/navidrome/model/request"
@@ -20,7 +21,7 @@ import (
var _ = Describe("Playlists", func() {
var ds *tests.MockDataStore
- var ps Playlists
+ var ps core.Playlists
var mockPlsRepo mockedPlaylistRepo
var mockLibRepo *tests.MockLibraryRepo
ctx := context.Background()
@@ -33,16 +34,16 @@ var _ = Describe("Playlists", func() {
MockedLibrary: mockLibRepo,
}
ctx = request.WithUser(ctx, model.User{ID: "123"})
- // Path should be libPath, but we want to match the root folder referenced in the m3u, which is `/`
- mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/"}})
})
Describe("ImportFile", func() {
var folder *model.Folder
BeforeEach(func() {
- ps = NewPlaylists(ds)
+ ps = core.NewPlaylists(ds)
ds.MockedMediaFile = &mockedMediaFileRepo{}
libPath, _ := os.Getwd()
+ // Set up library with the actual library path that matches the folder
+ mockLibRepo.SetData([]model.Library{{ID: 1, Path: libPath}})
folder = &model.Folder{
ID: "1",
LibraryID: 1,
@@ -112,6 +113,224 @@ var _ = Describe("Playlists", func() {
Expect(err.Error()).To(ContainSubstring("line 19, column 1: invalid character '\\n'"))
})
})
+
+ Describe("Cross-library relative paths", func() {
+ var tmpDir, plsDir, songsDir string
+
+ BeforeEach(func() {
+ // Create temp directory structure
+ tmpDir = GinkgoT().TempDir()
+ plsDir = tmpDir + "/playlists"
+ songsDir = tmpDir + "/songs"
+ Expect(os.Mkdir(plsDir, 0755)).To(Succeed())
+ Expect(os.Mkdir(songsDir, 0755)).To(Succeed())
+
+ // Setup two different libraries with paths matching our temp structure
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: songsDir},
+ {ID: 2, Path: plsDir},
+ })
+
+ // Create a mock media file repository that returns files for both libraries
+ // Note: The paths are relative to their respective library roots
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{
+ data: []string{
+ "abc.mp3", // This is songs/abc.mp3 relative to songsDir
+ "def.mp3", // This is playlists/def.mp3 relative to plsDir
+ },
+ }
+ ps = core.NewPlaylists(ds)
+ })
+
+ It("handles relative paths that reference files in other libraries", func() {
+ // Create a temporary playlist file with relative path
+ plsContent := "#PLAYLIST:Cross Library Test\n../songs/abc.mp3\ndef.mp3"
+ plsFile := plsDir + "/test.m3u"
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ // Playlist is in the Playlists library folder
+ // Important: Path should be relative to LibraryPath, and Name is the folder name
+ plsFolder := &model.Folder{
+ ID: "2",
+ LibraryID: 2,
+ LibraryPath: plsDir,
+ Path: "",
+ Name: "",
+ }
+
+ pls, err := ps.ImportFile(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
+ Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library
+ })
+
+ It("ignores paths that point outside all libraries", func() {
+ // Create a temporary playlist file with path outside libraries
+ plsContent := "#PLAYLIST:Outside Test\n../../outside.mp3\nabc.mp3"
+ plsFile := plsDir + "/test.m3u"
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ plsFolder := &model.Folder{
+ ID: "2",
+ LibraryID: 2,
+ LibraryPath: plsDir,
+ Path: "",
+ Name: "",
+ }
+
+ pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+ // Should only find abc.mp3, not outside.mp3
+ Expect(pls.Tracks).To(HaveLen(1))
+ Expect(pls.Tracks[0].Path).To(Equal("abc.mp3"))
+ })
+
+ It("handles relative paths with multiple '../' components", func() {
+ // Create a nested structure: tmpDir/playlists/subfolder/test.m3u
+ subFolder := plsDir + "/subfolder"
+ Expect(os.Mkdir(subFolder, 0755)).To(Succeed())
+
+ // Create the media file in the subfolder directory
+ // The mock will return it as "def.mp3" relative to plsDir
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{
+ data: []string{
+ "abc.mp3", // From songsDir library
+ "def.mp3", // From plsDir library root
+ },
+ }
+
+ // From subfolder, ../../songs/abc.mp3 should resolve to songs library
+ // ../def.mp3 should resolve to plsDir/def.mp3
+ plsContent := "#PLAYLIST:Nested Test\n../../songs/abc.mp3\n../def.mp3"
+ plsFile := subFolder + "/test.m3u"
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ // The folder: AbsolutePath = LibraryPath + Path + Name
+ // So for /playlists/subfolder: LibraryPath=/playlists, Path="", Name="subfolder"
+ plsFolder := &model.Folder{
+ ID: "2",
+ LibraryID: 2,
+ LibraryPath: plsDir,
+ Path: "", // Empty because subfolder is directly under library root
+ Name: "subfolder", // The folder name
+ }
+
+ pls, err := ps.ImportFile(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
+ Expect(pls.Tracks[1].Path).To(Equal("def.mp3")) // From plsDir library root
+ })
+
+ It("correctly resolves libraries when one path is a prefix of another", func() {
+ // This tests the bug where /music would match before /music-classical
+ // Create temp directory structure with prefix conflict
+ tmpDir := GinkgoT().TempDir()
+ musicDir := tmpDir + "/music"
+ musicClassicalDir := tmpDir + "/music-classical"
+ Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
+ Expect(os.Mkdir(musicClassicalDir, 0755)).To(Succeed())
+
+ // Setup two libraries where one is a prefix of the other
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: musicDir}, // /tmp/xxx/music
+ {ID: 2, Path: musicClassicalDir}, // /tmp/xxx/music-classical
+ })
+
+ // Mock will return tracks from both libraries
+ ds.MockedMediaFile = &mockedMediaFileFromListRepo{
+ data: []string{
+ "rock.mp3", // From music library
+ "bach.mp3", // From music-classical library
+ },
+ }
+
+ // Create playlist in music library that references music-classical
+ plsContent := "#PLAYLIST:Cross Prefix Test\nrock.mp3\n../music-classical/bach.mp3"
+ plsFile := musicDir + "/test.m3u"
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ plsFolder := &model.Folder{
+ ID: "1",
+ LibraryID: 1,
+ LibraryPath: musicDir,
+ Path: "",
+ Name: "",
+ }
+
+ pls, err := ps.ImportFile(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
+ Expect(pls.Tracks[1].Path).To(Equal("bach.mp3")) // From music-classical library (not music!)
+ })
+
+ It("correctly handles identical relative paths from different libraries", func() {
+ // This tests the bug where two libraries have files at the same relative path
+ // and only one appears in the playlist
+ tmpDir := GinkgoT().TempDir()
+ musicDir := tmpDir + "/music"
+ classicalDir := tmpDir + "/classical"
+ Expect(os.Mkdir(musicDir, 0755)).To(Succeed())
+ Expect(os.Mkdir(classicalDir, 0755)).To(Succeed())
+ Expect(os.MkdirAll(musicDir+"/album", 0755)).To(Succeed())
+ Expect(os.MkdirAll(classicalDir+"/album", 0755)).To(Succeed())
+ // Create placeholder files so paths resolve correctly
+ Expect(os.WriteFile(musicDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
+ Expect(os.WriteFile(classicalDir+"/album/track.mp3", []byte{}, 0600)).To(Succeed())
+
+ // Both libraries have a file at "album/track.mp3"
+ mockLibRepo.SetData([]model.Library{
+ {ID: 1, Path: musicDir},
+ {ID: 2, Path: classicalDir},
+ })
+
+ // Mock returns files with same relative path but different IDs and library IDs
+ // Keys use the library-qualified format: "libraryID:path"
+ ds.MockedMediaFile = &mockedMediaFileRepo{
+ data: map[string]model.MediaFile{
+ "1:album/track.mp3": {ID: "music-track", Path: "album/track.mp3", LibraryID: 1, Title: "Rock Song"},
+ "2:album/track.mp3": {ID: "classical-track", Path: "album/track.mp3", LibraryID: 2, Title: "Classical Piece"},
+ },
+ }
+ // Recreate playlists service to pick up new mock
+ ps = core.NewPlaylists(ds)
+
+ // Create playlist in music library that references both tracks
+ plsContent := "#PLAYLIST:Same Path Test\nalbum/track.mp3\n../classical/album/track.mp3"
+ plsFile := musicDir + "/test.m3u"
+ Expect(os.WriteFile(plsFile, []byte(plsContent), 0600)).To(Succeed())
+
+ plsFolder := &model.Folder{
+ ID: "1",
+ LibraryID: 1,
+ LibraryPath: musicDir,
+ Path: "",
+ Name: "",
+ }
+
+ pls, err := ps.ImportFile(ctx, plsFolder, "test.m3u")
+ Expect(err).ToNot(HaveOccurred())
+
+ // Should have BOTH tracks, not just one
+ Expect(pls.Tracks).To(HaveLen(2), "Playlist should contain both tracks with same relative path")
+
+ // Verify we got tracks from DIFFERENT libraries (the key fix!)
+ // Collect the library IDs
+ libIDs := make(map[int]bool)
+ for _, track := range pls.Tracks {
+ libIDs[track.LibraryID] = true
+ }
+ Expect(libIDs).To(HaveLen(2), "Tracks should come from two different libraries")
+ Expect(libIDs[1]).To(BeTrue(), "Should have track from library 1")
+ Expect(libIDs[2]).To(BeTrue(), "Should have track from library 2")
+
+ // Both tracks should have the same relative path
+ Expect(pls.Tracks[0].Path).To(Equal("album/track.mp3"))
+ Expect(pls.Tracks[1].Path).To(Equal("album/track.mp3"))
+ })
+ })
})
Describe("ImportM3U", func() {
@@ -119,7 +338,7 @@ var _ = Describe("Playlists", func() {
BeforeEach(func() {
repo = &mockedMediaFileFromListRepo{}
ds.MockedMediaFile = repo
- ps = NewPlaylists(ds)
+ ps = core.NewPlaylists(ds)
mockLibRepo.SetData([]model.Library{{ID: 1, Path: "/music"}, {ID: 2, Path: "/new"}})
ctx = request.WithUser(ctx, model.User{ID: "123"})
})
@@ -206,53 +425,23 @@ var _ = Describe("Playlists", func() {
Expect(pls.Tracks[0].Path).To(Equal("abc/tEsT1.Mp3"))
})
- It("handles Unicode normalization when comparing paths", func() {
- // Test case for Apple Music playlists that use NFC encoding vs macOS filesystem NFD
- // The character "è" can be represented as NFC (single codepoint) or NFD (e + combining accent)
-
- const pathWithAccents = "artist/Michèle Desrosiers/album/Noël.m4a"
-
- // Simulate a database entry with NFD encoding (as stored by macOS filesystem)
- nfdPath := norm.NFD.String(pathWithAccents)
+ It("handles Unicode normalization when comparing paths (NFD vs NFC)", func() {
+ // Simulate macOS filesystem: stores paths in NFD (decomposed) form
+ // "è" (U+00E8) in NFC becomes "e" + "◌̀" (U+0065 + U+0300) in NFD
+ nfdPath := "artist/Mich" + string([]rune{'e', '\u0300'}) + "le/song.mp3" // NFD: e + combining grave
repo.data = []string{nfdPath}
- // Simulate an Apple Music M3U playlist entry with NFC encoding
- nfcPath := norm.NFC.String("/music/" + pathWithAccents)
- m3u := strings.Join([]string{
- nfcPath,
- }, "\n")
+ // Simulate Apple Music M3U: uses NFC (composed) form
+ nfcPath := "/music/artist/Mich\u00E8le/song.mp3" // NFC: single è character
+ m3u := nfcPath + "\n"
f := strings.NewReader(m3u)
-
pls, err := ps.ImportM3U(ctx, f)
Expect(err).ToNot(HaveOccurred())
- Expect(pls.Tracks).To(HaveLen(1), "Should find the track despite Unicode normalization differences")
+ Expect(pls.Tracks).To(HaveLen(1))
+ // Should match despite different Unicode normalization forms
Expect(pls.Tracks[0].Path).To(Equal(nfdPath))
})
- })
- Describe("normalizePathForComparison", func() {
- It("normalizes Unicode characters to NFC form and converts to lowercase", func() {
- // Test with NFD (decomposed) input - as would come from macOS filesystem
- nfdPath := norm.NFD.String("Michèle") // Explicitly convert to NFD form
- normalized := normalizePathForComparison(nfdPath)
- Expect(normalized).To(Equal("michèle"))
-
- // Test with NFC (composed) input - as would come from Apple Music M3U
- nfcPath := "Michèle" // This might be in NFC form
- normalizedNfc := normalizePathForComparison(nfcPath)
-
- // Ensure the two paths are not equal in their original forms
- Expect(nfdPath).ToNot(Equal(nfcPath))
-
- // Both should normalize to the same result
- Expect(normalized).To(Equal(normalizedNfc))
- })
-
- It("handles paths with mixed case and Unicode characters", func() {
- path := "Artist/Noël Coward/Album/Song.mp3"
- normalized := normalizePathForComparison(path)
- Expect(normalized).To(Equal("artist/noël coward/album/song.mp3"))
- })
})
Describe("InPlaylistsPath", func() {
@@ -269,27 +458,27 @@ var _ = Describe("Playlists", func() {
It("returns true if PlaylistsPath is empty", func() {
conf.Server.PlaylistsPath = ""
- Expect(InPlaylistsPath(folder)).To(BeTrue())
+ Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns true if PlaylistsPath is any (**/**)", func() {
conf.Server.PlaylistsPath = "**/**"
- Expect(InPlaylistsPath(folder)).To(BeTrue())
+ Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns true if folder is in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other/**:playlists/**"
- Expect(InPlaylistsPath(folder)).To(BeTrue())
+ Expect(core.InPlaylistsPath(folder)).To(BeTrue())
})
It("returns false if folder is not in PlaylistsPath", func() {
conf.Server.PlaylistsPath = "other"
- Expect(InPlaylistsPath(folder)).To(BeFalse())
+ Expect(core.InPlaylistsPath(folder)).To(BeFalse())
})
It("returns true if for a playlist in root of MusicFolder if PlaylistsPath is '.'", func() {
conf.Server.PlaylistsPath = "."
- Expect(InPlaylistsPath(folder)).To(BeFalse())
+ Expect(core.InPlaylistsPath(folder)).To(BeFalse())
folder2 := model.Folder{
LibraryPath: "/music",
@@ -297,22 +486,47 @@ var _ = Describe("Playlists", func() {
Name: ".",
}
- Expect(InPlaylistsPath(folder2)).To(BeTrue())
+ Expect(core.InPlaylistsPath(folder2)).To(BeTrue())
})
})
})
-// mockedMediaFileRepo's FindByPaths method returns a list of MediaFiles with the same paths as the input
+// mockedMediaFileRepo's FindByPaths method returns MediaFiles for the given paths.
+// If data map is provided, looks up files by key; otherwise creates them from paths.
type mockedMediaFileRepo struct {
model.MediaFileRepository
+ data map[string]model.MediaFile
}
func (r *mockedMediaFileRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
+
+ // If data map provided, look up files
+ if r.data != nil {
+ for _, path := range paths {
+ if mf, ok := r.data[path]; ok {
+ mfs = append(mfs, mf)
+ }
+ }
+ return mfs, nil
+ }
+
+ // Otherwise, create MediaFiles from paths
for idx, path := range paths {
+ // Strip library qualifier if present (format: "libraryID:path")
+ actualPath := path
+ libraryID := 1
+ if parts := strings.SplitN(path, ":", 2); len(parts) == 2 {
+ if id, err := strconv.Atoi(parts[0]); err == nil {
+ libraryID = id
+ actualPath = parts[1]
+ }
+ }
+
mfs = append(mfs, model.MediaFile{
- ID: strconv.Itoa(idx),
- Path: path,
+ ID: strconv.Itoa(idx),
+ Path: actualPath,
+ LibraryID: libraryID,
})
}
return mfs, nil
@@ -324,13 +538,38 @@ type mockedMediaFileFromListRepo struct {
data []string
}
-func (r *mockedMediaFileFromListRepo) FindByPaths([]string) (model.MediaFiles, error) {
+func (r *mockedMediaFileFromListRepo) FindByPaths(paths []string) (model.MediaFiles, error) {
var mfs model.MediaFiles
- for idx, path := range r.data {
- mfs = append(mfs, model.MediaFile{
- ID: strconv.Itoa(idx),
- Path: path,
- })
+
+ for idx, dataPath := range r.data {
+ // Normalize the data path to NFD (simulates macOS filesystem storage)
+ normalizedDataPath := norm.NFD.String(dataPath)
+
+ for _, requestPath := range paths {
+ // Strip library qualifier if present (format: "libraryID:path")
+ actualPath := requestPath
+ libraryID := 1
+ if parts := strings.SplitN(requestPath, ":", 2); len(parts) == 2 {
+ if id, err := strconv.Atoi(parts[0]); err == nil {
+ libraryID = id
+ actualPath = parts[1]
+ }
+ }
+
+ // The request path should already be normalized to NFD by production code
+ // before calling FindByPaths (to match DB storage)
+ normalizedRequestPath := norm.NFD.String(actualPath)
+
+ // Case-insensitive comparison (like SQL's "collate nocase")
+ if strings.EqualFold(normalizedRequestPath, normalizedDataPath) {
+ mfs = append(mfs, model.MediaFile{
+ ID: strconv.Itoa(idx),
+ Path: dataPath, // Return original path from DB
+ LibraryID: libraryID,
+ })
+ break
+ }
+ }
}
return mfs, nil
}
diff --git a/persistence/mediafile_repository.go b/persistence/mediafile_repository.go
index 8f32accc6..4749bb0be 100644
--- a/persistence/mediafile_repository.go
+++ b/persistence/mediafile_repository.go
@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"slices"
+ "strconv"
+ "strings"
"sync"
"time"
@@ -193,12 +195,43 @@ func (r *mediaFileRepository) GetCursor(options ...model.QueryOptions) (model.Me
}, nil
}
+// FindByPaths finds media files by their paths.
+// The paths can be library-qualified (format: "libraryID:path") or unqualified ("path").
+// Library-qualified paths search within the specified library, while unqualified paths
+// search across all libraries for backward compatibility.
func (r *mediaFileRepository) FindByPaths(paths []string) (model.MediaFiles, error) {
- sel := r.newSelect().Columns("*").Where(Eq{"path collate nocase": paths})
+ query := Or{}
+
+ for _, path := range paths {
+ parts := strings.SplitN(path, ":", 2)
+ if len(parts) == 2 {
+ // Library-qualified path: "libraryID:path"
+ libraryID, err := strconv.Atoi(parts[0])
+ if err != nil {
+ // Invalid format, skip
+ continue
+ }
+ relativePath := parts[1]
+ query = append(query, And{
+ Eq{"path collate nocase": relativePath},
+ Eq{"library_id": libraryID},
+ })
+ } else {
+ // Unqualified path: search across all libraries
+ query = append(query, Eq{"path collate nocase": path})
+ }
+ }
+
+ if len(query) == 0 {
+ return model.MediaFiles{}, nil
+ }
+
+ sel := r.newSelect().Columns("*").Where(query)
var res dbMediaFiles
if err := r.queryAll(sel, &res); err != nil {
return nil, err
}
+
return res.toModels(), nil
}