diff --git a/.workbuddy/memory/2026-09-12.md b/.workbuddy/memory/2026-09-12.md new file mode 100644 index 0000000..2131974 --- /dev/null +++ b/.workbuddy/memory/2026-09-12.md @@ -0,0 +1,253 @@ +# 2026-09-12 + +## 设置页图标修复 + WebDAV 凭据加密存储 / 配置后隐藏 + +- **图标不显示根因**:设置页模板用了 `HardDriveDownload` 但从未 import——`vue-tsc` 不报错(未在 script 中引用、模板解析为未知组件静默失败),**运行时图标直接消失**。以后设置页新增图标必须同步加到 lucide import 列表。 +- **WebDAV 凭据加密**:新增 Rust 命令 `webdav_get_secret` / `webdav_save_secret`(`commands.rs`,非 specta,走 raw invoke;已注册 mod.rs 导出 + lib.rs generate_handler)。Windows 用 **keyring v3(windows-native,凭据管理器,DPAPI 保护)**,service="Thing" user="webdav-credentials",blob 为 `{"username","password"}` JSON;NoEntry 视为未配置。 +- **feiniuStore**:`WEBDAV_KEY` localStorage 现在只存 `{url, dir}`;`patchWebdav` 检测 `username/password` 字段时调 `webdav_save_secret`;`init()` 从凭据管理器加载凭据,并做一次性迁移(localStorage 有旧明文凭据 → 写入凭据管理器 + 立即改写 LS 去掉明文;用 `hasStoredSecret` 标志避免误覆盖)。`davConfig()` 仍从内存取明文传给 webdav_* 命令,链路不变。 +- **设置页 UI**:`webdavEditing` ref 控制——`webdavReady && !webdavEditing` 时显示摘要卡(服务地址/目标目录 + 「账号/密码已保存(系统凭据管理器加密存储)」),带「修改配置」「测试连接」;编辑态完整表单(密码框 type=password 本身掩码)+「收起」。 +- 校验:cargo check(新增 keyring 3.6.3)/ vue-tsc / vite build 全 0。 + +## 音乐模块全链路检查 + 排序/设置页/任务页调整 + +- **模块排序**:`music/index.ts` order 55→15(代理=10 之后、整体第二);`appStore.ts` loadSettings 增加一次性迁移(`musicOrderMigrated` 标记随设置持久化,声明在 store setup 内的非响应式 let),已保存过 moduleOrder 的用户下次启动自动把 music 移到 proxy 后,之后尊重手动拖动。 +- **隐藏 bug 修复(MusicModule.vue)**: + 1. `downloadToFeiniu` 早退分支(Rust 引擎/无下载源)未清理 `feiniuUploadingKeys` → 行按钮 spinner 永久卡死; + 2. `resolvePendingUploads` 只处理 done,临时「下载到飞牛」任务 error/cancelled/interrupted 或被手动移除时 spinner 同样卡死 → 增加失败/移除清理分支(注意:临时任务的 tempTaskIds.add 发生在任务进入 tasks 之后,watch 触发时序依赖后续状态变化再触发一次); + 3. `feiniu.uploading` 时不再叠加触发 `uploadRecentToFeiniu`。 +- **下载任务 tabs**:去掉标题上的进行中计数(连同 `activeTaskCount` computed,noUnusedLocals 会拦未用变量);任务卡片容器 `py-4`→`pb-4`,贴近 tabs。 +- **设置页 UI 重做**:section+Separator 改为 Card/CardHeader/CardTitle/CardContent 卡片式(max-w-2xl、gap-4、图标+text-base 标题,与 DownloaderModule 设置页一致);「播放条常驻在所有模块底部」等过时文案改为「标题栏播放控制」。 +- 全链路审查结论:旧审查的 P0(曲库搜索失效/重启后播放键失效/双音频引擎)均已在现码修复;musicdl 事件幂等、任务持久化签名 watch、歌词二分+邻近缓存等设计良好。 +- 校验:`vue-tsc --noEmit` 0 错误,`vite build` 通过。 + +## 「下载到飞牛」改为同链路落地即传(用户实测 URL 直传失败) + +用户确认:WebDAV 测试连接 OK、下载+自动上传 OK,但发现音乐行的「下载并上传」失败(旧的 URL 拉流直传机制)。按要求改为与「下载完成后自动上传」完全同链路: + +- `downloadToFeiniu`:直接 `store.startDownload([song])`(musicdl 引擎,含引擎侧解析/代理/音质),不再手动 resolve + 拼 headers;记 `tempTaskStarts`(taskId→创建时刻,扫描下界)。 +- 任务 done → `finishTempUpload`:`uploadRecentToFeiniu(0, { since, deleteLocal: true })` 上传 → `feiniu_delete_local` 删本地音频及同名 lrc/封面 → `store.removeTask` 移除记录。**失败时保留本地文件与任务记录**供排查重试。 +- `uploadRecentToFeiniu` 扩展 opts `{ since?, deleteLocal? }`;默认行为(自动上传)不变、不删本地。 +- 后端新增 `feiniu_delete_local`(音频/歌词/封面扩展名白名单 + 拒绝目录);**删除** `webdav_upload_from_url` 命令与 webdav.rs `upload_from_url`(机制废弃)。 +- musicdl 引擎守卫保留:Rust 引擎任务在「下载器」模块,无法回传。 +- 校验:tsc / vite / cargo 全 0。 + +## 文件服务 → WebDAV(飞牛传输改版,最终形态) + +fnOS 文件服务(WS + RSA/AES 登录)经三轮协议修复仍无法在用户 NAS 上稳定登录,且会话易失(重启要重登)。用户确认局域网场景改用标准文件协议,整体替换为 **WebDAV**(fnOS 系统设置 → 文件服务 → WebDAV,HTTP 5005/HTTPS 5006,Basic 认证,文件夹需在可见范围+团队文件夹要勾"允许协议挂载")。 + +- **删除**:`fnos.rs`(WS 客户端)、`Feiniu.fnos_sessions`、`feiniu_fnos_*` 与 `feiniu_upload_from_url` 命令、ConnectionsSettings 云朵登录弹窗。 +- **新增**:`src-tauri/src/music/feiniu/webdav.rs`——无状态:`test`(PROPFIND Depth 0)、`upload_file`(128KB 分块流式 PUT,unfold 转 Stream 免 tokio-util)、`upload_from_url`(GET 拉流→直接 PUT,本地不落地,**不再要求 Content-Length**)、`delete`;上传前逐级 MKCOL;URL 逐段百分号编码;401/403/404/409 可读错误。 +- **命令**:`webdav_test` / `webdav_upload` / `webdav_upload_from_url` / `webdav_delete`(config 由前端每次传入,serde+specta 结构 `WebDavConfig`)。 +- **前端**:`feiniuStore.webdav = {url, username, password, dir}` 持久化 `thing.music.feiniu.webdav`(密码明文 localStorage,LAN 工具可接受);`webdavReady` computed 四项齐备;`patchWebdav` / `testWebdav`;曲库目标目录语义改为 **WebDAV 根下相对路径**。设置页「存储与上传」为配置表单 + 测试按钮 + fnOS 侧设置折叠提示;TrackItem/发现音乐「下载到飞牛」门控全部改 `webdavReady`。 +- 校验:vue-tsc / vite build / cargo check 全 0。 + +## fnOS 登录协议修复(第三轮:"fnOS 登录未返回 token") + +第二轮修复后登录请求**有响应了**(不再超时),但 rid 命中的回包里没有 token。对照 pyfnos 发现关键行为:**登录最终成功包可能不按 reqid 关联**——pyfnos 的 `_is_final_login_success` 是按内容特征(`result=="succ" && token && secret`)识别任意消息,而非按 reqid 匹配。rid 命中的那条很可能只是中间应答,带 token 的包以独立推送到达,被旧 reader 直接丢弃。 + +修复(`fnos.rs`): +- 新增 `login_waiter` 通道;reader 对每条消息先做内容识别,命中最终成功包就投递给登录流程,reqid 匹配照旧(并容忍服务端把 reqid 回显成数字,`resp_rid` 辅助函数)。 +- `login()` 双通道等待:rid 应答里直接有 token → 用之;是中间应答 → 最多再等 8s 登录推送;send_and_wait 超时 → 再宽限 5s 等推送。 +- 识别两步验证挑战(`isBindTwofaSecret` / `isTwofaEnforced` / `accessToken`),明确报"账号开启了两步验证"而不是干等。 +- 诊断兜底:若始终等不到 token,报错里带上服务端实际回包的前 300 字符,用户可直接贴回继续定位。 +- 断开时同时唤醒登录等待者(Null)。 + +`cargo check` 通过。协议参考:pyfnos `client.py`(响应扁平结构:`result/token/secret/longToken/reqid` 顶层)。 + +## fnOS 登录协议修复(第二轮:"fnOS 请求超时(encrypted)") + +上一轮修完 WS 并发缺陷后,错误变成"请求超时(encrypted)"——说明握手和 `util.crypto.getRSAPub` 都通了,**卡在登录这一条**。根因:**加密登录体里缺字段**。 + +已对照两个参考实现逐字核对并修复: +- **FNOSP/fnnas-api** `sdk/encryption.py` 的 `login_encrypt()` 示例(原开发注释里引用的就是它) +- **Timandes/pyfnos** `fnos/client.py` 的 `_encrypt_login_data()` + +### 正确的加密登录体(必须逐字段对齐) +```json +{"reqid":"...","user":"...","password":"...","deviceType":"Browser", + "deviceName":"...","stay":false,"req":"user.login","si":"..."} +``` +要点: +- **`req: "user.login"` 必须在加密体内** —— 服务端靠它路由;缺了就静默不回包(=超时)。 +- **`reqid` 必须在加密体内**,且与 `pending` 等待者登记的 key 一致;外层 `encrypted` **不带** reqid。 +- **没有 `did` 字段**(FNOSP 示例没有;pyfnos 有但非必需,为减少变量已去掉)。 +- `stay: false`。 +- 加密方式:AES-256-CBC(key=32 字节随机 ASCII 串, iv=16 字节随机) + PKCS7,RSA 只加密那个 32 字符 key(PKCS1v1.5),全部 base64。 + +### 顺带核对过、确认我们原本就正确的部分 +- 签名:`hmac_sha256_b64(sign_key, json) + json`(签名前置);`sign_key` = 登录响应 `secret` → AES 解密 → base64(明文) → **base64 解码后的字节**。 +- **不签名白名单**:`encrypted` / `util.getSI` / `util.crypto.getRSAPub`(已按参考实现补上 `UNSIGNED_REQ`)。 +- 后续请求不携带 token 字段,服务端靠 WS 连接 + 签名识别会话;HTTP 上传走 `Trim-Token` 头。 +- reqid 格式宽松(各实现 18~28 位不等),我们现有的 `0000000000000000` 服务端接受(getRSAPub 已验证)。 + +### 其他改动 +- `request()` 拆成 `send_and_wait(message, rid, label)` + `request()`,登录走前者以便用加密体内的 reqid 关联。 +- 错误处理补齐 `result=="fail"` 分支并带出 `msg`/`errmsg`(密码错误等现在能看到具体原因)。 +- 新增**应用层心跳**:fnOS 网关会关闭长时间无消息的连接(参考实现默认 10s 一次 `{"req":"ping"}`)。做成"发请求前按需补发"(>20s 才发),避免常驻任务的生命周期问题——多文件批量上传时 WS 不会被闲置断开。 +- 报错里的 label 更可读(如 `user.login(encrypted)`)。 + +校验:`cargo check` exit 0。**仍未对真实 NAS 验证**,需要用户实测。 + +--- + +## 修复 fnOS 文件服务登录("输入密码没反应,很久后报发送请求失败") + +### 根因(`src-tauri/src/music/feiniu/fnos.rs`) +1. **WS 读写共用一个 `tokio::sync::Mutex`,且 reader 在持锁状态下 `await stream.next()`** —— `request()` 要拿同一把锁才能发送。第一次 `request()` 里 `ensure_reader()` 先 spawn 了 reader,reader 抢到锁后就在等一条永远不会发出的消息;发送方要么永久阻塞(超时也覆盖不到锁获取),要么在 reader 已退出后发送才报错 —— 这正是"等很久 → 发送请求失败"的来源。 +2. **握手没带 `Origin`**。浏览器发起的 WS 必带 `Origin`,Rust 客户端默认不带;fnOS 会据此判断来源,缺了它会"握手成功但立刻被对端关闭"。这是"连上了却什么都没发生"的典型原因。 +3. **没有回 Pong**。tungstenite 的 Stream/Sink API 不会自动回 Pong,服务端 ping 得不到响应会判定死连接。 +4. 连接/请求均**没有超时覆盖**(`timeout` 只包了 `rx`,不含锁获取与握手),主机不可达时会干等系统 TCP 重试。 + +### 修复 +- 读写分离:`futures_util::StreamExt::split(ws)` → writer 任务独占写半边(mpsc 队列),reader 独占读半边。发送不再与"等响应"争锁。 +- 握手请求改为构造 `Request` 并注入 `Origin`(由 ws url 反推 `http://host:port`)与浏览器 UA。 +- reader 显式处理 `Ping → Pong`,并记录断开原因到 `closed: Arc>>`。 +- `connect_async` 加 12s 超时;`request` 的 15s 超时改为覆盖整个等待,并在入口先检查 `closed`,断开时立刻带原因报错。 +- 所有报错都带上尝试的 ws 地址或断开原因,例如 `连接 fnOS 文件服务失败:ws://ip:port/websocket?type=main → ...`。 +- 前端:`ConnectionsSettings` 的 fnOS 弹窗加 `fnosConnecting`(按钮显示"连接中…")与弹窗内联错误块(错误里含地址/状态码,留在弹窗里可复制),并显示"当前地址"供核对。 +- `feiniuStore.fnosLogin(connectionId, ...)` 改为显式接收 connectionId 并校验它等于 activeId —— 上传命令按 `settings.feiniu_active_id` 找会话,登录非激活连接会存成永远用不上的会话。 + +**注意**:以上都只做了 `cargo check` + 前端构建校验,没有对真实飞牛 NAS 验证过协议细节(`Origin` 校验、`file.checkUpload`/`Trim-Path` 约定可能随 fnOS 版本不同)。如果仍失败,报错文案已包含具体地址与失败阶段。 + +--- + +## 发现音乐:分组头去白底 + 新增「下载到飞牛」一条龙 + 飞牛上传链路修复 + +### 分组头 +`MusicModule.vue` 里源分组头原来是 `sticky top-0 z-10 bg-background/95 backdrop-blur`,在浅色背景下会形成一条白色横条。已去掉 sticky 与底色,改为普通的 `border-b border-border/60 px-2` 分组标签(px-2 与行对齐)。 + +### 新增 Rust 命令 `feiniu_upload_from_url`(下载 + 上传一条龙,本地不留文件) +链路:前端 `feiniuStore.uploadUrlToFeiniu()` → `invoke('feiniu_upload_from_url')` → `Feiniu::fnos_upload_from_url()` → `fnos::upload_stream()`。 + +- `fnos.rs` 新增泛型 `upload_stream()`:`Part::stream_with_length(Body::wrap_stream(stream), size)`,配套 `upload_client(proxy)`(`proxy` 为空则 `no_proxy()`)。 +- `upload_file()` 重写为**流式**(`stream::unfold` + `tokio::fs::File` 分块 128KB)。原来用 `std::fs::read` 把整个文件读进内存,100MB 无损会直接吃 100MB RAM。用 unfold 是为了不引入 `tokio-util` 依赖。 +- `FnOsSession::connect` 增加 scheme 校验:`https://` 直接返回可读错误(`ws_url` 只把 `http://` 换成 `ws://`,https 会变成非法 WS scheme 并抛出难懂的 tungstenite 错误)。 +- `guess_mime(&Path)` 改为 `mime_of(&str)`(现在需要按 NAS 上的文件名推断)。 +- **依赖音源返回 `Content-Length`**(fnOS `checkUpload` 需要精确 size),缺失时返回可读错误,而不是先把整首缓冲进内存。 +- 前端 `downloadToFeiniu()`:懒解析歌曲先 `resolveSong(source, index, quality)` 拿真实链接(quality 取默认下载音质),合并 `defaultDownloadHeaders` + `defaultDownloadCookies` 为 Cookie 头,并**过滤非 ASCII 头**(reqwest 遇到非法头值会 panic,不能把外部数据直接塞进去)。 + +### 飞牛上传链路审查结论(重要) +1. **fnOS 会话只存在内存里**(`Feiniu.fnos_sessions`),应用重启即失效 → 设置页显示的「未连接」是准确的,需要重新点云朵图标登录。已在设置页与登录弹窗里明确写出"仅本次运行有效"。 +2. **仅支持 `http://` 局域网直连**,https/frp 无法上传(协议限制),已给出明确报错。 +3. `feiniu_scan_local` 扫描 `savedir` + `feiniu_local_dirs`,因此下载到本地目录的文件能被"上传最近下载"找到;但该功能是**按 mtime 10 分钟窗口**筛选,与具体下载任务无关联——这是它不可靠的根因(这就是为什么要新增 URL 直传)。 +4. `feiniu_fnos_upload` 用 `settings.feiniu_active_id` 找连接,`fnos_login` 用传入的 connection_id;只要激活连接一致就没问题。 + +### 校验(本机 bash 不可用,PowerShell + 绝对路径) +- `cargo check`(`src-tauri`,`%USERPROFILE%\.cargo\bin\cargo.exe`)→ exit 0,仅 3 个既有 warning +- `vue-tsc --noEmit` → exit 0;`vite build` → exit 0 + +--- + +## 曲库左栏:去掉灰底容器、数量列对齐、行高统一 + +用户反馈截图问题(灰底 + 文字竖向不齐)后的修正,`MyMusicLibrary.vue`: +- **去掉 `bg-muted` 容器**:来源项不再包在灰色圆角块里。选中态保留 `bg-background font-medium shadow-sm`,并**补 `ring-1 ring-border`**——否则白底上的白色胶囊只有 5% 透明度的阴影,几乎看不见(顶部 Tabs 那个"可见边框"其实是 `:focus-visible` 的 ring,不是常态样式,不能照抄)。 +- **数量列始终渲染**:原来 `v-if="nav.count"` 会在 0 时隐藏整列,造成右侧数字列参差。改为无歌曲时显示 `0`,并加 `min-w-[3ch] text-right tabular-nums` 让个位数/三位数右边缘对齐。 +- **行高统一为固定值**:来源项 `h-8`、歌单子项 `h-7`,配合 `items-center`,不再依赖 `py-1.5` + 行高推算,彻底消除逐行细微的垂直错位。 +- 未选中项 hover 从 `hover:bg-accent/50` 统一为 `hover:bg-accent/60`(无容器后需要更明显的悬停反馈)。 +- 歌单子项的选中态也同步改成同一套胶囊样式,保持层级内一致。 + +--- + +## 曲库左栏收窄 + 选中态对齐顶部 Tabs + 切换动画 + +`MyMusicLibrary.vue`: +- 左栏宽度 `w-[210px]` → `w-[168px]`("飞牛曲库 → 数量"之间的大段空白因此明显收窄)。内边距 `p-2.5` → `p-2`,字号 13px → 保留。 +- 三个来源(飞牛曲库/本地曲库/我的歌单)从裸列表改为 **`rounded-lg bg-muted p-1` 容器 + 选中项 `bg-background font-medium shadow-sm rounded-md`**,与顶部 `TabsList`/`TabsTrigger` 的默认选中态一致。 +- 歌单子树加 `border-l` 缩进表示层级;展开/收起加了 `pl-list` Transition(opacity + translateY(-6px),0.18s)。 +- **标签截断修复**:`` 在 flex 行里不会真正截断(flex item 默认 `min-width:auto`)。改成 `min-w-0 flex-1 truncate` + 右侧计数 `shrink-0`,并去掉 `ml-auto`。以后左栏加条目记得照这个写。 +- 右栏内容切换动画:给 `
` 加 `:key="view"` + 全局 `tab-animate`,靠重挂载触发动画(与模块级 TabsContent 的做法一致,无需自造 Transition)。 + +--- + +## 歌单新增「编辑音乐」编辑器 + +- 新组件 `src/modules/music/components/PlaylistEditorDialog.vue`(props `open` / `playlistId`,emit `update:open`)。 + - 双 Tab:**全部音乐**(来源筛选 全部/飞牛/本地 → 勾选加入;已在歌单的项置灰并标「已加入」,不可重复勾选)、**已在歌单**(勾选批量移出,勾选框用 destructive 色)。 + - 含搜索(歌名/歌手/专辑)、全选当前列表、增量渲染每批 120 + 「显示更多」、飞牛曲库「继续加载」、本地曲库未扫描时的内联「扫描本地」按钮。 + - 勾选框**不用** `Checkbox` 组件:整行是 ` + + + 红/橙标记 = 缺陷位置 + + + +
+
+
+ Thing +
我的音乐发现音乐下载任务设置
+
+
+
+ +
+
+
+
+ 我的音乐发现音乐下载任务设置 +
+
+ +
+ 飞牛曲库本地曲库我的歌单 + ← 第二套切换控件,与上一行风格不同 + 3 +
+ +
+ 下载到 + 本地目录 ▾ + 下载到本地目录(musicdl) + 1 +
+ +
+
输入歌名 / 歌手 / 专辑关键词
+ 已选 3 个源 ▾ + 搜索 +
+ +
+ 粘贴歌单链接(网易云 / QQ 音乐等) + 解析歌单 +
+ +
+
+
+
+
海阔天空 无损 flac 32.4 MB 320K mp3 +2
+
Beyond · 乐与怒
+
+ 05:21 + 25 +
+
+
+
+
光辉岁月 Hi-Res flac 96.1 MB 192K mp3
+
Beyond · 命运派对
+
+ 04:58 + 2 +
+
+
+
+
喜欢你
Beyond · 秘密警察
+
+ 04:35 + 6 +
+ 每行都是独立圆角卡片 + 8px 间距;一行内 6~8 个视觉元素,彩色徽标抢过歌名。 +
+
+
+
+
+
+
未播放
选择一首歌曲开始播放
+
+
+
+
+ --:-- +
+ --:-- +
+
+
🔊
+ 4 +
+
+ 7 + 8 +
+
+ + +
+
+
+ Thing +
曲库发现音乐下载任务设置
+ 3 +
+
+
+ +
+ + + +
+
+
搜索歌名 / 歌手 / 专辑
+ 全部来源 ▾ + 全部音质 ▾ + 排序 ▾ + 扫描本地 + ▶ 播放全部 + 1 +
+
+
+ #标题专辑时长 +
+
+ +
+
+
海阔天空
+
Beyond · 1993
+
+
乐与怒
+ 05:21 + +
+
+ 2 +
+
光辉岁月
Beyond
+
命运派对
+ 04:58 + +
+
+ 3 +
+
喜欢你
Beyond
+
秘密警察
+ 04:35 + +
+
+ 4 +
+
冷雨夜
Beyond
+
现代舞台
+ 04:12 + +
+
+ 5 +
+
真的爱你
Beyond
+
Beyond IV
+ 04:41 + +
+
+ 行高 44px、无独立边框、hover 高亮、双击整行播放;播放中行用主色竖条 + 均衡器表示,不再用“选中框”。 + 2 +
+
+
+ +
+
+
+

海阔天空

+

Beyond · 乐与怒

+
+
+
+ 今天我 寒夜里看雪飘过 + 怀着冷却了的心窝漂远方 + 风雨里追赶 雾里分不清影踪 + 天空海阔你与我 + 可会变(谁没在变) +
+ 右栏“正在播放”可折叠(窄窗口自动收起) +
+
+ +
+
+
+
+
海阔天空
+
Beyond · 乐与怒
+
+ 4 +
+
+
🔀❚❚
+
+ 01:12 +
+ 05:21 +
+ 拖动只更新预览,松手才 seek;轨道含缓冲层,hover 加粗到 8px +
+
+ 5 + 🔊
+
+
+
+ +
+
1
首屏信息层级

“下载到 本地/飞牛”不再独占一行卡片,收进工具栏;搜索框回到首行黄金位置。

+
2
列表密度与徽标降噪

卡片堆 → 44px 紧凑表格行;音质徽标从 5 色降为 1 类中性 outline,档位详情收进 ⋯ 菜单。

+
3
两级横向 Tab → 左侧来源树

模块级 Tab + 子 Tab 合并为一条来源树,导航层级从三层降到两层。

+
4
播放条全宽贴底

MiniPlayer 提升到 App 全局层:任何模块都能控制播放;离开音乐模块不再丢失控制入口。

+
5
进度与音量交互

提交式 seek + 缓冲进度层;音量图标可点静音;滑块配色改走主题 token(原为硬编码纯白)。

+
6
来源与来源标识统一

本地/NAS/歌单混排时用统一图标标识来源,不再靠行内小标签。

+
7
内边距不对称

模块左 28px / 右 40px(含滚动条),卡片右边距明显偏窄;统一为 24px 并保留滚动条槽位。

+
8
播放条未贴底

被模块 p-6 包住,分隔线左右各缩进 24px,读作“漂浮卡片”而非底部坞站。

+
+ + + + + diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e63c544..c6f403d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2865,6 +2865,18 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "log", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "kqueue" version = "1.2.0" @@ -6149,6 +6161,7 @@ dependencies = [ "futures-util", "hmac", "image", + "keyring", "librqbit", "md-5", "notify", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3a078b4..645db3e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -60,6 +60,8 @@ windows-capture = "2.0.1" [target.'cfg(windows)'.dependencies] winreg = "0.52" +# WebDAV 凭据加密存储(Windows 凭据管理器,DPAPI 保护,不落明文) +keyring = { version = "3", features = ["windows-native"] } raw-window-handle = "0.6" windows-sys = { version = "0.52", features = [ "Win32_Networking_WinInet", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 42f82f0..862e5ae 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -44,14 +44,13 @@ use monitor_kernel::{ }; use music::{ feiniu_activate_connection, feiniu_cache_clear, feiniu_cache_fetch, feiniu_cache_status, - feiniu_delete_connection, feiniu_fnconnect_resolve, feiniu_fnos_delete, feiniu_fnos_list, - feiniu_fnos_login, feiniu_fnos_logout, feiniu_fnos_status, feiniu_fnos_upload, - feiniu_get_config, feiniu_list_connections, feiniu_list_tracks, feiniu_login, feiniu_logout, - feiniu_lyric, feiniu_media_prefix, feiniu_save_connection, feiniu_scan_local, - feiniu_test_connection, music_cancel_runtime_install, music_download, music_download_cancel, - music_env_status, music_get_settings, music_get_sources, music_install_runtime, - music_parse_playlist, music_ping, music_resolve, music_save_settings, music_search, - music_stop_bridge, MusicManager, + feiniu_delete_connection, feiniu_delete_local, feiniu_fnconnect_resolve, feiniu_get_config, + feiniu_list_connections, feiniu_list_tracks, feiniu_login, feiniu_logout, feiniu_lyric, + feiniu_media_prefix, feiniu_save_connection, feiniu_scan_local, feiniu_test_connection, + music_cancel_runtime_install, music_download, music_download_cancel, music_env_status, + music_get_settings, music_get_sources, music_install_runtime, music_parse_playlist, music_ping, + music_resolve, music_save_settings, music_search, music_stop_bridge, webdav_delete, + webdav_get_secret, webdav_save_secret, webdav_test, webdav_upload, MusicManager, }; use network_monitor::network_status; use osd_window::{ @@ -287,13 +286,13 @@ pub fn run() { feiniu_cache_status, feiniu_cache_clear, feiniu_cache_fetch, - feiniu_fnos_login, - feiniu_fnos_logout, - feiniu_fnos_status, - feiniu_fnos_upload, - feiniu_fnos_delete, - feiniu_fnos_list, feiniu_fnconnect_resolve, + webdav_test, + webdav_upload, + webdav_delete, + webdav_get_secret, + webdav_save_secret, + feiniu_delete_local, network_status, osd_apply_overlay_style, osd_begin_drag, diff --git a/src-tauri/src/music/commands.rs b/src-tauri/src/music/commands.rs index a40e54c..de76f9b 100644 --- a/src-tauri/src/music/commands.rs +++ b/src-tauri/src/music/commands.rs @@ -437,102 +437,132 @@ fn new_conn_id() -> String { format!("c{n:x}") } -// ============ P6:下载到飞牛 + 曲库增删(fnOS 文件服务) ============ +// ============ P6:下载到飞牛 + 曲库增删(WebDAV,无状态) ============ -/// fnOS 登录:为某连接建立 NAS 文件服务会话(WS + RSA/AES)。成功后不持久化凭据。 +/// 测试 WebDAV 连接与目标目录是否可达。 #[tauri::command] -pub async fn feiniu_fnos_login( - state: State<'_, MusicManager>, - connection_id: String, - username: String, - password: String, +#[specta::specta] +pub async fn webdav_test( + config: crate::music::feiniu::webdav::WebDavConfig, + dir: String, ) -> Result { - let settings = state.load_settings(); - let conn = settings - .feiniu_connections - .iter() - .find(|c| c.id == connection_id) - .cloned() - .ok_or_else(|| "连接不存在".to_string())?; - state - .feiniu - .fnos_login(&connection_id, &conn.base_url, &username, &password) - .await?; - Ok(json!({ "ok": true })) + let msg = crate::music::feiniu::webdav::test(&config, &dir).await?; + Ok(json!({ "ok": true, "message": msg })) } -/// 登出 fnOS 文件服务会话。 +/// 上传本地文件到 WebDAV 目标目录(流式,不整载内存)。 #[tauri::command] -pub fn feiniu_fnos_logout( - state: State<'_, MusicManager>, - connection_id: String, -) -> Result { - state.feiniu.fnos_logout(&connection_id); - Ok(json!({ "ok": true })) -} - -/// fnOS 文件服务登录状态:{ loggedIn }(按连接)。 -#[tauri::command] -pub fn feiniu_fnos_status( - state: State<'_, MusicManager>, - connection_id: String, -) -> Result { - Ok(json!({ "loggedIn": state.feiniu.fnos_logged_in(&connection_id) })) -} - -/// 上传本地文件到 NAS(激活连接的 fnOS 会话)。返回上传文件名。 -#[tauri::command] -pub async fn feiniu_fnos_upload( - state: State<'_, MusicManager>, +#[specta::specta] +pub async fn webdav_upload( + config: crate::music::feiniu::webdav::WebDavConfig, local_path: String, - nas_path: String, + remote_path: String, ) -> Result { - let settings = state.load_settings(); - let conn = settings - .feiniu_connections - .iter() - .find(|c| c.id == settings.feiniu_active_id) - .cloned() - .ok_or_else(|| "没有激活连接".to_string())?; - let name = state - .feiniu - .fnos_upload(&conn.id, std::path::Path::new(&local_path), &nas_path) - .await?; + let name = crate::music::feiniu::webdav::upload_file( + &config, + std::path::Path::new(&local_path), + &remote_path, + ) + .await?; Ok(json!({ "ok": true, "name": name })) } -/// 删除 NAS 文件(激活连接的 fnOS 会话)。 +/// 删除 WebDAV 远程文件(404 视为已删除)。 #[tauri::command] -pub async fn feiniu_fnos_delete( - state: State<'_, MusicManager>, - nas_path: String, +#[specta::specta] +pub async fn webdav_delete( + config: crate::music::feiniu::webdav::WebDavConfig, + remote_path: String, ) -> Result { - let settings = state.load_settings(); - let conn = settings - .feiniu_connections - .iter() - .find(|c| c.id == settings.feiniu_active_id) - .cloned() - .ok_or_else(|| "没有激活连接".to_string())?; - state.feiniu.fnos_delete(&conn.id, &nas_path).await?; + crate::music::feiniu::webdav::delete(&config, &remote_path).await?; Ok(json!({ "ok": true })) } -/// 列出 NAS 目录(激活连接的 fnOS 会话)。 +// ============ WebDAV 凭据加密存储(Windows 凭据管理器,DPAPI 保护) ============ + +/// 凭据以 JSON blob 存入系统凭据管理器:{"username","password"}。 +/// 明文仅存在于内存与系统凭据库,绝不写回 localStorage / 配置文件。 +#[cfg(windows)] +const WEBDAV_SECRET_SERVICE: &str = "Thing"; +#[cfg(windows)] +const WEBDAV_SECRET_USER: &str = "webdav-credentials"; + +#[cfg(windows)] +fn webdav_secret_entry() -> Result { + keyring::Entry::new(WEBDAV_SECRET_SERVICE, WEBDAV_SECRET_USER) + .map_err(|e| format!("无法访问系统凭据管理器: {e}")) +} + +#[cfg(windows)] +fn webdav_secret_read() -> Result, String> { + let entry = webdav_secret_entry()?; + match entry.get_password() { + Ok(blob) => serde_json::from_str(&blob) + .map(Some) + .map_err(|e| format!("凭据数据损坏: {e}")), + // 未配置过凭据不算错误 + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(format!("读取凭据失败: {e}")), + } +} + +#[cfg(windows)] +fn webdav_secret_write(username: &str, password: &str) -> Result<(), String> { + let blob = json!({ "username": username, "password": password }).to_string(); + let entry = webdav_secret_entry()?; + entry.set_password(&blob).map_err(|e| format!("保存凭据失败: {e}")) +} + +/// 读取 WebDAV 凭据。未配置时 username/password 为 null。 #[tauri::command] -pub async fn feiniu_fnos_list( - state: State<'_, MusicManager>, - path: String, -) -> Result { - let settings = state.load_settings(); - let conn = settings - .feiniu_connections - .iter() - .find(|c| c.id == settings.feiniu_active_id) - .cloned() - .ok_or_else(|| "没有激活连接".to_string())?; - let v = state.feiniu.fnos_list(&conn.id, &path).await?; - Ok(v) +pub fn webdav_get_secret() -> Result { + #[cfg(windows)] + { + Ok(webdav_secret_read()?.unwrap_or_else(|| json!({ "username": null, "password": null }))) + } + #[cfg(not(windows))] + { + Ok(json!({ "username": null, "password": null })) + } +} + +/// 保存 WebDAV 凭据(账号 + 密码整体覆盖)。 +#[tauri::command] +pub fn webdav_save_secret(username: String, password: String) -> Result { + #[cfg(windows)] + { + webdav_secret_write(&username, &password)?; + } + #[cfg(not(windows))] + { + let _ = (&username, &password); + } + Ok(json!({ "ok": true })) +} + +/// 删除本地媒体文件(「下载到飞牛」落地即传流程的收尾)。 +/// 仅允许音频 / 歌词 / 封面扩展名,且拒绝目录——防止前端误删任意文件。 +#[tauri::command] +#[specta::specta] +pub fn feiniu_delete_local(path: String) -> Result { + const ALLOWED: [&str; 13] = [ + "mp3", "flac", "wav", "m4a", "aac", "ogg", "ape", "wma", "lrc", "jpg", "jpeg", "png", "webp", + ]; + let p = std::path::Path::new(&path); + let ext_ok = p + .extension() + .and_then(|e| e.to_str()) + .map(|e| ALLOWED.contains(&e.to_lowercase().as_str())) + .unwrap_or(false); + if !ext_ok { + return Err(format!("拒绝删除非媒体文件:{path}")); + } + let meta = std::fs::metadata(p).map_err(|e| format!("文件不存在: {e}"))?; + if meta.is_dir() { + return Err("拒绝删除目录".to_string()); + } + std::fs::remove_file(p).map_err(|e| format!("删除失败: {e}"))?; + Ok(json!({ "ok": true })) } // ============ P7:FnConnect 远程连接解析 ============ diff --git a/src-tauri/src/music/feiniu/fnos.rs b/src-tauri/src/music/feiniu/fnos.rs deleted file mode 100644 index 1b2fcd2..0000000 --- a/src-tauri/src/music/feiniu/fnos.rs +++ /dev/null @@ -1,410 +0,0 @@ -//! fnOS 文件服务客户端(WebSocket 协议 + HTTP 上传)。 -//! -//! 协议参照 `FNOSP/fnnas-api`: -//! 1. 连接 `ws://{host}:{port}/websocket?type=main` -//! 2. `util.crypto.getRSAPub` 取 RSA 公钥与 si -//! 3. `user.login`:随机 AES key/iv,AES-CBC 加密登录体 + RSA 加密 key,发 `{"req":"encrypted",...}` -//! 4. 之后每个请求 `{base64(HMAC-SHA256(json))}{json}` 签名 -//! 5. 上传:WS `file.checkUpload` → HTTP `POST /upload`(Trim-Token/Trim-Path/Trim-Sign) -//! 6. 删除:WS `file.rm` -//! -//! 仅支持 http://(ws://)直连;https/frp 的文件上传留待后续。 - -use std::collections::HashMap; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit}; -use aes::Aes256; -use base64::Engine; -use futures_util::{SinkExt, StreamExt}; -use hmac::{Hmac, Mac}; -use rand::RngCore; -use rsa::pkcs8::DecodePublicKey; -use rsa::{Pkcs1v15Encrypt, RsaPublicKey}; -use serde_json::{json, Value}; -use sha2::Sha256; -use tokio_tungstenite::tungstenite::Message; -use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; - -type HmacSha256 = Hmac; -type WsStream = WebSocketStream>; - -const KEY_CHARS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; - -fn random_key() -> String { - let mut rng = rand::thread_rng(); - (0..32) - .map(|_| { - let i = rng.next_u64() as usize % KEY_CHARS.len(); - KEY_CHARS[i] as char - }) - .collect() -} - -fn random_iv() -> [u8; 16] { - let mut iv = [0u8; 16]; - rand::thread_rng().fill_bytes(&mut iv); - iv -} - -/// 手动 AES-256-CBC 加密(避免 cbc crate trait 兼容问题),PKCS7 填充,返回 base64。 -fn aes_cbc_encrypt_b64(data: &[u8], key: &[u8; 32], iv: &[u8; 16]) -> String { - let cipher = Aes256::new(key.into()); - let mut padded = data.to_vec(); - let pad_len = 16 - (padded.len() % 16); - padded.extend(std::iter::repeat(pad_len as u8).take(pad_len)); - let mut out = Vec::with_capacity(padded.len()); - let mut prev = *iv; - for chunk in padded.chunks(16) { - let mut block = [0u8; 16]; - for i in 0..16 { - block[i] = chunk[i] ^ prev[i]; - } - cipher.encrypt_block((&mut block).into()); - out.extend_from_slice(&block); - prev = block; - } - base64::engine::general_purpose::STANDARD.encode(out) -} - -/// AES-256-CBC 解密 → 返回 base64(明文)(对齐 fnnas-api 的 aes_decrypt)。 -fn aes_cbc_decrypt_b64(ciphertext_b64: &str, key: &[u8; 32], iv: &[u8; 16]) -> Result { - let ct = base64::engine::general_purpose::STANDARD - .decode(ciphertext_b64) - .map_err(|e| format!("AES 密文解码失败: {e}"))?; - let cipher = Aes256::new(key.into()); - let mut prev = *iv; - let mut plain = Vec::with_capacity(ct.len()); - for chunk in ct.chunks(16) { - let mut block = [0u8; 16]; - block.copy_from_slice(chunk); - let enc = block; - cipher.decrypt_block((&mut block).into()); - for i in 0..16 { - plain.push(block[i] ^ prev[i]); - } - prev = enc; - } - // 去 PKCS7 填充 - if let Some(&last) = plain.last() { - let n = last as usize; - if n > 0 && n <= 16 && plain.len() >= n && plain[plain.len() - n..].iter().all(|&b| b == last) { - plain.truncate(plain.len() - n); - } - } - Ok(base64::engine::general_purpose::STANDARD.encode(plain)) -} - -fn rsa_encrypt_b64(pub_pem: &str, data: &str) -> Result { - let key = RsaPublicKey::from_public_key_pem(pub_pem) - .map_err(|e| format!("解析 RSA 公钥失败: {e}"))?; - let mut rng = rand::thread_rng(); - let ct = key - .encrypt(&mut rng, Pkcs1v15Encrypt, data.as_bytes()) - .map_err(|e| format!("RSA 加密失败: {e}"))?; - Ok(base64::engine::general_purpose::STANDARD.encode(ct)) -} - -fn hmac_sha256_b64(key: &[u8], data: &str) -> String { - let mut mac = ::new_from_slice(key).expect("hmac key"); - mac.update(data.as_bytes()); - base64::engine::general_purpose::STANDARD.encode(mac.finalize().into_bytes()) -} - -fn reqid() -> String { - let n = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0); - format!("0000000000000000{n:x}") -} - -fn ws_url(base_url: &str) -> String { - let u = base_url.trim_end_matches('/'); - u.replacen("http://", "ws://", 1) + "/websocket?type=main" -} - - /// 一次 fnOS 会话(登录后复用;命令层按 connection 存于 Feiniu)。 -pub struct FnOsSession { - pub base_url: String, - token: Mutex, - sign_key: Mutex>, - ws: Arc>>, - pending: Arc>>>, - reader: Arc, -} - -impl FnOsSession { - /// 建立会话(新 WS 连接 + 登录)。 - pub async fn connect(base_url: &str, username: &str, password: &str) -> Result { - let url = ws_url(base_url); - let (ws, _) = connect_async(&url) - .await - .map_err(|e| format!("连接 fnOS WebSocket 失败: {e}"))?; - let session = Self { - base_url: base_url.trim_end_matches('/').to_string(), - token: Mutex::new(String::new()), - sign_key: Mutex::new(Vec::new()), - ws: Arc::new(tokio::sync::Mutex::new(Some(ws))), - pending: Arc::new(Mutex::new(HashMap::new())), - reader: Arc::new(AtomicBool::new(false)), - }; - session.login(username, password).await?; - Ok(session) - } - - pub fn token(&self) -> String { - self.token.lock().unwrap_or_else(|e| e.into_inner()).clone() - } - - fn ensure_reader(&self) { - if self.reader.swap(true, Ordering::SeqCst) { - return; - } - let ws = self.ws.clone(); - let pending = self.pending.clone(); - tokio::spawn(async move { - loop { - let msg = { - let mut guard = ws.lock().await; - let Some(stream) = guard.as_mut() else { break }; - match stream.next().await { - Some(Ok(m)) => m, - _ => break, - } - }; - if let Message::Text(text) = msg { - if let Ok(v) = serde_json::from_str::(&text) { - if let Some(rid) = v["reqid"].as_str() { - if let Some(sender) = - pending.lock().unwrap_or_else(|e| e.into_inner()).remove(rid) - { - let _ = sender.send(v); - } - } - } - } - } - let pendings: Vec<_> = pending - .lock() - .unwrap_or_else(|e| e.into_inner()) - .drain() - .map(|(_, s)| s) - .collect(); - for s in pendings { - let _ = s.send(Value::Null); - } - }); - } - - /// 发送签名请求并等待响应。 - async fn request(&self, req: &str, data: Value) -> Result { - self.ensure_reader(); - let rid = reqid(); - let mut body = json!({ "req": req, "reqid": rid }); - if let Value::Object(map) = data { - for (k, v) in map { - body[k] = v; - } - } - let json_str = serde_json::to_string(&body).map_err(|e| e.to_string())?; - let sign_key = self.sign_key.lock().unwrap_or_else(|e| e.into_inner()).clone(); - let message = if sign_key.is_empty() { - json_str.clone() - } else { - hmac_sha256_b64(&sign_key, &json_str) + &json_str - }; - - let (tx, rx) = tokio::sync::oneshot::channel::(); - self.pending - .lock() - .unwrap_or_else(|e| e.into_inner()) - .insert(rid.clone(), tx); - { - let mut guard = self.ws.lock().await; - let stream = guard - .as_mut() - .ok_or_else(|| "fnOS 连接已断开".to_string())?; - stream - .send(Message::Text(message.into())) - .await - .map_err(|e| format!("发送请求失败: {e}"))?; - } - - let resp = tokio::time::timeout(Duration::from_secs(10), rx) - .await - .map_err(|_| "fnOS 请求超时".to_string())? - .map_err(|_| "fnOS 请求通道关闭".to_string())?; - if resp.is_null() { - return Err("fnOS 连接已断开".to_string()); - } - if let Some(errno) = resp["errno"].as_i64() { - if errno != 0 { - return Err(format!("fnOS 请求失败(errno {errno})")); - } - } - Ok(resp) - } - - async fn login(&self, username: &str, password: &str) -> Result<(), String> { - let resp = self.request("util.crypto.getRSAPub", Value::Null).await?; - let pub_pem = resp["pub"].as_str().ok_or("getRSAPub 未返回 pub")?.to_string(); - let si = resp["si"].as_str().unwrap_or("").to_string(); - - let key = random_key(); - let iv = random_iv(); - let login_body = json!({ - "user": username, - "password": password, - "deviceType": "Browser", - "deviceName": "Thing Client", - "stay": false, - "si": si, - }); - let json_str = serde_json::to_string(&login_body).map_err(|e| e.to_string())?; - let key_bytes: [u8; 32] = key - .as_bytes() - .try_into() - .map_err(|_| "AES 密钥长度错误".to_string())?; - let aes_b64 = aes_cbc_encrypt_b64(json_str.as_bytes(), &key_bytes, &iv); - let rsa_b64 = rsa_encrypt_b64(&pub_pem, &key)?; - let enc = json!({ - "req": "encrypted", - "iv": base64::engine::general_purpose::STANDARD.encode(iv), - "rsa": rsa_b64, - "aes": aes_b64, - }); - let resp = self.request("encrypted", enc).await?; - let token = resp["token"].as_str().ok_or("fnOS 登录未返回 token")?.to_string(); - let secret = resp["secret"].as_str().ok_or("fnOS 登录未返回 secret")?.to_string(); - let secret_dec = aes_cbc_decrypt_b64(&secret, &key_bytes, &iv)?; - let sign_key = base64::engine::general_purpose::STANDARD - .decode(&secret_dec) - .map_err(|e| format!("sign_key base64 解码失败: {e}"))?; - *self.token.lock().unwrap_or_else(|e| e.into_inner()) = token; - *self.sign_key.lock().unwrap_or_else(|e| e.into_inner()) = sign_key; - Ok(()) - } - - /// `file.checkUpload`:返回 uploadName。 - pub async fn check_upload(&self, nas_path: &str, size: u64, overwrite: u32) -> Result { - let resp = self - .request( - "file.checkUpload", - json!({ "size": size, "path": nas_path, "overwrite": overwrite }), - ) - .await?; - resp["uploadName"] - .as_str() - .map(|s| s.to_string()) - .ok_or_else(|| "checkUpload 未返回 uploadName".to_string()) - } - - /// `file.rm`:删除 NAS 文件(移入回收站)。 - pub async fn delete_file(&self, nas_path: &str) -> Result<(), String> { - let name = nas_path.rsplit('/').next().unwrap_or(nas_path).to_string(); - self.request( - "file.rm", - json!({ - "files": [nas_path], - "moveToTrashbin": true, - "details": { "name": name, "count": 1, "dir": 0 }, - }), - ) - .await?; - Ok(()) - } - - /// `file.ls`:列出目录。 - pub async fn list(&self, path: &str) -> Result { - self.request("file.ls", json!({ "path": path })).await - } -} - -/// HTTP 上传到 NAS(需要已登录的 session)。返回上传后的文件名。 -pub async fn upload_file( - session: &FnOsSession, - local_path: &std::path::Path, - nas_path: &str, - overwrite: u32, -) -> Result { - let size = std::fs::metadata(local_path) - .map_err(|e| format!("读取本地文件失败: {e}"))? - .len(); - let upload_name = session.check_upload(nas_path, size, overwrite).await?; - let parent = nas_path.rsplit('/').nth(1).unwrap_or("").to_string(); - let trim_path = if parent.is_empty() { - upload_name.clone() - } else { - format!("{parent}/{upload_name}") - }; - let mtim = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) - .to_string(); - let sign_key = session.sign_key.lock().unwrap_or_else(|e| e.into_inner()).clone(); - let trim_sign = hmac_sha256_b64(&sign_key, &trim_path); - let token = session.token(); - - let file_name = local_path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("music.bin") - .to_string(); - let bytes = std::fs::read(local_path).map_err(|e| format!("读取本地文件失败: {e}"))?; - let mime = guess_mime(local_path); - let form = reqwest::multipart::Form::new().part( - "trim-upload-file", - reqwest::multipart::Part::bytes(bytes) - .file_name(file_name) - .mime_str(mime) - .map_err(|e| e.to_string())?, - ); - - let client = reqwest::Client::builder() - .no_proxy() - .timeout(Duration::from_secs(300)) - .build() - .map_err(|e| e.to_string())?; - let resp = client - .post(format!("{}/upload", session.base_url)) - .header("Trim-Token", token) - .header("Trim-Path", trim_path) - .header("Trim-Sign", trim_sign) - .header("Trim-Mtim", mtim) - .header("Trim-Overwrite", overwrite.to_string()) - .header("Referer", format!("{}/", session.base_url)) - .header("User-Agent", "Thing/1.0") - .header("Accept", "application/json, text/plain, */*") - .multipart(form) - .send() - .await - .map_err(|e| format!("上传请求失败: {e}"))?; - let status = resp.status(); - if !status.is_success() { - let body = resp.text().await.unwrap_or_default(); - return Err(format!("上传失败(HTTP {status}): {body}")); - } - Ok(upload_name) -} - -fn guess_mime(p: &std::path::Path) -> &'static str { - match p - .extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_lowercase()) - .as_deref() - { - Some("mp3") => "audio/mpeg", - Some("flac") => "audio/flac", - Some("wav") => "audio/wav", - Some("m4a") => "audio/mp4", - Some("aac") => "audio/aac", - Some("ogg") => "audio/ogg", - Some("ape") => "audio/x-ape", - _ => "application/octet-stream", - } -} \ No newline at end of file diff --git a/src-tauri/src/music/feiniu/mod.rs b/src-tauri/src/music/feiniu/mod.rs index fd7e1f0..5833c85 100644 --- a/src-tauri/src/music/feiniu/mod.rs +++ b/src-tauri/src/music/feiniu/mod.rs @@ -5,7 +5,6 @@ //! 对照 FeiNiuMusic(Flutter) `api_client.dart` 的第三方纯前端实现翻译。 //! 所有对 NAS 的 HTTP 请求在本模块收敛(页面/命令层不直接发请求)。 -use std::collections::HashMap; use std::path::Path; use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -21,12 +20,11 @@ pub use conn::normalize_base_url; mod cache; mod conn; mod fnconnect; -mod fnos; pub mod proxy; +pub mod webdav; pub use cache::CacheManager; pub use fnconnect::{extract_fn_id, resolve_base_url}; -pub use fnos::FnOsSession; use conn::{generate_device_id, sha256_hex}; use proxy::{ProxyCfg, ProxyShared}; @@ -90,8 +88,6 @@ pub struct Feiniu { conn: Mutex, proxy: Mutex>, cache: CacheManager, - /// fnOS 文件服务会话:connection id → 会话 - fnos_sessions: Mutex>>, } impl Default for Feiniu { @@ -108,7 +104,6 @@ impl Default for Feiniu { }), proxy: Mutex::new(None), cache: CacheManager::new(Path::new("placeholder")), // 由 set_cache_root 重建 - fnos_sessions: Mutex::new(HashMap::new()), } } } @@ -435,76 +430,8 @@ impl Feiniu { Ok(self.cache.hit(guid)) } - // ===== fnOS 文件服务(P6:上传/删除) ===== - - /// fnOS 登录:为指定连接建立文件服务会话。 - pub async fn fnos_login( - &self, - connection_id: &str, - base_url: &str, - username: &str, - password: &str, - ) -> Result<(), String> { - let session = Arc::new(FnOsSession::connect(base_url, username, password).await?); - if let Ok(mut m) = self.fnos_sessions.lock() { - m.insert(connection_id.to_string(), session); - } - Ok(()) - } - - pub fn fnos_logout(&self, connection_id: &str) { - if let Ok(mut m) = self.fnos_sessions.lock() { - m.remove(connection_id); - } - } - - pub fn fnos_logged_in(&self, connection_id: &str) -> bool { - self.fnos_sessions - .lock() - .map(|m| m.contains_key(connection_id)) - .unwrap_or(false) - } - - /// 上传本地文件到 NAS(走 fnOS 会话;会话缺失返回 Err)。 - pub async fn fnos_upload( - &self, - connection_id: &str, - local_path: &std::path::Path, - nas_path: &str, - ) -> Result { - let session = self - .fnos_sessions - .lock() - .map(|m| m.get(connection_id).cloned()) - .ok() - .flatten() - .ok_or_else(|| "请先登录 NAS 文件服务(设置 → 连接 → fnOS 登录)")?; - fnos::upload_file(&session, local_path, nas_path, 2).await - } - - /// 删除 NAS 文件。 - pub async fn fnos_delete(&self, connection_id: &str, nas_path: &str) -> Result<(), String> { - let session = self - .fnos_sessions - .lock() - .map(|m| m.get(connection_id).cloned()) - .ok() - .flatten() - .ok_or_else(|| "请先登录 NAS 文件服务")?; - session.delete_file(nas_path).await - } - - /// 列出 NAS 目录。 - pub async fn fnos_list(&self, connection_id: &str, path: &str) -> Result { - let session = self - .fnos_sessions - .lock() - .map(|m| m.get(connection_id).cloned()) - .ok() - .flatten() - .ok_or_else(|| "请先登录 NAS 文件服务")?; - session.list(path).await - } + // ===== WebDAV 传输(下载到飞牛 / 曲库增删)===== + // 无状态:配置由前端每次调用传入,实现在 webdav 模块,命令层直接调用。 async fn ensure_proxy(&self) -> Result { if let Ok(g) = self.proxy.lock() { diff --git a/src-tauri/src/music/feiniu/webdav.rs b/src-tauri/src/music/feiniu/webdav.rs new file mode 100644 index 0000000..2f3f995 --- /dev/null +++ b/src-tauri/src/music/feiniu/webdav.rs @@ -0,0 +1,217 @@ +//! WebDAV 客户端(无状态)——用于把音乐传输到 fnOS NAS 的曲库目录。 +//! +//! 选择 WebDAV 而非 SMB/FTP/NFS 的原因: +//! - 纯 HTTP 语义,`reqwest` 即可实现,无原生依赖(SMB 在 Rust 侧生态差); +//! - **无会话**:Basic 认证随每个请求携带,没有"重启后要重新登录"的问题; +//! - fnOS 原生支持:系统设置 → 文件服务 → WebDAV(HTTP 5005 / HTTPS 5006)。 +//! +//! fnOS 侧需要的设置(作为 UI 提示展示): +//! 1. 开启 WebDAV 服务并记下端口; +//! 2. 目标文件夹必须在「可见文件夹范围」内(团队文件夹需勾选 +//! 「允许通过文件共享协议(SMB、WebDAV、FTP)挂载到其他设备上」); +//! 3. 账号对该文件夹有读写权限。 + +use bytes::Bytes; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +/// WebDAV 连接配置(由前端每次调用时传入,后端不持有状态)。 +#[derive(Debug, Clone, Deserialize, Serialize, specta::Type)] +pub struct WebDavConfig { + /// 服务地址,如 `http://192.168.110.100:5005` + pub url: String, + pub username: String, + pub password: String, +} + +/// WebDAV 操作结果状态码相关的可读错误。 +fn status_err(action: &str, status: u16, url: &str) -> String { + match status { + 401 => format!("WebDAV 认证失败(401):请检查账号与密码"), + 403 => format!("WebDAV 拒绝访问(403):账号可能没有该目录的写入权限"), + 404 => format!("WebDAV 目标不存在(404):{url}(目录需要先在 NAS 上创建,且在 WebDAV 可见范围内)"), + 409 => format!("WebDAV 父目录不存在(409):{url}"), + _ => format!("WebDAV {action}失败(HTTP {status}):{url}"), + } +} + +fn dav_client(_cfg: &WebDavConfig) -> Result { + // NAS 通常在内网,默认禁用系统代理(走代理会连不上);Basic 认证在请求级设置 + reqwest::Client::builder() + .timeout(Duration::from_secs(600)) + .connect_timeout(Duration::from_secs(10)) + .no_proxy() + .build() + .map_err(|e| e.to_string()) +} + +/// 拼接 WebDAV 完整 URL:base + 远程路径(逐段百分号编码,保留 `/`)。 +fn dav_url(cfg: &WebDavConfig, remote_path: &str) -> String { + let base = cfg.url.trim_end_matches('/'); + let path = remote_path.trim_start_matches('/'); + let encoded: Vec = path + .split('/') + .filter(|s| !s.is_empty()) + .map(|seg| { + let mut out = String::new(); + for b in seg.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out + }) + .collect(); + format!("{base}/{}", encoded.join("/")) +} + +fn with_auth(mut rb: reqwest::RequestBuilder, cfg: &WebDavConfig) -> reqwest::RequestBuilder { + if !cfg.username.is_empty() { + rb = rb.basic_auth(&cfg.username, Some(&cfg.password)); + } + rb +} + +/// 逐级 MKCOL 创建远程目录(已存在会被忽略)。 +async fn ensure_dirs(cfg: &WebDavConfig, remote_path: &str) -> Result<(), String> { + let path = remote_path.trim_start_matches('/'); + let segs: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + // 最后一段是文件名,只建目录部分 + let dirs: Vec = segs + .iter() + .take(segs.len().saturating_sub(1)) + .fold(Vec::new(), |mut acc, s| { + let prev = acc.last().cloned().unwrap_or_default(); + acc.push(if prev.is_empty() { + s.to_string() + } else { + format!("{prev}/{s}") + }); + acc + }); + let client = dav_client(cfg)?; + for d in dirs { + let url = dav_url(cfg, &d); + let method = reqwest::Method::from_bytes(b"MKCOL").expect("MKCOL 是合法的 HTTP 方法"); + let resp = with_auth(client.request(method, &url), cfg) + .send() + .await + .map_err(|e| format!("WebDAV MKCOL 失败: {e}"))?; + // 201 创建成功;405 已存在;其余只记不阻断(权限问题会在 PUT 时暴露) + let code = resp.status().as_u16(); + if code != 201 && code != 405 && code != 301 && code != 409 && code != 200 { + return Err(status_err("创建目录", code, &url)); + } + } + Ok(()) +} + +/// 测试连接:PROPFIND 目标目录(Depth 0)。成功返回目录确认信息。 +pub async fn test(cfg: &WebDavConfig, dir: &str) -> Result { + let url = dav_url(cfg, dir); + let client = dav_client(cfg)?; + let resp = with_auth( + client + .request(reqwest::Method::from_bytes(b"PROPFIND").unwrap(), &url) + .header("Depth", "0") + .body(String::new()), + cfg, + ) + .send() + .await + .map_err(|e| format!("无法连接 WebDAV 服务:{e}(请确认地址与端口)"))?; + let code = resp.status().as_u16(); + match code { + 207 | 200 => Ok(format!("连接成功:{url}")), + 404 => Err(format!( + "WebDAV 服务可达,但目标目录不存在(404):{url}\n请先在 NAS 上创建该目录,并确认它在 WebDAV「可见文件夹范围」内" + )), + 401 => Err("WebDAV 认证失败(401):请检查账号与密码".to_string()), + 403 => Err("WebDAV 拒绝访问(403):账号可能没有该目录的读取权限".to_string()), + _ => Err(status_err("连接", code, &url)), + } +} + +/// 上传本地文件(流式读取,不整载内存)。返回远程路径。 +pub async fn upload_file( + cfg: &WebDavConfig, + local_path: &std::path::Path, + remote_path: &str, +) -> Result { + ensure_dirs(cfg, remote_path).await?; + let file = tokio::fs::File::open(local_path) + .await + .map_err(|e| format!("读取本地文件失败: {e}"))?; + // 128KB 分块流:用 unfold 把 AsyncRead 转 Stream,避免引入 tokio-util + let stream = futures_util::stream::unfold(file, |mut f| async move { + use tokio::io::AsyncReadExt; + let mut buf = vec![0u8; 128 * 1024]; + match f.read(&mut buf).await { + Ok(0) => None, + Ok(n) => Some((Ok::(Bytes::copy_from_slice(&buf[..n])), f)), + Err(e) => Some((Err(e), f)), + } + }); + let url = dav_url(cfg, remote_path); + let client = dav_client(cfg)?; + let resp = with_auth(client.put(&url), cfg) + .header("Content-Type", mime_of(remote_path)) + .body(reqwest::Body::wrap_stream(stream)) + .send() + .await + .map_err(|e| format!("WebDAV 上传失败: {e}"))?; + let code = resp.status().as_u16(); + if !(200..300).contains(&code) { + return Err(status_err("上传", code, &url)); + } + Ok(remote_path.to_string()) +} + +/// 删除远程文件(404 视为已删除)。 +pub async fn delete(cfg: &WebDavConfig, remote_path: &str) -> Result<(), String> { + let url = dav_url(cfg, remote_path); + let client = dav_client(cfg)?; + let resp = with_auth(client.delete(&url), cfg) + .send() + .await + .map_err(|e| format!("WebDAV 删除失败: {e}"))?; + let code = resp.status().as_u16(); + if code == 404 { + return Ok(()); + } + if !(200..300).contains(&code) { + return Err(status_err("删除", code, &url)); + } + Ok(()) +} + +pub fn mime_of(name: &str) -> &'static str { + let ext = name.rsplit('.').next().unwrap_or("").to_lowercase(); + match ext.as_str() { + "mp3" => "audio/mpeg", + "flac" => "audio/flac", + "wav" => "audio/wav", + "m4a" => "audio/mp4", + "aac" => "audio/aac", + "ogg" => "audio/ogg", + "ape" => "audio/x-ape", + _ => "application/octet-stream", + } +} + +/// 保留给调用方校验配置完整性。 +pub fn validate(cfg: &WebDavConfig, dir: &str) -> Result<(), String> { + if cfg.url.trim().is_empty() { + return Err("请先填写 WebDAV 服务地址(如 http://192.168.110.100:5005)".into()); + } + if cfg.username.trim().is_empty() { + return Err("请先填写 WebDAV 账号".into()); + } + if dir.trim().is_empty() { + return Err("请先填写 WebDAV 曲库目标目录".into()); + } + Ok(()) +} diff --git a/src-tauri/src/music/mod.rs b/src-tauri/src/music/mod.rs index f47b8d9..a13ddaa 100644 --- a/src-tauri/src/music/mod.rs +++ b/src-tauri/src/music/mod.rs @@ -24,14 +24,13 @@ pub use feiniu::{extract_fn_id, normalize_base_url, resolve_base_url, Feiniu, Fe pub use commands::{ feiniu_activate_connection, feiniu_cache_clear, feiniu_cache_fetch, feiniu_cache_status, - feiniu_delete_connection, feiniu_fnconnect_resolve, feiniu_fnos_delete, feiniu_fnos_list, - feiniu_fnos_login, feiniu_fnos_logout, feiniu_fnos_status, feiniu_fnos_upload, - feiniu_get_config, feiniu_list_connections, feiniu_list_tracks, feiniu_login, feiniu_logout, - feiniu_lyric, feiniu_media_prefix, feiniu_save_connection, feiniu_scan_local, - feiniu_test_connection, music_cancel_runtime_install, music_download, music_download_cancel, - music_env_status, music_get_settings, music_get_sources, music_install_runtime, - music_parse_playlist, music_ping, music_resolve, music_save_settings, music_search, - music_stop_bridge, + feiniu_delete_connection, feiniu_delete_local, feiniu_fnconnect_resolve, feiniu_get_config, + feiniu_list_connections, feiniu_list_tracks, feiniu_login, feiniu_logout, feiniu_lyric, + feiniu_media_prefix, feiniu_save_connection, feiniu_scan_local, feiniu_test_connection, + music_cancel_runtime_install, music_download, music_download_cancel, music_env_status, + music_get_settings, music_get_sources, music_install_runtime, music_parse_playlist, music_ping, + music_resolve, music_save_settings, music_search, music_stop_bridge, webdav_delete, + webdav_get_secret, webdav_save_secret, webdav_test, webdav_upload, }; use serde::{Deserialize, Serialize}; diff --git a/src/App.vue b/src/App.vue index 0b09f67..8c9a15c 100644 --- a/src/App.vue +++ b/src/App.vue @@ -5,8 +5,10 @@ import { getCurrentWindow } from '@tauri-apps/api/window' import TitleBar from '@/components/layout/TitleBar.vue' import Sidebar from '@/components/layout/Sidebar.vue' import ModuleContainer from '@/components/layout/ModuleContainer.vue' +import NowPlayingDialog from '@/components/layout/NowPlayingDialog.vue' import { Toaster } from '@/components/ui/sonner' import { useAppStore } from '@/stores/appStore' +import { useFeiniuStore } from '@/stores/feiniuStore' import { useScreenshotStore } from '@/stores/screenshotStore' import { useQuickPanelStore } from '@/stores/quickpanelStore' import { useMonitorStore } from '@/stores/monitorStore' @@ -19,6 +21,7 @@ import { EVENTS, STORAGE_KEYS, WINDOWS } from '@/lib/constants' import { commands } from '@/lib/bindings' const appStore = useAppStore() +const feiniuStore = useFeiniuStore() const screenshotStore = useScreenshotStore() const quickpanelStore = useQuickPanelStore() const monitorStore = useMonitorStore() @@ -197,6 +200,9 @@ const resolveDefaultModule = (): string => { onMounted(async () => { await appStore.init().catch(e => console.error('App init error:', e)) + // 全局播放器:恢复队列/音量/播放模式等本地状态(幂等,音乐模块内会再次调用但不会重复执行) + feiniuStore.init().catch(e => console.error('播放器初始化失败:', e)) + // 初始化监控 store:订阅后端 monitor-data / monitor-network 等事件, // 使 OSD 窗口在应用启动后即可接收数据流,不依赖用户手动打开监控模块。 // init() 幂等:MonitorModule 挂载时再次调用不会重复订阅。 @@ -283,7 +289,7 @@ onUnmounted(() => {
-
+
{
+ diff --git a/src/components/common/ScrubBar.vue b/src/components/common/ScrubBar.vue new file mode 100644 index 0000000..fe78dca --- /dev/null +++ b/src/components/common/ScrubBar.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/src/components/common/SegmentedNav.vue b/src/components/common/SegmentedNav.vue new file mode 100644 index 0000000..3e758cf --- /dev/null +++ b/src/components/common/SegmentedNav.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/components/layout/NowPlayingDialog.vue b/src/components/layout/NowPlayingDialog.vue new file mode 100644 index 0000000..526aa73 --- /dev/null +++ b/src/components/layout/NowPlayingDialog.vue @@ -0,0 +1,319 @@ + + + diff --git a/src/components/layout/TitleBar.vue b/src/components/layout/TitleBar.vue index aee1046..4eaf406 100644 --- a/src/components/layout/TitleBar.vue +++ b/src/components/layout/TitleBar.vue @@ -2,6 +2,7 @@ import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue' import { Search, Settings, ChevronRight, ArrowUp, Check, Loader2 } from '@lucide/vue' import { Input } from '@/components/ui/input' +import TitleBarMusic from '@/components/layout/TitleBarMusic.vue' import { invoke } from '@tauri-apps/api/core' import { getCurrentWindow } from '@tauri-apps/api/window' import { useSearchStore, type SearchItem } from '@/stores/searchStore' @@ -363,6 +364,11 @@ const handleBlur = () => { 保存设置 + +
+ +
+
+import { computed, ref, watch } from 'vue' +import { toast } from 'vue-sonner' +import { + Disc3, + ListMusic, + Maximize2, + MicVocal, + Pause, + Play, + Repeat, + Repeat1, + Shuffle, + SkipBack, + SkipForward, + Volume2, + VolumeX +} from '@lucide/vue' +import { useFeiniuStore } from '@/stores/feiniuStore' +import ScrubBar from '@/components/common/ScrubBar.vue' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' + +const store = useFeiniuStore() + +const open = ref(false) + +const coverUrl = computed(() => { + const t = store.nowPlaying + if (!t) return '' + if (t.coverUrl) return t.coverUrl + if (t.source === 'feiniu' && t.coverId && store.mediaPrefix) { + return `${store.mediaPrefix}/cover?coverId=${encodeURIComponent(t.coverId)}&size=96` + } + return '' +}) +const bigCoverUrl = computed(() => { + const t = store.nowPlaying + if (!t) return '' + if (t.coverUrl) return t.coverUrl + if (t.source === 'feiniu' && t.coverId && store.mediaPrefix) { + return `${store.mediaPrefix}/cover?coverId=${encodeURIComponent(t.coverId)}&size=320` + } + return '' +}) +const coverFailed = ref(false) +watch(coverUrl, () => (coverFailed.value = false)) + +/** 是否已播放过(未播放过时不展示歌名,只留唱片图标) */ +const hasTrack = computed(() => !!store.nowPlaying) +const title = computed(() => store.nowPlaying?.title ?? '') +const subtitle = computed(() => { + const t = store.nowPlaying + if (!t) return '' + return t.artistNames || (t.source === 'local' ? '本地文件' : '') +}) +const isPreview = computed(() => store.nowPlaying?.source === 'preview') + +const modeIcon = computed(() => { + if (store.playMode === 'loopOne') return Repeat1 + if (store.playMode === 'shuffle') return Shuffle + return Repeat +}) +const modeLabel = computed(() => + store.playMode === 'loopAll' ? '列表循环' : store.playMode === 'loopOne' ? '单曲循环' : '随机播放' +) +const volumePercent = computed(() => (store.muted ? 0 : store.volume)) + +function onSeek(ratio: number) { + store.seek(ratio * store.duration) +} + +function openLyrics(tab: 'lyric' | 'queue') { + store.nowPlayingTab = tab + store.nowPlayingOpen = true + open.value = false +} + +// 播放失败统一在这里提示(store 只记录原因) +watch( + () => store.playError, + (msg) => { + if (!msg) return + toast.error(msg) + store.clearPlayError() + } +) + + + + + diff --git a/src/modules/music/MusicModule.vue b/src/modules/music/MusicModule.vue index 7f685ec..b11c716 100644 --- a/src/modules/music/MusicModule.vue +++ b/src/modules/music/MusicModule.vue @@ -3,8 +3,37 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { toast } from 'vue-sonner' import { open as openDialog } from '@tauri-apps/plugin-dialog' import { appDataDir } from '@tauri-apps/api/path' -import { Search, Loader2, Play, Square, RotateCw, FolderOpen, CircleAlert, Info, Download, X, ListMusic, Music2, Settings as SettingsIcon, History, ChevronDown, ChevronUp, Trash2, Database, Upload } from '@lucide/vue' -import { useMusicStore, type MusicDownloadTask, type MusicDownloadSongState, type MusicQuality, type MusicSong } from '@/stores/musicStore' +import { + ChevronDown, + ChevronUp, + CircleAlert, + CloudUpload, + Download, + FolderOpen, + HardDriveDownload, + History, + Info, + Library, + Link2, + ListMusic, + Loader2, + Music2, + Play, + RotateCw, + Search, + Settings as SettingsIcon, + Square, + Trash2, + Upload, + X +} from '@lucide/vue' +import { + useMusicStore, + type MusicDownloadTask, + type MusicDownloadSongState, + type MusicQuality, + type MusicSong +} from '@/stores/musicStore' import { useProxyStore } from '@/stores/proxyStore' import { useFeiniuStore } from '@/stores/feiniuStore' import { useModuleTabs } from '@/lib/use-module-tabs' @@ -13,13 +42,11 @@ import { commands } from '@/lib/bindings' import { sourceName, PICKER_SOURCES } from './sources' import SourcePicker from './SourcePicker.vue' import MyMusicLibrary from './components/MyMusicLibrary.vue' -import PlayerBar from './components/PlayerBar.vue' -import PlayerFull from './components/PlayerFull.vue' import ConnectionsSettings from './components/settings/ConnectionsSettings.vue' import CacheSettings from './components/settings/CacheSettings.vue' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Dialog, DialogContent, @@ -35,9 +62,9 @@ import { Separator } from '@/components/ui/separator' import { Progress } from '@/components/ui/progress' import { ScrollArea } from '@/components/ui/scroll-area' import { Checkbox } from '@/components/ui/checkbox' -import { Empty } from '@/components/ui/empty' import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { Empty, EmptyDescription, EmptyMedia, EmptyTitle } from '@/components/ui/empty' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' const store = useMusicStore() @@ -45,21 +72,24 @@ const proxyStore = useProxyStore() const feiniu = useFeiniuStore() const logger = createLogger('music') -// ===== 主 Tab 状态 ===== +// ===== 模块导航 ===== const activeTab = ref('mymusic') const tabsListRef = useModuleTabs('music', activeTab, [ - { value: 'mymusic', label: '我的音乐' }, + { value: 'mymusic', label: '曲库' }, { value: 'discover', label: '发现音乐' }, { value: 'tasks', label: '下载任务' }, { value: 'settings', label: '设置' } ]) -// ===== 发现音乐下载目标(本地 / 飞牛库) ===== +// ===== 发现音乐:下载目标 ===== const downloadTarget = ref<'local' | 'feiniu'>('local') -/** 本次下载完成后需上传到飞牛 */ -let pendingUploadOnDone = false +/** 需要在完成后上传到飞牛的 musicdl 任务 id */ +const uploadOnDoneTaskIds = ref>(new Set()) +/** 已触发过上传的任务 id(每个任务最多触发一次,避免重复全量上传) */ +const uploadedTaskIds = new Set() +/** 初始化完成前不触发自动上传(避免应用启动时把历史任务重复上传一遍) */ +let uploadsArmed = false -/** 把最近下载的本地音频批量上传到飞牛曲库 */ async function uploadRecentToFeiniu() { try { const { total, ok } = await feiniu.uploadRecentToFeiniu(10) @@ -70,28 +100,72 @@ async function uploadRecentToFeiniu() { } } -// 下载任务完成 → 若目标是飞牛库或开启了自动上传 → 自动上传 -watch( - () => store.tasks.map((t) => `${t.taskId}:${t.status}`).join('|'), - () => { - const doneNow = store.tasks.some((t) => t.status === 'done' && (pendingUploadOnDone || feiniu.autoUpload)) - if (!doneNow) return - pendingUploadOnDone = false - uploadRecentToFeiniu() +/** 精确到任务的上传触发:不再让残留标志位污染后续无关任务 */ +function resolvePendingUploads() { + if (!uploadsArmed) return + // 上一轮上传尚未结束时不叠加并发上传(等下一次任务状态变化重试) + if (feiniu.uploading) return + const alive = new Set(store.tasks.map((t) => t.taskId)) + for (const id of [...uploadedTaskIds]) if (!alive.has(id)) uploadedTaskIds.delete(id) + for (const t of store.tasks) { + if (t.status !== 'done') continue + // 「下载到飞牛」的临时任务:上传 + 清理本地副本 + 移除记录,不走常规自动上传 + if (tempTaskIds.has(t.taskId)) { + tempTaskIds.delete(t.taskId) + void finishTempUpload(t.taskId) + continue + } + const wanted = uploadOnDoneTaskIds.value.has(t.taskId) || feiniu.autoUpload + if (!wanted || uploadedTaskIds.has(t.taskId)) continue + uploadedTaskIds.add(t.taskId) + if (uploadOnDoneTaskIds.value.has(t.taskId)) uploadOnDoneTaskIds.value.delete(t.taskId) + void uploadRecentToFeiniu() } -) + // 临时任务下载失败/被取消:清理行按钮 spinner 与临时标记,避免永久转圈 + for (const t of store.tasks) { + if (t.status === 'done' || t.status === 'downloading' || t.status === 'cancelling') continue + if (!tempTaskIds.has(t.taskId)) continue + tempTaskIds.delete(t.taskId) + tempTaskStarts.delete(t.taskId) + const key = tempTaskKeys.get(t.taskId) + tempTaskKeys.delete(t.taskId) + if (key) { + const next = new Set(feiniuUploadingKeys.value) + next.delete(key) + feiniuUploadingKeys.value = next + } + toast.error(`下载到飞牛失败:${t.errorMessage || '任务未完成,文件保留在本地下载目录'}`) + } + // 临时任务记录被手动删除(下载中移除):同样释放行按钮 spinner + for (const id of [...tempTaskIds]) { + if (alive.has(id)) continue + tempTaskIds.delete(id) + tempTaskStarts.delete(id) + const key = tempTaskKeys.get(id) + tempTaskKeys.delete(id) + if (key) { + const next = new Set(feiniuUploadingKeys.value) + next.delete(key) + feiniuUploadingKeys.value = next + } + } +} +watch(() => store.tasks.map((t) => `${t.taskId}:${t.status}`).join('|'), resolvePendingUploads) +// ===== 生命周期 ===== +const appDataPath = ref('') onMounted(async () => { loadHistory() try { await store.init() } catch (e) { - logger.error(`音乐模块初始化失败: ${e}`) - } - // musicdl 未安装时不拉起桥接(避免常驻空转的 Python 进程),安装完成后会手动加载 - if (store.env?.musicdlInstalled) { - store.loadSources().catch(() => {}) + logger.error(`音乐模块初始化失败:${e}`) } + // musicdl 未安装时不拉起桥接(避免常驻空转的 Python 进程) + if (store.env?.musicdlInstalled) store.loadSources().catch(() => {}) + // 历史任务视为已处理:应用启动时不重传上一轮已上传过的文件 + for (const t of store.tasks) if (t.status === 'done') uploadedTaskIds.add(t.taskId) + uploadsArmed = true appDataDir() .then((p) => (appDataPath.value = p)) .catch(() => {}) @@ -104,14 +178,13 @@ const sourceOptions = computed(() => { const list = store.availableSources.length ? PICKER_SOURCES.filter((s) => avail.has(s)) : [...PICKER_SOURCES] - // 保留已选但不在常用列表中的源 const selected = store.settings?.sources ?? [] const extra = selected.filter((s) => !list.includes(s)) return [...list, ...extra] }) async function doSearch() { - if (store.searching) return // 重入保护:回车 + 按钮点击不重复发起 + if (store.searching) return const kw = keyword.value.trim() if (!kw) return const sources = store.settings?.sources ?? [] @@ -126,19 +199,16 @@ async function doSearch() { await store.search(kw, sources) pushHistory(kw) } catch { - // 错误详情由搜索结果区的 Empty 状态展示,不再重复 toast + // 错误详情由结果区空态展示 } } -function onKeywordEnter() { - doSearch() -} - -// ===== 搜索历史(localStorage,最多 10 条) ===== +// ===== 搜索历史 ===== const HISTORY_KEY = 'thing.music.searchHistory' const HISTORY_MAX = 10 const searchHistory = ref([]) const historyOpen = ref(false) +const searchBoxRef = ref(null) function loadHistory() { try { @@ -154,7 +224,7 @@ function pushHistory(kw: string) { try { localStorage.setItem(HISTORY_KEY, JSON.stringify(next)) } catch { - // localStorage 不可用时静默降级(仅影响历史) + /* 忽略 */ } } function clearHistory() { @@ -162,10 +232,9 @@ function clearHistory() { try { localStorage.removeItem(HISTORY_KEY) } catch { - // 忽略 + /* 忽略 */ } } -/** 按当前输入过滤的历史项(空输入显示全部) */ const filteredHistory = computed(() => { const kw = keyword.value.trim().toLowerCase() if (!kw) return searchHistory.value @@ -173,14 +242,22 @@ const filteredHistory = computed(() => { }) function pickHistory(h: string) { keyword.value = h + historyOpen.value = false doSearch() } +/** 焦点仍在搜索框/历史浮层内时保持展开,键盘 Tab 才能选中历史项 */ +function onHistoryFocusOut(e: FocusEvent) { + const next = e.relatedTarget as Node | null + if (next && searchBoxRef.value?.contains(next)) return + historyOpen.value = false +} // ===== 歌单解析 ===== const playlistUrl = ref('') const parsingPlaylist = ref(false) +const playlistLinkOpen = ref(false) async function doParsePlaylist() { - if (parsingPlaylist.value) return // 重入保护 + if (parsingPlaylist.value) return const url = playlistUrl.value.trim() if (!url) return const sources = store.settings?.sources ?? [] @@ -194,6 +271,8 @@ async function doParsePlaylist() { try { const n = await store.parsePlaylist(url, sources) toast.success(`歌单解析完成,共 ${n} 首`) + playlistLinkOpen.value = false + playlistUrl.value = '' } catch { toast.error(`歌单解析失败:${store.parseError || '未知错误'}`) } finally { @@ -201,7 +280,7 @@ async function doParsePlaylist() { } } -// 扁平化结果列表(按源返回顺序) +// ===== 结果扁平化 / 筛选 / 增量渲染 ===== interface FlatSong { song: MusicSong source: string @@ -216,7 +295,6 @@ const flatResults = computed(() => { return out }) -// ===== 源筛选 + 音质筛选 ===== const LOSSLESS_EXTS = new Set(['flac', 'wav', 'alac', 'ape', 'wv', 'tta', 'dsf', 'dff']) const sourceFilter = ref('all') const qualityFilter = ref<'all' | 'lossless' | 'mp3'>('all') @@ -227,42 +305,42 @@ const filteredResults = computed(() => { return flatResults.value.filter((f) => { if (src !== 'all' && f.source !== src) return false if (q === 'all') return true - // 音质筛选:优先 ext(已解析歌),懒解析歌用 lossless 提示 - // (酷狗 SQFileSize / 网易 privilege.maxbr / QQ 批量详情补查推断) - const ext = (f.song.ext ?? '').toLowerCase() - // qualities 为空数组时 some() 返回 false,需走 lossless 兜底(网易/酷狗提取器可能返回 []) - const lossless = ext - ? LOSSLESS_EXTS.has(ext) - : f.song.qualities?.length - ? f.song.qualities.some((q) => q.lossless === true) - : f.song.lossless === true + const lossless = isLossless(f.song) return q === 'lossless' ? lossless : !lossless }) }) -// ===== 分组展示(源分组头 + 歌曲行) ===== +/** 分组后的展示项(源分组头 + 歌曲行) */ interface DisplayItem { type: 'header' | 'song' source?: string count?: number + keys?: string[] + allSelected?: boolean song?: FlatSong } const displayItems = computed(() => { - const counts = new Map() - for (const f of filteredResults.value) counts.set(f.source, (counts.get(f.source) ?? 0) + 1) const out: DisplayItem[] = [] - let lastSource = '' + const bySource = new Map() for (const f of filteredResults.value) { - if (f.source !== lastSource) { - lastSource = f.source - out.push({ type: 'header', source: f.source, count: counts.get(f.source) ?? 0 }) - } - out.push({ type: 'song', song: f }) + const arr = bySource.get(f.source) + if (arr) arr.push(f) + else bySource.set(f.source, [f]) + } + for (const [source, list] of bySource) { + const keys = list.map((f) => f.key) + out.push({ + type: 'header', + source, + count: list.length, + keys, + allSelected: keys.every((k) => selectedKeys.value.has(k)) + }) + for (const f of list) out.push({ type: 'song', song: f }) } return out }) -// ===== 增量渲染(大歌单/多源搜索时避免一次性挂载上千行 DOM) ===== const PAGE_SIZE = 80 const visibleCount = ref(PAGE_SIZE) watch(filteredResults, () => { @@ -278,7 +356,6 @@ onMounted(() => { (entries) => { if (entries.some((e) => e.isIntersecting)) { visibleCount.value += PAGE_SIZE - // DOM 增长后 sentinel 可能仍在视口内,重新挂观察器以再次触发 nextTick(() => { const el = loadMoreRef.value if (el && loadMoreObserver) { @@ -300,85 +377,67 @@ onMounted(() => { { immediate: true } ) }) -onBeforeUnmount(() => { - loadMoreObserver?.disconnect() -}) +onBeforeUnmount(() => loadMoreObserver?.disconnect()) -// ===== 组级全选 ===== -function groupKeys(source: string): string[] { - return filteredResults.value.filter((f) => f.source === source).map((f) => f.key) -} -function groupAllSelected(source: string): boolean { - const keys = groupKeys(source) - return keys.length > 0 && keys.every((k) => selectedKeys.value.has(k)) -} -function toggleSelectGroup(source: string) { - const keys = groupKeys(source) +function toggleSelectGroup(item: DisplayItem) { + const keys = item.keys ?? [] const next = new Set(selectedKeys.value) - if (keys.every((k) => next.has(k))) keys.forEach((k) => next.delete(k)) + if (item.allSelected) keys.forEach((k) => next.delete(k)) else keys.forEach((k) => next.add(k)) selectedKeys.value = next } -/** bitrate 展示:≥1000 视为 Mbps 级,否则 kbps */ +// ===== 音质展示(降噪为单枚中性徽标 + 弹层详情) ===== +const TIER_ORDER = ['Hi-Res', '无损', 'FLAC', '320K', '192K', '128K', '96K', '标准'] +function tierRank(label?: string | null) { + const i = TIER_ORDER.indexOf((label ?? '').trim()) + return i === -1 ? 99 : i +} +/** 该曲可用的最高音质档位 */ +function bestTier(song: MusicSong): MusicQuality | null { + const list = song.qualities + if (!list || list.length === 0) return null + return [...list].sort((a, b) => tierRank(a.label) - tierRank(b.label))[0] +} +function isLossless(song: MusicSong): boolean { + const ext = (song.ext ?? '').toLowerCase() + if (ext) return LOSSLESS_EXTS.has(ext) + if (song.qualities?.length) return song.qualities.some((q) => q.lossless === true) + return song.lossless === true +} +/** 列表里展示的一行音质摘要:优先档位 label,其次 ext / 无损标记 */ +function qualitySummary(song: MusicSong): string { + const best = bestTier(song) + if (best?.label) return best.ext ? `${best.label} · ${best.ext.toUpperCase()}` : best.label + if (song.ext) return song.ext.toUpperCase() + if (isLossless(song)) return '无损' + return '' +} const formatBitrate = (b?: number | null) => { if (!b || b <= 0) return '' - return b >= 1000 ? `${(b / 1000).toFixed(1)}Mbps` : `${b}kbps` + return b >= 1000 ? `${(b / 1000).toFixed(1)} Mbps` : `${b} kbps` } - -// ===== 多音质档位展示 ===== -// 行内直接展示的档位数上限,超出部分收进 "+N" 悬浮弹层 -const maxTiers = 2 -/** 取前 maxTiers 个档位用于行内徽标;缺省返回空数组 */ -const visibleTiers = (t?: MusicQuality[] | null) => (t && t.length ? t.slice(0, maxTiers) : []) -/** 按 label 档位返回徽标配色(同一档位恒定同色,搜索页与任务页共用) - * Hi-Res=金 · 无损/FLAC=紫 · 320K=蓝 · 192K=橙 · 128K 及以下=灰 · 最高/未知=主题色 */ -const qualityLabelClass = (label: string) => { - const l = (label ?? '').trim() - if (l === 'Hi-Res') return 'bg-amber-500/15 text-amber-700 border-amber-500/40 dark:text-amber-400' - if (l === '无损' || l === 'FLAC') return 'bg-purple-500/15 text-purple-700 border-purple-500/30 dark:text-purple-400' - if (l === '320K') return 'bg-sky-500/15 text-sky-700 border-sky-500/30 dark:text-sky-400' - if (l === '192K') return 'bg-orange-500/15 text-orange-600 border-orange-500/30 dark:text-orange-400' - if (l === '128K' || l === '96K' || l === '标准') return 'bg-zinc-500/15 text-zinc-600 border-zinc-500/30 dark:text-zinc-400' - return 'bg-primary/10 text-primary border-primary/30' -} -/** 不同音质的徽标配色(按 label 档位统一区分;无 label 时按 ext/码率兜底) */ -const qualityTagClass = (q: MusicQuality) => { - const label = (q.label ?? '').trim() - if (label) return qualityLabelClass(label) - const ext = (q.ext ?? '').toLowerCase() - const br = q.bitrate ?? 0 - if (LOSSLESS_EXTS.has(ext)) return 'bg-purple-500/15 text-purple-700 border-purple-500/30 dark:text-purple-400' - if (br >= 320) return 'bg-sky-500/15 text-sky-700 border-sky-500/30 dark:text-sky-400' - if (br >= 192) return 'bg-orange-500/15 text-orange-600 border-orange-500/30 dark:text-orange-400' - return 'bg-zinc-500/15 text-zinc-600 border-zinc-500/30 dark:text-zinc-400' -} -/** 播放时长:分:秒(mm:ss) */ const formatDuration = (sec?: number | null, fallback?: string | null) => { if (sec && sec > 0) { const m = Math.floor(sec / 60) const s = Math.floor(sec % 60) return `${m}:${String(s).padStart(2, '0')}` } - return fallback || '' + return fallback || '--:--' } -// ===== 在线试听 ===== -const audioEl = ref(null) -const playingKey = ref('') -// 懒解析中(试听按钮点击 → 解析真实链接 → 播放)的歌曲 key +// ===== 在线试听(走全局播放内核,与播放器共用同一 audio,天然互斥) ===== const resolvingKeys = ref>(new Set()) +const previewKey = computed(() => feiniu.preview?.guid ?? '') -async function togglePlay(song: MusicSong, source: string, index: number) { +async function togglePreview(song: MusicSong, source: string, index: number) { const key = `${source}|${index}` - if (playingKey.value === key) { - audioEl.value?.pause() - playingKey.value = '' + if (previewKey.value === key) { + feiniu.stopPreview() return } let target = song if (!target.downloadUrl) { - // 懒解析:先解析真实下载链接再试听 if (!target.rawSearch || resolvingKeys.value.has(key)) return resolvingKeys.value.add(key) try { @@ -395,26 +454,137 @@ async function togglePlay(song: MusicSong, source: string, index: number) { resolvingKeys.value.delete(key) } } - playingKey.value = key - if (audioEl.value) { - audioEl.value.src = target.downloadUrl! - audioEl.value.play().catch(() => { - toast.error('试听失败:该链接无法播放') - playingKey.value = '' - }) - } -} -function onAudioEnded() { - playingKey.value = '' + feiniu.playPreview({ + source: 'preview', + guid: key, + title: target.songName || '未知歌名', + artistNames: target.singers || '', + album: target.album || undefined, + coverUrl: target.coverUrl || undefined, + ext: target.ext || undefined, + url: target.downloadUrl! + }) } // ===== 下载 ===== -/** 代理联动:mihomo mixed 端口 → 桥接 requests_overrides.proxies */ const proxyUrl = computed(() => { const port = proxyStore.settings?.mixedPort return port ? `http://127.0.0.1:${port}` : '' }) +/** 每行「下载到飞牛」进行中的 key */ +const feiniuUploadingKeys = ref>(new Set()) + +/** WebDAV 测试连接进行中 */ +const webdavTesting = ref(false) +/** WebDAV 已配置时默认收起凭据表单(账号/密码隐藏),点「修改配置」展开 */ +const webdavEditing = ref(false) + +async function testWebdav() { + if (webdavTesting.value) return + webdavTesting.value = true + try { + const msg = await feiniu.testWebdav() + toast.success(msg) + } catch (e) { + toast.error(String(e)) + } finally { + webdavTesting.value = false + } +} + +/** + * 单曲「下载到飞牛」:与「下载完成后自动上传」同一条链路—— + * 正常走 musicdl 下载(含引擎侧解析、代理、音质设置),任务完成后用 WebDAV + * 上传本地文件并删除本地副本(含同名歌词/封面),最后移除任务记录。 + */ +const tempTaskIds = new Set() +/** taskId → 任务创建时刻(秒),上传扫描的下界,避免误传窗口外的无关文件 */ +const tempTaskStarts = new Map() +/** taskId → 行按钮 spinner key */ +const tempTaskKeys = new Map() + +async function downloadToFeiniu(song: MusicSong, source: string, index: number) { + const key = `${source}|${index}` + if (feiniuUploadingKeys.value.has(key)) return + if (!feiniu.webdavReady) { + toast.error('请先在「设置 → 存储与上传」完成 WebDAV 配置(地址 / 账号 / 密码 / 目标目录)') + return + } + const s = store.settings + if (!s) return + // Rust 引擎的任务在「下载器」模块管理,无法回传飞牛曲库 + if (s.downloadEngine === 'rust') { + toast.error( + 'Rust 引擎的下载任务在「下载器」模块中管理,无法自动回传飞牛曲库。请改用 musicdl 引擎。' + ) + return + } + const next = new Set(feiniuUploadingKeys.value) + next.add(key) + feiniuUploadingKeys.value = next + try { + const { engine, skipped, taskId } = await store.startDownload([song], { + savedir: s.savedir, + lyric: s.lyricDownload, + cover: s.coverDownload, + proxyUrl: s.useProxy ? proxyUrl.value : '', + engine: s.downloadEngine, + maxConcurrent: s.maxConcurrent, + quality: s.defaultDownloadQuality ?? '' + }) + if (engine === 'rust' || skipped > 0) { + toast.error(skipped > 0 ? '该歌曲没有可用的下载源' : 'Rust 引擎无法回传飞牛曲库') + return + } + if (taskId) { + tempTaskIds.add(taskId) + tempTaskStarts.set(taskId, Date.now() / 1000 - 5) + tempTaskKeys.set(taskId, key) + // 任务记录在上传完成后移除;期间不出现在任务列表的常规上传流程里 + } + } catch (e) { + toast.error(`启动下载失败:${e}`) + const done = new Set(feiniuUploadingKeys.value) + done.delete(key) + feiniuUploadingKeys.value = done + } +} + +/** 临时任务的收尾:上传 → 删本地副本 → 移除任务记录。失败时保留任务记录供排查。 */ +async function finishTempUpload(taskId: string) { + const startedAt = tempTaskStarts.get(taskId) ?? Date.now() / 1000 - 600 + tempTaskStarts.delete(taskId) + const key = tempTaskKeys.get(taskId) + tempTaskKeys.delete(taskId) + try { + const { total, ok, deleted } = await feiniu.uploadRecentToFeiniu(0, { + since: startedAt, + deleteLocal: true + }) + const task = store.tasks.find((t) => t.taskId === taskId) + const name = task?.songs?.[0]?.songName || '' + if (ok > 0) { + toast.success(`已保存到飞牛曲库${name ? `:${name}` : ''}`) + store.removeTask(taskId) + if (deleted < total) toast.info(`${total - deleted} 个本地副本未能删除(可能被占用)`) + } else if (total === 0) { + toast.error('下载完成,但未在下载目录找到新文件——请检查下载目录设置') + } else { + throw new Error('上传失败') + } + } catch (e) { + // 失败:保留本地文件与任务记录,用户可在任务列表重试或排查 + toast.error(`上传到飞牛失败:${e}`) + } finally { + if (key) { + const done = new Set(feiniuUploadingKeys.value) + done.delete(key) + feiniuUploadingKeys.value = done + } + } +} + const selectedKeys = ref>(new Set()) const selectedSongs = computed(() => filteredResults.value.filter((f) => selectedKeys.value.has(f.key)).map((f) => f.song) @@ -425,29 +595,29 @@ function toggleSelect(key: string) { else next.add(key) selectedKeys.value = next } - -// ===== 全选(作用于当前音质筛选后的可见结果) ===== const allSelected = computed( () => filteredResults.value.length > 0 && filteredResults.value.every((f) => selectedKeys.value.has(f.key)) ) function toggleSelectAll() { - selectedKeys.value = allSelected.value - ? new Set() - : new Set(filteredResults.value.map((f) => f.key)) + selectedKeys.value = allSelected.value ? new Set() : new Set(filteredResults.value.map((f) => f.key)) } -/** 用系统资源管理器打开目录(复用下载器模块命令) */ function openDir(path?: string) { const dir = path || store.settings?.savedir if (!dir) return commands.downloaderOpenDir(dir).catch((e) => toast.error(`打开目录失败:${e}`)) } -async function doDownload(songs: MusicSong[], quality = '') { +async function doDownload(songs: MusicSong[], quality = '', clearSelection = false) { const s = store.settings - if (!s) return + if (!s || songs.length === 0) return + // Rust 引擎的任务在「下载器」模块管理,无法回传飞牛曲库 + if (downloadTarget.value === 'feiniu' && s.downloadEngine === 'rust') { + toast.error('Rust 引擎的下载任务在「下载器」模块中管理,无法自动回传飞牛曲库。请改用 musicdl 引擎,或把下载目标改为本地目录。') + return + } try { - const skipped = await store.startDownload(songs, { + const { engine, skipped, taskId } = await store.startDownload(songs, { savedir: s.savedir, lyric: s.lyricDownload, cover: s.coverDownload, @@ -457,59 +627,62 @@ async function doDownload(songs: MusicSong[], quality = '') { quality }) if (skipped > 0) toast.warning(`${skipped} 首歌曲无下载链接,已跳过`) + if (clearSelection) selectedKeys.value = new Set() + if (engine === 'rust') { + toast.success('已交给「下载器」模块下载,可在该模块查看进度') + return + } + if (taskId && downloadTarget.value === 'feiniu') uploadOnDoneTaskIds.value.add(taskId) activeTab.value = 'tasks' } catch (e) { toast.error(`启动下载失败:${e}`) } } -// ===== 音质选择下载 ===== +// ===== 音质选择 ===== const qualityDialogOpen = ref(false) -/** 待下载歌曲(弹窗确认后真正调用 doDownload) */ const pendingQualitySongs = ref([]) -/** 弹窗中选中的音质("" 表示最高) */ const chosenQuality = ref('') -/** 待下载歌曲的音质档位并集(有序,最高在前)+ 最高 */ +const pendingClearSelection = ref(false) function qualityOptionsFor(songs: MusicSong[]): string[] { const set = new Set() for (const s of songs) for (const q of s.qualities ?? []) if (q.label) set.add(q.label) - // 常见顺序优先:无损级在前,其次按码率降序 - const order = (l: string) => { - const idx = ['Hi-Res', '无损', 'FLAC', '320K', '192K', '128K', '96K'].indexOf(l) - return idx === -1 ? 99 : idx - } - return ['最高', ...[...set].sort((a, b) => order(a) - order(b))] + return ['最高', ...[...set].sort((a, b) => tierRank(a) - tierRank(b))] } -function requestDownloadFlow(songs: MusicSong[]) { +function requestDownloadFlow(songs: MusicSong[], clearSelection = false) { if (songs.length === 0) return - pendingUploadOnDone = downloadTarget.value === 'feiniu' const s = store.settings const defQ = s?.defaultDownloadQuality ?? '' if (s?.selectQualityOnDownload) { pendingQualitySongs.value = songs + pendingClearSelection.value = clearSelection chosenQuality.value = defQ && qualityOptionsFor(songs).includes(defQ) ? defQ : '' qualityDialogOpen.value = true } else { - doDownload(songs, defQ) + void doDownload(songs, defQ, clearSelection) } } function confirmQualityDialog() { qualityDialogOpen.value = false - doDownload(pendingQualitySongs.value, chosenQuality.value) + const songs = pendingQualitySongs.value + const clear = pendingClearSelection.value pendingQualitySongs.value = [] + pendingClearSelection.value = false + void doDownload(songs, chosenQuality.value, clear) } - function downloadOne(song: MusicSong) { requestDownloadFlow([song]) } function downloadSelected() { - if (selectedSongs.value.length === 0) return - requestDownloadFlow(selectedSongs.value) - selectedKeys.value = new Set() + const songs = selectedSongs.value + if (songs.length === 0) return + requestDownloadFlow(songs, true) } -// 任务展示辅助 -const taskStatusInfo = (task: MusicDownloadTask): { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline'; class?: string } => { +// ===== 任务展示 ===== +const taskStatusInfo = ( + task: MusicDownloadTask +): { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline'; class?: string } => { switch (task.status) { case 'downloading': return { label: '下载中', variant: 'default' } @@ -535,22 +708,17 @@ const formatBytes = (n?: number) => { } const songPercent = (st: MusicDownloadSongState) => st.total > 0 ? Math.min(100, Math.round(((st.downloaded ?? 0) / st.total) * 100)) : 0 - -/** 任务卡片首行标题:歌名(主)+ 副标题(歌手,多首追加"等 N 首") */ function taskHeadInfo(task: MusicDownloadTask): { name: string; sub: string } { const st = task.songs[0] if (!st) return { name: '', sub: '' } const n = task.songs.length - const sub = (st.singers || '未知歌手') + (n > 1 ? ` 等 ${n} 首` : '') - return { name: st.songName, sub } + return { name: st.songName, sub: (st.singers || '未知歌手') + (n > 1 ? ` 等 ${n} 首` : '') } } const taskAggDone = (task: MusicDownloadTask) => task.songs.reduce((a, s) => a + (s.downloaded || 0), 0) const taskAggTotal = (task: MusicDownloadTask) => task.songs.reduce((a, s) => a + (s.total || 0), 0) -/** 任务音质展示:优先实际解析出的档位(start 事件回传),未知时回退请求档位(如"最高") */ const taskQualityDisplay = (task: MusicDownloadTask) => task.songs.find((s) => s.quality)?.quality || task.quality || '' -// ===== 任务歌曲列表截断(大歌单任务避免一次挂几百行 DOM) ===== const TASK_SONGS_PREVIEW = 20 const expandedTasks = ref>(new Set()) function toggleTaskExpanded(taskId: string) { @@ -560,13 +728,10 @@ function toggleTaskExpanded(taskId: string) { expandedTasks.value = next } function visibleSongs(task: MusicDownloadTask): MusicDownloadSongState[] { - return expandedTasks.value.has(task.taskId) - ? task.songs - : task.songs.slice(0, TASK_SONGS_PREVIEW) + return expandedTasks.value.has(task.taskId) ? task.songs : task.songs.slice(0, TASK_SONGS_PREVIEW) } -// ===== 设置(400ms debounce 保存) ===== -// v-model 需要可赋值目标,用可写 computed 包装 store.settings 字段 +// ===== 设置 ===== const settingsSources = computed({ get: () => store.settings?.sources ?? [], set: (v) => { @@ -646,7 +811,6 @@ async function browseDir() { } } -const appDataPath = ref('') const runtimeDisplay = computed(() => { const p = store.env?.runtimeDir if (!p) return '' @@ -656,7 +820,6 @@ const runtimeDisplay = computed(() => { return p }) -// ===== 环境检查 ===== const pinging = ref(false) async function testBridge() { pinging.value = true @@ -682,707 +845,869 @@ async function doInstall() { const installPercent = computed(() => store.installProgress?.percent ?? 0) const installMessage = computed(() => store.installProgress?.message ?? '') +const diagnosticsOpen = ref(false) + +/** 发现音乐结果行网格(表头与行保持一致) */ +const RESULT_GRID = { gridTemplateColumns: '24px 36px minmax(0,1fr) 108px 52px 92px' } + + {{ formatBytes(taskAggDone(task)) }} - - {{ expandedTasks.has(task.taskId) ? '收起' : `展开全部 ${task.songs.length} 首` }} - + + +
+ +

{{ task.errorMessage }}

+ + +
+
+ {{ songStatusLabel(st) }} + {{ st.songName }} + + + {{ st.message }} + + + {{ formatBytes(st.downloaded) }} +
- - -
+ +
- + + - - + + -
- - - - - 飞牛音乐连接 - - - - - - +
+ + + + + + 飞牛音乐连接 + + + +

+ 曲库与播放来自飞牛 NAS,可配置局域网、frp 域名或 FnConnect 三种接入方式。 +

+ +
+
- - - - - 播放缓存 - - - - - - + + + + + + 存储与上传 + + + +

+ 本地下载目录用于保存搜索下载的歌曲;NAS 文件服务用于把本地曲目回传到飞牛曲库。 +

- - - - - 上传到飞牛曲库 - - - -
-
-
NAS 文件服务
-
- {{ feiniu.fnosLoggedIn ? `已连接(${feiniu.activeConn?.username || ''})` : '未连接' }} -
+
+ +
+ + +
- - {{ feiniu.fnosLoggedIn ? '已连接' : '未连接' }} +
+ +
+ WebDAV 传输(下载到飞牛 / 上传 / 删除) + + {{ feiniu.webdavReady ? '已配置' : '未配置' }}
-

- 在「飞牛音乐连接」卡片中点击云朵图标即可登录 NAS 文件服务(仅 http 局域网直连)。 -

-
- - + + + + +
+ + 飞牛 NAS 侧需要做什么? + +
    +
  1. + 登录飞牛后台 → 系统设置 → 文件服务(文件共享协议)→ WebDAV, + 开启 WebDAV 服务并记下端口(HTTP 默认 5005、HTTPS 默认 + 5006)。 +
  2. +
  3. + 在「可见文件夹范围」中勾选存放音乐的文件夹;团队文件夹需要在文件管理里编辑该文件夹, + 勾选「允许通过文件共享协议(SMB、WebDAV、FTP)挂载到其他设备上」。 +
  4. +
  5. 确保上面的账号对该文件夹有读写权限。
  6. +
  7. 目标目录请填 WebDAV 根下的相对路径(登录 WebDAV 后看到的目录结构,不一定是 NAS 内部路径)。
  8. +
+
+ +
+ 下载完成后自动上传到飞牛曲库 +
-
- - -
+
- 上传最近 10 分钟下载到本地目录的音频 + 上传最近 10 分钟内下载到本地目录的音频
- - + + - - - - - 环境检查 - - - - -
- -
- - {{ store.env?.pythonSource === 'bundled' ? '便携版' : store.env?.pythonSource === 'system' ? '系统' : '未安装' }} - - {{ store.env?.python ?? '—' }} -
-
-
- -
- - {{ store.env?.musicdlInstalled ? '已安装' : '未安装' }} - - {{ store.env?.musicdlVersion ?? '—' }} -
-
-
- -
- {{ store.env?.ffmpeg ? '可用' : '未安装' }} - {{ store.env?.ffmpeg ?? '部分海外音源需要' }} -
-
-
- -
- - {{ store.env?.bridgeRunning ? '运行中' : '已停止' }} - -
-
-
- - - {{ runtimeDisplay }} - -
- - - -
-
- {{ installMessage }} - {{ installPercent }}% -
- + + + + + + 播放 + + +

- 下载与安装依赖需要几分钟,进度条在下载阶段推进、依赖安装阶段按步骤跳转,请耐心等待 + 播放控制常驻在标题栏,切走音乐模块也能继续控制播放。

- -
-
-

- 安装失败:{{ store.installError }} + + + + + + + + + + 搜索与下载 + + + +

+ 搜索与下载会在选中的平台聚合;默认网易云 / QQ 音乐 / 酷狗。

-
- - - 自动下载约 11MB 便携 Python 并安装 musicdl - -
-
-
-
- - - - 设置 - - -
- -
- - - - - - 打开目录 - - -
-
- -
- +
+ -

- 搜索与下载在选中的平台聚合;默认网易云 / QQ音乐 / 酷狗 -

-
- - -
-
- - -
-
- - -
-
- - -

开启后点击下载会弹出音质选择

-
- -
-
- - -

- 解析下载时取「≤ 所选」可用的最优音质;选中的档位不可用时自动降档 -

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
- - +
+
+ + +
+
+ + +
-
- +
+ -

- musicdl 引擎在本模块内管理任务与进度;Rust 引擎将下载交给「下载器」模块的 HTTP 引擎 +

+ musicdl 引擎的任务与进度在本模块内管理,并支持自动回传飞牛曲库;Rust 引擎把下载交给「下载器」模块, + 该模式下「下载到飞牛」不可用。

- - -
- + + + + + + + + + + +
+ +
+ + {{ + store.env?.pythonSource === 'bundled' + ? '便携版' + : store.env?.pythonSource === 'system' + ? '系统' + : '未安装' + }} + + {{ store.env?.python ?? '—' }} +
+
+
+ +
+ + {{ store.env?.musicdlInstalled ? '已安装' : '未安装' }} + + {{ store.env?.musicdlVersion ?? '—' }} +
+
+
+ +
+ {{ store.env?.ffmpeg ? '可用' : '未安装' }} + {{ store.env?.ffmpeg ?? '部分海外音源需要' }} +
+
+
+ +
+ + {{ store.env?.bridgeRunning ? '运行中' : '已停止' }} + + +
+
+
+ + + {{ runtimeDisplay || '—' }} + +
+ + + +
+
+
+ - + 选择下载音质 - 将下载 {{ - pendingQualitySongs.length > 1 ? `${pendingQualitySongs.length} 首歌曲` : pendingQualitySongs[0]?.songName || '' - }};解析时取「≤ 所选」可用的最优音质,所选档位不可用时自动降档。 + 将下载 + {{ pendingQualitySongs.length > 1 ? `${pendingQualitySongs.length} 首歌曲` : pendingQualitySongs[0]?.songName || '' }}; + 解析时取「≤ 所选」可用的最优音质,所选档位不可用时自动降档。
@@ -1390,11 +1715,11 @@ const installMessage = computed(() => store.installProgress?.message ?? '') v-for="opt in qualityOptionsFor(pendingQualitySongs)" :key="opt" type="button" - class="flex items-center justify-between rounded-md border px-3 py-2 text-sm transition-colors" + class="flex items-center justify-between rounded-md border px-3 py-2 text-[13px] transition-colors" :class=" chosenQuality === opt ? 'border-primary bg-primary/10 text-foreground' - : 'border-border hover:bg-muted/50' + : 'border-border hover:bg-accent/50' " @click="chosenQuality = opt" > @@ -1408,14 +1733,5 @@ const installMessage = computed(() => store.installProgress?.message ?? '')
- - -
diff --git a/src/modules/music/SourcePicker.vue b/src/modules/music/SourcePicker.vue index dee2c84..5178fa7 100644 --- a/src/modules/music/SourcePicker.vue +++ b/src/modules/music/SourcePicker.vue @@ -26,8 +26,11 @@ const toggle = (code: string) => { emit('update:model-value', [...next]) } +const selectAll = () => emit('update:model-value', [...props.options]) +const clearAll = () => emit('update:model-value', []) + const label = computed(() => { - if (props.modelValue.length === 0) return '未选择' + if (props.modelValue.length === 0) return '选择搜索源' if (props.modelValue.length === 1) return sourceName(props.modelValue[0]) return `已选 ${props.modelValue.length} 个源` }) @@ -36,34 +39,52 @@ const label = computed(() => { + + diff --git a/src/modules/music/components/PlayerBar.vue b/src/modules/music/components/PlayerBar.vue deleted file mode 100644 index dbfd3c3..0000000 --- a/src/modules/music/components/PlayerBar.vue +++ /dev/null @@ -1,142 +0,0 @@ - - - \ No newline at end of file diff --git a/src/modules/music/components/PlayerFull.vue b/src/modules/music/components/PlayerFull.vue deleted file mode 100644 index 9966401..0000000 --- a/src/modules/music/components/PlayerFull.vue +++ /dev/null @@ -1,176 +0,0 @@ - - - \ No newline at end of file diff --git a/src/modules/music/components/PlaylistEditorDialog.vue b/src/modules/music/components/PlaylistEditorDialog.vue new file mode 100644 index 0000000..f9f8648 --- /dev/null +++ b/src/modules/music/components/PlaylistEditorDialog.vue @@ -0,0 +1,418 @@ + + + diff --git a/src/modules/music/components/TrackItem.vue b/src/modules/music/components/TrackItem.vue index 5ba998f..8ba6a7f 100644 --- a/src/modules/music/components/TrackItem.vue +++ b/src/modules/music/components/TrackItem.vue @@ -1,7 +1,19 @@ \ No newline at end of file + diff --git a/src/modules/music/components/settings/ConnectionsSettings.vue b/src/modules/music/components/settings/ConnectionsSettings.vue index 612d11b..65a5c73 100644 --- a/src/modules/music/components/settings/ConnectionsSettings.vue +++ b/src/modules/music/components/settings/ConnectionsSettings.vue @@ -1,7 +1,7 @@