From a92a829ca7b717d3c6384a205c69ad31ce178ae1 Mon Sep 17 00:00:00 2001 From: jeffvli Date: Mon, 17 Nov 2025 01:46:04 -0800 Subject: [PATCH] add global music folder selector --- package.json | 2 +- pnpm-lock.yaml | 170 ++++---- src/i18n/locales/en.json | 3 + src/renderer/api/controller.ts | 61 ++- .../api/navidrome/navidrome-controller.ts | 14 + .../api/subsonic/subsonic-controller.ts | 2 +- src/renderer/api/utils-music-folder.ts | 24 ++ .../components/album-list-header-filters.tsx | 2 - .../albums/hooks/use-album-list-filters.ts | 4 - .../album-artist-list-header-filters.tsx | 2 - .../components/artist-list-header-filters.tsx | 2 - .../hooks/use-album-artist-list-filters.ts | 4 - .../artists/hooks/use-artist-list-filters.ts | 4 - .../components/genre-list-header-filters.tsx | 2 - .../genres/hooks/use-genre-list-filters.ts | 4 - .../sidebar/components/action-bar.tsx | 27 +- .../components/server-selector-items.tsx | 163 +++++++ .../components/server-selector.module.css | 46 ++ .../sidebar/components/server-selector.tsx | 97 +++++ .../sidebar/components/sidebar.module.css | 9 +- .../features/sidebar/components/sidebar.tsx | 27 +- .../components/song-list-header-filters.tsx | 2 - .../songs/hooks/use-song-list-filters.ts | 4 - .../features/titlebar/components/app-menu.tsx | 403 ++++++++++-------- src/renderer/store/auth.store.ts | 27 +- src/shared/api/navidrome/navidrome-types.ts | 4 + src/shared/types/domain-types.ts | 23 +- src/shared/types/features-types.ts | 1 + 28 files changed, 782 insertions(+), 351 deletions(-) create mode 100644 src/renderer/api/utils-music-folder.ts create mode 100644 src/renderer/features/sidebar/components/server-selector-items.tsx create mode 100644 src/renderer/features/sidebar/components/server-selector.module.css create mode 100644 src/renderer/features/sidebar/components/server-selector.tsx diff --git a/package.json b/package.json index fe1c63645..ebddb7406 100644 --- a/package.json +++ b/package.json @@ -127,7 +127,7 @@ "semver": "^7.5.4", "string-to-color": "^2.2.2", "ws": "^8.18.2", - "zod": "^4.1.12", + "zod": "^3.22.3", "zustand": "^5.0.5" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ebba5e223..afee425d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,7 +61,7 @@ importers: version: 5.90.11(@tanstack/react-query@5.90.9(react@19.1.0))(react@19.1.0) '@ts-rest/core': specifier: ^3.52.1 - version: 3.52.1(@types/node@24.10.1)(zod@4.1.12) + version: 3.52.1(@types/node@24.10.1)(zod@3.25.76) '@types/react-window': specifier: ^1.8.8 version: 1.8.8 @@ -121,7 +121,7 @@ importers: version: 7.1.0 i18next: specifier: ^25.6.2 - version: 25.6.2(typescript@5.9.3) + version: 25.6.2(typescript@5.8.3) idb-keyval: specifier: ^6.2.2 version: 6.2.2 @@ -175,7 +175,7 @@ importers: version: 5.0.0(react@19.1.0) react-i18next: specifier: ^16.3.3 - version: 16.3.3(i18next@25.6.2(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3) + version: 16.3.3(i18next@25.6.2(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3) react-icons: specifier: ^5.5.0 version: 5.5.0(react@19.1.0) @@ -210,8 +210,8 @@ importers: specifier: ^8.18.2 version: 8.18.2 zod: - specifier: ^4.1.12 - version: 4.1.12 + specifier: ^3.22.3 + version: 3.25.76 zustand: specifier: ^5.0.5 version: 5.0.8(@types/react@19.2.5)(immer@10.2.0)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)) @@ -221,7 +221,7 @@ importers: version: 3.0.0(eslint@9.27.0)(prettier@3.6.2) '@electron-toolkit/eslint-config-ts': specifier: ^3.0.0 - version: 3.1.0(eslint@9.27.0)(typescript@5.9.3) + version: 3.1.0(eslint@9.27.0)(typescript@5.8.3) '@electron-toolkit/tsconfig': specifier: ^2.0.0 version: 2.0.0(@types/node@24.10.1) @@ -275,7 +275,7 @@ importers: version: 9.27.0 eslint-plugin-perfectionist: specifier: ^4.13.0 - version: 4.13.0(eslint@9.27.0)(typescript@5.9.3) + version: 4.13.0(eslint@9.27.0)(typescript@5.8.3) eslint-plugin-prettier: specifier: ^5.4.0 version: 5.4.0(eslint-config-prettier@10.1.5(eslint@9.27.0))(eslint@9.27.0)(prettier@3.6.2) @@ -305,19 +305,19 @@ importers: version: 2.5.19(prettier@3.6.2) stylelint: specifier: ^16.25.0 - version: 16.25.0(typescript@5.9.3) + version: 16.25.0(typescript@5.8.3) stylelint-config-css-modules: specifier: ^4.5.1 - version: 4.5.1(stylelint@16.25.0(typescript@5.9.3)) + version: 4.5.1(stylelint@16.25.0(typescript@5.8.3)) stylelint-config-recess-order: specifier: ^7.4.0 - version: 7.4.0(stylelint-order@6.0.4(stylelint@16.25.0(typescript@5.9.3)))(stylelint@16.25.0(typescript@5.9.3)) + version: 7.4.0(stylelint-order@6.0.4(stylelint@16.25.0(typescript@5.8.3)))(stylelint@16.25.0(typescript@5.8.3)) stylelint-config-standard: specifier: ^39.0.1 - version: 39.0.1(stylelint@16.25.0(typescript@5.9.3)) + version: 39.0.1(stylelint@16.25.0(typescript@5.8.3)) typescript: specifier: ^5.8.3 - version: 5.9.3 + version: 5.8.3 vite: specifier: ^7.2.2 version: 7.2.2(@types/node@24.10.1)(sass-embedded@1.89.0)(sugarss@5.0.1(postcss@8.5.6))(terser@5.44.0)(yaml@2.8.1) @@ -5367,8 +5367,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} engines: {node: '>=14.17'} hasBin: true @@ -5790,8 +5790,8 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@4.1.12: - resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} zustand@5.0.8: resolution: {integrity: sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==} @@ -6608,14 +6608,14 @@ snapshots: transitivePeerDependencies: - '@types/eslint' - '@electron-toolkit/eslint-config-ts@3.1.0(eslint@9.27.0)(typescript@5.9.3)': + '@electron-toolkit/eslint-config-ts@3.1.0(eslint@9.27.0)(typescript@5.8.3)': dependencies: '@eslint/js': 9.27.0 eslint: 9.27.0 globals: 16.1.0 - typescript-eslint: 8.32.1(eslint@9.27.0)(typescript@5.9.3) + typescript-eslint: 8.32.1(eslint@9.27.0)(typescript@5.8.3) optionalDependencies: - typescript: 5.9.3 + typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -7504,10 +7504,10 @@ snapshots: '@tootallnate/once@2.0.0': {} - '@ts-rest/core@3.52.1(@types/node@24.10.1)(zod@4.1.12)': + '@ts-rest/core@3.52.1(@types/node@24.10.1)(zod@3.25.76)': optionalDependencies: '@types/node': 24.10.1 - zod: 4.1.12 + zod: 3.25.76 '@types/babel__core@7.20.5': dependencies: @@ -7625,32 +7625,32 @@ snapshots: '@types/node': 24.10.1 optional: true - '@typescript-eslint/eslint-plugin@8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0)(typescript@5.9.3))(eslint@9.27.0)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0)(typescript@5.8.3))(eslint@9.27.0)(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.32.1(eslint@9.27.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.32.1(eslint@9.27.0)(typescript@5.8.3) '@typescript-eslint/scope-manager': 8.32.1 - '@typescript-eslint/type-utils': 8.32.1(eslint@9.27.0)(typescript@5.9.3) - '@typescript-eslint/utils': 8.32.1(eslint@9.27.0)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.32.1(eslint@9.27.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.32.1(eslint@9.27.0)(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.32.1 eslint: 9.27.0 graphemer: 1.4.0 ignore: 7.0.4 natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.32.1(eslint@9.27.0)(typescript@5.9.3)': + '@typescript-eslint/parser@8.32.1(eslint@9.27.0)(typescript@5.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.32.1 '@typescript-eslint/types': 8.32.1 - '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.3) '@typescript-eslint/visitor-keys': 8.32.1 debug: 4.4.3 eslint: 9.27.0 - typescript: 5.9.3 + typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -7659,20 +7659,20 @@ snapshots: '@typescript-eslint/types': 8.32.1 '@typescript-eslint/visitor-keys': 8.32.1 - '@typescript-eslint/type-utils@8.32.1(eslint@9.27.0)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.32.1(eslint@9.27.0)(typescript@5.8.3)': dependencies: - '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.9.3) - '@typescript-eslint/utils': 8.32.1(eslint@9.27.0)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.3) + '@typescript-eslint/utils': 8.32.1(eslint@9.27.0)(typescript@5.8.3) debug: 4.4.3 eslint: 9.27.0 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 transitivePeerDependencies: - supports-color '@typescript-eslint/types@8.32.1': {} - '@typescript-eslint/typescript-estree@8.32.1(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.32.1(typescript@5.8.3)': dependencies: '@typescript-eslint/types': 8.32.1 '@typescript-eslint/visitor-keys': 8.32.1 @@ -7681,19 +7681,19 @@ snapshots: is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.7.2 - ts-api-utils: 2.1.0(typescript@5.9.3) - typescript: 5.9.3 + ts-api-utils: 2.1.0(typescript@5.8.3) + typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.32.1(eslint@9.27.0)(typescript@5.9.3)': + '@typescript-eslint/utils@8.32.1(eslint@9.27.0)(typescript@5.8.3)': dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.27.0) '@typescript-eslint/scope-manager': 8.32.1 '@typescript-eslint/types': 8.32.1 - '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.3) eslint: 9.27.0 - typescript: 5.9.3 + typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -8301,7 +8301,7 @@ snapshots: config-file-ts@0.2.8-rc1: dependencies: glob: 10.4.5 - typescript: 5.9.3 + typescript: 5.8.3 convert-source-map@2.0.0: {} @@ -8316,14 +8316,14 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig@9.0.0(typescript@5.9.3): + cosmiconfig@9.0.0(typescript@5.8.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 js-yaml: 4.1.0 parse-json: 5.2.0 optionalDependencies: - typescript: 5.9.3 + typescript: 5.8.3 crc@3.8.0: dependencies: @@ -8852,10 +8852,10 @@ snapshots: dependencies: eslint: 9.27.0 - eslint-plugin-perfectionist@4.13.0(eslint@9.27.0)(typescript@5.9.3): + eslint-plugin-perfectionist@4.13.0(eslint@9.27.0)(typescript@5.8.3): dependencies: '@typescript-eslint/types': 8.32.1 - '@typescript-eslint/utils': 8.32.1(eslint@9.27.0)(typescript@5.9.3) + '@typescript-eslint/utils': 8.32.1(eslint@9.27.0)(typescript@5.8.3) eslint: 9.27.0 natural-orderby: 5.0.0 transitivePeerDependencies: @@ -8877,8 +8877,8 @@ snapshots: '@babel/parser': 7.28.5 eslint: 9.27.0 hermes-parser: 0.25.1 - zod: 4.1.12 - zod-validation-error: 4.0.2(zod@4.1.12) + zod: 3.25.76 + zod-validation-error: 4.0.2(zod@3.25.76) transitivePeerDependencies: - supports-color @@ -9461,28 +9461,28 @@ snapshots: esbuild: 0.25.11 fs-extra: 11.3.2 gulp-sort: 2.0.0 - i18next: 24.2.3(typescript@5.9.3) + i18next: 24.2.3(typescript@5.8.3) js-yaml: 4.1.0 lilconfig: 3.1.3 rsvp: 4.8.5 sort-keys: 5.1.0 - typescript: 5.9.3 + typescript: 5.8.3 vinyl: 3.0.0 vinyl-fs: 4.0.0 transitivePeerDependencies: - supports-color - i18next@24.2.3(typescript@5.9.3): + i18next@24.2.3(typescript@5.8.3): dependencies: '@babel/runtime': 7.27.1 optionalDependencies: - typescript: 5.9.3 + typescript: 5.8.3 - i18next@25.6.2(typescript@5.9.3): + i18next@25.6.2(typescript@5.8.3): dependencies: '@babel/runtime': 7.28.4 optionalDependencies: - typescript: 5.9.3 + typescript: 5.8.3 iconv-corefoundation@1.1.7: dependencies: @@ -10431,16 +10431,16 @@ snapshots: react-fast-compare@3.2.2: {} - react-i18next@16.3.3(i18next@25.6.2(typescript@5.9.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.9.3): + react-i18next@16.3.3(i18next@25.6.2(typescript@5.8.3))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.8.3): dependencies: '@babel/runtime': 7.28.4 html-parse-stringify: 3.0.1 - i18next: 25.6.2(typescript@5.9.3) + i18next: 25.6.2(typescript@5.8.3) react: 19.1.0 use-sync-external-store: 1.6.0(react@19.1.0) optionalDependencies: react-dom: 19.1.0(react@19.1.0) - typescript: 5.9.3 + typescript: 5.8.3 react-icons@5.5.0(react@19.1.0): dependencies: @@ -11139,33 +11139,33 @@ snapshots: strnum@2.1.1: {} - stylelint-config-css-modules@4.5.1(stylelint@16.25.0(typescript@5.9.3)): + stylelint-config-css-modules@4.5.1(stylelint@16.25.0(typescript@5.8.3)): dependencies: - stylelint: 16.25.0(typescript@5.9.3) + stylelint: 16.25.0(typescript@5.8.3) optionalDependencies: - stylelint-scss: 6.12.1(stylelint@16.25.0(typescript@5.9.3)) + stylelint-scss: 6.12.1(stylelint@16.25.0(typescript@5.8.3)) - stylelint-config-recess-order@7.4.0(stylelint-order@6.0.4(stylelint@16.25.0(typescript@5.9.3)))(stylelint@16.25.0(typescript@5.9.3)): + stylelint-config-recess-order@7.4.0(stylelint-order@6.0.4(stylelint@16.25.0(typescript@5.8.3)))(stylelint@16.25.0(typescript@5.8.3)): dependencies: - stylelint: 16.25.0(typescript@5.9.3) - stylelint-order: 6.0.4(stylelint@16.25.0(typescript@5.9.3)) + stylelint: 16.25.0(typescript@5.8.3) + stylelint-order: 6.0.4(stylelint@16.25.0(typescript@5.8.3)) - stylelint-config-recommended@17.0.0(stylelint@16.25.0(typescript@5.9.3)): + stylelint-config-recommended@17.0.0(stylelint@16.25.0(typescript@5.8.3)): dependencies: - stylelint: 16.25.0(typescript@5.9.3) + stylelint: 16.25.0(typescript@5.8.3) - stylelint-config-standard@39.0.1(stylelint@16.25.0(typescript@5.9.3)): + stylelint-config-standard@39.0.1(stylelint@16.25.0(typescript@5.8.3)): dependencies: - stylelint: 16.25.0(typescript@5.9.3) - stylelint-config-recommended: 17.0.0(stylelint@16.25.0(typescript@5.9.3)) + stylelint: 16.25.0(typescript@5.8.3) + stylelint-config-recommended: 17.0.0(stylelint@16.25.0(typescript@5.8.3)) - stylelint-order@6.0.4(stylelint@16.25.0(typescript@5.9.3)): + stylelint-order@6.0.4(stylelint@16.25.0(typescript@5.8.3)): dependencies: postcss: 8.5.6 postcss-sorting: 8.0.2(postcss@8.5.6) - stylelint: 16.25.0(typescript@5.9.3) + stylelint: 16.25.0(typescript@5.8.3) - stylelint-scss@6.12.1(stylelint@16.25.0(typescript@5.9.3)): + stylelint-scss@6.12.1(stylelint@16.25.0(typescript@5.8.3)): dependencies: css-tree: 3.1.0 is-plain-object: 5.0.0 @@ -11175,10 +11175,10 @@ snapshots: postcss-resolve-nested-selector: 0.1.6 postcss-selector-parser: 7.1.0 postcss-value-parser: 4.2.0 - stylelint: 16.25.0(typescript@5.9.3) + stylelint: 16.25.0(typescript@5.8.3) optional: true - stylelint@16.25.0(typescript@5.9.3): + stylelint@16.25.0(typescript@5.8.3): dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-tokenizer': 3.0.4 @@ -11187,7 +11187,7 @@ snapshots: '@dual-bundle/import-meta-resolve': 4.2.1 balanced-match: 2.0.0 colord: 2.9.3 - cosmiconfig: 9.0.0(typescript@5.9.3) + cosmiconfig: 9.0.0(typescript@5.8.3) css-functions-list: 3.2.3 css-tree: 3.1.0 debug: 4.4.3 @@ -11362,9 +11362,9 @@ snapshots: dependencies: utf8-byte-length: 1.0.5 - ts-api-utils@2.1.0(typescript@5.9.3): + ts-api-utils@2.1.0(typescript@5.8.3): dependencies: - typescript: 5.9.3 + typescript: 5.8.3 tslib@2.8.1: {} @@ -11414,17 +11414,17 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.32.1(eslint@9.27.0)(typescript@5.9.3): + typescript-eslint@8.32.1(eslint@9.27.0)(typescript@5.8.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0)(typescript@5.9.3))(eslint@9.27.0)(typescript@5.9.3) - '@typescript-eslint/parser': 8.32.1(eslint@9.27.0)(typescript@5.9.3) - '@typescript-eslint/utils': 8.32.1(eslint@9.27.0)(typescript@5.9.3) + '@typescript-eslint/eslint-plugin': 8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0)(typescript@5.8.3))(eslint@9.27.0)(typescript@5.8.3) + '@typescript-eslint/parser': 8.32.1(eslint@9.27.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.32.1(eslint@9.27.0)(typescript@5.8.3) eslint: 9.27.0 - typescript: 5.9.3 + typescript: 5.8.3 transitivePeerDependencies: - supports-color - typescript@5.9.3: {} + typescript@5.8.3: {} unbox-primitive@1.1.0: dependencies: @@ -11883,11 +11883,11 @@ snapshots: yocto-queue@0.1.0: {} - zod-validation-error@4.0.2(zod@4.1.12): + zod-validation-error@4.0.2(zod@3.25.76): dependencies: - zod: 4.1.12 + zod: 3.25.76 - zod@4.1.12: {} + zod@3.25.76: {} zustand@5.0.8(@types/react@19.2.5)(immer@10.2.0)(react@19.1.0)(use-sync-external-store@1.6.0(react@19.1.0)): optionalDependencies: diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8812039e4..ee409c794 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -349,6 +349,9 @@ "openBrowserDevtools": "open browser devtools", "quit": "$t(common.quit)", "selectServer": "select server", + "selectMusicFolder": "select music folder", + "noMusicFolder": "no music folder selected", + "multipleMusicFolders": "{{count}} music folders selected", "settings": "$t(common.setting_other)", "version": "version {{version}}" }, diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 041ba62cf..e8d450ed2 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -2,6 +2,7 @@ import i18n from '/@/i18n/i18n'; import { JellyfinController } from '/@/renderer/api/jellyfin/jellyfin-controller'; import { NavidromeController } from '/@/renderer/api/navidrome/navidrome-controller'; import { SubsonicController } from '/@/renderer/api/subsonic/subsonic-controller'; +import { mergeMusicFolderId } from '/@/renderer/api/utils-music-folder'; import { getServerById, useAuthStore } from '/@/renderer/store'; import { toast } from '/@/shared/components/toast/toast'; import { @@ -167,7 +168,11 @@ export const controller: GeneralController = { return apiController( 'getAlbumArtistList', server.type, - )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); + )?.({ + ...args, + apiClientProps: { ...args.apiClientProps, server }, + query: mergeMusicFolderId(args.query, server), + }); }, getAlbumArtistListCount(args) { const server = getServerById(args.apiClientProps.serverId); @@ -181,7 +186,11 @@ export const controller: GeneralController = { return apiController( 'getAlbumArtistListCount', server.type, - )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); + )?.({ + ...args, + apiClientProps: { ...args.apiClientProps, server }, + query: mergeMusicFolderId(args.query, server), + }); }, getAlbumDetail(args) { const server = getServerById(args.apiClientProps.serverId); @@ -223,7 +232,11 @@ export const controller: GeneralController = { return apiController( 'getAlbumList', server.type, - )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); + )?.({ + ...args, + apiClientProps: { ...args.apiClientProps, server }, + query: mergeMusicFolderId(args.query, server), + }); }, getAlbumListCount(args) { const server = getServerById(args.apiClientProps.serverId); @@ -237,7 +250,11 @@ export const controller: GeneralController = { return apiController( 'getAlbumListCount', server.type, - )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); + )?.({ + ...args, + apiClientProps: { ...args.apiClientProps, server }, + query: mergeMusicFolderId(args.query, server), + }); }, getArtistList(args) { const server = getServerById(args.apiClientProps.serverId); @@ -251,7 +268,11 @@ export const controller: GeneralController = { return apiController( 'getArtistList', server.type, - )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); + )?.({ + ...args, + apiClientProps: { ...args.apiClientProps, server }, + query: mergeMusicFolderId(args.query, server), + }); }, getArtistListCount(args) { const server = getServerById(args.apiClientProps.serverId); @@ -265,7 +286,11 @@ export const controller: GeneralController = { return apiController( 'getArtistListCount', server.type, - )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); + )?.({ + ...args, + apiClientProps: { ...args.apiClientProps, server }, + query: mergeMusicFolderId(args.query, server), + }); }, getDownloadUrl(args) { const server = getServerById(args.apiClientProps.serverId); @@ -293,7 +318,11 @@ export const controller: GeneralController = { return apiController( 'getGenreList', server.type, - )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); + )?.({ + ...args, + apiClientProps: { ...args.apiClientProps, server }, + query: mergeMusicFolderId(args.query, server), + }); }, getLyrics(args) { const server = getServerById(args.apiClientProps.serverId); @@ -461,7 +490,11 @@ export const controller: GeneralController = { return apiController( 'getSongList', server.type, - )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); + )?.({ + ...args, + apiClientProps: { ...args.apiClientProps, server }, + query: mergeMusicFolderId(args.query, server), + }); }, getSongListCount(args) { const server = getServerById(args.apiClientProps.serverId); @@ -475,7 +508,11 @@ export const controller: GeneralController = { return apiController( 'getSongListCount', server.type, - )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); + )?.({ + ...args, + apiClientProps: { ...args.apiClientProps, server }, + query: mergeMusicFolderId(args.query, server), + }); }, getStructuredLyrics(args) { const server = getServerById(args.apiClientProps.serverId); @@ -601,7 +638,11 @@ export const controller: GeneralController = { return apiController( 'search', server.type, - )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); + )?.({ + ...args, + apiClientProps: { ...args.apiClientProps, server }, + query: mergeMusicFolderId(args.query, server), + }); }, setRating(args) { const server = getServerById(args.apiClientProps.serverId); diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index ffdfed981..410d4165c 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -59,6 +59,14 @@ const excludeMissing = (server?: null | ServerListItemWithCredential) => { return undefined; }; +const getLibraryId = (musicFolderId?: string | string[]): string[] | undefined => { + if (!musicFolderId) { + return undefined; + } + + return Array.isArray(musicFolderId) ? musicFolderId : [musicFolderId]; +}; + const getArtistSongKey = (server: null | ServerListItemWithCredential) => hasFeature(server, ServerFeature.TRACK_ALBUM_ARTIST_SEARCH) ? 'artists_id' : 'album_artist_id'; @@ -189,6 +197,7 @@ export const NavidromeController: InternalControllerEndpoint = { _order: sortOrderMap.navidrome[query.sortOrder], _sort: albumArtistListSortMap.navidrome[query.sortBy], _start: query.startIndex, + library_id: getLibraryId(query.musicFolderId), name: query.searchTerm, ...query._custom, role: hasFeature(apiClientProps.server, ServerFeature.BFR) ? 'albumartist' : '', @@ -287,6 +296,7 @@ export const NavidromeController: InternalControllerEndpoint = { artist_id: query.artistIds?.[0], compilation: query.compilation, genre_id: genres, + library_id: getLibraryId(query.musicFolderId), name: query.searchTerm, ...query._custom, starred: query.favorite, @@ -318,6 +328,7 @@ export const NavidromeController: InternalControllerEndpoint = { _order: sortOrderMap.navidrome[query.sortOrder], _sort: albumArtistListSortMap.navidrome[query.sortBy], _start: query.startIndex, + library_id: getLibraryId(query.musicFolderId), name: query.searchTerm, ...query._custom, role: query.role || undefined, @@ -361,6 +372,7 @@ export const NavidromeController: InternalControllerEndpoint = { _order: sortOrderMap.navidrome[query.sortOrder], _sort: genreListSortMap.navidrome[query.sortBy], _start: query.startIndex, + library_id: getLibraryId(query.musicFolderId), name: query.searchTerm, }, }); @@ -480,6 +492,7 @@ export const NavidromeController: InternalControllerEndpoint = { ...navidromeFeatures, ...subsonicArgs.features, publicPlaylist: [1], + [ServerFeature.MUSIC_FOLDER_MULTISELECT]: [1], }; return { @@ -567,6 +580,7 @@ export const NavidromeController: InternalControllerEndpoint = { album_id: query.albumIds, genre_id: query.genreIds, [getArtistSongKey(apiClientProps.server)]: query.artistIds ?? query.albumArtistIds, + library_id: getLibraryId(query.musicFolderId), starred: query.favorite, title: query.searchTerm, ...query._custom, diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 0b9c8ac5f..b4ec80ba0 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -1388,7 +1388,7 @@ export const SubsonicController: InternalControllerEndpoint = { setRating: async (args) => { const { apiClientProps, query } = args; - const itemIds = query.item.map((item) => item.id); + const itemIds = query.id; for (const id of itemIds) { await ssApiClient(apiClientProps).setRating({ diff --git a/src/renderer/api/utils-music-folder.ts b/src/renderer/api/utils-music-folder.ts new file mode 100644 index 000000000..0514af98e --- /dev/null +++ b/src/renderer/api/utils-music-folder.ts @@ -0,0 +1,24 @@ +import { ServerListItemWithCredential } from '/@/shared/types/domain-types'; + +export const mergeMusicFolderId = ( + query: T, + server: null | ServerListItemWithCredential, +): T => { + if ( + !server || + !server.musicFolderId || + server.musicFolderId.length === 0 || + query.musicFolderId + ) { + return query; + } + + // Only merge if server matches and musicFolderId is not already in query + const musicFolderId = + server.musicFolderId.length === 1 ? server.musicFolderId[0] : server.musicFolderId; + + return { + ...query, + musicFolderId, + }; +}; diff --git a/src/renderer/features/albums/components/album-list-header-filters.tsx b/src/renderer/features/albums/components/album-list-header-filters.tsx index 08277bc00..6970ef7bd 100644 --- a/src/renderer/features/albums/components/album-list-header-filters.tsx +++ b/src/renderer/features/albums/components/album-list-header-filters.tsx @@ -1,7 +1,6 @@ import { ALBUM_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { ListFilters } from '/@/renderer/features/shared/components/list-filters'; -import { ListMusicFolderDropdown } from '/@/renderer/features/shared/components/list-music-folder-dropdown'; import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; @@ -25,7 +24,6 @@ export const AlbumListHeaderFilters = () => { defaultSortOrder={SortOrder.ASC} listKey={ItemListKey.ALBUM} /> - diff --git a/src/renderer/features/albums/hooks/use-album-list-filters.ts b/src/renderer/features/albums/hooks/use-album-list-filters.ts index 23f855d6c..711aac1d3 100644 --- a/src/renderer/features/albums/hooks/use-album-list-filters.ts +++ b/src/renderer/features/albums/hooks/use-album-list-filters.ts @@ -7,7 +7,6 @@ import { useQueryState, } from 'nuqs'; -import { useMusicFolderIdFilter } from '/@/renderer/features/shared/hooks/use-music-folder-id-filter'; import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter'; import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter'; import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter'; @@ -20,8 +19,6 @@ export const useAlbumListFilters = () => { const { sortOrder } = useSortOrderFilter(null, ItemListKey.ALBUM); - const { musicFolderId } = useMusicFolderIdFilter(null, ItemListKey.ALBUM); - const { searchTerm, setSearchTerm } = useSearchTermFilter(''); const [genreId, setGenreId] = useQueryState( @@ -67,7 +64,6 @@ export const useAlbumListFilters = () => { [FILTER_KEYS.ALBUM.MAX_YEAR]: maxYear ?? undefined, [FILTER_KEYS.ALBUM.MIN_YEAR]: minYear ?? undefined, [FILTER_KEYS.ALBUM.RECENTLY_PLAYED]: recentlyPlayed ?? undefined, - [FILTER_KEYS.SHARED.MUSIC_FOLDER_ID]: musicFolderId ?? undefined, [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined, [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined, [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined, diff --git a/src/renderer/features/artists/components/album-artist-list-header-filters.tsx b/src/renderer/features/artists/components/album-artist-list-header-filters.tsx index 89826cd95..cdfd12a1c 100644 --- a/src/renderer/features/artists/components/album-artist-list-header-filters.tsx +++ b/src/renderer/features/artists/components/album-artist-list-header-filters.tsx @@ -1,6 +1,5 @@ import { ALBUM_ARTIST_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; -import { ListMusicFolderDropdown } from '/@/renderer/features/shared/components/list-music-folder-dropdown'; import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; @@ -27,7 +26,6 @@ export const AlbumArtistListHeaderFilters = () => { defaultSortOrder={SortOrder.ASC} listKey={ItemListKey.ALBUM_ARTIST} /> - diff --git a/src/renderer/features/artists/components/artist-list-header-filters.tsx b/src/renderer/features/artists/components/artist-list-header-filters.tsx index 2ffcccced..0ed1f203c 100644 --- a/src/renderer/features/artists/components/artist-list-header-filters.tsx +++ b/src/renderer/features/artists/components/artist-list-header-filters.tsx @@ -3,7 +3,6 @@ import { useQuery } from '@tanstack/react-query'; import { ALBUM_ARTIST_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; -import { ListMusicFolderDropdown } from '/@/renderer/features/shared/components/list-music-folder-dropdown'; import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; import { ListSelectFilter } from '/@/renderer/features/shared/components/list-select-filter'; import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; @@ -36,7 +35,6 @@ export const ArtistListHeaderFilters = () => { defaultSortOrder={SortOrder.ASC} listKey={ItemListKey.ARTIST} /> - {rolesQuery.data && rolesQuery.data.length > 0 && ( <> diff --git a/src/renderer/features/artists/hooks/use-album-artist-list-filters.ts b/src/renderer/features/artists/hooks/use-album-artist-list-filters.ts index f55debad0..ad52f0df0 100644 --- a/src/renderer/features/artists/hooks/use-album-artist-list-filters.ts +++ b/src/renderer/features/artists/hooks/use-album-artist-list-filters.ts @@ -1,4 +1,3 @@ -import { useMusicFolderIdFilter } from '/@/renderer/features/shared/hooks/use-music-folder-id-filter'; import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter'; import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter'; import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter'; @@ -11,12 +10,9 @@ export const useAlbumArtistListFilters = () => { const { sortOrder } = useSortOrderFilter(null, ItemListKey.ALBUM_ARTIST); - const { musicFolderId } = useMusicFolderIdFilter(null, ItemListKey.ALBUM_ARTIST); - const { searchTerm, setSearchTerm } = useSearchTermFilter(''); const query = { - [FILTER_KEYS.SHARED.MUSIC_FOLDER_ID]: musicFolderId ?? undefined, [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined, [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined, [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined, diff --git a/src/renderer/features/artists/hooks/use-artist-list-filters.ts b/src/renderer/features/artists/hooks/use-artist-list-filters.ts index 38c56c5c4..0c9c56758 100644 --- a/src/renderer/features/artists/hooks/use-artist-list-filters.ts +++ b/src/renderer/features/artists/hooks/use-artist-list-filters.ts @@ -1,4 +1,3 @@ -import { useMusicFolderIdFilter } from '/@/renderer/features/shared/hooks/use-music-folder-id-filter'; import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter'; import { useSelectFilter } from '/@/renderer/features/shared/hooks/use-select-filter'; import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter'; @@ -12,15 +11,12 @@ export const useArtistListFilters = () => { const { sortOrder } = useSortOrderFilter(null, ItemListKey.ARTIST); - const { musicFolderId } = useMusicFolderIdFilter(null, ItemListKey.ARTIST); - const { searchTerm, setSearchTerm } = useSearchTermFilter(''); const { value: role } = useSelectFilter(FILTER_KEYS.ARTIST.ROLE, '', ItemListKey.ARTIST); const query = { [FILTER_KEYS.ARTIST.ROLE]: role ?? undefined, - [FILTER_KEYS.SHARED.MUSIC_FOLDER_ID]: musicFolderId ?? undefined, [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined, [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined, [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined, diff --git a/src/renderer/features/genres/components/genre-list-header-filters.tsx b/src/renderer/features/genres/components/genre-list-header-filters.tsx index bdf017704..7ea7d791b 100644 --- a/src/renderer/features/genres/components/genre-list-header-filters.tsx +++ b/src/renderer/features/genres/components/genre-list-header-filters.tsx @@ -1,7 +1,6 @@ import { GENRE_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { ListFilters } from '/@/renderer/features/shared/components/list-filters'; -import { ListMusicFolderDropdown } from '/@/renderer/features/shared/components/list-music-folder-dropdown'; import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; @@ -28,7 +27,6 @@ export const GenreListHeaderFilters = () => { defaultSortOrder={SortOrder.ASC} listKey={ItemListKey.GENRE} /> - diff --git a/src/renderer/features/genres/hooks/use-genre-list-filters.ts b/src/renderer/features/genres/hooks/use-genre-list-filters.ts index e3811a127..d329275b2 100644 --- a/src/renderer/features/genres/hooks/use-genre-list-filters.ts +++ b/src/renderer/features/genres/hooks/use-genre-list-filters.ts @@ -1,4 +1,3 @@ -import { useMusicFolderIdFilter } from '/@/renderer/features/shared/hooks/use-music-folder-id-filter'; import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter'; import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter'; import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter'; @@ -11,12 +10,9 @@ export const useGenreListFilters = () => { const { sortOrder } = useSortOrderFilter(null, ItemListKey.GENRE); - const { musicFolderId } = useMusicFolderIdFilter(null, ItemListKey.GENRE); - const { searchTerm, setSearchTerm } = useSearchTermFilter(''); const query = { - [FILTER_KEYS.SHARED.MUSIC_FOLDER_ID]: musicFolderId ?? undefined, [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined, [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined, [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined, diff --git a/src/renderer/features/sidebar/components/action-bar.tsx b/src/renderer/features/sidebar/components/action-bar.tsx index 35ea9862a..9672c0c0c 100644 --- a/src/renderer/features/sidebar/components/action-bar.tsx +++ b/src/renderer/features/sidebar/components/action-bar.tsx @@ -3,11 +3,8 @@ import { useNavigate } from 'react-router'; import styles from './action-bar.module.css'; -import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu'; -import { useContainerQuery } from '/@/renderer/hooks'; import { useCommandPalette } from '/@/renderer/store'; import { Button } from '/@/shared/components/button/button'; -import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; import { Grid } from '/@/shared/components/grid/grid'; import { Group } from '/@/shared/components/group/group'; import { Icon } from '/@/shared/components/icon/icon'; @@ -15,14 +12,18 @@ import { TextInput } from '/@/shared/components/text-input/text-input'; export const ActionBar = () => { const { t } = useTranslation(); - const { ref, ...cq } = useContainerQuery({ md: 300 }); const navigate = useNavigate(); const { open } = useCommandPalette(); return ( -
- - +
+ + } onClick={open} @@ -35,18 +36,8 @@ export const ActionBar = () => { readOnly /> - + - - - - - - - - diff --git a/src/renderer/features/sidebar/components/server-selector-items.tsx b/src/renderer/features/sidebar/components/server-selector-items.tsx new file mode 100644 index 000000000..5fb85f046 --- /dev/null +++ b/src/renderer/features/sidebar/components/server-selector-items.tsx @@ -0,0 +1,163 @@ +import { openModal } from '@mantine/modals'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; +import { useNavigate } from 'react-router'; + +import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png'; +import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png'; +import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png'; +import { ServerList } from '/@/renderer/features/servers/components/server-list'; +import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; +import { AppRoute } from '/@/renderer/router/routes'; +import { useAuthStoreActions, useCurrentServer, useServerList } from '/@/renderer/store'; +import { hasFeature } from '/@/shared/api/utils'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Icon } from '/@/shared/components/icon/icon'; +import { ServerListItemWithCredential, ServerType } from '/@/shared/types/domain-types'; +import { ServerFeature } from '/@/shared/types/features-types'; + +export const ServerSelectorItems = () => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const currentServer = useCurrentServer(); + const serverList = useServerList(); + const { setCurrentServer, setMusicFolderId } = useAuthStoreActions(); + + const { data: musicFolders } = useQuery( + currentServer + ? sharedQueries.musicFolders({ query: null, serverId: currentServer.id }) + : { enabled: false, queryKey: ['disabled'] }, + ); + + const handleSetCurrentServer = (server: ServerListItemWithCredential) => { + navigate(AppRoute.HOME); + setCurrentServer(server); + setMusicFolderId(undefined); + }; + + const supportsMultiSelect = hasFeature(currentServer, ServerFeature.MUSIC_FOLDER_MULTISELECT); + + const queryClient = useQueryClient(); + const handleToggleMusicFolder = (musicFolderId: string) => { + if (supportsMultiSelect) { + const currentIds = currentServer.musicFolderId || []; + const isSelected = currentIds.includes(musicFolderId); + + if (isSelected) { + // Remove from selection + const newIds = currentIds.filter((id) => id !== musicFolderId); + setMusicFolderId(newIds.length > 0 ? newIds : undefined); + } else { + // Add to selection + setMusicFolderId([...currentIds, musicFolderId]); + } + } else { + const currentId = Array.isArray(currentServer.musicFolderId) + ? currentServer.musicFolderId[0] + : currentServer.musicFolderId; + const isSelected = currentId === musicFolderId; + + if (isSelected) { + setMusicFolderId(undefined); + } else { + setMusicFolderId([musicFolderId]); + } + } + + queryClient.resetQueries(); + }; + + const handleClearMusicFolders = () => { + setMusicFolderId(undefined); + queryClient.resetQueries(); + }; + + if (!currentServer) { + return null; + } + + const selectedMusicFolders = + musicFolders?.items.filter((folder) => currentServer.musicFolderId?.includes(folder.id)) || + []; + + const handleManageServersModal = () => { + openModal({ + children: , + title: t('page.manageServers.title', { postProcess: 'titleCase' }), + }); + }; + + return ( + <> + + {t('page.appMenu.selectServer', { postProcess: 'titleCase' })} + + {Object.values(serverList).map((server) => { + const isNavidromeExpired = + server.type === ServerType.NAVIDROME && !server.ndCredential; + const isJellyfinExpired = server.type === ServerType.JELLYFIN && !server.credential; + const isSessionExpired = isNavidromeExpired || isJellyfinExpired; + + const logo = + server.type === ServerType.NAVIDROME + ? NavidromeLogo + : server.type === ServerType.JELLYFIN + ? JellyfinLogo + : OpenSubsonicLogo; + + return ( + } + onClick={() => { + if (!isSessionExpired) { + handleSetCurrentServer(server); + } + }} + > + {server.name} + + ); + })} + } + onClick={handleManageServersModal} + > + {t('page.appMenu.manageServers', { postProcess: 'sentenceCase' })} + + {musicFolders && musicFolders.items.length > 0 && ( + <> + + + {t('page.appMenu.selectMusicFolder', { postProcess: 'sentenceCase' })} + + } + onClick={handleClearMusicFolders} + > + {t('common.none', { postProcess: 'titleCase' })} + + {musicFolders.items.map((folder) => { + const isSelected = supportsMultiSelect + ? currentServer.musicFolderId?.includes(folder.id) || false + : (Array.isArray(currentServer.musicFolderId) + ? currentServer.musicFolderId[0] + : currentServer.musicFolderId) === folder.id; + return ( + } + onClick={() => handleToggleMusicFolder(folder.id)} + > + {folder.name} + + ); + })} + + )} + + ); +}; diff --git a/src/renderer/features/sidebar/components/server-selector.module.css b/src/renderer/features/sidebar/components/server-selector.module.css new file mode 100644 index 000000000..c76011cb8 --- /dev/null +++ b/src/renderer/features/sidebar/components/server-selector.module.css @@ -0,0 +1,46 @@ +.button-container { + align-items: center; + width: 100%; + padding: var(--theme-spacing-md); + cursor: pointer; +} + +.button-container-no-bottom-padding { + padding-bottom: 0; +} + +.button-group { + padding: var(--theme-spacing-sm); + background: var(--theme-colors-surface); + border-radius: var(--theme-radius-md); +} + +.logo { + flex-shrink: 0; + width: 2.5rem; + height: 2.5rem; + object-fit: cover; + border-radius: var(--theme-radius-md); +} + +.button-stack { + flex: 1; + min-width: 0; +} + +.popover-target { + width: 100%; +} + +.scroll-area { + max-height: 400px; +} + +.server-logo { + width: var(--theme-font-size-md); + height: var(--theme-font-size-md); +} + +.server-button { + justify-content: flex-start; +} diff --git a/src/renderer/features/sidebar/components/server-selector.tsx b/src/renderer/features/sidebar/components/server-selector.tsx new file mode 100644 index 000000000..7599446d2 --- /dev/null +++ b/src/renderer/features/sidebar/components/server-selector.tsx @@ -0,0 +1,97 @@ +import { useQuery } from '@tanstack/react-query'; +import { useTranslation } from 'react-i18next'; + +import styles from './server-selector.module.css'; + +import JellyfinLogo from '/@/renderer/features/servers/assets/jellyfin.png'; +import NavidromeLogo from '/@/renderer/features/servers/assets/navidrome.png'; +import OpenSubsonicLogo from '/@/renderer/features/servers/assets/opensubsonic.png'; +import { sharedQueries } from '/@/renderer/features/shared/api/shared-api'; +import { AppMenu } from '/@/renderer/features/titlebar/components/app-menu'; +import { useCurrentServer } from '/@/renderer/store'; +import { hasFeature } from '/@/shared/api/utils'; +import { Box } from '/@/shared/components/box/box'; +import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; +import { ServerType } from '/@/shared/types/domain-types'; +import { ServerFeature } from '/@/shared/types/features-types'; + +interface ServerSelectorProps { + showImage?: boolean; +} + +export const ServerSelector = ({ showImage = false }: ServerSelectorProps) => { + const { t } = useTranslation(); + const currentServer = useCurrentServer(); + + const { data: musicFolders } = useQuery( + currentServer + ? sharedQueries.musicFolders({ query: null, serverId: currentServer.id }) + : { enabled: false, queryKey: ['disabled'] }, + ); + + if (!currentServer) { + return null; + } + + const supportsMultiSelect = hasFeature(currentServer, ServerFeature.MUSIC_FOLDER_MULTISELECT); + + const selectedMusicFolders = + musicFolders?.items.filter((folder) => currentServer.musicFolderId?.includes(folder.id)) || + []; + + const musicFolderDisplayText = (() => { + if (selectedMusicFolders.length === 0) { + return t('page.appMenu.noMusicFolder', { postProcess: 'sentenceCase' }); + } + + if (supportsMultiSelect && selectedMusicFolders.length > 1) { + return t('page.appMenu.multipleMusicFolders', { + count: selectedMusicFolders.length, + postProcess: 'sentenceCase', + }); + } + + return selectedMusicFolders[0].name; + })(); + + const logo = + currentServer.type === ServerType.NAVIDROME + ? NavidromeLogo + : currentServer.type === ServerType.JELLYFIN + ? JellyfinLogo + : OpenSubsonicLogo; + + return ( + + +
+ + + + + + {currentServer.name} + + + {musicFolderDisplayText} + + + + + +
+
+ + + +
+ ); +}; diff --git a/src/renderer/features/sidebar/components/sidebar.module.css b/src/renderer/features/sidebar/components/sidebar.module.css index 5db3ac25c..4f3b0f8da 100644 --- a/src/renderer/features/sidebar/components/sidebar.module.css +++ b/src/renderer/features/sidebar/components/sidebar.module.css @@ -13,6 +13,8 @@ } .scroll-area { + flex: 1; + min-height: 0; padding: 0 var(--theme-spacing-md) var(--theme-spacing-md) var(--theme-spacing-md); } @@ -27,10 +29,11 @@ } .image-container { - position: absolute; - bottom: 0; + position: relative; + flex-shrink: 0; width: var(--sidebar-image-height); height: var(--sidebar-image-height); + padding: var(--theme-spacing-md); cursor: pointer; animation: fade-in 0.2s ease-in-out; @@ -48,7 +51,7 @@ height: 100%; object-fit: var(--theme-image-fit); background: var(--theme-colors-foreground-muted); - border-radius: 0; + border-radius: var(--theme-radius-md); } .accordion-root { diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index 3a8434859..728a56ac4 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -8,6 +8,7 @@ import styles from './sidebar.module.css'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar'; +import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector'; import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon'; import { SidebarItem } from '/@/renderer/features/sidebar/components/sidebar-item'; import { @@ -106,15 +107,6 @@ export const Sidebar = () => { return items; }, [sidebarItems, translatedSidebarItemMap]); - const scrollAreaHeight = useMemo(() => { - if (showImage) { - // Subtract the height of the top bar and padding - return `calc(100% - 65px - var(--mantine-spacing-xs) - ${sidebar.leftWidth})`; - } - - return '100%'; - }, [showImage, sidebar.leftWidth]); - const isCustomWindowBar = windowBarStyle === Platform.WINDOWS || windowBarStyle === Platform.MACOS; @@ -125,16 +117,10 @@ export const Sidebar = () => { })} id="left-sidebar" > - + - + { )} +
+ +
{showImage && ( { style={{ cursor: 'default', position: 'absolute', - right: 5, - top: 5, + right: '1rem', + top: '1rem', }} tooltip={{ label: t('common.collapse', { diff --git a/src/renderer/features/songs/components/song-list-header-filters.tsx b/src/renderer/features/songs/components/song-list-header-filters.tsx index 4d370719c..d90c08bd4 100644 --- a/src/renderer/features/songs/components/song-list-header-filters.tsx +++ b/src/renderer/features/songs/components/song-list-header-filters.tsx @@ -1,7 +1,6 @@ import { SONG_TABLE_COLUMNS } from '/@/renderer/components/item-list/item-table-list/default-columns'; import { ListConfigMenu } from '/@/renderer/features/shared/components/list-config-menu'; import { ListFilters } from '/@/renderer/features/shared/components/list-filters'; -import { ListMusicFolderDropdown } from '/@/renderer/features/shared/components/list-music-folder-dropdown'; import { ListRefreshButton } from '/@/renderer/features/shared/components/list-refresh-button'; import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; @@ -25,7 +24,6 @@ export const SongListHeaderFilters = () => { defaultSortOrder={SortOrder.ASC} listKey={ItemListKey.SONG} /> -
diff --git a/src/renderer/features/songs/hooks/use-song-list-filters.ts b/src/renderer/features/songs/hooks/use-song-list-filters.ts index fd78304f8..997a38ce1 100644 --- a/src/renderer/features/songs/hooks/use-song-list-filters.ts +++ b/src/renderer/features/songs/hooks/use-song-list-filters.ts @@ -7,7 +7,6 @@ import { useQueryState, } from 'nuqs'; -import { useMusicFolderIdFilter } from '/@/renderer/features/shared/hooks/use-music-folder-id-filter'; import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter'; import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter'; import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter'; @@ -20,8 +19,6 @@ export const useSongListFilters = () => { const { sortOrder } = useSortOrderFilter(null, ItemListKey.SONG); - const { musicFolderId } = useMusicFolderIdFilter(null, ItemListKey.SONG); - const { searchTerm, setSearchTerm } = useSearchTermFilter(''); const [albumIds, setAlbumIds] = useQueryState( @@ -51,7 +48,6 @@ export const useSongListFilters = () => { ); const query = { - [FILTER_KEYS.SHARED.MUSIC_FOLDER_ID]: musicFolderId ?? undefined, [FILTER_KEYS.SHARED.SEARCH_TERM]: searchTerm ?? undefined, [FILTER_KEYS.SHARED.SORT_BY]: sortBy ?? undefined, [FILTER_KEYS.SHARED.SORT_ORDER]: sortOrder ?? undefined, diff --git a/src/renderer/features/titlebar/components/app-menu.tsx b/src/renderer/features/titlebar/components/app-menu.tsx index 512da1e37..1196acda3 100644 --- a/src/renderer/features/titlebar/components/app-menu.tsx +++ b/src/renderer/features/titlebar/components/app-menu.tsx @@ -1,76 +1,77 @@ -import { closeAllModals, openModal } from '@mantine/modals'; import isElectron from 'is-electron'; +import { ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import { useNavigate } from 'react-router'; -import { Link } from 'react-router'; +import { Link, useNavigate } from 'react-router'; import packageJson from '../../../../../package.json'; -import { EditServerForm } from '/@/renderer/features/servers/components/edit-server-form'; -import { ServerList } from '/@/renderer/features/servers/components/server-list'; +import { ServerSelectorItems } from '/@/renderer/features/sidebar/components/server-selector-items'; import { AppRoute } from '/@/renderer/router/routes'; -import { - useAppStore, - useAppStoreActions, - useAuthStoreActions, - useCurrentServer, - useServerList, - useSidebarStore, -} from '/@/renderer/store'; +import { useAppStore, useAppStoreActions, useSidebarStore } from '/@/renderer/store'; import { DropdownMenu } from '/@/shared/components/dropdown-menu/dropdown-menu'; import { Icon } from '/@/shared/components/icon/icon'; import { toast } from '/@/shared/components/toast/toast'; -import { ServerListItemWithCredential, ServerType } from '/@/shared/types/domain-types'; const browser = isElectron() ? window.api.browser : null; -const localSettings = isElectron() ? window.api.localSettings : null; + +interface BaseMenuItem { + id: string; + type: 'conditional-group' | 'conditional-item' | 'custom' | 'divider' | 'item'; +} + +interface ConditionalGroupItem extends BaseMenuItem { + condition: boolean; + items: MenuItem[]; + type: 'conditional-group'; +} + +interface ConditionalItem extends BaseMenuItem { + condition: boolean; + item: Omit; + type: 'conditional-item'; +} + +interface CustomItem extends BaseMenuItem { + component: ReactNode; + type: 'custom'; +} + +interface DividerItem extends BaseMenuItem { + type: 'divider'; +} + +type MenuItem = ConditionalGroupItem | ConditionalItem | CustomItem | DividerItem | RegularMenuItem; + +interface RegularMenuItem extends BaseMenuItem { + component?: 'a' | typeof Link; + href?: string; + icon?: keyof typeof import('/@/shared/components/icon/icon').AppIcon; + iconColor?: + | 'contrast' + | 'default' + | 'error' + | 'info' + | 'inherit' + | 'muted' + | 'primary' + | 'success' + | 'warn'; + label: string; + leftSection?: ReactNode; + onClick?: () => void; + rightSection?: ReactNode; + target?: string; + to?: string; + type: 'item'; +} export const AppMenu = () => { const { t } = useTranslation(); const navigate = useNavigate(); - const currentServer = useCurrentServer(); - const serverList = useServerList(); - const { setCurrentServer } = useAuthStoreActions(); const { collapsed } = useSidebarStore(); const { privateMode } = useAppStore(); const { setPrivateMode, setSideBar } = useAppStoreActions(); - const handleSetCurrentServer = (server: ServerListItemWithCredential) => { - navigate(AppRoute.HOME); - setCurrentServer(server); - }; - - const handleCredentialsModal = async (server: ServerListItemWithCredential) => { - let password: null | string = null; - - try { - if (localSettings && server.savePassword) { - password = await localSettings.passwordGet(server.id); - } - } catch (error) { - console.error(error); - } - openModal({ - children: server && ( - - ), - size: 'sm', - title: `Update session for "${server.name}"`, - }); - }; - - const handleManageServersModal = () => { - openModal({ - children: , - title: t('page.manageServers.title', { postProcess: 'titleCase' }), - }); - }; - const handleBrowserDevTools = () => { browser?.devtools(); }; @@ -103,124 +104,184 @@ export const AppMenu = () => { browser?.quit(); }; - return ( - <> - } - onClick={() => navigate(-1)} - > - {t('page.appMenu.goBack', { postProcess: 'sentenceCase' })} - - } - onClick={() => navigate(1)} - > - {t('page.appMenu.goForward', { postProcess: 'sentenceCase' })} - - {collapsed ? ( - } - onClick={handleExpandSidebar} - > - {t('page.appMenu.expandSidebar', { postProcess: 'sentenceCase' })} - - ) : ( - } - onClick={handleCollapseSidebar} - > - {t('page.appMenu.collapseSidebar', { postProcess: 'sentenceCase' })} - - )} - - } - to={AppRoute.SETTINGS} - > - {t('page.appMenu.settings', { postProcess: 'sentenceCase' })} - - } - onClick={handleManageServersModal} - > - {t('page.appMenu.manageServers', { postProcess: 'sentenceCase' })} - - {privateMode ? ( - } - onClick={handlePrivateModeOff} - > - {t('page.appMenu.privateModeOff', { postProcess: 'sentenceCase' })} - - ) : ( - } - onClick={handlePrivateModeOn} - > - {t('page.appMenu.privateModeOn', { postProcess: 'sentenceCase' })} - - )} - - - {t('page.appMenu.selectServer', { postProcess: 'sentenceCase' })} - - {Object.keys(serverList).map((serverId) => { - const server = serverList[serverId]; - const isNavidromeExpired = - server.type === ServerType.NAVIDROME && !server.ndCredential; - const isJellyfinExpired = server.type === ServerType.JELLYFIN && !server.credential; - const isSessionExpired = isNavidromeExpired || isJellyfinExpired; + const menuConfig: MenuItem[] = [ + { + condition: privateMode, + id: 'private-mode', + item: { + icon: 'lock', + iconColor: 'error', + label: t('page.appMenu.privateModeOff', { postProcess: 'sentenceCase' }), + onClick: handlePrivateModeOff, + type: 'item', + }, + type: 'conditional-item', + }, + { + condition: !privateMode, + id: 'private-mode', + item: { + icon: 'lockOpen', + label: t('page.appMenu.privateModeOn', { postProcess: 'sentenceCase' }), + onClick: handlePrivateModeOn, + type: 'item', + }, + type: 'conditional-item', + }, + { + id: 'divider-1', + type: 'divider', + }, + { + condition: collapsed, + id: 'navigation-group', + items: [ + { + icon: 'arrowLeftS', + id: 'go-back', + label: t('page.appMenu.goBack', { postProcess: 'sentenceCase' }), + onClick: () => navigate(-1), + type: 'item', + }, + { + icon: 'arrowRightS', + id: 'go-forward', + label: t('page.appMenu.goForward', { postProcess: 'sentenceCase' }), + onClick: () => navigate(1), + type: 'item', + }, + ], + type: 'conditional-group', + }, + { + condition: collapsed, + id: 'sidebar-toggle', + item: { + icon: 'panelRightOpen', + id: 'expand-sidebar', + label: t('page.appMenu.expandSidebar', { postProcess: 'sentenceCase' }), + onClick: handleExpandSidebar, + type: 'item', + }, + type: 'conditional-item', + }, + { + condition: !collapsed, + id: 'sidebar-toggle', + item: { + icon: 'panelRightClose', + id: 'collapse-sidebar', + label: t('page.appMenu.collapseSidebar', { postProcess: 'sentenceCase' }), + onClick: handleCollapseSidebar, + type: 'item', + }, + type: 'conditional-item', + }, + { + id: 'divider-2', + type: 'divider', + }, + { + component: Link, + icon: 'settings', + id: 'settings', + label: t('page.appMenu.settings', { postProcess: 'sentenceCase' }), + to: AppRoute.SETTINGS, + type: 'item', + }, + { + id: 'divider-3', + type: 'divider', + }, + { + component: , + id: 'server-selector', + type: 'custom', + }, + { + id: 'divider-4', + type: 'divider', + }, + { + component: 'a', + href: 'https://github.com/jeffvli/feishin/releases', + icon: 'brandGitHub', + id: 'version', + label: t('page.appMenu.version', { + postProcess: 'sentenceCase', + version: packageJson.version, + }), + rightSection: , + target: '_blank', + type: 'item', + }, + { + condition: isElectron(), + id: 'devtools', + item: { + icon: 'appWindow', + id: 'open-devtools', + label: t('page.appMenu.openBrowserDevtools', { postProcess: 'sentenceCase' }), + onClick: handleBrowserDevTools, + type: 'item', + }, + type: 'conditional-item', + }, + { + condition: isElectron(), + id: 'quit', + item: { + icon: 'x', + id: 'quit-app', + label: t('page.appMenu.quit', { postProcess: 'sentenceCase' }), + onClick: handleQuit, + type: 'item', + }, + type: 'conditional-item', + }, + ]; + const renderMenuItem = (item: MenuItem): ReactNode => { + switch (item.type) { + case 'conditional-group': + if (!item.condition) return null; return ( - - ) : ( - - ) - } - onClick={() => { - if (!isSessionExpired) return handleSetCurrentServer(server); - return handleCredentialsModal(server); - }} - > - {server.name} - +
{item.items.map((subItem) => renderMenuItem(subItem))}
); - })} - - } - rightSection={} - target="_blank" - > - {t('page.appMenu.version', { - postProcess: 'sentenceCase', - version: packageJson.version, - })} - - {isElectron() && ( - <> - - } - onClick={handleBrowserDevTools} - > - {t('page.appMenu.openBrowserDevtools', { postProcess: 'sentenceCase' })} - - } onClick={handleQuit}> - {t('page.appMenu.quit', { postProcess: 'sentenceCase' })} - - - )} - - ); + + case 'conditional-item': + if (!item.condition) return null; + return renderMenuItem(item.item as MenuItem); + + case 'custom': + return
{item.component}
; + + case 'divider': + return ; + + case 'item': { + const leftSection = + item.leftSection || + (item.icon && ); + + const props: any = { + key: item.id, + leftSection, + ...(item.rightSection && { rightSection: item.rightSection }), + ...(item.onClick && { onClick: item.onClick }), + ...(item.component && { component: item.component }), + ...(item.to && { to: item.to }), + ...(item.href && { href: item.href }), + ...(item.target && { target: item.target }), + }; + + return {item.label}; + } + + default: + return null; + } + }; + + return <>{menuConfig.map((item) => renderMenuItem(item))}; }; diff --git a/src/renderer/store/auth.store.ts b/src/renderer/store/auth.store.ts index 4ad6b5312..246f772c9 100644 --- a/src/renderer/store/auth.store.ts +++ b/src/renderer/store/auth.store.ts @@ -16,6 +16,7 @@ export interface AuthSlice extends AuthState { deleteServer: (id: string) => void; getServer: (id: string) => null | ServerListItemWithCredential; setCurrentServer: (server: null | ServerListItemWithCredential) => void; + setMusicFolderId: (musicFolderId: string[] | undefined) => void; updateServer: (id: string, args: Partial) => void; }; } @@ -64,15 +65,36 @@ export const useAuthStore = createWithEqualityFn()( } }); }, - updateServer: (id: string, args: Partial) => { + setMusicFolderId: (musicFolderId: string[] | undefined) => { + set((state) => { + if (state.currentServer) { + state.currentServer.musicFolderId = musicFolderId; + const serverId = state.currentServer.id; + if (state.serverList[serverId]) { + state.serverList[serverId].musicFolderId = musicFolderId; + } + } + }); + }, + updateServer: (id: string, args: Partial) => { set((state) => { const updatedServer = { ...state.serverList[id], ...args, }; + if ( + state.currentServer?.id === id && + !('musicFolderId' in args) && + state.currentServer.musicFolderId !== undefined + ) { + updatedServer.musicFolderId = state.currentServer.musicFolderId; + } + state.serverList[id] = updatedServer; - state.currentServer = updatedServer; + if (state.currentServer?.id === id) { + state.currentServer = updatedServer; + } }); }, }, @@ -97,6 +119,7 @@ export const useCurrentServer = () => return { features: state.currentServer?.features, id: state.currentServer?.id, + musicFolderId: state.currentServer?.musicFolderId, name: state.currentServer?.name, preferInstantMix: state.currentServer?.preferInstantMix, savePassword: state.currentServer?.savePassword, diff --git a/src/shared/api/navidrome/navidrome-types.ts b/src/shared/api/navidrome/navidrome-types.ts index a26f1d105..09a02ea07 100644 --- a/src/shared/api/navidrome/navidrome-types.ts +++ b/src/shared/api/navidrome/navidrome-types.ts @@ -266,6 +266,7 @@ const genreListSort = { const genreListParameters = paginationParameters.extend({ _sort: z.nativeEnum(genreListSort).optional(), + library_id: z.array(z.string()).optional(), name: z.string().optional(), }); @@ -308,6 +309,7 @@ const albumArtistList = z.array(albumArtist); const albumArtistListParameters = paginationParameters.extend({ _sort: z.nativeEnum(NDAlbumArtistListSort).optional(), genre_id: z.string().optional(), + library_id: z.array(z.string()).optional(), missing: z.boolean().optional(), name: z.string().optional(), role: z.string().optional(), @@ -374,6 +376,7 @@ const albumListParameters = paginationParameters.extend({ genre_id: z.union([z.string(), z.string().array()]).optional(), has_rating: z.boolean().optional(), id: z.string().optional(), + library_id: z.array(z.string()).optional(), name: z.string().optional(), recently_added: z.boolean().optional(), recently_played: z.boolean().optional(), @@ -456,6 +459,7 @@ const songListParameters = paginationParameters.extend({ artist_id: z.array(z.string()).optional(), artists_id: z.array(z.string()).optional(), genre_id: z.array(z.string()).optional(), + library_id: z.array(z.string()).optional(), path: z.string().optional(), starred: z.boolean().optional(), title: z.string().optional(), diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 4a0b06c2b..264090a0e 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -89,6 +89,7 @@ export type QueueSong = Song & { export type ServerListItem = { features?: ServerFeatures; id: string; + musicFolderId?: string[]; name: string; preferInstantMix?: boolean; savePassword?: boolean; @@ -280,7 +281,7 @@ export interface GenreListQuery extends BaseQuery { navidrome?: null; }; limit?: number; - musicFolderId?: string; + musicFolderId?: string | string[]; searchTerm?: string; startIndex: number; } @@ -436,7 +437,7 @@ export interface AlbumListQuery extends AlbumListNavidromeQuery, BaseQuery { limit?: number; maxYear?: number; minYear?: number; - musicFolderId?: string; + musicFolderId?: string | string[]; searchTerm?: string; startIndex: number; } @@ -667,7 +668,7 @@ export type AlbumArtistListCountArgs = BaseEndpointArgs & { export interface AlbumArtistListQuery extends BaseQuery { _custom?: Record; limit?: number; - musicFolderId?: string; + musicFolderId?: string | string[]; searchTerm?: string; startIndex: number; } @@ -759,7 +760,7 @@ export type ArtistListCountArgs = BaseEndpointArgs & { query: ListCountQuery { _custom?: Record; limit?: number; - musicFolderId?: string; + musicFolderId?: string | string[]; role?: string; searchTerm?: string; startIndex: number; @@ -1058,7 +1059,7 @@ export type ArtistInfoArgs = BaseEndpointArgs & { query: ArtistInfoQuery }; export type ArtistInfoQuery = { artistId: string; limit: number; - musicFolderId?: string; + musicFolderId?: string | string[]; }; export type FullLyricsMetadata = Omit & { @@ -1104,7 +1105,7 @@ export type RandomSongListQuery = { limit?: number; maxYear?: number; minYear?: number; - musicFolderId?: string; + musicFolderId?: string | string[]; played: Played; }; @@ -1127,14 +1128,14 @@ export type ScrobbleResponse = null; export type SearchAlbumArtistsQuery = { albumArtistLimit?: number; albumArtistStartIndex?: number; - musicFolderId?: string; + musicFolderId?: string | string[]; query?: string; }; export type SearchAlbumsQuery = { albumLimit?: number; albumStartIndex?: number; - musicFolderId?: string; + musicFolderId?: string | string[]; query?: string; }; @@ -1147,7 +1148,7 @@ export type SearchQuery = { albumArtistStartIndex?: number; albumLimit?: number; albumStartIndex?: number; - musicFolderId?: string; + musicFolderId?: string | string[]; query?: string; songLimit?: number; songStartIndex?: number; @@ -1160,7 +1161,7 @@ export type SearchResponse = { }; export type SearchSongsQuery = { - musicFolderId?: string; + musicFolderId?: string | string[]; query?: string; songLimit?: number; songStartIndex?: number; diff --git a/src/shared/types/features-types.ts b/src/shared/types/features-types.ts index 1d62d5922..8d82cff37 100644 --- a/src/shared/types/features-types.ts +++ b/src/shared/types/features-types.ts @@ -4,6 +4,7 @@ export enum ServerFeature { BFR = 'bfr', LYRICS_MULTIPLE_STRUCTURED = 'lyricsMultipleStructured', LYRICS_SINGLE_STRUCTURED = 'lyricsSingleStructured', + MUSIC_FOLDER_MULTISELECT = 'musicFolderMultiselect', PLAYLISTS_SMART = 'playlistsSmart', PUBLIC_PLAYLIST = 'publicPlaylist', SHARING_ALBUM_SONG = 'sharingAlbumSong',