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 ( + + ) +} + +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 }) => ( + +) + 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 = ({