From 1fddbeab772284cd18f20bd9663b2ae0bb22c532 Mon Sep 17 00:00:00 2001 From: atdunbg Date: Thu, 2 Jul 2026 09:54:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20v0.9.0=20=E5=A4=9A=E9=9F=B3=E6=BA=90?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E3=80=81=E6=AD=8C=E6=9B=B2=E6=A0=87=E7=AD=BE?= =?UTF-8?q?=E3=80=81=E6=90=9C=E7=B4=A2=E5=BB=BA=E8=AE=AE=E3=80=81=E6=92=AD?= =?UTF-8?q?=E6=94=BE=E5=99=A8=E6=94=B9=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 音源系统: - 新增可配置多音源(波点/酷狗/酷我),支持启用/禁用/排序/自定义源 - 酷我 kwDES 加密 + mobi.s 接口绕过 VIP 限制 - VIP/版权歌曲跨源 fallback,版本一致性匹配(原版/DJ/Live/混音) - 多源聚合搜索,歌名+歌手组合去重 歌曲标签: - 付费/音质/版本/音源标签,网易云 fee 字段精确判断 - privilege 数组合并(歌手/歌单/专辑/每日推荐/收藏页) 搜索建议: - 120ms 防抖 + compositionend 支持,中文输入实时建议 - Teleport + fixed 定位避免父容器裁剪 播放器: - 网络流 seek 支持(SharedBuffer 下载完成后 byte_len 可用) - 切歌停止旧播放再加载新歌,进度条起步归零 - 切歌加载状态可视化,按钮禁用反馈 - tick generation 防竞态,旧 tick 不覆盖新歌位置 - 系统媒体控制(跨平台,Windows 增加 hwnd 就绪检查避免 panic) 其他: - 设置迁移 v7(恢复酷我音源) - useUpdater 模块级共享状态 --- pnpm-lock.yaml | 2039 +++++++++++++++++++ pnpm-workspace.yaml | 2 + src-tauri/Cargo.lock | 4 +- src-tauri/Cargo.toml | 4 +- src-tauri/src/api.rs | 157 +- src-tauri/src/audio/buffer.rs | 9 +- src-tauri/src/audio/controller.rs | 24 + src-tauri/src/audio/decoder.rs | 4 +- src-tauri/src/lib.rs | 11 +- src-tauri/src/music_sources/bodian.rs | 255 +++ src-tauri/src/music_sources/custom.rs | 166 ++ src-tauri/src/music_sources/kugou.rs | 179 ++ src-tauri/src/music_sources/kuwo.rs | 141 ++ src-tauri/src/music_sources/kwdes.rs | 300 +++ src-tauri/src/music_sources/mod.rs | 480 +++++ src/api/index.ts | 2 + src/api/search.ts | 15 +- src/api/song.ts | 36 +- src/components/PlayerBar.vue | 19 +- src/components/SongListItem.vue | 14 +- src/components/TopBar.vue | 14 +- src/composables/useUpdater.ts | 17 +- src/stores/migrations/settingsMigrations.ts | 145 +- src/stores/player.ts | 151 +- src/stores/settings.ts | 155 +- src/types/song.ts | 16 +- src/utils/song.ts | 101 + src/views/AlbumDetail.vue | 4 +- src/views/ArtistDetail.vue | 5 +- src/views/DailySongs.vue | 8 +- src/views/Discover.vue | 226 +- src/views/FavoriteSongs.vue | 8 +- src/views/Home.vue | 12 +- src/views/PlaylistDetail.vue | 4 +- src/views/Settings.vue | 275 +++ 35 files changed, 4754 insertions(+), 248 deletions(-) create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 src-tauri/src/music_sources/bodian.rs create mode 100644 src-tauri/src/music_sources/custom.rs create mode 100644 src-tauri/src/music_sources/kugou.rs create mode 100644 src-tauri/src/music_sources/kuwo.rs create mode 100644 src-tauri/src/music_sources/kwdes.rs create mode 100644 src-tauri/src/music_sources/mod.rs diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..e107281 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2039 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@tauri-apps/api': + specifier: ^2 + version: 2.11.1 + '@tauri-apps/plugin-dialog': + specifier: ^2.7.1 + version: 2.7.1 + '@tauri-apps/plugin-global-shortcut': + specifier: ^2.3.1 + version: 2.3.2 + '@tauri-apps/plugin-opener': + specifier: ^2 + version: 2.5.4 + '@tauri-apps/plugin-process': + specifier: ^2.3.1 + version: 2.3.1 + '@tauri-apps/plugin-updater': + specifier: ^2.10.1 + version: 2.10.1 + pinia: + specifier: ^3.0.4 + version: 3.0.4(typescript@5.6.3)(vue@3.5.39(typescript@5.6.3)) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 + vue: + specifier: ^3.5.13 + version: 3.5.39(typescript@5.6.3) + vue-router: + specifier: ^4.6.4 + version: 4.6.4(vue@3.5.39(typescript@5.6.3)) + devDependencies: + '@iconify-json/lucide': + specifier: ^1.2.110 + version: 1.2.115 + '@iconify/utils': + specifier: ^3.1.3 + version: 3.1.3 + '@tailwindcss/vite': + specifier: ^4.2.4 + version: 4.3.2(vite@6.4.3(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0)) + '@tauri-apps/cli': + specifier: ^2 + version: 2.11.4 + '@types/node': + specifier: ^25.6.0 + version: 25.9.4 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 + '@vitejs/plugin-vue': + specifier: ^5.2.1 + version: 5.2.4(vite@6.4.3(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.39(typescript@5.6.3)) + tailwindcss: + specifier: ^4.2.4 + version: 4.3.2 + typescript: + specifier: ~5.6.2 + version: 5.6.3 + unplugin-icons: + specifier: ^23.0.1 + version: 23.0.1(@vue/compiler-sfc@3.5.39) + vite: + specifier: ^6.0.3 + version: 6.4.3(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0) + vue-tsc: + specifier: ^2.1.10 + version: 2.2.12(typescript@5.6.3) + +packages: + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@iconify-json/lucide@1.2.115': + resolution: {integrity: sha512-plc4npdX0I2RpsE5FFIdkCjGtL9vG09FU/a2nZ+pJ0fFQV6mTqRlbUgZgUgSPDFswq0kaaNT229nW6AQRj5o9w==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.3': + resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@tailwindcss/node@4.3.2': + resolution: {integrity: sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==} + + '@tailwindcss/oxide-android-arm64@4.3.2': + resolution: {integrity: sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.2': + resolution: {integrity: sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.2': + resolution: {integrity: sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.2': + resolution: {integrity: sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + resolution: {integrity: sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + resolution: {integrity: sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + resolution: {integrity: sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + resolution: {integrity: sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + resolution: {integrity: sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + resolution: {integrity: sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + resolution: {integrity: sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + resolution: {integrity: sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.2': + resolution: {integrity: sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.2': + resolution: {integrity: sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + + '@tauri-apps/cli-darwin-arm64@2.11.4': + resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.11.4': + resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.11.4': + resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} + engines: {node: '>= 10'} + hasBin: true + + '@tauri-apps/plugin-dialog@2.7.1': + resolution: {integrity: sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==} + + '@tauri-apps/plugin-global-shortcut@2.3.2': + resolution: {integrity: sha512-UReHNXrLvpEjylE4jb4oCYiy96uRykPUthoCQCmRXYrd5hs5X9DrW+qOn7GLW57EJN4tdK8bgK5twBTz2NOxzA==} + + '@tauri-apps/plugin-opener@2.5.4': + resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} + + '@tauri-apps/plugin-process@2.3.1': + resolution: {integrity: sha512-nCa4fGVaDL/B9ai03VyPOjfAHRHSBz5v6F/ObsB73r/dA3MHHhZtldaDMIc0V/pnUw9ehzr2iEG+XkSEyC0JJA==} + + '@tauri-apps/plugin-updater@2.10.1': + resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@25.9.4': + resolution: {integrity: sha512-dszCsrKb5U7ZsVZBWiHFklTloVl0mSEnWH/iZXfZUlI4rzCUnsvGmgqfuVRHL54ugE7/wRuxEIXRa2iMZ+BG6g==} + + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + + '@vitejs/plugin-vue@5.2.4': + resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue/compiler-core@3.5.39': + resolution: {integrity: sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==} + + '@vue/compiler-dom@3.5.39': + resolution: {integrity: sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==} + + '@vue/compiler-sfc@3.5.39': + resolution: {integrity: sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==} + + '@vue/compiler-ssr@3.5.39': + resolution: {integrity: sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + + '@vue/devtools-api@7.7.10': + resolution: {integrity: sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA==} + + '@vue/devtools-kit@7.7.10': + resolution: {integrity: sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw==} + + '@vue/devtools-shared@7.7.10': + resolution: {integrity: sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ==} + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.39': + resolution: {integrity: sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==} + + '@vue/runtime-core@3.5.39': + resolution: {integrity: sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==} + + '@vue/runtime-dom@3.5.39': + resolution: {integrity: sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==} + + '@vue/server-renderer@3.5.39': + resolution: {integrity: sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==} + peerDependencies: + vue: 3.5.39 + + '@vue/shared@3.5.39': + resolution: {integrity: sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + alien-signals@1.0.13: + resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + + copy-anything@4.0.5: + resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + enhanced-resolve@5.21.6: + resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==} + engines: {node: '>=10.13.0'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + exsolve@1.1.0: + resolution: {integrity: sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-what@5.5.0: + resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} + engines: {node: '>=18'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} + engines: {node: '>=14'} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.3: + resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + engines: {node: '>=12.20.0'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-manager-detector@1.6.0: + resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + pinia@3.0.4: + resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==} + peerDependencies: + typescript: '>=4.5.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.1: + resolution: {integrity: sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==} + + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + + postcss@8.5.16: + resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} + engines: {node: ^10 || ^12 || >=14} + + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + superjson@2.2.6: + resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} + engines: {node: '>=16'} + + tailwindcss@4.3.2: + resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + unplugin-icons@23.0.1: + resolution: {integrity: sha512-rv0XEJepajKzDLvRUWASM8K+8+/CCfZn2jtogXqg6RIp7kpatRc/aFrVJn8ANQA09e++lPEEv9yX8cC9enc+QQ==} + peerDependencies: + '@svgr/core': '>=7.0.0' + '@svgx/core': ^1.0.1 + '@vue/compiler-sfc': ^3.0.2 + svelte: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + '@svgr/core': + optional: true + '@svgx/core': + optional: true + '@vue/compiler-sfc': + optional: true + svelte: + optional: true + + unplugin@2.3.11: + resolution: {integrity: sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==} + engines: {node: '>=18.12.0'} + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.39: + resolution: {integrity: sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + +snapshots: + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.6.0 + tinyexec: 1.2.4 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@iconify-json/lucide@1.2.115': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.3': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@tailwindcss/node@4.3.2': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.6 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.2 + + '@tailwindcss/oxide-android-arm64@4.3.2': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.2': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.2': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.2': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.2': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.2': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.2': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.2': + optional: true + + '@tailwindcss/oxide@4.3.2': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-arm64': 4.3.2 + '@tailwindcss/oxide-darwin-x64': 4.3.2 + '@tailwindcss/oxide-freebsd-x64': 4.3.2 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.2 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.2 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.2 + '@tailwindcss/oxide-linux-x64-musl': 4.3.2 + '@tailwindcss/oxide-wasm32-wasi': 4.3.2 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.2 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.2 + + '@tailwindcss/vite@4.3.2(vite@6.4.3(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0))': + dependencies: + '@tailwindcss/node': 4.3.2 + '@tailwindcss/oxide': 4.3.2 + tailwindcss: 4.3.2 + vite: 6.4.3(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0) + + '@tauri-apps/api@2.11.1': {} + + '@tauri-apps/cli-darwin-arm64@2.11.4': + optional: true + + '@tauri-apps/cli-darwin-x64@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli@2.11.4': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.11.4 + '@tauri-apps/cli-darwin-x64': 2.11.4 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.4 + '@tauri-apps/cli-linux-arm64-musl': 2.11.4 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-musl': 2.11.4 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.4 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 + '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + + '@tauri-apps/plugin-dialog@2.7.1': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-global-shortcut@2.3.2': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-opener@2.5.4': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-process@2.3.1': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@tauri-apps/plugin-updater@2.10.1': + dependencies: + '@tauri-apps/api': 2.11.1 + + '@types/estree@1.0.9': {} + + '@types/node@25.9.4': + dependencies: + undici-types: 7.24.6 + + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 25.9.4 + + '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0))(vue@3.5.39(typescript@5.6.3))': + dependencies: + vite: 6.4.3(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0) + vue: 3.5.39(typescript@5.6.3) + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.39': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.39 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.39': + dependencies: + '@vue/compiler-core': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/compiler-sfc@3.5.39': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.39 + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-ssr': 3.5.39 + '@vue/shared': 3.5.39 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.16 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.39': + dependencies: + '@vue/compiler-dom': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@6.6.4': {} + + '@vue/devtools-api@7.7.10': + dependencies: + '@vue/devtools-kit': 7.7.10 + + '@vue/devtools-kit@7.7.10': + dependencies: + '@vue/devtools-shared': 7.7.10 + birpc: 2.9.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.6 + + '@vue/devtools-shared@7.7.10': + dependencies: + rfdc: 1.4.1 + + '@vue/language-core@2.2.12(typescript@5.6.3)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.39 + alien-signals: 1.0.13 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.6.3 + + '@vue/reactivity@3.5.39': + dependencies: + '@vue/shared': 3.5.39 + + '@vue/runtime-core@3.5.39': + dependencies: + '@vue/reactivity': 3.5.39 + '@vue/shared': 3.5.39 + + '@vue/runtime-dom@3.5.39': + dependencies: + '@vue/reactivity': 3.5.39 + '@vue/runtime-core': 3.5.39 + '@vue/shared': 3.5.39 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.39(vue@3.5.39(typescript@5.6.3))': + dependencies: + '@vue/compiler-ssr': 3.5.39 + '@vue/shared': 3.5.39 + vue: 3.5.39(typescript@5.6.3) + + '@vue/shared@3.5.39': {} + + acorn@8.17.0: {} + + alien-signals@1.0.13: {} + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + balanced-match@1.0.2: {} + + birpc@2.9.0: {} + + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + + camelcase@5.3.1: {} + + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + confbox@0.1.8: {} + + confbox@0.2.4: {} + + copy-anything@4.0.5: + dependencies: + is-what: 5.5.0 + + csstype@3.2.3: {} + + de-indent@1.0.2: {} + + decamelize@1.2.0: {} + + detect-libc@2.1.2: {} + + dijkstrajs@1.0.3: {} + + emoji-regex@8.0.0: {} + + enhanced-resolve@5.21.6: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@7.0.1: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + estree-walker@2.0.2: {} + + exsolve@1.1.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + graceful-fs@4.2.11: {} + + he@1.2.0: {} + + hookable@5.5.3: {} + + import-meta-resolve@4.2.0: {} + + is-fullwidth-code-point@3.0.0: {} + + is-what@5.5.0: {} + + jiti@2.7.0: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + local-pkg@1.2.1: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.1 + quansync: 0.2.11 + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + + mitt@3.0.1: {} + + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + muggle-string@0.4.1: {} + + nanoid@3.3.15: {} + + obug@2.1.3: {} + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + package-manager-detector@1.6.0: {} + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + pathe@2.0.3: {} + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + pinia@3.0.4(typescript@5.6.3)(vue@3.5.39(typescript@5.6.3)): + dependencies: + '@vue/devtools-api': 7.7.10 + vue: 3.5.39(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.1: + dependencies: + confbox: 0.2.4 + exsolve: 1.1.0 + pathe: 2.0.3 + + pngjs@5.0.0: {} + + postcss@8.5.16: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + + quansync@0.2.11: {} + + require-directory@2.1.1: {} + + require-main-filename@2.0.0: {} + + rfdc@1.4.1: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + set-blocking@2.0.0: {} + + source-map-js@1.2.1: {} + + speakingurl@14.0.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + superjson@2.2.6: + dependencies: + copy-anything: 4.0.5 + + tailwindcss@4.3.2: {} + + tapable@2.3.3: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + typescript@5.6.3: {} + + ufo@1.6.4: {} + + undici-types@7.24.6: {} + + unplugin-icons@23.0.1(@vue/compiler-sfc@3.5.39): + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/utils': 3.1.3 + local-pkg: 1.2.1 + obug: 2.1.3 + unplugin: 2.3.11 + optionalDependencies: + '@vue/compiler-sfc': 3.5.39 + + unplugin@2.3.11: + dependencies: + '@jridgewell/remapping': 2.3.5 + acorn: 8.17.0 + picomatch: 4.0.4 + webpack-virtual-modules: 0.6.2 + + vite@6.4.3(@types/node@25.9.4)(jiti@2.7.0)(lightningcss@1.32.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.16 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 25.9.4 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + + vscode-uri@3.1.0: {} + + vue-router@4.6.4(vue@3.5.39(typescript@5.6.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.39(typescript@5.6.3) + + vue-tsc@2.2.12(typescript@5.6.3): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.6.3) + typescript: 5.6.3 + + vue@3.5.39(typescript@5.6.3): + dependencies: + '@vue/compiler-dom': 3.5.39 + '@vue/compiler-sfc': 3.5.39 + '@vue/runtime-dom': 3.5.39 + '@vue/server-renderer': 3.5.39(vue@3.5.39(typescript@5.6.3)) + '@vue/shared': 3.5.39 + optionalDependencies: + typescript: 5.6.3 + + webpack-virtual-modules@0.6.2: {} + + which-module@2.0.1: {} + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + y18n@4.0.3: {} + + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b73452f..c76f096 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4,8 +4,9 @@ version = 4 [[package]] name = "Nekosonic" -version = "0.8.0" +version = "0.8.1" dependencies = [ + "async-trait", "base64 0.22.1", "cpal", "dirs 5.0.1", @@ -29,6 +30,7 @@ dependencies = [ "tauri-plugin-single-instance", "tauri-plugin-updater", "tokio", + "urlencoding", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7b70cc3..2c810d8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -36,7 +36,9 @@ base64 = "0.22" md5 = "0.7" ncm-api-rs = "0.1" -tokio = { version = "1", features = ["rt", "sync"] } +tokio = { version = "1", features = ["rt", "sync"] } +async-trait = "0.1" +urlencoding = "2" tauri-plugin-process = "2.3.1" tauri-plugin-updater = "2" diff --git a/src-tauri/src/api.rs b/src-tauri/src/api.rs index 9a15255..2d4cbcd 100644 --- a/src-tauri/src/api.rs +++ b/src-tauri/src/api.rs @@ -165,7 +165,8 @@ pub async fn cloudsearch(query: CloudSearchQuery, state: State<'_, ApiController /// 搜索建议 #[tauri::command] pub async fn search_suggest(query: SearchSuggestQuery, state: State<'_, ApiController>) -> Result { - api_call!(state, search_suggest, params: [("keywords", &query.keyword)]) + // type=mobile 返回 allMatch 格式(纯关键词列表),不传 type 会返回 albums/songs 等详细字段 + api_call!(state, search_suggest, params: [("keywords", &query.keyword), ("type", "mobile")]) } /// 获取热搜词列表 @@ -185,8 +186,14 @@ pub async fn playlist_track_all(query: PlaylistTrackAllQuery, state: State<'_, A } /// 歌曲播放地址查询参数 +/// sources: 启用的第三方音源配置列表(按优先级排序),网易云无 URL 时按顺序降级 #[derive(Deserialize)] -pub struct SongUrlQuery { pub id: u64, pub level: Option, pub fm_mode: Option } +pub struct SongUrlQuery { + pub id: u64, + pub level: Option, + pub fm_mode: Option, + pub sources: Option>, +} /// 获取歌曲播放地址(返回完整 data 对象,包含 url、freeTrialInfo 等) #[tauri::command] @@ -230,10 +237,152 @@ pub async fn get_song_url(query: SongUrlQuery, state: State<'_, ApiController>) let data = &resp.body["data"][0]; let url = data["url"].as_str().filter(|s| !s.is_empty()); - if url.is_none() { + // VIP 试听片段标记:存在说明网易云返回的只是试听 URL,需走音源补充获取完整 URL + let has_free_trial = data["freeTrialInfo"].is_object(); + + // 网易云有可用 URL 且非试听片段,直接返回 + if url.is_some() && !has_free_trial { + return Ok(data.to_string()); + } + + // URL 为空 或 VIP 试听片段:尝试音源 fallback + let sources = query.sources.unwrap_or_default(); + if sources.is_empty() { + // 没有配置音源:有 URL(试听)就返回试听,否则报错 + if url.is_some() { + return Ok(data.to_string()); + } return Err("暂无播放源".into()); } - Ok(data.to_string()) + + // 获取歌曲信息用于搜索 + let detail_q = state.build_query().param("ids", &query.id.to_string()); + let detail_resp = client.song_detail(&detail_q).await + .map_err(|e| format!("获取歌曲详情失败: {}", e))?; + let song_info = &detail_resp.body["songs"][0]; + let song_name = song_info["name"].as_str().unwrap_or(""); + let artist_str: String = song_info["ar"].as_array() + .map(|arr| { + arr.iter() + .filter_map(|a| a["name"].as_str()) + .collect::>() + .join("/") + }) + .unwrap_or_default(); + + match crate::music_sources::fallback_get_url(song_name, &artist_str, level, &sources).await { + Ok((fb_url, source_label)) => { + let mut data_obj = data.clone(); + data_obj["url"] = json!(fb_url); + data_obj["source"] = json!(source_label); + data_obj["br"] = json!(320000); + // 清除试听标记,使用音源补充的完整 URL + if let Some(obj) = data_obj.as_object_mut() { + obj.remove("freeTrialInfo"); + } + Ok(data_obj.to_string()) + } + Err(_) => { + // fallback 失败:有试听 URL 就返回试听,否则报错 + if url.is_some() { + return Ok(data.to_string()); + } + Err("暂无播放源".into()) + } + } +} + +/// 多源聚合搜索查询参数 +#[derive(Deserialize)] +pub struct SearchMultiQuery { + pub keyword: String, + pub sources: Vec, + pub limit: Option, +} + +/// 多源聚合搜索:并发请求所有启用音源,合并结果返回 +/// 用于「网易云搜不到的歌,从其他音源补充搜索结果」场景 +#[tauri::command] +pub async fn search_songs_multi(query: SearchMultiQuery) -> Result { + let limit = query.limit.unwrap_or(20); + let results = crate::music_sources::search_multi(&query.keyword, &query.sources, limit).await; + serde_json::to_string(&results).map_err(|e| e.to_string()) +} + +/// 外部音源获取播放 URL 查询参数 +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalUrlQuery { + pub source: crate::music_sources::SourceConfig, + pub song_id: String, + pub quality: Option, + /// 歌曲名(用于主源失败时跨源 fallback) + #[serde(default)] + pub song_name: Option, + /// 歌手(用于跨源 fallback 精确匹配) + #[serde(default)] + pub artist: Option, + /// 所有启用的音源(主源失败时按顺序尝试其他源) + #[serde(default)] + pub all_sources: Option>, +} + +/// 从指定外部音源获取播放 URL +/// 用于「点击搜索结果中来自某外部源的歌曲,直接通过该源取 URL 播放」 +/// +/// 流程: +/// 1. 先用主源(query.source)直接取 URL(已有 song_id,最快) +/// 2. 主源失败时,用歌名+歌手在 all_sources 里 fallback 搜索 +/// (例如 kugou 的 VIP 歌曲无 URL,换 bodian/kuwo 找同款) +#[tauri::command] +pub async fn get_external_song_url(query: ExternalUrlQuery) -> Result { + let quality = query.quality.unwrap_or_else(|| "standard".into()); + + // 1. 主源直接取 URL(已有 song_id,最快路径) + let src = crate::music_sources::get_source(&query.source) + .ok_or_else(|| format!("未知音源: {} ({})", query.source.id, query.source.kind))?; + let primary_id = &query.source.id; + let primary_label = &query.source.label; + + match src.get_url(&query.song_id, &quality).await { + Ok(url) if !url.is_empty() => { + let resp = json!({ + "url": url, + "source": primary_id, + "source_label": primary_label, + }); + return Ok(resp.to_string()); + } + Ok(_) => {} // 空 URL,走 fallback + Err(e) => { + eprintln!("[music_sources] {} 取URL失败,尝试跨源 fallback: {}", primary_id, e); + } + } + + // 2. 主源失败,用歌名+歌手在其他启用源里 fallback + let song_name = match query.song_name.as_deref() { + Some(n) if !n.is_empty() => n, + _ => return Err(format!("{} 无法获取播放URL且缺少歌名无法 fallback", primary_id)), + }; + let artist = query.artist.as_deref().unwrap_or(""); + let all_sources = query.all_sources.unwrap_or_default(); + + if all_sources.is_empty() { + return Err(format!("{} 无法获取播放URL且无备用源", primary_id)); + } + + match crate::music_sources::fallback_get_url(song_name, artist, &quality, &all_sources).await { + Ok((url, label)) => { + eprintln!("[music_sources] 跨源 fallback 成功: {} -> {}", song_name, label); + let resp = json!({ + "url": url, + "source": "fallback", + "source_label": label, + }); + Ok(resp.to_string()) + } + Err(e) => Err(format!("{} 跨源 fallback 失败: {}", primary_id, e)), + } } /// 获取歌词 diff --git a/src-tauri/src/audio/buffer.rs b/src-tauri/src/audio/buffer.rs index 4d825ec..8a6f6c5 100644 --- a/src-tauri/src/audio/buffer.rs +++ b/src-tauri/src/audio/buffer.rs @@ -198,6 +198,13 @@ impl MediaSource for StreamingReader { } fn byte_len(&self) -> Option { - None + // 只有下载完成后才能返回正确的文件总长度 + // symphonia 需要 byte_len() 来计算 seek 的字节位置 + let state = self.buffer.state_mutex().lock().unwrap(); + if state.done { + Some(state.bytes.len() as u64) + } else { + None + } } } diff --git a/src-tauri/src/audio/controller.rs b/src-tauri/src/audio/controller.rs index 1cafa4e..1d56f1c 100644 --- a/src-tauri/src/audio/controller.rs +++ b/src-tauri/src/audio/controller.rs @@ -95,6 +95,23 @@ fn stop_playback(output_ctx: &mut Option, shared_position: &Arc deadline { + eprintln!("[audio] seek 等待网络流下载超时(3s)"); + break; + } + thread::sleep(Duration::from_millis(50)); + } +} + /// 根据当前播放源(本地文件或网络缓冲区)重建 MediaSourceStream fn rebuild_mss( local_path: &Option, @@ -290,6 +307,13 @@ fn audio_thread( AudioCmd::Seek(time) => { stop_playback(&mut output_ctx, &shared_position); + // 对网络流,等待 SharedBuffer 下载完成后再 seek + if current_local_path.is_none() { + if let Some(ref buffer) = current_audio_buffer { + wait_for_buffer_done(buffer); + } + } + let mss = match rebuild_mss(¤t_local_path, ¤t_audio_buffer) { Some(mss) => mss, None => continue, diff --git a/src-tauri/src/audio/decoder.rs b/src-tauri/src/audio/decoder.rs index 26e9b67..603b437 100644 --- a/src-tauri/src/audio/decoder.rs +++ b/src-tauri/src/audio/decoder.rs @@ -253,7 +253,9 @@ pub fn decode_to_ring( time: Time::from(time), track_id: Some(track_id), }; - let _ = format_reader.seek(SeekMode::Accurate, seek_to); + if let Err(e) = format_reader.seek(SeekMode::Accurate, seek_to) { + eprintln!("[audio] seek 失败(time={}s): {}", time, e); + } } let ratio = target_sample_rate as f64 / src_rate; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4c5a675..bc8e325 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; mod api; mod audio; mod media_controls; +mod music_sources; use api::ApiController; use audio::AppAudio; @@ -41,7 +42,13 @@ pub fn run() { } else { None }; - media_controls::start_media_controls(app.handle().clone(), hwnd); + // Windows 上 souvlaki 要求 hwnd 必须存在,否则 panic + // 窗口尚未创建时跳过媒体控制初始化(不阻塞 app 启动) + if hwnd.is_some() { + media_controls::start_media_controls(app.handle().clone(), hwnd); + } else { + eprintln!("[media_controls] 窗口未就绪,跳过媒体控制初始化"); + } } #[cfg(not(target_os = "windows"))] @@ -140,6 +147,8 @@ pub fn run() { api::cloudsearch, api::search_suggest, api::get_song_url, + api::search_songs_multi, + api::get_external_song_url, api::get_hot_search, api::get_playlist_detail, api::get_lyric, diff --git a/src-tauri/src/music_sources/bodian.rs b/src-tauri/src/music_sources/bodian.rs new file mode 100644 index 0000000..a2bf591 --- /dev/null +++ b/src-tauri/src/music_sources/bodian.rs @@ -0,0 +1,255 @@ +// 波点音乐(酷我旗下子品牌) +// 参考 SPlayer bodian.ts / UnblockNeteaseMusic bodian.js +// +// 搜索:复用 kuwo 的 search.kuwo.cn/r.s 接口(无需签名) +// URL:bd-api.kuwo.cn/api/play/music/v2/audioUrl,需 sign 签名 + 先调一次广告接口 +// +// sign 算法(来自 bodian.js generateSign): +// 1. url 末尾追加 ×tamp={ms} +// 2. 提取 ? 后的 query 部分 +// 3. 去除非字母数字字符后按字符排序 +// 4. 拼接 "kuwotest" + sorted_chars + url.pathname +// 5. md5 + +use async_trait::async_trait; +use md5::compute as md5_compute; +use serde_json::Value; +use std::time::{SystemTime, UNIX_EPOCH}; + +use super::{ExternalSong, MusicSource, build_client, parse_kuwo_abslist, parse_kuwo_json, truncate_chars}; + +const UA: &str = "Dart/2.19 (dart:io)"; + +pub struct BodianSource { + label: String, + /// 随机设备 ID(参考 SPlayer,每次实例化生成) + device_id: String, +} + +impl BodianSource { + pub fn new(label: String) -> Self { + Self { + label, + device_id: gen_device_id(), + } + } +} + +/// 生成随机设备 ID(参考 SPlayer getRandomDeviceId) +/// 用系统时间纳秒作为种子,每次实例化不同 +fn gen_device_id() -> String { + let ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + // 取纳秒的低 11 位数字,范围 0~100000000000 + let n = (ns % 100_000_000_001) as u64; + n.to_string() +} + +fn now_ms() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0) +} + +/// 生成 bodian 风格签名 URL +/// 输入:原始 url(含 query),如 http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br=320kmp3&musicId=123 +/// 输出:追加 ×tamp=...&sign=... 的完整 url +/// +/// 算法(参考 UnblockNeteaseMusic bodian.js generateSign): +/// 1. str += ×tamp={ms} +/// 2. query = str 中 ? 之后的部分 +/// 3. filteredChars = query 去除非字母数字后按字符排序 +/// 4. dataToEncrypt = "kuwotest" + filteredChars + url.pathname +/// 5. sign = md5(dataToEncrypt) +fn generate_sign(url: &str) -> String { + let ts = now_ms(); + let with_ts = format!("{}×tamp={}", url, ts); + // 提取 pathname(? 之前的部分去掉 scheme://host) + let (scheme_host, pathname, query_with_ts) = split_url(&with_ts); + let _ = scheme_host; // 不需要 + // 提取 ? 之后的 query 部分(含 timestamp) + let q = query_with_ts; + // 去除非字母数字,按字符排序 + let mut chars: Vec = q.chars().filter(|c| c.is_ascii_alphanumeric()).collect(); + chars.sort(); + let sorted: String = chars.iter().collect(); + let data = format!("kuwotest{}{}", sorted, pathname); + let hash = md5_compute(data.as_bytes()); + let sign = format!("{:x}", hash); + format!("{}&sign={}", with_ts, sign) +} + +/// 将 URL 拆分为 (scheme://host, pathname, query) +/// 例:http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br=320kmp3&musicId=123×tamp=... +/// → ("http://bd-api.kuwo.cn", "/api/play/music/v2/audioUrl", "&br=320kmp3&musicId=123×tamp=...") +fn split_url(url: &str) -> (String, String, String) { + // 分离 query + let (path_part, query) = url.split_once('?').map(|(p, q)| (p.to_string(), q.to_string())) + .unwrap_or((url.to_string(), String::new())); + // path_part 形如 http://bd-api.kuwo.cn/api/play/music/v2/audioUrl + // 找到 scheme:// 后的第三个 /(即 host 之后的第一个 /) + let scheme_end = path_part.find("://").map(|i| i + 3).unwrap_or(0); + let after_scheme = &path_part[scheme_end..]; + let path_start = after_scheme.find('/').map(|i| scheme_end + i).unwrap_or(path_part.len()); + let scheme_host = path_part[..path_start].to_string(); + let pathname = path_part[path_start..].to_string(); + (scheme_host, pathname, query) +} + +fn client() -> Result { + build_client(UA, 10) +} + +#[async_trait] +impl MusicSource for BodianSource { + fn id(&self) -> &str { + "bodian" + } + fn label(&self) -> &str { + &self.label + } + + async fn search(&self, keyword: &str, _page: u32, limit: u32) -> Result, String> { + let client = client()?; + let kw = urlencoding::encode(keyword); + let url = format!( + "http://search.kuwo.cn/r.s?&correct=1&vipver=1&stype=comprehensive&encoding=utf8&rformat=json&mobi=1&show_copyright_off=1&searchapi=6&all={}", + kw + ); + let resp = client + .get(&url) + .send() + .await + .map_err(|e| format!("bodian 搜索请求失败: {}", e))?; + let text = resp + .text() + .await + .map_err(|e| format!("bodian 读取响应失败: {}", e))?; + + // 复用 kuwo 的 JSON 解析(含单引号 JSON 兜底)和 abslist 解析逻辑 + let json: Value = parse_kuwo_json(&text, "bodian")?; + + let abslist = json["content"][1]["musicpage"]["abslist"] + .as_array() + .ok_or_else(|| format!("bodian 搜索结果格式异常, body(前300): {}", truncate_chars(&text, 300)))?; + + let list = parse_kuwo_abslist(&abslist[..abslist.len().min(limit as usize)], "bodian", &self.label); + if list.is_empty() { + return Err("bodian 搜索无结果".into()); + } + Ok(list) + } + + async fn get_url(&self, song_id: &str, quality: &str) -> Result { + let client = client()?; + // 1. 先调一次广告接口(参考 SPlayer sendAdFreeRequest) + // 模拟"观看广告"获取免费播放权限 + let _ = client + .post("http://bd-api.kuwo.cn/api/service/advert/watch?uid=-1&token=×tamp=1724306124436&sign=15a676d66285117ad714e8c8371691da") + .header("user-agent", "Dart/2.19 (dart:io)") + .header("plat", "ar") + .header("channel", "aliopen") + .header("devid", &self.device_id) + .header("ver", "3.9.0") + .header("host", "bd-api.kuwo.cn") + .header("qimei36", "1e9970cbcdc20a031dee9f37100017e1840e") + .header("content-type", "application/json; charset=utf-8") + .body(r#"{"type":5,"subType":5,"musicId":0,"adToken":""}"#) + .send() + .await; + + // 2. 构造带签名的 audioUrl 请求 + let br = if quality.contains("lossless") || quality.contains("hires") { + "2000kflac" + } else { + "320kmp3" + }; + let base = format!( + "http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br={}&musicId={}", + br, song_id + ); + let signed_url = generate_sign(&base); + + let resp = client + .get(&signed_url) + .header("user-agent", "Dart/2.19 (dart:io)") + .header("plat", "ar") + .header("channel", "aliopen") + .header("devid", &self.device_id) + .header("ver", "3.9.0") + .header("host", "bd-api.kuwo.cn") + .header("qimei36", "1e9970cbcdc20a031dee9f37100017e1840e") + .header("X-Forwarded-For", "1.0.1.114") + .send() + .await + .map_err(|e| format!("bodian 获取URL失败: {}", e))?; + + let text = resp + .text() + .await + .map_err(|e| format!("bodian 读取URL失败: {}", e))?; + + // 解析 bd-api 返回的 JSON + let json: Value = serde_json::from_str(&text) + .map_err(|e| format!("bodian 解析URL JSON失败: {} | body: {}", e, text))?; + + if json["code"].as_i64() == Some(200) { + if let Some(url) = json["data"]["audioUrl"].as_str().filter(|s| !s.is_empty()) { + return Ok(url.to_string()); + } + } + + Err(format!("bodian 无法获取URL (code={}): {}", + json["code"].as_i64().unwrap_or(-1), + json["msg"].as_str().unwrap_or("未知错误"))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_url() { + let url = "http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br=320kmp3&musicId=123×tamp=1700000000000"; + let (host, path, query) = split_url(url); + assert_eq!(host, "http://bd-api.kuwo.cn"); + assert_eq!(path, "/api/play/music/v2/audioUrl"); + assert_eq!(query, "&br=320kmp3&musicId=123×tamp=1700000000000"); + } + + #[test] + fn test_generate_sign_format() { + let url = "http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br=320kmp3&musicId=123"; + let signed = generate_sign(url); + // 应包含 timestamp 和 sign + assert!(signed.contains("×tamp=")); + assert!(signed.contains("&sign=")); + // 原始 URL 应保留 + assert!(signed.starts_with("http://bd-api.kuwo.cn/api/play/music/v2/audioUrl?&br=320kmp3&musicId=123")); + } + + /// 真实接口测试,需手动运行: cargo test -- --ignored --nocapture + #[tokio::test] + #[ignore] + async fn test_bodian_get_url_real() { + // 测试:搜索「唯一 邓紫棋」 + let src = BodianSource::new("波点音乐".into()); + let kw = "唯一 邓紫棋"; + let songs = src.search(kw, 1, 10).await.expect("搜索应成功"); + println!("bodian 搜索「{}」到 {} 首", kw, songs.len()); + for s in &songs { + println!(" - {} - {} (id={})", s.name, s.artist, s.id); + } + // 尝试获取前 5 首的 URL + for s in songs.iter().take(5) { + match src.get_url(&s.id, "standard").await { + Ok(url) => println!(" ✅ {} -> {}", s.name, url), + Err(e) => println!(" ❌ {} -> {}", s.name, e), + } + } + } +} diff --git a/src-tauri/src/music_sources/custom.rs b/src-tauri/src/music_sources/custom.rs new file mode 100644 index 0000000..320e566 --- /dev/null +++ b/src-tauri/src/music_sources/custom.rs @@ -0,0 +1,166 @@ +// 自定义 HTTP API 音源 +// 用户通过模板 URL + JSON 路径配置搜索/获取播放地址的接口 +// +// 模板占位符: +// 搜索 URL:{keyword} {page} {limit} +// URL 接口:{id} {quality} +// +// JSON 路径用 "." 分隔,如 "data.songs" +// 字段映射指定结果数组每项里各字段对应的 key 名 + +use async_trait::async_trait; +use serde_json::Value; + +use super::{ExternalSong, MusicSource, SourceConfig, build_client}; + +const UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"; + +pub struct CustomHttpSource { + cfg: SourceConfig, +} + +impl CustomHttpSource { + pub fn new(cfg: SourceConfig) -> Self { + Self { cfg } + } +} + +fn client() -> Result { + build_client(UA, 10) +} + +/// 按 "a.b.c" 路径访问嵌套 JSON +fn json_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> { + let mut cur = value; + for k in path.split('.') { + if k.is_empty() { + continue; + } + cur = cur.get(k)?; + } + Some(cur) +} + +/// 取节点的字符串值(兼容字符串/数字) +fn value_to_string(v: &Value) -> String { + v.as_str() + .map(String::from) + .unwrap_or_else(|| v.to_string()) +} + +#[async_trait] +impl MusicSource for CustomHttpSource { + fn id(&self) -> &str { + &self.cfg.id + } + fn label(&self) -> &str { + &self.cfg.label + } + + async fn search(&self, keyword: &str, page: u32, limit: u32) -> Result, String> { + let client = client()?; + let tmpl = self + .cfg + .search_url + .as_deref() + .ok_or("自定义音源缺少 search_url")?; + let url = tmpl + .replace("{keyword}", &urlencoding::encode(keyword)) + .replace("{page}", &page.to_string()) + .replace("{limit}", &limit.to_string()); + + let resp = client + .get(&url) + .send() + .await + .map_err(|e| format!("custom [{}] 请求失败: {}", self.cfg.id, e))?; + let text = resp + .text() + .await + .map_err(|e| format!("custom [{}] 读取失败: {}", self.cfg.id, e))?; + let json: Value = serde_json::from_str(&text) + .map_err(|e| format!("custom [{}] JSON 解析失败: {}", self.cfg.id, e))?; + + let arr_path = self.cfg.search_path.as_deref().unwrap_or("data"); + let arr = json_path(&json, arr_path).and_then(|v| v.as_array()).ok_or_else(|| { + format!( + "custom [{}] 找不到数组路径: {}(响应: {})", + self.cfg.id, arr_path, &text[..text.len().min(200)] + ) + })?; + + let id_f = self.cfg.id_field.as_deref().unwrap_or("id"); + let name_f = self.cfg.name_field.as_deref().unwrap_or("name"); + let artist_f = self.cfg.artist_field.as_deref().unwrap_or("artist"); + let album_f = self.cfg.album_field.as_deref().unwrap_or("album"); + let dur_f = self.cfg.duration_field.as_deref().unwrap_or("duration"); + let pic_f = self.cfg.pic_field.as_deref().unwrap_or("pic"); + + let list: Vec = arr + .iter() + .map(|s| { + let id = json_path(s, id_f).map(value_to_string).unwrap_or_default(); + let name = json_path(s, name_f).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let artist = json_path(s, artist_f).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let album = json_path(s, album_f).and_then(|v| v.as_str()).unwrap_or("").to_string(); + let duration = json_path(s, dur_f) + .and_then(|v| { + v.as_u64().or_else(|| v.as_str().and_then(|s| s.parse().ok())) + }) + .unwrap_or(0); + let pic = json_path(s, pic_f).and_then(|v| v.as_str()).unwrap_or("").to_string(); + ExternalSong { + id, + source: self.cfg.id.clone(), + source_label: self.cfg.label.clone(), + name, + artist, + album, + duration, + pic_url: pic, + } + }) + .collect(); + Ok(list) + } + + async fn get_url(&self, song_id: &str, quality: &str) -> Result { + let client = client()?; + let tmpl = self.cfg.url_api.as_deref().ok_or("自定义音源缺少 url_api")?; + let url = tmpl + .replace("{id}", &urlencoding::encode(song_id)) + .replace("{quality}", quality); + + let resp = client + .get(&url) + .send() + .await + .map_err(|e| format!("custom [{}] URL 请求失败: {}", self.cfg.id, e))?; + let text = resp + .text() + .await + .map_err(|e| format!("custom [{}] URL 读取失败: {}", self.cfg.id, e))?; + + // 先尝试 JSON 路径取 URL;不是 JSON 则视为纯 URL 文本 + if let Ok(json) = serde_json::from_str::(&text) { + let url_path = self.cfg.url_path.as_deref().unwrap_or("data.url"); + if let Some(v) = json_path(&json, url_path) { + if let Some(s) = v.as_str() { + if !s.is_empty() { + return Ok(s.to_string()); + } + } + } + return Err(format!( + "custom [{}] URL 字段为空或路径错误: {}", + self.cfg.id, text + )); + } + + let s = text.trim(); + if s.is_empty() { + return Err("custom URL 为空".into()); + } + Ok(s.to_string()) + } +} diff --git a/src-tauri/src/music_sources/kugou.rs b/src-tauri/src/music_sources/kugou.rs new file mode 100644 index 0000000..7838758 --- /dev/null +++ b/src-tauri/src/music_sources/kugou.rs @@ -0,0 +1,179 @@ +// 酷狗音乐(内置音源) +// 搜索:mobilecdn.kugou.com/api/v3/search/song +// URL:trackercdn.kugou.com/i/v2/ (key = md5(hash + "kgcloudv2")) + +use async_trait::async_trait; +use md5::compute as md5_compute; +use serde_json::Value; + +use super::{ExternalSong, MusicSource, build_client, truncate_chars}; + +const UA: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"; + +pub struct KugouSource { + label: String, +} + +impl KugouSource { + pub fn new(label: String) -> Self { + Self { label } + } +} + +fn client() -> Result { + build_client(UA, 8) +} + +/// 酷狗 V2 接口 key 算法:md5(hash + "kgcloudv2") +fn make_key(hash: &str) -> String { + let data = format!("{}kgcloudv2", hash); + let digest = md5_compute(data.as_bytes()); + format!("{:x}", digest) +} + +#[async_trait] +impl MusicSource for KugouSource { + fn id(&self) -> &str { + "kugou" + } + fn label(&self) -> &str { + &self.label + } + + async fn search(&self, keyword: &str, _page: u32, limit: u32) -> Result, String> { + let client = client()?; + let limit_str = limit.to_string(); + let resp = client + .get("http://mobilecdn.kugou.com/api/v3/search/song") + .query(&[ + ("keyword", keyword), + ("page", "1"), + ("pagesize", &limit_str), + ("showtype", "20"), + ]) + .send() + .await + .map_err(|e| format!("kugou 搜索请求失败: {}", e))?; + let text = resp + .text() + .await + .map_err(|e| format!("kugou 读取响应失败: {}", e))?; + let json: Value = + serde_json::from_str(&text).map_err(|e| format!("kugou 解析JSON失败: {}", e))?; + + let info = json["data"]["info"] + .as_array() + .ok_or_else(|| format!("kugou 搜索无结果: {}", truncate_chars(&text, 200)))?; + + let list: Vec = info + .iter() + .map(|s| { + let hash = s["hash"].as_str().unwrap_or("").to_string(); + // V3 接口字段是 songname/album_name/singername(带下划线),老接口是 songname/albumname/singername + let name = s["songname"] + .as_str() + .or_else(|| s["filename"].as_str()) + .unwrap_or("") + .to_string(); + let artist = s["singername"] + .as_str() + .or_else(|| s["singer_name"].as_str()) + .unwrap_or("") + .to_string(); + let album = s["albumname"] + .as_str() + .or_else(|| s["album_name"].as_str()) + .unwrap_or("") + .to_string(); + let duration = s["duration"].as_u64().map(|d| d * 1000).unwrap_or(0); + // kugou V3 搜索接口不返回封面字段,需通过 album_id 单独调接口获取 + // 这里先留空,前端会 fallback 到默认封面图标 + let pic = s["albumpic"] + .as_str() + .or_else(|| s["album_img"].as_str()) + .or_else(|| s["img"].as_str()) + .or_else(|| s["pic"].as_str()) + .unwrap_or("") + .to_string(); + ExternalSong { + id: hash, + source: "kugou".into(), + source_label: self.label.clone(), + name, + artist, + album, + duration, + pic_url: pic, + } + }) + .collect(); + + if list.is_empty() { + return Err("kugou 搜索无结果".into()); + } + Ok(list) + } + + async fn get_url(&self, song_id: &str, _quality: &str) -> Result { + let client = client()?; + let hash = song_id; + let key = make_key(hash); + let filename = format!("{}.mp3", hash); + let resp = client + .get("http://trackercdn.kugou.com/i/v2/") + .query(&[ + ("key", key.as_str()), + ("hash", hash), + ("br", "hq"), + ("appid", "1005"), + ("pid", "2"), + ("behavior", "play"), + ("cmd", "25"), + ("filename", &filename), + ]) + .send() + .await + .map_err(|e| format!("kugou 获取URL请求失败: {}", e))?; + let text = resp + .text() + .await + .map_err(|e| format!("kugou 读取URL响应失败: {}", e))?; + let json: Value = + serde_json::from_str(&text).map_err(|e| format!("kugou 解析URL JSON失败: {} | body: {}", e, truncate_chars(&text, 300)))?; + // 响应中 url 是数组(新接口)或字符串(旧接口) + let url = json["url"] + .as_array() + .and_then(|arr| arr.first()) + .and_then(|v| v.as_str()) + .or_else(|| json["url"].as_str()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| format!("kugou URL 为空: status={} | body: {}", json["status"].as_u64().unwrap_or(0), truncate_chars(&text, 300)))?; + Ok(url.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 真实接口测试,需手动运行: cargo test -- --ignored --nocapture + #[tokio::test] + #[ignore] + async fn test_kugou_search_and_url() { + // 测试:搜索「唯一 邓紫棋」 + let src = KugouSource::new("酷狗音乐".into()); + let kw = "唯一 邓紫棋"; + let songs = src.search(kw, 1, 10).await.expect("搜索应成功"); + println!("kugou 搜索「{}」到 {} 首", kw, songs.len()); + for s in &songs { + println!(" - {} - {} (id={})", s.name, s.artist, s.id); + } + // 尝试获取前 5 首的 URL + for s in songs.iter().take(5) { + match src.get_url(&s.id, "standard").await { + Ok(url) => println!(" ✅ {} -> {}", s.name, url), + Err(e) => println!(" ❌ {} -> {}", s.name, e), + } + } + } +} diff --git a/src-tauri/src/music_sources/kuwo.rs b/src-tauri/src/music_sources/kuwo.rs new file mode 100644 index 0000000..388b3e5 --- /dev/null +++ b/src-tauri/src/music_sources/kuwo.rs @@ -0,0 +1,141 @@ +//! 酷我音乐音源 +//! 搜索: search.kuwo.cn/r.s(返回单引号 JSON,需预处理) +//! URL: mobi.kuwo.cn/mobi.s + kwDES 加密(能绕过 VIP 限制) +//! 移植自 UnblockNeteaseMusic/server 的 kuwo.js + +use crate::music_sources::kwdes; +use crate::music_sources::{ExternalSong, MusicSource, build_client, parse_kuwo_abslist, parse_kuwo_json, truncate_chars}; +use serde_json::Value; + +const UA: &str = "okhttp/3.10.0"; + +pub struct KuwoSource { + label: String, +} + +impl KuwoSource { + pub fn new(label: String) -> Self { + Self { label } + } +} + +#[async_trait::async_trait] +impl MusicSource for KuwoSource { + fn id(&self) -> &str { + "kuwo" + } + + fn label(&self) -> &str { + &self.label + } + + async fn search(&self, keyword: &str, _page: u32, limit: u32) -> Result, String> { + let url = format!( + "http://search.kuwo.cn/r.s?correct=1&vipver=1&stype=comprehensive&encoding=utf8&rformat=json&mobi=1&show_copyright_off=1&searchapi=6&all={}", + urlencoding::encode(keyword) + ); + + let client = build_client(UA, 10)?; + let resp = client + .get(&url) + .header("X-Forwarded-For", "1.0.1.114") + .send() + .await + .map_err(|e| format!("kuwo 搜索请求失败: {}", e))?; + + let text = resp + .text() + .await + .map_err(|e| format!("kuwo 搜索读取失败: {}", e))?; + + let json: Value = parse_kuwo_json(&text, "kuwo")?; + + let abs_list = json["content"][1]["musicpage"]["abslist"] + .as_array() + .ok_or_else(|| format!("kuwo 搜索结果格式异常, body(前300): {}", truncate_chars(&text, 300)))?; + + Ok(parse_kuwo_abslist(&abs_list[..abs_list.len().min(limit as usize)], "kuwo", &self.label)) + } + + async fn get_url(&self, song_id: &str, _quality: &str) -> Result { + // 用 kwDES 加密的 mobi.s 接口获取播放 URL(能绕过 VIP 限制) + let query = format!( + "corp=kuwo&source=kwplayer_ar_5.1.0.0_B_jiakong_vh.apk&p2p=1&type=convert_url2&sig=0&format=mp3&rid={}", + song_id + ); + let encrypted = kwdes::encrypt_query(&query); + let url = format!("http://mobi.kuwo.cn/mobi.s?f=kuwo&q={}", encrypted); + + let client = build_client(UA, 10)?; + let resp = client + .get(&url) + .send() + .await + .map_err(|e| format!("kuwo mobi.s 请求失败: {}", e))?; + + let text = resp + .text() + .await + .map_err(|e| format!("kuwo mobi.s 读取失败: {}", e))?; + + // 解析 key=value 格式响应(每行一个),提取 url 和 bitrate + let mut url_val = String::new(); + let mut bitrate: u64 = 0; + for line in text.lines() { + let line = line.trim(); + if let Some(pos) = line.find('=') { + let key = line[..pos].trim(); + let val = line[pos + 1..].trim(); + match key { + "url" => url_val = val.to_string(), + "bitrate" => bitrate = val.parse::().unwrap_or(0), + _ => {} + } + } + } + + // 广告音频检测:bitrate < 32 通常是"酷我音乐以为你免费开放播放权限"提示音 + // 正常歌曲 bitrate >= 128 + if bitrate > 0 && bitrate < 32 { + return Err(format!( + "kuwo 返回广告音频 (bitrate={}), song_id={}", + bitrate, song_id + )); + } + + if url_val.starts_with("http") { + Ok(url_val) + } else { + Err(format!( + "kuwo mobi.s 未返回有效 URL: {}", + truncate_chars(&text, 200) + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 真实接口测试:搜索 + 获取 VIP 歌曲 URL + /// 手动运行: cargo test -- --ignored --nocapture + #[tokio::test] + #[ignore] + async fn test_kuwo_search_and_url() { + let src = KuwoSource::new("酷我音乐".into()); + let kw = "唯一 邓紫棋"; + let songs = src.search(kw, 1, 10).await.expect("搜索应成功"); + println!("kuwo 搜索「{}」到 {} 首", kw, songs.len()); + for s in &songs { + println!(" - {} - {} (id={})", s.name, s.artist, s.id); + } + // 尝试获取前 5 首的 URL + for s in songs.iter().take(5) { + match src.get_url(&s.id, "standard").await { + Ok(url) => println!(" ✅ {} -> {}", s.name, url), + Err(e) => println!(" ❌ {} -> {}", s.name, e), + } + } + } +} diff --git a/src-tauri/src/music_sources/kwdes.rs b/src-tauri/src/music_sources/kwdes.rs new file mode 100644 index 0000000..b1e73ad --- /dev/null +++ b/src-tauri/src/music_sources/kwdes.rs @@ -0,0 +1,300 @@ +//! kwDES 加密算法 —— 酷我音乐 mobi.s 接口的 query 加密 +//! 移植自 UnblockNeteaseMusic/server 的 kwDES.js +//! 参考: https://github.com/UnblockNeteaseMusic/server/blob/enhanced/src/kwDES.js + +// EXPANSION (E 扩展置换, 64个元素, 32->48位) +const ARRAY_E: [i32; 64] = [ + 31, 0, 1, 2, 3, 4, -1, -1, 3, 4, 5, 6, 7, 8, -1, -1, + 7, 8, 9, 10, 11, 12, -1, -1, 11, 12, 13, 14, 15, 16, -1, -1, + 15, 16, 17, 18, 19, 20, -1, -1, 19, 20, 21, 22, 23, 24, -1, -1, + 23, 24, 25, 26, 27, 28, -1, -1, 27, 28, 29, 30, 31, 30, -1, -1, +]; + +// INITIAL_PERMUTATION (IP 初始置换, 64个元素) +const ARRAY_IP: [i32; 64] = [ + 57, 49, 41, 33, 25, 17, 9, 1, + 59, 51, 43, 35, 27, 19, 11, 3, + 61, 53, 45, 37, 29, 21, 13, 5, + 63, 55, 47, 39, 31, 23, 15, 7, + 56, 48, 40, 32, 24, 16, 8, 0, + 58, 50, 42, 34, 26, 18, 10, 2, + 60, 52, 44, 36, 28, 20, 12, 4, + 62, 54, 46, 38, 30, 22, 14, 6, +]; + +// INVERSE_PERMUTATION (IP-1 逆置换, 64个元素) +const ARRAY_IP_1: [i32; 64] = [ + 39, 7, 47, 15, 55, 23, 63, 31, + 38, 6, 46, 14, 54, 22, 62, 30, + 37, 5, 45, 13, 53, 21, 61, 29, + 36, 4, 44, 12, 52, 20, 60, 28, + 35, 3, 43, 11, 51, 19, 59, 27, + 34, 2, 42, 10, 50, 18, 58, 26, + 33, 1, 41, 9, 49, 17, 57, 25, + 32, 0, 40, 8, 48, 16, 56, 24, +]; + +// ROTATES (循环移位表, 16个元素) +const ARRAY_LS: [u32; 16] = [ + 1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1, +]; + +// 移位掩码 +const ARRAY_LS_MASK: [u64; 3] = [0, 0x100001, 0x300003]; + +// PERMUTATION (P 置换, 32个元素) +const ARRAY_P: [i32; 32] = [ + 15, 6, 19, 20, 28, 11, 27, 16, + 0, 14, 22, 25, 4, 17, 30, 9, + 1, 7, 23, 13, 31, 26, 2, 8, + 18, 12, 29, 5, 21, 10, 3, 24, +]; + +// PERMUTED_CHOICE1 (PC-1, 56个元素) +const ARRAY_PC_1: [i32; 56] = [ + 56, 48, 40, 32, 24, 16, 8, + 0, 57, 49, 41, 33, 25, 17, + 9, 1, 58, 50, 42, 34, 26, + 18, 10, 2, 59, 51, 43, 35, + 62, 54, 46, 38, 30, 22, 14, + 6, 61, 53, 45, 37, 29, 21, + 13, 5, 60, 52, 44, 36, 28, + 20, 12, 4, 27, 19, 11, 3, +]; + +// PERMUTED_CHOICE2 (PC-2, 64个元素, -1表示跳过) +const ARRAY_PC_2: [i32; 64] = [ + 13, 16, 10, 23, 0, 4, -1, -1, + 2, 27, 14, 5, 20, 9, -1, -1, + 22, 18, 11, 3, 25, 7, -1, -1, + 15, 6, 26, 19, 12, 1, -1, -1, + 40, 51, 30, 36, 46, 54, -1, -1, + 29, 39, 50, 44, 32, 47, -1, -1, + 43, 48, 38, 55, 33, 52, -1, -1, + 45, 41, 49, 35, 28, 31, -1, -1, +]; + +// S-Box (8个子表, 每个64个元素) +const MATRIX_NS_BOX: [[u8; 64]; 8] = [ + [ + 14, 4, 3, 15, 2, 13, 5, 3, 13, 14, 6, 9, 11, 2, 0, 5, 4, 1, 10, 12, 15, + 6, 9, 10, 1, 8, 12, 7, 8, 11, 7, 0, 0, 15, 10, 5, 14, 4, 9, 10, 7, 8, + 12, 3, 13, 1, 3, 6, 15, 12, 6, 11, 2, 9, 5, 0, 4, 2, 11, 14, 1, 7, 8, + 13, + ], + [ + 15, 0, 9, 5, 6, 10, 12, 9, 8, 7, 2, 12, 3, 13, 5, 2, 1, 14, 7, 8, 11, 4, + 0, 3, 14, 11, 13, 6, 4, 1, 10, 15, 3, 13, 12, 11, 15, 3, 6, 0, 4, 10, 1, + 7, 8, 4, 11, 14, 13, 8, 0, 6, 2, 15, 9, 5, 7, 1, 10, 12, 14, 2, 5, 9, + ], + [ + 10, 13, 1, 11, 6, 8, 11, 5, 9, 4, 12, 2, 15, 3, 2, 14, 0, 6, 13, 1, 3, + 15, 4, 10, 14, 9, 7, 12, 5, 0, 8, 7, 13, 1, 2, 4, 3, 6, 12, 11, 0, 13, + 5, 14, 6, 8, 15, 2, 7, 10, 8, 15, 4, 9, 11, 5, 9, 0, 14, 3, 10, 7, 1, + 12, + ], + [ + 7, 10, 1, 15, 0, 12, 11, 5, 14, 9, 8, 3, 9, 7, 4, 8, 13, 6, 2, 1, 6, 11, + 12, 2, 3, 0, 5, 14, 10, 13, 15, 4, 13, 3, 4, 9, 6, 10, 1, 12, 11, 0, 2, + 5, 0, 13, 14, 2, 8, 15, 7, 4, 15, 1, 10, 7, 5, 6, 12, 11, 3, 8, 9, 14, + ], + [ + 2, 4, 8, 15, 7, 10, 13, 6, 4, 1, 3, 12, 11, 7, 14, 0, 12, 2, 5, 9, 10, + 13, 0, 3, 1, 11, 15, 5, 6, 8, 9, 14, 14, 11, 5, 6, 4, 1, 3, 10, 2, 12, + 15, 0, 13, 2, 8, 5, 11, 8, 0, 15, 7, 14, 9, 4, 12, 7, 10, 9, 1, 13, 6, + 3, + ], + [ + 12, 9, 0, 7, 9, 2, 14, 1, 10, 15, 3, 4, 6, 12, 5, 11, 1, 14, 13, 0, 2, + 8, 7, 13, 15, 5, 4, 10, 8, 3, 11, 6, 10, 4, 6, 11, 7, 9, 0, 6, 4, 2, 13, + 1, 9, 15, 3, 8, 15, 3, 1, 14, 12, 5, 11, 0, 2, 12, 14, 7, 5, 10, 8, 13, + ], + [ + 4, 1, 3, 10, 15, 12, 5, 0, 2, 11, 9, 6, 8, 7, 6, 9, 11, 4, 12, 15, 0, 3, + 10, 5, 14, 13, 7, 8, 13, 14, 1, 2, 13, 6, 14, 9, 4, 1, 2, 14, 11, 13, 5, + 0, 1, 10, 8, 3, 0, 11, 3, 5, 9, 4, 15, 2, 7, 8, 12, 15, 10, 7, 6, 12, + ], + [ + 13, 7, 10, 0, 6, 9, 5, 15, 8, 4, 3, 10, 11, 14, 12, 5, 2, 11, 9, 6, 15, + 12, 0, 3, 4, 1, 14, 13, 1, 2, 7, 8, 1, 2, 12, 15, 10, 4, 0, 3, 13, 14, + 6, 9, 7, 8, 9, 6, 15, 1, 5, 12, 3, 10, 14, 5, 8, 7, 11, 0, 4, 13, 2, 11, + ], +]; + +/// 按位置换:从 l 中按 arr 的索引取位,放到结果的对应位置 +fn bit_transform(arr: &[i32], l: u64) -> u64 { + let mut result: u64 = 0; + for (i, &idx) in arr.iter().enumerate() { + if idx < 0 { + continue; + } + if l & (1u64 << idx as u64) != 0 { + result |= 1u64 << i as u64; + } + } + result +} + +/// 生成 16 个子密钥 +fn sub_keys(l: u64, mode: i32) -> [u64; 16] { + let mut l2 = bit_transform(&ARRAY_PC_1, l); + let mut longs = [0u64; 16]; + + for i in 0..16 { + let ls = ARRAY_LS[i] as u32; + let mask = ARRAY_LS_MASK[ARRAY_LS[i] as usize]; + + // (l2 & mask) << (28 - ls) | (l2 & !mask) >> ls + let part1 = (l2 & mask).wrapping_shl(28 - ls); + let part2 = (l2 & !mask) >> ls; + l2 = part1 | part2; + + longs[i] = bit_transform(&ARRAY_PC_2, l2); + } + + // 解密模式:反转子密钥顺序 + if mode == 1 { + for j in 0..8 { + longs.swap(j, 15 - j); + } + } + + longs +} + +/// DES 加密一个 64 位块 +fn des64(subkeys: &[u64; 16], l: u64) -> u64 { + let mut out = bit_transform(&ARRAY_IP, l); + let mut p0 = out & 0xffffffff; // 低 32 位 (pSource[0]) + let mut p1 = out >> 32; // 高 32 位 (pSource[1]) + + for i in 0..16 { + let mut r = p1; + r = bit_transform(&ARRAY_E, r); + r ^= subkeys[i]; + + // 取 8 个 8 位组 + let pr: [u64; 8] = [ + (r >> 0) & 255, + (r >> 8) & 255, + (r >> 16) & 255, + (r >> 24) & 255, + (r >> 32) & 255, + (r >> 40) & 255, + (r >> 48) & 255, + (r >> 56) & 255, + ]; + + // S-Box 替换(反向遍历,每次左移 4 位) + let mut s_out: u64 = 0; + for sbi in (0..8).rev() { + s_out = (s_out << 4) | MATRIX_NS_BOX[sbi][pr[sbi] as usize] as u64; + } + + r = bit_transform(&ARRAY_P, s_out); + let temp = p0; + p0 = p1; + p1 = temp ^ r; + } + + // pSource.reverse() 后: pSource[0] = p1, pSource[1] = p0 + // out = (pSource[1] << 32) | pSource[0] = (p0 << 32) | p1 + out = (p0 << 32) | (p1 & 0xffffffff); + out = bit_transform(&ARRAY_IP_1, out); + out +} + +/// 加密/解密消息 +fn crypt(msg: &[u8], key: &[u8], mode: i32) -> Vec { + // 处理密钥块:将 8 字节密钥转为 u64 + let mut l: u64 = 0; + for i in 0..8 { + l |= (key[i] as u64) << (i * 8); + } + + let subkeys = sub_keys(l, mode); + + let j = msg.len() / 8; + let mut result = Vec::new(); + + // 处理完整块 + for m in 0..j { + let mut block: u64 = 0; + for n in 0..8 { + block |= (msg[n + m * 8] as u64) << (n * 8); + } + let encrypted = des64(&subkeys, block); + for n in 0..8 { + result.push((encrypted >> (n * 8)) as u8); + } + } + + // 处理剩余字节(加密模式追加完整块) + let remainder = msg.len() % 8; + if remainder > 0 || mode == 0 { + let mut block: u64 = 0; + let start = j * 8; + for i in 0..remainder { + block |= (msg[start + i] as u64) << (i * 8); + } + let encrypted = des64(&subkeys, block); + for n in 0..8 { + result.push((encrypted >> (n * 8)) as u8); + } + } + + result +} + +const SECRET_KEY: &[u8] = b"ylzsxkwm"; + +/// 加密 query 字符串并返回 base64 编码 +pub fn encrypt_query(query: &str) -> String { + use base64::Engine; + let encrypted = crypt(query.as_bytes(), SECRET_KEY, 0); + base64::engine::general_purpose::STANDARD.encode(&encrypted) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 验证 kwDES 加密能正常执行且输出 base64 + #[test] + fn test_encrypt_query_basic() { + let query = "corp=kuwo&source=kwplayer_ar_5.1.0.0_B_jiakong_vh.apk&p2p=1&type=convert_url2&sig=0&format=mp3&rid=321260769"; + let result = encrypt_query(query); + assert!(!result.is_empty()); + // base64 字符串只包含合法字符 + assert!(result.chars().all(|c| c.is_alphanumeric() || c == '+' || c == '/' || c == '=')); + println!("kwDES encrypt_query result: {}", result); + } + + /// 真实接口测试:用 kwDES 加密的 query 请求 kuwo mobi.s 获取 VIP 歌曲 URL + /// 手动运行: cargo test -- --ignored --nocapture + #[tokio::test] + #[ignore] + async fn test_kuwo_mobi_with_kwdes() { + let rid = "321260769"; // 邓紫棋「唯一」VIP 歌曲 + let query = format!( + "corp=kuwo&source=kwplayer_ar_5.1.0.0_B_jiakong_vh.apk&p2p=1&type=convert_url2&sig=0&format=mp3&rid={}", + rid + ); + let encrypted = encrypt_query(&query); + let url = format!("http://mobi.kuwo.cn/mobi.s?f=kuwo&q={}", encrypted); + + let client = reqwest::Client::new(); + let resp = client + .get(&url) + .header("user-agent", "okhttp/3.10.0") + .send() + .await + .expect("请求应成功"); + + let text = resp.text().await.expect("读取响应应成功"); + println!("kuwo mobi.s 响应 (前 500 字符): {}", &text[..text.len().min(500)]); + + // 响应中应包含 http URL + assert!(text.contains("http"), "响应应包含 URL: {}", text); + } +} diff --git a/src-tauri/src/music_sources/mod.rs b/src-tauri/src/music_sources/mod.rs new file mode 100644 index 0000000..92878bf --- /dev/null +++ b/src-tauri/src/music_sources/mod.rs @@ -0,0 +1,480 @@ +// 音源模块:可扩展架构(内置 + 自定义 HTTP API) +// +// 设计要点: +// - MusicSource trait 定义统一接口(search + get_url) +// - 内置源:bodian(波点,首选)/ kugou(酷狗,次选) +// - 自定义源:通过 SourceConfig 配置 HTTP API 模板,由 CustomHttpSource 实现 +// - 调用方传入 Vec,模块根据 kind 路由到对应实现 +// - 多源聚合搜索:并发请求所有启用源,按配置顺序合并结果 + +pub mod bodian; +pub mod custom; +pub mod kugou; +pub mod kwdes; +pub mod kuwo; + +use async_trait::async_trait; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// 音源配置(前端持久化、传给后端都用这个结构) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceConfig { + /// 唯一 ID + /// - builtin: 'bodian' / 'kugou' / 'kuwo' / 'bilibili' + /// - custom: 'custom-xxxx'(前端生成) + pub id: String, + /// 显示名称 + pub label: String, + /// 是否启用 + pub enabled: bool, + /// 类型: "builtin" | "custom" + pub kind: String, + + // ===== 自定义 HTTP API 源字段(kind == "custom" 时使用)===== + /// 搜索 URL 模板,支持 {keyword} / {page} / {limit} 占位符 + #[serde(default)] + pub search_url: Option, + /// 获取播放 URL 接口,支持 {id} / {quality} 占位符 + #[serde(default)] + pub url_api: Option, + /// JSON 路径:搜索结果数组(如 "data.songs") + #[serde(default)] + pub search_path: Option, + /// JSON 路径:URL 字符串字段(如 "data.url") + #[serde(default)] + pub url_path: Option, + /// 字段映射(自定义源 JSON 字段名) + #[serde(default)] + pub id_field: Option, + #[serde(default)] + pub name_field: Option, + #[serde(default)] + pub artist_field: Option, + #[serde(default)] + pub album_field: Option, + #[serde(default)] + pub duration_field: Option, + #[serde(default)] + pub pic_field: Option, +} + +impl SourceConfig { + /// 默认内置音源列表(顺序即默认优先级,波点为首选,参考 SPlayer) + /// kuwo 使用 kwDES + mobi.s 接口,能绕过 VIP 限制播放完整歌曲 + #[allow(dead_code)] + pub fn builtin_defaults() -> Vec { + vec![ + SourceConfig::builtin("bodian", "波点音乐", true), + SourceConfig::builtin("kugou", "酷狗音乐", true), + SourceConfig::builtin("kuwo", "酷我音乐", true), + ] + } + + #[allow(dead_code)] + fn builtin(id: &str, label: &str, enabled: bool) -> SourceConfig { + SourceConfig { + id: id.into(), + label: label.into(), + enabled, + kind: "builtin".into(), + search_url: None, + url_api: None, + search_path: None, + url_path: None, + id_field: None, + name_field: None, + artist_field: None, + album_field: None, + duration_field: None, + pic_field: None, + } + } +} + +/// 外部歌曲(搜索结果) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExternalSong { + /// 源内 ID(字符串,便于通用) + pub id: String, + /// 来源 source id("bodian" / "kugou" / 自定义源 id) + pub source: String, + /// 来源显示名("波点音乐" / ...) + pub source_label: String, + pub name: String, + pub artist: String, + pub album: String, + /// 时长(毫秒) + pub duration: u64, + pub pic_url: String, +} + +/// 音源统一接口 +#[async_trait] +pub trait MusicSource: Send + Sync { + fn id(&self) -> &str; + fn label(&self) -> &str; + /// 搜索 + async fn search(&self, keyword: &str, page: u32, limit: u32) -> Result, String>; + /// 获取播放 URL + async fn get_url(&self, song_id: &str, quality: &str) -> Result; +} + +/// 构建 HTTP 客户端(统一 UA / 超时,复用连接池) +pub(crate) fn build_client(ua: &str, timeout_secs: u64) -> Result { + Client::builder() + .user_agent(ua) + .timeout(Duration::from_secs(timeout_secs)) + .build() + .map_err(|e| format!("构建 HTTP 客户端失败: {}", e)) +} + +/// 安全截断字符串到指定字符数(避免 UTF-8 多字节边界 panic) +pub(crate) fn truncate_chars(s: &str, n: usize) -> String { + s.chars().take(n).collect() +} + +/// 解析 kuwo r.s 接口返回的 JSON(bodian 与 kuwo 共用) +/// +/// 该接口返回格式不稳定: +/// - 有时返回标准双引号 JSON +/// - 有时返回单引号 JSON(key/value 用 ' 包裹) +/// +/// 先尝试直接解析标准 JSON;失败再用 `fix_single_quote_json` 修复单引号格式。 +/// 不能无脑 fix,因为 `fix_single_quote_json` 遇到标准 JSON 值内部的单引号 +/// (如歌名 "Don't Stop")会误判为定界符,破坏正确的 JSON。 +pub(crate) fn parse_kuwo_json(text: &str, source_tag: &str) -> Result { + match serde_json::from_str(text) { + Ok(j) => Ok(j), + Err(_) => { + let fixed = fix_single_quote_json(text); + serde_json::from_str(&fixed).map_err(|e| { + format!( + "{} 搜索JSON解析失败: {} | body(前200): {}", + source_tag, + e, + truncate_chars(text, 200) + ) + }) + } + } +} + +/// 从 kuwo r.s 接口的 abslist 数组构造 ExternalSong 列表(bodian 与 kuwo 共用) +pub(crate) fn parse_kuwo_abslist( + abslist: &[serde_json::Value], + source_id: &str, + source_label: &str, +) -> Vec { + abslist + .iter() + .map(|item| { + let musicrid = item["MUSICRID"].as_str().unwrap_or(""); + let id = musicrid + .strip_prefix("MUSIC_") + .or_else(|| musicrid.strip_prefix("music_")) + .unwrap_or(musicrid) + .to_string(); + let name = item["SONGNAME"].as_str().unwrap_or("").to_string(); + let artist = item["ARTIST"].as_str().unwrap_or("").to_string(); + let album = item["ALBUM"].as_str().unwrap_or("").to_string(); + // DURATION 兼容字符串/数字两种格式 + let duration = item["DURATION"] + .as_str() + .and_then(|d| d.parse::().ok()) + .or_else(|| item["DURATION"].as_u64()) + .map(|d| d * 1000) + .unwrap_or(0); + // web_albumpic_short 是相对路径(如 "120/s4s61/37/4021488843.jpg") + // 需拼接 kuwo 图片 CDN 前缀;albumpic 字段有时是完整 URL + let pic_raw = item["web_albumpic_short"] + .as_str() + .or_else(|| item["albumpic"].as_str()) + .or_else(|| item["pic"].as_str()) + .unwrap_or(""); + let pic_url = if pic_raw.is_empty() { + String::new() + } else if pic_raw.starts_with("http") { + pic_raw.to_string() + } else { + format!("https://img1.kuwo.cn/star/albumcover/{}", pic_raw) + }; + ExternalSong { + id, + source: source_id.to_string(), + source_label: source_label.to_string(), + name, + artist, + album, + duration, + pic_url, + } + }) + .collect() +} + +/// 修复 kuwo r.s 接口返回的单引号 JSON +/// kuwo 用 `'` 代替 `"` 作为字符串定界符,但值内部可能也含单引号 +/// 用状态机遍历:只在「作为定界符的位置」把 `'` 替换成 `"` +/// +/// 规则:遇到 `'` 时判断它是否为定界符: +/// - 如果当前在字符串内部(已遇到开定界符 `'`),则看下一个非空白字符 +/// 是 `:` `}` `,` `]` 之一 → 这是闭定界符;否则是值内部的字符,保留 +/// - 如果不在字符串内部,遇到 `'` 一定是开定界符 +fn fix_single_quote_json(text: &str) -> String { + let chars: Vec = text.chars().collect(); + let n = chars.len(); + let mut out = String::with_capacity(n); + let mut in_str = false; + let mut i = 0; + + while i < n { + let c = chars[i]; + if c == '\'' { + if !in_str { + out.push('"'); + in_str = true; + } else { + let mut j = i + 1; + while j < n && (chars[j] == ' ' || chars[j] == '\t' || chars[j] == '\n' || chars[j] == '\r') { + j += 1; + } + let next = if j < n { Some(chars[j]) } else { None }; + match next { + Some(':') | Some('}') | Some(',') | Some(']') | None => { + out.push('"'); + in_str = false; + } + _ => { + out.push('\''); + } + } + } + } else if c == '"' && in_str { + out.push('\\'); + out.push('"'); + } else { + out.push(c); + } + i += 1; + } + + out +} + +/// 根据 SourceConfig 构造对应的音源实例 +pub fn get_source(cfg: &SourceConfig) -> Option> { + match cfg.kind.as_str() { + "builtin" => match cfg.id.as_str() { + "bodian" => Some(Box::new(bodian::BodianSource::new(cfg.label.clone()))), + "kugou" => Some(Box::new(kugou::KugouSource::new(cfg.label.clone()))), + "kuwo" => Some(Box::new(kuwo::KuwoSource::new(cfg.label.clone()))), + _ => None, + }, + "custom" => Some(Box::new(custom::CustomHttpSource::new(cfg.clone()))), + _ => None, + } +} + +/// 多源聚合搜索:并发请求所有启用的源,按配置顺序合并结果 +pub async fn search_multi( + keyword: &str, + sources: &[SourceConfig], + limit: u32, +) -> Vec { + use futures_util::future::join_all; + + let tasks: Vec<_> = sources + .iter() + .filter(|s| s.enabled) + .filter_map(|cfg| { + let src = get_source(cfg)?; + let kw = keyword.to_string(); + Some(async move { + let label = src.label().to_string(); + let sid = src.id().to_string(); + let _ = label; + match src.search(&kw, 1, limit).await { + Ok(list) => list, + Err(e) => { + eprintln!("[music_sources] {} 搜索失败: {}", sid, e); + Vec::new() + } + } + }) + }) + .collect(); + + let results = join_all(tasks).await; + let mut all = Vec::new(); + for r in results { + all.extend(r); + } + all +} + +/// 网易云 fallback:按 sources 顺序尝试 (search + get_url) +/// 用于 get_song_url 在网易云无 URL 时降级 +/// 返回 (url, source_label) +/// +/// 匹配策略: +/// 1. 用「歌名 歌手」在源里搜索 +/// 2. 只选原版(排除 Live/DJ/翻唱/伴奏/remix 等非原版) +/// 3. 原版 get_url 失败(VIP/广告/付费)则换下一个源继续尝试 +/// 4. 所有源都失败就报错,不塞非原版替代品 +pub async fn fallback_get_url( + song_name: &str, + artist: &str, + quality: &str, + sources: &[SourceConfig], +) -> Result<(String, String), String> { + let target_lower = song_name.to_lowercase(); + // 歌手列表:按 "/" 分割(兼容前端 " / " 和 api.rs 的 "/" 格式) + let artists: Vec = if artist.is_empty() { + Vec::new() + } else { + artist + .split('/') + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .collect() + }; + // 搜索关键词只用第一个歌手(多歌手全拼接会搜不到结果) + let first_artist = artists.first().cloned().unwrap_or_default(); + + // 收集每个源的失败原因,便于排障 + let mut details: Vec = Vec::new(); + + for cfg in sources.iter().filter(|s| s.enabled) { + let src = match get_source(cfg) { + Some(s) => s, + None => continue, + }; + let kw = if first_artist.is_empty() { + song_name.to_string() + } else { + format!("{} {}", song_name, first_artist) + }; + let results = match src.search(&kw, 1, 20).await { + Ok(r) => r, + Err(e) => { + details.push(format!("{}搜索失败:{}", src.id(), e)); + continue; + } + }; + + // 版本标签提取:从歌名中识别版本关键词 + // 用于「版本一致性匹配」——目标是原版就只匹配原版,目标是DJ版就只匹配DJ版 + // 避免 VIP 原版歌曲误匹配到同名 DJ 版,也避免 VIP DJ 版误匹配到同名原版 + let extract_version = |name: &str| -> Vec<&'static str> { + let n = name.to_lowercase(); + let mut tags = Vec::new(); + // 括号通常表示版本标注(如 "Rise (Redux)" "RISE (Live)") + // 目标是原版(无括号)时,带括号的搜索结果视为不同版本 + if n.contains('(') || n.contains('(') { + tags.push("paren"); + } + if n.contains("live") || n.contains("现场") || n.contains("演唱会") { + tags.push("live"); + } + if n.contains("remix") || n.contains("混音") { + tags.push("remix"); + } + if n.contains("dj") || n.contains("电音") { + tags.push("dj"); + } + if n.contains("伴奏") || n.contains("karaoke") || n.contains("inst") { + tags.push("accompaniment"); + } + if n.contains("翻唱") { + tags.push("cover"); + } + tags + }; + // 版本一致性:目标歌名和搜索结果歌名的版本标签必须相同 + // - 目标是原版(无标签)→ 搜索结果也必须是原版(无标签) + // - 目标是 DJ 版 → 搜索结果也必须是 DJ 版 + let version_match = |result_name: &str| -> bool { + extract_version(&target_lower) == extract_version(result_name) + }; + + // 歌手匹配:任一网易云歌手名与外部源歌手双向包含匹配 + // 双向是因为网易云可能用中文名"邓紫棋",外部源用英文名"G.E.M." + // 去掉歌手字段中的标点(如 "G.E.M." → "gem"),避免点号/空格影响匹配 + let normalize_artist = |a: &str| -> String { + a.to_lowercase() + .chars() + .filter(|c| c.is_alphanumeric() || *c == '&' || *c == '、') + .collect() + }; + let artist_match = |a: &str| -> bool { + if artists.is_empty() { + return false; + } + let a_norm = normalize_artist(a); + if a_norm.is_empty() { + return false; + } + artists.iter().any(|art| { + let art_norm = normalize_artist(art); + !art_norm.is_empty() && (a_norm.contains(&art_norm) || art_norm.contains(&a_norm)) + }) + }; + + // 匹配优先级(逐级放宽,但每级都要求版本一致): + // 1. 歌名完全匹配 + 版本一致 + 歌手匹配(最严格,优先选同名同版本同歌手) + // 2. 歌名包含目标歌名 + 版本一致 + 歌手匹配(处理歌名带后缀,必须歌手匹配防误匹配) + // 3. 歌名完全匹配 + 版本一致(最后兜底,不检查歌手——宁可播放同名同版本也不返回失败) + let best = results.iter().find(|s| { + s.name.to_lowercase() == target_lower && version_match(&s.name) && artist_match(&s.artist) + }) + .or_else(|| results.iter().find(|s| { + let n = s.name.to_lowercase(); + n.contains(&target_lower) && version_match(&s.name) && artist_match(&s.artist) + })) + .or_else(|| results.iter().find(|s| { + s.name.to_lowercase() == target_lower && version_match(&s.name) + })); + + if let Some(song) = best { + eprintln!("[music_sources] fallback 匹配: [{}] {} - {} (id={})", src.id(), song.name, song.artist, song.id); + match src.get_url(&song.id, quality).await { + Ok(url) if !url.is_empty() => { + return Ok((url, src.label().to_string())); + } + Ok(_) => { + details.push(format!("{}返回空URL", src.id())); + } + Err(e) => { + details.push(format!("{}取URL失败:{}", src.id(), e)); + } + } + } else { + details.push(format!("{}未匹配到同版本", src.id())); + } + } + Err(format!("所有音源均无可用 URL; 尝试明细: {}", details.join(", "))) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 真实接口测试:验证「唯一 邓紫棋」的 fallback 能找到可播放 URL + /// 手动运行: cargo test -- --ignored --nocapture + #[tokio::test] + #[ignore] + async fn test_fallback_weiyi_dengziqi() { + let sources = SourceConfig::builtin_defaults(); + match fallback_get_url("唯一", "邓紫棋", "standard", &sources).await { + Ok((url, label)) => { + println!("✅ fallback 成功: [{}] {}", label, url); + assert!(url.starts_with("http")); + } + Err(e) => { + panic!("❌ fallback 失败: {}", e); + } + } + } +} diff --git a/src/api/index.ts b/src/api/index.ts index 874529b..76cc962 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -40,6 +40,7 @@ export const MusicApi = { likelist: SongApi.likelist, likeSong: SongApi.likeSong, scrobble: SongApi.scrobble, + getExternalSongUrl: SongApi.getExternalSongUrl, // 歌单 userPlaylist: PlaylistApi.userPlaylist, @@ -53,6 +54,7 @@ export const MusicApi = { searchSuggest: SearchApi.searchSuggest, getHotSearch: SearchApi.getHotSearch, cloudsearch: SearchApi.cloudsearch, + searchSongsMulti: SearchApi.searchSongsMulti, // 专辑 albumDetail: AlbumApi.albumDetail, diff --git a/src/api/search.ts b/src/api/search.ts index de1e788..30c9417 100644 --- a/src/api/search.ts +++ b/src/api/search.ts @@ -1,4 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; +import type { MusicSourceConfig } from '../stores/settings'; /** * 搜索相关 API @@ -14,8 +15,20 @@ export const SearchApi = { return invoke('get_hot_search'); }, - /** 云搜索 */ + /** 云搜索(网易云) */ async cloudsearch(query: { keyword: string; searchType: number; limit: number }): Promise { return invoke('cloudsearch', { query }); }, + + /** 多源聚合搜索:并发请求所有启用音源,合并结果返回 + * 返回 JSON: ExternalSong[](与后端 ExternalSong 结构对齐) + * 用于「网易云搜不到的歌,从其他音源补充搜索结果」场景 + */ + async searchSongsMulti(query: { + keyword: string; + sources: MusicSourceConfig[]; + limit?: number; + }): Promise { + return invoke('search_songs_multi', { query }); + }, }; diff --git a/src/api/song.ts b/src/api/song.ts index 7e19ed6..e53d336 100644 --- a/src/api/song.ts +++ b/src/api/song.ts @@ -1,4 +1,5 @@ import { invoke } from '@tauri-apps/api/core'; +import type { MusicSourceConfig } from '../stores/settings'; /** * 歌曲相关 API @@ -9,8 +10,15 @@ export const SongApi = { return invoke('get_song_detail', { id }); }, - /** 获取歌曲播放 URL */ - async getSongUrl(query: { id: number; level: string; fm_mode?: boolean }): Promise { + /** 获取歌曲播放 URL + * sources: 启用的第三方音源配置列表(按优先级排序),网易云无 URL 时按顺序降级 + */ + async getSongUrl(query: { + id: number; + level: string; + fm_mode?: boolean; + sources?: MusicSourceConfig[]; + }): Promise { return invoke('get_song_url', { query }); }, @@ -30,7 +38,29 @@ export const SongApi = { }, /** 上报听歌记录(scrobble) */ - async scrobble(query: { id: number; sourceid: string; time: number; alg?: string; source?: string; bitrate?: number }): Promise { + async scrobble(query: { + id: number; + sourceid: string; + time: number; + alg?: string; + source?: string; + bitrate?: number; + }): Promise { return invoke('scrobble', { query }); }, + + /** 从指定外部音源获取播放 URL(用于搜索结果里来自外部源的歌曲) + * 返回 JSON: { url, source, source_label } + * 主源失败时会用 songName/artist/allSources 跨源 fallback + */ + async getExternalSongUrl(query: { + source: MusicSourceConfig; + songId: string; + quality?: string; + songName?: string; + artist?: string; + allSources?: MusicSourceConfig[]; + }): Promise { + return invoke('get_external_song_url', { query }); + }, }; diff --git a/src/components/PlayerBar.vue b/src/components/PlayerBar.vue index b354166..a2fe988 100644 --- a/src/components/PlayerBar.vue +++ b/src/components/PlayerBar.vue @@ -59,19 +59,26 @@
- - -
-

{{ song.name }}

+

+ {{ song.name }} + {{ tag.label }} + {{ song.sourceLabel }} +