diff --git a/.github/workflows/player-validation.yml b/.github/workflows/player-validation.yml new file mode 100644 index 00000000..98b12145 --- /dev/null +++ b/.github/workflows/player-validation.yml @@ -0,0 +1,32 @@ +name: Player validation + +on: + pull_request: + branches: [dev] + paths: + - 'public/music/**' + - 'test/**' + - 'src/**' + - 'package*.json' + - 'tsconfig.json' + - '.github/workflows/player-validation.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + player: + runs-on: ubuntu-latest + strategy: + matrix: + node: [22, 24] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci --ignore-scripts --no-audit --no-fund + - run: npm test + - run: npm run build --ignore-scripts diff --git a/README.md b/README.md index bccdacb3..62c57919 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ docker build -t lxserver:local . ### Subsonic 客户端 -支持使用音流、Feishin、Symfonium 等客户端访问已实现的 Subsonic/OpenSubsonic 接口。各客户端支持的功能不同,接口范围和已知限制见 [Subsonic 状态](docs/subsonic-status.md)。 +支持使用音流、Feishin、Symfonium 等客户端访问已实现的 Subsonic/OpenSubsonic 接口。各客户端支持的功能不同,接口范围和已知限制见 [Subsonic 协议支持](docs/api/subsonic.md)。 默认连接信息: @@ -238,7 +238,7 @@ docker build -t lxserver:local . Subsonic 本地曲库按当前账号的持久化存储设置读取音乐和缓存,并加入该账号已启用的自定义目录。网页中临时切换浏览目录,不会改变 Subsonic 的曲库范围。 -默认返回只读的“本地歌曲”歌单;自定义目录可以单独显示。纯本地歌单保存在服务端,不进入 LX 官方客户端同步;混合歌单中的在线歌曲和本地成员分别保存。详情见 [Subsonic 本地音乐库](docs/subsonic-local-library.md)。 +默认返回只读的“本地歌曲”歌单;自定义目录可以单独显示。纯本地歌单保存在服务端,不进入 LX 官方客户端同步;混合歌单中的在线歌曲和本地成员分别保存。详情见 [Subsonic 本地音乐库](docs/guide/subsonic-local-library.md)。

Subsonic 客户端连接 @@ -383,7 +383,7 @@ Subsonic 用户目录中的 `subsonic-local-registry.json` 和 `subsonic-local-s 配置备份默认开启,保留 7 天;默认位置为数据目录的 `backups/`。可以用 `CONFIG_BACKUP_DIR` 和 `SNAPSHOT_BACKUP_PATH` 自定义配置备份、歌单快照的存储位置。 -在「系统设置 → 云端备份范围」中,可分别选择 WebDAV 和 OpenList 备份的数据类型。两端默认都选中以下五类,但仍需单独启用对应服务并配置连接信息;同时启用会保存两份副本,不会在两个云端之间同步。 +在「备份管理 → 云端备份 → 备份范围」中,可分别选择 WebDAV 和 OpenList 备份的数据类型。两端默认都选中以下五类,但仍需单独启用对应服务并配置连接信息;同时启用会保存两份副本,不会在两个云端之间同步。 | 类型 | 备份内容 | | --- | --- | @@ -404,9 +404,13 @@ Subsonic 用户目录中的 `subsonic-local-registry.json` 和 `subsonic-local-s 类型数组为空表示该端不备份任何数据类型。普通数据备份始终排除缓存、日志、临时文件、运行数据库和本地备份包;程序根目录的音乐和自定义外部音乐库需要另行备份。OpenList 的「永久播放缓存」单独配置,勾选歌曲文件不会自动开启缓存上传或删除本地歌曲。 +新增配置项 `configBackup.intervalHours`(默认 24)、`configBackup.scheduleTime`(默认 `00:00`)、`webdav.retentionCount`(默认 5)和 `webdav.retentionDays`(默认 0,不限天数)通过后台或 `config.js` 设置。OpenList 连接、历史保留和播放来源选项也使用配置键,目前没有 `OPENLIST_*` 等对应环境变量;不要把配置键自动改写成大写变量。完整默认值见 [备份配置参考](docs/guide/configuration.md#备份配置项-后台或-config-js)。 + WebDAV 增量文件与 ZIP 备份采用相同范围,默认分别每 60 分钟、24 小时执行;OpenList 数据备份保存在其根目录的 `_data/` 下,默认每 60 分钟检查。普通备份保留本地文件。WebDAV 默认也保留云端已有文件,只有显式开启 `backup.webdav.mirrorDeletes` 才会向增量目录传播本地删除,历史 ZIP 不受影响。 -WebDAV 默认不在启动时恢复,需显式开启 `backup.webdav.restoreOnStartup`。手动恢复按该端当前勾选范围下载,覆盖本地同名文件并保留本地独有文件,不会反向上传。恢复不是整个目录的原子替换,完成后应重启服务,使配置、账号、歌单和音乐索引完整重新加载。部署新实例前确认云端目录和备份范围,具体步骤见 [备份与恢复](docs/guide/backup-recovery.md)。 +普通 OpenList 上传只检查响应、存在与大小,不下载正文;需要核对内容时,在「备份管理 → 记录与校验 → OpenList → 内容校验」先查看预计流量再手动启动。上传后删除本地缓存仍需完整内容校验。 + +WebDAV 默认不在启动时恢复,需显式开启 `backup.webdav.restoreOnStartup`。WebDAV 手动恢复先预览,支持仅补缺失或覆盖同名文件,保留本地独有文件,不反向上传;所有所选文件暂存校验后才写入,写入失败尝试回滚,默认保留恢复前副本。恢复不是整个目录的原子替换,完成后应重启服务,使配置、账号、歌单和音乐索引完整重新加载。部署新实例前确认云端目录和备份范围,具体步骤见 [备份与恢复](docs/guide/backup-recovery.md)。 ### 更新与迁移 @@ -424,7 +428,7 @@ docker compose logs --tail=100 系统默认使用 `data/config.js`;`DATA_PATH` 改变数据目录,`CONFIG_PATH` 指定独立配置文件。首次启动时,若目标配置文件不存在,会尝试从程序根目录的旧配置初始化。 -普通启动按默认值、配置文件、环境变量的顺序合并配置。显式启用 WebDAV 启动恢复时,恢复过程还会重新加载远端配置和账号数据;手动恢复后需要重启服务。当前生效值和配置路径可在管理后台查看。 +普通启动按默认值、配置文件、环境变量的顺序合并配置。显式开启 WebDAV 启动恢复后,还会重新加载备份中的配置和账号;手动恢复后需要重启服务。当前生效值和配置路径可在管理后台查看。 环境变量在进程启动时读取。修改 Compose 中的环境变量后需要重建容器;后台配置是否即时生效取决于具体选项,监听端口和路径调整后应按提示重新连接或重启。 diff --git a/README_EN.md b/README_EN.md index 13404310..9980d769 100644 --- a/README_EN.md +++ b/README_EN.md @@ -206,7 +206,7 @@ Do not use `/admin`, `/rest` or the player access password for LX sync. Review m ### Subsonic clients -Compatible clients include Yinliu, Feishin and Symfonium. The server implements Subsonic/OpenSubsonic endpoints for library browsing, search, playback and related features; the UI and available features vary by client. +Compatible clients include Yinliu, Feishin and Symfonium. The server implements Subsonic/OpenSubsonic endpoints for library browsing, search, playback and related features; the UI and available features vary by client. See [Subsonic protocol support](docs/en/api/subsonic.md) for coverage and limits. | Field | Value | | --- | --- | @@ -220,7 +220,7 @@ By default Subsonic shares the main port. Separate listening requires `subsonic. Online search uses `subsonic.onlineSearch` and `subsonic.onlineSearchMode`: `fallback` supplements insufficient local results; `merge` combines local and online results; `local_only` stays local. Query prefixes include `wy:`, `tx:`, `kw:`, `kg:`, `mg:`, `online:` and `local:`. Online playback still needs a working custom source. -Local library selection follows the account's saved storage location plus its enabled custom directory. A temporary browsing switch in the web UI does not change that library. Read-only local/custom playlists can be exposed separately. Pure local playlists stay on the server; only the online portion of mixed playlists enters LX native sync. +Local library selection follows the account's saved storage location plus its enabled custom directory. A temporary browsing switch in the web UI does not change that library. Read-only local/custom playlists can be exposed separately. Pure local playlists stay on the server; only the online portion of mixed playlists enters LX native sync. See [Subsonic local music library](docs/en/guide/subsonic-local-library.md).

Subsonic connection @@ -340,7 +340,7 @@ Keep `subsonic-local-registry.json` and `subsonic-local-state.json` with user da Playlist snapshots restore playlists; configuration backups restore configuration. Neither substitutes for audio backups. Configuration backups default to a seven-day retention period under the data directory's `backups/` folder. -In **System settings → Cloud backup scope**, select data types independently for WebDAV and OpenList. Both default to all five types below, but each provider must also be enabled and configured. Enabling both creates two copies; it does not synchronize the providers with each other. +In **Backup management → Cloud backup → Backup scope**, select data types independently for WebDAV and OpenList. Both default to all five types below, but each provider must also be enabled and configured. Enabling both creates two copies; it does not synchronize the providers with each other. | Type | Contents | | --- | --- | @@ -361,9 +361,13 @@ These system settings are saved in the active `config.js` and have no correspond An empty type array selects no data for that provider. Ordinary data backups always exclude caches, logs, temporary files, runtime databases and local backup archives. Back up root-directory music and custom external libraries separately. OpenList's **permanent playback cache** is configured separately; selecting music files does not enable cache uploads or local song deletion. +Set `configBackup.intervalHours` (default 24), `configBackup.scheduleTime` (default `00:00`), `webdav.retentionCount` (default 5) and `webdav.retentionDays` (default 0, no age limit) in the admin UI or `config.js`. OpenList connection/history/playback settings also use configuration keys; there are currently no corresponding `OPENLIST_*` environment mappings. See the [configuration reference](docs/en/guide/configuration.md#backup-configuration-keys-admin-ui-or-config-js) for defaults. + WebDAV incremental files and ZIP backups use the same scope, running every 60 minutes and 24 hours by default. OpenList stores data backups under `_data/` within its root and checks every 60 minutes by default. Ordinary backups retain local files. WebDAV also retains existing cloud files by default; explicitly enabling `backup.webdav.mirrorDeletes` propagates local deletions to the incremental directory, without changing historical ZIPs. -WebDAV startup restore is disabled by default and requires `backup.webdav.restoreOnStartup`. Manual restore downloads the provider's currently selected types, overwrites matching local files and preserves local-only files, without uploading local data. Restore is not an atomic replacement of the entire directory. Restart the service afterward to fully reload configuration, accounts, playlists and music indexes. Confirm the cloud namespace and selected scope before connecting a new installation. See [Backup and recovery](docs/en/guide/backup-recovery.md) for procedures. +Ordinary OpenList uploads check existence and size without downloading the body. Use **Backup management → Records and verification → OpenList → Content verification** for manual SHA-256 checks and preview the maximum body-read estimate before starting. Upload-and-delete cache mode still requires full verification. + +WebDAV startup restore is disabled by default and requires `backup.webdav.restoreOnStartup`. WebDAV manual restore previews the selected scope, supports missing-only or overwrite, and preserves local-only files without uploads. Required files are staged and checked before replacement; commit failure attempts rollback, with pre-restore copies kept by default. Restore is not an atomic replacement of the entire directory. Restart the service afterward to fully reload configuration, accounts, playlists and music indexes. Confirm the cloud namespace and selected scope before connecting a new installation. See [Backup and recovery](docs/en/guide/backup-recovery.md) for procedures. For container updates: @@ -427,7 +431,7 @@ Defaults below refer to code defaults. Bundled configuration, saved values and i | `ENABLE_LOGIN_USER_CACHE_RESTRICTION` | `user.enableLoginCacheRestriction` | Enable cache settings restriction for logged-in non-admin users | `false` | | `ENABLE_CACHE_SIZE_LIMIT` | `user.enableCacheSizeLimit` | Enable cache size limit (auto-cleanup via LRU) | `false` | | `CACHE_SIZE_LIMIT` | `user.cacheSizeLimit` | Cache size limit in MB | `2000` | -| `CONFIG_BACKUP_ENABLE` | `configBackup.enable` | Enable automatic config.js backup (creates daily backup copy) | `true` | +| `CONFIG_BACKUP_ENABLE` | `configBackup.enable` | Enable automatic config.js backup (default: every 24 hours) | `true` | | `CONFIG_BACKUP_RETENTION_DAYS` | `configBackup.retentionDays` | Config backup retention period in days (auto cleans up expired backups) | `7` | | `CONFIG_BACKUP_DIR` | `configBackup.dir` | Backup directory, relative to data; empty uses data/backups | `empty` | | `SNAPSHOT_BACKUP_PATH` | `snapshot.backupPath` | Custom storage path for playlist snapshots (absolute or relative to `./data`) | - | diff --git a/changelog.md b/changelog.md index a869edf2..affb646c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,17 +1,80 @@ # Changelog -## v2.2.0 (2026-10-31) +## v2.2.0 (2026-11-11) ### 🌟 新增功能与体验升级 -- **OpenList 备份与统一云端备份范围 (#488 @pyss56)**:支持 OpenList 数据备份、恢复及独立的永久播放缓存;系统设置可分别选择 WebDAV / OpenList 备份歌曲、用户文件、快照、用户数据和系统配置,设置保存至 `config.js`。歌曲备份仅包含数据目录内的 `music/`,不包含缓存或外部音乐库。 +- **后台仪表盘与明/暗主题重构**:仪表盘重新设计为数据看板,保留 KPI 与 CPU/内存实时图并新增 1/5/15 分钟时间窗口和趋势迷你折线;新增「日志活动」(近 12 小时分级柱状图与 24 小时错误/警告统计)、「服务与存储」(WebDAV、OpenList 状态与最近/下次备份、数据盘与进程内存用量)、「用户与设备」在线概览和「最近告警」列表,对应新增 `GET /api/dashboard/overview` 接口。后台界面去除渐变与毛玻璃,改为实色卡片加细边框的单一强调色风格,并新增顶栏明/暗主题切换按钮(默认跟随系统、记住选择,图表颜色同步切换)。 + - ![后台仪表盘与明暗主题](md/log/2.2.0-11.png) +- **Subsonic 本地歌单缓存筛选**:在“返回本地歌曲歌单”下新增“返回缓存目录歌曲”,默认关闭,仅返回 `music` 歌曲;开启后将 `cache` 歌曲合并到本地歌单。 + - **返回缓存目录歌曲** + - ![返回缓存目录歌曲](md/log/2.2.0-10.png) + - 获取歌单列表时跳过歌曲详情转换,详情仅读取一次收藏状态;自定义目录与内置 `music` / `cache` 重合时优先按内置目录归属,避免本地歌曲被错误隐藏。 +- **Subsonic 评分歌单与联动补数据 (#508 @pyss56)**:新增按 1–5 星汇总的虚拟评分歌单,1 星歌单包含命中不喜欢规则的歌曲,空星级不生成歌单;接入评分快照创建、导出与恢复,并在后台提供目标用户选择和两个方向的手动联动补数据入口。 +- **快照导入与恢复完善 (#508 @pyss56)**:歌单及不喜欢快照区分导入与系统生成来源,支持按来源筛选,避免导入记录参与系统快照自动清理;恢复前先保存当前状态。 +- **Web 播放器歌曲评分**:播放器、歌曲列表、歌单、排行榜及本地音乐支持 1–5 星评分与清除,「我的评分」集中查看、筛选及导出;与 Subsonic 共用评分数据,独立保存到评分目录,不写入歌单。本地歌曲关联在线版后,在线已有评分优先,否则迁移本地评分、来源与历史,并删除本地重复记录。 + - ![Web 播放器歌曲评分与我的评分](md/log/2.2.0-6.png) +- **后台评分与不喜欢数据查看**:按用户查看歌曲、歌手、专辑的评分与不喜欢记录,展示封面、音乐平台、添加或评分来源及详细资料;支持组合筛选、排序、分页和导出筛选结果。 + - ![后台查看用户评分数据](md/log/2.2.0-7.png) +- **Subsonic 缓存优先时优先使用 OpenList**:后台新增「缓存优先时优先使用 OpenList」设置,可在缓存优先播放流程中优先尝试该用户可用的 OpenList 播放来源。 + - ![后台缓存优先时优先使用 OpenList 设置](md/log/2.2.0-8.png) +- **Web 不喜欢自动补分设置**:在 Web 设置中为当前账号单独开启或关闭「不喜欢自动补分」,默认关闭;将歌曲加入不喜欢时,仅为未评分歌曲补 1 星,已有评分不覆盖。设置保存到服务器,同一账号跨设备生效,后台手动补分独立执行。 + - ![Web 设置中的不喜欢自动补分](md/log/2.2.0-9.png) +- **聚合搜索关键词联想**:并发获取多源联想,快源先展示,合并重复词并优先展示多源共同推荐内容;增加短期缓存、超时与中文输入法处理,切换输入或正式搜索时取消旧联想,支持方向键选词和回车搜索,保留聚合音源选择。 +- **歌曲搜索分批加载与聚合排序**:各音源先请求一页并按歌名相关性及原始排名交错展示,保留按设置数量的本地分页,翻到未加载页面时自动取下一批,也可点击常驻「加载更多」按钮;独立记录各源页码、结束与失败状态,避免深页翻唱因歌手名字较短挤到前列,修复跨源 ID 碰撞,并保留手动追加前的页码、结果顺序与滚动位置。 +- **内置音乐 SDK 接口更新**:同步 QQ 签名搜索、推荐歌单、歌手与歌单详情及 QRC 逐字歌词,更新酷我歌词、网易歌手和咪咕封面接口,补齐酷狗分享歌单解析;保留被替换的旧实现为注释,修复歌词时间精度、分页与取消请求问题。 +- **OpenList 备份与统一云端备份范围 (#488 @pyss56)**:支持 OpenList 数据备份、恢复及独立的永久播放缓存;可分别选择 WebDAV / OpenList 备份歌曲与歌词、用户音源文件、快照、用户数据和系统配置。常规歌曲备份包含 `data/music/` 内的音频、LRC、封面与索引,排除独立的根目录 `music/`、所有 `cache/` 及外部自定义音乐目录;OpenList 播放缓存按用户单独配置上传、下载、双向同步或校验后删除本地副本。 +- **后台统一备份管理**:将系统配置中的备份设置及 WebDAV、OpenList 入口集中到「备份管理」,按概览、本地备份、云端备份、数据恢复、记录与校验组织;补充执行时间、周期、范围和异常状态,未启用的服务收起相关操作。重构记录筛选,支持数据类型、用户、存储类别、状态、本地存在性、日期、关键词与排序,并优化文件详情、用户归属及时间显示。 + - ![备份管理界面](md/log/2.2.0-2.png) +- **服务器已有歌曲优先播放与本地歌词**:Web 播放器可优先读取当前用户的 `music/` 与 `cache/`,按用户存储设置使用数据目录或运行根目录下的位置;新增「优先使用服务器本地歌词」,支持匹配已有 LRC,本地音乐条目也支持自定义目录中的同名歌词。补充 Web / Subsonic 播放顺序说明,区分已有文件读取、后台缓存写入和浏览器缓存。 + - ![优先使用服务器本地歌词与优先播放服务器已有歌曲设置](md/log/2.2.0-3.png) +- **OpenList 播放来源与内容校验**:开启相关设置后,Web / Subsonic 在本地未命中时可使用该用户已登记的 OpenList 缓存或下载文件,不可用时回退在线解析,无需每次遍历网盘;WebDAV 仍用于备份恢复。普通上传仅检查响应与云端大小,独立的手动内容校验支持预估流量、后台执行、停止及结果筛选;「上传完成」与当前版本的「已校验」分别展示,上传后删除本地缓存仍需完整内容校验。 - **均衡器与音效调节界面重构与紧凑交互**: - ![均衡器与音效调节面板](md/log/2.2.0-1.png) - **动态生效配置标签(Tag)与一键移除**:顶部状态栏实时展示已生效的音效配置标签(环境混响、EQ 预设/段数、音调调节、3D 环绕),支持鼠标悬停(桌面端)或轻点(移动端)标签右上角 `✕` 一键关闭单项配置;多项开启时支持一键「全部重置」,并提供全局总开关及纯净原音直通提示。 +- **歌词卡片分享界面现代化**:重绘弹窗样式,加大圆角与悬浮阴影,预览区加入主题色光晕与点阵背景,预览卡片增加细描边和悬停辉光;比例、色彩风格、歌词行数按钮改为半透明渐变卡片,带悬停上浮、点击缩放及选中发光反馈;复选框与滑块改为自绘主题色样式,下载按钮增加渐变、内高光与悬停光泽,复制按钮采用毛玻璃描边。仅调整样式,不影响卡片生成逻辑。 + - ![歌词卡片分享界面](md/log/2.2.0-5.png) ### 🐛 缺陷修复与体验优化 -- **备份与恢复流程梳理**:统一 WebDAV 增量、ZIP 和离线导入导出的数据范围,完善 OpenList 上传校验、失败重试及 Subsonic 云端播放处理;WebDAV 启动恢复和云端删除同步默认关闭,恢复按所选类型覆盖同名文件,完成后需重启服务。 +- **PWA 与浏览器下载认证完善 (#496)**:原生下载前检查并恢复登录 Cookie,恢复后再次确认会话有效;Fetch 下载携带同源认证头,登录失效或账号切换时明确提示并停止自动重试,避免页面可操作但下载提示「需要授权」。 +- **自动切歌优先使用已完成缓存 (#490)**:接管下一首预读链接前重新检查服务器已有歌曲,避免后台缓存已完成仍使用在线链接;补查仅检查本地文件,设置 1.2 秒超时,未就绪或失败时继续使用预读链接;预读按平台、标准化歌曲 ID 和目标音质匹配,避免跨平台或修改音质后误用旧结果。关闭缓存优先后不再复用在线歌曲的文件缓存预读,播放失败时清除对应预读记录,保留并发更新的新结果。快速切歌或队列变化后丢弃旧预读结果,关闭预读时立即停止缓冲,避免过期任务覆盖下一首。 +- **无可用音源时停止无效重试**:保留本地与缓存播放,在线解析确认当前用户无可用源后停止自动换源、降音质、跳歌及后台连续预读,统一提示添加或启用音源;仅当前平台无源时跳过同平台重试与降音质。 +- **自定义目录加载与索引优化 (#475)**:重复进入复用列表,按索引版本更新并保留分页与筛选,关闭歌词详情不再重复加载;文件操作后局部更新,合并并发扫描与目录遍历,批量删除仅保存一次索引。索引采用临时文件替换,读取失败保留有效记录,防止异步补数据覆盖新关联;Subsonic 按需自动扫描间隔调整为至少 5 分钟,手动同步仍立即执行。 +- **切歌进度重置 (#494)**:切换歌曲时清零进度显示,避免新歌链接解析失败后自动重试时误用上一首的播放位置,保留同曲故障恢复与刷新续播。 +- **缓存自动清理修复 (#489)**:空间限制仅清理 `cache`,不再误删 `music` 下载文件;异步递归统计并分批清理媒体文件,保护索引、临时文件、下载收尾与活跃备份,按最近访问或修改时间成组清理音频和歌词并同步索引。避免清理替换扫描中的索引实例,并容忍扫描途中删除文件;修复保留天数重启丢失,启动、修改清理设置后及定时检查两处缓存目录。 +- **下载与播放歌词获取流程统一**:复用服务器歌词缓存、原平台接口及内嵌歌词兜底,补齐收藏歌曲和咪咕等平台的歌词参数;下载仅按平台与歌曲 ID 匹配,禁用同名歌曲兜底,并保留翻译、音译与用户隔离。 +- **浏览器单曲分段下载**:支持 Range 的大文件默认使用 4 路分段下载,临时文件拼接后继续写入封面和歌词;加入分段校验、失败重试、单连接回退、取消清理及全局连接限制,连接中断时同步显示失败状态。 +- **单曲下载音质筛选修复**:仅展示已验证可用或服务器已有的音质,过滤错误响应、异常文件大小和返回音质不匹配的选项,修复「64 B」占位大小误显示;筛选后按实际音质提交下载与缓存任务。 +- **下载内嵌歌词修复**:统一处理带音源前缀的歌曲 ID,在线下载优先读取当前用户已有歌词,保留翻译与音译;补充歌词写入回读校验及失败日志,修复网页有歌词但下载文件未嵌入的问题。 +- **浏览器下载进度与状态修复(#485)**:补齐进度查询认证信息,修复文件已下载却提示「浏览器未启动下载」的误报;服务器已有文件直接交给浏览器保存,无法确认进度时提示查看浏览器下载记录,进度查询失败不再计为下载失败。 +- **评分模块归类与迁移边界修复**:评分服务、偏好、快照、校验、身份锁及事务迁移集中到 `src/server/rating/`;修复无评分用户的关联整理、仅评分事务覆盖未保存不喜欢数据的问题,并在不喜欢数据初始化时恢复遗留事务。 +- **评分迁移与恢复一致性修复**:本地关联在线歌曲时同步迁移不喜欢原因中的评分 ID;评分、歌曲资料与相关不喜欢快照通过事务日志提交,写入失败可回滚,中断后可恢复。旧评分快照恢复时直接整理关联身份,保留手动不喜欢原因与历史快照。 +- 修复不喜欢规则括号空格重复、同身份资料补全及跨来源隔离;记录评分与不喜欢来源,手动同步增加候选预览、资料补全统计及未解析失败计数。 +- 修复 Subsonic 评分歌单显示与自动联动开关耦合,补齐评分歌单配置和评分快照备份分类;恢复评分时保留收藏,缓存淘汰后尝试重新解析评分歌曲,并提示评分已保存但联动失败的情况。 +- **酷狗码导入修复 (#483)**:完善酷狗分享歌单的链接解析与歌曲列表获取,修复部分歌单导入失败的问题。 +- **备份记录详情面板优化**:加宽桌面详情弹窗,分开展示标题与文件名,优化表格间距及长路径、SHA-256 和元数据换行,避免内容撑出面板或挤压关闭按钮,并适配移动端宽度。 +- **列表长文本滚动优化**:Web 播放器及后台多类列表中,超出宽度的文件名、歌曲名、歌单名等文字在桌面端悬停时滚动,移开后恢复开头;移动端保持自动滚动,完整显示的文字保持静止。 + - ![列表长文本在鼠标悬停时滚动](md/log/2.2.0-4.png) +- **后台滚动条样式统一**:横向、纵向滚动条统一采用深色轨道与细圆角滑块,增加悬停及拖动颜色反馈,优化列表、表格和弹窗等区域的视觉效果。 +- **清空搜索返回热搜页修复**:网络搜索时,点击输入框清空按钮或手动清空关键词后,取消旧搜索并清除搜索缓存,返回热搜页,避免继续停留在搜索结果列表。 +- **搜索进度显示范围修复**:搜索状态条仅在网络搜索页显示,切换收藏分类或其他列表时隐藏,返回搜索后恢复当前状态,避免后台更新导致残留。 +- **暗色模式开关状态修复**:修复开启后的开关底色被暗色灰色样式覆盖的问题,歌曲洗版等开关统一使用主题色区分开启与关闭状态。 +- **AirPlay 原生播放恢复修复 (#511 @pokemonred06-creator)**:关闭混响时保留原生模式,避免混响滑块动画在切回本机后重新启用音效或覆盖后续修改,恢复提示按指定时长显示。 +- **无需刷新恢复原生播放**:关闭音效或点击恢复按钮时,通过新音频元素交接歌曲、进度、暂停状态、音量和倍速,释放旧音频图及 iOS 保活桥;保留队列和音效配置,切歌及快速开关不会套用旧进度。切换可能短暂缓冲,浏览器阻止续播时提示手动播放。 +- **音效原生模式提示与配置预览**:原生播放显示醒目的状态标签,总开关显示关闭,保留全部配置但锁定编辑;明确打开总开关后按当前配置启用本机音效,投播期间锁定本机音效控件。 +- **删除用户时清理音源文件**:单个或批量删除用户并勾选删除数据时,同时清理该用户的自定义音源脚本、顺序及状态文件,保留公共音源和其他用户文件。 +- **歌手与专辑详情切换及返回修复**:切换标签、收藏分类或歌单时清理详情头部与悬浮栏,避免残留到其他列表;修复打开专辑后歌手详情因容器被删除而一直加载的问题;记录详情入口,返回时恢复对应的收藏歌手、收藏专辑或原歌单,避免浏览器返回事件重复处理后跳到「我的喜爱」。 +- **批量选择行高亮修复**:修复收藏、搜索等歌曲列表点击「全选」后仅更新选中数量、未同步行高亮的问题;正确识别当前显示的列表,统一排行榜批量操作的刷新入口,确保全选、清空及退出批量模式时选中状态与高亮同步。 +- **播放队列去重与歌曲定位修复**:统一搜索结果与默认列表的歌曲 ID 匹配规则,修复播放时自动加入默认列表的歌曲仍可重复入队的问题;「加入播放队列」遇到已有歌曲时提示并展开侧边面板,滚动定位、高亮对应歌曲;「下一首播放」将已有条目移至下一首,当前正在播放的歌曲不再重复添加。 +- **搜索页面意外跳回收藏修复 (#482)**:修复恢复上次播放时遗留的歌单跳转覆盖当前搜索页面的问题;收藏歌单后台更新完成后,仅在仍浏览该收藏列表时刷新内容,避免搜索过程中突然切回“我的喜爱”等列表。 +- **管理面板 WebDAV 连接测试修复 (#474)**:改为分别检查已保存配置的同步目录与备份目录,避免根目录列举失败导致测试误判;逐项显示检查结果,并针对认证、权限、路径、网关、网络及证书错误提供排查建议。请求超过 10 秒自动取消,测试期间禁止重复点击;明确说明测试仅验证目录读取,不验证上传、删除权限或完整恢复能力。 +- **备份管理手机端布局优化**:统一设置与恢复页间距、按钮分组和触控尺寸;本地配置备份与快照按卡片显示,下载、恢复、删除等宽排列,长路径和文件名自动换行,优化预览、任务记录、校验结果及详情弹窗的窄屏展示。 +- **后台提示样式统一**:批量重试使用后台确认弹窗,取消不会提交且避免重复操作;移除管理主页连接测试、同步结果及详情异常中的浏览器原生提示框。 +- **OpenList 轻量文件检查**:可按已选、筛选结果或当前连接全部记录手动检查本地与云端文件元信息,显示进度、停止操作及上次检查时间;结果按连接和版本保留,不下载文件正文,也不上传或删除文件。 +- **备份任务与校验状态统一**:记录页按待上传、待下载、传输中、已备份、需处理、失败及暂停展示主状态,内容校验作为独立历史标记;本地变化诊断收进详情和高级筛选,云端变化后可重新校验,记录与校验页统一类型、用户和结果筛选。 +- **备份记录本地状态修复**:查询记录时检查实际文件,修复手动删除后仍显示“本地存在”的问题;打开详情重新获取状态,权限异常显示“无法检查”,不影响云端备份与内容校验记录。 +- **备份与恢复可靠性改进**:统一 WebDAV 增量、ZIP 和离线导入导出的数据范围,支持恢复预览、仅补缺失或覆盖同名文件;下载暂存并校验后再替换,默认保留恢复前副本,提供恢复记录与回滚。完善 OpenList 历史版本、失败重试及 WebDAV 临时上传、成功记录和 ZIP 保留策略;WebDAV 启动恢复与本地删除同步到云端默认关闭,恢复保留本地独有文件,完成后需重启服务。同步更新中英文备份、播放、配置及接口说明。 - **运行环境要求更新**:Node.js 最低版本调整为 `22.13.0`,以支持 OpenList 使用的内置 SQLite 模块。 - **搜索与凭据表单隔离 (#477 @pokemonred06-creator)**:为搜索、登录及远程连接设置独立表单和自动填充属性,降低搜索内容被误识别为登录凭据的风险;登录成功后清空密码输入框,修复手动搜索 Enter 重复触发及列表搜索输入法组合输入时误操作。 - **VU 表盘与指针优化 (#477 @pokemonred06-creator)**:更新各主题表盘设计、刻度映射及指针响应,调整动态参考电平,避免持续高 RMS 音频导致指针顶格。 @@ -196,7 +259,7 @@ - **Media Session 媒体会话控制、封面渲染与播放恢复稳定性提升 (#432 @pokemonred06-creator)**: - **播放控制与指令幂等执行**: 修复系统媒体控制栏(Media Session)的播放/暂停指令未比对当前播放状态导致的逻辑反转问题,确保系统与锁屏快捷指令严格幂等。 - **意外暂停后的错误恢复守护**: 保留系统意外打断(如来电、系统音频中断)时的错误恢复监听,确保用户在恢复播放时重新挂载错误恢复能力,避免状态丢失。 - - **Media Session 封面与车载渲染规范化**: 通过内建下载代理中转跨![1791824601110](image/changelog/1791824601110.png)![1791824602639](image/changelog/1791824602639.png)域封面,避免 Canvas 污染与跨域访问失败;统一在离屏 Canvas 居中缩放为标准 512×512 JPEG 位图(失败时优雅回退),防止尺寸或格式不符导致蓝牙车机或锁屏黑屏;增加版本锁与内存缓存,杜绝切歌时旧封面异步覆盖新歌曲,并补充专辑名 (`song.meta.albumName`) 读取。 + - **Media Session 封面与车载渲染规范化**: 通过内建下载代理中转跨域封面,避免 Canvas 污染与跨域访问失败;统一在离屏 Canvas 居中缩放为标准 512×512 JPEG 位图(失败时优雅回退),防止尺寸或格式不符导致蓝牙车机或锁屏黑屏;增加版本锁与内存缓存,杜绝切歌时旧封面异步覆盖新歌曲,并补充专辑名 (`song.meta.albumName`) 读取。 - **iOS 后台保活桥接重连与打断恢复**: 在音频图重建(如调节均衡器/音效)时自动重新连接 MediaStreamDestination,避免桥接断开或静音;优化切回前台(`visibilitychange`)时的 AudioContext 状态恢复;捕获并容忍自动播放限制(`NotAllowedError`),保留锚点以便后续用户手势重试。 - **服务端缓存音频流 Range 请求与连接管理优化**: 严格限制只读取缓存目录内合法普通文件,增强路径遍历安全校验;补齐 HEAD 请求支持;完整支持不区分大小写的 `bytes=` 范围头、后缀范围(如 `bytes=-500`)及单边范围,对超出文件大小的请求规范返回 `416 Range Not Satisfiable`;并在客户端断开或读取出错时及时销毁读取流并释放文件描述符。 diff --git a/docker-compose.yml b/docker-compose.yml index 53edb212..ceba9e40 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: lx-sync-server: build: . @@ -7,13 +5,30 @@ services: restart: always ports: - "9527:9527" - # Subsonic 独立监听端口:仅当 subsonic.standalone 启用时按需取消注释以暴露到主机 + # Subsonic 独立服务端口:仅当 subsonic.standalone 启用时取消注释 # - "4050:4050" volumes: - # 数据持久化 + # 数据持久化(配置、快照、日志等均落在此目录) - ./data:/server/data environment: - # 环境变量配置,优先级高于 config.js - NODE_ENV=production - # 示例:通过环境变量设置用户 + # 通过环境变量覆盖配置,优先级高于 config.js + # 用户:LX_USER_<用户名>=<密码 或 JSON 配置> # - LX_USER_myuser=mypassword + # 管理员密码:覆盖默认 123456(强烈建议设置为强密码) + # - ADMIN_PASSWORD=your-strong-password + # Subsonic 独立服务 + # - SUBSONIC_STANDALONE=true + # - SUBSONIC_PORT=4050 + # WebDAV 备份目标(仅填地址与凭据不会启动同步,需显式开启 WEBDAV_ENABLE) + # - WEBDAV_ENABLE=true + # - WEBDAV_URL= + # - WEBDAV_USERNAME= + # - WEBDAV_PASSWORD= + # 网页播放器鉴权 + # - ENABLE_WEBPLAYER_AUTH=true + # - WEBPLAYER_PASSWORD=<请设置强密码,勿使用默认值> + # 其他常用项 + # - SYNC_INTERVAL=60 + # - CONFIG_PATH=/server/data/config.js + # - SERVER_NAME=My Sync Server diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index c92f1611..db02af2d 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -50,20 +50,23 @@ export default defineConfig({ text: '核心功能', items: [ { text: '同步服务器设置', link: '/guide/sync-server' }, - { text: 'Web 播放器指南', link: '/guide/web-player' } + { text: 'Subsonic 本地音乐库', link: '/guide/subsonic-local-library' }, + { text: '播放器、歌词与解析顺序', link: '/guide/web-player' } ] }, { text: '配置指南', items: [ { text: '配置文件及环境变量', link: '/guide/configuration' }, - { text: '云端备份与恢复', link: '/guide/backup-recovery' } + { text: '备份与恢复', link: '/guide/backup-recovery' } ] }, { text: 'API 文档', items: [ - { text: '服务端 API 参考', link: '/api/reference' } + { text: '服务端 API 参考', link: '/api/reference' }, + { text: 'HTTP 路由索引', link: '/api/routes' }, + { text: 'Subsonic 协议支持', link: '/api/subsonic' } ] } ], @@ -123,20 +126,23 @@ export default defineConfig({ text: 'Core Features', items: [ { text: 'Sync Server Settings', link: '/en/guide/sync-server' }, - { text: 'Web Player Guide', link: '/en/guide/web-player' } + { text: 'Subsonic Local Library', link: '/en/guide/subsonic-local-library' }, + { text: 'Playback, Lyrics & Sources', link: '/en/guide/web-player' } ] }, { text: 'Config Guide', items: [ { text: 'Config & Env Vars', link: '/en/guide/configuration' }, - { text: 'Cloud Backup & Recovery', link: '/en/guide/backup-recovery' } + { text: 'Backup & Recovery', link: '/en/guide/backup-recovery' } ] }, { text: 'API Docs', items: [ - { text: 'Server API Ref', link: '/en/api/reference' } + { text: 'Server API Ref', link: '/en/api/reference' }, + { text: 'HTTP Route Index', link: '/en/api/routes' }, + { text: 'Subsonic Protocol Support', link: '/en/api/subsonic' } ] } ], diff --git a/docs/api/reference.md b/docs/api/reference.md index 73955522..2d4f36f7 100644 --- a/docs/api/reference.md +++ b/docs/api/reference.md @@ -336,4 +336,128 @@ GET /api/music/search?name=周杰伦&source=kw&page=2&limit=20 - `POST /api/webdav/backup`、`GET /api/webdav/backups`、`DELETE /api/webdav/backup`、`POST /api/webdav/restore`:远程备份管理。 - `GET /api/webdav/logs`、`GET /api/webdav/progress`:同步日志与 SSE 进度。 -Subsonic 的协议范围与本地库行为另见 [本地音乐库说明](../subsonic-local-library.md) 和 [协议状态](../subsonic-status.md)。 +### 评分与不喜欢联动 {#rating-api} + +| 方法 / 路径 | 认证 | 请求与返回要点 | +| --- | --- | --- | +| `GET /api/music/ratings` | 用户认证 | 返回 `version,pending,ratings,origins,songs`,只读取当前账号 | +| `POST /api/music/ratings` | 用户认证 | 默认写入 `song` 与整数 `rating`(0–5,0 清除);返回保存结果,可带 `warning`,警告不代表评分未保存 | +| `GET /api/music/rating-preferences` | 用户认证 | 返回 `dislikeAutoRating`,默认 false | +| `POST /api/music/rating-preferences` | 用户认证 | JSON `dislikeAutoRating` 必须是布尔值;保存后返回设置 | +| `GET /api/data/rating-sync?user=<账号>` | 管理员认证 | 返回该账号的 `pending,version` | +| `POST /api/data/rating-sync?user=<账号>` | 管理员认证 | 重试该账号待处理的评分同步任务 | +| `POST /api/subsonic/sync-link` | 管理员认证 | JSON `user,direction,preview,reconcile`,执行或预览评分与不喜欢规则同步 | + +`POST /api/music/ratings` 也接受 `operation`:`version` 查询版本,`retryFailed` 重试任务,`exportBackup` 导出评分备份,`resolve` 批量解析最多 100 个 `songs`,可用 `includeDetails=true` 请求详情。各操作的响应结构不同,不统一包装为 `success`。 + +`sync-link` 的 `direction` 必须为 `ratingToDislike` 或 `dislikeToRating`,前者要求启用并保存 `subsonic.linkRatingToDislike`。建议显式使用 `preview:true` 检查候选,再明确执行:普通同步仅在 `preview===true` 时预览;`ratingToDislike` 且 `reconcile:true` 时默认预览,只有 `preview:false` 执行。该端点的 HTTP 400 错误体可为纯文本,不能假定为 JSON。 + +### OpenList 连接状态 {#openlist-connection} + +`GET /api/openlist/connection` 使用管理员认证,读取已记录的连接状态;`POST` 使用已保存配置主动探测。成功返回 `{ success: true, connection }`。POST 探测在线时还会在后台触发待检查记录核验,不是无副作用的查询。连接状态与历史文件内容校验结果是不同维度;探测失败返回 HTTP 500 与 `error`,其他方法返回 405。 + +### 仪表盘概览 {#dashboard-overview} +`GET /api/dashboard/overview` 使用 `x-frontend-auth: <管理员密码>`,无需查询参数,认证失败返回 HTTP 401。成功响应为 JSON,并设置 `Cache-Control: no-store`。 + +| 响应字段 | 含义 | +| --- | --- | +| `success` | 成功时为 `true` | +| `logs.totals` | 最近 24 小时窗口内的 `info`、`warn`、`error` 计数 | +| `logs.buckets` | 最近 12 个滚动小时桶,含毫秒时间戳 `t` 与各级别计数 | +| `logs.recent` | 最多 8 条近期警告/错误,按新到旧返回;含 `time`、`level`、`msg`,消息最多 200 字符 | +| `disk` | 数据目录所在文件系统的 `total`、`free`,单位字节;无法读取时为 `null` | +| `heap` | `used` 为进程 RSS,`total` 为系统总内存,`external` 为进程外部内存,均为字节;并非 V8 堆使用量与堆上限 | +| `services` | WebDAV、OpenList、公共收藏的启用配置,以及恢复维护状态;不是远端连通性检查 | +| `node`、`pid` | Node.js 版本与进程 ID | + +日志只读取 `app.log` 末尾最多 512 KiB 后按时间筛选,不读取轮转文件。高日志量时统计可能不覆盖完整 24 小时;日志不可读取或格式不匹配时,可返回空统计,不能据此判断没有发生错误。小时桶按请求时刻向前滚动,不对齐整点。 + +Subsonic 的协议范围与本地库行为另见 [本地音乐库说明](../guide/subsonic-local-library.md) 和 [协议支持](./subsonic.md)。 + +## 备份与恢复接口 {#backup-api} + +本节用于脚本集成;后台操作步骤见 [备份与恢复](../guide/backup-recovery.md)。这些管理接口使用 `x-frontend-auth: <管理员密码>`,JSON 请求使用 `Content-Type: application/json`。用户 Token 不能代替管理员密码。不要只按 HTTP 200 判断成功,还要检查 `success`、`errors` 及各接口的 `message`/`error`。 + +### 本地导入导出与配置历史 + +| 方法 / 路径 | 请求与返回要点 | +| --- | --- | +| `GET /api/backup/download` | 返回 ZIP;使用已保存的 WebDAV 类型范围,无需启用 WebDAV,不是无条件全目录导出 | +| `POST /api/backup/upload` | multipart 文件字段 `backup`(也兼容 `file`);按当前范围恢复本地 ZIP,不使用下面的 WebDAV 预览 token | +| `GET /api/config/backups` | `success,list,autoBackupEnabled,retentionDays,intervalHours,scheduleTime,nextBackupAt,lastBackupAt`;时间为毫秒时间戳,0 表示暂无 | +| `POST /api/config/backup-now` | 立即保存配置副本,返回 `success,filename`,失败可返回 `error` | +| `GET /api/config/backups/download?file=<文件名>` | 下载指定配置副本 | +| `POST /api/config/backups/restore` | JSON `fileName`;恢复配置历史,不等同于数据 ZIP 恢复 | +| `DELETE /api/config/backups/<文件名>` | 删除指定本地配置副本 | + +### WebDAV + +| 方法 / 路径 | 请求与行为 | +| --- | --- | +| `POST /api/webdav/test` | 使用已保存配置,只检查目录读取;返回 `success,message,checks`,不验证写入/删除能力 | +| `POST /api/webdav/sync` | 上传所选类型的本地文件,不做双向合并 | +| `POST /api/webdav/backup` | JSON 可选 `force`,默认 `false`;创建并上传 ZIP | +| `GET /api/webdav/backups` | 返回 `success,backups`;条目含 `filename,basename,size,time,timeStr` | +| `DELETE /api/webdav/backup` | JSON `filename`,必须位于配置的 ZIP 目录;不是查询参数 `file` | +| `POST /api/webdav/restore` | 先预览,再使用返回的 token 确认,详见下例 | +| `GET /api/webdav/restore/tasks` | 返回 `success,tasks`;含状态、下载/写入数量、错误与 `canRollback` | +| `POST /api/webdav/restore/tasks//rollback` | 回退保留的恢复前副本;拒绝覆盖恢复后的新修改 | +| `DELETE /api/webdav/restore/tasks/` | 删除本地任务及副本,不删除云端备份;运行中/待故障处理任务拒绝删除 | +| `GET /api/webdav/logs` | 同步日志 | +| `GET /api/webdav/progress` | SSE;也支持查询参数 `auth`,避免在可分享链接中携带凭据 | + +预览请求: + +```json +{ + "preview": true, + "mode": "files", + "strategy": "missing", + "keepCopies": true +} +``` + +- `mode`:`files`(增量目录)、`zip`、`auto`(默认,目录无所选文件或不存在时尝试 ZIP)。 +- `targetFilename`:ZIP 模式下可指定备份列表中的文件名,省略选择最新 ZIP。 +- `strategy`:`missing`(仅补缺失)或 `overwrite`(覆盖同名);API 默认 **overwrite**,不要省略后误以为是仅补缺失。 +- `keepCopies`:默认 `true`,保留恢复前副本。 +- 返回包含 `success,token,source,total,added,overwritten,merged,skipped,bytes,warnings,files`。`bytes` 是计划写入文件的字节数;ZIP 预览需要下载完整压缩包,不应将此值当作预览流量。 + +确认时仅发送 `{"token":"预览返回值"}`;取消发送 `{"cancel":true,"token":"预览返回值"}`。预览有效期 15 分钟,配置或本地文件变化需重新预览。执行返回包含 `success,taskId,restored,backupDir,errors,restartRequired,message`(失败早于创建任务时部分字段不存在)。手动恢复需要维护状态与重启;跨文件提交不是文件系统原子事务。 + +`POST /api/webdav/sync-file` 是单文件兼容入口,JSON 为 `path,action`,`action` 为 `upload` 或 `download`。它不具备整批恢复的预览、事务及回退记录;常规恢复应使用上述预览流程。 + +### OpenList + +| 方法 / 路径 | 请求与行为 | +| --- | --- | +| `POST /api/openlist/test` | 可传 `endpoint,username,password,token,root`,未传使用已保存配置;只读测试,不保存 | +| `POST /api/openlist/sync` | 可选 `user,folder`;备份并执行已启用用户缓存规则,返回 `success,summary` | +| `GET /api/openlist/backups` | 返回 `success,list`;常规上传状态不等于完整内容校验结果 | +| `GET /api/openlist/metadata` | 当前轻量检查任务、当前连接的主文件记录数量 | +| `POST /api/openlist/metadata/start` | JSON `target` 为 `local`、`remote` 或 `both`;可传非空整数数组 `ids`,省略时检查当前连接全部记录。后台只读检查元信息,不传输正文、不删除文件 | +| `POST /api/openlist/metadata/cancel` | 停止轻量检查,保留已完成观察结果 | +| `POST /api/openlist/retry` | JSON `id` | +| `POST /api/openlist/retry-batch` | JSON `ids` | +| `POST /api/openlist/delete` | JSON `ids`;显式删除操作可能删除远端文件,返回 `deleted,remoteRemoved,remoteFailed` | +| `GET /api/openlist/restore/history` | 可恢复的云端历史版本 | +| `POST /api/openlist/restore` | 扫描预览:`preview:true,mode,types,backupBeforeRestore,snapshotId`;返回 `planToken` 及数量、文件清单、警告 | +| `POST /api/openlist/restore/jobs/start` | 提交相同恢复参数和 `planToken`,返回 `success,taskId`,任务后台运行 | +| `GET /api/openlist/restore/jobs` | 恢复任务列表及 `maintenance` 状态 | +| `POST /api/openlist/restore/jobs//cancel` | 停止任务 | +| `POST /api/openlist/restore/jobs//resume` | 继续可恢复任务;提交中断需先处理回退 | +| `POST /api/openlist/restore/jobs//rollback` | 回退本次恢复,返回 `restartRequired` | +| `DELETE /api/openlist/restore/jobs/` | 删除任务与本地副本 | +| `POST /api/openlist/integrity/preview` | JSON 可选 `type,user,scope`;scope 为 `changed`(默认)、`all` 或 `problems`,返回 `token,total,bytes,unknown` | +| `POST /api/openlist/integrity/start` | JSON `token`,启动手动内容校验 | +| `GET /api/openlist/integrity` | 当前校验任务和结果 | +| `POST /api/openlist/integrity/cancel` | 停止内容校验 | + +OpenList 恢复的 `mode` 是 `missing` 或 `overwrite`(默认),与 WebDAV 的来源 `mode` 含义不同。`types` 可选,使用五类数据标识;未传使用已保存范围。`snapshotId` 省略恢复当前备份,指定时使用历史版本;`backupBeforeRestore` 默认 `true`。WebDAV 的 `token`、OpenList 恢复的 `planToken`、内容校验的 `token` 互不通用。 + +维护期间可读取状态/配置及访问恢复管理、回退和重启入口;普通业务接口可能返回 503。重启后再继续备份或播放。内容校验可能下载文件正文,`bytes` 为最大正文读取估算,`unknown` 为缺少上传指纹的文件数;校验不会自动修复文件。 + + +### 备份概览状态 + +`GET /api/backup/status` 使用 `x-frontend-auth` 鉴权。返回 `success,maintenance,webdav,openlist`,未启用的服务返回 null。WebDAV 包含 `running,restoring,nextSyncAt,nextZipAt,lastActivity,lastError`;OpenList 包含 `running,checking,nextSyncAt,nextCheckAt,counts,lastRun,lastRecordAt,lastError`。时间为毫秒时间戳,0 表示未安排。下一次时间是调度检查时间,不保证届时一定传输文件。运行结果摘要在进程重启后重新记录,持久化文件记录与整轮执行结果分开呈现。此接口不读取云端文件正文;未启用的服务在界面隐藏详情与执行按钮。 diff --git a/docs/api/routes.md b/docs/api/routes.md index 22764064..681a8e55 100644 --- a/docs/api/routes.md +++ b/docs/api/routes.md @@ -16,6 +16,33 @@ | --- | --- | --- | | GET | `/api/status` | — | +## dashboard + +| 方法 | 路径 | 直接读取字段(部分) | +| --- | --- | --- | +| GET | `/api/dashboard/overview` | — | + +## ratings + +| 方法 | 路径 | 直接读取字段(部分) | +| --- | --- | --- | +| POST | `/api/subsonic/sync-link` | `user`, `direction`, `preview`, `reconcile` | +| GET / POST | `/api/data/rating-sync` | 查询参数 `user` | +| GET / POST | `/api/music/rating-preferences` | `dislikeAutoRating` | +| GET / POST | `/api/music/ratings` | `operation`, `songs`, `includeDetails`, `song`, `rating` | + +## openlist/connection + +| 方法 | 路径 | 直接读取字段(部分) | +| --- | --- | --- | +| GET / POST | `/api/openlist/connection` | — | + +## files + +| 方法 | 路径 | 直接读取字段(部分) | +| --- | --- | --- | +| GET / HEAD | `/api/files` | 查询参数 `path`;委托 VueFinder,详见 API 参考 | + ## users | 方法 | 路径 | 直接读取字段(部分) | @@ -518,7 +545,7 @@ | 方法 | 路径 | 直接读取字段(部分) | | --- | --- | --- | -| POST | `/api/webdav/restore` | — | +| POST | `/api/webdav/restore` | `preview,mode,targetFilename,strategy,keepCopies,token,cancel` | ## webdav/logs @@ -578,3 +605,36 @@ | POST | `/api/music/remaster/start` | — | | GET | `/api/music/remaster/status` | — | | POST | `/api/music/remaster/cancel` | — | + +## 备份恢复任务与内容校验 + +鉴权、完整请求参数及执行顺序见 [备份接口说明](./reference.md#backup-api)。 + +| 方法 | 路径 | 主要字段 / 用途 | +| --- | --- | --- | +| GET | `/api/webdav/restore/tasks` | `tasks` | +| POST | `/api/webdav/restore/tasks//rollback` | `id` | +| DELETE | `/api/webdav/restore/tasks/` | `id` | +| POST | `/api/openlist/test` | `endpoint,username,password,token,root` | +| POST | `/api/openlist/sync` | `user,folder` | +| GET | `/api/openlist/backups` | `list` | +| GET | `/api/openlist/metadata` | `job,total` | +| POST | `/api/openlist/metadata/start` | `target,ids?` | +| POST | `/api/openlist/metadata/cancel` | `—` | +| POST | `/api/openlist/retry` | `id` | +| POST | `/api/openlist/retry-batch` | `ids` | +| POST | `/api/openlist/delete` | `ids` | +| POST | `/api/openlist/restore` | `preview,mode,types,backupBeforeRestore,snapshotId,planToken` | +| GET | `/api/openlist/restore/history` | `history` | +| GET | `/api/openlist/restore/jobs` | `tasks,maintenance` | +| POST | `/api/openlist/restore/jobs/start` | `planToken,mode,types,backupBeforeRestore,snapshotId` | +| POST | `/api/openlist/restore/jobs//cancel` | `id` | +| POST | `/api/openlist/restore/jobs//resume` | `id` | +| POST | `/api/openlist/restore/jobs//rollback` | `id` | +| DELETE | `/api/openlist/restore/jobs/` | `id` | +| GET | `/api/openlist/integrity` | `job,results` | +| POST | `/api/openlist/integrity/preview` | `type,user,scope` | +| POST | `/api/openlist/integrity/start` | `token` | +| POST | `/api/openlist/integrity/cancel` | `—` | + +| GET | `/api/backup/status` | `success,maintenance,webdav,openlist` | diff --git a/docs/api/subsonic.md b/docs/api/subsonic.md new file mode 100644 index 00000000..cc725dd5 --- /dev/null +++ b/docs/api/subsonic.md @@ -0,0 +1,109 @@ +# Subsonic 协议支持 + +本服务实现 Subsonic / OpenSubsonic 接口,用于将 LX 歌单、在线音源和账号本地曲库提供给兼容客户端。接口行为由服务端配置、音源可用性及客户端能力共同决定。本页按功能说明支持范围,不代表所有客户端提供相同界面。 + +本地文件目录、身份和歌单持久化见 [Subsonic 本地音乐库](../guide/subsonic-local-library.md),连接设置见 [同步服务器设置](../guide/sync-server.md)。 + +## 接入方式 + +默认协议路径为 `/rest`,与主服务共用端口;路径可通过 `subsonic.path` 调整。使用独立端口时,必须同时启用 `subsonic.standalone` 并设置有效的 `subsonic.port`,只设置端口不会启用独立监听。 + +使用用户账号的同步密码认证,不使用管理密码。接口支持 JSON / XML;以下路径中的参数仅示意,正式客户端应提供协议认证和版本等参数。 + +```text +GET /rest/ping.view?u=&t=&s=&v=1.16.1&c=&f=json +``` + +## 支持的接口 + +### 系统、浏览与搜索 + +| 方法 | 行为与限制 | +| --- | --- | +| `ping`、`getLicense`、`getUser` | 连接检查、许可信息和当前用户信息 | +| `getMusicFolders` | 返回服务器设置入口、NAS 本地音乐及在线平台入口 | +| `getMusicDirectory` | 目录、专辑、歌单和本地音乐入口 | +| `getIndexes`、`getArtists`、`getArtistList` | 艺术家索引;`getArtistList` 为兼容入口 | +| `getArtist`、`getArtistInfo`、`getArtistInfo2` | 艺术家详情和信息 | +| `getAlbum`、`getAlbumInfo`、`getAlbumInfo2` | 专辑歌曲、介绍及封面信息 | +| `getAlbumList`、`getAlbumList2` | 专辑列表、推荐和最近播放等;指定 NAS 文件夹时使用本地分支 | +| `getGenres` | 在线或本地曲库的流派信息 | +| `search`、`search2`、`search3` | 根据在线搜索设置和查询前缀选择数据来源 | +| `startScan`、`getScanStatus` | 刷新本地索引,返回扫描状态、数量和失败原因;不是固定占位状态 | + +在线搜索使用 `subsonic.onlineSearch` 与 `subsonic.onlineSearchMode`:`fallback` 在本地结果不足时补充在线结果,`merge` 合并结果,`local_only` 只查本地。可使用 `local:`、`online:` 或平台前缀限定范围;在线播放仍需要可用的自定义音源。 + +### 媒体、歌单与收藏 + +| 方法 | 行为与限制 | +| --- | --- | +| `getSong` | 在线或本地歌曲信息 | +| `stream`、`download` | 音频读取;本地文件支持 Range,在线资源依赖音源解析 | +| `getCoverArt` | 歌曲、专辑及歌单封面,支持 `size` 参数 | +| `getLyrics`、`getLyricsBySongId` | 普通及结构化歌词;来源和逐字歌词限制见本地音乐库说明 | +| `getPlaylists`、`getPlaylist` | 用户、共享及已启用的只读本地歌单 | +| `createPlaylist`、`updatePlaylist`、`deletePlaylist` | 用户歌单管理;内置和共享歌单有写入限制 | +| `getStarred`、`getStarred2`、`star`、`unstar` | 查询及设置收藏 | +| `setRating` | 按用户保存 0–5 分;0 表示清除评分 | + +纯本地与混合歌单的同步规则、删除行为和备份文件见 [收藏与歌单同步](../guide/subsonic-local-library.md#收藏与歌单同步)。 + +### 发现与电台 + +| 方法 | 行为与限制 | +| --- | --- | +| `getRandomSongs`、`getSongsByGenre`、`getSongsByGenre2` | 随机或按流派返回歌曲,支持本地库分支 | +| `getSimilarSongs`、`getSimilarSongs2`、`getSonicSimilarTracks` | 基于同歌手等条件挑选相似歌曲,不进行声学分析 | +| `getTopSongs` | 艺术家热门歌曲 | +| `getRecommendedSongs`、`getDailySongs`、`getSongsByTag` | 服务端推荐入口;后两者为别名,`getSongsByTag` 不执行独立标签检索 | +| `getInternetRadioStations` | 返回可用的官方电台、用户电台及音源歌单电台 | +| `createInternetRadioStation`、`updateInternetRadioStation`、`deleteInternetRadioStation` | 用户电台管理;另接受复数形式的删除兼容入口 | + +每日推荐从在线推荐专辑获取歌曲并去重,同一天使用稳定的洗牌顺序。上游不可用时可回退到上次成功结果。返回前按账号过滤落入“不喜欢”评分区间的歌曲:`0 < rating <= subsonic.dislikeRating`,默认阈值为 1。 + +服务端生成的电台流地址使用绝对 URL 和限时票据,不直接携带长期认证凭据;用户自建的外部电台地址按配置返回。反向代理应正确传递协议等信息,且客户端必须能访问这些地址。上游官方电台不可用时会被暂时隐藏。 + +### 播放活动、队列与书签 + +| 方法 | 行为与限制 | +| --- | --- | +| `scrobble` | 保存请求中的歌曲播放记录,接受播放时间参数 | +| `reportPlayback` | 更新播放状态和位置;仅 `stopped` 且未设置 `ignoreScrobble=true` 时记录播放历史 | +| `getNowPlaying` | 返回近期播放活动,可附带位置;并非对所有客户端的实时播放状态监测 | +| `getPlayQueue`、`savePlayQueue` | 按用户持久化播放队列 | +| `getPlayQueueByIndex`、`savePlayQueueByIndex` | 按索引读写队列;当前索引越界返回参数错误 | +| `getBookmarks`、`createBookmark`、`deleteBookmark` | 按用户持久化书签 | + +## 转码与媒体限制 + +| 配置项 | 默认值 | 用途 | +| --- | --- | --- | +| `subsonic.transcode.enabled` | `false` | 服务端转码总开关 | +| `subsonic.transcode.format` | `mp3` | 目标格式,可选 `mp3`、`opus`、`aac` | +| `subsonic.transcode.maxConcurrent` | `2` | 同时转码任务上限 | + +转码需要可用的 ffmpeg。`stream` / `download` 可结合客户端码率要求选择转码;`timeOffset` 使用秒。OpenSubsonic 的 `getTranscodeDecision` / `getTranscodeStream` 提供转码决策和签名参数。 + +ffmpeg 不可用时在线资源可降级为原始链接,不能保证原始格式符合客户端要求。目标格式、音源可用性和服务器资源均会影响播放结果。 + +## OpenSubsonic 扩展 + +`getOpenSubsonicExtensions` 声明以下扩展,版本均为 1: + +| 扩展 | 对应能力 | +| --- | --- | +| `formPost` | 表单 POST 参数 | +| `songLyrics` | 结构化歌词 | +| `playbackReport` | 播放状态与位置上报 | +| `sonicSimilarity` | 相似曲目入口,使用启发式挑选 | +| `indexBasedQueue` | 按索引管理播放队列 | +| `transcodeOffset` | 转码时间偏移 | +| `transcoding` | 转码决策与转码流 | + +声明扩展表示接口存在,客户端是否调用和展示仍取决于其实现。 + +## 不支持的范围 + +视频、播客、聊天、分享,以及通过 Subsonic 接口创建或修改服务器账号等功能不在当前实现范围。典型未支持方法包括 `getVideos`、`getPodcasts`、`getChatMessages`、`createShare`、`createUser`、`updateUser`、`deleteUser`、`changePassword` 和 `getAvatar`。 + +未支持的方法返回 `Method not found`。账号管理应使用后台管理页;客户端出现缺失控件或兼容问题时,应先核对本页能力范围与该客户端实际请求的方法。 diff --git a/docs/chromecast/architecture.md b/docs/chromecast/architecture.md deleted file mode 100644 index 35611518..00000000 --- a/docs/chromecast/architecture.md +++ /dev/null @@ -1,30 +0,0 @@ -# Cast architecture - -`public/music/js/player/playback.js` adapts the native audio element behind the player's existing media properties, methods and events. Local media events pass through. While casting, the adapter routes controls to the remote output and suppresses native events, preventing duplicate playback and false local recovery. Other audio modules retain the real DOM element. - -`chromecast.js` loads Google's official Web Sender SDK in supported secure browser contexts and uses the Default Media Receiver; no custom application registration or server dependency is required. RemotePlayer status drives transport events, position and volume. LOAD commands are serialized and versioned so stale preparation cannot replace a newer track. A seek made during LOAD is applied after it completes; local stall timers cannot recover across a route change. Pending play/pause requests both take effect after LOAD. Cast route changes synchronize lyric speed; local startup restoration yields to resumed remote sessions. Standard/local artwork is resolved through the shared image helper and retained in session metadata. Completion is delivered once. Session resumption adopts existing media without another LOAD. The shared controls remain available on mobile layouts. Equal side columns keep the transport centered, with repeat on the left and speed/wireless controls on the right. Local crossfade is suspended while casting, including an in-progress fade, to preserve receiver volume. - -`src/server/integrations/castAccess.ts` issues opaque, random capabilities: - -- `POST /api/music/cast/session`: existing player/account authentication, `{url, mime}`; returns a resource-scoped URL and expiry. -- `GET /api/music/cast/session?id=…`: authenticated issuing owner can recover the normalized source for resumption. -- `DELETE /api/music/cast/session?id=…`: authenticated owner revokes a capability. -- `GET/HEAD /api/music/cast/media?id=…`: verifies expiry and the original credentials, then internally routes the immutable target through existing music handlers. Receiver-supplied URLs and authentication headers cannot change its scope. - -Capabilities live only in bounded process memory (two hours, sixteen per owner, 1,024 total) and are revoked after track replacement/disconnection. Receiver URLs contain no account credential. Internal music targets are allowlisted; external URLs use the existing public-address validation, pinned DNS connection and redirect validation. Cached/custom media retains its usual ownership/containment rules. Tagging/mutation parameters are excluded from proxy targets. CORS, exposed range headers, no-store responses and HEAD support permit receiver reads and seeking. - -Native same-origin audio, artwork and browser downloads use the HttpOnly player session cookie (Secure on HTTPS); the player removes account query tokens from these URLs. Cookie fallback is confined to read-only media handlers and validates the account and session lifetime. Legacy API query-token handling remains for compatibility, but the player does not generate it. API mutations still require their existing header authentication. Local sound-effect changes are saved during wireless playback without building or updating a local graph. - -The current combined submission caches first-party modules under `lx-music-web-v51`. Google's SDK stays network-loaded. SDK failure or unsupported browsers leave local playback available. - -References: [Google Web Sender setup](https://developers.google.com/cast/docs/web_sender), [integration](https://developers.google.com/cast/docs/web_sender/integrate), [supported media](https://developers.google.com/cast/docs/media). - -The combined deployment profile retains its existing custom VU themes and pass-through audio policy: stored crossfade is disabled and direct volume updates do not affect a remote receiver. Transient preload failures remain retryable during foreground playback. - -The combined profile, including the user's earlier VU/audio/login customizations, is now included in the upstream submission. `/js/config.js` reads the running package version independently of generated frontend metadata; `scripts/update-build-hash.js` synchronizes the public version and source fingerprint during builds/starts. Player and About share that identity and never infer a release number from unavailable metadata. - -Cookie recovery first checks native media access without API headers, then restores the cookie through the existing Token-authenticated verification endpoint. Password renewal is only attempted when a saved password exists; Token-only credentials remain intact. Login supports explicit account and player-password modes, with separate guest credential storage and matching automatic restoration. Once a desktop effects graph captures audio, viewport resizing does not disable its controls; wireless routes still bypass local edits. - -Token verification returns the session owner and upgrades guest or other-account cookies to the authenticated account before native media. Existing-Token fast paths also verify this binding. Cast quota ownership uses only a validated, unexpired stored session or a verified account; unknown cookies share the public quota. Playlist card metadata is escaped in both layouts, with JSON arguments escaped for inline click attributes. - -Mobile loads remain native until an explicit local effect edit opts into DSP. The panel reports native, local-processed or receiver output. After mobile capture the in-page AirPlay picker directs the user through a native reload, because disconnecting Web Audio does not release a captured media element. That reload preserves queue/position with a one-time session flag without changing the auto-resume preference. Existing wireless/Cast edits remain saved-only. diff --git a/docs/chromecast/progress.md b/docs/chromecast/progress.md deleted file mode 100644 index 46609a75..00000000 --- a/docs/chromecast/progress.md +++ /dev/null @@ -1,118 +0,0 @@ -# Chromecast progress - -## Complete customization submission and version identity — 2026-10-20 - -### Expanded review before deployment - -- [x] Final thread audit found three older unresolved findings: unescaped playlist metadata/click arguments, account Tokens retaining a guest media cookie, and Cast quotas using unvalidated cookie IDs. Reproduced nine cases against `6a48885` and corrected them. Existing Token/login paths now bind the matching account cookie before media; account verification promotes guest/other-account sessions; playlist row/grid templates escape remote metadata and safely encode click arguments; Cast ownership accepts only live stored sessions. -- [x] Follow-up passes all159tests (147CJS+12TS), type/build/JS/diff and53 compiled-server HTTP checks, including actual guest-to-account cookie promotion and private media reads. Follow-up fingerprint `9925f04`, worker51. -- [x] Deploy follow-up `4ebc5bc` over the verified prior image: 53 isolated NAS/52 safe live HTTP checks, 264 file hashes, 12 session/10 effects/5 login regressions per image, eleven authenticated HTTPS assets and five initialized sources pass. Six existing files updated/258 preserved; configuration/environment/six mounts and all51other containers unchanged. Protected stopped-state rollback evidence retained; candidate/clone removed. Current deployment is worker51/build `9925f04`, superseding the first successful worker50 deployment below. The six corrected review threads are resolved; official merge remains separate. - -- [x] Reproduce token-only cookie recovery failure, loss of a valid Token without a saved password, touch-desktop resize loss of DSP control, and missing player-password login/restore behavior. -- [x] Restore a native media cookie through the existing Token-authenticated verification endpoint before considering password renewal; retain Token-only credentials. Keep an already established desktop effects graph controllable after resize while still yielding to AirPlay/Cast. Restore explicit account/player-password login modes, mode-aware persistence and automatic recovery without a username for guests. -- [x] Pass all 149 tests (137 CJS + 12 TypeScript), type/build/JS/diff checks and 50 isolated compiled-server HTTP checks. Repin version `v2.1.3`, fingerprint `93301ce`, worker v50; the earlier v49 artifact is superseded before deployment. - -- [x] Include all remaining tracked customizations in the official submission while preserving the user's earlier commit history: VU themes/geometry/stereo routing, native/pass-through audio policy, compact login, search compatibility, quality badges, foreground preload retry and the combined conflict fixes. -- [x] Include the subsequent QQ/Safari MIME normalization, mobile/wireless effects guards, playback-session recovery, credential-log removal and closed-drawer animation optimization. -- [x] Replace fabricated Player/About/runtime-config v1.0.0 fallbacks. Read the running package version, synchronize generated version metadata at build/start, and show the source fingerprint beside the Player version. About uses the same metadata. -- [x] Add regressions for stale/missing metadata, reproducible build identities, source/version changes, matching Player/About labels and escaped About substitutions; refresh worker v49. -- [x] Pass all 141 tests (129 CJS + 12 TypeScript), TypeScript checking, production compilation, JS syntax and diff checks. The compiled server passes 48 isolated HTTP checks using disposable account/data/configuration, including version/fingerprint agreement, login, media ranges and Cast access. No configured external provider or physical receiver was used in this run. -- [x] Update official PR #451 with the complete profile, original customization history and current results; code checkpoint `f3535de`. The expanded PR contains 59 changed files. Its new automated review is pending and is separate from the previously resolved review threads. -- [x] Prepare a pinned six-file release overlay plus full before/after manifests, verification scripts and rollback handoff; schedule a quiet 30-minute connectivity retry that stops after verified deployment. -- [x] Deploy reviewed code `6a48885`, fingerprint `93301ce`, worker v50 after connectivity returned. All 263 pre-deployment hashes matched; the isolated NAS candidate passed 50 HTTP checks, all 264 resulting hashes, 12 session/10 effects/4 login regressions and initialization of all five sources. Back up current data/configuration and recreate only LX; live verification passed 49 safe HTTP checks, the same hashes/regressions, eleven authenticated HTTPS assets and five initialized sources. Configuration bytes, environment, six mounts and all 51 unrelated containers were preserved; the candidate/clone were removed and rollback evidence retained. No new physical receiver, Windows or iPhone test is claimed. - -The dated sections below record earlier verification and the historical separation of public/custom profiles. This submission combines them; earlier hardware results are not a fresh physical receiver test of the new build. The user's pre-existing customizations retain their original commits and attribution. - -- [x] Review existing playback, authentication and Google Web Sender integration. -- [x] Implement mobile-accessible Cast controls, remote transport and local handoff. -- [x] Implement expiring resource capabilities without sharing general login credentials. -- [x] Preserve native Safari/AirPlay and existing player behavior. -- [x] Keep transport centered with equal side columns; group speed and wireless controls on the right. -- [x] Integrate on the latest upstream `dev`, retaining its HTTP cookie, metadata, playback recovery and synchronization fixes. -- [x] Prevent local crossfade from modifying Cast volume, including fades already running during handoff. -- [x] Pass 76 automated checks, TypeScript checking and production compilation. -- [x] Start the compiled server with isolated temporary data; verify HTTP cookie login, authenticated player markup, Cast assets and worker version. - -An earlier compatible deployment passed 35 isolated/live runtime checks, 30 existing metadata/artwork checks and release asset verification. Desktop Chrome and an Onkyo TX-RZ50 accepted MP3 and reported advancing playback, pause, seek and disconnect; local audio stayed paused while casting. Responsive controls were checked at desktop and 320px widths with zero center offset and no control overlap. These hardware/layout checks describe that deployed version; the latest upstream integration has automated and isolated-startup coverage, not another physical receiver run. - -Live validation also reproduced and fixed playlist-category escaping-helper shadowing and a batch-module export referring to a function defined by a later script. Regression tests cover both startup/rendering failures. - -Physical Android, other receiver/codec combinations, independent audible monitoring and iOS lock-screen/DSP transitions remain separate manual checks. - -## Combined deployment profile - -- [x] Merge the upstream integration with the existing customization branch, preserving the custom stereo VU themes, search response compatibility, quality badges, pass-through volume policy and transient preload retry behavior. -- [x] Retain lyric-title, composite artist and raster artwork behavior; include the 30 existing metadata regressions in the combined suite. -- [x] Pass all 82 tests, including saved pass-through settings and transient preload retry regressions; refresh the combined frontend worker to v42. -- [x] Validate and deploy the combined release: 38 isolated and 38 live runtime checks; 179 release file hashes and 84 retained static asset hashes match; five sources initialize successfully. -- [x] Verify signed-in HTTPS assets, secure cookies and centered desktop/320px controls; configuration bytes, six mounts and 51 unrelated containers remain unchanged. - -The existing customization history includes eleven commits from October 12–13, before the October 19 audit, covering VU themes, audio behavior, search compatibility and badges. These are retained from the user's fork. Commit author identities alone do not distinguish manual work from assistant-generated work. - -## Combined-code compatibility audit - -- [x] Reproduce and fix nine behavioral conflicts across fixed audio policies, automatic login, sync surfaces, Cast/recovery handoff and custom VU rendering. -- [x] Pass all 91 tests, type checking and production compilation; retain the existing lyric/artwork, security and playback regressions. -- [x] Deploy code `6cafc09`: 41 isolated and 41 live runtime checks pass, seven changed and 256 preserved file hashes match, all five sources initialize, and signed-in HTTPS/browser checks pass. Configuration, six mounts and 51 unrelated containers remain unchanged. -- [x] Port the four shared Cast/recovery/sync corrections to upstream PR #451 (`abd404a`), with all 54 tests and type checks passing. - -The compatibility audit (`combined-conflict-audit.md`, not included in this documentation tree) records reproductions, fixes and validation limits. Frontend worker v43 refreshes the corrected assets. - -- [x] Follow-up compatibility audit: retain seeks during pending Cast LOAD, reject stale local-recovery timers after route changes, and synchronize unique sync-status views with the correct initiating connection form. Four focused regressions cover these shared fixes. - - -## PR review follow-up - -- [x] Verify both automated review batches against the current source; address all twelve distinct findings. -- [x] Preserve standard/scoped artwork, pending play intent, lyric speed and resumed Cast sessions. -- [x] Save local EQ/preset/reverb/pitch/panner settings during wireless playback without capturing native audio. -- [x] Remove generated reusable account tokens from native media/artwork/download URLs; validate read-only account-cookie access and rejection of guest/expired/deleted sessions. -- [x] Reject empty administrator secrets, revoke credentials before user-space rename/migration, escape playlist statistics/errors, and use a Windows-compatible directory-link fixture. -- [x] Isolate shared-download cancellation and retry unaffected callers; serialize differing lyric/provenance requirements and use separate temporary paths for retries. -- [x] Limit proxy timeout to response headers and terminate downstream output on request/response failure or upstream abort. -- [x] Pass all 76 tests, type checking and production compilation; refresh first-party assets with worker v44. -- [x] Start the corrected compiled application with disposable data: ten isolated checks pass for login/cookie flags, authenticated player/worker and cookie-only private media authorization. Historical source replay reproduces twenty failures across 59 focused tests; the corrected suite passes. - - -## Reviewed fixes in the customized deployment - -- [x] Merge PR review corrections through `2e833cf` with the custom audio/login/VU profile, retaining the previous nine compatibility corrections. -- [x] Pass all 116 combined tests (104 CJS + 12 TypeScript), type checking and production compilation. -- [x] Validate the isolated image: 45 runtime checks, all five sources initialized, 11 updated and 252 retained file hashes match. -- [x] Back up and deploy code `fc98ffc`: 45 live checks and ten authenticated HTTPS asset hashes pass. Configuration bytes, environment values, six mounts and 51 unrelated containers are preserved. Desktop/320px controls are centered without overlap, artwork loads without account query tokens, and playback remains paused at its prior position. - -The combined release uses worker v45. The public PR remains a separate profile and is not merged into the official repository by this deployment. - -## Safari proxy media compatibility - -- [x] Reproduce a CDN response carrying M4A audio with `application/x-www-form-urlencoded`, then normalize generic MIME labels using recognized media extensions on successful proxy responses. Preserve explicit document/error types, failed statuses, byte ranges and upstream address validation. -- [x] Pass all 116 tests, TypeScript checking and production compilation. The regression fails on the preceding implementation; it covers mislabeled AAC, generic FLAC, explicit JSON and upstream rejection. -- [x] Deploy the backend correction `e8256ce` on the customized profile: 45 isolated and 45 live runtime checks, one updated and 262 preserved file hashes, ten authenticated HTTPS assets and actual proxied AAC range bytes/MIME pass. Configuration, environment, mounts and unrelated containers remain unchanged; frontend assets/worker v45 are retained. - -A provider authorization error is separate from LX account authentication. A successful source response labeled with the requested quality does not establish actual master audio; observed fallbacks included AAC and MP3. This correction repairs media headers and does not grant provider access or establish physical iPhone playback success. - -## Effects and native AirPlay playback - -- [x] Reproduce five failures involving forced mobile media capture, resets on existing wireless graphs, and pending pitch/reverb/panner work after AirPlay/Cast handoff. -- [x] Keep mobile audio native even for forced effects initialization; save edits while bypassing local processing on mobile and wireless routes. Guard resets, server-loaded settings and asynchronous DSP work; apply deferred settings to an existing desktop graph on return to local playback. Explain the saved-setting behavior when opening the effects panel. -- [x] Pass all 124 tests and TypeScript checking. Eight route/effects regressions also pass against the isolated and live image modules; desktop effects remain functional. -- [x] Deploy `503e2ab` with worker v46: 45 isolated and 45 live runtime checks, two updated/261 retained file hashes and ten authenticated HTTPS asset hashes pass. Prior fixes/customizations and configuration/environment/mounts/unrelated containers are preserved. - -Existing pages that already captured the audio element need a full reload to start with native mobile playback. Physical iPhone/AirPlay playback still needs user confirmation; simulated route transitions do not establish receiver behavior. - -## Playback authentication recovery - -- [x] Reproduce valid API credentials alongside an expired/missing native-media cookie: audio returns 401 while the server-availability endpoint returns HTTP 200 with `valid:false`. The user reported Windows Chrome/all songs affected, then spontaneous recovery before this patch; the exact browser incident remains an inference from the reproduced gap and timing. -- [x] Renew the API token and media cookie together; retry URL authentication once with refreshed headers. Repair native playback sessions before excluding providers, retain the original track/resume time, reject stale recovery after a track change and show a login prompt when recovery fails. Remove account-password values from startup logging and duplicate-password errors. -- [x] Pass all 134 tests, type checking and compilation. Each isolated/live module passes ten session regressions and eight retained AirPlay/effects regressions, alongside 45 runtime checks and ten authenticated HTTPS asset hashes. -- [x] Publish `b3aea76` through atomic file updates with worker v47. Three files updated/260 retained hashes verified; container ID/start/restart count, configuration/environment/mounts and unrelated containers preserved. Compose selects a tested image containing the same files for future recreation; the current process retains its prior base image. Startup-log redaction takes effect on its next start. - -QQ search errors were also observed during the reported interval; the investigation does not establish that every provider failed or that the external search service is repaired. Existing tabs load the frontend correction on their next full reload. - -## Song-list scrolling profile - -- [x] Measure search-list scrolling and isolate animation work with an A/B/A comparison. Closed drawers retained running marquee animations; pausing them reduced style-calculation work by 43–69% across the tested browser states. Total main-thread improvement varied, so these figures are not a guaranteed frame-rate gain. -- [x] Pause marquee animations only while queue/cache/download drawers are translated closed; preserve their behavior when open. Publish `0521bae` with worker v48 using atomic file updates and retain the current process. -- [x] Pass all 134 tests, 45 isolated/live runtime checks, ten session/eight effects regressions per image, two updated/261 retained hashes and eleven authenticated HTTPS asset hashes including the stylesheet. Browser checks confirm zero running marquee tracks in the closed queue, normal resumption on open, removed diagnostic overrides and preserved paused position. - -The profile used connected macOS Chrome, 20 visible search rows and a 98-track queue; the user reported Windows Chrome search/playlists. Windows hardware/GPU/extensions were not profiled and are not ruled out. NAS load was low at the sampled time. No virtualization or saved page-size setting was changed. diff --git a/docs/chromecast/requirements.md b/docs/chromecast/requirements.md deleted file mode 100644 index 364c21f2..00000000 --- a/docs/chromecast/requirements.md +++ /dev/null @@ -1,13 +0,0 @@ -# Chromecast playback - -Use the player in a Cast-enabled desktop or Android Chrome browser over HTTPS. A Cast button appears when the Google sender SDK is ready; opening its picker starts receiver discovery. Safari and iOS keep their existing playback and AirPlay behavior; Google's Web Sender does not support iOS Chrome. - -Select a receiver to transfer the current track and position. The normal player controls must operate remote play/pause, seeking, volume, mute, previous/next and automatic queue advancement. Disconnect returns the current position and playback intent to the local player. Connecting must preserve the receiver's existing volume. - -Support online music, server cache/downloads and configured custom music directories. Browser-only blob/file URLs cannot be fetched by a receiver and must produce a useful error. Receiver codec support varies; users can select another quality/source if necessary. Casting uses normal speed; local playback speed is restored on disconnect. The receiver fetches the original audio, so browser audio effects and visualizers do not process its output. - -Never send account tokens, cookies or administrator credentials to a receiver. Its media capability must permit only one immutable read resource, expire, and stop working after the issuing credentials are revoked. Existing ownership, filesystem and public-upstream restrictions remain mandatory. - -Acceptance: automated local/remote handoff and race tests, actual HTTP range/HEAD/CORS and authorization tests, existing AirPlay regressions, type checking, production compilation, isolated release startup and deployed API/asset verification. Report real receiver playback separately from simulations and network advertisements. - -Player and About must identify the running package version plus a reproducible source build fingerprint. Custom builds sharing an upstream release number must remain distinguishable. Missing generated config must not fabricate an older release; package metadata remains authoritative when available, otherwise show an unavailable state. diff --git a/docs/components/MusicLanding.vue b/docs/components/MusicLanding.vue index 62705f4e..078fa99a 100644 --- a/docs/components/MusicLanding.vue +++ b/docs/components/MusicLanding.vue @@ -12,6 +12,7 @@ const doc = (path: string) => { } const asset = (name: string) => withBase(`/showcase/${name}.jpg`) const clipNames: Record = { 'player-library': 'playlists', search: 'search', charts: 'charts', themes: 'themes', 'admin-dashboard': 'console' } +const mobileShot = (image: string) => image === 'local-library' ? asset('local-library-mobile') : '' const clipFor = (image: string) => clipNames[image] || '' const posterFor = (image: string) => asset(clipFor(image) ? `${clipFor(image)}-poster` : image) const copy = computed(() => english.value ? { @@ -142,10 +143,10 @@ function chooseClient(index: number, focus = false) {

{{ line }}

{{ copy.libraryIntro }}

- +
-
{{ copy.interfaceNote }}
+
{{ copy.interfaceNote }}
diff --git a/docs/components/music-landing.css b/docs/components/music-landing.css index 294df59e..c6f7fd2e 100644 --- a/docs/components/music-landing.css +++ b/docs/components/music-landing.css @@ -365,3 +365,39 @@ .is-animated .music-footer-bottom { font-size: 13px; } .is-animated .music-footer-bottom button { font-size: 13px; } } + +/* ===== Motion layer ===== */ +.music-landing::before, .music-landing::after { content: ''; position: fixed; z-index: -1; width: 60vmax; height: 60vmax; border-radius: 50%; filter: blur(90px); opacity: .2; pointer-events: none; } +.music-landing::before { top: -25vmax; left: -15vmax; background: radial-gradient(circle, #6fcf8a, transparent 65%); animation: music-drift-a 18s ease-in-out infinite alternate; } +.music-landing::after { bottom: -30vmax; right: -20vmax; background: radial-gradient(circle, #4a8fe0, transparent 65%); opacity: .13; animation: music-drift-b 22s ease-in-out infinite alternate; } +@keyframes music-drift-a { to { transform: translate3d(18vmax, 12vmax, 0) scale(1.15); } } +@keyframes music-drift-b { to { transform: translate3d(-16vmax, -10vmax, 0) scale(1.2); } } +.music-nav { backdrop-filter: blur(14px) saturate(1.3); -webkit-backdrop-filter: blur(14px) saturate(1.3); animation: music-fade-down .8s cubic-bezier(.2,.8,.2,1) both; } +.is-animated .music-nav { background: rgba(16,20,17,.62); } +.is-animated.is-closing .music-nav { background: rgba(180,231,160,.9); } +@keyframes music-fade-down { from { opacity: 0; transform: translateY(-16px); } } +.music-nav-action, .music-button { position: relative; overflow: hidden; } +.music-nav-action::after, .music-button::after { content: ''; position: absolute; inset: 0; background: linear-gradient(110deg, transparent 30%, rgba(255,255,255,.4) 50%, transparent 70%); transform: translateX(-120%); animation: music-shine 4.5s 1.5s ease-in-out infinite; } +@keyframes music-shine { 40%, 100% { transform: translateX(120%); } } +.music-product-window { box-shadow: 0 40px 90px -30px rgba(0,0,0,.7), 0 0 0 1px rgba(255,255,255,.06); border-radius: 12px; overflow: hidden; } +.music-install-options article { transition: transform .35s cubic-bezier(.2,.8,.2,1); } +.music-install-options article:hover { transform: translateY(-6px); } +.music-signal span { animation: music-pulse 1.4s ease-in-out infinite; } +.music-signal span:nth-child(2) { animation-delay: .2s; } .music-signal span:nth-child(3) { animation-delay: .4s; } +@keyframes music-pulse { 50% { transform: scale(2.2); opacity: .4; } } + +/* ===== Mobile: portrait clips shown whole ===== */ +@media (max-width: 759px) { + .music-landing::before, .music-landing::after { filter: blur(60px); } + .music-product-images { aspect-ratio: 9 / 17; background: #0b0e0c; } + .music-product-shot picture, .music-product-shot picture > img { display: block; width: 100%; height: 100%; } + .music-product-shot > picture > img, .music-product-shot video, .music-hero-media video { object-fit: contain; object-position: center; } + .is-animated .music-product-shot:has(video) > picture, .is-animated .music-hero-media:has(video) > img { display: none; } + .is-animated .music-product-window { width: min(78%, calc(38svh * .53)); margin: 0 auto; } + .is-animated .music-hero-media { left: 21%; width: 58%; top: 55%; border-radius: 14px; overflow: hidden; } + .is-animated .music-hero-media video, .is-animated .music-product-shot video { opacity: 1; background: #0b0e0c; } + .is-animated .music-hero-media video { position: relative; height: auto; aspect-ratio: 9 / 17; } + .music-rest-shot { aspect-ratio: auto; object-fit: contain; max-width: 320px; margin-inline: auto; display: block; } + .is-animated .music-collection-heading > p { display: none; } + .music-product-window figcaption, .is-animated .music-hero-media figcaption { text-align: center; } +} diff --git a/docs/en/api/reference.md b/docs/en/api/reference.md index 57b574e1..5ac581ed 100644 --- a/docs/en/api/reference.md +++ b/docs/en/api/reference.md @@ -289,22 +289,42 @@ Success is an **object**, not an array. Read `data.list`: - **`GET /api/stats`**: Performance and memory usage statistics. - **`POST /api/utils/check-dir`**: Validate if a local directory exists and is accessible. +#### Dashboard overview {#dashboard-overview} + + +`GET /api/dashboard/overview` requires `x-frontend-auth: ` and no query parameters. Invalid authentication returns HTTP 401. Successful responses are JSON with `Cache-Control: no-store`. + +| Response field | Meaning | +| --- | --- | +| `success` | `true` on success | +| `logs.totals` | `info`, `warn` and `error` counts within the last 24-hour window | +| `logs.buckets` | Twelve rolling hourly buckets with millisecond timestamp `t` and per-level counts | +| `logs.recent` | Up to eight recent warnings/errors, newest first, with `time`, `level` and `msg`; messages are limited to 200 characters | +| `disk` | `total` and `free` bytes on the data directory's filesystem, or `null` when unavailable | +| `heap` | `used` is process RSS, `total` is system memory and `external` is process external memory, all in bytes; these are not V8 heap usage and heap limits | +| `services` | WebDAV, OpenList and public-favorites enablement settings, plus restore-maintenance state; not remote connectivity checks | +| `node`, `pid` | Node.js version and process ID | + +Logs are filtered by time after reading at most the last 512 KiB of `app.log`; rotated files are not read. High log volume may prevent full 24-hour coverage. Unreadable or unrecognized logs can produce empty statistics, which do not establish the absence of errors. Buckets roll back from request time rather than aligning to clock hours. + +See [Subsonic local music library](../guide/subsonic-local-library.md) and [protocol support](./subsonic.md) for Subsonic behavior and coverage. + ### 9.2 Local Config Backups - **`GET /api/config/backups`**: List configuration snapshots. - **`POST /api/config/backup-now`**: Create immediate config snapshot. - **`GET /api/config/backups/download?file=`**: Download backup archive. - **`DELETE /api/config/backups/`**: Delete config backup. - **`POST /api/config/backups/restore`**: Restore configuration snapshot. -- **`GET /api/backup/download`**: Export full user data backup. +- **`GET /api/backup/download`**: Export ZIP using the saved WebDAV scope. - **`POST /api/backup/upload`**: Import and restore user data backup. ### 9.3 WebDAV Cloud Sync & Remote Backups - **`POST /api/webdav/test`**: Test WebDAV connection and credentials. -- **`POST /api/webdav/sync`**: Trigger full bidirectional WebDAV sync. +- **`POST /api/webdav/sync`**: Upload local files within the selected scope. - **`POST /api/webdav/sync-file`**: Sync single file to WebDAV. - **`POST /api/webdav/backup`**: Create remote WebDAV backup archive. - **`GET /api/webdav/backups`**: List backups stored on WebDAV. -- **`DELETE /api/webdav/backup`**: Delete remote backup (`?file=...`). +- **`DELETE /api/webdav/backup`**: Delete remote backup (JSON `filename`). - **`POST /api/webdav/restore`**: Restore data from WebDAV backup. - **`GET /api/webdav/logs`**: Get WebDAV synchronization logs. - **`GET /api/webdav/progress`**: **SSE streaming endpoint**, real-time sync progress. @@ -359,3 +379,91 @@ Errors return `{"message":"..."}` with the corresponding HTTP status. - Auto-link requires user token; success: `{"success":true,"data":...}`; business errors: HTTP 400, `{"success":false,"message":"..."}`. See the [current route index](./routes.md) for all route entries and directly read fields. + +## Backup and recovery API {#backup-api} + +For scripting; see [Backup and recovery](../guide/backup-recovery.md) for UI instructions. Admin requests use `x-frontend-auth: ` and JSON requests use `Content-Type: application/json`. User tokens do not replace admin authentication. Check `success`, `errors` and endpoint-specific `message`/`error`, not only HTTP 200. + +### Local archives and configuration history + +| Method / path | Contract | +| --- | --- | +| `GET /api/backup/download` | ZIP using the saved WebDAV scope; no enabled WebDAV required; not an unconditional full-directory export | +| `POST /api/backup/upload` | Multipart field `backup` (also accepts `file`); restores local ZIP with current scope, without the WebDAV preview token | +| `GET /api/config/backups` | `success,list,autoBackupEnabled,retentionDays,intervalHours,scheduleTime,nextBackupAt,lastBackupAt`; timestamps are milliseconds, 0 means unavailable | +| `POST /api/config/backup-now` | Create config copy; `success,filename`, or `error` on failure | +| `GET /api/config/backups/download?file=` | Download a configuration copy | +| `POST /api/config/backups/restore` | JSON `fileName`; config history restoration differs from data ZIP restoration | +| `DELETE /api/config/backups/` | Delete a local configuration copy | + +### WebDAV + +| Method / path | Contract | +| --- | --- | +| `POST /api/webdav/test` | Read-only directory checks with saved settings; returns `success,message,checks`, not proof of write/delete permissions | +| `POST /api/webdav/sync` | Upload selected local types, not bidirectional merging | +| `POST /api/webdav/backup` | Optional JSON `force`, default false; create/upload ZIP | +| `GET /api/webdav/backups` | `success,backups`, entries: `filename,basename,size,time,timeStr` | +| `DELETE /api/webdav/backup` | JSON `filename` within configured ZIP directory, not query `file` | +| `POST /api/webdav/restore` | Preview, then confirm with returned token; example below | +| `GET /api/webdav/restore/tasks` | `success,tasks`: state, downloaded/applied counts, errors and `canRollback` | +| `POST /api/webdav/restore/tasks//rollback` | Restore retained pre-restore copies; rejects subsequent local changes | +| `DELETE /api/webdav/restore/tasks/` | Delete local task/copies, not cloud backups; running/unresolved tasks cannot be deleted | +| `GET /api/webdav/logs` | Sync logs | +| `GET /api/webdav/progress` | SSE; also accepts query `auth`, which should not be exposed in shared links | + +Preview request: + +```json +{ + "preview": true, + "mode": "files", + "strategy": "missing", + "keepCopies": true +} +``` + +- `mode`: `files` (incremental directory), `zip`, or `auto` (default; ZIP fallback only for missing directory/no selected files). +- `targetFilename`: optional ZIP filename from the backup list; omitted selects latest ZIP. +- `strategy`: `missing` or `overwrite`; API default is **overwrite**, not missing-only. +- `keepCopies`: defaults to true. +- Response includes `success,token,source,total,added,overwritten,merged,skipped,bytes,warnings,files`. `bytes` counts planned write bytes; ZIP preview downloads the complete compressed archive, so this is not its preview transfer estimate. + +Confirm with `{"token":"returned-value"}` or cancel with `{"cancel":true,"token":"returned-value"}`. Plans expire after 15 minutes; configuration or local-file changes require a new preview. Execution returns `success,taskId,restored,backupDir,errors,restartRequired,message`; early failures may omit task fields. Manual restore enters maintenance mode and requires restart. Multi-file commit is not a filesystem atomic transaction. + +`POST /api/webdav/sync-file` is a legacy single-file endpoint with JSON `path,action`; action is `upload` or `download`. It does not provide batch preview, transaction or rollback records. Prefer the preview flow for recovery. + +### OpenList + +| Method / path | Contract | +| --- | --- | +| `POST /api/openlist/test` | Optional `endpoint,username,password,token,root`, falling back to saved values; read-only, does not save | +| `POST /api/openlist/sync` | Optional `user,folder`; backup and enabled user-cache rules, returns `success,summary` | +| `GET /api/openlist/backups` | `success,list`; upload status is separate from content verification | +| `GET /api/openlist/metadata` | Current metadata job and count of primary records for this connection | +| `POST /api/openlist/metadata/start` | JSON `target`: `local`, `remote`, `both`; optional nonempty integer `ids` array, omitted for all current-connection records. Starts a background read-only metadata check | +| `POST /api/openlist/metadata/cancel` | Stop the metadata check; completed observations remain saved | +| `POST /api/openlist/retry` | JSON `id` | +| `POST /api/openlist/retry-batch` | JSON `ids` | +| `POST /api/openlist/delete` | JSON `ids`; explicit deletion may remove remote files; returns `deleted,remoteRemoved,remoteFailed` | +| `GET /api/openlist/restore/history` | Available recovery versions | +| `POST /api/openlist/restore` | Preview with `preview:true,mode,types,backupBeforeRestore,snapshotId`; returns `planToken`, counts, files and warnings | +| `POST /api/openlist/restore/jobs/start` | Same restore parameters plus `planToken`; returns `success,taskId`, runs in background | +| `GET /api/openlist/restore/jobs` | Tasks and `maintenance` state | +| `POST /api/openlist/restore/jobs//cancel` | Stop task | +| `POST /api/openlist/restore/jobs//resume` | Continue an eligible task; interrupted commit needs rollback handling first | +| `POST /api/openlist/restore/jobs//rollback` | Roll back, returning `restartRequired` | +| `DELETE /api/openlist/restore/jobs/` | Delete task/local copies | +| `POST /api/openlist/integrity/preview` | Optional JSON `type,user,scope`: scope `changed` (default), `all`, `problems`; returns `token,total,bytes,unknown` | +| `POST /api/openlist/integrity/start` | JSON `token`, start manual content verification | +| `GET /api/openlist/integrity` | Current job and results | +| `POST /api/openlist/integrity/cancel` | Stop verification | + +OpenList restore `mode` is `missing` or `overwrite` (default), unlike WebDAV's source-mode field. Optional `types` uses the five scope identifiers, defaulting to saved types. Omit `snapshotId` for current backup or supply a historical version. `backupBeforeRestore` defaults to true. WebDAV tokens, OpenList restore `planToken` and integrity tokens are not interchangeable. + +Maintenance allows status/config reads, recovery management, rollback and restart; ordinary business APIs may return 503. Resume backup/playback after restart. Verification may read full remote bodies: `bytes` is the maximum body-read estimate and `unknown` counts unavailable upload fingerprints. Verification does not automatically repair files. + + +### Backup overview status + +`GET /api/backup/status` requires `x-frontend-auth`. Returns `success,maintenance,webdav,openlist`; disabled providers return null. WebDAV exposes `running,restoring,nextSyncAt,nextZipAt,lastActivity,lastError`. OpenList exposes `running,checking,nextSyncAt,nextCheckAt,counts,lastRun,lastRecordAt,lastError`. Times are milliseconds; 0 means no scheduled run. Next times indicate scheduler checks, not guaranteed transfers. Runtime summaries reset at process restart; persistent file records are separate. No cloud file bodies are read. The UI hides details and execution actions for disabled services. diff --git a/docs/en/api/routes.md b/docs/en/api/routes.md index 09be2c61..604f01f9 100644 --- a/docs/en/api/routes.md +++ b/docs/en/api/routes.md @@ -16,6 +16,33 @@ See [API reference](./reference.md). File operations are delegated to VueFinder | --- | --- | --- | | GET | `/api/status` | — | +## dashboard + +| Method | Path | Direct fields (partial) | +| --- | --- | --- | +| GET | `/api/dashboard/overview` | — | + +## ratings + +| Method | Path | Direct fields (partial) | +| --- | --- | --- | +| POST | `/api/subsonic/sync-link` | `user`, `direction`, `preview`, `reconcile` | +| GET / POST | `/api/data/rating-sync` | Query parameter `user` | +| GET / POST | `/api/music/rating-preferences` | `dislikeAutoRating` | +| GET / POST | `/api/music/ratings` | `operation`, `songs`, `includeDetails`, `song`, `rating` | + +## openlist/connection + +| Method | Path | Direct fields (partial) | +| --- | --- | --- | +| GET / POST | `/api/openlist/connection` | — | + +## files + +| Method | Path | Direct fields (partial) | +| --- | --- | --- | +| GET / HEAD | `/api/files` | Query parameter `path`; delegated to VueFinder, see API reference | + ## users | Method | Path | Direct fields (partial) | @@ -518,7 +545,7 @@ See [API reference](./reference.md). File operations are delegated to VueFinder | Method | Path | Direct fields (partial) | | --- | --- | --- | -| POST | `/api/webdav/restore` | — | +| POST | `/api/webdav/restore` | `preview,mode,targetFilename,strategy,keepCopies,token,cancel` | ## webdav/logs @@ -578,3 +605,36 @@ See [API reference](./reference.md). File operations are delegated to VueFinder | POST | `/api/music/remaster/start` | — | | GET | `/api/music/remaster/status` | — | | POST | `/api/music/remaster/cancel` | — | + +## Backup recovery and verification + +[Detailed contracts](./reference.md#backup-api). + +| Method | Path | Main fields / purpose | +| --- | --- | --- | +| GET | `/api/webdav/restore/tasks` | `tasks` | +| POST | `/api/webdav/restore/tasks//rollback` | `id` | +| DELETE | `/api/webdav/restore/tasks/` | `id` | +| POST | `/api/openlist/test` | `endpoint,username,password,token,root` | +| POST | `/api/openlist/sync` | `user,folder` | +| GET | `/api/openlist/backups` | `list` | +| GET | `/api/openlist/metadata` | `job,total` | +| POST | `/api/openlist/metadata/start` | `target,ids?` | +| POST | `/api/openlist/metadata/cancel` | `—` | +| POST | `/api/openlist/retry` | `id` | +| POST | `/api/openlist/retry-batch` | `ids` | +| POST | `/api/openlist/delete` | `ids` | +| POST | `/api/openlist/restore` | `preview,mode,types,backupBeforeRestore,snapshotId,planToken` | +| GET | `/api/openlist/restore/history` | `history` | +| GET | `/api/openlist/restore/jobs` | `tasks,maintenance` | +| POST | `/api/openlist/restore/jobs/start` | `planToken,mode,types,backupBeforeRestore,snapshotId` | +| POST | `/api/openlist/restore/jobs//cancel` | `id` | +| POST | `/api/openlist/restore/jobs//resume` | `id` | +| POST | `/api/openlist/restore/jobs//rollback` | `id` | +| DELETE | `/api/openlist/restore/jobs/` | `id` | +| GET | `/api/openlist/integrity` | `job,results` | +| POST | `/api/openlist/integrity/preview` | `type,user,scope` | +| POST | `/api/openlist/integrity/start` | `token` | +| POST | `/api/openlist/integrity/cancel` | `—` | + +| GET | `/api/backup/status` | `success,maintenance,webdav,openlist` | diff --git a/docs/en/api/subsonic.md b/docs/en/api/subsonic.md new file mode 100644 index 00000000..c96e5702 --- /dev/null +++ b/docs/en/api/subsonic.md @@ -0,0 +1,109 @@ +# Subsonic protocol support + +The server implements Subsonic / OpenSubsonic endpoints to expose LX playlists, online sources and account-local music to compatible clients. Behavior depends on server settings, source availability and client capabilities. This page describes functional coverage, not identical interfaces across clients. + +See [Subsonic local music library](../guide/subsonic-local-library.md) for directories, identities and playlist persistence, and [sync server settings](../guide/sync-server.md) for connection setup. + +## Connection + +The default protocol path is `/rest`, sharing the main service port; `subsonic.path` customizes it. Separate listening requires both `subsonic.standalone` and a valid `subsonic.port`. Setting the port alone does not enable a separate listener. + +Authenticate with the user account's sync password, not the administrator password. JSON and XML are supported. Example paths are illustrative; clients must supply protocol authentication, version and other required parameters. + +```text +GET /rest/ping.view?u=&t=&s=&v=1.16.1&c=&f=json +``` + +## Supported endpoints + +### System, browsing and search + +| Methods | Behavior and limits | +| --- | --- | +| `ping`, `getLicense`, `getUser` | Connection checks, license information and current-user information | +| `getMusicFolders` | Server-configured entry, NAS local music and online platforms | +| `getMusicDirectory` | Directories, albums, playlists and local music | +| `getIndexes`, `getArtists`, `getArtistList` | Artist indexes; `getArtistList` is a compatibility entry | +| `getArtist`, `getArtistInfo`, `getArtistInfo2` | Artist details and information | +| `getAlbum`, `getAlbumInfo`, `getAlbumInfo2` | Album tracks, descriptions and artwork | +| `getAlbumList`, `getAlbumList2` | Album lists, recommendations and recent playback; explicit NAS-folder selection uses the local branch | +| `getGenres` | Online or local-library genre information | +| `search`, `search2`, `search3` | Source selection follows online-search settings and query prefixes | +| `startScan`, `getScanStatus` | Refresh the local index and report scan state, counts and failures, rather than a fixed placeholder | + +Online search uses `subsonic.onlineSearch` and `subsonic.onlineSearchMode`: `fallback` supplements insufficient local results, `merge` combines results and `local_only` stays local. Use `local:`, `online:` or platform prefixes to restrict scope. Online playback still requires a working custom source. + +### Media, playlists and favorites + +| Methods | Behavior and limits | +| --- | --- | +| `getSong` | Online or local track details | +| `stream`, `download` | Audio access; local files support Range, while online resources depend on source resolution | +| `getCoverArt` | Track, album and playlist artwork, with a `size` parameter | +| `getLyrics`, `getLyricsBySongId` | Ordinary and structured lyrics; see the local-library guide for sources and word-level limitations | +| `getPlaylists`, `getPlaylist` | User, shared and enabled read-only local playlists | +| `createPlaylist`, `updatePlaylist`, `deletePlaylist` | User playlist management; built-in and shared playlists have write restrictions | +| `getStarred`, `getStarred2`, `star`, `unstar` | Read and update favorites | +| `setRating` | Store per-user ratings from 0–5; 0 clears the rating | + +See [favorites and playlist synchronization](../guide/subsonic-local-library.md#favorites-and-playlist-synchronization) for local/mixed playlist rules, deletion behavior and backup files. + +### Discovery and radio + +| Methods | Behavior and limits | +| --- | --- | +| `getRandomSongs`, `getSongsByGenre`, `getSongsByGenre2` | Random or genre-filtered tracks, including a local-library branch | +| `getSimilarSongs`, `getSimilarSongs2`, `getSonicSimilarTracks` | Select similar tracks using conditions such as the same artist; no acoustic analysis | +| `getTopSongs` | Artist top tracks | +| `getRecommendedSongs`, `getDailySongs`, `getSongsByTag` | Server recommendations; the last two are aliases, and `getSongsByTag` does not perform independent tag search | +| `getInternetRadioStations` | Available official stations, user stations and source-playlist stations | +| `createInternetRadioStation`, `updateInternetRadioStation`, `deleteInternetRadioStation` | User station management; a plural delete compatibility entry is also accepted | + +Daily recommendations obtain tracks from online recommended albums, deduplicate them and use stable shuffling within a day. Upstream failures can fall back to the last successful results. Per-account filtering excludes tracks in the disliked rating range: `0 < rating <= subsonic.dislikeRating`, whose default threshold is 1. + +Server-generated station streams use absolute URLs with expiring tickets rather than long-lived credentials. External user station URLs are returned as configured. Reverse proxies must pass the correct protocol information, and clients must be able to reach these URLs. Unavailable official upstream stations are temporarily hidden. + +### Playback activity, queues and bookmarks + +| Methods | Behavior and limits | +| --- | --- | +| `scrobble` | Store the requested track playback records, accepting playback timestamps | +| `reportPlayback` | Update state and position; record history only for `stopped` without `ignoreScrobble=true` | +| `getNowPlaying` | Recent playback activity, optionally with position; not live monitoring of every client | +| `getPlayQueue`, `savePlayQueue` | Persist a queue per user | +| `getPlayQueueByIndex`, `savePlayQueueByIndex` | Read/write queues by index; an out-of-range current index returns a parameter error | +| `getBookmarks`, `createBookmark`, `deleteBookmark` | Persist bookmarks per user | + +## Transcoding and media limits + +| Setting | Default | Purpose | +| --- | --- | --- | +| `subsonic.transcode.enabled` | `false` | Enable server transcoding | +| `subsonic.transcode.format` | `mp3` | Target format: `mp3`, `opus` or `aac` | +| `subsonic.transcode.maxConcurrent` | `2` | Maximum concurrent transcodes | + +Transcoding requires available ffmpeg. `stream` / `download` can select transcoding based on client bitrate requirements; `timeOffset` is measured in seconds. OpenSubsonic `getTranscodeDecision` / `getTranscodeStream` provide decisions and signed parameters. + +Without ffmpeg, online resources can fall back to their original links, which may not match client format requirements. Target format, source availability and server resources affect playback. + +## OpenSubsonic extensions + +`getOpenSubsonicExtensions` advertises these extensions, all at version 1: + +| Extension | Capability | +| --- | --- | +| `formPost` | Form POST parameters | +| `songLyrics` | Structured lyrics | +| `playbackReport` | Playback state and position reports | +| `sonicSimilarity` | Similar-track entry using heuristic selection | +| `indexBasedQueue` | Index-based queue management | +| `transcodeOffset` | Transcode time offsets | +| `transcoding` | Transcode decisions and streams | + +Advertising an extension means its endpoint exists; client calls and presentation still depend on client implementation. + +## Unsupported scope + +Video, podcasts, chat, sharing and server-account administration through Subsonic are outside the current implementation. Typical unsupported methods include `getVideos`, `getPodcasts`, `getChatMessages`, `createShare`, `createUser`, `updateUser`, `deleteUser`, `changePassword` and `getAvatar`. + +Unsupported methods return `Method not found`. Manage accounts through the console. For missing controls or compatibility issues, compare this coverage with the methods actually requested by the client. diff --git a/docs/en/guide/backup-recovery.md b/docs/en/guide/backup-recovery.md index 057a8614..8fd945c4 100644 --- a/docs/en/guide/backup-recovery.md +++ b/docs/en/guide/backup-recovery.md @@ -1,60 +1,190 @@ -# Cloud backup and recovery +# Backup and recovery -Use **System settings → Cloud backup scope** to select data types independently for WebDAV and OpenList. Selecting both destinations stores two copies; it does not synchronize the destinations with each other. Each provider still needs its own enabled connection. +Choose what to save and where to keep it before enabling automatic backups. You can keep a local copy, use WebDAV, use OpenList, or enable both cloud destinations. The two providers store independent copies; they do not synchronize with one another. -## Included files +## Choose a backup method -| Type | Scope | Restore destination | +| What you need | Use | +| --- | --- | +| Recover a previous playlist or dislike list | Playlist/dislike history snapshots | +| Recover server settings | Local configuration history | +| Download data for safekeeping or migration | Local ZIP export/import in Backup management | +| Automatically copy data to cloud storage | WebDAV or OpenList data backup | +| Upload playback cache to OpenList or free local cache space | Separate advanced per-user OpenList cache rules | + +Configuration history does not include all user files or audio. Playlist snapshots do not include audio either. To recover a full installation, select the required data types and separately protect external music libraries excluded from the backup. + +## Choose what to save + +Open **Backup management → Cloud backup → Backup scope**, select types independently for WebDAV and OpenList, and save. Clearing every type disables that destination's data backup without deleting files already uploaded. + +| Option | Includes | +| --- | --- | +| Songs and lyrics | Audio, LRC, artwork and music indexes under the data directory's `music` folder | +| User source files | Source scripts, ordering/status files and uploaded user files | +| Playlist and dislike snapshots | Historical snapshots, including the configured external snapshot location | +| User data | User settings, playback history, favorites and other user files | +| System and accounts | Server configuration, account list and system JSON files at the data directory root | + +**Ordinary data backup excludes cache, logs, temporary files, local backup archives and external custom music directories. Directory links are not followed.** A separate `music` directory in the program working directory is also excluded. Paths refer to your actual data directory, which may differ from the default `data`. + +New backup scans, new OpenList history manifests and full content verification exclude `test-evidence` directories. Existing cloud files are not automatically deleted, and older history remains restorable. Failed manual OpenList deletion preserves the affected records and local files for retry using the same records. Some remote files may already have been removed; retries skip missing files. Backup and full verification are blocked during deletion. The verification table shows all latest results for current versions, independently of the task scope selected above. + +Advanced OpenList playback caching can upload cache separately for selected users. It is not the same as ordinary data backup. System/account backups may contain credentials; store them somewhere you control. + + +### Program directory versus data directory + +These paths are relative to the program working directory and assume the default `DATA_PATH=data`: + +| Location | Ordinary WebDAV / OpenList data backup | +| --- | --- | +| `cache/` | Excluded: program-directory cache | +| `music/` | Excluded: program-directory music separate from the data directory | +| `data/cache/` | Excluded: data-directory playback cache | +| `data/users/account/cache/` | Excluded: per-user cache; cache directories at other levels are excluded too | +| `data/music/` | Included when Songs and lyrics is selected and saved; audio, existing LRC, artwork and indexes | +| External directory, e.g. `D:/Music/` | Excluded; protect separately | + +With a custom `DATA_PATH`, the scope is **music under the actual data directory**. For `D:/LXData`, that is `D:/LXData/music`. If the program working directory itself is configured as the data directory, its `music/` can be selected for backup. A blanket rule excluding every root-level music directory would be incorrect. Cache remains excluded. + +Saving a scope does not enable the service. WebDAV needs an enabled, configured connection for cloud backup. Local ZIP export/import also uses the WebDAV-column scope but does not require WebDAV to be enabled. Advanced OpenList playback caching has independent user rules that can handle cache without changing ordinary backup exclusions. + +## Configure local backups + +Open **Backup management → Local backups** for snapshot counts, local configuration history and storage paths. + +- Configuration history is enabled by default, runs every 24 hours, retains 7 days and uses the data directory's `backups` folder. +- The default anchor time is `00:00` in the server's local time. Leave it empty to count intervals from startup or rescheduling. +- An empty configuration-backup path uses the default. Relative paths start at the data directory; absolute paths are used directly. +- An empty snapshot path keeps existing per-user snapshot locations. An additional configured location separates snapshots by user. + +Local ZIP export/import in **Backup management** does not require WebDAV. It uses the types saved in the WebDAV scope column, so check that selection before exporting. + +## Back up with WebDAV + +1. Open **Backup management → Cloud backup → WebDAV**, enter the URL and credentials, and enable it. +2. Set incremental and ZIP directories; defaults are `/lx-sync` and `/lx-sync-backups`. +3. Save, then test the connection on the same page. The test reads the saved directories without creating files; it does not prove upload or deletion permissions. +4. Run file synchronization or a full ZIP backup and inspect the result. Start with a small backup and test recovery in a separate installation. + +Incremental backup uploads changed files and checks every 60 minutes by default. ZIP archives cover the same selected data; scheduled checks run every 24 hours and create an archive when data changed. A manual full backup can force a new archive. + +### Understand the two optional switches + +| Switch | Off: recommended default | On | | --- | --- | --- | -| `music` | `data/music/**`, including audio, lyrics, covers and `music_index.json` | The current data directory, retaining relative paths | -| `userFiles` | `users/source/**`, `source/**`, `uploads/**` | The same relative paths in the current data directory | -| `snapshots` | Playlist/dislike snapshot bodies and `snapshotInfo.json`, including extensionless bodies | Original relative paths; external playlist snapshots use the current `snapshot.backupPath` | -| `userData` | Other user files under `users//**` | The same account directories and relative paths | -| `system` | `config.js` and JSON files in the data directory root, including `users.json` and `serverInfo.json` | Current `CONFIG_PATH` for configuration; current data directory for JSON | +| Delete the WebDAV incremental copy when its local file is deleted | Keep the cloud copy | Next incremental run deletes the tracked counterpart; historical ZIPs remain | +| Overwrite selected local data from WebDAV before every startup | Restore manually when needed | Restore before opening the service; cloud files replace matching local files | + +Startup restoration is for deployments deliberately using the cloud as their recovery source. It is not limited to an empty first start and can overwrite newer local data. Failure leaves maintenance mode active and does not upload local data to the cloud. + +### Retention and transfer usage + +ZIP retention defaults to 5 archives and 0 days (no age limit). Successful uploads clean archives exceeding either limit, always keeping the newest. This does not clean the incremental directory. + +Uploads check the response and remote size without downloading the whole file again. Safe replacement requires WebDAV `MOVE`; check server/proxy support if MOVE fails. Matching size is not proof of matching contents. + +## Back up with OpenList + +1. Open **Backup management → Cloud backup → OpenList**, and enter the endpoint and token or account details. +2. Enter an **existing OpenList mount path** such as `/local` as the root. Do not enter a Windows host path such as `E:\OpenList`; configure that physical path inside OpenList's storage settings. +3. Enable, save and test, then run the immediate backup/cache-sync action. +4. Inspect upload results in the corresponding record tab. + +Ordinary backups keep relative paths under `_data` within the configured root. For example, a song might be stored at `/local/_data/music/account/song.mp3`. Its physical disk location depends on the OpenList mount. + +Data backups retain local files. Deleting a local file does not automatically delete its `_data` backup. Advanced playback caching has separate user rules and locations; only an explicit upload-and-delete rule removes corresponding cache files after strict verification. + +### Upload completion versus content verification + +“Upload complete” means upload and remote size checks succeeded, not full content equality. Ordinary backup does not download songs for SHA-256 verification. The Backed up task state and the Content verification filter are independent. Verification results describe the current registered backup fingerprint at a past check time; changed versions or observed cloud changes require another check. Legacy internal verified states alone do not qualify. Upload-and-delete and restoration retain their automatic safety checks. + +To check contents, open **Backup management → Records and verification → OpenList → Content verification**: + +1. Choose the user, type and scope, then preview estimated transfer usage. +2. The default checks unchecked or changed versions. Choose all current files when investigating later cloud corruption. +3. Confirm and start; the job runs in the background and can be stopped. +4. Review matching contents, mismatches, missing files and read errors. Verification does not automatically repair or overwrite cloud files. + +If storage supplies a usable SHA-256 it can be compared directly; otherwise verification reads the remote body and consumes download transfer. Legacy records without upload fingerprints require another backup to establish a baseline. Upload-and-delete cache rules still require strict content verification and may read remote audio and attachments. + +## Filter OpenList backup records + +In Backup management → Records and verification, select OpenList file records. The record panel contains one set of filters: type buttons, user, storage category, local availability and sorting. Click a status card to filter that status; click again to clear it. Counts reflect other active conditions. Active-condition chips can be removed individually. + +Space-separated search terms must all match, across filenames, users, paths and available song metadata. More conditions adds path, snapshot subtype and an inclusive update-date range interpreted in browser-local time. Conditions combine; remove individual chips or reset if nothing matches. Filtering/pagination reuse loaded records; Refresh records and re-entering the record view fetch current results. These controls do not change backup scope or files. + +## Restore your data + +Recovery runs **from the backup to the current server**. It does not upload local files or delete files that exist only locally. + +### WebDAV + +1. Confirm the current data/configuration locations and external snapshot path, then save the types to restore. +2. Open **Backup management → Data recovery → WebDAV** and choose the incremental directory or a specific ZIP. +3. Choose missing-only or overwrite. Keeping pre-restore copies is recommended. +4. Preview additions, replacements, index merges and skips before confirming. ZIP preview downloads the entire archive once and reuses it on confirmation; cancelling or expiry removes that temporary archive. +5. Restart the service to reload accounts, playlists, sources and music indexes. + +### OpenList + +Open **Backup management → Data recovery → OpenList**, select the current backup or an available historical version, choose scope and overwrite behavior, then scan and confirm. Restore records show progress and support continuation, cancellation or rollback where available. A fresh installation can enumerate cloud data without the old backup database. + +### Replacement rules and failures + +- **Missing-only:** retain existing files and add absent files. Required music/snapshot indexes merge entries; ordinary JSON files are not merged field by field. +- **Overwrite:** matching files in the selected scope come from the backup. Local-only files remain; the directory is not cleared. +- **Pre-restore copies:** kept by default for rollback. Rollback refuses files modified again after restoration. Delete records and copies explicitly when no longer needed. + +Required files are staged and checked before local replacement. Download failure changes no originals; write failure attempts rollback. Legacy backups without fingerprints receive size checks and a warning. Allow extra disk space for staged files and pre-restore copies. + +Recovery enters maintenance mode. Restart after a manual restore; refreshing the browser is insufficient. If a process stops during commit or rollback fails, use restore records to resolve rollback before restarting normal service. + +## Common questions + +**“Storage not found”?** Check that the OpenList mount exists, is enabled and matches the configured root. Connecting to OpenList alone does not create writable storage. + +**Why are LRC files uploaded?** Songs and lyrics includes existing LRC under the data directory's `music` folder. The advanced cache attachment switch only controls separate cache rules. Disabling lyric generation does not delete existing LRC. + +**Nothing changed after restore?** Check source, types, account directories and replacement mode. Missing-only skips existing files. Restart after completion before checking results. + +**Do WebDAV and OpenList synchronize with each other?** No. Choose one source explicitly when restoring. + +See [configuration](./configuration.md) for keys/defaults and the [backup API](../api/reference.md#backup-api) for scripting. -Cache, runtime data, logs, temporary files, SQLite working databases/WAL and local backup archives are always excluded. Directory links are not followed. External custom music directories are not included. Preserve account directory names during recovery. Music indexes and audio are grouped together. System and user data can contain credentials or device information. +### Task states and verification results -## System configuration +Record states describe pending uploads, pending downloads, active transfers, backed-up files, items needing attention, failures and paused rules. Local size or modification-time changes flag enabled upload rules for the existing scheduled process. Background content comparison refreshes the baseline when only metadata changed. Disabled services or scopes appear paused. Removing local playback cache after upload does not request cloud deletion. -```js -module.exports = { - 'backup.webdav.types': ['music', 'userFiles', 'snapshots', 'userData', 'system'], - 'backup.openlist.types': ['music', 'userFiles', 'snapshots', 'userData', 'system'], - 'backup.webdav.mirrorDeletes': false, - 'backup.webdav.restoreOnStartup': false, -} -``` +Content verification is a separate historical result, not a real-time guarantee. Old matching badges are hidden while local changes or transfers are pending. Observed cloud changes mark previous results as needing rechecking and include them in the default verification scope. Select all current files to detect later corruption with unchanged size and timestamps. Refreshing records checks local metadata without downloading cloud bodies. Diagnostic states remain in details and advanced filters. User and type filters also apply to verification results. -An empty type array disables data backup for that destination. The admin UI validates and saves these fields in the active configuration file. Deselecting a type does not remove existing cloud copies. +### Manual metadata checks and timestamps -The old `webdav.excludeCache` and `webdav.excludeMusic` interfaces remain for compatibility. Explicit type arrays control the new scope; cache is always excluded. The old `openlist.backupSystemConfig` applies only to the legacy flow without explicit types, avoiding a second system-backup switch in the new UI. +In records, choose Check files, select local, cloud or both, and selected records, current filtered results or all records for this connection. Checks cover primary record files; use content verification for attachments. Historical objects are excluded. Checks inspect existence, size and modification time without uploading, downloading bodies, deleting files or producing SHA-256 results. Cloud requests run sequentially and can be stopped while preserving completed observations. -## Provider behavior +Advanced filters show checked X/Y records and the newest check time for the matching scope; hover for the oldest timestamp. This does not imply every file was checked successfully in one run. Details show per-file timestamps. Listing records refreshes local metadata; manual local check times are retained separately. Cloud observations persist by connection and backup version across restarts. Interrupted jobs must be restarted. -WebDAV incremental backup stores selected files under `webdav.syncPath`; ZIP backup archives the same scope under `webdav.backupPath`. Separate successful baselines prevent incremental uploads from suppressing ZIP backups. Failed uploads remain eligible for retry. +### Subsonic ratings and snapshots -Local offline ZIP export/import uses the saved WebDAV-column scope without requiring an enabled connection or accessing the cloud. The UI states this explicitly so partial exports are not mistaken for whole-server backups. Daily local configuration history remains controlled by `configBackup.*`; it does not replace a data backup. +The rating-playlist switch controls virtual playlist visibility only. Each link switch controls its own direction; a zero threshold disables linking. Links only fill missing data: raising or clearing a rating preserves dislike rules, removing a dislike preserves ratings, and existing ratings are never overwritten. The one-star playlist also includes disliked tracks while preserving their actual ratings. -Cloud files are retained after local deletion by default. Enable `backup.webdav.mirrorDeletes` to propagate deletions to scattered files; historical ZIPs remain unchanged. Backup and restore operations do not run concurrently within one WebDAV instance. +Restoring a rating snapshot replaces ratings only and preserves current artist and album favorites, including when reading legacy snapshots. Rating snapshots and their index belong to the snapshots backup scope; the current subsonic-meta.json belongs to user data. Select both scopes for a complete backup. If ratings are saved but dislike resolution or writing fails, the client receives a partial-success error and can retry or use manual synchronization. The two stores are not a cross-file transaction. Evicted rated tracks are resolved again when possible; unavailable sources retain their ratings but may temporarily prevent playlist display. -OpenList stores data at `/_data/`. Data backup uploads files and retains local copies. `openlist.syncInterval` controls scheduled runs. Recovery enumerates cloud files directly, so a fresh installation can recover without the previous SQLite database. +New dislike rules are deduplicated and matched by source; rules without a source explicitly apply to all sources. Duplicate records with matching source and non-conflicting song identity fill missing fields while preserving valid data and display names. Dislikes record provenance and enrichedBy; rating provenance is stored in ratingOrigins. Manual linking previews candidates including the online cache, counts unresolved targets as failures, and reports enrichment separately. -Advanced permanent playback caching is separate. Per-user cache rules may upload disposable cache files, delete local copies after verification, or provide Subsonic direct/proxy delivery. Selecting `music` in the backup matrix does not enable cache uploading. Song, snapshot and system backup are no longer controlled twice by these per-user cache rules. +Configuration changes do not reconcile historical data. Raising the threshold allows manual linking to add newly qualifying rules. Lowering it, setting it to zero, disabling linking, or clearing ratings does not remove existing rules. Matching and visibility settings only change subsequent filtering and display. Automatic Web-to-rating linking applies only to individual songs with a source and song ID; artist and album dislikes are not written as song ratings. -Before removing a local playback-cache copy, SHA-256 verification reads the actual cloud bytes. This adds a remote verification transfer. Files modified during transfer are retained. Normal data backup never removes local files after upload. +The admin Data view includes a Dislikes tab for the selected user, combining dislike rules and disliked artist/album libraries. Filter by type, creation channel, platform, or keyword; inspect saved covers, rules, ratings, and complete fields. Missing provenance is explicitly shown as unrecorded rather than inferred. The view is read-only and does not change linking or ratings. -## Recovery procedure +Rating identity migration and restore use a transaction journal: write failures roll back, and the next rating access completes an interrupted commit. Restore normalizes local/online scores against current file links and updates rating IDs in dislike reasons while preserving manual reasons and older snapshots. The temporary journal is stored in each user’s `rating/transaction.json` and removed after completion. Multiple server processes must not write to the same data directory concurrently. -1. Configure the destination server's cloud connection, data directory, `CONFIG_PATH` and snapshot mounts. -2. Select and save the types to restore. Configure `snapshot.backupPath` before restoring external snapshots; cloud absolute paths are never used as destinations. -3. Restore scattered files or a specified ZIP from WebDAV, or use OpenList's restore action. Only selected types are downloaded. Matching local files are replaced, local-only files remain, and no files are uploaded during restore. -4. Check results and logs. Downloads use temporary files and rename after completion. A multi-file restore is not an atomic transaction; inspect partial failures and retry. -5. Restart the service to reload accounts, playlists, sources and music indexes. Refreshing the browser alone does not reload all server-side state. +### OpenList connection status -Startup restore is disabled by default. Set `backup.webdav.restoreOnStartup=true` only when unattended disaster recovery is intended; matching files will be overwritten. Automatic recovery tries ZIP only when the scattered directory is missing or has no selected files. Authentication/network errors do not imply an empty cloud. Failed recovery never initializes the cloud by uploading local files. +The backup records page shows destination health separately, including the latest probe and successful connection times. “探测连接” (Probe connection) checks authentication and the configured backup directory without uploading or downloading files. An unavailable destination preserves historical backup results and marks remote observations as temporarily unavailable. Automatic probes back off from 30 seconds to 5 minutes; recovery triggers a check of previously backed-up files. A successful connection or unchanged metadata does not prove content integrity. -Old ZIPs are filtered using the current selection, even if they contain caches. Legacy per-user OpenList audio remains accessible through compatible cache flows; the new unified restore reads `_data` and does not guess or move legacy directories. +Connection failures use Chinese explanations, while record details retain raw diagnostics. Uploads, downloads, and attachment transfers return to pending on destination outages without consuming per-file retry attempts, then continue after recovery. File checks preserve their current position while disconnected, display a waiting state, and retry the current file after recovery. They remain cancellable. A process restart marks unfinished checks as interrupted; start a new check to proceed. -## Validation limits +“上次未变” means the last metadata observation was unchanged. The per-record “检查” action only checks local and remote metadata; “重试上传/重试下载” performs a transfer. -Regression coverage uses real SQLite, a local HTTP OpenList fixture, a WebDAV client substitute, ZIP archives and fresh restore directories. It covers selection, cache exclusion, retry behavior and selected Subsonic delivery paths. It does not establish compatibility with every real cloud mount, WebDAV server or Subsonic client. Verify a small backup in an independent directory before relying on it. +Before propagating a bidirectional deletion, files and readable linked attachments are copied to `backups/openlist-recycle//` under the data directory. A `manifest.json` records original paths for manual recovery. A failed safety copy prevents deletion. Recycle copies are not automatically removed. More than 20 candidate deletions in one user folder pause that folder's synchronization before any deletion, upload, or download. Check the mount and local directory, then handle confirmed deletions manually in smaller batches. Explicit administrator deletion actions are unchanged. diff --git a/docs/en/guide/configuration.md b/docs/en/guide/configuration.md index 400efa95..e5aa3d0a 100644 --- a/docs/en/guide/configuration.md +++ b/docs/en/guide/configuration.md @@ -50,7 +50,7 @@ Defaults below refer to code defaults. Bundled configuration, saved values and i | `ENABLE_LOGIN_USER_CACHE_RESTRICTION` | `user.enableLoginCacheRestriction` | Enable cache settings restriction for logged-in non-admin users | `false` | | `ENABLE_CACHE_SIZE_LIMIT` | `user.enableCacheSizeLimit` | Enable cache size limit (auto-cleanup via LRU) | `false` | | `CACHE_SIZE_LIMIT` | `user.cacheSizeLimit` | Cache size limit in MB | `2000` | -| `CONFIG_BACKUP_ENABLE` | `configBackup.enable` | Enable automatic config.js backup (creates daily backup copy) | `true` | +| `CONFIG_BACKUP_ENABLE` | `configBackup.enable` | Enable automatic config.js backup (default: every 24 hours) | `true` | | `CONFIG_BACKUP_RETENTION_DAYS` | `configBackup.retentionDays` | Config backup retention period in days (auto cleans up expired backups) | `7` | | `CONFIG_BACKUP_DIR` | `configBackup.dir` | Backup directory, relative to data; empty uses data/backups | `empty` | | `SNAPSHOT_BACKUP_PATH` | `snapshot.backupPath` | Custom storage path for playlist snapshots (absolute or relative to `./data`) | - | @@ -80,6 +80,9 @@ Defaults below refer to code defaults. Bundled configuration, saved values and i | `SYSTEM_ALLOW_UNSAFE_VM` | `system.allowUnsafeVM` | Allow VM mode custom source scripts (note security risks) | `false` | | `LX_USER_` | `users` | Create account using a password string | — | +Cache cleanup applies only to `cache/` (including subdirectories), never to `music/`. Each user at each location (working directory and data directory) has an independent size quota. Cleanup runs after downloads, on startup, after changing cleanup settings, and every 12 hours. Oldest access/modification time is used; same-name audio and lyrics are grouped, active backups are protected, and indexes/partial files are excluded. Set `cache.maxAgeDays` in config.js or the admin UI (default 14); 0 disables expiration only, not size-based cleanup. + + Boolean values are case-insensitive: `true/1/yes/y/on` and `false/0/no/n/off`. `LX_USER_` accepts a password string. Use user management for advanced account settings; JSON in that environment variable is not an advanced-account configuration format. @@ -105,7 +108,7 @@ Keep `subsonic-local-registry.json` and `subsonic-local-state.json` with user da Playlist snapshots restore playlists; configuration backups restore configuration. Neither substitutes for audio backups. Configuration backups default to a seven-day retention period under the data directory's `backups/` folder. -WebDAV and OpenList use the data types selected in **System settings → Cloud backup scope**. Startup restore is off by default and requires `backup.webdav.restoreOnStartup=true`. `backup.webdav.types` and `backup.openlist.types` independently select each destination's scope. Music means `data/music`; cache and external libraries are excluded. See [Cloud backup and recovery](./backup-recovery.md) for provider behavior, compatibility and restore steps. +WebDAV and OpenList use the data types selected in **Backup management → Cloud backup → Backup scope**. Startup restore is off by default and requires `backup.webdav.restoreOnStartup=true`. `backup.webdav.types` and `backup.openlist.types` independently select each destination's scope. Music means `data/music`; cache and external libraries are excluded. See [Cloud backup and recovery](./backup-recovery.md) for provider behavior, compatibility and restore steps. For container updates: @@ -175,3 +178,50 @@ Paths belong to the server's environment: host paths for source runs, container - WebDAV: check remote namespace, exclusions, recovery logs and effective settings. See [deployment](./getting-started.md) and [operations](./operations.md). + +## Backup configuration keys (admin UI or config.js) + +Backup settings live in **Backup management**: Local backups holds configuration/snapshot settings, Cloud backup holds connections, scopes, schedules and ZIP retention, and the OpenList playback cache card below its connection settings in Cloud backup holds user cache rules. See [Backup and recovery](./backup-recovery.md) for instructions. + +These are configuration keys, **not automatically supported uppercase environment variables**. There are currently no mappings for `OPENLIST_*`, `WEBDAV_RETENTION_*`, `CONFIG_BACKUP_INTERVAL_HOURS` or `CONFIG_BACKUP_SCHEDULE_TIME`. Save in the admin UI or edit the active `config.js`. Existing variables listed above, including `WEBDAV_*`, `CONFIG_BACKUP_ENABLE`, `CONFIG_BACKUP_RETENTION_DAYS` and `CONFIG_BACKUP_DIR`, remain supported. + +| Key | Default | Purpose | +| --- | --- | --- | +| `maxSnapshotNum` | `10` | Shared maximum snapshot count; users can override it | +| `configBackup.enable` | `true` | Automatic local configuration history | +| `configBackup.intervalHours` | `24` | Configuration backup interval, hours | +| `configBackup.scheduleTime` | `'00:00'` | Server-local anchor; empty starts intervals at startup/rescheduling | +| `configBackup.retentionDays` | `7` | Configuration history retention, days | +| `configBackup.dir` | `''` | Empty uses the data directory's `backups` | +| `snapshot.backupPath` | `''` | Additional per-user snapshot location | +| `backup.webdav.types`, `backup.openlist.types` | All five types | Independent scopes shown below | +| `backup.webdav.mirrorDeletes` | `false` | Propagate local deletion to tracked incremental counterparts | +| `backup.webdav.restoreOnStartup` | `false` | Restore and overwrite selected local data before every startup | +| `webdav.retentionCount` | `5` | Keep 1–1000 ZIPs, always retaining the newest | +| `webdav.retentionDays` | `0` | ZIP age limit, 0–3650 days; 0 disables age-based removal | +| `openlist.enable` | `false` | Enable OpenList backup and user cache rules | +| `openlist.endpoint` | `''` | OpenList API URL | +| `openlist.token` | `''` | OpenList API token | +| `openlist.username`, `openlist.password` | `''` | Login credentials when not using a token | +| `openlist.root` | `'/lx_music'` | Path within an existing OpenList mount, not a physical disk path | +| `openlist.externalUrl` | `''` | External address; empty uses endpoint | +| `openlist.syncInterval` | `60` | Backup/cache-rule check interval, minutes | +| `openlist.verifyInterval` | `5` | Background status check interval, minutes; not scheduled full-body SHA-256 verification | +| `openlist.historyRetentionDays` | `7` | Cloud history retention, days | +| `openlist.userBackups` | `'[]'` | Advanced user cache rules as a JSON-array string; edit through the UI | +| `openlist.includeNonAudio` | `false` | Include existing cache lyrics/artwork; does not change ordinary music scope | +| `openlist.playback.enable` | `false` | Allow registered OpenList files as playback fallback; player/Subsonic options also apply | + +```js +module.exports = { + // Merge into existing settings; do not replace unrelated configuration. + 'backup.webdav.types': ['music', 'userFiles', 'snapshots', 'userData', 'system'], + 'backup.openlist.types': ['music', 'userFiles', 'snapshots', 'userData', 'system'], + 'backup.webdav.mirrorDeletes': false, + 'backup.webdav.restoreOnStartup': false, + 'webdav.retentionCount': 5, + 'webdav.retentionDays': 0, +} +``` + +An empty array selects no data. Explicit types take precedence over legacy `webdav.excludeMusic`. `webdav.excludeCache` remains a compatibility key; ordinary backups always exclude cache. Legacy `openlist.backupSystemConfig` applies only without explicit types; use `backup.openlist.types` for new settings. diff --git a/docs/en/guide/operations.md b/docs/en/guide/operations.md index 3722f9d7..7abf520c 100644 --- a/docs/en/guide/operations.md +++ b/docs/en/guide/operations.md @@ -48,7 +48,7 @@ Application rollback and data rollback are separate. An older binary may not und Preserve user state together with audio. Subsonic local registry/state files retain IDs, favorites and playlist relationships. Moving or renaming audio externally can assign it a new identity. -WebDAV incremental checks default to 60 minutes (`SYNC_INTERVAL`); full backup intervals default to 24 hours (`BACKUP_INTERVAL`). Startup with WebDAV configured attempts remote recovery and then reloads configuration/accounts; recovery is not limited to a first start with empty data. +WebDAV incremental checks default to 60 minutes (`SYNC_INTERVAL`); full backup intervals default to 24 hours (`BACKUP_INTERVAL`). Configuring WebDAV does not enable automatic recovery. `backup.webdav.restoreOnStartup` defaults to false. When explicitly enabled, every startup restores selected data and reloads configuration/accounts before opening the service, not just the first empty start. Restart after manual restoration. See [Backup and recovery](./backup-recovery.md) for instructions. ## Reverse proxy diff --git a/docs/en/guide/subsonic-local-library.md b/docs/en/guide/subsonic-local-library.md new file mode 100644 index 00000000..793bd40f --- /dev/null +++ b/docs/en/guide/subsonic-local-library.md @@ -0,0 +1,96 @@ +# Subsonic local music library + +Subsonic clients can access the current account's server music files, cache and custom music directory. This page covers directory scope, playlist settings, media access and persistent data. See [Subsonic protocol support](../api/subsonic.md) for endpoint coverage. + +## Setup and directory scope + +1. Select the account's persistent storage location in the console. For NAS storage, enable its custom music directory and ensure the server process can read it. +2. Enable the required local playlist entries under **Subsonic settings** and save. +3. Select the **NAS local music (storage + custom directory)** music folder in the client to browse local albums, artists, genres and tracks. Directory browsing also provides a NAS entry. +4. After adding or updating files, use the client's scan action to call `startScan` and refresh the index. + +| Account storage setting | Built-in library scope | +| --- | --- | +| `data` | The account's `music` and `cache` under the data directory | +| `root` | The account's `music` and `cache` under the program root | +| Enabled custom directory | Accessible files in that directory, in addition to built-in storage; the same physical file is included only once | + +A temporary directory switch in the web file manager does not change Subsonic's library scope. Changing account storage or disabling the custom directory changes subsequent requests. Saved track IDs cannot bypass the current access scope. + +## Local playlist settings + +The console's **Subsonic settings → Local playlists** section provides: + +| Setting | Default | Behavior | +| --- | --- | --- | +| `subsonic.localPlaylist` | `true` | Expose the read-only local-tracks playlist for the account; disabling it hides both local and custom-directory playlists | +| `subsonic.customPlaylist` | `true` | Expose a separate custom-directory playlist when the parent switch and the account's custom directory are enabled | +| `subsonic.cachePlaylist` | `false` | Include the selected storage location's `cache` tracks in the local playlist; otherwise include only `music` | + +The server places local playlists near the beginning of the list; client-side sorting takes precedence in the client. Enabled entries remain available even when empty. Disabling the parent switch retains the child settings. + +Custom-directory tracks are displayed separately from built-in tracks. If the custom directory points to built-in `music` or `cache`, files belong to built-in storage first and are not duplicated in the custom playlist. Cache tracks remain subject to the cache-playlist switch. These switches control playlist presentation, not the complete library used by search, albums and ratings. + +## Browsing, search and scans + +- Explicit NAS-folder selection uses the local library for album browsing and related endpoints. Default newest/random recommendations still use the online recommendation pool and do not automatically include NAS albums. +- `local:query` searches local files only. Ordinary searches follow server search settings; explicit online searches use the online branch. +- The index reads track number, disc number, album artist, year and genre. Albums are grouped by directory, album name and album artist to keep versions in different directories separate. +- Results are cached for 30 seconds while directory scope remains unchanged. `startScan` forces a refresh; `getScanStatus` returns scan state, track count and failure information. + +## Playback, artwork and lyrics + +Local tracks support details, streaming, downloads and HTTP Range requests. Track and album durations are returned as integer seconds while precise durations remain internal. File sizes are actual byte counts. + +With server transcoding enabled and ffmpeg available, local playback supports format conversion, bitrate limits and time offsets. See [transcoding and media limits](../api/subsonic.md#transcoding-and-media-limits). + +### Lyric sources + +Lyrics are resolved in this order: + +1. A non-empty external LRC file with the same basename. +2. Embedded audio lyrics. +3. The web player's server-side lyric cache. +4. The linked music platform's online lyric endpoint. + +Online fallback uses linked platform IDs only; it does not guess another track by title. Requests have timeouts, share concurrent work per account and briefly cache successful results. Structured lyrics can include original, translated and transliterated text; ordinary lyrics follow the translation setting. Multiple timestamps, integer-second timestamps and offsets are supported. Word-timed content is not additionally converted to OpenSubsonic's word-level format. + +### Artwork sources + +Local tracks use embedded or directory artwork and accept client-added `al-`, `ar-`, `tr-`, `sg-` and `mg-` prefixes. Access remains restricted to the current account's files. + +Ordinary user playlist lists and details return a consistent artwork token. Resolution prefers playlist network artwork, then attempts to obtain missing artwork for a linked online playlist, then falls back to the first track's artwork, including local artwork. Empty playlists without artwork return empty content. Resolution is account-scoped. + +## File identity and platform links + +Each local file has an independent `local_` ID. Linking an online platform does not change that ID or rewrite audio tags. Responses expose local identity and link status through `lxStorage=local`, `lxLinked` and `comment`; presentation depends on the client. + +Local and online tracks are not automatically merged by title. Identity is stored by physical path: updating a file at the same path or linking a platform retains its ID; externally moving or renaming it creates a new file identity, without fingerprint tracking. Tracks outside the current scope disappear from results, but playlist references and favorites remain and can recover when the original scope is restored. + +## Favorites and playlist synchronization + +| Operation | Storage and synchronization | +| --- | --- | +| Create a purely local playlist | Stored on the server only; excluded from official LX client sync | +| Create a mixed playlist | Online tracks enter the native playlist; local members and mixed order are stored separately | +| Resynchronize an online playlist | Online members follow synchronization changes; local members remain; new online members are appended to the combined list by default | +| Remove a track by position | Resolve the position against the mixed list visible to the client | +| Delete a mixed playlist | Remove the native online playlist and local supplemental state, without deleting audio files | +| Favorite a local track | Use `star` / `unstar`; directly adding local members to the built-in favorites playlist returns guidance to use favorites | + +If another synchronization operation deletes the native playlist, local supplemental members can still appear as a server-side playlist. Playback history, queues and bookmarks can resolve local tracks. + +## Backup and troubleshooting + +Back up these files with the account's user data: + +| File | Contents | +| --- | --- | +| `subsonic-local-registry.json` | File identities and metadata; not disposable cache | +| `subsonic-local-state.json` | Local favorites, purely local playlists and mixed-playlist supplemental state | + +For a missing library, check account storage selection, custom-directory enablement and server filesystem permissions, then scan and inspect `getScanStatus`. For missing cache tracks in the local playlist, check `subsonic.cachePlaylist`. + +Some clients use track `size` to decide whether to load playlist artwork. Online tracks prefer source-provided byte counts, otherwise estimate from bitrate and duration; unknown duration without a known size still returns 0. Estimates are not used for actual audio Content-Length or Range responses. + +Large-library scanning depends on directory access and tag-reading performance. Transcoding depends on ffmpeg and server resources. Artwork, lyrics, scan progress and other controls also depend on client implementation. diff --git a/docs/en/guide/sync-server.md b/docs/en/guide/sync-server.md index bdbce5c1..592c656e 100644 --- a/docs/en/guide/sync-server.md +++ b/docs/en/guide/sync-server.md @@ -24,7 +24,7 @@ Do not use `/admin`, `/rest` or the player access password for LX sync. Review m ### Subsonic clients -Compatible clients include Yinliu, Feishin and Symfonium. The server implements Subsonic/OpenSubsonic endpoints for library browsing, search, playback and related features; the UI and available features vary by client. +Compatible clients include Yinliu, Feishin and Symfonium. The server implements Subsonic/OpenSubsonic endpoints for library browsing, search, playback and related features; the UI and available features vary by client. See [Subsonic protocol support](../api/subsonic.md) for coverage and limits. | Field | Value | | --- | --- | @@ -38,7 +38,7 @@ By default Subsonic shares the main port. Separate listening requires `subsonic. Online search uses `subsonic.onlineSearch` and `subsonic.onlineSearchMode`: `fallback` supplements insufficient local results; `merge` combines local and online results; `local_only` stays local. Query prefixes include `wy:`, `tx:`, `kw:`, `kg:`, `mg:`, `online:` and `local:`. Online playback still needs a working custom source. -Local library selection follows the account's saved storage location plus its enabled custom directory. A temporary browsing switch in the web UI does not change that library. Read-only local/custom playlists can be exposed separately. Pure local playlists stay on the server; only the online portion of mixed playlists enters LX native sync. +Local library selection follows the account's saved storage location plus its enabled custom directory. A temporary browsing switch in the web UI does not change that library. Read-only local/custom playlists can be exposed separately. Pure local playlists stay on the server; only the online portion of mixed playlists enters LX native sync. See [Subsonic local music library](./subsonic-local-library.md). @@ -64,6 +64,7 @@ For scripts, use the personal Token management UI to create, name, expire, disab | --- | --- | | Local music folder | Explicit local-folder selection uses the local library; the default recommendation pool is not the entire NAS library | | `subsonic.localPlaylist` / `subsonic.customPlaylist` | Read-only local and separate custom-directory playlists; custom depends on the local switch and account directory enablement | +| `subsonic.cachePlaylist` | Include cache-directory tracks in the local playlist, default false; does not shrink the complete library used by other endpoints | | `subsonic.cacheOnPlay` | Save online audio during playback, default false | | `subsonic.playCacheFirst` | Prefer an existing server file, default false; server carries the file traffic | | `subsonic.quality.*` / `subsonic.source.*` | Quality and platform/script candidate selection, not guaranteed availability | @@ -74,7 +75,7 @@ For scripts, use the personal Token management UI to create, name, expire, disab Local tracks have independent IDs; associating a platform does not make them online tracks. Updating at the same path generally retains identity, while moving/renaming externally can assign a new ID. Mixed playlists preserve local members/order separately. Deleting a playlist does not delete its audio files. -Artwork, word lyrics, scan display and folders depend on both protocol and client support. A web setting does not imply an equivalent control exists in every third-party client. +Artwork, word lyrics, scan display and folders depend on both protocol and client support. A web setting does not imply an equivalent control exists in every third-party client. See [Subsonic local music library](./subsonic-local-library.md) for details. ## Console modules @@ -84,7 +85,7 @@ Artwork, word lyrics, scan display and folders depend on both protocol and clien | Users | Create, rename, disable and assign directories | Distinguish removing an account from deleting its data | | Data/snapshots | Inspect playlists and restore past state | Verify account and timestamp before restoring | | Configuration | Paths, authentication, proxies, cache and Subsonic | Environment/WebDAV recovery can affect the next start | -| WebDAV | Test, sync, back up and restore | Incremental/full backups have separate intervals | +| Backup management | Local backups, WebDAV, OpenList, recovery and verification | Connections/scopes in Cloud backup; preview recovery and restart when requested | | File manager | Upload, edit, move and archive server files | Administrative filesystem access, not ordinary library permission | | Logs/maintenance | Inspect errors, reload or restart | Changed ports/paths require a new connection address | diff --git a/docs/en/guide/web-player.md b/docs/en/guide/web-player.md index 927c88ca..f040bce6 100644 --- a/docs/en/guide/web-player.md +++ b/docs/en/guide/web-player.md @@ -1,7 +1,66 @@ -# Web player and local music +# Playback, lyrics and local music The player defaults to `/`, configurable with `PLAYER_PATH`. Player access authentication opens the player; personal login selects playlists, settings and directory permissions. A valid browser session normally avoids repeated access-password prompts. + +## Playback sources and resolution order + +**Prefer existing server audio** (formerly prefer cached songs) includes both the current user's `music` downloads and `cache` playback files. Matching uses indexed platform, song ID and quality; scan/link manually copied files or play them directly from the local library. Users are isolated, with `_open` for public files. + +| Files | Runtime-root mode | DATA_PATH mode | +| --- | --- | --- | +| Downloads and adjacent LRC | `/music//` | `/music//` | +| Playback cache and adjacent LRC | `/cache//` | `/cache//` | + +The persistent per-user serverCacheLocation selects the base. A temporary local-library browsing location does not change the persistent playback base. Custom libraries use a separately configured per-user directory. + +### Web audio + +Local-library entries directly use their file URL for music, cache or custom directories. Online songs resolve in this order: + +1. With prefer-existing-server-audio enabled, probe the user's music/cache. Exact-quality downloads precede cache; other qualities may be fallback candidates. +2. On a local miss, use registered OpenList user cache/download files if the backend playback-source master switch permits it. +3. Reuse a browser-cached URL when enabled. URLs can expire and are not offline audio copies. +4. Resolve an online source-script URL and deliver directly or through the configured proxy. + +Cache-song-files controls background writes; prefer-existing-server-audio controls existing-file reads. Disabling writes does not disable reads. Background writes pass the only-download-mode setting: enabled writes to music, otherwise cache, under the selected base. Foreground playback does not wait for the full background download. + +### Web lyrics + +Prefer-server-local-lyrics is independent of save-lyrics-files: + +| Setting | Effect | +| --- | --- | +| Prefer server-local lyrics | User music/cache LRC, then browser lyrics, then online lyrics | +| Save lyric files | Write standalone LRC during lyric fetching/background audio saving; keep it adjacent to existing audio | +| Browser lyric cache | Store/reuse text in this browser | +| Embed lyrics during download | Write audio lyric tags independently of standalone LRC | + +Turning local preference off skips standalone-LRC lookup in both frontend and backend. Unlinked local tracks can still use embedded lyrics. Local entries support adjacent LRC in music, cache and custom directories even without a platform identity. Roots are determined by the authenticated user; arbitrary paths and cross-user traversal are not permitted. Audio and lyrics resolve independently. + +### Subsonic + +Online IDs such as tx_... probe local music/cache when subsonic.playCacheFirst is on. OpenList follows only when the Subsonic OpenList option and openlist.playback.enable are both on; online sources follow on a miss. Turning cache-first off skips both local and OpenList for online IDs. Delivery is normally a redirect, or server-side transcoding when required and enabled. Online-ID lyrics now try local LRC before the SDK, including indexed custom-library lyrics. + +Local IDs such as local_... are handled separately. The catalog includes the selected cache/music base and enabled custom directory regardless of cache-first. Missing local audio is an error, not a silent replacement with another online track. Lyrics try adjacent LRC, embedded lyrics, indexed local lyrics, and an online SDK fallback if linked to a platform. + +subsonic.cacheOnPlay controls background cache writes after online playback resolution, not existing-file reads. Switching tracks can cancel that task. The online download branch caches even when cacheOnPlay is off. Subsonic background caching explicitly fetches lyrics independently of Web save settings. Early local/OpenList hits avoid duplicate online caching. + +### Cloud sources + +In the admin UI, **Backup management → Cloud backup → OpenList playback cache → Allow OpenList as a playback source (local first)** is the shared master switch for Web and Subsonic. Web also requires its server-audio preference; Subsonic requires its own cache-first and OpenList options. Saving upload-and-delete rules no longer silently enables playback settings. Legacy enabled Subsonic settings migrate only when the new field is absent; explicit false is respected. + +A local database matches user/song/quality before querying one remote file, without directory scans per play. Concurrent lookups are coalesced, download addresses reused for up to 30 seconds, and metadata requests limited to four seconds each. Failed lookups fall back to online sources. Web uses existing proxy preferences; Subsonic uses direct redirects, proxy delivery or transcoding. + +Only registered user cache/download backups with the corresponding per-user directory rule enabled are direct playback candidates. Enabling only the ordinary five backup scopes is insufficient. Ordinary _data backups and historical objects are not automatically a music catalog. The shared downloader can also retrieve an OpenList backup into local storage, gated by the master setting independently of Subsonic-only preferences. + +WebDAV is not a live audio/lyrics fallback. Restore files manually or through explicit startup restore, then use local playback. See [Cloud backup and recovery](./backup-recovery.md). + +### LRC and backup scope + +Writing LRC and uploading LRC are separate. Disabling Web standalone-lyric saving stops implicit LRC creation during audio caching without deleting existing files. Ordinary music backups include existing audio, LRC, covers and indexes under data/music. openlist.includeNonAudio only controls the separate user-cache attachment pipeline. Ordinary backups exclude cache, cwd/music and external custom libraries. + + ## Playback, lyrics and appearance The responsive player includes a queue, quality selection, playback speed, sleep timer, full-screen lyrics, themes and player backgrounds. Lyrics can include translations, romanization and word highlighting when supplied by the source. Lyric cards offer image layouts for sharing selected lines. @@ -100,3 +159,41 @@ Favorite artists/albums and dislike rules are stored separately. Subsonic can hi Server downloads, sync-download and matching run in the backend; browser downloads run on the visiting device. Closing the page does not cancel submitted server work. Inspect task status and use its stop/cancel action rather than refreshing to stop it. See [operations](./operations.md) for diagnosis and [API reference](../api/reference.md) for fields. + +### Song ratings + +After signing in to a local account, rate songs from 1 to 5 stars or clear ratings using the player, song menus, artist detail, charts, local music, and custom directories. Favorites → My ratings provides a searchable rating collection. + +Web and Subsonic share per-user `ratings` and `ratingOrigins` in `subsonic-meta.json`. Rating snapshots live in `rating/snapshot/`; `rating/songs.json` stores essential Web-submitted song descriptions without playback URLs or authentication parameters. Ratings do not modify playlists or audio tags. Current ratings and song descriptions belong to the user-data backup scope; snapshots belong to the snapshots scope. + +Online songs use source-specific IDs, preferring QQ song mids. Unlinked local files use stable registry IDs shared by Web and Subsonic. Once linked, the online ID becomes authoritative: reads and writes share the online version and other linked copies. If the online version already has a rating, it wins and the local score record is deleted. Otherwise the local score, origin and history move to the online ID, and the local entry is deleted. Relinking selects the new online ID; titles are never used to merge ratings. Files recognized as new after importing, rebuilding, or moving are not automatically matched to old ratings by name. + +Low ratings only add dislike rules when linking and the threshold allow it. Raising or clearing ratings does not delete dislike rules. Direct Web ratings record `web`; Web rating-to-dislike linking records `web-rating-link`; direct Subsonic ratings record `subsonic`. Partial snapshot/link failures are reported after the rating is saved. Lists load ratings in batches and refresh periodically or when returning to the page. + + +### Rating identity, reconciliation and backups + +Unlinked local songs use local IDs. Linking keeps an existing online score or migrates the local score when the online version is unrated, then removes the local score record. The dialog shows the target ID and linked copy count; Admin Data View no longer has rating status labels or filters. + +The two supplement actions remain additive. Saving settings does not delete historical rules. To apply a changed threshold, select the user and choose Recalculate by threshold, preview the changes, then execute. Only invalid reasons traceable to a specific rating ID are removed. Rules with manual or other valid reasons remain; historical rules without precise provenance are preserved. Disabling linking does not trigger cleanup. + +My ratings → Export rating backup includes scores, origins, song descriptions and local link references, without audio files. New rating snapshots also contain descriptions. Importing/restoring a rating backup in the admin interface restores scores and available descriptions while preserving current favorites and file links. Link references are for comparison: backup paths never locate or move files or revert associations. A score-only backup cannot recreate missing covers or titles. + +Recommendation exclusion uses the same policy for daily, random and similar tracks: matching dislike rules and scores at or below the threshold are excluded. This is independent of ordinary list hiding and automatic linking. + +Admin rating and dislike dashboards support platform, action origin, cover availability, record age, combined keywords and score filters. Platform options show user totals, including zero-count platforms. Click the score distribution to filter, change page size, inspect records or export the current selection. Rating statistics follow platform, origin, age and keyword filters; type and score further narrow the list. + +### Per-user dislike auto-rating + +Settings → Logic → Dislike auto-rating is saved to the current account on the server and defaults to off. Adding a Web song dislike supplements 1 star only when unrated; existing ratings remain. Other users and the low-score threshold do not control this preference. The global reverse-link switch has been removed. Admin backfill is an independent, previewable operation and works with auto-rating off or threshold zero. Sources are labeled Web manual rating, Subsonic manual rating, Dislike automatic rating, and Admin backfill. The preference is stored in each user’s `rating/preferences.json`; score backup restore does not change this preference. + +Playlist detail supports direct rating. Open rating collections refresh periodically, on returning to the page and after local rating/dislike actions, preserving filters and list scroll. Each entity retains up to 30 recent score changes, including clearing, backfill and restore; identical submissions keep origin timestamps. Old history is not fabricated. Failed auto-rating reports partial completion while retaining the dislike. Recoverable restore write errors roll back files; this does not guarantee cross-file atomicity during process termination or power loss. + + +### Metadata repair and synchronization status + +Identical scores keep their origin and timestamp while allowing available metadata to be repaired. Direct ratings and automatic/admin backfill share one write service; backfill only supplements missing scores. Resolved Subsonic metadata is stored in the user's `rating/songs.json`, reducing dependence on runtime caches. Favorite operations retain rating origins and history. + +My ratings and admin Ratings show pending work with Retry failed items. The user's `rating/sync-status.json` tracks backfill writes, catalog writes, unresolved metadata and snapshot failures. Retries preserve newer scores; metadata resolution still depends on provider availability. Corrupt primary rating files block writes and exports without replacing the original. Corrupt status files request manual inspection instead of being cleared. + +Admin previews separate additions, already-rated skips, duplicate linked identities and resolution failures. Saved scores with failed snapshots report partial completion. Version checks avoid unnecessary full refreshes. The Web collection average follows the current selection; the distribution follows the other filters, so users can switch score levels. diff --git a/docs/guide/backup-recovery.md b/docs/guide/backup-recovery.md index e02160fc..61d5443a 100644 --- a/docs/guide/backup-recovery.md +++ b/docs/guide/backup-recovery.md @@ -1,62 +1,190 @@ -# 云端备份与恢复 +# 备份与恢复 -在后台「系统设置 → 云端备份范围」中,按数据类型分别选择 WebDAV 和 OpenList。两列互相独立:同时勾选表示保存两份副本,不会在两个云端之间同步。服务连接和自动执行仍由各后端的启用开关控制。 +要防止误删歌单、服务器损坏或迁移时丢失数据,先选择备份内容,再选择保存位置。可以只保存在本机,也可以使用 WebDAV、OpenList 保存云端副本。两种云端独立工作,同时启用会保存两份,不会互相同步。 + +## 先选适合你的方式 + +| 需要做什么 | 使用什么功能 | +| --- | --- | +| 找回以前的歌单或不喜欢列表 | 歌单、不喜欢列表的历史快照 | +| 找回以前的服务器设置 | 本地配置历史备份 | +| 下载一份数据留存或迁移 | 备份管理中的本地 ZIP 导出、导入 | +| 自动把数据复制到网盘 | WebDAV 或 OpenList 数据备份 | +| 把播放缓存上传到 OpenList,或上传后节省本地空间 | OpenList 的高级用户播放缓存规则,需单独配置 | + +配置历史备份只保存配置,不包含全部用户数据和歌曲。歌单快照也不包含音频文件。需要恢复整套数据时,应选择对应的数据备份范围,并单独保管未纳入范围的外部音乐目录。 ## 哪些内容会备份 -| 类型 | 文件范围 | 恢复位置 | +在后台 **备份管理 → 云端备份 → 备份范围** 分别选择 WebDAV 和 OpenList 的内容,保存后生效。每一列独立选择;全部关闭表示该端不备份这些数据,但不会删除已经上传的文件。 + +| 界面选项 | 包含的内容 | +| --- | --- | +| 歌曲与歌词 | 数据目录 `music` 下的音频、LRC 歌词、封面和音乐索引 | +| 用户音源文件 | 音源脚本、顺序与状态文件,以及上传的用户文件 | +| 歌单与不喜欢快照 | 歌单和不喜欢列表的历史快照,包括已配置的外部快照目录 | +| 用户数据 | 用户设置、播放记录、收藏等其他用户文件 | +| 系统与账户 | 服务器配置、账号列表及数据目录根部的系统 JSON 文件 | + +**常规数据备份不包含 `cache`、日志、临时文件、本地备份包和外部自定义音乐目录,也不跟随目录链接。** 程序运行目录下的独立 `music` 目录也不在此范围内。若实际数据目录不是默认 `data`,上述路径以当前数据目录为准。 + +新备份扫描、OpenList 新历史清单及完整校验排除 `test-evidence` 测试目录。已有云端文件不会自动删除,旧历史版本仍可恢复。OpenList 手动删除失败时保留失败记录及本地文件,可选择原记录重试;部分远端文件可能已删除,重试会跳过不存在的文件。删除期间暂停备份及完整校验。校验结果表显示当前版本的全部最近结果,不受上方任务校验范围限制。 + +OpenList 的高级播放缓存可以按用户单独上传 `cache`;它与这里的常规备份不同。系统与账户备份可能包含连接凭据,应保存在自己控制的位置。 + + +### 程序根目录和数据目录如何区分 + +以下路径相对于程序运行目录,假设使用默认 `DATA_PATH=data`: + +| 位置 | 是否进入常规 WebDAV / OpenList 数据备份 | +| --- | --- | +| `cache/` | 不备份,程序根目录缓存 | +| `music/` | 不备份,程序根目录中独立于数据目录的音乐 | +| `data/cache/` | 不备份,数据目录播放缓存 | +| `data/users/用户/cache/` | 不备份,用户目录缓存;其他层级的 cache 同样排除 | +| `data/music/` | 选中并保存“歌曲与歌词”后备份,包括歌曲、已有 LRC、封面和音乐索引 | +| 外部目录,如 `D:/音乐/` | 不备份,需单独保管 | + +修改 `DATA_PATH` 后,歌曲范围是 **实际数据目录下的 music**。例如数据目录为 `D:/LXData`,范围就是 `D:/LXData/music`。如果直接把程序运行目录设为数据目录,该位置的 `music/` 就可以被选中备份;不能简单地认为所有“根目录 music”都排除。缓存仍固定排除。 + +范围选择与服务启用是两件事:保存 WebDAV 列的范围后,还需启用并配置 WebDAV 才会执行云端备份。本地 ZIP 导出和导入也使用这列范围,但不需要启用 WebDAV。OpenList 高级播放缓存使用独立用户规则,可单独处理缓存,不改变常规备份的上述排除范围。 + +## 配置本地备份 + +在 **备份管理 → 本地备份** 设置快照数量、本地配置备份和存储路径。 + +- 本地配置备份默认开启,周期为 24 小时,保留 7 天,默认写入数据目录的 `backups`。 +- “锚点时间”默认 `00:00`,表示按服务器本地时间安排周期;留空则从启动或重新设置后按周期计时。 +- 配置备份目录留空使用默认位置。相对路径基于数据目录,绝对路径直接使用。 +- 快照路径留空使用各用户原有快照目录;设置额外路径后,按用户隔离存放。 + +在 **备份管理** 中可以导出或导入本地 ZIP,无需开启 WebDAV。ZIP 使用 WebDAV 列已经保存的数据范围;导出前先确认没有漏选需要的内容。 + +## 使用 WebDAV 备份 + +1. 在 **备份管理 → 云端备份 → WebDAV** 填写地址、用户名和密码,并启用服务。 +2. 设置增量目录和 ZIP 目录,默认分别为 `/lx-sync`、`/lx-sync-backups`。 +3. 保存配置,在当前页测试连接。测试只检查已保存目录是否可读取,不创建文件;测试通过不代表上传、删除权限均已验证。 +4. 执行一次文件同步或全量 ZIP 备份,查看结果。建议先用少量文件,并实际恢复到独立测试实例确认可用。 + +增量备份只上传发生变化的文件,默认每 60 分钟检查。ZIP 是所选数据的一份归档,默认每 24 小时检查并在数据变化时创建;手动全量备份可强制创建新归档。 + +### 两个容易混淆的开关 + +| 开关 | 关闭时(推荐默认值) | 开启时 | | --- | --- | --- | -| 歌曲与歌词 `music` | `data/music/**`,包括歌曲、歌词、封面、`music_index.json` | 当前实例的 `data/music`,保留相对目录 | -| 用户音源文件 `userFiles` | `data/users/source/**`、`data/source/**`、`data/uploads/**` | 当前实例的同名相对目录 | -| 快照 `snapshots` | 用户歌单和不喜欢列表的快照正文及 `snapshotInfo.json`;包括无扩展名文件 | 原相对目录;外部歌单快照按本机 `snapshot.backupPath` 定位 | -| 用户数据 `userData` | `data/users/<用户目录>/**` 中除音源、快照及临时文件之外的数据 | 保留用户目录名及相对路径 | -| 系统与账户 `system` | `config.js`、数据目录根部的 JSON 文件,如 `users.json`、`serverInfo.json` | 配置恢复到当前 `CONFIG_PATH`;JSON 恢复到当前数据目录 | +| 本地删除文件时,也删除 WebDAV 增量副本 | 本地删除后,云端仍保留副本 | 下次增量备份删除已跟踪的对应云端文件;ZIP 历史不受影响 | +| 每次启动前,用 WebDAV 备份覆盖本地所选数据 | 需要时手动恢复 | 每次启动先恢复,云端同名文件覆盖本地;恢复完成后才开放服务 | + +启动恢复适合明确需要云端作为恢复来源的部署。它不是“只在空目录第一次启动时恢复”,可能覆盖本地较新的数据;失败后进入维护状态,不会自动把本地数据反向上传。 + +### 历史保留和上传流量 + +ZIP 默认最多保留 5 份,保留天数默认 0(不限天数)。成功上传后按份数和天数清理,始终保留最新一份;增量目录不受 ZIP 保留策略影响。 + +上传后检查响应和云端大小,不把整个文件再下载一遍。安全替换需要 WebDAV 服务支持 `MOVE`;若出现 MOVE 错误,应检查服务及代理是否支持该方法。大小一致只表示大小检查通过,不代表内容逐字节相同。 + +## 使用 OpenList 备份 + +1. 在 **备份管理 → 云端备份 → OpenList** 填写 OpenList 地址、令牌或账号信息。 +2. 根目录填写 **OpenList 中已经挂载的路径**,例如 `/local`。不要填写 Windows 主机路径 `E:\OpenList`;主机路径应在 OpenList 自己的存储挂载中配置。 +3. 启用、保存并测试连接,再执行“立即备份并同步缓存”。 +4. 在对应记录页查看上传结果。 + +常规备份保存到根目录的 `_data` 下,并保留相对目录结构。例如根目录为 `/local`,歌曲可能保存在 `/local/_data/music/用户目录/歌曲.mp3`。实际 Windows 磁盘位置由 OpenList 的挂载配置决定。 + +常规数据备份默认保留本地文件,本地删除不会自动让 `_data` 中的备份跟着删除。高级播放缓存则有独立的按用户规则和存储位置;只有明确选择“上传后删除本地”的规则,才会在严格校验后清理对应缓存。 + +### “上传完成”和“内容校验”有什么区别 + +“上传完成”表示上传成功且云端大小检查通过,不表示已比对完整内容。普通备份不会自动下载歌曲验证 SHA-256。记录列表主状态“已备份”与独立的“内容校验”筛选分开;校验筛选匹配当前备份指纹的历史结果。备份版本变化或后台发现云端变化后需重新校验,内部旧 verified 状态不能作为依据。上传后删除和恢复仍会执行各自的自动安全校验。 + +需要检查内容时,打开 **备份管理 → 记录与校验 → OpenList → 内容校验**: + +1. 选择用户、数据类型和范围,先统计预计读取流量。 +2. 默认只检查未校验或版本变化的文件;怀疑网盘文件后来损坏时选择全部当前文件。 +3. 确认后手动启动,可在后台执行并随时停止。 +4. 查看内容一致、不一致、文件缺失或读取失败等结果。校验不会自动覆盖或修复云端文件。 + +如果存储提供可用的 SHA-256,可直接比较;否则需读取云端正文,会消耗下载流量。旧记录缺少上传指纹时会明确提示,需要重新备份才能建立可比对记录。“上传后删除本地”仍需严格内容校验,因此可能读取云端音频和附件。 + +## 筛选 OpenList 备份记录 + +在“备份管理 → 记录与校验”选择 OpenList 文件备份记录。记录区域只有一套筛选:数据类型按钮、用户、存储类别、本地文件状态及排序;点击状态卡筛选对应状态,再次点击取消。状态卡数量随其他条件变化,当前已选条件会显示在面板底部,点击条件标签可单独移除。 + +搜索支持空格分隔的多个关键词,所有关键词都需命中,可匹配文件名、用户、路径及已有歌曲元数据。“更多条件”提供路径、快照子类型和更新日期范围,日期包含起止当天,按浏览器本地时间解释。多个条件同时生效,没有匹配结果时可逐项移除或重置。筛选和分页复用当前记录;点击“刷新记录”获取最新状态,重新进入记录页也会刷新。不改变备份范围或实际文件。 + +## 从备份恢复 + +恢复方向是 **备份 → 当前服务器**,不会上传本地文件,也不会删除本地独有文件。 + +### WebDAV + +1. 在当前实例确认数据目录、配置文件位置和外部快照路径,保存需要恢复的数据类型。 +2. 打开 **备份管理 → 数据恢复 → WebDAV** 的恢复窗口,选择增量目录或指定 ZIP。 +3. 选择“仅补缺失文件”或“覆盖同名文件”,建议保留恢复前副本。 +4. 先预览新增、覆盖、合并索引与跳过数量,再确认恢复。ZIP 预览需下载整个归档,确认后复用,不重复下载;取消或预览过期会清理该临时归档。 +5. 完成后重启服务,重新加载用户、歌单、音源与音乐索引。 + +### OpenList + +在 **备份管理 → 数据恢复 → OpenList** 选择当前备份或可用的历史版本,选择范围和覆盖方式,扫描预览后启动。恢复记录可以查看进度,并对支持的任务继续、停止或回退。新实例无需原来的备份数据库,也能从云端数据备份枚举恢复。 + +### 覆盖规则与失败处理 + +- **仅补缺失文件**:保留已有文件,只补本地没有的内容。必要的音乐、快照索引按条目合并;普通 JSON 不做字段级合并。 +- **覆盖同名文件**:选中范围内的同名文件以备份为准,本地独有文件保留,不是清空整个目录。 +- **恢复前副本**:默认保留,可在恢复记录中回退;恢复后又被修改的文件会拒绝自动回退,避免丢失后续改动。确认不需要后可删除记录及副本。 + +所有需要写入的文件会先下载暂存并校验,再开始替换本地文件。下载失败不会覆盖原文件;写入失败会尝试回滚。旧备份缺少内容指纹时仅检查大小并提示。恢复需要额外磁盘空间存放暂存文件和恢复前副本。 + +恢复期间进入维护状态;手动恢复完成后需重启,刷新网页不能代替重启。若进程在写入时中断或回滚失败,先在恢复记录中处理回退,再重启恢复正常服务。 + +## 常见问题 + +**提示 storage not found?** 检查 OpenList 是否存在且已启用相应挂载,根目录是否与挂载路径一致。连接到 OpenList 不等于已经创建了可写存储。 + +**为什么 LRC 也上传了?** “歌曲与歌词”包含数据目录 `music` 下已有的 LRC;高级缓存的附件开关只控制独立缓存规则。歌词生成与备份是两个步骤,关闭生成不会删除已有 LRC。 + +**恢复后看起来没有变化?** 确认恢复来源、数据类型、用户目录和覆盖方式;“仅补缺失”会跳过已有文件。完成后重启服务,再查看结果。 + +**WebDAV 与 OpenList 会互相同步吗?** 不会。两端各自保存副本,恢复时明确选择一个来源。 -`cache`、`runtime`、日志、临时文件、SQLite 工作库/WAL、已有本地备份包始终排除。不会跟随目录联接或符号链接,不会备份外部自定义音乐目录。歌曲备份不是整个服务器磁盘备份。 +配置键与默认值见 [配置说明](./configuration.md),脚本集成见 [备份接口](../api/reference.md#backup-api)。 -用户目录名称包含账户标识,恢复时需要保留名称。歌曲索引与音频归在同一类,避免只恢复索引而没有对应音频。系统和用户数据可能包含密码、令牌或设备信息,请使用自己控制的云端目录。 +### 记录状态与校验结果 -## 写入 config.js 的设置 +记录页主状态表示任务进度:待上传、待下载、传输中、已备份、需处理、失败或已暂停。启用范围中的本地文件大小或修改时间变化会提示待上传,后台按现有周期核对并处理;仅时间变化且内容相同的文件在后台确认后更新基线。服务或范围停用时显示已暂停。播放缓存按上传后删除规则清理本地,不等于删除云端备份。 -```js -module.exports = { - 'backup.webdav.types': ['music', 'userFiles', 'snapshots', 'userData', 'system'], - 'backup.openlist.types': ['music', 'userFiles', 'snapshots', 'userData', 'system'], - 'backup.webdav.mirrorDeletes': false, - 'backup.webdav.restoreOnStartup': false, -} -``` +内容校验独立显示历史结果,不代表实时一致。本地有变化或传输等待期间不在列表突出旧通过标记。后台观察到云端变化后,旧通过结果显示需重新校验,并纳入默认校验范围;要查找同大小、同修改时间的云端内容损坏,应选择所有当前备份文件。刷新记录只检查本地元信息,不下载云端正文。本地、云端变化诊断在详情和高级筛选查看。校验页用户、类型也会限制结果列表。 -类型数组允许为空,表示该目的地不备份任何数据类型。后台保存时校验类型并写入实际使用的配置文件。取消勾选只改变之后的备份和恢复范围,不会删除云端已有文件。 +### 手动检查文件与上次检查时间 -`webdav.excludeCache`、`webdav.excludeMusic` 保留旧配置兼容接口。新的类型数组是范围选择的依据;缓存现在固定排除,不再提供相互冲突的界面开关。旧 `openlist.backupSystemConfig` 只用于未采用新类型设置的旧流程,不再作为新界面的第二个系统备份开关。 +在记录页点击“检查文件”,选择本地、云端或两者,以及已选记录、当前筛选结果或当前连接的全部记录。范围只包括记录主文件;绑定附件请使用内容校验入口,历史版本对象不在本次范围。检查存在性、大小、修改时间,不上传、不下载正文、不删除文件,不会生成 SHA-256 内容校验结果。云端逐项请求元信息,允许停止;已完成结果保留。 -## 两种云端的执行方式 +高级筛选显示当前匹配范围内“已检查 X/Y 项”和最近检查时间,悬停可看最早检查时间;这不是整轮全部成功的证明。每个文件详情有准确时间。本地时间随刷新记录重新检查,手动检查时间另外保留;云端结果按连接与备份版本保存,服务重启后仍可查看。后台检查不保证所有文件同时检查,进程中断的任务需重新启动。 -WebDAV 增量备份将所选文件按相对路径保存到 `webdav.syncPath`;ZIP 备份将相同范围打包保存到 `webdav.backupPath`。它们分别维护成功基线,增量上传不会让 ZIP 误判为没有变化,失败上传也会在下一轮重试。 +### Subsonic 评分与快照 -本地离线 ZIP 导出和导入复用 WebDAV 列已保存的范围,但无需启用 WebDAV,也不会访问云端。界面会明确说明这个范围,避免把部分导出误认为整个服务器的全量副本。本地配置每日历史副本仍由 `configBackup.*` 管理;它只保存配置,不替代上述数据备份。 +评分歌单开关仅控制虚拟歌单显示,不启用自动联动。两个联动开关独立控制方向,阈值为 0 时停止联动;联动只补缺失数据,提高或清除评分不删除不喜欢规则,取消不喜欢不删除评分,也不覆盖已有评分。1 星歌单并入不喜欢命中歌曲,保留已有真实评分。 -默认保留已上传的散文件,即使本地已经删除。只有显式打开 `backup.webdav.mirrorDeletes`,本地删除才会传播到散文件目录。镜像删除不会更改历史 ZIP。两种定时任务和恢复操作不会在同一个 WebDAV 实例内同时运行。 +评分快照恢复只替换评分,保留当前歌手和专辑收藏,兼容旧快照格式。评分快照及索引属于备份的「快照」范围;当前 subsonic-meta.json 属于「用户数据」,完整备份请同时选择两项。评分成功但不喜欢解析或写入失败会返回部分成功提示,可重试或手动补同步;两个存储不构成跨文件事务。缓存淘汰的已评分歌曲会尝试重新解析,源不可用时保留评分但暂时无法展示歌曲。 -OpenList 数据备份保存在 `/_data/<相对路径>`,默认只上传并保留本地文件。同步间隔由 `openlist.syncInterval` 控制;「立即备份 / 同步缓存」也会执行一次。SQLite 记录用于显示进度及重试,不是恢复的唯一依据:新实例没有旧数据库,也能枚举 `_data` 恢复文件。 +新不喜欢规则按来源去重和匹配;未指定来源时显式作用于全部来源。相同来源、歌曲身份不冲突的重复记录会补齐缺失字段,保留已有有效资料和原始显示名称。新不喜欢记录含 provenance,补全记录含 enrichedBy;评分来源存于 subsonic-meta.json 的 ratingOrigins。手动补同步先预览候选(包含在线缓存),执行时空解析计入失败,资料补全数单独报告。 -OpenList 高级永久播放缓存使用独立的用户规则和目录。它可以上传 `cache`、上传后清本地或用于 Subsonic 直链/代理交付,**不属于上面的数据备份范围**,默认不会因为勾选「歌曲与歌词」而启用。新界面的歌曲、快照、系统备份不再由这些用户缓存规则重复控制。 +配置变化不会重算历史数据。提高低分阈值后,手动补齐可增加符合条件的规则;降低阈值、设为0、关闭联动或清除评分不会撤销旧规则。匹配和显示选项仅改变后续过滤/展示结果。Web反向自动补1星仅限带来源和歌曲ID的单首歌曲,歌手和专辑不喜欢不会被误写成歌曲评分。 -上传后清理播放缓存之前会核对远端真实内容的 SHA-256;这一步需要从远端读取音频,会增加一次校验流量。本地在传输期间发生变化时保留文件。普通数据备份没有上传后删除行为。 +后台「数据查看 → 不喜欢」按所选用户合并不喜欢规则与不喜欢歌手/专辑库。可筛选类型、添加来源和音乐平台,预览已保存封面、查看规则及完整字段。没有来源记录时显示「来源未记录」,不会推断其来自Web或Subsonic;该页只读,不修改联动或评分。 -## 恢复步骤 +评分身份迁移与恢复使用事务日志:写入失败回滚,进程中断后在下次评分访问时完成提交。恢复时按当前文件关联整理本地与在线评分,并同步不喜欢原因中的评分 ID,保留手动原因与旧快照。事务日志临时保存于用户 `rating/transaction.json`,完成后移除;不要让多个服务器进程同时写入同一数据目录。 -1. 在目标实例配置云端连接,确认数据目录、`CONFIG_PATH` 和外部快照挂载位置。 -2. 在系统设置中选择要恢复的类型并保存。外部快照必须先设置本机的 `snapshot.backupPath`;不会直接采用云端提供的绝对路径。 -3. WebDAV 选择散文件或指定 ZIP 恢复;OpenList 使用「恢复所选数据类型」。两者都只下载所选内容,覆盖同名文件,保留本地独有文件,不反向上传。 -4. 检查返回结果和日志。恢复过程使用临时文件后重命名,截断下载不会覆盖原文件。多个文件的恢复不是一个原子事务,部分失败时应检查失败列表后重试。 -5. 重启服务后,重新加载用户、歌单、音源和音乐索引。仅刷新网页不能刷新所有服务端内存状态。 +### OpenList 连接状态 -WebDAV 启动恢复默认关闭。确需无人工干预的灾备启动时可设置 `backup.webdav.restoreOnStartup=true`;这会按已保存范围覆盖同名本地文件。自动模式只在散文件目录不存在或没有所选文件时尝试 ZIP,不会把认证或网络故障解释成“云端为空”,也不会在恢复失败后自动上传本地数据。 +备份记录页独立显示连接状态、最近探测及成功时间。点击「探测连接」只验证认证和配置的备份目录,不重新上传或下载文件。连接异常时保留历史备份结果,云端文件标记为「暂无法检查」;服务自动退避探测(30 秒至 5 分钟),连接恢复后重新检查已备份文件。连接正常和元信息未变均不代表内容校验通过。 -旧 ZIP 中即使存在缓存,恢复仍按当前类型过滤。旧 OpenList 按用户存储的音频可继续由兼容缓存流程访问;新的统一恢复入口读取 `_data`,不猜测或移动旧目录。 +连接异常统一显示中文原因,详情保留原始错误。上传、下载和附件传输遇到连接故障时回到待执行状态,不消耗文件失败重试次数;服务恢复后自动继续。文件检查中途断连时保留当前进度,显示「等待连接恢复」,恢复后继续检查当前文件;可随时停止。进程重启后的未完成检查会显示中断,需要重新发起。 -## 验证边界 +记录中的「上次未变」仅表示上次元信息观察结果。单条「检查」只检查本地与云端元信息;「重试上传/重试下载」会实际传输文件。 -回归测试覆盖真实 SQLite、本地 HTTP OpenList 测试服务、WebDAV 客户端替身、ZIP 打包/恢复、全新目录恢复、类型过滤、缓存排除、上传失败重试及部分音频交付行为。这些检查不代表所有云盘挂载、真实 WebDAV 服务和真实 Subsonic 客户端都已实测。首次使用时应先备份少量文件,并在独立空目录验证恢复结果。 +双向同步传播删除前,将文件和可读取的关联附件保存到数据目录的 `backups/openlist-recycle/<随机编号>/`,附带 `manifest.json` 记录原路径;保存失败则中止删除。可根据清单手动恢复,回收副本不会自动清理。单个用户目录单轮检测到超过 20 项删除时暂停该目录同步,不执行该目录的删除、上传或下载;请核对挂载及本地目录,确认后分批手动处理。此保护不改变管理员明确执行的删除操作。 diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index f97ecb2d..5608d684 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -80,6 +80,9 @@ | `SYSTEM_ALLOW_UNSAFE_VM` | `system.allowUnsafeVM` | 是否允许运行 VM 模式自定义源脚本 (需注意安全风险) | `false` | | `LX_USER_<用户名>` | `users` 数组 | 快速添加用户,值为该用户的密码 (如 `LX_USER_test=123`) | - | +缓存清理仅处理 `cache/`(含子目录),不处理 `music/`。运行根目录和数据目录中,每个用户、每个位置分别计算空间额度;下载完成后检查空间,启动、修改清理设置后及每 12 小时检查两处缓存。按最近访问或修改时间清理,同名音频与歌词成组处理,跳过活跃备份、索引与临时文件。`cache.maxAgeDays` 可在 config.js 或后台设置,默认 14 天;0 仅关闭过期清理,不关闭空间限制。 + + 布尔值不区分大小写,开启可用 `true/1/yes/y/on`,关闭可用 `false/0/no/n/off`。 `LX_USER_<用户名>` 的值作为该用户密码使用,例如 `LX_USER_user1=your-password`。自定义目录、写入权限和账号禁用等高级设置请通过后台用户管理维护,不要把 JSON 当作该环境变量的高级配置格式。 @@ -122,7 +125,7 @@ Subsonic 用户目录中的 `subsonic-local-registry.json` 和 `subsonic-local-s 配置备份默认开启,保留 7 天;默认位置为数据目录的 `backups/`。可以用 `CONFIG_BACKUP_DIR` 和 `SNAPSHOT_BACKUP_PATH` 自定义配置备份、歌单快照的存储位置。 -WebDAV 和 OpenList 按「系统设置 → 云端备份范围」选择的数据类型备份。启动时恢复默认关闭,可用 `backup.webdav.restoreOnStartup` 显式开启。恢复步骤和两种后端的职责见 [云端备份与恢复](./backup-recovery.md)。 +WebDAV 和 OpenList 按「备份管理 → 云端备份 → 备份范围」选择的数据类型备份。启动时恢复默认关闭,可用 `backup.webdav.restoreOnStartup` 显式开启。恢复步骤和两种后端的职责见 [云端备份与恢复](./backup-recovery.md)。 新的 `backup.webdav.types` 和 `backup.openlist.types` 类型数组分别控制两个目的地。歌曲只包含 `data/music`;`cache` 和外部音乐目录不属于数据备份。旧排除选项不再作为第二套界面开关。 @@ -196,3 +199,50 @@ npm run dev - WebDAV:核对远端命名空间、排除项、恢复日志和运行时值。 启动方式见 [部署指南](./getting-started.md),备份恢复见 [运行维护](./operations.md)。 + +## 备份配置项(后台或 config.js) + +备份设置统一位于 **备份管理**。本地配置与快照在“本地备份”,连接、周期、范围和 ZIP 保留策略在“云端备份”,用户播放缓存规则也在“云端备份”内,位于 OpenList 连接配置下方的“OpenList 播放缓存”卡片。实际操作见 [备份与恢复](./backup-recovery.md)。 + +以下是配置文件键,**不代表存在同名大写环境变量**。目前没有 `OPENLIST_*`、`WEBDAV_RETENTION_*`、`CONFIG_BACKUP_INTERVAL_HOURS`、`CONFIG_BACKUP_SCHEDULE_TIME` 的环境变量映射;请在后台保存,或修改生效的 `config.js`。上方表格列出的 `WEBDAV_*`、`CONFIG_BACKUP_ENABLE`、`CONFIG_BACKUP_RETENTION_DAYS`、`CONFIG_BACKUP_DIR` 等环境变量仍然有效。 + +| 配置键 | 默认值 | 用途 | +| --- | --- | --- | +| `maxSnapshotNum` | `10` | 公共最大历史快照数;用户可有独立设置 | +| `configBackup.enable` | `true` | 自动保存本地配置历史副本 | +| `configBackup.intervalHours` | `24` | 配置备份周期,小时 | +| `configBackup.scheduleTime` | `'00:00'` | 服务器本地锚点时间;空字符串从启动/重新设置后计时 | +| `configBackup.retentionDays` | `7` | 配置历史保留天数 | +| `configBackup.dir` | `''` | 空值使用数据目录的 `backups` | +| `snapshot.backupPath` | `''` | 额外快照路径,按用户隔离 | +| `backup.webdav.types`、`backup.openlist.types` | 五类全部选中 | 两端独立的数据范围,见下方示例 | +| `backup.webdav.mirrorDeletes` | `false` | 本地删除时删除已跟踪的 WebDAV 增量副本 | +| `backup.webdav.restoreOnStartup` | `false` | 每次启动服务前用 WebDAV 覆盖所选本地数据 | +| `webdav.retentionCount` | `5` | ZIP 最多保留 1–1000 份,始终保留最新一份 | +| `webdav.retentionDays` | `0` | ZIP 保留 0–3650 天,0 不按天数清理 | +| `openlist.enable` | `false` | 启用 OpenList 备份与用户缓存规则 | +| `openlist.endpoint` | `''` | OpenList API 地址 | +| `openlist.token` | `''` | OpenList API 令牌 | +| `openlist.username`、`openlist.password` | `''` | 未使用令牌时的登录凭据 | +| `openlist.root` | `'/lx_music'` | 已存在的 OpenList 挂载内路径,不是主机磁盘路径 | +| `openlist.externalUrl` | `''` | 外网地址,留空使用 endpoint | +| `openlist.syncInterval` | `60` | 备份与缓存规则的检查间隔,分钟 | +| `openlist.verifyInterval` | `5` | 后台状态检查间隔,分钟;不是定时下载正文做 SHA-256 校验 | +| `openlist.historyRetentionDays` | `7` | 云端历史版本保留天数 | +| `openlist.userBackups` | `'[]'` | 高级按用户缓存规则,JSON 数组字符串;建议通过界面编辑 | +| `openlist.includeNonAudio` | `false` | 高级缓存规则附带已有歌词与封面,不控制常规 music 备份 | +| `openlist.playback.enable` | `false` | 允许已登记 OpenList 文件参与播放回退,另需播放器/Subsonic 相应设置 | + +```js +module.exports = { + // 合并到现有配置,不要用此片段覆盖其他配置。 + 'backup.webdav.types': ['music', 'userFiles', 'snapshots', 'userData', 'system'], + 'backup.openlist.types': ['music', 'userFiles', 'snapshots', 'userData', 'system'], + 'backup.webdav.mirrorDeletes': false, + 'backup.webdav.restoreOnStartup': false, + 'webdav.retentionCount': 5, + 'webdav.retentionDays': 0, +} +``` + +类型数组留空 `[]` 表示该端不备份数据。显式类型数组优先于旧 `webdav.excludeMusic`;`webdav.excludeCache` 是兼容项,常规备份始终排除 cache。旧 `openlist.backupSystemConfig` 仅用于没有显式类型设置的兼容流程,新配置使用 `backup.openlist.types`。 diff --git a/docs/guide/operations.md b/docs/guide/operations.md index e90f582d..c8048736 100644 --- a/docs/guide/operations.md +++ b/docs/guide/operations.md @@ -50,7 +50,7 @@ pm2 restart ecosystem.config.js --env production --update-env 迁移本地库时同时保留账号状态和音频。Subsonic 的本地 ID 登记文件与本地歌单状态应一起保留;外部移动或重命名歌曲文件可能产生新身份。 -WebDAV 的增量检测间隔默认 60 分钟,全量备份间隔默认 24 小时,分别由 `SYNC_INTERVAL`、`BACKUP_INTERVAL` 控制。已配置 WebDAV 的启动过程会尝试恢复远端数据,随后重新加载配置和账号;它并不限于第一次启动空目录。 +WebDAV 的增量检测间隔默认 60 分钟,全量备份间隔默认 24 小时,分别由 `SYNC_INTERVAL`、`BACKUP_INTERVAL` 控制。仅配置 WebDAV 不会自动恢复。`backup.webdav.restoreOnStartup` 默认关闭;显式开启后,每次启动都会先恢复所选数据并重新加载配置、账号,成功后才开放服务,不限于空目录首次启动。手动恢复完成后需要重启;操作步骤见 [备份与恢复](./backup-recovery.md)。 ## 反向代理 diff --git a/docs/guide/subsonic-local-library.md b/docs/guide/subsonic-local-library.md new file mode 100644 index 00000000..69253db8 --- /dev/null +++ b/docs/guide/subsonic-local-library.md @@ -0,0 +1,96 @@ +# Subsonic 本地音乐库 + +Subsonic 客户端可访问当前账号的服务器音乐文件、缓存及自定义音乐目录。本页说明目录范围、歌单配置、媒体读取和持久化数据;接口范围见 [Subsonic 协议支持](../api/subsonic.md)。 + +## 接入与目录范围 + +1. 在后台为账号选择持久化存储位置;使用 NAS 目录时,启用该账号的自定义音乐目录并保证服务进程可读取。 +2. 在后台“Subsonic 协议配置”中启用需要的本地歌单入口并保存。 +3. 在客户端选择“NAS 本地音乐(存储目录+自定义目录)”音乐文件夹,浏览本地专辑、艺术家、流派和歌曲。目录浏览模式也提供 NAS 入口。 +4. 添加或更新文件后,可通过客户端的扫描功能调用 `startScan` 刷新索引。 + +| 账号存储设置 | 内置曲库范围 | +| --- | --- | +| `data` | 数据目录下该账号的 `music`、`cache` | +| `root` | 程序根目录下该账号的 `music`、`cache` | +| 已启用的自定义目录 | 在内置曲库之外收录该目录的可访问文件;相同实际文件只收录一次 | + +网页文件管理器中的临时目录切换不改变 Subsonic 的曲库范围。切换账号存储位置或关闭自定义目录后,后续请求使用新的目录范围;已保存的歌曲 ID 不能绕过访问范围限制。 + +## 本地歌单配置 + +后台“Subsonic 协议配置 → 本地歌曲歌单”提供以下设置: + +| 配置项 | 默认值 | 行为 | +| --- | --- | --- | +| `subsonic.localPlaylist` | `true` | 返回只读的“本地歌曲(用户名)”歌单;关闭后不返回该歌单及自定义目录歌单 | +| `subsonic.customPlaylist` | `true` | 父开关开启且账号已启用自定义目录时,单独返回“自定义目录(用户名)”歌单 | +| `subsonic.cachePlaylist` | `false` | 是否在“本地歌曲”歌单中合并当前存储位置的 `cache` 歌曲;关闭时仅显示 `music` 歌曲 | + +本地歌单在服务端返回列表中排在前面,客户端自行排序时以客户端规则为准。已启用的入口即使没有歌曲也会保留。关闭父开关会保留子开关的设置。 + +自定义目录歌曲与内置目录歌曲分开展示。自定义目录指向内置 `music` 或 `cache` 时,文件优先归属内置目录,不重复加入自定义目录歌单;其中的缓存歌曲仍受缓存目录开关控制。这些开关只控制歌单展示,不缩减搜索、专辑和评分等接口共用的完整曲库。 + +## 浏览、搜索与扫描 + +- 指定 NAS 本地音乐文件夹时,专辑等浏览接口使用本地曲库。默认推荐页的“最新专辑”“随机专辑”仍使用在线推荐池,不自动混入 NAS 专辑。 +- `local:关键词` 只搜索本地文件;普通搜索按服务器搜索设置处理,显式在线搜索使用在线分支。 +- 索引读取曲序、碟号、专辑艺术家、年份和流派。专辑按所在目录、专辑名及专辑艺术家分组,避免不同目录中的版本混合。 +- 目录范围不变时,曲库结果缓存 30 秒;`startScan` 可主动刷新。`getScanStatus` 返回扫描状态、歌曲数量及失败原因。 + +## 播放、封面与歌词 + +本地歌曲支持详情、播放、下载及 HTTP Range 请求。歌曲和专辑时长按协议返回整数秒,内部保留精确时长;文件大小返回实际字节数。 + +服务端开启转码并安装可用的 ffmpeg 后,本地播放可进行格式转换、码率限制和时间偏移。相关配置与限制见 [协议支持说明](../api/subsonic.md#转码与媒体限制)。 + +### 歌词来源 + +按以下顺序查找可用歌词: + +1. 非空的同名外置 LRC 文件。 +2. 音频文件的内嵌歌词。 +3. 网页播放器的服务器歌词缓存。 +4. 已关联音乐平台的在线歌词接口。 + +在线回退只使用已关联的平台 ID,不按歌名猜测其他歌曲。请求有超时限制,并按账号复用并发请求、短暂缓存成功结果。结构化歌词接口可返回原文、翻译和音译;普通歌词接口按翻译配置合并。支持多时间标签、整数秒标签和 offset,不额外转换为 OpenSubsonic 逐字歌词格式。 + +### 封面来源 + +本地歌曲读取音频内嵌或目录封面,并兼容客户端附加的 `al-`、`ar-`、`tr-`、`sg-`、`mg-` 前缀;读取范围仍受当前账号权限约束。 + +普通用户歌单的列表与详情返回统一封面令牌。解析时优先使用歌单网络封面;关联网络歌单缺少封面时尝试向原平台获取,再回退到第一首歌曲的封面,包括本地封面。空歌单没有可用封面时返回空内容。解析按账号隔离。 + +## 文件身份与关联 + +每个本地文件使用独立的 `local_`。关联在线平台不会改变 ID,也不会改写音频标签。响应通过 `lxStorage=local`、`lxLinked` 和 `comment` 表达本地身份与关联状态,是否显示由客户端决定。 + +本地歌曲不会按歌名与在线歌曲自动合并。身份登记按实际路径保存:在原路径更新文件或关联平台会保留 ID;外部移动、重命名文件会被视为新文件,不进行指纹追踪。不在当前目录范围内的歌曲会停止显示,但原歌单引用和收藏仍保留,切回原目录后可恢复。 + +## 收藏与歌单同步 + +| 操作 | 存储及同步行为 | +| --- | --- | +| 创建纯本地歌单 | 只保存在服务器,不进入 LX 官方客户端同步 | +| 创建混合歌单 | 在线歌曲写入原生歌单,本地成员及混排顺序独立保存 | +| 在线歌单重新同步 | 在线成员随同步增删,本地成员保留;新增在线成员默认追加到合成列表末尾 | +| 按位置删除歌曲 | 按客户端看到的混合列表解析位置 | +| 删除混合歌单 | 删除原生在线歌单与本地附加信息,不删除音频文件 | +| 收藏本地歌曲 | 使用 `star` / `unstar`;向内置“我的喜爱”歌单直接添加本地成员会返回提示 | + +原生歌单被其他同步操作删除后,本地附加成员仍可作为服务端歌单查看。播放记录、队列和书签可解析本地歌曲。 + +## 数据备份与排查 + +以下文件位于用户目录,应随用户数据一起备份: + +| 文件 | 内容 | +| --- | --- | +| `subsonic-local-registry.json` | 文件身份和元数据;不是可随意清理的缓存 | +| `subsonic-local-state.json` | 本地收藏、纯本地歌单及混合歌单附加信息 | + +曲库缺失时,先核对账号存储位置、自定义目录开关和服务进程的文件权限,再发起扫描并查看 `getScanStatus`。本地歌单缺少缓存歌曲时,检查 `subsonic.cachePlaylist`。 + +部分客户端会根据歌曲 `size` 决定是否加载歌单封面。在线歌曲优先返回音源提供的字节数,缺失时按码率和时长估算;未知时长且没有已知大小时仍返回 0。估算值不用于实际音频响应的 Content-Length 或 Range。 + +大曲库扫描速度取决于目录访问和标签读取性能;转码取决于 ffmpeg 与服务端资源。封面、歌词、扫描进度及其他控件的展示也受客户端实现影响。 diff --git a/docs/guide/sync-server.md b/docs/guide/sync-server.md index 22987dce..7c4c9dfb 100644 --- a/docs/guide/sync-server.md +++ b/docs/guide/sync-server.md @@ -26,7 +26,7 @@ ### Subsonic 客户端 -支持使用音流、Feishin、Symfonium 等客户端访问已实现的 Subsonic/OpenSubsonic 接口。各客户端支持的功能不同,接口范围和已知限制见 [Subsonic 状态](../subsonic-status.md)。 +支持使用音流、Feishin、Symfonium 等客户端访问已实现的 Subsonic/OpenSubsonic 接口。各客户端支持的功能不同,接口范围和已知限制见 [Subsonic 协议支持](../api/subsonic.md)。 默认连接信息: @@ -52,7 +52,7 @@ Subsonic 本地曲库按当前账号的持久化存储设置读取音乐和缓存,并加入该账号已启用的自定义目录。网页中临时切换浏览目录,不会改变 Subsonic 的曲库范围。 -默认返回只读的“本地歌曲”歌单;自定义目录可以单独显示。纯本地歌单保存在服务端,不进入 LX 官方客户端同步;混合歌单中的在线歌曲和本地成员分别保存。详情见 [Subsonic 本地音乐库](../subsonic-local-library.md)。 +默认返回只读的“本地歌曲”歌单;自定义目录可以单独显示。纯本地歌单保存在服务端,不进入 LX 官方客户端同步;混合歌单中的在线歌曲和本地成员分别保存。详情见 [Subsonic 本地音乐库](./subsonic-local-library.md)。 @@ -91,6 +91,7 @@ Subsonic 本地曲库按当前账号的持久化存储设置读取音乐和缓 | --- | --- | | 本地音乐文件夹 | 明确选择本地文件夹时使用本地库分支;默认推荐池不等同于 NAS 全部专辑 | | `subsonic.localPlaylist` / `subsonic.customPlaylist` | 分别控制只读本地歌单和独立自定义目录歌单,后者依赖前者及账号目录启用 | +| `subsonic.cachePlaylist` | 是否在本地歌单中包含缓存目录歌曲,默认 false;不缩减其他接口共用的完整曲库 | | `subsonic.cacheOnPlay` | 播放在线资源时触发落盘缓存,默认 false | | `subsonic.playCacheFirst` | 优先通过服务器已有文件播放,默认 false;使用时服务器承担文件传输 | | `subsonic.quality.*` / `subsonic.source.*` | 控制候选音质和跨平台/脚本优选,不保证每首都有目标音质 | @@ -101,7 +102,7 @@ Subsonic 本地曲库按当前账号的持久化存储设置读取音乐和缓 本地歌曲使用独立 ID,关联平台不会把它变成在线歌曲。文件在原路径更新通常保留身份,外部移动或重命名文件则可能产生新 ID。混合歌单的本地成员与顺序单独保存;删除歌单不等于删除音频文件。 -客户端封面、逐字歌词、扫描进度与可用的目录展示受协议和客户端能力共同影响。网页版有某个设置不代表第三方客户端会提供同名控件。详细本地库行为见 [Subsonic 本地音乐库](../subsonic-local-library.md)。 +客户端封面、逐字歌词、扫描进度与可用的目录展示受协议和客户端能力共同影响。网页版有某个设置不代表第三方客户端会提供同名控件。详细本地库行为见 [Subsonic 本地音乐库](./subsonic-local-library.md)。 ## 后台各模块的用途 @@ -111,7 +112,7 @@ Subsonic 本地曲库按当前账号的持久化存储设置读取音乐和缓 | 用户管理 | 新建、重命名、禁用账号,设置自定义目录 | 删除账号时区分移除账号与删除数据 | | 数据与快照 | 查看歌单、恢复历史快照 | 恢复会改变同步端后续读取的数据,先确认账号和时间 | | 系统配置 | 路径、认证、代理、缓存和 Subsonic 设置 | 环境变量和 WebDAV 恢复可能改变下一次启动值 | -| WebDAV | 测试连接、同步、备份与恢复 | 增量同步和全量备份有独立间隔 | +| 备份管理 | 本地备份、WebDAV、OpenList、数据恢复及内容校验 | 连接与范围在“云端备份”;恢复前预览,完成后按提示重启 | | 文件管理 | 查看与编辑服务器文件、上传、压缩和解压 | 这是管理员文件操作,不按普通用户曲库权限授权 | | 日志与维护 | 查看错误、重新加载配置、重启服务 | 端口或路径变化后需要使用新地址连接 | diff --git a/docs/guide/web-player.md b/docs/guide/web-player.md index 23189929..c75871a8 100644 --- a/docs/guide/web-player.md +++ b/docs/guide/web-player.md @@ -1,7 +1,75 @@ -# Web 播放器与本地音乐指南 +# 播放器、歌词与本地音乐 默认播放器入口为 `/`,可通过 `PLAYER_PATH` 修改。访问密码用于进入播放器;个人账号用于自己的歌单、设置和目录权限。浏览器会话有效时通常无需重复输入播放器密码。 + +## 播放来源与解析顺序 + +“优先播放服务器已有歌曲”(原“优先播放缓存歌曲”)同时包含当前用户的 `music` 下载文件和 `cache` 播放缓存。不是扫描所有用户,也不代表自动下载所有歌曲。必须能通过索引中的平台、歌曲 ID 和音质匹配;手动放入的文件应先扫描和关联,或直接从本地音乐页面播放。 + +| 来源 | 运行目录模式 | DATA_PATH 模式 | +| --- | --- | --- | +| 下载歌曲与同名 LRC | `<运行目录>/music/<用户>/` | `/music/<用户>/` | +| 播放缓存与同名 LRC | `<运行目录>/cache/<用户>/` | `/cache/<用户>/` | + +播放使用用户持久化的 `serverCacheLocation`。本地音乐页面可以临时浏览另一位置;临时浏览不等于改变后续所有播放的根目录。公开用户使用 `_open`。自定义曲库使用用户单独配置的目录,不混入所有在线歌曲的普通缓存扫描。 + +### Web 音频 + +从本地曲库点击歌曲时,直接读取该条目的文件 URL,支持 music、cache 和自定义目录,不依赖在线解析。 + +在线歌曲的正常解析顺序: + +1. 开启“优先播放服务器已有歌曲”时,查当前用户 music/cache。同请求音质先 music、后 cache,允许时再回退其他音质。 +2. 本地未命中,且后台 OpenList 来源总开关开启时,查询已登记的该用户云端缓存/下载文件。 +3. 浏览器链接缓存开启时,复用已有 URL;链接可能过期,不等于本地音频。 +4. 调用在线音源脚本获取播放 URL;按代理设置直连或由服务器转发。 + +“缓存歌曲文件”仅控制在线解析后的后台写入;“优先播放服务器已有歌曲”仅控制读取已有文件。两者独立,关闭写入仍可读取已有 music/cache。后台请求明确传递“仅下载模式”:开启时写 music,关闭时写 cache,根目录按用户位置选择。首次播放与后台保存是不同请求,不等待整首落盘。 + +### Web 歌词 + +“优先使用服务器本地歌词”与“缓存歌词文件”是独立设置: + +| 设置 | 开启时的行为 | +| --- | --- | +| 优先使用服务器本地歌词 | 当前用户 music/cache LRC → 浏览器歌词缓存 → 在线歌词 | +| 缓存歌词文件 | 在线取词、后台保存音频时保存独立 LRC;已有音频时尽量同目录同名 | +| 浏览器歌词缓存 | 在当前浏览器保存和复用歌词文本,不写服务器目录 | +| 下载时嵌入歌词 | 写入音频内嵌歌词标签,独立于是否保存 LRC | + +关闭本地歌词优先后,Web 跳过独立 LRC 查找,走浏览器缓存和在线平台;不隐式在后端再查一次本地 LRC。本地未关联歌曲没有在线平台身份时,可用音频内嵌歌词兜底。 + +从本地音乐条目播放时,支持读取 music/cache/自定义目录中的相邻同名 LRC,即使没有平台关联。所有路径按认证用户确定,不能传任意绝对路径或跨用户目录。音频和歌词分开解析,不能根据歌曲来自哪里推断歌词一定同源。 + +### Subsonic 音频和歌词 + +对于 `tx_...` 等在线 ID: + +- `subsonic.playCacheFirst` 开启:按请求音质和客户端码率上限查当前用户 music/cache。 +- 本地未命中,且 `subsonic.playCacheFirstUseOpenlist` 与后台 `openlist.playback.enable` 同时开启:查已登记 OpenList 文件;再未命中则在线解析。 +- 关闭缓存优先后,在线 ID 跳过本地及 OpenList。正常在线结果通过 302 交付;需要格式、码率或偏移转换且开启转码时,由服务器拉取并转码输出。 +- 在线 ID 的歌词先查本地 LRC(cache/music 和已登记自定义曲库),再调用平台 SDK,返回文本或结构化时间行。 + +`local_...` 本地曲库 ID 在另一入口处理:曲库包含当前位置的 cache/music 和启用的自定义目录,不依赖缓存优先开关。音频直接读文件或转码;缺失时报告错误,不悄悄替换为在线曲目。歌词依次读取相邻 LRC、内嵌歌词、已登记本地歌词,有平台关联时再在线获取。 + +`subsonic.cacheOnPlay` 仅表示在线播放成功后,在后台下载一份到 cache,不决定是否读取已有文件。后台任务随切歌可取消。在线 download 分支即使关闭 cacheOnPlay 也会后台保存。Subsonic 后台缓存显式获取歌词,独立于 Web 的保存设置;本地或 OpenList 提前命中时不会重复进入在线写入分支。 + +### OpenList 和 WebDAV + +后台「备份管理 → 云端备份 → OpenList 播放缓存→ 允许 OpenList 作为播放来源(本地优先)」是 Web/Subsonic 共用总开关;Web 还要开启服务器已有歌曲优先,Subsonic 还要开启其两个相关选项。保存上传后删除规则不会自动开启这些播放设置。旧版本已开启的 Subsonic OpenList 配置会在缺少新字段时迁移,显式关闭新总开关会被尊重。 + +OpenList 通过本地备份数据库匹配用户、歌曲和音质,再查询单个文件下载地址;不在每次播放时遍历网盘。并发请求合并,地址最多短期复用 30 秒,单次元信息查询限制 4 秒;未命中或不可用则回退在线音源。Web 获得云端地址后使用现有代理设置;Subsonic 由 `subsonic.openlistDelivery` 决定 302 直链或代理,也可能按客户端要求转码。 + +目前直接播放来源是已登记且相应用户目录同步规则仍启用的缓存/下载备份;仅开启普通五类备份不够。普通 `_data` 和历史对象不会自动当成曲库。统一下载器也可在本地未命中时先从 OpenList 取回到本地,再登记音频及附件;这一能力由新总开关控制,不再依赖 Web 不相关的 Subsonic 设置。 + +WebDAV 不参与实时歌曲或歌词解析候选。手动恢复或显式启用启动恢复后,文件回到本地,才由正常本地路径读取。详见 [云端备份与恢复](./backup-recovery.md)。 + +### LRC 与备份范围 + +生成 LRC 和上传 LRC 是两回事。“缓存歌词文件”关闭时,Web 不因后台保存音频而默认另写 LRC,但保留已有文件。只要常规备份选择 music,`data/music` 下已有的音频、LRC、封面和索引都会被备份。`openlist.includeNonAudio` 只控制独立用户缓存同步的附件,不能用它排除普通 music 类型中的 LRC。常规备份固定排除 cache、运行目录 music 和外部自定义曲库。 + + ## 播放与界面 播放器提供播放队列、音质切换、倍速、睡眠定时、全屏歌词和主题设置。桌面和手机使用响应式布局;可以按自己的使用习惯选择主题、播放器背景和歌词展示方式。 @@ -69,7 +137,7 @@ Chromecast 支持具备 Cast 能力的桌面/Android Chromium 浏览器,需要安全上下文,通常为 HTTPS。接收设备还必须能访问服务器和媒体资源;浏览器中的 `blob:` 或本地文件链接不能直接交给接收设备读取。投屏时由接收端播放,浏览器音效和倍速不会应用到接收端;iOS 保留原生 AirPlay 路径。 -网络歌单定时检查、同步下载和普通歌单同步分别配置。可以在任务界面检查上次执行、下次执行和结果;启用歌单同步不会自动开启音频下载。更多操作步骤见 [播放器指南](../guide/web-player.md)。 +网络歌单定时检查、同步下载和普通歌单同步分别配置。可以在任务界面检查上次执行、下次执行和结果;启用歌单同步不会自动开启音频下载。更多任务配置见 [运行维护](./operations.md)。 ## 账号音源与公开音源 @@ -106,3 +174,41 @@ Chromecast 支持具备 Cast 能力的桌面/Android Chromium 浏览器,需要 服务端下载、同步下载和自动匹配由后台执行;浏览器下载由访问设备执行。关闭页面不会自动取消已经提交的服务端任务。查看任务状态并使用对应的停止/取消入口,不要依靠刷新页面终止操作。 更多诊断步骤见 [运行维护](./operations.md),接口字段见 [API 参考](../api/reference.md)。 + +### 歌曲评分 + +登录本地账号后,可在底部播放器、歌曲列表更多菜单、歌手详情、排行榜、本地音乐与自定义目录给歌曲设置1至5星,或清除评分。「我的收藏 → 我的评分」支持搜索和修改评分。 + +Web 与 Subsonic 共用用户的 `subsonic-meta.json` 中的 `ratings` 和 `ratingOrigins`;`rating/snapshot/` 保存评分快照,`rating/songs.json` 保存 Web 提交的必要歌曲资料。评分不修改实体歌单、不写入音频标签;歌曲资料不持久化播放URL或鉴权参数。当前评分及歌曲资料属于用户数据备份,评分快照属于快照备份。 + +在线歌曲按来源及平台歌曲ID区分,QQ优先使用歌曲mid。本地歌曲未关联时按用户本地曲库注册ID评分,Web与Subsonic共用。关联在线歌曲后,以在线ID的评分为准,与在线版本及其他关联副本共用读写;在线歌曲已有评分时保留在线评分并删除本地评分记录;仅本地有评分时,将分数、来源与历史迁移到在线ID,并删除本地条目。重新关联时切换到新的在线ID,不按歌名合并。另行导入、重建或移动后被曲库识别为新文件时,不自动猜测和合并历史评分。 + +低分是否补不喜欢仍受评分联动开关和阈值控制;提高或清除评分不会删除不喜欢规则。Web直接评分来源为 `web`,其不喜欢联动来源为 `web-rating-link`;Subsonic直接评分来源为 `subsonic`。评分已保存但快照或联动失败时会明确提示。列表评分批量读取,其他客户端的修改会在页面恢复可见或周期刷新后更新。 + + +### 评分归属、重算与备份 + +未关联的本地歌曲使用本地ID评分;关联后使用在线ID评分,在线已有评分则优先使用在线评分,否则迁移本地评分,随后删除本地评分记录。评分弹窗可查看目标ID与关联副本数,管理端不再区分评分使用状态。 + +「低分补不喜欢」「不喜欢补1星」仍只补充数据。配置保存后不会自动删除历史规则;需要按新阈值处理时,选择用户后点击「按阈值重算」,先查看预览再执行。重算仅撤销能追溯到具体评分ID的失效联动原因;同一条规则仍有手动或其他有效原因时保留。缺乏精确来源的历史规则保留,不猜测删除。关闭联动不触发清理。 + +「我的评分 → 导出评分备份」包含分数、来源、歌曲描述及本地关联参照,不包含音频。新生成的评分快照也包含描述;在管理端按评分备份导入/恢复时,恢复分数和可用描述、保留当前收藏与文件关联。关联参照用于核对,不按备份中的路径寻找、移动文件或恢复旧关联。只有旧分数而没有描述的备份无法凭空补出封面和标题。 + +推荐排除开关对每日推荐、随机及相似歌曲使用统一策略,同时排除规则命中项与阈值内低分;与普通列表隐藏、自动联动开关独立。 + +管理端数据查看的评分与不喜欢看板支持按音乐平台、操作来源、封面、记录时间、组合关键词及评分筛选;平台选项显示用户总条数,包含0条平台。可点击星级分布筛选、调整每页条数、查看记录详情并导出当前筛选结果。统计按当前平台、来源、时间及关键词条件汇总;评分类型与星级用于进一步限定列表。 + +### 用户独立的不喜欢自动补分 + +「设置 → 逻辑 → 不喜欢自动补分」按当前账号保存到服务器,默认关闭;Web 将单首歌曲加入不喜欢时,仅为未评分歌曲补1星。已有评分不覆盖,取消不喜欢或关闭设置不删除评分。用户之间独立,低分阈值不控制该设置。原全局反向联动开关已移除。「后台补分」是管理员独立操作,先预览再执行,不受用户自动开关或低分阈值为0影响。来源分别显示 Web手动评分、Subsonic手动评分、不喜欢自动补分、后台补分。偏好保存在用户目录的 `rating/preferences.json`;评分备份恢复不更改此偏好。 + +歌单详情支持直接评分。评分合集打开时随周期刷新、返回页面及本端评分/不喜欢操作更新,并保留筛选和列表滚动位置。每个对象保留最近30条分数变更(含清除、补分和恢复),同分重复提交不刷新来源时间;旧数据不补造历史。自动补分失败会提示部分完成,已保存的不喜欢保留。评分恢复发生可捕获的写盘错误时回滚文件;此机制不保证进程突然退出或断电时的跨文件原子性。 + + +### 资料修复与同步状态 + +同分提交不会改写评分来源和时间,但会补齐可用歌曲资料。直接评分、用户自动补分与后台补分共用评分写入服务;补分只补未评分项。Subsonic 已解析资料保存到用户的 `rating/songs.json`,避免展示长期依赖运行缓存。收藏操作保留评分来源和历史。 + +「我的评分」及管理端评分区域可查看待处理项并选择“仅重试失败项”。状态保存在用户 `rating/sync-status.json`,包括补分写入、描述保存、资料未解析和快照失败;重试不会覆盖新评分。资料解析仍受平台可用性影响,失败项会保留。主评分文件损坏时停止写入和导出,保留原文件;状态文件损坏会显示人工检查提示,不自动清空。 + +后台补分预览分别列出待新增、已有评分跳过、重复关联及解析失败;评分已落盘但快照失败时,明确提示部分完成。评分数据提供版本查询,版本不变时避免重复全量刷新;Web 评分合集的平均分按当前筛选结果计算,星级分布按其余筛选条件计算,供切换星级使用。 diff --git a/docs/public/showcase/charts-mobile.mp4 b/docs/public/showcase/charts-mobile.mp4 index d5c8c75c..c2e785f7 100644 Binary files a/docs/public/showcase/charts-mobile.mp4 and b/docs/public/showcase/charts-mobile.mp4 differ diff --git a/docs/public/showcase/console-mobile.mp4 b/docs/public/showcase/console-mobile.mp4 index 877ddd8d..11a27cf9 100644 Binary files a/docs/public/showcase/console-mobile.mp4 and b/docs/public/showcase/console-mobile.mp4 differ diff --git a/docs/public/showcase/console.mp4 b/docs/public/showcase/console.mp4 index 335b60a6..e3cd2f0b 100644 Binary files a/docs/public/showcase/console.mp4 and b/docs/public/showcase/console.mp4 differ diff --git a/docs/public/showcase/listening-mobile.mp4 b/docs/public/showcase/listening-mobile.mp4 index 7dde3a34..0691aea4 100644 Binary files a/docs/public/showcase/listening-mobile.mp4 and b/docs/public/showcase/listening-mobile.mp4 differ diff --git a/docs/public/showcase/local-library-mobile.jpg b/docs/public/showcase/local-library-mobile.jpg new file mode 100644 index 00000000..504a5ea8 Binary files /dev/null and b/docs/public/showcase/local-library-mobile.jpg differ diff --git a/docs/public/showcase/playlists-mobile.mp4 b/docs/public/showcase/playlists-mobile.mp4 index 961c7c2e..bb5900c0 100644 Binary files a/docs/public/showcase/playlists-mobile.mp4 and b/docs/public/showcase/playlists-mobile.mp4 differ diff --git a/docs/public/showcase/search-mobile.mp4 b/docs/public/showcase/search-mobile.mp4 index b2c63069..325114a2 100644 Binary files a/docs/public/showcase/search-mobile.mp4 and b/docs/public/showcase/search-mobile.mp4 differ diff --git a/docs/public/showcase/themes-mobile.mp4 b/docs/public/showcase/themes-mobile.mp4 index 81124acc..02903327 100644 Binary files a/docs/public/showcase/themes-mobile.mp4 and b/docs/public/showcase/themes-mobile.mp4 differ diff --git a/docs/subsonic-local-library.md b/docs/subsonic-local-library.md deleted file mode 100644 index b6133294..00000000 --- a/docs/subsonic-local-library.md +++ /dev/null @@ -1,60 +0,0 @@ -# Subsonic 本地音乐库 - -Subsonic 本地曲库使用当前账号的持久化存储设置,并叠加已启用的自定义音乐目录。 - -- 存储选择为 `data`:读取数据目录下该账号的 `music` 和 `cache`。 -- 存储选择为 `root`:读取程序根目录下该账号的 `music` 和 `cache`。 -- 自定义目录启用且可访问时,同时收录其中歌曲。与主目录重复的实际文件只收录一次。 -- 网页文件浏览中的临时根目录/数据目录切换不影响 Subsonic。 -- 改变账号的存储设置或关闭自定义目录后,后续请求会重新选择曲库;旧目录歌曲的保存 ID 也不能绕过范围限制。 - -## 使用 - -后台“Subsonic 协议配置 → 本地歌曲歌单 → 返回本地歌曲歌单”控制只读的“本地歌曲(用户名)”歌单,默认开启。设置通过现有后台配置接口保存到服务器配置(`subsonic.localPlaylist`),关闭后列表和详情均不返回此歌单。开启时服务端在歌单列表首位返回;客户端自行排序时以客户端设置为准。 - -此歌单汇总当前账号所选存储目录中的歌曲。父开关开启时显示“返回自定义目录歌曲”子开关(`subsonic.customPlaylist`,默认开启),开启后在本地歌单之后单独返回“自定义目录(用户名)”歌单;账号还需启用自定义音乐目录。关闭父开关时两个歌单均隐藏,子开关的选择保留。两种歌曲分开显示,子开关关闭时不会将自定义歌曲混入本地歌单。 - -空曲库时保留已启用的入口。切换存储设置后歌单内容同步变化;不会写入 LX 原生歌单或同步到官方客户端。歌曲及专辑时长按协议返回整数秒,内部保留文件的精确时长。 - -默认推荐页的“最新专辑”“随机专辑”保持在线推荐池及其分页逻辑,不把 NAS 专辑插入推荐池。明确选择 NAS 本地音乐文件夹时,专辑接口才使用本地曲库分支。 - -客户端选择“NAS 本地音乐(存储目录+自定义目录)”音乐文件夹,可浏览本地专辑、艺术家、流派和随机歌曲。目录模式也有 NAS 本地音乐入口。搜索 `local:关键词` 仅搜索本地文件;普通搜索会包括本地文件,显式在线搜索仍按在线模式处理。 - -支持扫描和扫描状态、歌曲/专辑/艺术家详情、封面、外置及内嵌歌词、播放、下载、Range 请求、收藏、评分,以及现有播放记录、队列和书签的本地歌曲解析。开启现有服务端转码配置且安装 ffmpeg 后,本地播放可使用格式转换、码率限制和时间偏移。 - -歌词按非空外置同名 LRC、内嵌歌词、网页端服务器歌词缓存、已关联平台在线接口的顺序获取。在线回退仅使用已关联平台 ID,不按歌名猜测其他歌曲;请求有超时限制,并按账号复用并发请求、短暂缓存成功结果。外置及内嵌歌词复用网页端扩展歌词解码,结构化接口返回原文、翻译和音译;普通歌词接口按现有翻译配置合并。支持多时间标签、整数秒标签及 offset。逐字内容没有额外转换为 OpenSubsonic 逐字格式。 - -本地封面支持客户端附加的 al-/ar-/tr-/sg-/mg- 前缀,仍按当前账号可访问的本地文件读取内嵌或目录封面。 - -普通用户歌单的列表和详情返回统一的封面令牌。获取时优先用歌单自身的网络封面;关联网络歌单未保存封面时尝试从原平台补取;没有则用第一首歌曲的封面(含本地文件封面)。空歌单且无网络封面时返回空内容。封面解析按账号隔离,并兼容客户端附加的前缀。 - -扫描复用现有文件索引,额外读取曲序、碟号、专辑艺术家、年份、流派。目录不变时曲库结果缓存 30 秒;`startScan` 可主动刷新。`getScanStatus` 返回扫描状态、歌曲数量及失败原因。 - -## 标记与身份 - -文件获得独立的 `local_`,在线关联不改变此 ID。响应保留原始歌名,使用 `lxStorage=local`、`lxLinked` 和 `comment` 表达本地身份及关联状态,不改写音频标签。第三方客户端是否显示这些说明取决于客户端支持;不会自动出现通用徽章。 - -本地歌曲不会按歌名与在线歌曲自动合并,以保留原版、重录版和不同文件版本。专辑按所在目录、专辑名及专辑艺术家分组,避免不同目录中的版本混在一起。 - -目前身份登记按实际文件路径保存:关联平台及在原路径更新文件会保留 ID;外部移动或重命名文件尚不做指纹追踪,会视为新文件。停止显示不在当前目录范围的歌曲时,原歌单引用和收藏仍保留,切回目录后可恢复。 - -## 歌单同步 - -纯本地歌单只保存在服务端,不进入 LX 官方客户端同步。混合歌单的在线歌曲写入原生歌单,本地歌曲及混排顺序独立保存。在线歌单重新同步时,删除/新增的在线成员随之更新,本地成员保留;新增在线成员默认追加到合成列表末尾。 - -Subsonic 的按位置删除针对客户端看到的混合列表解析。删除混合歌单会删除其原生在线歌单和本地附加信息,不删除音频文件。若原生歌单被其他同步操作删除,本地附加歌曲仍可以作为服务端歌单查看。 - -本地歌曲收藏通过 `star/unstar` 管理。直接向内置“我的喜爱”歌单添加本地成员会提示使用收藏接口。 - -用户目录中的持久化文件: - -- `subsonic-local-registry.json`:文件身份和元数据,不属于可随意清理的缓存。 -- `subsonic-local-state.json`:本地收藏、纯本地歌单及混合歌单附加信息。 - -## 验证 - -音流的歌单封面加载受歌曲 `size` 影响:同一歌单、同一网络歌曲,`size=0` 不加载封面,改为正数后加载。网络歌曲优先返回音源提供的字节数;缺失时用码率 × 时长 ÷ 8 估算,估算值不是精确文件大小。未知时长且无已知大小时仍返回 0。本地歌曲返回真实大小;音频响应的 Content-Length / Range 不使用估算值。 - -`node scripts/subsonic-local.test.cjs` 使用隔离临时文件、模拟索引/标签读取和本机 HTTP 服务,覆盖目录选择、账号隔离、播放范围、JSON/XML、歌词、收藏和歌单同步。不会读取生产账号配置或修改真实音乐文件。 - -真实 NAS 大曲库扫描性能、原生标签库在部署架构上的表现、ffmpeg 实际转码和音流/箭头/Feishin 客户端显示,仍需在对应环境进行集成验证。 diff --git a/docs/subsonic-status.md b/docs/subsonic-status.md deleted file mode 100644 index 4ff06fed..00000000 --- a/docs/subsonic-status.md +++ /dev/null @@ -1,115 +0,0 @@ -# Subsonic 协议支持状态对照 - -> 范围:`src/server/integrations/subsonic.ts` 对 Subsonic REST API 的**自定义实现**对照表。 -> 图例:✅ 已实现可用 🟡 占位(返回空 / no-op,兼容客户端握手) ❌ 未实现(返回 `Method not found`) -> 更新:2026-09-19(协议补全:专辑信息 / 播放上报 / 书签 / 播放队列 / 相似歌曲 / 转码,见第四节) - ---- - -## 一、已实现 / 可用接口 - -| 方法 (method) | 类别 | 状态 | 说明 | -|---|---|---|---| -| `ping` | 系统 | ✅ | 握手 / 存活检测 | -| `getLicense` | 系统 | ✅ | 许可信息 | -| `getScanStatus` | 系统 | 🟡 | 固定返回 `scanning:false` | -| `getMusicFolders` | 浏览 | ✅ | 单一音乐库 | -| `getMusicDirectory` | 浏览 | ✅ | 专辑 / 歌单 / 歌手目录 | -| `getGenres` | 浏览 | ✅ | 流派(来自 QQ 音乐 discovery) | -| `getArtists` | 浏览 | ✅ | 按字母索引的歌手 | -| `getArtist` | 浏览 | ✅ | 歌手详情 | -| `getArtistInfo` / `getArtistInfo2` | 浏览 | ✅ | 歌手信息 | -| `getAlbum` | 浏览 | ✅ | 专辑详情(含歌曲列表) | -| `getAlbumList` / `getAlbumList2` | 浏览 | ✅ | 含 `recommend`/`random`/`newest`/`recent` 推荐专辑逻辑 | -| `getStarred` / `getStarred2` | 浏览 | ✅ | 收藏 | -| `getSong` | 媒体 | ✅ | 单曲信息 | -| `stream` / `download` | 媒体 | ✅ | 在线音频流(按 `source_songmid` 解析) | -| `getCoverArt` | 媒体 | ✅ | 封面代理 | -| `getUser` | 用户 | ✅ | 当前用户 | -| `search` / `search2` / `search3` | 搜索 | ✅ | 本地 + 在线全网搜索 | -| `getPlaylists` / `getPlaylist` | 歌单 | ✅ | | -| `createPlaylist` / `updatePlaylist` / `deletePlaylist` | 歌单 | ✅ | | -| `star` / `unstar` | 收藏 | ✅ | | -| `setRating` | 评分 | ✅ | 评分按用户持久化(0-5,0=清除);每日推荐会排除评分落入「不喜欢」阈值(`subsonic.dislikeRating`,默认 1)的歌曲 | -| `scrobble` | 播放 | ✅ | 写入播放历史;按规范仅 `stopped` 且未带 `ignoreScrobble` 时记录 | -| `getNowPlaying` | 播放 | ✅ | 返回正在播放列表,条目带 `positionMs` | -| `getLyrics` / `getLyricsBySongId` | 歌词 | ✅ | | -| `getOpenSubsonicExtensions` | 扩展 | ✅ | | -| `getRandomSongs` | 发现 | ✅ | 本地库随机 / 流派随机 | -| `getSongsByGenre` / `getSongsByGenre2` | 发现 | ✅ | 按流派拉取云端歌曲 | -| `getSimilarSongs` / `getSimilarSongs2` | 发现 | ✅ | 同歌手相似 | -| `getTopSongs` | 发现 | ✅ | 歌手热门 | -| `getInternetRadioStations` | 电台 | ✅ | 三类来源:QQ 官方电台 / 用户自建电台(`radioStations.ts` 落盘)/ 音乐源歌单电台 | -| **`getRecommendedSongs`** | **发现(新增)** | ✅ | **每日推荐歌曲(见第二节)** | -| **`getDailySongs`** | **发现(新增)** | ✅ | **`getRecommendedSongs` 的别名** | -| **`getSongsByTag`** | **发现(新增)** | ✅ | **别名,同样映射为每日推荐**(lx-server 未实现独立按标签检索) | - -> **网络电台补充说明(2026-09-19)** -> -> - 自产电台地址会补全为**绝对地址**(尊重反向代理的 `X-Forwarded-Proto`)—— 客户端把 `internetRadioStation.streamUrl` 当作可直接播放的音频地址「原样请求」,相对路径在第三方客户端必然失败。 -> - 这类地址带**服务端短期签名票据**(`u` + `rtexp` + `rtsig`,HMAC-SHA256,12 小时),而不是用户凭据:客户端请求该 URL 时不会附带 Subsonic 凭据,直接透传 `u+t+s` 会把长期令牌写进客户端会展示、可复制的 URL。票据仅对 `stream` / `download` 且 `id=radio_*` 生效,外部(用户自建)电台地址原样返回。 -> - QQ 官方电台的取歌接口(`GetRadioSong`)当前返回 `500003`、旧接口 404,因此对官方电台做可用性探测(结果缓存 10 分钟)并在不可用时暂时从列表隐藏,上游恢复后自动重现。 - ---- - -## 二、新增:每日推荐歌曲(2026-09-10 实现) - -### 背景 -此前代码里**完全没有**歌曲级「每日推荐」接口(`getRecommendedSongs` / `getDailySongs` / `getSongsByTag` / `DailySongs` 在 `subsonic.ts` 中均为空,走 default 分支返回 `Method not found`)。仅 `getAlbumList2` 带有「推荐专辑」逻辑(`cachedRecommend → fetchRecommendedAlbums`,专辑级)。 - -### 实现 -- 新增数据源 `src/server/utils/recommendSongs.ts`:`fetchRecommendedSongs(size)` - - 复用已验证的 `fetchRecommendedAlbums('random')` 拿推荐专辑; - - 逐张调用 `musicSdk.tx.extendDetail.getAlbumSongs(mid)` 取出专辑内歌曲; - - 按 `tx_` 去重,按「自然日」做种子洗牌(当天稳定、跨天换批),返回可直接播放的在线歌曲。 - - 全程公开接口、无需登录;QQ 抓取失败时回退到上次成功结果,避免每日推荐空白。 - - **评分过滤**:返回前按请求用户过滤——评分落入「不喜欢」区间(`0 < rating <= subsonic.dislikeRating`,默认阈值为 1,可配置)的歌曲不会进入每日推荐(`setRating` 设置)。 -- `subsonic.ts` 分发开关新增 `case 'getRecommendedSongs' / 'getDailySongs' / 'getSongsByTag'`,handler `handleGetRecommendedSongs` 复用 `renderRandomSongs` 输出(与 `getRandomSongs`/`getSongsByGenre` 同一条渲染链路,封面 / 播放均正常)。 - -### 调用示例 -``` -GET /rest/getRecommendedSongs?u=&p=&f=json&size=20 -GET /rest/getDailySongs?u=&p=&f=json -``` -返回结构:`{ recommendedSongs: { song: [ ... ] } }`(XML 下为 `recommendedSongs > children > song`)。 - ---- - -## 三、未实现(官方标准方法,当前返回 `Method not found`) - -| 方法 | 类别 | -|---|---| -| `getVideos` | 媒体 | -| `createUser` / `updateUser` / `deleteUser` / `changePassword` / `getAvatar` | 用户管理 | -| `getPodcasts` / `getNewestPodcasts` / `refreshPodcasts` 等播客系列 | 播客 | -| `getChatMessages` / `createChatMessage` | 聊天 | -| `createShare` / `getShares` / `deleteShare` | 分享 | -| `createInternetRadioStation` / `updateInternetRadioStation` / `deleteInternetRadioStation` | 电台管理 | - -> 注:lx-server 定位是「个人音乐库桥接」,未实现多用户管理 / 播客 / 分享 / 聊天等协作类接口属预期范围。 - ---- - -## 四、2026-09-19 协议补全(本轮新增) - -补齐以下官方 / OpenSubsonic 方法(此前均走 default 分支返回 `Method not found`): - -| 方法 | 类别 | 说明 | -|---|---|---| -| `getIndexes` | 浏览 | 艺术家按拼音首字母索引分组 | -| `getAlbumInfo` / `getAlbumInfo2` | 浏览 | 专辑介绍(`notes`)+ 封面 URL;构造不出平台直链时回退歌曲自带封面 | -| `reportPlayback` | 播放 | OpenSubsonic `playbackReport` 扩展;仅 `stopped` 且未带 `ignoreScrobble` 时写入播放历史 | -| `getPlayQueue` / `savePlayQueue` | 播放队列 | 按用户持久化;`current` 指向的歌曲缺失时按列表兜底 | -| `getPlayQueueByIndex` / `savePlayQueueByIndex` | 播放队列 | OpenSubsonic `indexBasedQueue` 扩展;`currentIndex` 越界返回错误码 10 | -| `getBookmarks` / `createBookmark` / `deleteBookmark` | 书签 | 按用户落盘持久化 | -| `getSonicSimilarTracks` | 发现 | OpenSubsonic `sonicSimilarity` 扩展;复用同歌手相似歌挑选逻辑(排序分,非声学分析) | -| `getTranscodeDecision` / `getTranscodeStream` | 转码 | OpenSubsonic `transcoding` 扩展;下发签名 `transcodeParams`,服务端 ffmpeg 流式转码 | - -配套变更: - -- `scrobble` 由 no-op 改为写入播放历史,`getNowPlaying` 返回正在播放列表(条目带 `positionMs`)。 -- `stream` / `download` 支持 OpenSubsonic `transcodeOffset`:`timeOffset`(秒)作为 ffmpeg 快速定位(`-ss` 置于 `-i` 之前)。 -- `getOpenSubsonicExtensions` 改为声明**实际实现**的扩展名:`formPost`、`songLyrics`、`playbackReport`、`sonicSimilarity`、`indexBasedQueue`、`transcodeOffset`、`transcoding`。此前的 `coverArtScaling` / `thumbnails` / `lyrics` 并非官方扩展名,客户端据此永远不会调用 `getLyricsBySongId`;封面缩放仍通过 `getCoverArt` 的 `size` 参数生效。 -- 服务端转码:`ffmpeg` 可用性探测(缺失时降级 302 直链)、并发信号量(`subsonic.transcode.maxConcurrent`)、目标格式(`subsonic.transcode.format`),并在 `handleStream` 中按客户端 `maxBitrate` 决定是否转码。 -- `search3` 补齐在线歌手 / 专辑搜索(此前只查本地库,未收藏时恒为空);歌手寻址改为相似度匹配(失败短缓存 + 诊断日志);跨源同名歌手合并与去重;简繁双向搜索(新增 `src/server/utils/zhConvert.ts`)。 -- 协议根元素、参数截断与封面尺寸修正;共享歌单支持「排行榜 / 歌单 / 都要」三态配置(`subsonic.sharedListMode`、`subsonic.sharedListSort`)。 diff --git a/docs/ui-audit/architecture.md b/docs/ui-audit/architecture.md deleted file mode 100644 index a06578cb..00000000 --- a/docs/ui-audit/architecture.md +++ /dev/null @@ -1,9 +0,0 @@ -# UI repair architecture - -Keep the existing vanilla JavaScript player and backend. Extend existing queue, search and collection entry points rather than adding another playback model. Add shared UI focus management for dialogs/drawers and history metadata for main views. Preserve nested lyric/detail navigation. - -Player CSS owns responsive layout using the same 1025px desktop boundary as the existing theme. The visualizer retains its artwork and audio behavior; its mobile canvas occupies a dedicated row. Static Tailwind output scans player markup and script-generated classes, using the existing CSS-variable theme and breakpoint configuration. - -Local artwork endpoints retain their existing authentication and path validation. An optional bounded thumbnail transform runs only after authorization, with byte-limited caching and constrained decoding/concurrency. Original images remain available for lyrics/Media Session/export. Closed queue content is materialized only when opened; existing queue state remains authoritative. - -Deployment uses an immutable overlay on the verified live image, a disposable candidate with isolated data and disabled background jobs, protected backup/rollback and recreation of only the lxserver Compose service. Compare assets, runtime, configuration, mounts and unrelated containers before/after. diff --git a/docs/ui-audit/backup-verification.md b/docs/ui-audit/backup-verification.md deleted file mode 100644 index 8b630f95..00000000 --- a/docs/ui-audit/backup-verification.md +++ /dev/null @@ -1,30 +0,0 @@ -# 备份设置与恢复验证 - -本次改动位于 `review/pr-488-openlist`,包含 PR #488 的数据安全修复及统一备份范围。没有提交或推送。 - -## 界面决定 - -- 系统设置首先回答“哪些数据会备份到哪个云端”,用五行数据类型和 WebDAV/OpenList 两列勾选展示。 -- 使用 Framed sheet 方向,seed `1449062923`,保留既有管理台字体、主题和页面框架;新范围面板使用平面分隔,不新增图标卡片或统计数字。 -- 普通数据备份与永久播放缓存分开。普通数据备份没有“上传后删本地”的选项;恢复单独执行。 -- 保留本地副本、镜像删除、启动恢复的后果写在控制旁;不把未保存的选择用于恢复。 - -## 已验证 - -- `npm test`:50 项通过,其中 OpenList/播放交付 20 项,TypeScript 测试 30 项。 -- TypeScript `--noEmit`、前端及配置 JS 语法、`git diff --check` 通过。 -- 真实 SQLite 和本地 HTTP OpenList 测试服务:校验后删本地、软删除登记恢复、损坏文件保护、上传/下载错误、同大小修改、目录开关、共享附件、无数据库的新实例恢复。 -- WebDAV 客户端替身及真实 ZIP:增量与 ZIP 范围相同、扩展名为空的快照、外部配置/快照映射、按类型恢复、失败重试、镜像删除与默认保留、恢复不反向上传。 -- 使用生产配置读取字段和实际 `saveConfigToFile` 写入函数,验证类型数组、布尔设置及 Subsonic 交付设置保存、重新加载后的往返一致性;写入失败会报告错误并清理临时文件。 -- 在真实 Chrome 中检查隔离设置面板,宽度为 1440、768、390;验证独立勾选和保存反馈。面板复用实际 HTML、CSS 和 App 方法,保存请求为测试替身,没有修改正在运行的服务配置。 -- 新面板源码检查 0 FAIL / 0 WARN;独立无头浏览器检查器启动失败,改用已有 Chrome 连接进行视觉与交互检查,没有将检查器标记为通过。 - -截图位于本地 `output/playwright/backup-policy/`,包括桌面、平板和手机预览。方向说明也保存在该目录。 - -## 尚未验证 - -没有连接真实用户云盘、真实 WebDAV 服务,没有实际恢复或重启用户正在运行的服务,也没有执行 Docker 构建。Subsonic 测试执行生产处理方法并使用本地音频 HTTP 服务,转码调用以替身确认参数,未实跑 ffmpeg 或第三方客户端。 - -完整文档构建受已有死链接阻断:`docs/chromecast/progress.md` 引用了不存在的 `combined-conflict-audit`。新增中英文备份指南已经接入导航。 - -恢复是按文件替换,多个文件不是一个原子事务。发生部分失败时需检查失败列表;恢复后重启服务完整加载用户、歌单和音乐索引。普通数据备份不会覆盖外部自定义音乐目录。 diff --git a/docs/ui-audit/progress.md b/docs/ui-audit/progress.md deleted file mode 100644 index ae8d2233..00000000 --- a/docs/ui-audit/progress.md +++ /dev/null @@ -1,12 +0,0 @@ -# UI repair progress - -- [x] Port reviewed responsive/accessibility/search/artwork/queue repairs onto current upstream dev. -- [x] Preserve upstream VU artwork and modern login layout while applying mobile reachability/input fixes. -- [x] Enable explicit iPhone audio effects and provide a native-playback reload for AirPlay. -- [x] Add runnable regression coverage and a pull-request test/build workflow. -- [x] Production compilation and179regression tests pass. -- [x] Combined browser checks at320/390/1024/1440px and desktop-to-mobile resizing pass without overflow or hidden Play. -- [x] Actual modern login succeeds with a disposable local account; input text is16px, capitalization is disabled and the username field remains unique. Mode/rate menus fit and effects dialog Escape/focus behavior passes. -- [x] Submitted [PR #463](https://github.com/XCQ0607/lxserver/pull/463) to upstream dev; GitHub Player validation passes (tests, production build and compiled smoke). Maintainer review/merge remains pending. - -The independently deployed repair source remains verified on Synology. Deployment state and private operational evidence are kept outside this upstream contribution. See [verification](verification.md) for test scope and physical-device limits. diff --git a/docs/ui-audit/requirements.md b/docs/ui-audit/requirements.md deleted file mode 100644 index 537d4b36..00000000 --- a/docs/ui-audit/requirements.md +++ /dev/null @@ -1,14 +0,0 @@ -# Web player UI repairs - -Repair the audited player without changing saved preferences, native/AirPlay/Cast routing, lyric metadata, provider configuration or library data. - -- Keep all player controls and the VU meter visible without overlap at 320px, 390px, tablet and desktop widths, including after resizing. -- Expose labelled, keyboard-accessible controls with adequate touch targets. Dialogs contain focus, close on Escape where dismissal is allowed, and restore focus; closed drawers cannot receive focus. -- Allow Play next and Add to queue without replacing the queue or interrupting playback. Include Default List in the collection picker. -- Restore the prior main view through browser Back/Forward. -- Cancel obsolete searches, ignore late responses, bound provider waits, show partial results and distinguish provider failure from no results. -- Defer closed queue rendering/artwork, serve bounded thumbnails, compile utility CSS ahead of time and defer optional features. -- Pass existing regression tests, type/build checks, isolated browser/HTTP checks and candidate/live asset verification before declaring deployment successful. Report physical-device limitations separately. -- Verify mobile flows on the newest installed iPhone simulator runtime; keep simulator, browser-fixture and physical-device results distinct. Keep short-screen agreements/detail actions reachable, avoid iOS input zoom and hover-only first taps, and fit player menus within the viewport. - -- Make explicit iPhone effect edits change local audio output while native startup and existing AirPlay/Cast routes remain protected. Explain the native reload required to release Safari media capture before AirPlay. diff --git a/docs/ui-audit/verification.md b/docs/ui-audit/verification.md deleted file mode 100644 index d8e49886..00000000 --- a/docs/ui-audit/verification.md +++ /dev/null @@ -1,20 +0,0 @@ -# UI repair verification - -- [x] Responsive footer/VU and controls, including resizing between mobile/tablet/desktop. -- [x] Queue insertion preserves current playback; unopened queue omits rows and artwork. -- [x] Default List supports individual and batch collection changes. -- [x] Search replacement cancels prior requests; provider deadlines cover response-body reads; partial failures remain visible. -- [x] Dialog focus/inert lifecycle handles nested input/confirmation surfaces. -- [x] iPhone agreement remains scrollable in landscape; inputs use16px text and account names disable capitalization. -- [x] Local iPhone effect edits activate processing; wireless/Cast edits remain saved-only; native reload releases processing and preserves saved settings. -- [x] Regression suite:168CommonJS and11TypeScript tests on current upstream dev, plus production compilation and compiled authorization smoke test. - -The three configuration-migration tests from the separately deployed source are omitted here because that deployment-only module is absent from upstream dev. Backend route fixtures use upstream's actual cache-browse request context. - -The earlier deployed repair source passed182tests,69isolated/65live HTTP checks,31effects/session/login regressions per image and14public HTTPS asset checks. These deployment results describe that source; this PR is rebased onto upstream v2.1.4 and retains its newer VU/login/library features. - -An iPhone18Pro/iOS27.0 simulator, driven through CLI HID touches, verified a16x RMS change between EQ-12/+12dB, a1kHz→1.5kHz pitch shift, a reverb change and reversal of stereo channel dominance. Native reload left no graph/context/bridge and retained saved effects. The PR sound-effects and AirPlay modules match those signal-tested modules exactly. This establishes simulator processing, not physical speaker fidelity or receiver delivery. - -Broad simulator checks cover playback, seek/speed/mode, search/pagination, collections, queue removal/clear, playlists/rankings, lyrics/export, settings and artist/album navigation. Queue dragging, PWA/admin, exhaustive secondary settings/import/export mutations and physical receiver/lock-screen playback remain unverified. The original query-specific search500and old WebKit crash have no proven root cause; this change does not claim to fix those incidents. - -Combined branch browser verification uses a separate loopback-only server with disposable data/configuration. Account login succeeds; the modern login retains one username field,16px input text and disabled capitalization. At320/390/1024/1440px and after desktop-to-mobile resize, Play stays inside the viewport, the document has no horizontal overflow and the mobile VU starts below song information. Mode/rate menus remain within the phone viewport; the effects modal exposes dialog semantics and Escape closes it. diff --git a/md/log/2.2.0-10.png b/md/log/2.2.0-10.png new file mode 100644 index 00000000..8c6f955f Binary files /dev/null and b/md/log/2.2.0-10.png differ diff --git a/md/log/2.2.0-11.png b/md/log/2.2.0-11.png new file mode 100644 index 00000000..34c7d6c4 Binary files /dev/null and b/md/log/2.2.0-11.png differ diff --git a/md/log/2.2.0-2.png b/md/log/2.2.0-2.png new file mode 100644 index 00000000..766094a3 Binary files /dev/null and b/md/log/2.2.0-2.png differ diff --git a/md/log/2.2.0-3.png b/md/log/2.2.0-3.png new file mode 100644 index 00000000..70e61574 Binary files /dev/null and b/md/log/2.2.0-3.png differ diff --git a/md/log/2.2.0-4.png b/md/log/2.2.0-4.png new file mode 100644 index 00000000..ed8d0fba Binary files /dev/null and b/md/log/2.2.0-4.png differ diff --git a/md/log/2.2.0-5.png b/md/log/2.2.0-5.png new file mode 100644 index 00000000..80f7dc55 Binary files /dev/null and b/md/log/2.2.0-5.png differ diff --git a/md/log/2.2.0-6.png b/md/log/2.2.0-6.png new file mode 100644 index 00000000..e419eeb4 Binary files /dev/null and b/md/log/2.2.0-6.png differ diff --git a/md/log/2.2.0-7.png b/md/log/2.2.0-7.png new file mode 100644 index 00000000..d0552b6a Binary files /dev/null and b/md/log/2.2.0-7.png differ diff --git a/md/log/2.2.0-8.png b/md/log/2.2.0-8.png new file mode 100644 index 00000000..1ff00c73 Binary files /dev/null and b/md/log/2.2.0-8.png differ diff --git a/md/log/2.2.0-9.png b/md/log/2.2.0-9.png new file mode 100644 index 00000000..628f8701 Binary files /dev/null and b/md/log/2.2.0-9.png differ diff --git a/notice/notification.json b/notice/notification.json index 02342148..c90909be 100644 --- a/notice/notification.json +++ b/notice/notification.json @@ -18,25 +18,25 @@ } }, { - "comment": "=== 版本更新 v2.1.4 ===", - "id": "update_v2_1_4", + "comment": "=== 版本更新 v2.2.0 ===", + "id": "update_v2_2_0", "status": "active", "type": "version", "logic": { - "target_version": "v2.1.4", + "target_version": "v2.2.0", "operator": "<", "interval_hours": 24 }, "ui": { - "title": "发现新版本 v2.1.4", - "date": "2026-10-25", - "message": "本次更新带来多项重磅视觉革新、连接监控与系统级体验跃升:\n1. [新增] 播放页动态特效与背景自适应: 背景与 12 种粒子特效彻底解耦组合,明暗主题深度自适应,支持同源引擎轻量化复用与自动休眠。\n2. [新增] 用户在线状态指示与禁用功能: 用户列表前置 WebSocket 实时连接指示圆点(🟢在线/🔴离线),新增「禁用用户」开关并在全链路安全拦截登录。\n3. [新增] 本地音乐智能检测与多平台匹配: 优先提取声学指纹比对,支持多平台智能匹配,新增并发线程配置与本地持久化。\n4. [新增] 侧边栏折叠手柄与 Alt+B 快捷键: 极简半透明折叠控制手柄,支持一键沉浸浏览,全局快捷键与设置开关实时联动。\n5. [新增] 自定义目录侧边面板与树形层级: 沉浸式侧边目录分类树,支持多选快捷开关与歌曲数量角标,弹窗与侧边栏体验统一。\n6. [新增] Subsonic 配置独立管理页: 与主配置完全解耦,支持独立端口监听(4050),统一流媒体播放与下载缓存机制。\n7. [新增] Web 播放器无线投放与 VU 表头: 接入原生 AirPlay 与 Chromecast 投放,新增双通道动圈 VU 表头与真实 Git 构建指纹。\n8. [新增] 自定义音源在线一键自动更新: 提示栏拆分为「更新」与「前往」,支持在当前页一键拉取最新自定义音源并重载。\n9. [重构] 文件管理器全面升级 VueFinder: 彻底淘汰陈旧 jQuery / elFinder,基于 Vue 3 + VueFinder 4.7.6 构建轻量自包含静态资源。", + "title": "发现新版本 v2.2.0", + "date": "2026-11-11", + "message": "本次更新带来备份体系、评分系统与后台界面的全面升级:\n1. [新增] 后台仪表盘与明/暗主题: 仪表盘重构为数据看板,新增日志活动、服务与存储、用户与设备、最近告警,并支持明/暗主题一键切换。\n2. [新增] OpenList 备份与统一云端备份: 支持 OpenList 数据备份与恢复,WebDAV / OpenList 可分别选择备份范围,并新增后台「备份管理」统一入口。\n3. [新增] 歌曲评分与 Subsonic 评分歌单: Web 播放器支持 1–5 星评分,Subsonic 生成按星级汇总的虚拟歌单,后台可查看评分与不喜欢数据。\n4. [新增] 聚合搜索升级: 多源关键词联想、分批加载与聚合排序,内置音乐 SDK 接口同步更新。\n5. [新增] 服务器已有歌曲优先播放: 优先使用服务器 music / cache 中的歌曲与本地歌词,Subsonic 可选返回缓存目录歌曲。\n6. [优化] 下载与缓存: 浏览器单曲 4 路分段下载,自动切歌优先使用已完成缓存,缓存清理不再误删 music 文件。\n7. [修复] PWA 下载认证失效、切歌进度未重置、自定义目录索引等多项问题。", "confirm_text": "查看日志", "cancel_text": "稍后提醒" }, "action": { "type": "link", - "url": "https://github.com/XCQ0607/lxserver/releases/tag/v2.1.4" + "url": "https://github.com/XCQ0607/lxserver/releases/tag/v2.2.0" } } ] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index da8c831c..ba635010 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "lx-music-sync-server", - "version": "2.1.4", + "version": "2.2.0", "lockfileVersion": 3, "requires": true, "packages": { @@ -20,7 +20,7 @@ "log4js": "^6.9.1", "lru-cache": "11.5.3", "message2call": "0.1.3", - "module-alias": "^2.3.4", + "module-alias": "^2.3.5", "music-tag-native": "0.2.5", "needle": "^3.3.1", "sharp": "0.35.5", @@ -45,7 +45,7 @@ "@types/unzipper": "^0.10.11", "@types/ws": "8.18.2", "changelog-parser": "^4.1.0", - "electron": "~43.8.3", + "electron": "~43.8.6", "electron-builder": "^26.18.0", "esbuild": "^0.28.2", "husky": "^9.1.7", @@ -6141,9 +6141,9 @@ } }, "node_modules/electron": { - "version": "43.8.3", - "resolved": "https://registry.npmjs.org/electron/-/electron-43.8.3.tgz", - "integrity": "sha512-KgvSm3bHXz96hSXzqxyRjLeli80rIdszbDQFKlRSfckmGazTPCH/CxUN31Dp9c5rFKTQSNf9gPJRWl1yWNB3pA==", + "version": "43.8.6", + "resolved": "https://registry.npmjs.org/electron/-/electron-43.8.6.tgz", + "integrity": "sha512-2k3n5cjPHZqYTOn8iaJrm4fmb/QsvciomLD7PG9IECd0N/jr8Hxcu6Z/3mvMRrtXWCOp/i8qm5ZAcSnUzAa5Mw==", "dev": true, "license": "MIT", "dependencies": { @@ -8489,9 +8489,9 @@ } }, "node_modules/module-alias": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/module-alias/-/module-alias-2.3.4.tgz", - "integrity": "sha512-bOclZt8hkpuGgSSoG07PKmvzTizROilUTvLNyrMqvlC9snhs7y7GzjNWAVbISIOlhCP1T14rH1PDAV9iNyBq/w==", + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/module-alias/-/module-alias-2.3.5.tgz", + "integrity": "sha512-hbei6S1tXkg/P+ZJTyUqqL66LOeg/40WGcZYPFzIeBltTCRm+O4IZ/KRtR9hGanXaJxuvPj9q9ldl/cmbV6Osw==", "license": "MIT" }, "node_modules/mri": { @@ -11970,4 +11970,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 9802e322..398b83e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lx-music-sync-server", - "version": "2.1.4", + "version": "2.2.0", "private": true, "scripts": { "prebuild": "npm run build:css && npm run build:files && node scripts/download-binary.js && node scripts/update-build-hash.js", @@ -52,7 +52,7 @@ "log4js": "^6.9.1", "lru-cache": "11.5.3", "message2call": "0.1.3", - "module-alias": "^2.3.4", + "module-alias": "^2.3.5", "music-tag-native": "0.2.5", "needle": "^3.3.1", "sharp": "0.35.5", @@ -76,7 +76,7 @@ "@types/unzipper": "^0.10.11", "@types/ws": "8.18.2", "changelog-parser": "^4.1.0", - "electron": "~43.8.3", + "electron": "~43.8.6", "electron-builder": "^26.18.0", "esbuild": "^0.28.2", "husky": "^9.1.7", @@ -103,4 +103,4 @@ ], "exec": "npx tsx ./src/index.ts" } -} +} \ No newline at end of file diff --git a/public/app.js b/public/app.js index 6aac9a58..59bbe6d4 100644 --- a/public/app.js +++ b/public/app.js @@ -18,13 +18,12 @@ const API_BASE = ''; function stringToColor(str) { - if (!str) return 'var(--accent-primary)'; + if (!str) return 'var(--accent)'; let hash = 0; for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); } - const h = Math.abs(hash) % 360; - return `hsl(${h}, 70%, 45%)`; + return ['var(--avatar-1)', 'var(--avatar-2)', 'var(--avatar-3)', 'var(--avatar-4)'][Math.abs(hash) % 4]; } class App { @@ -74,7 +73,7 @@ class App { const isPassword = pwdInput.type === 'password'; pwdInput.type = isPassword ? 'text' : 'password'; if (icon) { - icon.className = isPassword ? 'fas fa-eye-slash text-indigo-400' : 'far fa-eye'; + icon.className = isPassword ? 'fas fa-eye-slash tone-accent' : 'far fa-eye'; } } }); @@ -140,7 +139,7 @@ class App { const statusEl = document.getElementById('user-custom-dir-status'); if (statusEl) { statusEl.textContent = '目录路径已修改,请点击右侧「检测」验证可用性'; - statusEl.style.color = 'var(--text-secondary, #94a3b8)'; + statusEl.style.color = 'var(--text-2)'; } }); @@ -322,7 +321,7 @@ class App { const sizeClass = ['text-xs', 'text-sm', 'text-base', 'text-lg', 'text-xl', 'text-2xl'][Math.floor(Math.random() * 6)]; item.className = `fas ${icon} admin-floating-note ${sizeClass}`; item.style.left = `${Math.random() * 95}%`; - + const duration = 12 + Math.random() * 12; const delay = Math.random() * duration; item.style.animationDuration = `${duration}s`; @@ -351,7 +350,7 @@ class App { x: Math.random() * width, y: Math.random() * height, radius: Math.random() * 2 + 0.8, - color: Math.random() > 0.5 ? 'rgba(99, 102, 241, ' : 'rgba(6, 182, 212, ', + color: Math.random() > 0.5 ? 'accent' : 'info', alpha: Math.random() * 0.45 + 0.1, speedY: Math.random() * 0.4 + 0.15, speedX: (Math.random() - 0.5) * 0.2, @@ -362,6 +361,7 @@ class App { const animateParticles = () => { if (document.getElementById('login-overlay')?.classList.contains('hidden')) return; ctx.clearRect(0, 0, width, height); + const colors = themeColors(); for (const p of particles) { p.y -= p.speedY; p.x += p.speedX; @@ -377,13 +377,16 @@ class App { const currentAlpha = p.alpha * (0.6 + 0.4 * Math.sin(p.pulse)); ctx.beginPath(); ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2); - ctx.fillStyle = p.color + currentAlpha + ')'; + ctx.globalAlpha = currentAlpha; + ctx.fillStyle = colors[p.color]; ctx.shadowBlur = 8; - ctx.shadowColor = p.color + '0.5)'; + ctx.shadowColor = colors[p.color]; ctx.fill(); } - requestAnimationFrame(animateParticles); + ctx.globalAlpha = 1; + this._loginParticlesFrame = requestAnimationFrame(animateParticles); }; + this._resumeLoginParticles = animateParticles; animateParticles(); } @@ -441,7 +444,7 @@ class App { if (btnText) btnText.textContent = '验证成功,正在进入...'; if (btn) { btn.classList.remove('admin-btn-submit'); - btn.classList.add('bg-emerald-600'); + btn.classList.add('tone-fill-success'); } if (card) { @@ -477,15 +480,49 @@ class App { logout() { localStorage.removeItem('lx_auth'); - location.reload(); + this.password = null; + for (const timer of ['monitorTimer', 'logsRefreshTimer', '_openlistRestoreTimer', '_olbConnectionTimer', '_olIntegrityTimer']) { + clearInterval(this[timer]); + this[timer] = null; + } + document.getElementById('app')?.classList.add('hidden'); + document.getElementById('login-overlay')?.classList.remove('hidden'); + const card = document.getElementById('admin-login-card'); + if (card) { card.style.removeProperty('transform'); card.style.removeProperty('opacity'); } + const button = document.getElementById('login-btn'); + if (button) { + button.disabled = false; + button.classList.remove('tone-fill-success'); + button.classList.add('admin-btn-submit'); + const icon = button.querySelector('i'); + if (icon) icon.className = 'fas fa-right-to-bracket text-sm'; + } + const text = document.getElementById('login-btn-text'); + if (text) text.textContent = '登录控制台'; + if (!this._loginEffectsInited) this.initLoginScreenEffects(); + else if (!this._loginParticlesFrame) this._resumeLoginParticles?.(); } showApp() { + cancelAnimationFrame(this._loginParticlesFrame); + this._loginParticlesFrame = null; document.getElementById('login-overlay').classList.add('hidden'); document.getElementById('app').classList.remove('hidden'); } async switchView(viewName) { + // 兼容旧入口:WebDAV / OpenList / 快照管理已并入「备份管理」,转向对应页签 + const legacyBackupViews = { webdav: 'cloud', openlist: 'cloud', snapshots: 'local' }; + if (legacyBackupViews[viewName]) { + this._bmPendingTab = legacyBackupViews[viewName]; + viewName = 'backups'; + } + // 离开备份管理且存在未保存输入时先确认,避免静默丢失 + if (this.currentView === 'backups' && viewName !== 'backups' && this.hasBackupUnsaved()) { + const leave = await showSelect('有未保存的备份设置', `以下内容尚未保存:${this.backupUnsavedLabels().join('、')}。\n离开后这些修改将被丢弃。`, { danger: true, confirmText: '放弃修改并离开', cancelText: '留在此页' }); + if (!leave) return; + this.clearBackupDirty(); + } // 更新导航状态 document.querySelectorAll('.nav-item').forEach(item => { item.classList.toggle('active', item.dataset.view === viewName); @@ -503,11 +540,9 @@ class App { data: '数据查看', config: '系统配置', logs: '系统日志', - webdav: 'WebDAV同步', + backups: '备份管理', subsonic: 'Subsonic 配置', - openlist: 'OpenList 同步', files: '文件管理', - snapshots: '快照管理', about: '关于' }; document.getElementById('page-title').textContent = titles[viewName] || viewName; @@ -532,29 +567,17 @@ class App { this.loadLogs(); this.startLogsAutoRefresh(); break; - case 'webdav': - try { - const status = await this.request('/api/status'); - this.checkWebDAVConfig(status.isWebDAVConfigured); - this.loadSyncLogs(); - } catch (e) { - console.error('Failed to check webdav status:', e); - } - break; case 'subsonic': this.loadSubsonicConfig(); break; - case 'backups': - case 'snapshots': - if (this.currentBackupTab === 'snapshot') { - this.loadSnapshots(); - } else { - this.loadConfigBackups(); - } - break; - case 'openlist': - this.loadOpenlistConfig(); + case 'backups': { + const targetTab = this._bmPendingTab || this.currentBackupCenterTab || 'overview'; + const targetSub = this._bmPendingSub || ''; + this._bmPendingTab = null; + this._bmPendingSub = null; + await this.enterBackupCenter(targetTab, targetSub); break; + } case 'about': this.loadAbout(); break; @@ -615,7 +638,7 @@ class App { } } catch (e) { console.error('Failed to load about content:', e); - container.innerHTML = '

加载关于页面失败

'; + container.innerHTML = '

加载关于页面失败

'; } } @@ -720,7 +743,7 @@ class App { } startMonitor() { - if (this.monitorTimer) return; + if (this.monitorTimer || !this.password) return; this.monitorTimer = setInterval(async () => { if (this.currentView !== 'dashboard' || !this.password) { clearInterval(this.monitorTimer); @@ -759,7 +782,7 @@ class App { this.monitorLabels.push(nowStr); this.systemCpuHistory.push(sysCpuVal); this.processCpuHistory.push(procCpuVal); - if (this.systemCpuHistory.length > 20) { + if (this.systemCpuHistory.length > (this.monitorWindow || 20)) { this.monitorLabels.shift(); this.systemCpuHistory.shift(); this.processCpuHistory.shift(); @@ -768,24 +791,26 @@ class App { { label: '系统 CPU (%)', data: this.systemCpuHistory, - borderColor: '#3b82f6', - backgroundColor: 'rgba(59, 130, 246, 0.15)', + themeSeries: 0, + borderColor: themeColors().series[0], + backgroundColor: themeColors().series[0], borderWidth: 2, pointRadius: 2, pointHoverRadius: 5, tension: 0.35, - fill: true + fill: false }, { label: '本服务 CPU (%)', data: this.processCpuHistory, - borderColor: '#c084fc', - backgroundColor: 'rgba(192, 132, 252, 0.08)', + themeSeries: 2, + borderColor: themeColors().series[2], + backgroundColor: themeColors().series[2], borderWidth: 2, pointRadius: 2, pointHoverRadius: 5, tension: 0.35, - fill: true + fill: false } ]); @@ -821,7 +846,7 @@ class App { this.systemMemHistory.push(sysMemVal); this.processMemHistory.push(procMemVal); - if (this.systemMemHistory.length > 20) { + if (this.systemMemHistory.length > (this.monitorWindow || 20)) { this.systemMemHistory.shift(); this.processMemHistory.shift(); } @@ -829,24 +854,26 @@ class App { { label: '系统内存 (%)', data: this.systemMemHistory, - borderColor: '#10b981', - backgroundColor: 'rgba(16, 185, 129, 0.15)', + themeSeries: 1, + borderColor: themeColors().series[1], + backgroundColor: themeColors().series[1], borderWidth: 2, pointRadius: 2, pointHoverRadius: 5, tension: 0.35, - fill: true + fill: false }, { label: '本服务内存 (%)', data: this.processMemHistory, - borderColor: '#60a5fa', - backgroundColor: 'rgba(96, 165, 250, 0.08)', + themeSeries: 3, + borderColor: themeColors().series[3], + backgroundColor: themeColors().series[3], borderWidth: 2, pointRadius: 2, pointHoverRadius: 5, tension: 0.35, - fill: true + fill: false } ]); @@ -878,16 +905,12 @@ class App { const webdavPill = document.getElementById('dash-webdav-status-pill'); if (webdavPill) { + webdavPill.classList.toggle('is-ready', status.isWebDAVConfigured); + webdavPill.classList.toggle('is-unconfigured', !status.isWebDAVConfigured); if (status.isWebDAVConfigured) { webdavPill.textContent = 'WebDAV 已就绪'; - webdavPill.style.background = 'rgba(16, 185, 129, 0.15)'; - webdavPill.style.color = '#34d399'; - webdavPill.style.borderColor = 'rgba(16, 185, 129, 0.3)'; } else { webdavPill.textContent = 'WebDAV 未配置'; - webdavPill.style.background = 'rgba(245, 158, 11, 0.15)'; - webdavPill.style.color = '#fbbf24'; - webdavPill.style.borderColor = 'rgba(245, 158, 11, 0.3)'; } } } @@ -931,10 +954,10 @@ class App { display: false }, tooltip: { - backgroundColor: 'rgba(15, 23, 42, 0.92)', - titleColor: '#e2e8f0', - bodyColor: '#cbd5e1', - borderColor: 'rgba(255, 255, 255, 0.12)', + backgroundColor: themeColors().tipBg, + titleColor: themeColors().tipText, + bodyColor: themeColors().tipText, + borderColor: themeColors().border, borderWidth: 1, padding: 10, boxPadding: 4, @@ -950,11 +973,11 @@ class App { x: { display: true, grid: { - color: 'rgba(255, 255, 255, 0.04)', + color: themeColors().grid, drawBorder: false }, ticks: { - color: 'rgba(255, 255, 255, 0.35)', + color: themeColors().tick, font: { size: 10 }, maxTicksLimit: 6, maxRotation: 0 @@ -965,11 +988,11 @@ class App { suggestedMin: 0, suggestedMax: suggestedMax, grid: { - color: 'rgba(255, 255, 255, 0.05)', + color: themeColors().grid, drawBorder: false }, ticks: { - color: 'rgba(255, 255, 255, 0.4)', + color: themeColors().tick, font: { size: 10 }, callback: function (value) { return value + '%'; @@ -1035,11 +1058,10 @@ class App { const isPublic = user.name === '_open'; const displayName = isPublic ? '公开用户 (_open)' : this.escapeHtml(user.name); const avatarChar = isPublic ? '🌐' : this.escapeHtml(user.name.charAt(0).toUpperCase()); - const avatarStyle = isPublic ? 'background: linear-gradient(135deg, #10b981, #059669); font-size:12px;' : ''; return ` - @@ -1075,11 +1097,10 @@ class App { const isPublic = user.name === '_open'; const displayName = isPublic ? '公开用户 (_open)' : this.escapeHtml(user.name); const roleText = isPublic ? '公共数据与歌单' : '用户数据'; - const avatarStyle = isPublic ? 'background: linear-gradient(135deg, #10b981, #059669); font-size: 1.5rem;' : ''; const avatarHtml = isPublic ? '🌐' : this.escapeHtml(user.name.charAt(0).toUpperCase()); return `
-
${avatarHtml}
+
${avatarHtml}
${displayName}
${roleText}
@@ -1160,10 +1181,10 @@ class App {
- - ⚠️ 勾选后将永久删除所有选中用户的数据(歌单、收藏等),不可恢复! + + ⚠️ 勾选后将永久删除所有选中用户的数据(歌单、收藏、音源脚本及配置等),不可恢复!
@@ -1227,7 +1248,7 @@ class App { if (!this.users.length) { container.innerHTML = `
-

暂无用户,点击上方按钮添加用户

+

暂无用户,点击上方按钮添加用户

`; return; @@ -1426,10 +1447,10 @@ class App {
- - ⚠️ 勾选后将永久删除该用户的所有数据(歌单、收藏等),不可恢复! + + ⚠️ 勾选后将永久删除该用户的所有数据(歌单、收藏、音源脚本及配置等),不可恢复!
@@ -1474,7 +1495,7 @@ class App { const statusEl = document.getElementById('user-custom-dir-status'); if (statusEl) { statusEl.textContent = '请输入自定义歌曲目录地址'; - statusEl.style.color = '#ef4444'; + statusEl.style.color = 'var(--danger-text)'; } } else { // 如果已有固定目录,建议先检测或设为需要检测 @@ -1482,7 +1503,7 @@ class App { const statusEl = document.getElementById('user-custom-dir-status'); if (statusEl) { statusEl.textContent = '请点击右侧「检测」按钮进行可用性验证'; - statusEl.style.color = 'var(--text-secondary, #94a3b8)'; + statusEl.style.color = 'var(--text-2)'; } } } else { @@ -1500,7 +1521,7 @@ class App { if (!dirPath) { if (statusEl) { statusEl.textContent = '请输入自定义歌曲目录地址'; - statusEl.style.color = '#ef4444'; + statusEl.style.color = 'var(--danger-text)'; } this.setUserConfigConfirmBtnState(false); return; @@ -1508,7 +1529,7 @@ class App { if (statusEl) { statusEl.textContent = '正在检测目录可用性...'; - statusEl.style.color = '#38bdf8'; + statusEl.style.color = 'var(--info-text)'; } try { @@ -1520,20 +1541,20 @@ class App { if (res && res.success) { if (statusEl) { statusEl.textContent = `✓ 目录可用 (${res.path || dirPath})`; - statusEl.style.color = '#10b981'; + statusEl.style.color = 'var(--success-text)'; } this.setUserConfigConfirmBtnState(true); } else { if (statusEl) { statusEl.textContent = `✕ ${res?.message || '目录不可用'}`; - statusEl.style.color = '#ef4444'; + statusEl.style.color = 'var(--danger-text)'; } this.setUserConfigConfirmBtnState(false); } } catch (err) { if (statusEl) { statusEl.textContent = `✕ 检测失败: ${err.message}`; - statusEl.style.color = '#ef4444'; + statusEl.style.color = 'var(--danger-text)'; } this.setUserConfigConfirmBtnState(false); } @@ -1637,6 +1658,7 @@ class App { } async loadUserData() { + const requestId = this._dataRequestId = (this._dataRequestId || 0) + 1; const username = document.getElementById('data-user-select')?.value; const statsContainer = document.getElementById('data-stats'); const contentContainer = document.getElementById('data-content'); @@ -1652,6 +1674,8 @@ class App { try { const data = await this.request(`/api/data?user=${encodeURIComponent(username)}`); + if (this._dataRequestId !== requestId || document.getElementById('data-user-select')?.value !== username) return; + if (this.currentUserData?.username !== username) this._dislikeFilters = {}, this._ratingFilters = {}; this.currentUserData = { username, data }; // 统计数据 @@ -1712,6 +1736,12 @@ class App { `; + const dislikeCount = this.getDislikeEntries(data).length; + statsContainer.insertAdjacentHTML('beforeend', `

不喜欢

⊘
${dislikeCount}
`); + const ratingBadge = document.getElementById('tab-count-ratings'); + if (ratingBadge) ratingBadge.textContent = (data.ratingItems || []).length; + const dislikeBadge = document.getElementById('tab-count-dislikes'); + if (dislikeBadge) dislikeBadge.textContent = dislikeCount; // 更新 Tabs 徽章与显示状态 const tabsContainer = document.getElementById('data-tabs-container'); if (tabsContainer) { @@ -1739,7 +1769,10 @@ class App { }, 400); } catch (err) { - contentContainer.innerHTML = '

加载数据失败

'; + if (this._dataRequestId !== requestId) return; + statsContainer.classList.remove('content-loading'); + contentContainer.classList.remove('content-loading'); + contentContainer.innerHTML = '

加载数据失败

'; } finally { applyMarqueeChecks(); } @@ -1794,16 +1827,343 @@ class App { html += this.getArtistsSectionHtml(data.artists || [], tab === 'all'); } + if (tab === 'all' || tab === 'ratings') html += this.getRatingSectionHtml(data); + if (tab === 'all' || tab === 'dislikes') html += this.getDislikeSectionHtml(data); contentContainer.innerHTML = html; + this.initCustomSelects(); applyMarqueeChecks(); } + dislikeOriginLabel(channel) { + return ({ web: 'Web 添加', 'subsonic-rating': 'Subsonic 低分联动', 'web-rating-link': 'Web 低分联动', 'manual-rating-to-dislike': '手动评分联动', 'snapshot-restore': '评分恢复', unknown: '来源未记录' })[channel] || (channel ? `其他:${channel}` : '来源未记录'); + } + + adminPlatformOptions(items, selected, allLabel) { + const e = value => this.escapeHtml(String(value)); + const platforms = [...new Set(['wy', 'tx', 'kw', 'kg', 'mg', 'local', ...items.map(x => x.source || '*')])].filter(source => source !== 'bd'); + const label = code => code === 'local' ? '本地音乐' : code === '*' ? '未指定平台' : this.getSourceLabel(code); + return `` + platforms.map(code => ``).join(''); + } + + adminExtraFilters(section, filters) { + const handler = section === 'rating' ? 'filterRatingData' : 'filterDislikeData'; + const select = (key, label, options) => ``; + return `
+ ${select('cover','封面资料',[['','全部封面状态'],['yes','有封面'],['no','缺少封面']])} + ${select('period','记录时间',[['','不限时间'],['7','最近7天'],['30','最近30天'],['90','最近90天'],['unknown','未记录时间']])} + ${select('pageSize','每页条数',[['','默认条数'],['24','24条'],['48','48条'],['96','96条']])} + + +
`; + } + + adminMatchesExtra(filters, cover, time, text) { + if (filters.cover && (filters.cover === 'yes') !== !!cover) return false; + if (filters.period === 'unknown' ? !!time : filters.period && (!time || time < Date.now() - Number(filters.period) * 86400000)) return false; + const terms = String(filters.query || '').normalize('NFKC').trim().toLowerCase().split(/\s+/).filter(Boolean); + const haystack = String(text).normalize('NFKC').toLowerCase(); + return terms.every(term => haystack.includes(term)); + } + + adminPageSize(filters, fallback) { + const value = Number(filters.pageSize); + return [24,48,96].includes(value) ? value : fallback; + } + + exportAdminRecords(section) { + const data = this.currentUserData?.data || {}; + const rows = section === 'rating' ? this.getFilteredRatingRows(data.ratingItems || []) : this.getFilteredDislikeRows(this.getDislikeEntries(data)).map(({ x, r }) => ({ kind: x.kind, name: x.title, singer: x.singer, source: x.source, rule: x.rule, rating: r.rating, provenance: x.raw.provenance, reasons: x.raw.reasons })); + const url = URL.createObjectURL(new Blob([JSON.stringify({ user: this.currentUserData?.username, exportedAt: new Date().toISOString(), count: rows.length, records: rows }, null, 2)], { type: 'application/json' })); + const link = document.createElement('a'); link.href = url; link.download = section === 'rating' ? 'filtered-ratings.json' : 'filtered-dislikes.json'; link.click(); + setTimeout(() => URL.revokeObjectURL(url), 1000); + } + + resetAdminFilters(section) { + const rating = section === 'rating'; + if (rating) this._ratingFilters = {}; + else this._dislikeFilters = { view: this._dislikeFilters?.view }; + const node = document.querySelector(rating ? '.admin-ratings' : '.admin-dislikes:not(.admin-ratings)'); + if (node) node.outerHTML = rating ? this.getRatingSectionHtml(this.currentUserData?.data || {}) : this.getDislikeSectionHtml(this.currentUserData?.data || {}); + this.initCustomSelects(); + } + + getDislikeEntries(data) { + const entries = new Map(); + const add = (raw, kind, library = false) => { + const rule = String(raw.dislikeRule || ''); + const split = rule.lastIndexOf('@'); + const title = raw.name || raw.albumName || (kind === 'album' ? rule.slice(1, split < 0 ? undefined : split) : kind === 'artist' ? raw.singer || rule.slice(1) : rule.slice(0, split < 0 ? undefined : split)) || '未命名规则'; + const singer = raw.singer || raw.artistName || (split >= 0 ? rule.slice(split + 1) : ''); + const source = raw.source || ''; + const key = `${kind}|${source}|${rule || (kind === 'album' ? `!${title}@${singer}` : kind === 'artist' ? `@${title}` : `${title}@${singer}`)}`.normalize('NFKC').toLowerCase().replace(/\s*([()@])\s*/g, '$1'); + const image = raw.img || raw.pic || raw.picUrl || raw.cover || raw.meta?.picUrl || ''; + const cover = typeof image === 'string' && (/^https?:\/\//i.test(image) || /^\/(?!\/)/.test(image)) ? image : ''; + const item = { kind, title, singer, source, cover, rule, raw, library, channel: raw.provenance?.channel || 'unknown' }; + if (!entries.has(key)) entries.set(key, item); + else { const prior = entries.get(key); if (!prior.cover) prior.cover = cover; prior.libraryData = raw; } + }; + for (const row of data.dislikeList || []) { + const rule = String(row.dislikeRule || ''); + add(row, rule.startsWith('!') ? 'album' : rule.startsWith('@') || (!row.name && row.singer) ? 'artist' : 'song'); + } + for (const row of data.dislikeAlbums || []) add(row, 'album', true); + for (const row of data.dislikeArtists || []) add(row, 'artist', true); + return [...entries.values()]; + } + + getDislikeRating(x, data) { + const raw = x.raw; + const id = raw.id || raw.songmid || raw.meta?.songId || ''; + const keys = [id, `${x.source}_${id}`, x.kind === 'album' ? `alb_${x.source}_${raw.albumId || id}` : x.kind === 'artist' ? `art_${x.source}_${id}` : '']; + const key = keys.find(k => k && Object.hasOwn(data.userRatings || {}, k)); + return { id, key, rating: key ? Number(data.userRatings[key]) : null }; + } + + starsHtml(value, size = '') { + const v = Math.max(0, Math.min(5, Number(value) || 0)); + return `★★★★★`; + } + + getDislikeSectionHtml(data) { + const items = this.getDislikeEntries(data); + const e = value => this.escapeHtml(String(value ?? '')); + const f = this._dislikeFilters || {}; + const option = (value, text, key) => ``; + const channels = [...new Set(['web','web-rating-link','subsonic-rating','manual-rating-to-dislike','unknown', ...items.flatMap(x => [x.channel, ...(x.raw.reasons || []).map(r => r.channel)])])].sort(); + return `
+
⊘

不喜欢

歌曲、歌手与专辑规则 · Web 与 Subsonic 联动数据

${items.length} 条
+
${this.getDislikeStatsHtml(items)}
+
+ + + + + +
+
+ ${this.adminExtraFilters('dislike', f)} +

平台标注总条数;来源筛选包含同一条规则的全部添加原因。多个关键词需同时匹配。添加来源与音乐平台分别记录。缺少来源记录的条目显示“来源未记录”;展开详情可查看所有已保存字段。评分只表示关联记录,不代表整张专辑或歌手的歌曲都被评分。

+
${this.getDislikeResultsHtml(items)}
+
`; + } + + getDislikeStatsHtml(items) { + const data = this.currentUserData?.data || {}; + const f = this._dislikeFilters || {}; + items = this.getFilteredDislikeRows(items, { ...f, kind: '' }).map(row => row.x); + const count = k => items.filter(x => x.kind === k).length; + const rated = items.filter(x => this.getDislikeRating(x, data).rating != null).length; + const linked = items.filter(x => [x.raw.provenance, ...(x.raw.reasons || [])].some(reason => reason && /rating/.test(reason.channel))).length; + const chip = (kind, label, n, icon) => ``; + return `
${chip('', '全部', items.length, '⊘')}${chip('song', '歌曲', count('song'), '♪')}${chip('artist', '歌手', count('artist'), '♙')}${chip('album', '专辑', count('album'), '◎')}
★${rated}已评分
⇄${linked}评分联动
`; + } + + refreshDislikeStats() { + const node = document.getElementById('admin-dislike-stats'); + if (node) node.innerHTML = this.getDislikeStatsHtml(this.getDislikeEntries(this.currentUserData?.data || {})); + } + + refreshDislikeToggle() { + const f = this._dislikeFilters || {}; + document.querySelectorAll('.admin-view-toggle button').forEach((b, i) => b.classList.toggle('on', (i === 1) === (f.view !== 'grid'))); + } + + filterDislikeData(key, value) { + this._dislikeFilters = { ...(this._dislikeFilters || {}), [key]: value, page: 0 }; + const node = document.getElementById('admin-dislike-results'); + if (node) node.innerHTML = this.getDislikeResultsHtml(this.getDislikeEntries(this.currentUserData?.data || {})); + this.refreshDislikeStats(); + } + + dislikePage(delta) { + this._dislikeFilters = { ...(this._dislikeFilters || {}), page: Math.max(0, (this._dislikeFilters?.page || 0) + delta) }; + const node = document.getElementById('admin-dislike-results'); + if (node) { node.innerHTML = this.getDislikeResultsHtml(this.getDislikeEntries(this.currentUserData?.data || {})); node.scrollIntoView({ block: 'start', behavior: 'smooth' }); } + } + + getFilteredDislikeRows(items, f = this._dislikeFilters || {}) { + const data = this.currentUserData?.data || {}; + const withRating = items.map(x => ({ x, r: this.getDislikeRating(x, data) })); + const matchRating = r => !f.rating || (f.rating === 'rated' ? r.rating != null : f.rating === 'none' ? r.rating == null : f.rating === 'low' ? r.rating != null && r.rating <= 2 : f.rating === 'high' ? r.rating >= 4 : r.rating === Number(f.rating)); + return withRating.filter(({ x, r }) => (!f.kind || f.kind === x.kind) && (!f.channel || f.channel === x.channel || (x.raw.reasons || []).some(reason => reason.channel === f.channel)) && (!f.source || f.source === (x.source || '*')) && matchRating(r) && this.adminMatchesExtra(f, x.cover, x.raw.provenance?.at, JSON.stringify([x.title, x.singer, x.rule, x.source, x.raw.id, x.raw.songmid, x.raw.meta]))); + } + + getDislikeResultsHtml(items) { + const e = value => this.escapeHtml(String(value ?? '')); + const f = this._dislikeFilters || {}; + const data = this.currentUserData?.data || {}; + let selected = this.getFilteredDislikeRows(items, f); + if (f.sort === 'name') selected = [...selected].sort((a, b) => a.x.title.localeCompare(b.x.title, 'zh')); + else if (f.sort === 'rating') selected = [...selected].sort((a, b) => (b.r.rating ?? -1) - (a.r.rating ?? -1)); + else if (f.sort === 'time') selected = [...selected].sort((a, b) => (b.x.raw.provenance?.at || 0) - (a.x.raw.provenance?.at || 0)); + if (!selected.length) return '
⊘
暂无符合条件的不喜欢记录
'; + const PAGE = this.adminPageSize(f, 24), pages = Math.ceil(selected.length / PAGE); + const page = Math.min(f.page || 0, pages - 1); + this.resetAdminDetails(); + const cards = selected.slice(page * PAGE, page * PAGE + PAGE).map(({ x, r }) => { + const raw = x.raw, { id, key: ratingKey, rating } = r; + const fields = { '歌手': x.singer, '规则': x.rule, '音乐来源': x.source ? this.getSourceLabel(x.source) : '未指定(通用)', '对象 ID': id, '歌曲标识': raw.songmid, '专辑': raw.albumName || raw.meta?.albumName, '专辑 ID': raw.albumId || raw.meta?.albumId, '时长': raw.interval, '评分': rating == null ? '' : `${rating} 星`, '添加来源': this.dislikeOriginLabel(x.channel), '全部添加原因': (raw.reasons || [raw.provenance]).filter(Boolean).map(reason => this.dislikeOriginLabel(reason.channel) + (reason.ratingId ? `(${reason.ratingId})` : '')).join('、'), '添加时间': raw.provenance?.at ? new Date(raw.provenance.at).toLocaleString() : '', '补全来源': raw.enrichedBy ? this.dislikeOriginLabel(raw.enrichedBy.channel) : '', '补全时间': raw.enrichedBy?.at ? new Date(raw.enrichedBy.at).toLocaleString() : '', '音质': Array.isArray(raw.types) ? raw.types.map(q => `${q.type}${q.size ? ` (${q.size})` : ''}`).join('、') : '' }; + const image = x.cover ? `${e(x.title)}封面` : ``; + const full = JSON.stringify({ ...raw, ...(x.libraryData ? { libraryData: x.libraryData } : {}), ...(ratingKey ? { linkedRating: { id: ratingKey, value: rating, origin: data.ratingOrigins?.[ratingKey] || null } } : {}) }, null, 2); + const when = raw.provenance?.at ? new Date(raw.provenance.at).toLocaleDateString() : ''; + const g = keys => keys.map(k => [k, fields[k]]); + const token = this.registerAdminDetail({ title: x.title, subtitle: [x.singer, raw.albumName || raw.meta?.albumName].filter(Boolean).join(' · '), cover: x.cover, icon: x.kind === 'song' ? '♪' : x.kind === 'album' ? '◎' : '♙', rating, raw: { ...raw, ...(x.libraryData ? { libraryData: x.libraryData } : {}), ...(ratingKey ? { linkedRating: { id: ratingKey, value: rating, origin: data.ratingOrigins?.[ratingKey] || null } } : {}) }, badges: [({ song: '歌曲', artist: '歌手', album: '专辑' })[x.kind], x.source ? this.getSourceLabel(x.source) : '通用规则', this.dislikeOriginLabel(x.channel)], sections: [{ title: '基本信息', fields: g(['歌手', '专辑', '时长', '音乐来源']) }, { title: '评分关联', fields: [['评分', fields['评分']], ['评分 ID', ratingKey || '']] }, { title: '添加记录', fields: g(['规则', '添加来源', '全部添加原因', '添加时间', '补全来源', '补全时间']) }, { title: '标识', fields: g(['对象 ID', '歌曲标识', '专辑 ID']) }, { title: '可用音质', fields: [['音质', Array.isArray(raw.types) ? raw.types.map(q => q.type + (q.size ? ' · ' + q.size : '')) : []]] }] }); + return `
${image}

${e(x.title)}

${e([x.singer || '未记录歌手', raw.albumName || raw.meta?.albumName].filter(Boolean).join(' · '))}

${e(({ song: '歌曲', artist: '歌手', album: '专辑' })[x.kind])}${e(this.dislikeOriginLabel(x.channel))}${e(x.source ? this.getSourceLabel(x.source) : '通用规则')}
${rating == null ? '未评分' : `${this.starsHtml(rating)}${e(rating)}`}
${e(when || '时间未记录')}
`; + }).join(''); + return `
共 ${selected.length} 条(总计 ${items.length} 条) · 第 ${page + 1} / ${pages} 页
${cards}
`; + } + + ratingOriginLabel(origin) { + if (!origin?.channel) return '来源未记录'; + return ({ web: 'Web 手动评分', subsonic: 'Subsonic 手动评分', 'web-dislike-link': '不喜欢自动补分', 'manual-dislike-to-rating': '后台补分', 'web-rating-link': 'Web 低分联动', 'subsonic-rating': 'Subsonic 低分联动', 'manual-rating-to-dislike': '后台低分补不喜欢', 'snapshot-restore': '评分恢复', unknown: '来源未记录' })[origin.channel] || '其他来源(未识别)'; + } + + async retryAdminRatingSync() { + if (this._ratingRetryBusy) return; + const user=this.currentUserData?.username;if(!user)return; + this._ratingRetryBusy=true; + try {const result=await this.request(`/api/data/rating-sync?user=${encodeURIComponent(user)}`,{method:'POST'});if(result.pending?.length)showInfo(`重试完成,仍有 ${result.pending.length} 项待处理`);else showSuccess('失败项已处理');if(this.currentUserData?.username===user)await this.loadUserData()} + catch(error){showError('重试失败:'+error.message)}finally{this._ratingRetryBusy=false} + } + + getRatingSectionHtml(data) { + const items = data.ratingItems || []; + const e = v => this.escapeHtml(String(v ?? '')); + const f = this._ratingFilters || {}; + const opt = (v, t, k) => ``; + const origins = ['web', 'subsonic', 'web-dislike-link', 'manual-dislike-to-rating']; + return `
+
★

评分

歌曲、专辑、歌手评分 · 含封面、平台与评分来源

${items.length} 条
+ ${(data.ratingSyncPending || []).length ? `
同步待处理 ${(data.ratingSyncPending || []).length} 项${data.ratingSyncPending.map(task => `

${e(task.id || '评分快照')}:${e(task.message)}

`).join('')}
` : ''} +
${this.getRatingStatsHtml(items)}
+
+ + + + + +
+ ${this.adminExtraFilters('rating', f)} +

平台标注该用户的总条数,0条也可筛选。多个关键词以空格分隔,需同时匹配;音乐平台与评分操作来源分别筛选。

+
${this.getRatingResultsHtml(items)}
+
`; + } + + getRatingStatsHtml(items) { + const f = this._ratingFilters || {}; + const total = items.length, dormant = items.filter(x => x.dormant).length; + const selected = this.getFilteredRatingRows(items).length; + items = this.getFilteredRatingRows(items, { ...f, kind: '', stars: '' }); + const n = k => items.filter(x => x.kind === k).length; + const avg = items.length ? (items.reduce((a, x) => a + x.rating, 0) / items.length).toFixed(2) : '–'; + const cell = (k, label, v) => ``; + const dist = [5, 4, 3, 2, 1].map(s => { const c = items.filter(x => x.rating === s).length; return ``; }).join(''); + return `
${cell('', '全部', items.length)}${cell('song', '歌曲', n('song'))}${cell('album', '专辑', n('album'))}${cell('artist', '歌手', n('artist'))}
${avg}平均分
评分分布 · 点击筛选 / 再次点击取消
${dist}
${avg}平均评分

当前匹配 ${selected} / ${total} 条

`; + } + + refreshRatingStats() { + const node = document.getElementById('admin-rating-stats'); + if (node) node.innerHTML = this.getRatingStatsHtml(this.currentUserData?.data?.ratingItems || []); + } + + filterRatingData(key, value) { + this._ratingFilters = { ...(this._ratingFilters || {}), [key]: value, page: 0 }; + const node = document.getElementById('admin-rating-results'); + if (node) node.innerHTML = this.getRatingResultsHtml(this.currentUserData?.data?.ratingItems || []); + this.refreshRatingStats(); + const stars = document.querySelector('.admin-ratings select[aria-label="星级"]'); + if (stars && key === 'stars') { stars.value = value; this.syncCustomSelects(); } + } + + ratingPage(delta) { + this._ratingFilters = { ...(this._ratingFilters || {}), page: Math.max(0, (this._ratingFilters?.page || 0) + delta) }; + const node = document.getElementById('admin-rating-results'); + if (node) { node.innerHTML = this.getRatingResultsHtml(this.currentUserData?.data?.ratingItems || []); node.scrollIntoView({ block: 'start' }); } + } + + getFilteredRatingRows(items, f = this._ratingFilters || {}) { + return items.filter(x => (!f.kind || x.kind === f.kind) && (!f.source || (x.source || '*') === f.source) && (!f.origin || (x.origin?.channel || '*') === f.origin) && (!f.stars || (f.stars === 'low' ? x.rating <= 2 : f.stars === 'high' ? x.rating >= 4 : x.rating === Number(f.stars))) && this.adminMatchesExtra(f, x.img, x.origin?.at, `${x.name} ${x.singer} ${x.albumName} ${x.id} ${x.effectiveId || ''} ${x.source || ''}`)); + } + + getRatingResultsHtml(items) { + const e = v => this.escapeHtml(String(v ?? '')); + const f = this._ratingFilters || {}; + let rows = this.getFilteredRatingRows(items, f); + rows = [...rows].sort((a, b) => f.sort === 'low' ? a.rating - b.rating : f.sort === 'time' ? (b.origin?.at || 0) - (a.origin?.at || 0) : f.sort === 'oldest' ? (a.origin?.at || 0) - (b.origin?.at || 0) : f.sort === 'singer' ? String(a.singer).localeCompare(String(b.singer), 'zh') : f.sort === 'source' ? String(a.source).localeCompare(String(b.source)) : f.sort === 'name' ? String(a.name).localeCompare(String(b.name), 'zh') : b.rating - a.rating); + if (!rows.length) return '
暂无符合条件的评分
'; + const PAGE = this.adminPageSize(f, 30), pages = Math.ceil(rows.length / PAGE), page = Math.min(f.page || 0, pages - 1); + const kindName = { song: '歌曲', album: '专辑', artist: '歌手' }; + this.resetAdminDetails(); + const body = rows.slice(page * PAGE, page * PAGE + PAGE).map(x => { + const cover = x.img ? `` : ''; + const sub = [x.singer, x.albumName].filter(Boolean).join(' · '); + const at = x.origin?.at ? new Date(x.origin.at).toLocaleString() : ''; + const token = this.registerAdminDetail({ title: x.name, subtitle: sub, cover: x.img, icon: x.kind === 'song' ? '♪' : x.kind === 'album' ? '◎' : '♙', rating: x.rating, raw: x, badges: [kindName[x.kind], x.source === 'local' ? '本地' : x.source ? this.getSourceLabel(x.source) : '未知平台'], sections: [{ title: '基本信息', fields: [['歌手', x.singer], ['专辑', x.albumName], ['时长', x.interval], ['音乐平台', x.source === 'local' ? '本地' : x.source ? this.getSourceLabel(x.source) : '']] }, { title: '评分记录', fields: [['评分来源', this.ratingOriginLabel(x.origin)], ['评分时间', at]] }, { title: '标识', fields: [['评分 ID', x.id], ['当前使用 ID', x.dormant ? x.effectiveId : '']] }] }); + return `
${cover}${x.kind === 'song' ? '♪' : x.kind === 'album' ? '◎' : '♙'}
${e(x.name)}
${e(sub || '—')}
${e(kindName[x.kind])}${e(x.source === 'local' ? '本地' : x.source ? this.getSourceLabel(x.source) : '未知平台')}${e(this.ratingOriginLabel(x.origin))}
${this.starsHtml(x.rating)}${e(at)}
`; + }).join(''); + return `
共 ${rows.length} 条(总计 ${items.length} 条) · 第 ${page + 1} / ${pages} 页
${body}
`; + } + + registerAdminDetail(model) { + this._adminDetails = this._adminDetails || new Map(); + const token = `d${this._adminSeq = (this._adminSeq || 0) + 1}`; + this._adminDetails.set(token, model); + return token; + } + + /** 解析/渲染后清空旧登记,避免翻页后令牌无限增长 */ + resetAdminDetails() { if ((this._adminDetails?.size || 0) > 5000) this._adminDetails = new Map(); } + + closeAdminDetail() { + const el = document.getElementById('admin-detail-modal'); + if (!el) return; + el.classList.add('closing'); + document.removeEventListener('keydown', this._adminDetailKey); + setTimeout(() => el.remove(), 160); + } + + openAdminDetail(token) { + const m = this._adminDetails?.get(token); + if (!m) return; + this.closeAdminDetail(); + document.getElementById('admin-detail-modal')?.remove(); + const e = v => this.escapeHtml(String(v ?? '')); + const cell = ([k, v]) => { + const copy = /ID|标识/.test(k) ? `` : ''; + const val = Array.isArray(v) ? `
${v.map(x => `${e(x)}`).join('')}
` : `${e(v)}`; + return `
${e(k)}
${val}${copy}
`; + }; + const sections = m.sections.map(s => ({ ...s, fields: s.fields.filter(([, v]) => v !== '' && v != null && !(Array.isArray(v) && !v.length)) })).filter(s => s.fields.length) + .map(s => `
${e(s.title)}
${s.fields.map(cell).join('')}
`).join(''); + const cover = m.cover ? `` : `
${m.icon || '♪'}
`; + const wrap = document.createElement('div'); + wrap.id = 'admin-detail-modal'; + wrap.className = 'adm-overlay'; + wrap.innerHTML = ``; + document.body.appendChild(wrap); + const close = () => this.closeAdminDetail(); + wrap.addEventListener('click', ev => { + if (ev.target === wrap || ev.target.closest('.adm-x')) return close(); + const c = ev.target.closest('.adm-copy'); + if (c) { navigator.clipboard?.writeText(c.dataset.copy); c.textContent = '已复制'; setTimeout(() => c.textContent = '复制', 1200); } + if (ev.target.closest('.adm-copy-json')) { navigator.clipboard?.writeText(JSON.stringify(m.raw, null, 2)); const b = ev.target.closest('.adm-copy-json'); b.textContent = '已复制'; setTimeout(() => b.textContent = '复制 JSON', 1200); } + }); + this._adminDetailKey = ev => { if (ev.key === 'Escape') close(); }; + document.addEventListener('keydown', this._adminDetailKey); + wrap.querySelector('.adm-panel').focus(); + } + getPlaylistsSectionHtml(userList, isAllTab) { const count = userList.length; let html = `
-
+
@@ -1856,7 +2216,7 @@ class App { }); html += '
'; } else { - html += '

暂无自定义列表

'; + html += '

暂无自定义列表

'; } return html; @@ -1867,7 +2227,7 @@ class App { let html = `
-
+
@@ -1929,7 +2289,7 @@ class App { }); html += '
'; } else { - html += '

暂无收藏专辑

'; + html += '

暂无收藏专辑

'; } return html; @@ -1940,7 +2300,7 @@ class App { let html = `
-
+
@@ -1985,7 +2345,7 @@ class App { }); html += '
'; } else { - html += '

暂无收藏歌手

'; + html += '

暂无收藏歌手

'; } return html; @@ -2063,7 +2423,7 @@ class App {
${songIndex + 1}
${this.renderSongNameCell(song, picUrl)}
${this.escapeHtml(song.singer || artist || '未知歌手')}
-
+
${this.escapeHtml(song.interval || '--:--')}
@@ -2072,7 +2432,7 @@ class App { content += '
'; } else { - content += '

此专辑暂无曲目

'; + content += '

此专辑暂无曲目

'; } document.getElementById('data-content').innerHTML = content; @@ -2177,7 +2537,7 @@ class App { content += '
'; } else { - content += '

此歌单暂无歌曲

'; + content += '

此歌单暂无歌曲

'; } document.getElementById('data-content').innerHTML = content; @@ -2315,7 +2675,7 @@ class App { content += '
'; } else { - content += '

此列表暂无歌曲

'; + content += '

此列表暂无歌曲

'; } document.getElementById('data-content').innerHTML = content; @@ -2582,7 +2942,7 @@ class App { content += '
'; } else { - content += '

暂无歌曲

'; + content += '

暂无歌曲

'; } document.getElementById('data-content').innerHTML = content; @@ -2594,16 +2954,16 @@ class App { this.configLoaded = true; this.loadedConfig = config; // 保存原始配置,供 saveConfig 对比「需重启」字段是否变更 const form = document.getElementById('config-form'); - this.renderBackupPolicy(config); + this.populateBackupForms(config); - form.elements['serverName'].value = config.serverName || ''; + if (form.elements['serverName']) form.elements['serverName'].value = config.serverName || ''; if (form.elements['debug.enabled']) { form.elements['debug.enabled'].checked = config['debug.enabled'] || false; } - form.elements['maxSnapshotNum'].value = config.maxSnapshotNum || 10; - form.elements['list.addMusicLocationType'].value = config['list.addMusicLocationType'] || 'top'; - form.elements['proxy.enabled'].checked = config['proxy.enabled'] || false; - form.elements['proxy.header'].value = config['proxy.header'] || ''; + if (form.elements['maxSnapshotNum']) form.elements['maxSnapshotNum'].value = config.maxSnapshotNum || 10; + if (form.elements['list.addMusicLocationType']) form.elements['list.addMusicLocationType'].value = config['list.addMusicLocationType'] || 'top'; + if (form.elements['proxy.enabled']) form.elements['proxy.enabled'].checked = config['proxy.enabled'] || false; + if (form.elements['proxy.header']) form.elements['proxy.header'].value = config['proxy.header'] || ''; if (form.elements['proxy.all.enabled']) { form.elements['proxy.all.enabled'].checked = config['proxy.all.enabled'] || false; } @@ -2656,11 +3016,14 @@ class App { if (form.elements['user.cacheSizeLimit']) { form.elements['user.cacheSizeLimit'].value = config['user.cacheSizeLimit'] || 2000; } + if (form.elements['cache.maxAgeDays']) { + form.elements['cache.maxAgeDays'].value = config['cache.maxAgeDays'] ?? 14; + } if (form.elements['system.allowUnsafeVM']) { form.elements['system.allowUnsafeVM'].checked = config['system.allowUnsafeVM'] === true; } - form.elements['admin.password'].value = config['admin.password'] || ''; + if (form.elements['admin.password']) form.elements['admin.password'].value = config['admin.password'] || ''; // Web播放器配置 if (form.elements['player.name']) { @@ -2673,53 +3036,6 @@ class App { form.elements['player.password'].value = config['player.password'] || ''; } - // WebDAV 配置 - if (form.elements['webdav.enable']) { - form.elements['webdav.enable'].checked = config['webdav.enable'] === true; - } - this.toggleWebdavVisibility(); - if (form.elements['webdav.url']) { - form.elements['webdav.url'].value = config['webdav.url'] || ''; - } - if (form.elements['webdav.username']) { - form.elements['webdav.username'].value = config['webdav.username'] || ''; - } - if (form.elements['webdav.password']) { - form.elements['webdav.password'].value = config['webdav.password'] || ''; - } - if (form.elements['webdav.syncPath']) { - form.elements['webdav.syncPath'].value = config['webdav.syncPath'] || '/lx-sync'; - } - if (form.elements['webdav.backupPath']) { - form.elements['webdav.backupPath'].value = config['webdav.backupPath'] || '/lx-sync-backups'; - } - if (form.elements['sync.interval']) { - form.elements['sync.interval'].value = config['sync.interval'] || 60; - } - if (form.elements['sync.backupInterval']) { - form.elements['sync.backupInterval'].value = config['sync.backupInterval'] || 24; - } - if (form.elements['webdav.excludeCache']) { - form.elements['webdav.excludeCache'].checked = config['webdav.excludeCache'] === true; - } - if (form.elements['webdav.excludeMusic']) { - form.elements['webdav.excludeMusic'].checked = config['webdav.excludeMusic'] === true; - } - - // 本地配置备份 - if (form.elements['configBackup.enable']) { - form.elements['configBackup.enable'].checked = config['configBackup.enable'] !== false; - } - if (form.elements['configBackup.retentionDays']) { - form.elements['configBackup.retentionDays'].value = config['configBackup.retentionDays'] || 7; - } - if (form.elements['configBackup.dir']) { - form.elements['configBackup.dir'].value = config['configBackup.dir'] || ''; - } - if (form.elements['snapshot.backupPath']) { - form.elements['snapshot.backupPath'].value = config['snapshot.backupPath'] || ''; - } - // URL路径配置 if (form.elements['admin.path']) { form.elements['admin.path'].value = config['admin.path'] ?? ''; @@ -2755,6 +3071,8 @@ class App { const webdavCb = document.querySelector('input[name="webdav.enable"]'); const childWrapper = document.getElementById('webdav-options'); const hintWrapper = document.getElementById('webdav-disabled-hint'); + const operations = document.getElementById('webdav-operation-panel'); + if (operations) operations.style.display = webdavCb?.checked && this.loadedConfig?.['webdav.enable'] === true ? '' : 'none'; if (webdavCb && childWrapper && hintWrapper) { if (webdavCb.checked) { childWrapper.style.display = 'block'; @@ -3157,10 +3475,12 @@ class App { } if (pathError) return; + // 备份相关字段已迁移至「备份管理」独立表单,此处仅提交「系统配置」表单自身字段,避免 FormData 缺失导致覆盖为默认值或 false + const rawMaxAge = formData.get('cache.maxAgeDays'); + const parsedMaxAge = rawMaxAge !== null && rawMaxAge !== '' ? parseInt(rawMaxAge, 10) : NaN; const config = { serverName: formData.get('serverName'), 'debug.enabled': formData.get('debug.enabled') === 'on', - maxSnapshotNum: parseInt(formData.get('maxSnapshotNum')), 'list.addMusicLocationType': formData.get('list.addMusicLocationType'), 'proxy.enabled': formData.get('proxy.enabled') === 'on', 'proxy.header': formData.get('proxy.header'), @@ -3188,23 +3508,11 @@ class App { 'user.enableLoginCacheRestriction': formData.get('user.enableLoginCacheRestriction') === 'on', 'user.enableCacheSizeLimit': formData.get('user.enableCacheSizeLimit') === 'on', 'user.cacheSizeLimit': parseInt(formData.get('user.cacheSizeLimit')) || 2000, + ...(Number.isFinite(parsedMaxAge) ? { 'cache.maxAgeDays': Math.max(0, parsedMaxAge) } : {}), 'admin.password': formData.get('admin.password'), 'player.name': (formData.get('player.name') || '').trim() || 'LX Music Web', 'player.enableAuth': formData.get('player.enableAuth') === 'on', 'player.password': formData.get('player.password'), - 'webdav.enable': formData.get('webdav.enable') === 'on', - 'webdav.url': formData.get('webdav.url'), - 'webdav.username': formData.get('webdav.username'), - 'webdav.password': formData.get('webdav.password'), - 'webdav.syncPath': (formData.get('webdav.syncPath') || '').trim() || '/lx-sync', - 'webdav.backupPath': (formData.get('webdav.backupPath') || '').trim() || '/lx-sync-backups', - 'sync.interval': parseInt(formData.get('sync.interval')) || 60, - 'sync.backupInterval': parseInt(formData.get('sync.backupInterval')) || 24, - ...this.collectBackupPolicy(), - 'configBackup.enable': formData.get('configBackup.enable') === 'on', - 'configBackup.retentionDays': parseInt(formData.get('configBackup.retentionDays')) || 7, - 'configBackup.dir': (formData.get('configBackup.dir') || '').trim(), - 'snapshot.backupPath': (formData.get('snapshot.backupPath') || '').trim(), 'admin.path': adminPath, 'player.path': playerPath, 'system.allowUnsafeVM': formData.get('system.allowUnsafeVM') === 'on', @@ -3356,6 +3664,9 @@ class App { if (form.elements['subsonic.customPlaylist']) { form.elements['subsonic.customPlaylist'].checked = config['subsonic.customPlaylist'] !== false; } + if (form.elements['subsonic.cachePlaylist']) { + form.elements['subsonic.cachePlaylist'].checked = config['subsonic.cachePlaylist'] === true; + } this.toggleSubsonicCustomPlaylistVisibility(); if (form.elements['subsonic.publicLeaderboards']) { form.elements['subsonic.publicLeaderboards'].checked = config['subsonic.publicLeaderboards'] === true; @@ -3415,12 +3726,12 @@ class App { if (form.elements['subsonic.dislikeRequireSinger']) { form.elements['subsonic.dislikeRequireSinger'].checked = config['subsonic.dislikeRequireSinger'] !== false; } + if (form.elements['subsonic.ratingPlaylists']) { + form.elements['subsonic.ratingPlaylists'].checked = config['subsonic.ratingPlaylists'] !== false; + } if (form.elements['subsonic.linkRatingToDislike']) { form.elements['subsonic.linkRatingToDislike'].checked = config['subsonic.linkRatingToDislike'] === true; } - if (form.elements['subsonic.linkDislikeToRating']) { - form.elements['subsonic.linkDislikeToRating'].checked = config['subsonic.linkDislikeToRating'] === true; - } // 音质 / 源优选 if (form.elements['subsonic.quality.enabled']) { @@ -3446,6 +3757,9 @@ class App { this.initCustomSelects(); this.syncCustomSelects(); + this.bindLinkSyncToggles(); + this.updateLinkSyncUI(); + void this.loadLinkSyncUsers(); } catch (e) { console.error('Failed to load subsonic config:', e); } @@ -3467,6 +3781,7 @@ class App { 'subsonic.onlineSearchSources': (formData.get('subsonic.onlineSearchSources') || '').trim() || 'wy,tx,kw,kg,mg', 'subsonic.localPlaylist': formData.get('subsonic.localPlaylist') === 'on', 'subsonic.customPlaylist': formData.get('subsonic.customPlaylist') === 'on', + 'subsonic.cachePlaylist': formData.get('subsonic.cachePlaylist') === 'on', 'subsonic.publicLeaderboards': formData.get('subsonic.publicLeaderboards') === 'on', 'subsonic.leaderboardSource': (formData.get('subsonic.leaderboardSource') || '').trim() || 'tx', 'subsonic.sharedListMode': (formData.get('subsonic.sharedListMode') || '').trim() || 'leaderboard', @@ -3484,8 +3799,8 @@ class App { 'subsonic.dislikeDuetMode': formData.get('subsonic.dislikeDuetMode') || 'any', 'subsonic.dislikeNormalizeName': formData.get('subsonic.dislikeNormalizeName') === 'on', 'subsonic.dislikeRequireSinger': formData.get('subsonic.dislikeRequireSinger') === 'on', + 'subsonic.ratingPlaylists': formData.get('subsonic.ratingPlaylists') === 'on', 'subsonic.linkRatingToDislike': formData.get('subsonic.linkRatingToDislike') === 'on', - 'subsonic.linkDislikeToRating': formData.get('subsonic.linkDislikeToRating') === 'on', 'subsonic.quality.enabled': formData.get('subsonic.quality.enabled') === 'on', 'subsonic.quality.priority': (formData.get('subsonic.quality.priority') || '').trim() || 'flac,320k,128k', @@ -3516,10 +3831,12 @@ class App { body: JSON.stringify(config) }); this.loadedSubsonicConfig = Object.assign({}, this.loadedSubsonicConfig || {}, config); + this.updateLinkSyncUI(); if (res.warning) { showInfo('Subsonic 配置保存成功!\n\n⚠️ 警告:' + res.warning); } else { - showSuccess('Subsonic 配置保存成功!'); + const linkChanged = ['subsonic.dislikeRating', 'subsonic.linkRatingToDislike'].some(k => config[k] !== prevConfig[k]); + showSuccess('Subsonic 配置保存成功!' + (linkChanged ? '\n联动配置对后续操作生效,历史评分和不喜欢不会自动重算或删除。' : '')); } if (needRestart) { const ok = await showSelect( @@ -3548,6 +3865,154 @@ class App { } } + // ---- Subsonic 联动数据手动同步(与导入 / 回滚链路解耦) ---- + + bindLinkSyncToggles() { + if (this._linkSyncBound) return; + const form = document.getElementById('subsonic-form'); + if (!form) return; + ['subsonic.linkRatingToDislike', 'subsonic.dislikeRating'].forEach(key => { + const el = form.elements[key]; + if (el) el.addEventListener('change', () => this.updateLinkSyncUI()); + }); + this._linkSyncBound = true; + } + + updateLinkSyncUI() { + const form = document.getElementById('subsonic-form'); + [ + ['subsonic.linkRatingToDislike', 'sync-rating-to-dislike-btn'], + ['subsonic.linkRatingToDislike', 'reconcile-rating-btn'], + ].forEach(([key, btnId]) => { + const btn = document.getElementById(btnId); + if (btn) { + btn.classList.remove('hidden'); + const saved = this.loadedSubsonicConfig || {}; + btn.disabled = !form?.elements[key]?.checked || saved[key] !== true || Number(saved['subsonic.dislikeRating'] ?? 1) <= 0; + btn.title = btn.disabled ? '请先开启并保存低分联动开关,且阈值大于 0' : btnId === 'reconcile-rating-btn' ? '先预览,再撤销失效评分原因;保留手动及其他有效原因' : '先预览,再补齐缺失的不喜欢规则'; + } + }); + const manualButton = document.getElementById('sync-dislike-to-rating-btn'); + if (manualButton) { manualButton.classList.remove('hidden'); manualButton.disabled = this._linkSyncBusy === true; manualButton.title = '管理员预览并批量补1星,只补未评分歌曲;不依赖自动开关或阈值'; } + const saved = this.loadedSubsonicConfig || {}; + const threshold = Number(saved['subsonic.dislikeRating'] ?? 1); + const status = document.getElementById('subsonic-link-saved-status'); + if (status) status.textContent = '已保存:' + (threshold <= 0 ? '低分联动已暂停(阈值为 0);用户自动补分及后台补分不受影响' : `低分阈值 ${threshold} 星;低分补不喜欢${saved['subsonic.linkRatingToDislike'] ? '开启' : '关闭'};不喜欢自动补分由各用户独立设置`) + '。更改配置不会重算历史数据。'; + } + + async loadLinkSyncUsers() { + const select = document.getElementById('subsonic-sync-user'); + if (!select) return; + try { + const users = await this.request('/api/users'); + const names = (Array.isArray(users) ? users : []).map(u => u.name).filter(Boolean); + const wanted = this.pendingLinkSyncUser || select.value; + select.innerHTML = ''; + names.forEach(n => { + const opt = document.createElement('option'); + opt.value = n; + opt.textContent = n; + select.appendChild(opt); + }); + if (names.includes(wanted)) select.value = wanted; + else if (names.length) select.value = names[0]; + this.pendingLinkSyncUser = null; + // 动态填充后同步自定义下拉显示(菜单在展开时会重新渲染) + this.initCustomSelects(); + this.syncCustomSelects(); + } catch (e) { + console.warn('[LinkSync] 加载用户列表失败:', e); + } + } + + async syncLink(direction, reconcile = false) { + if (this._linkSyncBusy) return; + const isRatingToDislike = direction === 'ratingToDislike'; + const key = 'subsonic.linkRatingToDislike'; + const label = reconcile ? '按当前阈值重算' : isRatingToDislike ? '低分补不喜欢' : '后台补分'; + const btn = document.getElementById(reconcile ? 'reconcile-rating-btn' : isRatingToDislike ? 'sync-rating-to-dislike-btn' : 'sync-dislike-to-rating-btn'); + const form = document.getElementById('subsonic-form'); + // 开关以服务端已保存的配置为准,避免「表单勾了但没保存」时后端拒绝 + const checked = !!form?.elements[key]?.checked; + const saved = (this.loadedSubsonicConfig || {})[key] === true; + if (isRatingToDislike && (!checked || !saved)) { + showError(`请先开启「${label} 自动联动」并保存配置,再执行同步。`); + return; + } + const user = document.getElementById('subsonic-sync-user')?.value || ''; + if (!user) { + showError('请先选择联动同步目标用户'); + return; + } + let preview; + try { + preview = await this.request('/api/subsonic/sync-link', { method: 'POST', body: JSON.stringify({ user, direction, preview: true, reconcile }) }); + } catch (err) { showError('同步预览失败: ' + err.message); return; } + const sample = (preview.candidates || []).slice(0, 20).map(x => `${x.name || x.id} [${x.source || '?'}]`).join('\n'); + const confirmed = await showSelect('手动联动同步', + `扫描范围包含用户歌单、本地曲目及在线缓存。待新增 ${preview.candidates?.length || 0} 项;已有评分 ${preview.existing?.length || 0} 项;重复关联 ${preview.duplicates?.length || 0} 项;解析失败 ${preview.failures?.length || 0} 项(最多显示20项):\n${this.escapeHtml(sample)}\n\n将对用户 ${this.escapeHtml(user)} 执行「${label}」补数据:${reconcile ? `将撤销 ${preview.removedReasons || 0} 个失效评分原因,移除 ${preview.removedRules || 0} 条无其他原因的规则;保留手动及来源不明规则,并补齐当前低分。` : '只补目标侧缺失的数据,不覆盖已有数据,也不会改动导入 / 回滚逻辑。'}\n\n确定继续吗?`, + { confirmText: '开始同步', cancelText: '取消' }); + if (!confirmed) return; + + this._linkSyncBusy = true; + const oldText = btn?.textContent; + if (btn) { btn.disabled = true; btn.textContent = '同步中…'; } + try { + const res = await this.request('/api/subsonic/sync-link', { + method: 'POST', + body: JSON.stringify({ user, direction, reconcile, preview: false }) + }); + const text = `同步完成(${label} · 用户 ${user}):扫描 ${res.scanned} 条,新增 ${res.added} 条,补全 ${res.updated || 0} 条,已存在 ${res.skipped} 条` + + (res.failed ? `,失败 ${res.failed} 条` : '') + + (res.batches > 1 ? `,分 ${res.batches} 批处理(全量)` : '') + (reconcile ? `,撤销原因 ${res.removedReasons || 0} 个、删除规则 ${res.removedRules || 0} 条` : '') + '。'; + const box = document.getElementById('link-sync-result'); + if (box) { + box.textContent = text; + box.classList.remove('hidden'); + } + if (res.warning) showInfo(text + '\n' + res.warning); else showSuccess(text); + } catch (err) { + showError('同步失败: ' + err.message); + } finally { + this._linkSyncBusy = false; + if (btn) btn.textContent = oldText || '同步'; + this.updateLinkSyncUI(); + } + } + + async maybeOfferLinkSync(user, kindLabel) { + try { + // 用服务端已保存的配置校验开关,只提供手动操作入口,绝不自动执行联动 + const config = await this.request('/api/config'); + const ratingToDislike = config['subsonic.linkRatingToDislike'] === true; + const dislikeToRating = true; + const threshold = Number(config['subsonic.dislikeRating'] ?? 1); + if ((!ratingToDislike && !dislikeToRating) || !(threshold > 0)) return; + const dirs = [ratingToDislike ? '评星 → 不喜欢' : '', dislikeToRating ? '不喜欢 → 评星' : ''] + .filter(Boolean).join(' / '); + const go = await showSelect('回滚完成,联动数据未自动同步', + `已回滚用户 ${this.escapeHtml(user)} 的「${this.escapeHtml(kindLabel)}」数据。\n\n` + + `检测到联动开关已开启(${dirs})。导入 / 回滚不会自动补另一侧数据,如需对齐请前往 Subsonic 配置页手动点击「同步」。`, + { confirmText: '前往配置', cancelText: '稍后处理' }); + if (go) await this.jumpToLinkSync(user); + } catch (e) { + console.warn('[LinkSync] 联动提示检查失败:', e); + } + } + + async jumpToLinkSync(user) { + this.pendingLinkSyncUser = user; + await this.switchView('subsonic'); + setTimeout(() => { + const target = document.getElementById('link-sync-field'); + if (!target) return; + target.scrollIntoView({ behavior: 'smooth', block: 'center' }); + target.style.outline = '2px solid var(--accent)'; + target.style.outlineOffset = '4px'; + setTimeout(() => { target.style.outline = 'none'; }, 2000); + }, 350); + } + async loadLogs(isAuto = false) { const logType = document.getElementById('log-type-select')?.value || 'app'; @@ -3588,11 +4053,11 @@ class App { container.scrollTop = container.scrollHeight; } } else { - container.innerHTML = '

暂无日志数据

'; + container.innerHTML = '

暂无日志数据

'; } } catch (err) { if (!isAuto) { - document.getElementById('logs-content').innerHTML = '

加载日志失败

'; + document.getElementById('logs-content').innerHTML = '

加载日志失败

'; } } } @@ -3629,35 +4094,35 @@ class App { const isChecked = (config['backup.' + destination + '.types'] || []).includes(type.id); return '' + '' + - ''; + ''; }).join(''); return '' + '' + - '
' + this.escapeHtml(type.label) + '
' + - '
' + this.escapeHtml(type.description) + '
' + + '
' + this.escapeHtml(type.label) + '
' + + '
' + this.escapeHtml(type.description) + '
' + '' + cells + - ''; + ''; }).join('') + '
'; - const form = document.getElementById('config-form'); + const webdavForm = document.getElementById('webdav-form') || document.getElementById('config-form'); ['backup.webdav.mirrorDeletes', 'backup.webdav.restoreOnStartup'].forEach(key => { - if (form && form.elements[key]) { - form.elements[key].checked = config[key] === true; + if (webdavForm && webdavForm.elements[key]) { + webdavForm.elements[key].checked = config[key] === true; } }); } collectBackupPolicy() { if (!this._backupTypesLoaded) return {}; - const fd = new FormData(document.getElementById('config-form')); + const policyForm = document.getElementById('backup-policy-form') || document.getElementById('config-form'); + if (!policyForm) return {}; + const fd = new FormData(policyForm); return { 'backup.webdav.types': fd.getAll('backup.webdav.types'), 'backup.openlist.types': fd.getAll('backup.openlist.types'), - 'backup.webdav.mirrorDeletes': fd.get('backup.webdav.mirrorDeletes') === 'on', - 'backup.webdav.restoreOnStartup': fd.get('backup.webdav.restoreOnStartup') === 'on', }; } @@ -3665,95 +4130,372 @@ class App { if (!this._backupTypesLoaded) return; const status = document.getElementById('backup-policy-status'); try { - const result = await this.request('/api/config', { method: 'POST', body: JSON.stringify(this.collectBackupPolicy()) }); - status.textContent = result.warning || '备份范围已写入 config.js。云端已有副本保持不变。'; - } catch (e) { status.textContent = '保存失败:' + e.message; } + const payload = this.collectBackupPolicy(); + const result = await this.request('/api/config', { method: 'POST', body: JSON.stringify(payload) }); + if (this.loadedConfig) Object.assign(this.loadedConfig, payload); + this.markBackupDirty('backup-policy-form', false); + if (status) { + status.classList.remove('is-error', 'is-dirty'); + status.textContent = result.warning || '备份范围已写入 config.js。云端已有副本保持不变。'; + } + if (window.showSuccess) window.showSuccess('云端备份范围已保存'); + } catch (e) { + if (status) { + status.classList.add('is-error'); + status.textContent = '保存失败:' + e.message; + } + if (window.showError) window.showError('保存失败:' + e.message); + } } async restoreOpenlistBackup() { - if (!(await showSelect('恢复 OpenList 数据备份', '按系统设置中已保存的备份类型,从 OpenList 恢复同名文件。会覆盖本地对应文件,保留本地独有文件;不会上传文件。恢复后需重启服务。', { danger: true }))) return; - try { - const result = await this.request('/api/openlist/restore', { method: 'POST' }); - if (!result.success) throw new Error((result.errors || []).join(';') || result.error || '云端没有所选类型的文件'); - showSuccess('已恢复 ' + result.restored + ' 个文件。请重启服务完整加载数据。'); - } catch (e) { showError('恢复失败:' + e.message); } + await this.openBackupCenter('restore', 'openlist'); } - async loadOpenlistConfig() { - try { - const config = await this.request('/api/config') - const form = document.getElementById('openlist-form') - if (!form) return - form.elements['openlist.enable'].checked = config['openlist.enable'] === true - form.elements['openlist.endpoint'].value = config['openlist.endpoint'] || '' - form.elements['openlist.token'].value = config['openlist.token'] || '' - form.elements['openlist.root'].value = config['openlist.root'] || '/lx_music' - form.elements['openlist.externalUrl'].value = config['openlist.externalUrl'] || '' - form.elements['openlist.includeNonAudio'].checked = config['openlist.includeNonAudio'] === true - form.elements['openlist.verifyInterval'].value = config['openlist.verifyInterval'] || 5 - form.elements['openlist.syncInterval'].value = config['openlist.syncInterval'] || 60 - this.toggleOpenlistVisibility() - form.elements['openlist.enable'].onchange = () => this.toggleOpenlistVisibility() - - let users = [] - this._olbUsersLoaded = true - try { - const ulist = await this.request('/api/users') - users = (ulist || []).map(u => u.name).filter(Boolean) - } catch (e) { - this._olbUsersLoaded = false - console.warn('获取用户列表失败', e) - if (window.showError) window.showError('获取用户列表失败,OpenList 用户备份配置可能不准确:' + (e && e.message || e)) + initOpenlistRestore(config) { + const form = document.getElementById('openlist-restore-form'); + if (!form) return; + this._openlistRestoreSupported = config.openlistRestoreVersion >= 2; + const sourceText = (config['openlist.root'] || '/lx_music').replace(/\/+$/, '') + '/_data'; + const sourceEl = document.getElementById('openlist-restore-source'); + if (sourceEl) sourceEl.textContent = sourceText; + if (this._openlistRestoreInitialized) return; + const types = config.backupTypeInfo || []; + const saved = config['backup.openlist.types'] || []; + const typeMeta = { + music: { + bg: 'var(--accent-soft)', + color: 'var(--accent-text)', + svg: '' + }, + userFiles: { + bg: 'var(--success-soft)', + color: 'var(--info-text)', + svg: '' + }, + snapshots: { + bg: 'var(--success-soft)', + color: 'var(--success-text)', + svg: '' + }, + userData: { + bg: 'var(--accent-soft)', + color: 'var(--accent-text)', + svg: '' + }, + system: { + bg: 'var(--danger-soft)', + color: 'var(--warning-text)', + svg: '' } - this.renderOpenlistUserConfig(users, config['openlist.userBackups']) - this.loadOpenlistBackups() - } catch (e) { - console.error(e) - if (window.showError) window.showError('加载 OpenList 配置失败: ' + (e && e.message || e)) + }; + + const typesContainer = document.getElementById('openlist-restore-types'); + if (typesContainer) { + typesContainer.innerHTML = types.map(type => { + const meta = typeMeta[type.id] || { bg: 'var(--accent-soft)', color: 'var(--accent-text)', svg: '' }; + const isChecked = saved.includes(type.id); + return ''; + }).join(''); } + + form.addEventListener('input', () => this.invalidateOpenlistRestore()); + form.addEventListener('change', () => this.invalidateOpenlistRestore()); + this._openlistRestoreInitialized = true; } - async saveOpenlistConfig() { - const form = document.getElementById('openlist-form') - if (!form) return - const fd = new FormData(form) - const userBackupsRaw = this.collectOpenlistUserBackups() - const cfg = { - 'openlist.enable': fd.get('openlist.enable') === 'on', - 'openlist.endpoint': (fd.get('openlist.endpoint') || '').trim(), - 'openlist.token': fd.get('openlist.token'), - 'openlist.root': (fd.get('openlist.root') || '').trim() || '/lx_music', - 'openlist.includeNonAudio': fd.get('openlist.includeNonAudio') === 'on', - 'openlist.externalUrl': (fd.get('openlist.externalUrl') || '').trim(), - 'openlist.verifyInterval': parseInt(fd.get('openlist.verifyInterval')) || 5, - 'openlist.syncInterval': parseInt(fd.get('openlist.syncInterval')) || 60, - // 仅当用户列表成功加载后才提交备份配置,避免失败时以空选择覆盖已保存规则 - 'openlist.userBackups': this._olbUsersLoaded ? userBackupsRaw : undefined, - } - // 仅当用户列表成功加载时,才根据「上传后删除」模式自动开启 Subsonic「缓存优先时优先使用 OpenList」, - // 避免本地被删后下次播放回源重下,而是直接命中 OpenList 直链(需 Subsonic「缓存优先」也已开启)。 - if (this._olbUsersLoaded) { - try { - const arr = JSON.parse(userBackupsRaw) - if (cfg['openlist.enable'] && Array.isArray(arr) && arr.some(b => b && b.enabled && b.backupCache === true && b.mode === 'upload-delete')) { - cfg['subsonic.playCacheFirst'] = true - cfg['subsonic.playCacheFirstUseOpenlist'] = true - } - } catch {} + invalidateOpenlistRestore() { + this._openlistRestorePlan = null; + const button = document.getElementById('openlist-restore-run'); + if (button) button.disabled = true; + const result = document.getElementById('openlist-restore-preview'); + if (result) { + result.innerHTML = '
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '恢复设置已改变,请先扫描预览' + + '

选项发生变动,请点击「扫描并预览差异」重新生成文件比对分析与恢复计划。

' + + '
' + + '
'; } + } + + collectOpenlistRestore() { + const fd = new FormData(document.getElementById('openlist-restore-form')); + const types = fd.getAll('types'); + if (!types.length) throw new Error('请至少勾选一种要恢复的内容分类'); + return { types, mode: fd.get('mode'), backupBeforeRestore: fd.get('backupBeforeRestore') === 'on', snapshotId: fd.get('snapshotId') || undefined }; + } + + async previewOpenlistRestore() { + if (this._openlistRestoreBusy) return; + this.invalidateOpenlistRestore(); + const box = document.getElementById('openlist-restore-preview'); try { - const result = await this.request('/api/config', { method: 'POST', body: JSON.stringify(cfg) }) - if (result.warning) { - if (window.showInfo) window.showInfo(result.warning) - else alert(result.warning) - return - } - if (window.showSuccess) window.showSuccess('OpenList 配置已保存') - else alert('OpenList 配置已保存') + if (!this._openlistRestoreSupported) throw new Error('当前服务仍是旧版恢复接口,请重启服务后重新加载本页。尚未执行任何恢复。'); + const options = this.collectOpenlistRestore(); + this._openlistRestoreBusy = true; + box.innerHTML = '
' + + '
' + + '
' + + '正在扫描云端备份清单与文件指纹...' + + '

正在比对本地现有数据与云端差异,此操作为只读安全预检,绝不改动任何本地文件。

' + + '
' + + '
'; + const result = await this.request('/api/openlist/restore', { method: 'POST', body: JSON.stringify({ ...options, preview: true }) }); + if (!result.success) throw new Error((result.errors || []).join(';') || result.error || '云端没有所选类型的文件'); + if (JSON.stringify(options) !== JSON.stringify(this.collectOpenlistRestore())) throw new Error('扫描期间设置已变化,请重新扫描'); + this._openlistRestorePlan = { options, token: result.planToken }; + const esc = value => this.escapeHtml(String(value)); + const actions = { add: '新增', overwrite: '覆盖', skip: '跳过', merge: '合并索引' }; + const actionClasses = { add: 'is-add', overwrite: 'is-overwrite', skip: 'is-skip', merge: 'is-merge' }; + + box.innerHTML = '
' + + '
' + + '' + result.added + '' + + '将新增文件' + + '
' + + '
' + + '' + result.overwritten + '' + + '将覆盖替换' + + '
' + + '
' + + '' + (result.merged || 0) + '' + + '合并索引' + + '
' + + '
' + + '' + result.skipped + '' + + '安全跳过' + + '
' + + '
' + + '
' + + '' + + '预检完成:共比对 ' + result.files.length + ' 个文件。本地独有文件均安全保留,不会向云端写回。' + + '
' + + (result.warnings || []).map(w => '
' + esc(w) + '
').join('') + + '
' + + '' + + '' + + '' + + result.files.map(file => '').join('') + + '
操作类型备份文件相对路径
' + (actions[file.action] || esc(file.action)) + '' + esc(file.path) + '
'; + document.getElementById('openlist-restore-run').disabled = result.added + result.overwritten === 0; } catch (e) { - console.error(e) - if (window.showError) window.showError('保存失败: ' + (e && e.message || e)) + box.innerHTML = '
' + + '' + + '
扫描预检失败

' + this.escapeHtml(e.message) + '

' + + '
'; + } + finally { this._openlistRestoreBusy = false; } + } + + async executeOpenlistRestore() { + if (this._openlistRestoreBusy || !this._openlistRestorePlan) return; + const plan = this._openlistRestorePlan; + if (JSON.stringify(plan.options) !== JSON.stringify(this.collectOpenlistRestore())) { this.invalidateOpenlistRestore(); return; } + const modeText = plan.options.mode === 'missing' ? '补回缺失文件,并合并新增歌曲/快照的索引,保留已有业务条目。' : '整份替换同名文件,不合并 JSON 或歌单内容。'; + const backupText = plan.options.backupBeforeRestore ? '成功后保留恢复前副本,可在任务记录中随时一键回退。' : '成功后不再保留回退副本。'; + if (!(await showSelect('确认从 OpenList 恢复', modeText + '\n' + backupText + '\n\n全部文件下载校验后再提交;提交失败会自动尝试回退。提交后服务端将进入维护状态,需重启服务生效。', { danger: true }))) return; + if (plan !== this._openlistRestorePlan || this._openlistRestoreBusy) return; + this._openlistRestoreBusy = true; + const box = document.getElementById('openlist-restore-preview'); + document.getElementById('openlist-restore-run').disabled = true; + box.innerHTML = '
' + + '
' + + '
正在初始化恢复任务并拉取文件清单...

任务由后台托管,请勿重复操作

' + + '
'; + try { + const result = await this.request('/api/openlist/restore/jobs/start', { method: 'POST', body: JSON.stringify({ ...plan.options, planToken: plan.token }) }); + if (!result.success) throw new Error(result.error || '创建恢复任务失败'); + box.innerHTML = '
' + + '' + + '
恢复任务已提交并启动

正在后台下载校验文件,请在下方实时查看任务进度与本地副本状态。任务生效后请重启服务。

' + + '
'; + await this.loadOpenlistRecoveryHistory(); + this.startOpenlistRestorePolling(); + } catch (e) { + box.innerHTML = '
' + + '' + + '
创建恢复任务失败

' + this.escapeHtml(e.message) + '

' + + '
'; + showError('恢复失败:' + e.message); + } + finally { this._openlistRestoreBusy = false; this._openlistRestorePlan = null; } + } + + async loadOpenlistRecoveryHistory() { + if (!this._openlistRestoreSupported || this._openlistHistoryLoading) return; + this._openlistHistoryLoading = true; + const box = document.getElementById('openlist-restore-tasks'); + const esc = value => this.escapeHtml(String(value)); + const labels = { + pending: '等待扫描', + downloading: '正在下载校验', + ready: '校验就绪 · 等待提交', + committing: '正在提交写入', + completed: '已完成 · 请重启服务', + failed: '恢复失败 · 可重试', + cancelled: '已取消', + interrupted: '进程中断 · 可续传', + 'recovery-required': '提交中断 · 需先回退', + 'rolled-back': '已回退副本 · 请重启' + }; + const badgeClasses = { + pending: 'is-pending', + downloading: 'is-running', + ready: 'is-ready', + committing: 'is-committing', + completed: 'is-completed', + failed: 'is-failed', + cancelled: 'is-cancelled', + interrupted: 'is-interrupted', + 'recovery-required': 'is-danger', + 'rolled-back': 'is-rolled-back' + }; + const badgeIcons = { + completed: '', + downloading: '', + committing: '', + failed: '', + 'recovery-required': '', + 'rolled-back': '' + }; + + try { + const result = await this.request('/api/openlist/restore/history'); + const select = document.getElementById('openlist-restore-version'); + const selected = select.value; + select.innerHTML = '' + (result.versions || []).map(v => '').join(''); + if ([...select.options].some(o => o.value === selected)) select.value = selected; + else if (selected) this.invalidateOpenlistRestore(); + const tasks = result.tasks || []; + + if (!tasks.length) { + box.innerHTML = (result.warning ? '
' + esc(result.warning) + '
' : '') + + '
' + + '' + + '暂无历史恢复任务。执行恢复后,本地副本状态与进度将在此集中管理。' + + '
'; + } else { + box.innerHTML = (result.warning ? '
' + esc(result.warning) + '
' : '') + + tasks.map(t => { + const active = ['pending', 'downloading', 'ready', 'committing'].includes(t.state); + const bClass = badgeClasses[t.state] || 'is-pending'; + const bIcon = badgeIcons[t.state] || ''; + const total = t.total || 0; + const downloaded = t.downloaded || 0; + const applied = t.applied || 0; + const percent = total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : (t.state === 'completed' ? 100 : 0); + + let actionsHtml = ''; + if (active && t.state !== 'committing') { + actionsHtml += ''; + } + if (['failed', 'cancelled', 'interrupted'].includes(t.state)) { + actionsHtml += ''; + } + if (t.canRollback || t.state === 'recovery-required') { + actionsHtml += ''; + } + if (!active && t.state !== 'recovery-required') { + actionsHtml += ''; + } + + const errorsHtml = (t.errors || []).length ? '
' + + t.errors.map(e => '

' + esc(e) + '

').join('') + + '
' : ''; + + return '
' + + '
' + + '
' + + '
' + + '' + + '' + esc(new Date(t.createdAt).toLocaleString()) + '' + + '
' + + 'ID: ' + esc(t.id.slice(0, 8)) + '' + + '
' + + '
' + + bIcon + + '' + esc(labels[t.state] || t.state) + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '下载校验进度' + + '' + esc(downloaded) + ' / ' + esc(total) + ' 文件 (' + percent + '%)' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '已提交生效' + + '' + esc(applied) + ' 个文件' + + '
' + + (t.canRollback ? '
' + + '' + + '已保留本地快照副本(支持一键回退)' + + '
' : '') + + '
' + + errorsHtml + + '
' + + (actionsHtml ? '
' + actionsHtml + '
' : '') + + '
'; + }).join(''); + } + + if (tasks.some(t => ['pending', 'downloading', 'ready', 'committing'].includes(t.state))) this.startOpenlistRestorePolling(); + else { clearInterval(this._openlistRestoreTimer); this._openlistRestoreTimer = null; } + } catch (e) { + box.innerHTML = '
加载恢复任务失败

' + this.escapeHtml(e.message) + '

'; } + finally { this._openlistHistoryLoading = false; } + } + + startOpenlistRestorePolling() { + if (this._openlistRestoreTimer) return; + this._openlistRestoreTimer = setInterval(() => { + if (this.currentView !== 'backups') { clearInterval(this._openlistRestoreTimer); this._openlistRestoreTimer = null; return; } + this.loadOpenlistRecoveryHistory(); + }, 2000); + } + + async openlistRestoreTaskAction(id, action) { + if (['delete', 'rollback'].includes(action) && !(await showSelect(action === 'delete' ? '删除本地任务及副本' : '回退本次恢复', action === 'delete' ? '删除后不能再使用此任务继续或回退。不删除云端历史备份。' : '恢复被替换文件,并移除本次新增文件。文件有后续修改时会阻止自动回退。完成后需重启服务。', { danger: true }))) return; + try { + await this.request('/api/openlist/restore/jobs/' + encodeURIComponent(id) + (action === 'delete' ? '' : '/' + action), { method: action === 'delete' ? 'DELETE' : 'POST' }); + await this.loadOpenlistRecoveryHistory(); + } catch (e) { showError(e.message); } + } + + async loadOpenlistConfig() { + await this.loadBackupSettings(false); + } + + async saveOpenlistConfig() { + await this.saveOpenlistConnection(); } toggleOpenlistVisibility() { @@ -3761,19 +4503,33 @@ class App { const enabled = !!(form && form.elements['openlist.enable'] && form.elements['openlist.enable'].checked) const opts = document.getElementById('openlist-options') const hint = document.getElementById('openlist-disabled-hint') + const cacheHint = document.getElementById('openlist-cache-disabled-hint') if (opts) opts.style.display = enabled ? 'block' : 'none' if (hint) hint.style.display = enabled ? 'none' : 'block' + if (cacheHint) cacheHint.style.display = enabled ? 'none' : 'block' + const active = enabled && this.loadedConfig?.['openlist.enable'] === true + for (const id of ['openlist-operation-panel', 'openlist-cache-form', 'openlist-cache-shortcut']) { + const element = document.getElementById(id) + if (element) element.style.display = active ? '' : 'none' + } + if (hint && !enabled) hint.textContent = this.loadedConfig?.['openlist.enable'] === true + ? '尚未保存关闭操作,后台仍按已保存设置运行。点击“保存 OpenList 设置”后才会停止服务;已有连接与缓存规则会保留。' + : 'OpenList 备份服务已关闭。连接参数、立即操作和播放缓存设置已隐藏;原有设置保留,开启并保存后可继续使用。' + } renderOpenlistUserConfig(users, userBackupsRaw) { let backups = [] - try { backups = JSON.parse(userBackupsRaw || '[]') } catch {} + try { backups = JSON.parse(userBackupsRaw || '[]') } catch { } const selected = backups.map(b => b.user) const labelMap = {} users.forEach(u => { labelMap[u] = u }) this.renderSortableTagGroup('tag-group-openlist-users', 'openlist.userBackupUsers', selected, labelMap, users, true) const container = document.getElementById('tag-group-openlist-users') - if (container) container.onclick = () => setTimeout(() => this.renderOpenlistUserDirs(backups), 0) + if (container) container.onclick = () => setTimeout(() => { + this.renderOpenlistUserDirs(backups) + this.markBackupDirty('openlist-cache-form', true) + }, 0) this.renderOpenlistUserDirs(backups) } @@ -3783,7 +4539,7 @@ class App { const dirsContainer = document.getElementById('openlist-user-dirs') if (!dirsContainer) return const map = {} - ;(backups || []).forEach(b => { map[b.user] = b }) + ; (backups || []).forEach(b => { map[b.user] = b }) const prev = {} selected.forEach(u => { const d = document.getElementById('openlist-dl-sw-' + u) @@ -3793,12 +4549,18 @@ class App { prev[u] = { download: d ? d.checked : null, cache: c ? c.checked : null, snapshot: s ? s.checked : null, mode: m ? m.value : '' } }) const modeOptions = (sel) => ([ - ['upload', '上传(保留本地)'], - ['upload-delete', '上传后删除本地'], - ['download', '下载(远端→本地)'], - ['sync', '双向同步'], + ['upload', '缓存上传到 OpenList,保留本地'], + ['upload-delete', '缓存上传并校验后,删除本地缓存'], + ['download', '从 OpenList 下载缓存到本地'], + ['sync', '本地缓存与 OpenList 双向同步'], ]).map(([v, t]) => '').join('') - const modeHelp = '上传:本地新增自动上传,保留本地;上传后删除:上传完成即删本地并清缓存索引;下载:仅把云端该用户目录拉到本地并补缓存;双向同步:补齐缓存并同步修改;本地缓存淘汰不会删除远端文件' + const modeShortLabel = { + 'upload': '仅上传保留本地', + 'upload-delete': '上传后删本地', + 'download': '仅下载到本地', + 'sync': '双向同步' + } + const modeHelp = '仅对该用户服务器上的播放缓存生效。上传:复制到 OpenList 并保留本地;上传并删除:校验内容后删除本地缓存及已上传附件;下载:只从 OpenList 取回缓存,不上传;双向同步:两边补齐并同步变化。本地缓存被清理不会删除远端副本。不会自动下载所有歌单歌曲,也不影响配置、歌单、用户数据等常规备份。' const toggleHtml = (id, checked) => '
' return } - const statusMap = { pending: '待上传', uploading: '进行中', completed: '已完成', verified: '已验证', failed: '失败', deleted: '已删除' } - const badgeColor = { pending: '#64748b', uploading: '#3b82f6', completed: '#0ea5e9', verified: '#22c55e', failed: '#ef4444', deleted: '#94a3b8' } + const statusMap = { pending: '待上传', download: '待下载', uploading: '传输中', completed: '已备份', attention: '需处理', paused: '已暂停', failed: '失败', deleted: '已删除' } + const badgeColor = { pending: 'var(--text-2)', uploading: 'var(--info-text)', completed: 'var(--info-text)', download: 'var(--accent-text)', attention: 'var(--warning-text)', paused: 'var(--text-2)', failed: 'var(--danger-text)', deleted: 'var(--text-2)' } const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') const fmtName = (f) => { const nm = (f.name || '').toString().trim() - const base = nm || (f.local_path ? f.local_path.split(/[\\/]/).pop() : '') || '(未知)' + const base = nm ? nm.split(/[\\/]/).pop() : ((f.local_path ? f.local_path.split(/[\\/]/).pop() : '') || '(未知)') return esc(base) } const fmtSize = (n) => { @@ -4002,66 +5015,93 @@ class App { this._olbPageIds = list.slice(start, start + pageSize).map(f => f.id) const rows = list.slice(start, start + pageSize).map(f => { const d = new Date(f.updated_at || f.created_at || Date.now()) - const color = badgeColor[f.status] || '#64748b' + const color = badgeColor[f.status] || 'var(--text-2)' const label = statusMap[f.status] || f.status || '' - const typeLabel = f.folder_type === 'data' ? '数据备份' : f.folder_type === 'music' ? '下载' : (f.folder_type === 'cache' ? '缓存' : (f.folder_type === 'list' ? '快照' : (f.folder_type || ''))) - const canRetry = f.status !== 'verified' && f.status !== 'deleted' + let typeLabel + if (this._olbFolderScope && this._olbFolderScope.split(',').includes('list')) { + const st = this.olbSnapshotType(f) + typeLabel = st === 'dislike' ? '我不喜欢' : (st === 'list' ? '我的歌单' : (f.folder_type === 'list' ? '我的歌单' : '数据备份')) + } else { + typeLabel = f.folder_type === 'data' ? ({ music: '歌曲与歌词', system: '系统与账户', userFiles: '用户音源文件', userData: '用户数据', snapshots: '快照' }[f.backup_type] || '数据备份') : f.folder_type === 'music' ? '下载' : (f.folder_type === 'cache' ? '缓存' : (f.folder_type === 'list' ? '快照' : (f.folder_type || ''))) + } + const canRetry = ['failed', 'pending', 'download', 'attention'].includes(f.status) const retryBtn = canRetry - ? '' - : '—' + ? '' + : '' const hasLocal = !!f.local_exists - const localMark = hasLocal ? '✓' : '✗' - const pathStr = f.local_path || f.remote_path || '' - const errStr = f.error || '' + const localMark = f.local_exists == null ? '未知' : hasLocal + ? '✓ 存在' + : '✗ 已删' + const errStr = f.remote_state === 'unavailable' ? '等待 OpenList 连接恢复,历史备份记录保留' : f.error || f.remote_error || '' const errCell = errStr - ? '' + esc(errStr.length > 50 ? errStr.slice(0, 50) + '…' : errStr) + '' - : '—' + ? '' + esc(errStr.length > 46 ? errStr.slice(0, 46) + '…' : errStr) + '' + : '—' const sel = this._olbSelected || (this._olbSelected = new Set()) - const checked = sel.has(f.id) ? 'checked' : '' - return '' + - '' + + const isSelected = sel.has(f.id) + const checked = isSelected ? 'checked' : '' + return '' + + '' + '' + esc(f.user || '') + '' + - '' + typeLabel + '' + - '' + fmtName(f) + '' + - '' + label + '' + - '' + fmtSize(f.size) + '' + - '' + localMark + '' + - '' + errCell + '' + - '' + d.toLocaleString() + '' + - '' + retryBtn + '' + - '' + esc(pathStr) + '' + + '' + esc(typeLabel) + '' + + '' + fmtName(f) + '' + + '
' + label + '' + this.openlistStateTags(f) + '
' + + '' + fmtSize(f.size) + '' + + '' + localMark + '' + + '' + errCell + '' + + '' + d.toLocaleString('zh-CN', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }) + '' + + '
' + retryBtn + '
' + '' }).join('') const pager = '
' + - '' + - '' + - '第 ' + this._olbPage + ' / ' + totalPages + ' 页(共 ' + total + ' 条)' + - '' + - '' + - '
' + '
' + + '显示第 ' + (start + 1) + '–' + Math.min(start + pageSize, total) + ' 条,共 ' + total + ' 条记录(第 ' + this._olbPage + ' / ' + totalPages + ' 页)' + + '
' + + '
' + + '' + + '' + + '' + this._olbPage + ' / ' + totalPages + '' + + '' + + '' + + '' + + '
' + const currentSort = document.getElementById('olb-filter-sort')?.value || 'time-desc'; + const heading = (field, label, width) => { + const active = currentSort.startsWith(field + '-'); + const direction = currentSort.endsWith('-asc') ? 'ascending' : 'descending'; + return ''; + }; box.innerHTML = '
' + - '' + + '
' + '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + + '' + + heading('user', '用户', 88) + + heading('type', '类型', 96) + + heading('name', '文件名', 260) + + heading('status', '任务状态 / 文件检查 / 内容校验', 340) + + heading('size', '大小', 88) + + heading('local', '本地', 76) + + heading('error', '异常原因', 160) + + heading('time', '更新时间', 152) + + '' + '' + rows + '
用户类型文件名状态大小本地异常原因更新时间操作云端路径操作
' + pager + this.initCustomSelects() this.updateOlbSelCount() } catch (e) { console.error(e) - box.innerHTML = '
加载失败: ' + (e && e.message || e) + '
' + box.innerHTML = '
加载失败: ' + (e && e.message || e) + '
' + } + } + + async checkOpenlistBackup(id) { + try { + await this.request('/api/openlist/metadata/start', { method: 'POST', body: JSON.stringify({ target: 'both', ids: [id] }) }); + await this.loadOpenlistMetadataStatus(); + } catch (error) { + if (window.showError) window.showError('无法开始检查:' + error.message); } } @@ -4080,10 +5120,216 @@ class App { } } + async retrySelectedOpenlist() { + if (this._olbRetryBusy || !this._olbSelected || !this._olbSelected.size) return + const ids = Array.from(this._olbSelected) + this._olbRetryBusy = true + this.updateOlbSelCount?.() + try { + if (!(await showSelect('批量重试 OpenList 文件', '将对选中的 ' + ids.length + ' 条记录按已保存规则重新上传、下载或检查。\n\n选择上传后清理模式的播放缓存,安全校验通过后可能删除本地副本。', { danger: true, confirmText: '确认重试', cancelText: '取消' }))) return + const r = await this.request('/api/openlist/retry-batch', { method: 'POST', body: JSON.stringify({ ids }) }) + if (r && r.success) { + if (window.showInfo) window.showInfo('已提交 ' + (r.retried || 0) + ' 条重试' + ((r.failed ? (',' + r.failed + ' 条失败') : ''))) + this.loadOpenlistBackups(true) + } else if (window.showError) { + window.showError('批量重试失败: ' + ((r && (r.error || r.message)) || '未知错误')) + } + } catch (e) { + console.error(e) + if (window.showError) window.showError('批量重试失败: ' + (e && e.message || e)) + } finally { + this._olbRetryBusy = false + this.updateOlbSelCount?.() + } + } + + async showOpenlistDetail(id) { + const requestToken = this._olbDetailRequest = (this._olbDetailRequest || 0) + 1 + let response + try { response = await this.request('/api/openlist/backups') } + catch { if (window.showError) window.showError('无法获取最新文件状态,请稍后重试'); return } + if (requestToken !== this._olbDetailRequest) return + this._olbRawRecords = response.list || [] + await this.loadOpenlistBackups(true, true) + const f = this._olbRawRecords.find(x => x.id === id) + if (f) f.status = f.task_state || (['completed', 'verified'].includes(f.status) ? 'completed' : f.status) + if (!f) { if (window.showError) window.showError('未找到该记录详情'); return } + const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') + const fmtSize = (n) => { n = Number(n) || 0; if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; return (n / 1024 / 1024).toFixed(1) + ' MB' } + const row = (k, v) => '' + esc(k) + '' + (v === '' || v == null ? '—' : esc(v)) + '' + const optional = (k, v) => v == null || String(v).trim() === '' ? '' : row(k, v) + const fmtTime = value => { + if (value == null || value === '') return '' + const numeric = Number(value) + const date = new Date(Number.isFinite(numeric) ? (Math.abs(numeric) < 1e11 ? numeric * 1000 : numeric) : value) + if (!Number.isFinite(date.getTime())) return '' + const pad = n => String(n).padStart(2, '0') + return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes()) + ':' + pad(date.getSeconds()) + '(本地时间)' + } + const statusLabels = { pending: '待上传', download: '待下载', uploading: '传输中', completed: '已备份', attention: '需处理', paused: '已暂停', failed: '失败', deleted: '已删除' } + const folderLabels = { data: '数据备份', cache: '播放缓存', music: '用户下载', list: '歌单快照' } + const sourceLabels = { wy: '网易云音乐', tx: 'QQ 音乐', kw: '酷我音乐', kg: '酷狗音乐', mg: '咪咕音乐' } + const filename = String(f.name || f.local_path || '').split(/[\\/]/).pop() || '' + const isAudio = /\.(mp3|flac|m4a|aac|ogg|opus|wav|wma|ape|alac|aiff|aif|dsf|dff)$/i.test(filename) + const fileKind = isAudio ? '音频文件' : /\.lrc$/i.test(filename) ? '歌词文件' : /\.json$/i.test(filename) ? 'JSON 数据文件' : /\.(jpg|jpeg|png|webp|gif|avif)$/i.test(filename) ? '图片文件' : '普通文件' + + let metaObj = null + if (f.cache_metadata) { + try { + metaObj = typeof f.cache_metadata === 'string' ? JSON.parse(f.cache_metadata) : f.cache_metadata + } catch { metaObj = null } + } + + // 提取封面图片 URL (网易/QQ 等常为 img 或 pic,或本地缓存封面) + const coverUrl = metaObj && (metaObj.img || metaObj.pic || metaObj.cover || metaObj.albumCover) + + // 渲染缓存元数据为现代 Tag 风格 + let metaHtml = '' + if (metaObj && typeof metaObj === 'object' && Object.keys(metaObj).length) { + const tagEntries = [] + // 常见核心字段优先展示 + const keyLabelMap = { + id: '歌曲 ID', + songmid: '平台 MID', + name: '歌名', + singer: '歌手', + album: '专辑', + albumId: '专辑 ID', + interval: '时长', + source: '来源', + quality: '音质', + bitrate: '比特率', + sampleRate: '采样率', + audioContainer: '格式', + ext: '扩展名', + size: '大小', + hasCover: '含封面', + hasLyric: '含歌词', + hasEmbedLyric: '内嵌歌词', + lyricFilename: '歌词文件', + coverType: '封面来源', + mtime: '修改时间', + subPath: '相对子路径', + } + + // 按类型赋予不同颜色的 tag 样式 + const getTagColorClass = (k, v) => { + if (typeof v === 'boolean') return v ? 'ol-meta-tag-bool-true' : 'ol-meta-tag-bool-false' + if (['source', 'quality', 'bitrate', 'audioContainer'].includes(k)) return 'ol-meta-tag-tech' + if (['singer', 'album', 'name'].includes(k)) return 'ol-meta-tag-primary' + if (['interval', 'size'].includes(k)) return 'ol-meta-tag-info' + return 'ol-meta-tag-default' + } + + // 去重:与上方表格重复或内部冗余的字段不再以 tag 展示 + const skipKeys = new Set(['img', 'pic', 'cover', 'albumCover', 'size', 'filename', 'folder', 'mtime', 'subPath', 'ext', 'coverCheckedVersion', 'coverCheckedMtime', 'coverCheckedSize', 'metadataWritable', 'lyricFilename', 'nativeIds']) + if (metaObj.songmid === metaObj.id || !metaObj.id) skipKeys.add('songmid') + if (metaObj.requestedSource === metaObj.source) skipKeys.add('requestedSource') + if (metaObj.downloadSource === metaObj.source) skipKeys.add('downloadSource') + if (metaObj.quality === f.quality) skipKeys.add('quality') + if (metaObj.source === f.source) skipKeys.add('source') + if (metaObj.id === f.songmid) skipKeys.add('id') + keyLabelMap.requestedSource = '请求来源' + keyLabelMap.downloadSource = '下载来源' + keyLabelMap.coverCheckedVersion = '封面校验版本' + for (const [key, val] of Object.entries(metaObj)) { + if (skipKeys.has(key) || val === '' || val == null) continue + if (key === 'hasLyric' && metaObj.hasEmbedLyric && val === true) continue + const displayKey = keyLabelMap[key] || key + let displayVal = val + if (typeof val === 'boolean') displayVal = val ? '是' : '否' + else if (typeof val === 'object' && val !== null) displayVal = JSON.stringify(val) + else if (key === 'size') displayVal = fmtSize(val) + else if (key === 'bitrate' && typeof val === 'number') displayVal = val + ' kbps' + else if (key === 'sampleRate' && typeof val === 'number') displayVal = val + ' Hz' + + const colorCls = getTagColorClass(key, val) + tagEntries.push(`
${esc(displayKey)}${esc(displayVal)}
`) + } + + metaHtml = ` +
+ ${coverUrl ? ` +
+ 封面预览 + 封面预览 +
` : ''} +
+ ${tagEntries.join('')} +
+
` + } + + // 无缓存元数据的音频(如数据备份里的 flac):由记录字段与文件名推导 tag + if (!metaHtml && isAudio) { + const ext = (filename.match(/\.([^.]+)$/) || [])[1] || '' + const stem = filename.replace(/\.[^.]+$/, '').replace(/\s*\([a-z]{2}_[^)]+\)\s*$/i, '') + const parts = stem.split(' - ') + const tags = [] + const mk = (cls, k, v) => v ? `
${esc(k)}${esc(v)}
` : '' + if (parts.length >= 2) { + tags.push(mk('ol-meta-tag-primary', '歌名', parts[0]), mk('ol-meta-tag-primary', '歌手', parts[1])) + if (parts.length >= 4) tags.push(mk('ol-meta-tag-primary', '专辑', parts.slice(3).join(' - '))) + if (parts.length >= 3) tags.push(mk('ol-meta-tag-tech', '音质', parts[2])) + } + tags.push(mk('ol-meta-tag-tech', '格式', ext.toUpperCase())) + const idm = filename.match(/\(([a-z]{2}_[^)]+)\)\.[^.]+$/i) + if (idm) tags.push(mk('ol-meta-tag-default', '歌曲 ID', idm[1])) + const t = tags.filter(Boolean) + if (t.length) metaHtml = '
' + t.join('') + '
' + } + + const html = '' + + row('记录 ID', f.id) + + row('所属用户', f.user === '_system' ? '系统公共数据' : f.user === '_open' ? '开放接口公共数据' : f.user) + + row('备份类型', folderLabels[f.folder_type] || f.folder_type) + + row('文件类型', fileKind) + + row('文件名', f.name) + + row('状态', statusLabels[f.status] || f.status) + + row('大小', f.size == null ? null : fmtSize(f.size)) + + (isAudio ? optional('来源平台', sourceLabels[f.source] || f.source) + optional('音质', f.quality) + optional('歌曲 ID', f.songmid) : '') + + row('状态说明', ({ same: '本地大小和修改时间未变', changed: '本地有变化,等待备份', missing: '本地不存在', untracked: '缺少本地版本基线', error: '本地无法检查' })[f.local_state] || '未检查') + + row('云端检查', ({ unavailable: 'OpenList 暂时无法访问,保留历史备份结果', same: '大小和修改时间未变(非内容校验)', changed: '云端有变化,需重新校验', missing: '云端文件缺失', error: '检查失败', unchecked: '尚未检查', unknown: '云端未提供完整元信息' })[f.remote_state] || '尚未检查') + + optional('本地检查时间', fmtTime(f.local_checked_at)) + + optional('上次手动本地检查', fmtTime(f.last_manual_local_check)) + + optional('云端检查时间', fmtTime(f.remote_checked_at)) + + row('本地存在', f.local_exists == null ? '无法检查(请检查文件权限)' : f.local_exists ? '是' : '否') + + row('原始错误', f.error_raw || '—') + + row('重试次数', f.retry_count) + + row('本地路径', f.local_path) + + row('云端路径', f.remote_path) + + (f.remote_size == null ? '' : row('远端大小', fmtSize(f.remote_size))) + + optional('本地 SHA-256', f.local_hash) + + optional('更新时间', fmtTime(f.updated_at)) + + optional('内容校验时间', fmtTime(f.content_verified_at)) + + optional('错误信息', f.error) + + (metaHtml ? '' : '') + + '
缓存元数据' + metaHtml + '
' + const modal = document.getElementById('modal') + const title = document.getElementById('modal-title') + const body = document.getElementById('modal-body') + if (modal && title && body) { + const nmBase = String(f.name || '').trim().split(/[\\/]/).pop() || '' + title.replaceChildren(document.createTextNode('记录详情 #' + f.id)) + if (nmBase) { + const subtitle = document.createElement('span') + subtitle.className = 'olb-detail-subtitle' + subtitle.textContent = nmBase + title.appendChild(subtitle) + } + body.innerHTML = html + modal.classList.remove('hidden') + } else { + window.showError?.('详情窗口未加载,请刷新后台页面后重试') + } + } + toggleOpenlistSelect(el) { const id = parseInt(el.dataset.id) if (!this._olbSelected) this._olbSelected = new Set() if (el.checked) this._olbSelected.add(id); else this._olbSelected.delete(id) + const tr = el.closest('tr') + if (tr) tr.classList.toggle('is-selected', el.checked) this.updateOlbSelCount() } @@ -4093,7 +5339,10 @@ class App { ids.forEach(id => { if (el.checked) this._olbSelected.add(id); else this._olbSelected.delete(id) }) document.querySelectorAll('.olb-sel').forEach(cb => { const id = parseInt(cb.dataset.id) - cb.checked = this._olbSelected.has(id) + const isChecked = this._olbSelected.has(id) + cb.checked = isChecked + const tr = cb.closest('tr') + if (tr) tr.classList.toggle('is-selected', isChecked) }) this.updateOlbSelCount() } @@ -4101,20 +5350,39 @@ class App { updateOlbSelCount() { const n = this._olbSelected ? this._olbSelected.size : 0 const span = document.getElementById('olb-selcount') - if (span) span.textContent = n ? ('已选 ' + n + ' 项') : '' + if (span) { + span.textContent = n ? ('已选 ' + n + ' 项') : '' + span.classList.toggle('has-selection', n > 0) + } + const checkBtn = document.getElementById('olb-bulkcheck') + if (checkBtn) checkBtn.disabled = n === 0 const btn = document.getElementById('olb-bulkdel') if (btn) btn.disabled = n === 0 - } - - async deleteSelectedOpenlist() { - if (!this._olbSelected || !this._olbSelected.size) return - const ids = Array.from(this._olbSelected) - if (!confirm('确认删除选中的 ' + ids.length + ' 条备份记录?\n将软删除记录并级联删除绑定的附件记录与文件(缓存音频副本一并删除;音乐库文件仅删记录不删本地)。')) return + const rbtn = document.getElementById('olb-bulkretry') + if (rbtn) rbtn.disabled = n === 0 || !!this._olbRetryBusy + const selAll = document.getElementById('olb-selall') + if (selAll) { + const pageIds = this._olbPageIds || [] + const selOnPage = pageIds.filter(id => this._olbSelected && this._olbSelected.has(id)).length + selAll.checked = pageIds.length > 0 && selOnPage === pageIds.length + selAll.indeterminate = selOnPage > 0 && selOnPage < pageIds.length + } + } + + async deleteSelectedOpenlist(singleId) { + const ids = singleId == null ? Array.from(this._olbSelected || []) : [singleId] + if (!ids.length) return + const message = `将处理选中的 ${ids.length} 条备份记录。\n\n云端:尝试删除对应的当前文件及绑定附件;其他有效记录仍引用的文件会保留。历史版本不在本次删除范围,由保留策略管理。\n\n本地:播放缓存音频和未被其他记录引用的绑定附件可能一并删除;常规备份原文件(歌曲、音源、配置等)保留。\n\n注意:云端删除失败时文件可能残留,但备份记录仍可能隐藏。此操作不是只清空列表,也不是自动镜像清理。` + if (!(await showSelect('删除 OpenList 云端备份', message, { danger: true, confirmText: '确认删除', cancelText: '保留备份' }))) return try { const r = await this.request('/api/openlist/delete', { method: 'POST', body: JSON.stringify({ ids }) }) if (r && r.success) { - this._olbSelected.clear() - if (window.showInfo) window.showInfo('已删除 ' + (r.deleted || 0) + ' 条记录') + ids.forEach(id => this._olbSelected?.delete(id)) + if (r.remoteFailed?.length) { + window.showError?.('已移除 ' + (r.deleted || 0) + ' 条记录,但有 ' + r.remoteFailed.length + ' 项云端删除失败或未执行,文件可能仍保留在 OpenList。请检查连接、权限及云端目录。') + } else { + window.showInfo?.('已移除 ' + (r.deleted || 0) + ' 条记录,删除 ' + (r.remoteRemoved || 0) + ' 个云端文件;共享文件及历史版本可能保留。') + } this.loadOpenlistBackups() } else if (window.showError) { window.showError('删除失败: ' + ((r && r.error) || '未知错误')) @@ -4124,37 +5392,136 @@ class App { } } + invalidateOpenlistIntegrity() { + this._olIntegrityPlan = null; + document.getElementById('ol-integrity-start').disabled = true; + document.getElementById('ol-integrity-estimate').textContent = '范围已变化,请重新统计文件数量与预计读取量。'; + } + + async previewOpenlistIntegrity() { + this.invalidateOpenlistIntegrity(); + try { + const options = { type: document.getElementById('ol-integrity-type').value, user: document.getElementById('ol-integrity-user').value, scope: document.getElementById('ol-integrity-scope').value }; + const plan = await this.request('/api/openlist/integrity/preview', { method: 'POST', body: JSON.stringify(options) }); + // Ignore a response if the user changed the range while it was loading. + if (JSON.stringify(options) !== JSON.stringify({ type: document.getElementById('ol-integrity-type').value, user: document.getElementById('ol-integrity-user').value, scope: document.getElementById('ol-integrity-scope').value })) return; + this._olIntegrityPlan = plan; + document.getElementById('ol-integrity-estimate').textContent = '待检查 ' + plan.total + ' 个文件,预计最多读取正文 ' + this.openlistIntegritySize(plan.bytes) + '。' + (plan.unknown ? '其中 ' + plan.unknown + ' 个文件缺少上传指纹,只报告无法比对,不读取正文。' : '') + ' 云端提供 SHA-256 时可减少下载量。'; + document.getElementById('ol-integrity-start').disabled = !plan.total; + } catch (e) { window.showError?.('统计失败:' + e.message); } + } + + openlistIntegritySize(bytes) { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; let value = Number(bytes) || 0, unit = 0; + while (value >= 1024 && unit < units.length - 1) { value /= 1024; unit++; } + return value.toFixed(unit ? 1 : 0) + ' ' + units[unit]; + } + + async startOpenlistIntegrity() { + const plan = this._olIntegrityPlan; + if (!plan) return; + document.getElementById('ol-integrity-start').disabled = true; + try { + await this.request('/api/openlist/integrity/start', { method: 'POST', body: JSON.stringify({ token: plan.token }) }); + this._olIntegrityPlan = null; + await this.loadOpenlistIntegrity(); + } catch (e) { this.invalidateOpenlistIntegrity(); window.showError?.('启动失败:' + e.message); } + } + + async cancelOpenlistIntegrity() { + try { await this.request('/api/openlist/integrity/cancel', { method: 'POST' }); await this.loadOpenlistIntegrity(); } + catch (e) { window.showError?.('停止失败:' + e.message); } + } + + changeOpenlistIntegrityPage(page) { + this._olIntegrityPage = Math.max(1, page); + this.loadOpenlistIntegrity(); + } + + async loadOpenlistIntegrity() { + if (this._olIntegrityLoading) return; + this._olIntegrityLoading = true; + const esc = value => this.escapeHtml(String(value == null ? '' : value)); + try { + const state = await this.request('/api/openlist/integrity'); + const userSelect = document.getElementById('ol-integrity-user'); + if (userSelect.options.length <= 1) { + const backups = await this.request('/api/openlist/backups'); + for (const user of [...new Set((backups.list || []).map(f => f.user))].sort()) { + const option = document.createElement('option'); option.value = user; option.textContent = user === '_system' ? '系统公共数据' : user; userSelect.appendChild(option); + } + } + const job = state.job, active = job?.state === 'running'; + const jobLabels = { running: '正在校验', completed: '校验结束', cancelled: '已停止', interrupted: '进程中断,可重新统计并继续', failed: '任务失败' }; + document.getElementById('ol-integrity-cancel').disabled = !active; + document.getElementById('ol-integrity-start').disabled = active || !this._olIntegrityPlan?.total; + document.getElementById('ol-integrity-progress').innerHTML = job ? '

' + esc(jobLabels[job.state] || job.state) + ' · ' + job.done + ' / ' + job.total + ' 个文件,实际读取 ' + esc(this.openlistIntegritySize(job.bytes)) + (job.current ? ' · ' + esc(job.current) : '') + '

' : ''; + const labels = { stale: '需重新校验', matched: '曾校验通过', mismatch: '内容不一致', missing: '云端缺失', error: '读取失败', unavailable: '缺少上传指纹' }; + const resultBadgeCls = { + matched: 'ol-integrity-badge-matched', + mismatch: 'ol-integrity-badge-mismatch', + missing: 'ol-integrity-badge-missing', + error: 'ol-integrity-badge-error', + unavailable: 'ol-integrity-badge-unavailable' + }; + const filter = document.getElementById('ol-integrity-result-filter').value; + const query = document.getElementById('ol-integrity-search').value.trim().toLowerCase(); + const rows = (state.results || []).filter(row => (!filter || row.result === filter) && (!document.getElementById('ol-integrity-user').value || row.user === document.getElementById('ol-integrity-user').value) && (!document.getElementById('ol-integrity-type').value || row.type === document.getElementById('ol-integrity-type').value) && (!query || (row.name + ' ' + row.remote).toLowerCase().includes(query))); + const pages = Math.max(1, Math.ceil(rows.length / 50)); + const page = this._olIntegrityPage = Math.min(pages, this._olIntegrityPage || 1); + const counts = rows.reduce((counts, row) => { counts[row.result] = (counts[row.result] || 0) + 1; return counts; }, {}); + const countPills = Object.entries(counts).map(([key, n]) => { + const cls = resultBadgeCls[key] || ''; + return `${esc(labels[key] || key)} ${n}`; + }).join(''); + document.getElementById('ol-integrity-results').innerHTML = rows.length ? '
' + countPills + '当前备份版本的全部最近校验结果(包含历史任务,不受上方校验范围限制)
' + rows.slice((page - 1) * 50, page * 50).map(row => '').join('') + '
文件名 / 云端路径校验结果最近校验时间说明
' + esc(row.name) + '
' + esc(row.remote) + '
' + esc(labels[row.result] || row.result) + '' + esc(new Date(row.checkedAt).toLocaleString()) + '' + esc(row.result === 'stale' ? '云端状态在上次校验后发生变化,请重新校验' : (row.error || (row.result === 'matched' ? '匹配该次备份的 SHA-256' : '暂无详细说明'))) + '
' + page + ' / ' + pages + ' 页,共 ' + rows.length + ' 条
' : '
暂无符合条件的校验结果
历史版本生成与普通后台检查不会自动触发完整校验
'; + if (active && !this._olIntegrityTimer) this._olIntegrityTimer = setInterval(() => { + if (this.currentView !== 'backups' || !document.getElementById('openlist-pane-integrity')?.classList.contains('active')) { clearInterval(this._olIntegrityTimer); this._olIntegrityTimer = null; return; } + this.loadOpenlistIntegrity(); + }, 2000); + if (!active) { clearInterval(this._olIntegrityTimer); this._olIntegrityTimer = null; } + this.initCustomSelects(); + } catch (e) { document.getElementById('ol-integrity-progress').textContent = '加载失败:' + e.message; } + finally { this._olIntegrityLoading = false; } + } + switchOpenlistTab(name) { - const map = { - config: ['tab-btn-openlist-config', 'openlist-pane-config'], - records: ['tab-btn-openlist-records', 'openlist-pane-records'], + // 兼容旧入口:转向新「备份管理」对应页签与筛选 + if (name === 'config') { + this.openBackupCenter('cloud', 'openlist') + return + } + if (name === 'restore') { + this.openBackupCenter('restore', 'openlist') + return } - for (const [btnId, paneId] of Object.values(map)) { - const b = document.getElementById(btnId); if (b) b.classList.remove('active') - const p = document.getElementById(paneId); if (p) p.classList.remove('active') + if (name === 'integrity') { + this.openBackupCenter('records', 'openlist-integrity') + return } - const [btnId, paneId] = map[name] || map.config - const b = document.getElementById(btnId); if (b) b.classList.add('active') - const p = document.getElementById(paneId); if (p) p.classList.add('active') - if (name === 'records') this.loadOpenlistBackups() + this.openBackupCenter('records', 'openlist-records:' + (name || '')) } scheduleOpenlistReload() { if (this._olbReloadTimer) clearTimeout(this._olbReloadTimer) - this._olbReloadTimer = setTimeout(() => { this._olbPage = 1; this.loadOpenlistBackups() }, 300) + this._olbReloadTimer = setTimeout(() => { this._olbPage = 1; this.loadOpenlistBackups(true, true) }, 300) } clearOpenlistFilters() { - ['olb-filter-user', 'olb-filter-folder', 'olb-filter-status', 'olb-filter-name', 'olb-filter-path'].forEach((id) => { + if (this._olbReloadTimer) clearTimeout(this._olbReloadTimer) + ['olb-filter-user', 'olb-filter-folder', 'olb-filter-status', 'olb-filter-type', 'olb-filter-name', 'olb-filter-path', 'olb-filter-local', 'olb-filter-local-state', 'olb-filter-remote-state', 'olb-filter-integrity', 'olb-filter-from', 'olb-filter-to', 'bm-rec-type'].forEach((id) => { const el = document.getElementById(id); if (el) el.value = '' }) - this._olbPage = 1 - this.loadOpenlistBackups() + const sort = document.getElementById('olb-filter-sort'); if (sort) sort.value = 'time-desc' + this.syncCustomSelects() + this.selectRecordsType('') } applyOpenlistFilter() { + if (this._olbReloadTimer) clearTimeout(this._olbReloadTimer) + this.syncCustomSelects() this._olbPage = 1 - this.loadOpenlistBackups() + this.loadOpenlistBackups(true, true) } filterOpenlistByStatus(status) { @@ -4162,25 +5529,39 @@ class App { if (!el) return // 再次点击当前已选状态则取消筛选 el.value = (el.value === status) ? '' : status + this.syncCustomSelects() + if (this._olbReloadTimer) clearTimeout(this._olbReloadTimer) + this.syncCustomSelects() this._olbPage = 1 - this.loadOpenlistBackups() + this.loadOpenlistBackups(true, true) + } + + // 判断 OpenList 同步记录属于「我的歌单」还是「我不喜欢」(仅歌单快照页有意义) + olbSnapshotType(f) { + const name = String(f.name || '') + '/' + String(f.local_path || '') + '/' + String(f.remote_path || ''); + const normalized = name.replace(/\\/g, '/'); + if (/(^|\/)dislike\/snapshot(\/|Info\.json|$)/i.test(normalized)) return 'dislike'; + if (f.folder_type === 'list' || f.backup_type === 'snapshots' || /(^|\/)list\/snapshot(\/|Info\.json|$)/i.test(normalized)) return 'list'; + return ''; } gotoOpenlistPage(n) { const tp = this._olbTotalPages || 1 n = Math.max(1, Math.min(tp, parseInt(n) || 1)) this._olbPage = n - this.loadOpenlistBackups(true) + this.loadOpenlistBackups(true, true) } setOpenlistPageSize(v) { this._olbPageSize = parseInt(v) || 50 this._olbPage = 1 - this.loadOpenlistBackups() + this.loadOpenlistBackups(true, true) } async request(url, options = {}) { + const requestPassword = this.password; + if (url !== '/api/login' && !requestPassword) throw new Error('Unauthorized'); const defaultOptions = { headers: { 'Content-Type': 'application/json', @@ -4191,7 +5572,9 @@ class App { const response = await fetch(API_BASE + url, { ...defaultOptions, ...options }); if (response.status === 401) { - this.logout(); + if (url === '/api/login') return { success: false }; + // A failed login stays on the form. Ignore stale failures from an older session. + if (url !== '/api/login' && requestPassword && this.password === requestPassword) this.logout(); throw new Error('Unauthorized'); } @@ -4233,15 +5616,35 @@ class App { // ========== WebDAV 功能 ========== async testWebDAV() { + const button = document.getElementById('test-webdav-btn'); + if (button?.disabled) return; + let report = document.getElementById('webdav-test-result'); + if (!report && button) { + report = document.createElement('div'); + report.id = 'webdav-test-result'; + report.setAttribute('role', 'status'); + report.setAttribute('aria-live', 'polite'); + report.style.cssText = 'grid-column:1/-1;flex-basis:100%;min-width:0;white-space:pre-wrap;overflow-wrap:anywhere;font-size:14px;line-height:1.7;margin-top:12px;'; + button.parentElement.appendChild(report); + } + if (button) { button.disabled = true; button.setAttribute('aria-busy', 'true'); } + if (report) report.textContent = '正在检查已保存的同步目录和备份目录,请稍候…'; try { const result = await this.request('/api/webdav/test', { method: 'POST' }); + const details = (result.checks || []).map(check => + `${check.name} ${check.path}:${check.success ? '通过' : '未通过'},${check.message}` + ); + if (report) report.textContent = [result.message, ...details].join('\n'); if (result.success) { - showSuccess('✅ WebDAV连接成功!\n' + result.message); + showSuccess('WebDAV 目录读取检查通过,详情见测试按钮下方'); } else { - showError('❌ WebDAV连接失败\n' + result.message); + showError('WebDAV 检查有未通过项,详情见测试按钮下方'); } } catch (err) { - showError('❌ 连接失败: ' + err.message); + if (report) report.textContent = '无法完成 WebDAV 检查:' + err.message; + showError('无法完成 WebDAV 检查,详情见测试按钮下方'); + } finally { + if (button) { button.disabled = false; button.removeAttribute('aria-busy'); } } } @@ -4293,7 +5696,7 @@ class App { const statusEl = document.getElementById('sync-status-content'); if (statusEl) { - statusEl.innerHTML = '
正在扫描本地文件并打包上传...
'; + statusEl.innerHTML = '
正在扫描本地文件并打包上传...
'; } this.showProgress(true, '全量 ZIP 备份与云端归档', '正在扫描并打包服务器数据...'); @@ -4304,17 +5707,17 @@ class App { }); if (result.success) { if (statusEl) { - statusEl.innerHTML = '

✅ 全量备份已成功上传至 WebDAV 云端!

'; + statusEl.innerHTML = '

✅ 全量备份已成功上传至 WebDAV 云端!

'; } this.loadSyncLogs(); } else { if (statusEl) { - statusEl.innerHTML = '

❌ 备份失败: 服务器返回异常

'; + statusEl.innerHTML = '

❌ 备份失败: 服务器返回异常

'; } } } catch (err) { if (statusEl) { - statusEl.innerHTML = '

❌ 备份失败: ' + this.escapeHtml(err.message) + '

'; + statusEl.innerHTML = '

❌ 备份失败: ' + this.escapeHtml(err.message) + '

'; } } } @@ -4359,7 +5762,7 @@ class App { if (!container) return; container.innerHTML = ` -
+
正在获取云端备份列表...
@@ -4372,7 +5775,7 @@ class App { res.backups.forEach((item, index) => { const isLatest = index === 0; const sizeStr = item.size ? (item.size / (1024 * 1024)).toFixed(1) + ' MB' : '大小未知'; - const timeStr = item.timeStr || (item.time ? new Date(item.time).toLocaleString() : '未知时间'); + const timeStr = item.timeStr || (item.time ? new Date(item.time).toLocaleString('zh-CN', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '未知时间'); const escapedFilename = this.escapeHtml(item.filename || item.basename); const escapedBasename = this.escapeHtml(item.basename); @@ -4384,7 +5787,7 @@ class App { ${isLatest ? '最新快照' : ''}
- 🕒 备份时间: ${timeStr} + 🕒 备份时间: ${this.escapeHtml(timeStr)} 💾 体积: ${sizeStr}
@@ -4407,7 +5810,7 @@ class App { container.innerHTML = html; } else { container.innerHTML = ` -
+

⚠️ 云端备份目录暂无可恢复的 ZIP 备份文件

您可以先点击主页的「立即全量备份」进行首次归档。

@@ -4415,7 +5818,7 @@ class App { } } catch (err) { container.innerHTML = ` -
+
获取云端备份列表失败: ${this.escapeHtml(err.message)}
`; @@ -4446,71 +5849,157 @@ class App { } } - async executeZipRestore(targetFilename, targetBasename) { - const displayName = targetBasename ? `备份快照「${targetBasename}」` : '最新全量备份'; - if (!(await showSelect('全量 ZIP 恢复', `⚠️ 警告:恢复${displayName}将按已保存的备份类型覆盖同名本地文件!\n\n确定要立即恢复吗?建议恢复前确保本地数据已妥善归档。`, { danger: true }))) return; - - this.closeRestoreModal(); + async executeZipRestore(targetFilename) { + await this.executeWebdavRestore({ mode: 'zip', targetFilename }); + } - const statusEl = document.getElementById('sync-status-content'); - if (statusEl) { - statusEl.innerHTML = `
正在从云端下载并恢复${displayName}...
`; - } - this.showProgress(true, '全量快照还原', `正在从云端下载${displayName}并恢复已选择的数据类型...`); + async executeScatteredRestore() { + await this.executeWebdavRestore({ mode: 'files' }); + } + async executeWebdavRestore(options) { + if (this._webdavRestoreBusy) return; + this._webdavRestoreBusy = true; + const status = document.getElementById('sync-status-content'); try { - const result = await this.request('/api/webdav/restore', { - method: 'POST', - body: JSON.stringify({ mode: 'zip', targetFilename: targetFilename || undefined }) + const preview = await this.request('/api/webdav/restore', { + method: 'POST', body: JSON.stringify({ + ...options, preview: true, + strategy: document.getElementById('webdav-restore-strategy')?.value || 'missing', + keepCopies: document.getElementById('webdav-restore-copies')?.checked !== false, + }) }); - if (result.success) { - if (statusEl) { - statusEl.innerHTML = '

恢复已完成。请重启服务,完整加载配置、用户数据和音乐索引。

'; - } - - } else { - if (statusEl) { - statusEl.innerHTML = '

❌ 恢复失败

'; - } - } - } catch (err) { - if (statusEl) { - statusEl.innerHTML = '

❌ 恢复失败: ' + this.escapeHtml(err.message) + '

'; + if (!preview.success) throw new Error(preview.message || '预览失败'); + const actions = { add: '新增', overwrite: '覆盖', skip: '跳过', merge: '合并索引' }; + const examples = preview.files.slice(0, 20).map(file => `${actions[file.action]}:${file.path}`).join('\n'); + const summary = `新增 ${preview.added},覆盖 ${preview.overwritten},合并索引 ${preview.merged},跳过 ${preview.skipped}。\n${(preview.warnings || []).join('\n')}\n\n${examples}${preview.files.length > 20 ? '\n…仅展示前 20 项' : ''}\n\n确认后进入维护状态,完成后需重启服务。`; + if (!(await showSelect('确认 WebDAV 恢复范围', summary, { danger: preview.overwritten > 0 }))) { + await this.request('/api/webdav/restore', { method: 'POST', body: JSON.stringify({ cancel: true, token: preview.token }) }); + return; } - } + const result = await this.request('/api/webdav/restore', { method: 'POST', body: JSON.stringify({ token: preview.token }) }); + if (!result.success) throw new Error(result.message || '恢复失败,请查看恢复记录'); + this.closeRestoreModal(); + if (status) status.textContent = '恢复完成,请重启服务加载数据。恢复记录中可以回退保留的本地副本。'; + showSuccess('恢复完成,请重启服务'); + } catch (error) { + if (status) status.textContent = error.message + ';若已进入维护状态,请查看恢复记录后重启服务。'; + showError(error.message); + } finally { this._webdavRestoreBusy = false; } } - async executeScatteredRestore() { - if (!(await showSelect('散文件增量恢复', '确定要从云端增量恢复散文件吗?\n\n系统将比对云端散列文件并下载新增或变动的文件,本地独有文件将予以保留。'))) return; + async loadWebdavRestoreTasks() { + const container = document.getElementById('webdav-restore-tasks'); + if (!container) return; + const esc = value => this.escapeHtml(String(value)); + try { + const result = await this.request('/api/webdav/restore/tasks'); + const states = { + completed: '已完成 · 请重启服务', + downloading: '正在下载', + committing: '正在写入', + failed: '恢复失败', + interrupted: '已中断', + 'recovery-required': '异常中断 · 需回退', + 'rolled-back': '已回退副本', + cancelled: '已取消', + ready: '待写入' + }; + const badgeClasses = { + completed: 'is-completed', + downloading: 'is-running', + committing: 'is-committing', + failed: 'is-failed', + interrupted: 'is-interrupted', + 'recovery-required': 'is-danger', + 'rolled-back': 'is-rolled-back', + cancelled: 'is-cancelled', + ready: 'is-ready' + }; + const badgeIcons = { + completed: '', + downloading: '', + committing: '', + failed: '', + 'recovery-required': '', + 'rolled-back': '' + }; - this.closeRestoreModal(); + const tasks = result.tasks || []; + if (!tasks.length) { + container.innerHTML = '
' + + '' + + '暂无 WebDAV 恢复记录。' + + '
'; + return; + } - const statusEl = document.getElementById('sync-status-content'); - if (statusEl) { - statusEl.innerHTML = '
正在比对并增量恢复散文件...
'; - } - this.showProgress(true, '散文件增量恢复', '正在比对云端散文件并增量下载还原...'); + container.innerHTML = tasks.map(task => { + const bClass = badgeClasses[task.state] || 'is-pending'; + const bIcon = badgeIcons[task.state] || ''; + const total = task.total || 0; + const downloaded = task.downloaded || 0; + const applied = task.applied || 0; + const percent = total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : (task.state === 'completed' ? 100 : 0); - try { - const result = await this.request('/api/webdav/restore', { - method: 'POST', - body: JSON.stringify({ mode: 'files' }) - }); - if (result.success) { - if (statusEl) { - statusEl.innerHTML = '

已按保存的类型恢复散文件。请重启服务完整加载数据。

'; - } - this.loadSyncLogs(); - } else { - if (statusEl) { - statusEl.innerHTML = '

❌ 散文件恢复失败

'; + let actionsHtml = ''; + if (task.canRollback || task.state === 'recovery-required') { + actionsHtml += ''; } - } - } catch (err) { - if (statusEl) { - statusEl.innerHTML = '

❌ 散文件恢复失败: ' + this.escapeHtml(err.message) + '

'; - } - } + actionsHtml += ''; + + const errorsHtml = (task.errors || []).length ? '
' + + task.errors.map(e => '

' + esc(e) + '

').join('') + + '
' : ''; + + return '
' + + '
' + + '
' + + '
' + + '' + + '' + esc(new Date(task.createdAt).toLocaleString()) + '' + + '
' + + 'ID: ' + esc(task.id.slice(0, 8)) + '' + + '
' + + '
' + + bIcon + + '' + esc(states[task.state] || task.state) + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '传输进度' + + '' + esc(downloaded) + ' / ' + esc(total) + ' 文件 (' + percent + '%)' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '已写入文件' + + '' + esc(applied) + ' 个' + + '
' + + '
' + + errorsHtml + + '
' + + '
' + actionsHtml + '
' + + '
'; + }).join(''); + } catch (error) { container.innerHTML = '
' + this.escapeHtml(error.message) + '
'; } + } + + async webdavRestoreTaskAction(id, action) { + if (!(await showSelect(action === 'rollback' ? '回退恢复' : '删除恢复副本', action === 'rollback' ? '确认回退本次已写入文件?恢复后又被修改的文件会拒绝自动回退。完成后需重启服务。' : '确认删除这条记录及其暂存文件、恢复前副本?删除后无法通过此记录回退。', { danger: true }))) return; + try { + await this.request(`/api/webdav/restore/tasks/${id}${action === 'rollback' ? '/rollback' : ''}`, { method: action === 'rollback' ? 'POST' : 'DELETE' }); + await this.loadWebdavRestoreTasks(); + } catch (error) { showError(error.message); } } async syncFilesToWebDAV() { @@ -4518,7 +6007,7 @@ class App { const statusEl = document.getElementById('sync-status-content'); if (statusEl) { - statusEl.innerHTML = '
正在比对本地与云端散文件...
'; + statusEl.innerHTML = '
正在比对本地与云端散文件...
'; } this.showProgress(true, '批量文件多线程同步', '正在比对本地散文件并同步至云端...'); @@ -4526,17 +6015,17 @@ class App { const result = await this.request('/api/webdav/sync', { method: 'POST' }); if (result.success) { if (statusEl) { - statusEl.innerHTML = '

✅ 散文件已全部同步至云端!

'; + statusEl.innerHTML = '

✅ 散文件已全部同步至云端!

'; } this.loadSyncLogs(); } else { if (statusEl) { - statusEl.innerHTML = '

❌ 同步失败

'; + statusEl.innerHTML = '

❌ 同步失败

'; } } } catch (err) { if (statusEl) { - statusEl.innerHTML = '

❌ 同步失败: ' + this.escapeHtml(err.message) + '

'; + statusEl.innerHTML = '

❌ 同步失败: ' + this.escapeHtml(err.message) + '

'; } } } @@ -4639,8 +6128,8 @@ class App { renderSongNameCell(song, defaultCover = '') { const picUrl = song.img || song.picUrl || song.cover || song.meta?.picUrl || defaultCover || ''; const coverHtml = picUrl - ? `cover` - : `
🎵
`; + ? `cover` + : `
🎵
`; const singerHtml = song.singer ? `${this.escapeHtml(song.singer)}` @@ -4776,7 +6265,7 @@ class App {
${this.getLogTypeText(log.type)} ${this.escapeHtml(log.file)} - ${log.message ? `
${this.escapeHtml(log.message)}
` : ''} + ${log.message ? `
${this.escapeHtml(log.message)}
` : ''}
${log.status === 'success' ? '成功' : '失败'} @@ -4799,7 +6288,7 @@ class App { if (!container) return; if (!data.logs || data.logs.length === 0) { - container.innerHTML = '

暂无同步日志

'; + container.innerHTML = '

暂无同步日志

'; return; } @@ -4808,7 +6297,7 @@ class App {
${this.getLogTypeText(log.type)} ${this.escapeHtml(log.file)} - ${log.message ? `
${this.escapeHtml(log.message)}
` : ''} + ${log.message ? `
${this.escapeHtml(log.message)}
` : ''}
${log.status === 'success' ? '成功' : '失败'} @@ -4843,7 +6332,7 @@ class App { if (diff < day) return Math.floor(diff / hour) + '小时前'; const date = new Date(timestamp); - return date.toLocaleString('zh-CN'); + return date.toLocaleString('zh-CN', { hour12: false }); } // ========== 文件管理器功能 ========== @@ -4859,7 +6348,7 @@ class App { this.updateBreadcrumb(path); } catch (err) { console.error('Failed to load files:', err); - document.getElementById('file-items').innerHTML = '

加载文件失败

'; + document.getElementById('file-items').innerHTML = '

加载文件失败

'; } } @@ -4867,7 +6356,7 @@ class App { const container = document.getElementById('file-items'); if (items.length === 0) { - container.innerHTML = '

此文件夹为空

'; + container.innerHTML = '

此文件夹为空

'; return; } @@ -4889,7 +6378,7 @@ class App {
${!item.isDirectory ? `` : ''} - +
`).join(''); @@ -5012,7 +6501,7 @@ class App { formatDate(timestamp) { const date = new Date(timestamp); - return date.toLocaleString('zh-CN'); + return date.toLocaleString('zh-CN', { hour12: false }); } formatUptime(seconds) { @@ -5104,17 +6593,20 @@ class App { // 更新状态卡片与徽标 const statusDot = document.getElementById('config-backup-status-dot'); + const tabLocalDot = document.getElementById('bm-tab-dot-local'); const modeBadge = document.getElementById('config-backup-mode-badge'); const retentionText = document.getElementById('config-backup-retention-text'); const dirText = document.getElementById('config-backup-dir-text'); if (data.autoBackupEnabled) { statusDot?.classList.add('dot-active'); + tabLocalDot?.classList.add('dot-active'); if (modeBadge) { - modeBadge.innerHTML = '已开启 (每日自动)'; + modeBadge.innerHTML = `已开启 (每 ${data.intervalHours || 24} 小时)`; } } else { statusDot?.classList.remove('dot-active'); + tabLocalDot?.classList.remove('dot-active'); if (modeBadge) { modeBadge.innerHTML = '已停用'; } @@ -5127,9 +6619,23 @@ class App { dirText.textContent = data.backupDir || 'backups'; dirText.title = data.backupDir || 'backups'; } + const nextText = document.getElementById('config-backup-next-text'); + if (nextText) { + if (data.autoBackupEnabled && data.nextBackupAt) { + nextText.textContent = new Date(data.nextBackupAt).toLocaleString('zh-CN', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } else if (data.autoBackupEnabled) { + nextText.textContent = '计算中…'; + } else { + nextText.textContent = '已停用'; + } + } + const lastText = document.getElementById('config-backup-last-text'); + if (lastText) { + lastText.textContent = data.lastBackupAt ? new Date(data.lastBackupAt).toLocaleString('zh-CN', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }) : '暂无'; + } if (!list.length) { - container.innerHTML = '
暂无系统配置备份文件。可点击上方【立即备份配置】手动创建第一份备份。
'; + container.innerHTML = '
暂无系统配置备份文件。可点击上方【立即备份配置】手动创建第一份备份。
'; container.classList.remove('content-loading'); return; } @@ -5142,7 +6648,7 @@ class App { return `
-
${new Date(item.time).toLocaleString()}
+
${new Date(item.time).toLocaleString('zh-CN', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })}
${item.name} @@ -5266,26 +6772,47 @@ class App { return; } + // 确保「类型 / 来源」筛选条件存在 + this.ensureSnapshotKindTabs(); + const kind = this._snapshotKind || 'all'; + const origin = this._snapshotOrigin || 'all'; + // 添加加载状态 container.classList.add('content-loading'); try { // 添加 user 参数 - const list = await this.request(`/api/data/snapshots?user=${encodeURIComponent(username)}`); + const all = await this.request(`/api/data/snapshots?user=${encodeURIComponent(username)}`); + const list = all.filter(i => + (kind === 'all' || (i.kind || 'list') === kind) && + (origin === 'all' || (i.origin || 'auto') === origin)); if (!list.length) { - container.innerHTML = '
暂无快照
'; + const emptyText = origin === 'imported' ? '该用户暂无导入的备份快照' + : kind === 'dislike' ? '该用户暂无「不喜欢」快照' + : kind === 'rating' ? '该用户暂无「我的评分」快照' : '暂无快照'; + container.innerHTML = `
${emptyText}
`; container.classList.remove('content-loading'); return; } - container.innerHTML = list.map(item => ` + container.innerHTML = list.map(item => { + const imported = item.origin === 'imported'; + const originTag = imported + ? '导入备份' + : '系统快照'; + const kindTag = item.kind === 'dislike' + ? '我不喜欢' + : item.kind === 'rating' + ? '我的评分' + : '我的歌单'; + return `
-
${new Date(item.time).toLocaleString()}
-
snapshot_${item.id}
+
${new Date(item.time).toLocaleString('zh-CN', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })}
+
snapshot_${item.id}${kindTag}${originTag}
${this.formatFileSize(item.size)}
- - -
- `).join(''); + `; }).join(''); // 移除加载状态并添加淡入动画 container.classList.remove('content-loading'); @@ -5329,6 +6856,30 @@ class App { container.classList.remove('content-loading'); } } + + // 在「歌单快照」面板顶部注入「类型 / 来源」两个下拉查询条件 + ensureSnapshotKindTabs() { + const container = document.getElementById('snapshots-list'); + const toolbar = document.getElementById('snapshot-toolbar-actions'); + if (!container || !toolbar) return; + // loadSnapshots 会被反复调用(切用户 / 上传 / 删除后),避免重复注入多个筛选框 + if (document.getElementById('snapshot-kind-filter')) return; + const sel = document.createElement('select'); + sel.id = 'snapshot-kind-filter'; + sel.className = 'form-input config-input olb-select'; + sel.setAttribute('aria-label', '快照类型'); + sel.style.width = 'auto'; + const cur = this._snapshotKind || 'all'; + sel.innerHTML = ''; + sel.value = cur; + sel.addEventListener('change', () => { + this._snapshotKind = sel.value; + this.loadSnapshots(); + }); + toolbar.insertBefore(sel, toolbar.firstChild); + this.initCustomSelects(); + } + triggerUploadSnapshot() { const username = document.getElementById('snapshot-user-select')?.value; if (!username) { @@ -5338,24 +6889,97 @@ class App { document.getElementById('snapshot-upload-input').click(); } + // 导入时由超管从用户清单显式选择目标用户,选择结果即最终目标 + async showImportTargetDialog({ filename, kindLabel, defaultUser }) { + let users = this.allUsers; + if (!Array.isArray(users) || !users.length) { + try { + users = await this.request('/api/users'); + this.allUsers = users; + } catch (err) { + showError('获取用户清单失败: ' + err.message); + return null; + } + } + if (!users.length) { + showError('用户清单为空,无法导入'); + return null; + } + const options = users.map(u => { + const label = u.name === '_open' ? '公开用户 (_open)' : u.name; + return ``; + }).join(''); + return new Promise(resolve => { + const modal = document.createElement('div'); + modal.id = 'snapshot-import-target-modal'; + modal.className = 'fixed inset-0 z-[3000] flex items-center justify-center p-6 animate-fade-in'; + modal.innerHTML = ` + +
+
+

选择导入目标用户

+

+ 备份文件:${this.escapeHtml(filename)}
+ 备份类型:${kindLabel} +

+ + +
+
+ + +
+
`; + document.body.appendChild(modal); + const close = (result) => { modal.remove(); resolve(result); }; + modal.querySelector('#snapshot-import-target-ok').onclick = () => close(modal.querySelector('#snapshot-import-target-user').value); + modal.querySelector('#snapshot-import-target-cancel').onclick = () => close(null); + modal.querySelector('.absolute.inset-0').onclick = () => close(null); + }); + } + // [新增] 处理快照上传 async handleSnapshotUpload(event) { const file = event.target.files[0]; if (!file) return; - - const username = document.getElementById('snapshot-user-select')?.value; - if (!username) return; - // 重置 input,允许重复上传同名文件 event.target.value = ''; + const panelUser = document.getElementById('snapshot-user-select')?.value || ''; + let content; try { - const content = await file.text(); - // 使用文件最后修改时间 - const time = file.lastModified; - const filename = file.name; + content = await file.text(); + } catch (err) { + showError('读取备份文件失败: ' + err.message); + return; + } + let jsonData = null; + try { jsonData = JSON.parse(content); } catch { /* 非 JSON 也可按原生歌单快照导入 */ } + const isRatingBackup = jsonData && (jsonData.type === 'lxRating_v1' || ( + jsonData.ratings && typeof jsonData.ratings === 'object' && !Array.isArray(jsonData.ratings) && + !Array.isArray(jsonData.dislikeList) && !Array.isArray(jsonData.defaultList) && !Array.isArray(jsonData.userList) + )); + const kindLabel = isRatingBackup ? '我的评分' + : jsonData && Array.isArray(jsonData.dislikeList) && + !Array.isArray(jsonData.defaultList) && !Array.isArray(jsonData.userList) ? '我不喜欢' : '我的歌单'; + + // 目标用户由超管在清单中选择,选择结果即最终目标 + const targetUser = await this.showImportTargetDialog({ filename: file.name, kindLabel, defaultUser: panelUser }); + if (!targetUser) return; + + // 「导入后立即恢复」默认开启:回滚会覆盖目标用户的数据,操作前二次确认 + const restore = document.getElementById('snapshot-upload-restore')?.checked !== false; + if (restore) { + const safeTarget = this.escapeHtml(targetUser); + const confirmed = await showSelect('导入并恢复备份', + `导入到用户:${safeTarget}\n\n导入后将以该文件覆盖 ${safeTarget} 当前的对应数据。\n系统会自动把导入前的状态保存为一份「系统快照」,之后可随时回滚。是否继续?`, + { danger: true, confirmText: '导入并恢复' }); + if (!confirmed) return; + } - const response = await fetch(`/api/data/upload-snapshot?user=${encodeURIComponent(username)}&time=${time}&filename=${encodeURIComponent(filename)}`, { + try { + const time = file.lastModified; + const response = await fetch(`/api/data/upload-snapshot?user=${encodeURIComponent(targetUser)}&time=${time}&filename=${encodeURIComponent(file.name)}&restore=${restore ? 1 : 0}`, { method: 'POST', headers: { 'X-Frontend-Auth': this.password @@ -5368,8 +6992,16 @@ class App { throw new Error(text || 'Upload failed'); } - showSuccess('上传成功'); - this.loadSnapshots(); + const result = await response.json().catch(() => ({})); + const resultKindLabel = result.kind === 'rating' ? '我的评分' : result.kind === 'dislike' ? '我不喜欢' : '我的歌单'; + showSuccess(result.restored + ? `已导入「${resultKindLabel}」备份到用户 ${targetUser} 并恢复成功${result.safetySnapshot ? '(已自动保存导入前快照)' : ''}` + : `已导入「${resultKindLabel}」备份到用户 ${targetUser}(仅保存为快照,可稍后手动恢复)`); + // 仅提示 + 提供手动操作入口,不在此处调用联动同步 + if (result.restored) void this.maybeOfferLinkSync(targetUser, resultKindLabel); + // 目标用户与面板用户不同时切换面板,便于立刻看到导入结果(selectUser 内部会刷新列表) + if (targetUser !== panelUser) this.selectUser('snapshot', targetUser); + else this.loadSnapshots(); } catch (err) { console.error(err); showError('上传失败: ' + err.message); @@ -5377,7 +7009,7 @@ class App { } // [新增] 删除快照 - async deleteSnapshot(id) { + async deleteSnapshot(id, kind) { if (!(await showSelect('删除快照', '确定要删除这个快照吗?', { danger: true }))) return; const username = document.getElementById('snapshot-user-select')?.value; @@ -5390,7 +7022,7 @@ class App { 'Content-Type': 'application/json', 'X-Frontend-Auth': this.password }, - body: JSON.stringify({ id }) + body: JSON.stringify({ id, kind }) }); if (!response.ok) { @@ -5405,7 +7037,7 @@ class App { showError('删除失败: ' + err.message); } } - async downloadSnapshot(id) { + async downloadSnapshot(id, kind) { const username = document.getElementById('snapshot-user-select')?.value; if (!username) { showInfo('请先选择用户'); @@ -5413,27 +7045,38 @@ class App { } try { - // 添加 user 参数 - const data = await this.request(`/api/data/snapshot?id=${id}&user=${encodeURIComponent(username)}`); - - // 转换为 LX Music 备份格式 - const defaultList = { id: 'default', name: 'list__name_default' }; - const loveList = { id: 'love', name: 'list__name_love' }; - - const backupData = { - type: 'playList_v2', - data: [ - { ...defaultList, list: data.defaultList || [] }, - { ...loveList, list: data.loveList || [] }, - ...(data.userList || []), - ], - }; + const data = await this.request(`/api/data/snapshot?id=${encodeURIComponent(id)}&kind=${kind || 'list'}&user=${encodeURIComponent(username)}`); + + let backupData, fname; + if (kind === 'rating') { + // 评分快照导出为 lxRating_v1(含完整 subsonic-meta 结构),导入时按 type 识别 + backupData = { ...data, type: 'lxRating_v1' }; + fname = `lx_rating_${username}_${id.substring(0, 8)}.json`; + } else if (kind === 'dislike') { + // 不喜欢快照直接导出原始规则({ dislikeList: [...] }),不要套 playList_v2(否则内容会被清空成空壳) + backupData = data; + fname = `lx_dislike_${username}_${id.substring(0, 8)}.json`; + } else { + // 转换为 LX Music 备份格式 + const defaultList = { id: 'default', name: 'list__name_default' }; + const loveList = { id: 'love', name: 'list__name_love' }; + + backupData = { + type: 'playList_v2', + data: [ + { ...defaultList, list: data.defaultList || [] }, + { ...loveList, list: data.loveList || [] }, + ...(data.userList || []), + ], + }; + fname = `lx_backup_${username}_${id.substring(0, 8)}.json`; + } const blob = new Blob([JSON.stringify(backupData, null, 2)], { type: 'application/json' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `lx_backup_${username}_${id.substring(0, 8)}.json`; + a.download = fname; a.click(); URL.revokeObjectURL(url); } catch (err) { @@ -5444,7 +7087,7 @@ class App { // [新增] 本地备份下载 async downloadLocalBackup() { - if (!(await showSelect('本地备份', '按系统设置中 WebDAV 已保存的类型打包本地 ZIP。不会上传到云端,也不包含 cache。确定下载吗?'))) return; + if (!(await showSelect('本地备份', '按「备份管理 → 云端备份」中 WebDAV 已保存的类型打包本地 ZIP。不会上传到云端,也不包含 cache。确定下载吗?'))) return; this.showProgress(true, '本地 ZIP 备份下载', '正在打包服务器数据,请稍候...'); this.setTaskStep(1); @@ -5478,7 +7121,7 @@ class App { const file = event.target.files[0]; if (!file) return; - if (!(await showSelect('还原数据', '按系统设置中 WebDAV 已保存的类型恢复 ZIP:覆盖同名本地文件,保留其他文件,不恢复 cache。恢复后需重启服务。请先保存当前数据的副本。', { danger: true }))) { + if (!(await showSelect('还原数据', '按「备份管理 → 云端备份」中 WebDAV 已保存的类型恢复 ZIP:覆盖同名本地文件,保留其他文件,不恢复 cache。恢复后需重启服务。请先保存当前数据的副本。', { danger: true }))) { event.target.value = ''; return; } @@ -5489,12 +7132,12 @@ class App { // 创建临时加载提示 const loadingOverlay = document.createElement('div'); loadingOverlay.className = 'overlay'; - loadingOverlay.style.background = 'rgba(0,0,0,0.8)'; + loadingOverlay.style.background = 'var(--overlay)'; loadingOverlay.innerHTML = ` `; document.body.appendChild(loadingOverlay); @@ -5525,24 +7168,26 @@ class App { } } - async restoreSnapshot(id) { + async restoreSnapshot(id, kind) { const username = document.getElementById('snapshot-user-select')?.value; if (!username) { showInfo('请先选择用户'); return; } - if (!(await showSelect('回滚快照', '警告:此操作将把服务器数据回滚到选定的快照状态!\n\n1. 当前所有未保存的更改将丢失。\n2. 所有客户端的同步状态将被重置。\n3. 客户端连接后,请务必选择【远程覆盖本地】以获取回滚后的数据。\n\n确定要继续吗?', { danger: true }))) { + if (!(await showSelect('回滚快照', (kind === 'rating' ? '评分恢复范围:分数、来源与备份中的歌曲描述。保留当前收藏及本地文件关联;关联参照不会恢复旧关联。\n\n' : '') + '警告:此操作将把服务器数据回滚到选定的快照状态!\n\n1. 当前所有未保存的更改将丢失。\n2. 所有客户端的同步状态将被重置。\n3. 客户端连接后,请务必选择【远程覆盖本地】以获取回滚后的数据。\n\n确定要继续吗?', { danger: true }))) { return; } try { - // 添加 user 参数 + // 通用回滚入口:list / dislike 共用,按 kind 分流到后端对应快照恢复 await this.request(`/api/data/restore-snapshot?user=${encodeURIComponent(username)}`, { method: 'POST', - body: JSON.stringify({ id }) + body: JSON.stringify({ id, kind }) }); showSuccess('回滚成功!请重启客户端或重新连接同步服务。'); + // 仅提示 + 提供手动操作入口,不在此处调用联动同步 + void this.maybeOfferLinkSync(username, kind === 'rating' ? '我的评分' : kind === 'dislike' ? '我不喜欢' : '我的歌单'); this.loadDashboard(); // 刷新数据概览 } catch (err) { showError('回滚失败: ' + err.message); @@ -5589,21 +7234,667 @@ class App { } jumpToWebDAVConfig() { - this.switchView('config').then(() => { - setTimeout(() => { - const target = document.getElementById('config-card-webdav'); - if (target) { - target.scrollIntoView({ behavior: 'smooth', block: 'center' }); - target.style.outline = '2px solid var(--accent-primary)'; - target.style.outlineOffset = '4px'; - setTimeout(() => { - target.style.outline = 'none'; - }, 2000); + this.openBackupCenter('cloud', 'webdav'); + } + + // =============== 备份管理(统一备份中心) =============== + async openBackupCenter(tab = 'overview', sub = '') { + if (this.currentView !== 'backups') { + this._bmPendingTab = tab; + this._bmPendingSub = sub; + await this.switchView('backups'); + return; + } + await this.enterBackupCenter(tab, sub); + } + + async enterBackupCenter(tab = 'overview', sub = '') { + this.bindBackupDirtyTrackers(); + await this.loadBackupSettings(false); + this.switchBackupCenterTab(tab || 'overview', sub); + } + + switchBackupCenterTab(tab = 'overview', sub = '') { + // Retain old playback-cache shortcuts while selecting the cloud tab. + if (tab === 'playcache') { tab = 'cloud'; sub = 'playcache'; } + const tabs = ['overview', 'local', 'cloud', 'restore', 'records']; + const activeTab = tabs.includes(tab) ? tab : 'overview'; + this.currentBackupCenterTab = activeTab; + tabs.forEach(id => { + document.getElementById(`bm-tab-btn-${id}`)?.classList.toggle('active', id === activeTab); + document.getElementById(`bm-pane-${id}`)?.classList.toggle('active', id === activeTab); + }); + + if (activeTab === 'overview') { + this.loadBackupOverview(); + } else if (activeTab === 'local') { + this.switchBackupTab(sub === 'snapshot' ? 'snapshot' : (this.currentBackupTab || 'config')); + } else if (activeTab === 'cloud') { + if (sub === 'webdav') this.scrollBackupCenter('webdav-form'); + else if (sub === 'openlist') this.scrollBackupCenter('openlist-form'); + else if (sub === 'policy') this.scrollBackupCenter('backup-policy-form'); + else if (sub === 'playcache') this.scrollBackupCenter('openlist-cache-form'); + } else if (activeTab === 'restore') { + const source = ['local', 'webdav', 'openlist'].includes(sub) ? sub : (this._bmRestoreSource || 'local'); + this.selectRestoreSource(source); + } else if (activeTab === 'records') { + let recSource = this._bmRecSource || 'openlist-records'; + let recType = ''; + if (sub) { + if (sub.startsWith('openlist-records:')) { + recSource = 'openlist-records'; + recType = sub.split(':')[1] || ''; + } else if (['openlist-records', 'openlist-integrity', 'webdav', 'local'].includes(sub)) { + recSource = sub; } - }, 300); + } + const srcSel = document.getElementById('bm-rec-source'); + if (srcSel) srcSel.value = recSource; + if (recType) { + const typeSel = document.getElementById('bm-rec-type'); + if (typeSel) typeSel.value = recType; + } + this.syncCustomSelects(); + this.selectRecordsSource(recSource); + } + setTimeout(() => this.initCustomSelects(), 30); + } + + scrollBackupCenter(elementId) { + setTimeout(() => { + const target = document.getElementById(elementId); + if (!target) return; + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + target.style.outline = '2px solid var(--accent)'; + target.style.outlineOffset = '4px'; + setTimeout(() => { target.style.outline = 'none'; }, 1800); + }, 80); + } + + selectRestoreSource(source = 'local') { + const valid = ['local', 'webdav', 'openlist'].includes(source) ? source : 'local'; + this._bmRestoreSource = valid; + document.querySelectorAll('.bm-source-card').forEach(card => { + const isSel = card.dataset.source === valid; + card.classList.toggle('is-selected', isSel); + const radio = card.querySelector('input[type="radio"]'); + if (radio) radio.checked = isSel; + }); + ['local', 'webdav', 'openlist'].forEach(key => { + document.getElementById(`bm-restore-panel-${key}`)?.classList.toggle('active', key === valid); + }); + if (valid === 'webdav') { + this.loadWebdavRestoreTasks(); + } else if (valid === 'openlist') { + this.loadOpenlistRecoveryHistory(); + } + } + + selectRecordsSource(source = 'openlist-records') { + const valid = ['openlist-records', 'openlist-integrity', 'webdav', 'local'].includes(source) ? source : 'openlist-records'; + this._bmRecSource = valid; + const map = { + 'openlist-records': 'bm-rec-openlist-records', + 'openlist-integrity': 'openlist-pane-integrity', + webdav: 'bm-rec-webdav', + local: 'bm-rec-local', + }; + Object.entries(map).forEach(([key, id]) => { + document.getElementById(id)?.classList.toggle('active', key === valid); + }); + const hintEl = document.getElementById('bm-rec-hint'); + const hints = { + 'openlist-records': '主状态表示任务进度;内容校验是独立的历史结果。本地变化等待后台备份,云端变化后需重新校验;详细观察信息见详情和高级筛选。', + 'openlist-integrity': '完整内容校验会比对云端 SHA-256 或流式读取文件正文(可能消耗下载流量),与普通后台大小检查相互独立。', + webdav: 'WebDAV 仅支持查看同步状态与日志,不提供单文件重试或 SHA-256 内容校验;上传后仅核对大小。', + local: '本地配置备份与歌单快照记录在“本地备份”页签查看与管理。', + }; + if (hintEl) hintEl.textContent = hints[valid] || ''; + + if (valid === 'openlist-records') { + this._olbRawRecords = null; // Re-entering after verification must fetch the latest results. + this.selectRecordsType(document.getElementById('bm-rec-type')?.value || ''); + } else if (valid === 'openlist-integrity') { + this.loadOpenlistIntegrity(); + } else if (valid === 'webdav') { + const isConfigured = !!(this.loadedConfig?.['webdav.enable'] && (this.loadedConfig?.['webdav.url'] || '').trim()); + this.checkWebDAVConfig(isConfigured); + if (isConfigured) this.loadSyncLogs(); + } + } + + selectRecordsType(type = '') { + const selected = document.getElementById('bm-rec-type'); if (selected) selected.value = type; + document.querySelectorAll?.('[data-record-type]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.recordType === type))); + const scopeMap = { media: 'cache,music', system: 'system', snapshot: 'list', userFiles: 'userFiles', userData: 'userData' }; + const headerMeta = { + '': ['全部文件同步记录', '查看 OpenList 所有类型文件的上传状态、大小检查结果与失败重试'], + media: ['歌曲与播放缓存同步记录', '查看已下载歌曲、播放缓存及歌词封面的上传状态、大小检查与失败重试'], + system: ['系统与账户同步记录', '查看系统设置与账户配置(_system)的上传状态、大小检查与失败重试'], + snapshot: ['歌单与不喜欢快照同步记录', '查看各用户的我的歌单与我不喜欢快照上传状态、大小检查与失败重试'], + userFiles: ['用户音源同步记录', '查看用户音源文件的上传与校验状态'], + userData: ['用户数据同步记录', '查看用户设置、播放历史等数据的上传与校验状态'], + }; + this._olbFolderScope = scopeMap[type] || ''; + const meta = headerMeta[type] || headerMeta['']; + const titleEl = document.getElementById('openlist-records-title'); + const descEl = document.getElementById('openlist-records-desc'); + if (titleEl) titleEl.textContent = meta[0]; + if (descEl) descEl.textContent = meta[1]; + this._olbPage = 1; + this.loadOpenlistBackups(true, true); + } + + bindBackupDirtyTrackers() { + if (this._bmDirtyTrackersBound) return; + this._bmDirtyTrackersBound = true; + this._bmDirty = new Set(); + const forms = ['local-backup-form', 'backup-policy-form', 'webdav-form', 'openlist-form', 'openlist-cache-form']; + forms.forEach(formId => { + const form = document.getElementById(formId); + if (!form) return; + const onEdit = () => { + if (this._bmSuppressDirty) return; + this.markBackupDirty(formId, true); + }; + form.addEventListener('input', onEdit); + form.addEventListener('change', onEdit); + }); + document.getElementById('openlist-enable')?.addEventListener('change', () => this.toggleOpenlistVisibility()); + } + + markBackupDirty(formId, dirty) { + if (!this._bmDirty) this._bmDirty = new Set(); + if (dirty) this._bmDirty.add(formId); + else this._bmDirty.delete(formId); + + const statusMap = { + 'local-backup-form': 'bm-status-local', + 'backup-policy-form': 'backup-policy-status', + 'webdav-form': 'bm-status-webdav', + 'openlist-form': 'bm-status-openlist', + 'openlist-cache-form': 'bm-status-playcache', + }; + const statusEl = document.getElementById(statusMap[formId]); + if (statusEl && dirty) { + statusEl.classList.remove('is-error'); + statusEl.classList.add('is-dirty'); + statusEl.textContent = '有未保存的更改,点击本卡片的保存按钮后生效。'; + } + document.getElementById('bm-tab-btn-local')?.classList.toggle('bm-tab-unsaved', this._bmDirty.has('local-backup-form')); + document.getElementById('bm-tab-btn-cloud')?.classList.toggle('bm-tab-unsaved', this._bmDirty.has('backup-policy-form') || this._bmDirty.has('webdav-form') || this._bmDirty.has('openlist-form') || this._bmDirty.has('openlist-cache-form')); + } + + clearBackupDirty() { + if (!this._bmDirty) return; + ['local-backup-form', 'backup-policy-form', 'webdav-form', 'openlist-form', 'openlist-cache-form'].forEach(id => { + this.markBackupDirty(id, false); + const statusMap = { + 'local-backup-form': 'bm-status-local', + 'backup-policy-form': 'backup-policy-status', + 'webdav-form': 'bm-status-webdav', + 'openlist-form': 'bm-status-openlist', + 'openlist-cache-form': 'bm-status-playcache', + }; + const el = document.getElementById(statusMap[id]); + if (el) { el.classList.remove('is-dirty', 'is-error'); el.textContent = ''; } }); } + hasBackupUnsaved() { + return !!(this._bmDirty && this._bmDirty.size > 0); + } + + backupUnsavedLabels() { + const names = { + 'local-backup-form': '本地备份设置', + 'backup-policy-form': '云端备份范围', + 'webdav-form': 'WebDAV 设置', + 'openlist-form': 'OpenList 连接与周期', + 'openlist-cache-form': 'OpenList 播放缓存设置', + }; + return Array.from(this._bmDirty || []).map(k => names[k] || k); + } + + async loadBackupSettings(forceDiscard = false) { + if (forceDiscard) this.clearBackupDirty(); + try { + const config = await this.request('/api/config'); + this.configLoaded = true; + this.loadedConfig = config; + document.getElementById('bm-maintenance-banner')?.classList.toggle('hidden', !config.restoreMaintenance); + await this.populateBackupForms(config, forceDiscard); + } catch (e) { + console.error('Failed to load backup settings:', e); + if (/维护状态|503/.test(String(e?.message || e))) { + document.getElementById('bm-maintenance-banner')?.classList.remove('hidden'); + } + } + } + + async populateBackupForms(config, forceDiscard = false) { + if (!config) return; + this._bmSuppressDirty = true; + try { + const dirty = this._bmDirty || new Set(); + // 1. 备份范围矩阵 + if (forceDiscard || !dirty.has('backup-policy-form')) { + this.renderBackupPolicy(config); + } + // 2. 本地备份表单 + const localForm = document.getElementById('local-backup-form'); + if (localForm && (forceDiscard || !dirty.has('local-backup-form'))) { + if (localForm.elements['maxSnapshotNum']) localForm.elements['maxSnapshotNum'].value = config.maxSnapshotNum ?? 10; + if (localForm.elements['configBackup.enable']) localForm.elements['configBackup.enable'].checked = config['configBackup.enable'] !== false; + if (localForm.elements['configBackup.retentionDays']) localForm.elements['configBackup.retentionDays'].value = config['configBackup.retentionDays'] ?? 7; + if (localForm.elements['configBackup.dir']) localForm.elements['configBackup.dir'].value = config['configBackup.dir'] || ''; + if (localForm.elements['configBackup.intervalHours']) localForm.elements['configBackup.intervalHours'].value = config['configBackup.intervalHours'] ?? 24; + if (localForm.elements['configBackup.scheduleTime']) localForm.elements['configBackup.scheduleTime'].value = config['configBackup.scheduleTime'] ?? '00:00'; + if (localForm.elements['snapshot.backupPath']) localForm.elements['snapshot.backupPath'].value = config['snapshot.backupPath'] || ''; + } + const configBackupJsPathRef = document.getElementById('config-backup-js-path-ref'); + if (configBackupJsPathRef && config.configFilePath) { + configBackupJsPathRef.textContent = config.configFilePath; + } + // 3. WebDAV 表单 + const webdavForm = document.getElementById('webdav-form'); + if (webdavForm && (forceDiscard || !dirty.has('webdav-form'))) { + if (webdavForm.elements['webdav.enable']) webdavForm.elements['webdav.enable'].checked = config['webdav.enable'] === true; + if (webdavForm.elements['webdav.url']) webdavForm.elements['webdav.url'].value = config['webdav.url'] || ''; + if (webdavForm.elements['webdav.username']) webdavForm.elements['webdav.username'].value = config['webdav.username'] || ''; + if (webdavForm.elements['webdav.password']) webdavForm.elements['webdav.password'].value = config['webdav.password'] || ''; + if (webdavForm.elements['webdav.syncPath']) webdavForm.elements['webdav.syncPath'].value = config['webdav.syncPath'] || '/lx-sync'; + if (webdavForm.elements['webdav.backupPath']) webdavForm.elements['webdav.backupPath'].value = config['webdav.backupPath'] || '/lx-sync-backups'; + if (webdavForm.elements['sync.interval']) webdavForm.elements['sync.interval'].value = config['sync.interval'] || 60; + if (webdavForm.elements['sync.backupInterval']) webdavForm.elements['sync.backupInterval'].value = config['sync.backupInterval'] || 24; + if (webdavForm.elements['webdav.retentionCount']) webdavForm.elements['webdav.retentionCount'].value = config['webdav.retentionCount'] ?? 5; + if (webdavForm.elements['webdav.retentionDays']) webdavForm.elements['webdav.retentionDays'].value = config['webdav.retentionDays'] ?? 0; + if (webdavForm.elements['backup.webdav.mirrorDeletes']) webdavForm.elements['backup.webdav.mirrorDeletes'].checked = config['backup.webdav.mirrorDeletes'] === true; + if (webdavForm.elements['backup.webdav.restoreOnStartup']) webdavForm.elements['backup.webdav.restoreOnStartup'].checked = config['backup.webdav.restoreOnStartup'] === true; + this.toggleWebdavVisibility(); + } + // 4. OpenList 连接与周期表单 + const olForm = document.getElementById('openlist-form'); + if (olForm && (forceDiscard || !dirty.has('openlist-form'))) { + if (olForm.elements['openlist.enable']) olForm.elements['openlist.enable'].checked = config['openlist.enable'] === true; + if (olForm.elements['openlist.endpoint']) olForm.elements['openlist.endpoint'].value = config['openlist.endpoint'] || ''; + if (olForm.elements['openlist.token']) olForm.elements['openlist.token'].value = config['openlist.token'] || ''; + if (olForm.elements['openlist.externalUrl']) olForm.elements['openlist.externalUrl'].value = config['openlist.externalUrl'] || ''; + if (olForm.elements['openlist.root']) olForm.elements['openlist.root'].value = config['openlist.root'] || '/lx_music'; + if (olForm.elements['openlist.syncInterval']) olForm.elements['openlist.syncInterval'].value = config['openlist.syncInterval'] || 60; + if (olForm.elements['openlist.verifyInterval']) olForm.elements['openlist.verifyInterval'].value = config['openlist.verifyInterval'] || 5; + if (olForm.elements['openlist.historyRetentionDays']) olForm.elements['openlist.historyRetentionDays'].value = config['openlist.historyRetentionDays'] || 7; + this.toggleOpenlistVisibility(); + } + this.initOpenlistRestore(config); + // 5. OpenList 播放缓存表单 + const cacheForm = document.getElementById('openlist-cache-form'); + if (cacheForm && (forceDiscard || !dirty.has('openlist-cache-form'))) { + if (cacheForm.elements['openlist.playback.enable']) cacheForm.elements['openlist.playback.enable'].checked = config['openlist.playback.enable'] === true; + if (cacheForm.elements['openlist.includeNonAudio']) cacheForm.elements['openlist.includeNonAudio'].checked = config['openlist.includeNonAudio'] === true; + let users = []; + this._olbUsersLoaded = true; + try { + const ulist = await this.request('/api/users'); + users = (ulist || []).map(u => u.name).filter(Boolean); + } catch { + this._olbUsersLoaded = false; + } + if (this._olbUsersLoaded) { + this.renderOpenlistUserConfig(users, config['openlist.userBackups']); + } + } + } finally { + this._bmSuppressDirty = false; + } + } + + async saveLocalBackupSettings() { + const form = document.getElementById('local-backup-form'); + if (!form) return; + const fd = new FormData(form); + const status = document.getElementById('bm-status-local'); + const payload = { + maxSnapshotNum: Math.max(1, parseInt(fd.get('maxSnapshotNum'), 10) || 10), + 'configBackup.enable': fd.get('configBackup.enable') === 'on', + 'configBackup.retentionDays': Math.max(1, Math.min(365, parseInt(fd.get('configBackup.retentionDays'), 10) || 7)), + 'configBackup.dir': (fd.get('configBackup.dir') || '').trim(), + 'configBackup.intervalHours': Math.max(1, Math.min(8760, parseInt(fd.get('configBackup.intervalHours'), 10) || 24)), + 'configBackup.scheduleTime': (fd.get('configBackup.scheduleTime') || '').trim(), + 'snapshot.backupPath': (fd.get('snapshot.backupPath') || '').trim(), + }; + try { + const res = await this.request('/api/config', { method: 'POST', body: JSON.stringify(payload) }); + if (this.loadedConfig) Object.assign(this.loadedConfig, payload); + this.markBackupDirty('local-backup-form', false); + if (status) { + status.classList.remove('is-error', 'is-dirty'); + status.textContent = res.warning || '本地备份设置已保存并生效。'; + } + showSuccess('本地备份设置已保存'); + this.loadConfigBackups(); + } catch (err) { + if (status) { + status.classList.add('is-error'); + status.textContent = '保存失败:' + err.message; + } + showError('保存本地备份设置失败: ' + err.message); + } + } + + async saveWebdavSettings() { + const form = document.getElementById('webdav-form'); + if (!form) return; + const fd = new FormData(form); + const status = document.getElementById('bm-status-webdav'); + const payload = { + 'webdav.enable': fd.get('webdav.enable') === 'on', + 'webdav.url': (fd.get('webdav.url') || '').trim(), + 'webdav.username': fd.get('webdav.username') || '', + 'webdav.password': fd.get('webdav.password') || '', + 'webdav.syncPath': (fd.get('webdav.syncPath') || '').trim() || '/lx-sync', + 'webdav.backupPath': (fd.get('webdav.backupPath') || '').trim() || '/lx-sync-backups', + 'sync.interval': Math.max(1, parseInt(fd.get('sync.interval'), 10) || 60), + 'sync.backupInterval': Math.max(1, parseInt(fd.get('sync.backupInterval'), 10) || 24), + 'webdav.retentionCount': Math.max(1, Math.min(1000, Number(fd.get('webdav.retentionCount')) || 5)), + 'webdav.retentionDays': Math.max(0, Math.min(3650, Number(fd.get('webdav.retentionDays')) || 0)), + 'backup.webdav.mirrorDeletes': fd.get('backup.webdav.mirrorDeletes') === 'on', + 'backup.webdav.restoreOnStartup': fd.get('backup.webdav.restoreOnStartup') === 'on', + }; + try { + const res = await this.request('/api/config', { method: 'POST', body: JSON.stringify(payload) }); + if (this.loadedConfig) Object.assign(this.loadedConfig, payload); + this.markBackupDirty('webdav-form', false); + if (status) { + status.classList.remove('is-error', 'is-dirty'); + status.textContent = res.warning || 'WebDAV 设置已保存。'; + } + this.toggleWebdavVisibility(); + showSuccess('WebDAV 设置已保存'); + } catch (err) { + if (status) { + status.classList.add('is-error'); + status.textContent = '保存失败:' + err.message; + } + showError('保存 WebDAV 设置失败: ' + err.message); + } + } + + async saveOpenlistConnection() { + const form = document.getElementById('openlist-form'); + if (!form) return; + const fd = new FormData(form); + const status = document.getElementById('bm-status-openlist'); + const payload = { + 'openlist.enable': fd.get('openlist.enable') === 'on', + 'openlist.endpoint': (fd.get('openlist.endpoint') || '').trim(), + 'openlist.token': fd.get('openlist.token') || '', + 'openlist.externalUrl': (fd.get('openlist.externalUrl') || '').trim(), + 'openlist.root': (fd.get('openlist.root') || '').trim() || '/lx_music', + 'openlist.syncInterval': Math.max(1, parseInt(fd.get('openlist.syncInterval'), 10) || 60), + 'openlist.verifyInterval': Math.max(1, parseInt(fd.get('openlist.verifyInterval'), 10) || 5), + 'openlist.historyRetentionDays': Math.max(1, Math.min(3650, parseInt(fd.get('openlist.historyRetentionDays'), 10) || 7)), + }; + try { + const res = await this.request('/api/config', { method: 'POST', body: JSON.stringify(payload) }); + if (this.loadedConfig) Object.assign(this.loadedConfig, payload); + this.markBackupDirty('openlist-form', false); + this.toggleOpenlistVisibility(); + this.initOpenlistRestore(this.loadedConfig || payload); + if (status) { + status.classList.remove('is-error', 'is-dirty'); + status.textContent = res.warning || 'OpenList 连接与周期设置已保存。'; + } + if (res.warning) showInfo(res.warning); + else showSuccess('OpenList 设置已保存'); + } catch (err) { + if (status) { + status.classList.add('is-error'); + status.textContent = '保存失败:' + err.message; + } + showError('保存 OpenList 设置失败: ' + err.message); + } + } + + async saveOpenlistPlaycache() { + const form = document.getElementById('openlist-cache-form'); + if (!form) return; + const fd = new FormData(form); + const status = document.getElementById('bm-status-playcache'); + const userBackupsRaw = this.collectOpenlistUserBackups(); + const payload = { + 'openlist.playback.enable': fd.get('openlist.playback.enable') === 'on', + 'openlist.includeNonAudio': fd.get('openlist.includeNonAudio') === 'on', + 'openlist.userBackups': this._olbUsersLoaded ? userBackupsRaw : undefined, + }; + try { + const res = await this.request('/api/config', { method: 'POST', body: JSON.stringify(payload) }); + if (this.loadedConfig) Object.assign(this.loadedConfig, payload); + this.markBackupDirty('openlist-cache-form', false); + if (status) { + status.classList.remove('is-error', 'is-dirty'); + status.textContent = res.warning || 'OpenList 播放缓存设置已保存。'; + } + if (res.warning) showInfo(res.warning); + else showSuccess('OpenList 播放缓存设置已保存'); + } catch (err) { + if (status) { + status.classList.add('is-error'); + status.textContent = '保存失败:' + err.message; + } + showError('保存播放缓存设置失败: ' + err.message); + } + } + + async loadBackupOverview() { + const cfg = this.loadedConfig || {}; + const isEnabled = key => key === 'local' ? cfg['configBackup.enable'] !== false : cfg[key + '.enable'] === true; + const showDetails = (key, enabled) => { + const card = document.getElementById(`bm-ov-${key}`); + const list = card?.querySelector('.bm-ov-list'); + if (list) list.hidden = !enabled; + const extra = card?.querySelector('.bm-ov-details'); + if (extra) extra.hidden = !enabled; + card?.querySelectorAll('.bm-ov-actions button').forEach((button, index) => { if (index !== 1) button.hidden = !enabled; }); + }; + ['local', 'webdav', 'openlist'].forEach(key => showDetails(key, isEnabled(key))); + let runtime = {}, runtimeError = ''; + if (isEnabled('webdav') || isEnabled('openlist')) { + try { runtime = await this.request('/api/backup/status'); if (!runtime.success) throw new Error(runtime.message || '调度状态读取失败'); } + catch (error) { runtimeError = error.message; } + } + const time = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '尚未执行'; + const nextTime = value => runtime.maintenance ? '恢复维护中,已暂停' : runtimeError ? '读取失败:' + runtimeError : value ? time(value) + '(调度检查)' : '未安排(检查启用状态与连接)'; + const escOverview = value => String(value ?? '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + const tagValues = (values, tone = '') => '' + values.map(value => '' + escOverview(value) + '').join('') + ''; + const addDetails = (key, entries) => { + const card = document.getElementById(`bm-ov-${key}`); + const list = card?.querySelector('.bm-ov-extra-list') || card?.querySelector('.bm-ov-list'); + if (!list) return; + list.querySelectorAll('[data-overview-extra]').forEach(row => row.remove()); + for (const [label, value] of entries) { + const row = document.createElement('div'); row.className = 'bm-ov-metric'; row.dataset.overviewExtra = 'true'; + const title = document.createElement('dt'), detail = document.createElement('dd'); + title.textContent = label; detail.textContent = String(value); + if (['备份范围', '文件记录', 'ZIP 保留', '删除 / 启动恢复'].includes(label)) { + detail.innerHTML = tagValues(String(value).split(label === '备份范围' ? '、' : ' / ')); + } else if (['当前任务', '云端历史保留', '用户缓存规则', '播放来源', '最大快照数', '锚点时间'].includes(label)) { + detail.innerHTML = tagValues([value], label === '当前任务' && value === '空闲' ? 'is-good' : ''); + } else if (label.includes('目录') || label === '配置来源') detail.className = 'bm-ov-path'; + row.append(title, detail); list.appendChild(row); + } + }; + const scopeNames = key => (cfg[`backup.${key}.types`] || []).map(type => ({ music: '歌曲与歌词', userFiles: '用户音源', snapshots: '歌单/不喜欢快照', userData: '用户数据', system: '系统与账户' }[type] || type)).join('、') || '未选择数据类型'; + const setCard = (key, { badge, badgeCls, last, next, scope, error }) => { + const card = document.getElementById(`bm-ov-${key}`); + if (!card) return; + const badgeEl = card.querySelector('[data-role="badge"]'); + if (badgeEl) { + badgeEl.className = 'bm-ov-badge' + (badgeCls ? ' ' + badgeCls : ''); + badgeEl.textContent = badge || '暂无记录'; + } + const setRole = (r, val) => { + const el = card.querySelector(`[data-role="${r}"]`); + if (el) { + el.textContent = val || '暂无记录'; + if (r === 'scope' && val) el.innerHTML = tagValues(val.split(/ [·/] /)); + if (r === 'last' && val?.includes(' · ')) { + const [stamp, ...notes] = val.split(' · '); + el.innerHTML = '' + escOverview(stamp) + '' + escOverview(notes.join(' · ')) + ''; + } + } + }; + setRole('last', last); + setRole('next', next); + setRole('scope', scope); + setRole('error', error); + }; + + if (cfg.restoreMaintenance) { + document.getElementById('bm-maintenance-banner')?.classList.remove('hidden'); + } + + // 1. 本地配置备份概览 + try { + const localData = await this.request('/api/config/backups'); + const enabled = localData.autoBackupEnabled !== false; + const latestFile = (localData.list || [])[0]; + const lastStr = localData.lastBackupAt + ? new Date(localData.lastBackupAt).toLocaleString('zh-CN', { hour12: false }) + : (latestFile ? `${new Date(latestFile.time).toLocaleString('zh-CN', { hour12: false })}(${latestFile.name})` : '暂无记录'); + const nextStr = enabled + ? (localData.nextBackupAt ? new Date(localData.nextBackupAt).toLocaleString('zh-CN', { hour12: false }) : '暂无记录') + : '已停用'; + setCard('local', { + badge: enabled ? '已启用' : '已停用', + badgeCls: enabled ? 'is-on' : '', + last: lastStr, + next: nextStr, + scope: `每 ${localData.intervalHours || 24} 小时 · 保留 ${localData.retentionDays || 7} 天 · 现有 ${(localData.list || []).length} 份副本`, + error: localData.lastError || '尚无已记录的备份错误(不代表已验证恢复)', + }); + } catch (err) { + setCard('local', { + badge: cfg['configBackup.enable'] !== false ? '已启用' : '已停用', + badgeCls: 'is-warn', + last: '暂无记录', + next: '暂无记录', + scope: `每 ${cfg['configBackup.intervalHours'] || 24} 小时 · 保留 ${cfg['configBackup.retentionDays'] || 7} 天`, + error: err.message || '读取失败', + }); + } + + // 2. WebDAV 概览 + const wdEnabled = cfg['webdav.enable'] === true; + const wdHasUrl = !!((cfg['webdav.url'] || '').trim()); + const wdTypesCount = (cfg['backup.webdav.types'] || []).length; + if (wdEnabled) { + try { + const logsRes = await this.request('/api/webdav/logs'); + const logs = logsRes.logs || []; + const latest = logs[0]; + const latestErr = logs.find(l => l.status && l.status !== 'success'); + setCard('webdav', { + badge: wdEnabled ? (wdHasUrl ? '已启用' : '未填地址') : '未启用', + badgeCls: wdEnabled ? (wdHasUrl ? 'is-on' : 'is-warn') : '', + last: latest ? `${this.formatTime(latest.timestamp)} · ${this.getLogTypeText(latest.type)}(${latest.status === 'success' ? '成功' : '失败'})` : '暂无记录', + next: nextTime(runtime.webdav?.nextSyncAt), + scope: `增量 ${cfg['sync.interval'] || 60} 分钟 / ZIP ${cfg['sync.backupInterval'] || 24} 小时 · 已选 ${wdTypesCount} 类数据`, + error: latestErr ? `${this.formatTime(latestErr.timestamp)}:${latestErr.message || latestErr.file || '执行失败'}` : '本次启动尚无失败记录', + }); + } catch (err) { + setCard('webdav', { + badge: wdEnabled ? '已启用' : '未启用', + badgeCls: 'is-warn', + last: '暂无记录', + next: '暂无记录', + scope: `已选 ${wdTypesCount} 类数据`, + error: err.message || '读取失败', + }); + } + + addDetails('webdav', [ + ['当前任务', runtime.maintenance ? '恢复维护中' : runtime.webdav?.restoring ? '恢复中' : runtime.webdav?.running ? '备份中' : runtimeError ? '读取失败' : '空闲'], + ['下次 ZIP 检查', nextTime(runtime.webdav?.nextZipAt)], + ['备份范围', scopeNames('webdav')], + ['增量目录', cfg['webdav.syncPath'] || '/lx-sync'], + ['ZIP 目录', cfg['webdav.backupPath'] || '/lx-sync-backups'], + ['ZIP 保留', `最多 ${cfg['webdav.retentionCount'] || 5} 份 / ${cfg['webdav.retentionDays'] ? cfg['webdav.retentionDays'] + ' 天' : '不限天数'}`], + ['删除 / 启动恢复', `${cfg['backup.webdav.mirrorDeletes'] ? '传播本地删除' : '保留云端副本'} / ${cfg['backup.webdav.restoreOnStartup'] ? '每次启动恢复' : '仅手动恢复'}`], + ]); + } else setCard('webdav', { badge: '未启用' }); + + // 3. OpenList 概览 + const olEnabled = cfg['openlist.enable'] === true; + const olHasUrl = !!((cfg['openlist.endpoint'] || '').trim()); + const olTypesCount = (cfg['backup.openlist.types'] || []).length; + if (olEnabled) { + try { + const olRes = await this.request('/api/openlist/backups'); + const rows = olRes.list || []; + const completed = rows.filter(r => ['completed', 'verified'].includes(r.status)).length; + const failedRows = rows.filter(r => r.status === 'failed'); + const latestTs = rows.reduce((max, r) => Math.max(max, Number(r.updated_at) || 0), 0); + setCard('openlist', { + badge: olEnabled ? (olHasUrl ? (failedRows.length ? '有失败项' : '已启用') : '未填地址') : '未启用', + badgeCls: olEnabled ? (failedRows.length ? 'is-error' : (olHasUrl ? 'is-on' : 'is-warn')) : '', + last: runtime.openlist?.lastRun ? `${time(runtime.openlist.lastRun.at)} · 上传 ${runtime.openlist.lastRun.uploaded} / 下载 ${runtime.openlist.lastRun.downloaded} / 删除 ${runtime.openlist.lastRun.deleted} / 错误 ${runtime.openlist.lastRun.errors.length}` : latestTs ? `${time(latestTs)} · 文件记录更新(上传完成 ${completed} 项,非整轮执行结果)` : '暂无记录', + next: nextTime(runtime.openlist?.nextSyncAt), + scope: `备份 ${cfg['openlist.syncInterval'] || 60} 分钟 / 检查 ${cfg['openlist.verifyInterval'] || 5} 分钟 · 已选 ${olTypesCount} 类数据`, + error: failedRows.length ? `${failedRows.length} 个文件上传或校验失败(${failedRows[0].error || failedRows[0].name})` : runtime.openlist?.lastRun?.errors?.join(';') || '当前无失败文件记录', + }); + } catch (err) { + setCard('openlist', { + badge: olEnabled ? '已启用' : '未启用', + badgeCls: 'is-warn', + last: '暂无记录', + next: '暂无记录', + scope: `已选 ${olTypesCount} 类数据`, + error: err.message || '读取失败', + }); + } + let rules = []; + try { rules = JSON.parse(cfg['openlist.userBackups'] || '[]').filter(rule => rule.enabled !== false && rule.backupCache === true); } catch { } + const counts = runtime.openlist?.counts; + addDetails('openlist', [ + ['当前任务', runtime.maintenance ? '恢复维护中' : runtime.openlist?.running ? '同步中' : runtime.openlist?.checking ? '后台检查中' : runtimeError ? '读取失败' : '空闲'], + ['下次后台检查', nextTime(runtime.openlist?.nextCheckAt)], + ['文件记录', counts ? `待上传 ${counts.pending} / 进行中 ${counts.uploading} / 上传完成 ${counts.completed} / 失败 ${counts.failed}` : '状态读取失败'], + ['备份范围', scopeNames('openlist')], + ['数据目录', (cfg['openlist.root'] || '/lx_music').replace(/\/$/, '') + '/_data'], + ['云端历史保留', `${cfg['openlist.historyRetentionDays'] || 7} 天`], + ['用户缓存规则', `${rules.length} 个用户启用缓存同步`], + ['播放来源', cfg['openlist.playback.enable'] ? '允许(另需用户端配置)' : '未开启'], + ]); + } else setCard('openlist', { badge: '未启用' }); + if (isEnabled('local')) addDetails('local', [ + ['配置来源', cfg.configPath || '当前生效的 config.js'], + ['保存目录', cfg['configBackup.dir'] || '数据目录/backups'], + ['锚点时间', cfg['configBackup.scheduleTime'] || '从启动/重新设置后计时'], + ['最大快照数', cfg.maxSnapshotNum || 10], + ['额外快照目录', cfg['snapshot.backupPath'] || '未设置,使用用户原快照目录'], + ]); + } + + openOpenlistUsageModal() { + const modal = document.getElementById('openlist-usage-modal'); + if (!modal) return; + this._openlistUsageOrigin = document.activeElement; + modal.classList.remove('hidden'); + document.getElementById('openlist-usage-close')?.focus(); + this._openlistUsageKeydown ??= event => { + if (event.key === 'Escape') this.closeOpenlistUsageModal(); + }; + document.addEventListener('keydown', this._openlistUsageKeydown); + } + + closeOpenlistUsageModal() { + document.getElementById('openlist-usage-modal')?.classList.add('hidden'); + if (this._openlistUsageKeydown) document.removeEventListener('keydown', this._openlistUsageKeydown); + this._openlistUsageOrigin?.focus(); + this._openlistUsageOrigin = null; + } + openWebdavUsageModal() { const modal = document.getElementById('webdav-usage-modal'); if (modal) { @@ -5622,13 +7913,16 @@ class App { const selects = document.querySelectorAll('select.config-input, select.form-input, .config-field select, select.olb-select'); selects.forEach(sel => { if (sel._lxInit) { + sel._lxRender?.(); sel._lxSync?.(); return; } sel._lxInit = true; const wrap = document.createElement('div'); - wrap.className = 'lx-select' + (sel.classList.contains('olb-select') ? ' select-sm' : ''); + const isSm = sel.classList.contains('olb-select'); + const isPager = sel.classList.contains('olb-pagesize-select'); + wrap.className = 'lx-select' + (isSm ? ' select-sm' : '') + (isPager ? ' select-pager' : ''); sel.parentNode.insertBefore(wrap, sel); wrap.appendChild(sel); sel.classList.add('lx-select-native'); @@ -5648,9 +7942,12 @@ class App { menu.className = 'lx-select-menu'; wrap.appendChild(menu); + const checkSvg = ''; + const sync = () => { const opt = sel.options[sel.selectedIndex] || sel.options[0]; text.textContent = opt ? opt.textContent : ''; + wrap.classList.toggle('is-disabled', !!sel.disabled); menu.querySelectorAll('.lx-select-item').forEach(it => { it.classList.toggle('is-selected', it.dataset.value === (opt ? opt.value : '')); }); @@ -5662,7 +7959,11 @@ class App { const item = document.createElement('div'); item.className = 'lx-select-item' + (opt.value === sel.value ? ' is-selected' : ''); item.dataset.value = opt.value; - item.textContent = opt.textContent; + const labelSpan = document.createElement('span'); + labelSpan.className = 'lx-select-item-label'; + labelSpan.textContent = opt.textContent; + item.appendChild(labelSpan); + item.insertAdjacentHTML('beforeend', checkSvg); item.addEventListener('click', (e) => { e.stopPropagation(); if (sel.value !== opt.value) { @@ -5679,6 +7980,7 @@ class App { trigger.addEventListener('click', (e) => { e.stopPropagation(); + if (sel.disabled) return; const isOpen = wrap.classList.contains('is-open'); document.querySelectorAll('.lx-select.is-open').forEach(el => el.classList.remove('is-open')); if (!isOpen) { @@ -5696,6 +7998,7 @@ class App { }); sel.addEventListener('change', sync); + sel._lxRender = render; sel._lxSync = sync; render(); sync(); @@ -5704,6 +8007,7 @@ class App { syncCustomSelects() { document.querySelectorAll('select.lx-select-native').forEach(sel => { + sel._lxRender?.(); sel._lxSync?.(); }); } @@ -5722,3 +8026,48 @@ document.addEventListener('click', (e) => { // 初始化应用 const app = new App(); + +// Shared overflow handling for admin list labels. Keep existing nodes and events. +(() => { + const selectors = [ + '.olb-filename-text', '.olb-err-text', '.user-name-text', '.col-id-text', + '.playlist-name', '.playlist-id', '.song-title-text', '.song-singer-mobile', + '.song-col-album', '.song-col-playlist', '.song-col-source', + '.album-title', '.artist-name', '.lx-select-text', '.lx-select-item-label' + ].join(','); + let pending = false; + function refresh() { + pending = false; + document.querySelectorAll(selectors).forEach(el => { + if (!el.clientWidth || el.querySelector('button, input, select')) return; + let track = el.querySelector(':scope > .admin-overflow-track'); + if (!track) { + if (el.scrollWidth <= el.clientWidth + 1) return; + track = document.createElement('span'); + track.className = 'admin-overflow-track'; + track.append(...el.childNodes); + el.appendChild(track); + el.classList.add('admin-overflow-label'); + } + const distance = Math.max(0, track.scrollWidth - el.clientWidth); + el.style.setProperty('--admin-overflow-distance', `-${distance}px`); + el.style.setProperty('--admin-overflow-duration', `${Math.max(5, distance / 30 + 3)}s`); + const overflowing = distance > 1; + if (el.classList.contains('admin-is-overflowing') !== overflowing) { + el.classList.toggle('admin-is-overflowing', overflowing); + } + }); + } + function schedule() { + if (pending) return; + pending = true; + requestAnimationFrame(refresh); + } + new MutationObserver(schedule).observe(document.body, { + childList: true, subtree: true, characterData: true, + attributes: true, attributeFilter: ['class', 'hidden'] + }); + window.addEventListener('resize', schedule); + document.fonts?.ready.then(schedule); + schedule(); +})(); diff --git a/public/css/dashboard.css b/public/css/dashboard.css new file mode 100644 index 00000000..8a96e980 --- /dev/null +++ b/public/css/dashboard.css @@ -0,0 +1,73 @@ + +#view-dashboard .welcome-section{margin-bottom:1rem} +#view-dashboard .welcome-section h1{font-size:1.4rem} +#view-dashboard .stats-grid{gap:12px} +#view-dashboard .monitor-grid{gap:12px;margin-bottom:12px} +#view-dashboard .monitor-card .chart-canvas-wrapper{height:190px} +.dash-insights{display:grid;grid-template-columns:repeat(12,1fr);gap:12px;margin-bottom:18px} +.dash-span-7{grid-column:span 7} +.dash-span-5{grid-column:span 5} +@media(max-width:1100px){.dash-span-7,.dash-span-5{grid-column:span 12}} +.dash-panel{background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:16px 18px;min-width:0} +.dash-panel-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;margin-bottom:14px} +.dash-panel-head h3{margin:0;font-size:.95rem;font-weight:600;color:var(--text)} +.dash-panel-head p{margin:2px 0 0;font-size:.75rem;color:var(--text-3)} +.dash-link{font-size:.78rem;color:var(--accent-text);cursor:pointer;text-decoration:none} +.dash-link:hover{text-decoration:underline} +.dash-health{font-size:.72rem;padding:3px 10px;border-radius:var(--radius-pill);border:1px solid var(--border);color:var(--text-3)} +.dash-health.ok{color:var(--success-text);border-color:color-mix(in srgb,var(--success) 35%,transparent);background:color-mix(in srgb,var(--success) 10%,transparent)} +.dash-health.warn{color:var(--warning-text);border-color:color-mix(in srgb,var(--warning) 35%,transparent);background:color-mix(in srgb,var(--warning) 10%,transparent)} +.dash-health.bad{color:var(--danger-text);border-color:color-mix(in srgb,var(--danger) 35%,transparent);background:color-mix(in srgb,var(--danger) 10%,transparent)} +.dash-kpis{display:flex;gap:28px;margin-bottom:14px} +.dash-kpis b{display:block;font-size:1.5rem;font-weight:650;letter-spacing:-.02em;font-variant-numeric:tabular-nums;color:var(--text)} +.dash-kpis span{font-size:.72rem;color:var(--text-3)} +.dash-kpis div:nth-child(1) b{color:var(--danger-text)} +.dash-kpis div:nth-child(2) b{color:var(--warning-text)} +.dash-bars{display:flex;align-items:flex-end;gap:6px;height:110px} +.dash-bar{flex:1;height:100%;display:flex;flex-direction:column-reverse;justify-content:flex-start;position:relative;padding-bottom:16px;border-radius:var(--radius-sm)} +.dash-bar i{display:block;width:100%;min-height:2px} +.dash-bar i:last-of-type{border-radius:3px 3px 0 0} +.dash-bar i.i{background:color-mix(in srgb,var(--accent) 55%,transparent)} +.dash-bar i.w{background:var(--warning)} +.dash-bar i.e{background:var(--danger)} +.dash-bar span{position:absolute;bottom:0;left:0;right:0;text-align:center;font-size:.62rem;color:var(--text-3)} +.dash-bar:hover{background:var(--surface-hover)} +.dash-services{list-style:none;margin:0 0 14px;padding:0} +.dash-services li{display:flex;justify-content:space-between;align-items:center;padding:7px 0;border-bottom:1px solid var(--border);font-size:.82rem;color:var(--text-2)} +.dash-services li:last-child{border-bottom:0} +.dash-pill{font-size:.68rem;padding:2px 8px;border-radius:var(--radius-pill);border:1px solid var(--border);color:var(--text-3)} +.dash-pill.on{color:var(--success-text);border-color:color-mix(in srgb,var(--success) 35%,transparent);background:color-mix(in srgb,var(--success) 10%,transparent)} +.dash-pill.off{color:var(--text-3);background:var(--surface-2)} +.dash-meter{margin-top:10px} +.dash-meter-row{display:flex;justify-content:space-between;font-size:.74rem;color:var(--text-2);margin-bottom:5px} +.dash-meter-track{height:6px;border-radius:var(--radius-pill);background:var(--surface-2);border:1px solid var(--border);overflow:hidden} +.dash-meter-track i{display:block;height:100%;background:var(--accent);border-radius:var(--radius-pill);transition:width .4s} +.dash-meter-track i.warn{background:var(--warning)} +.dash-meter-track i.bad{background:var(--danger)} +.dash-users,.dash-events{list-style:none;margin:0;padding:0} +.dash-users li{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);font-size:.84rem} +.dash-users li:last-child,.dash-events li:last-child{border-bottom:0} +.dash-dot{width:8px;height:8px;border-radius:50%;background:var(--danger)} +.dash-dot.on{background:var(--success)} +.dash-uname{color:var(--text);font-weight:500} +.dash-udev{margin-left:auto;font-size:.74rem;color:var(--text-3)} +.dash-events li{display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--border);font-size:.8rem} +.dash-lv{flex:none;font-size:.62rem;font-weight:600;padding:2px 6px;border-radius:var(--radius-sm)} +.dash-lv.error{color:var(--danger-text);background:color-mix(in srgb,var(--danger) 12%,transparent)} +.dash-lv.warn{color:var(--warning-text);background:color-mix(in srgb,var(--warning) 14%,transparent)} +.dash-msg{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text-2)} +.dash-events time{flex:none;font-size:.7rem;color:var(--text-3);font-variant-numeric:tabular-nums} +.dash-empty{color:var(--text-3);font-size:.82rem;padding:14px 0;justify-content:center} +/* ===== polish ===== */ +.stat-card svg.spark{position:absolute;right:12px;bottom:10px;width:34%;height:26px;color:var(--accent-text);opacity:.55;pointer-events:none;stroke:currentColor;fill:none} +.dash-win{display:inline-flex;gap:2px;padding:2px;margin:0 0 10px;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--surface)} +.dash-win button{border:0;background:transparent;color:var(--text-3);font-size:.74rem;padding:4px 12px;border-radius:var(--radius-sm);cursor:pointer} +.dash-win button.on{background:var(--surface-2);color:var(--text);box-shadow:inset 0 0 0 1px var(--border)} +.dash-services li small{display:block;margin-top:2px;font-size:.68rem;color:var(--text-3)} +.monitor-card .progress-bar{height:3px} +/* compact quick actions */ +#view-dashboard .dash-section-header{margin:6px 0 8px} +.quick-actions .action-btn{flex:1 1 200px;padding:8px 12px;gap:10px} +.quick-actions .action-btn-icon{width:30px;height:30px;min-width:30px;border-radius:var(--radius-md)} +.quick-actions .action-btn-icon svg{width:15px;height:15px} +.quick-actions .action-btn-desc{font-size:.68rem} diff --git a/public/css/tokens.css b/public/css/tokens.css new file mode 100644 index 00000000..08ca48ac --- /dev/null +++ b/public/css/tokens.css @@ -0,0 +1,266 @@ +:root { + --login-ambient-opacity: .18; + --login-ambient-blend: multiply; + --login-grid-mask: radial-gradient(ellipse 80% 80% at 50% 50%, #000 60%, transparent 100%); + --login-particle-1: #6366f1; + --login-particle-2: #06b6d4; + color-scheme: light; + --bg: #f6f7f9; + --surface: #ffffff; + --surface-2: #eef0f4; + --surface-hover: color-mix(in srgb, var(--text) 6%, transparent); + --overlay: color-mix(in srgb, var(--text) 48%, transparent); + --border: #d8dce4; + --border-strong: #7a8291; + --text: #1f2430; + --text-2: #475467; + --text-3: #596579; + --text-on-accent: #ffffff; + --accent: #4f46e5; + --accent-hover: #4338ca; + --accent-soft: #eeecff; + --accent-text: #4338ca; + --success: #15803d; + --success-soft: #eaf7ee; + --success-text: #166534; + --warning: #b45309; + --warning-soft: #fff4db; + --warning-text: #92400e; + --danger: #dc2626; + --danger-soft: #fff0f0; + --danger-text: #b91c1c; + --info: #2563eb; + --info-soft: #edf4ff; + --info-text: #1d4ed8; + --chart-grid: #d8dce4; + --chart-tick: #596579; + --chart-1: #2563eb; + --chart-2: #15803d; + --chart-3: #7c3aed; + --chart-4: #b45309; + --shadow-sm: 0 1px 2px color-mix(in srgb, var(--text) 8%, transparent); + --shadow-md: 0 4px 12px color-mix(in srgb, var(--text) 10%, transparent); + --shadow-lg: 0 12px 32px color-mix(in srgb, var(--text) 14%, transparent); + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --space-16: 64px; + --radius-pill: 999px; + --focus-ring: var(--accent-text); + --control-disabled-bg: var(--surface-2); + --control-disabled-text: var(--text-3); + --scrollbar-track: transparent; + --scrollbar-thumb: #7a8291; + --scrollbar-thumb-hover: #596579; + --scrollbar-thumb-active: var(--accent); + --on-success: #ffffff; + --on-warning: #ffffff; + --on-danger: #ffffff; + --on-info: #ffffff; + --text-main: var(--text); + --bg-item-hover: var(--surface-hover); + --avatar-1: var(--accent); + --avatar-2: #0369a1; + --avatar-3: #166534; + --avatar-4: #9f1239; + /* Shared announcement engine integration; player tokens are unchanged. */ + --bg-card: var(--surface); + --border-dim: var(--border); + --text-muted: var(--text-2); + --text-secondary: var(--text-2); + --accent-primary: var(--accent-text); + --field-bg: var(--surface); + --field-border: #cbd5e1; + --field-focus-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); + --bg-panel: var(--surface); + --text-dim: var(--text-3); + --notification-button-bg: var(--accent); + --notification-button-text: var(--text-on-accent); + --login-scene-background: #f6f8ff; + --login-aurora-1-background: radial-gradient(circle, #2563eb 0%, rgba(79, 70, 229, 0.45) 50%, transparent 70%); + --login-aurora-2-background: radial-gradient(circle, #7c3aed 0%, rgba(147, 51, 234, 0.4) 50%, transparent 70%); + --login-aurora-3-background: radial-gradient(circle, #06b6d4 0%, rgba(14, 165, 233, 0.35) 50%, transparent 70%); + --login-grid-background-image: linear-gradient(to right, rgba(67, 56, 202, 0.045) 1px, transparent 1px), + linear-gradient(to bottom, rgba(67, 56, 202, 0.045) 1px, transparent 1px); + --login-note-color: rgba(67,56,202,.16); + --login-note-text-shadow: 0 0 12px rgba(99, 102, 241, 0.35); + --login-cursor-background: radial-gradient(circle, rgba(99, 102, 241, 0.13) 0%, rgba(6, 182, 212, 0.04) 45%, transparent 70%); + --login-card-background: rgba(255,255,255,.92); + --login-card-border: 1px solid #d9e1f0; + --login-card-box-shadow: 0 24px 60px -12px rgba(40,52,95,.18), inset 0 1px 0 rgba(255,255,255,.9); + --login-logo-background: linear-gradient(135deg, #3b82f6 0%, #6366f1 50%, #8b5cf6 100%); + --login-logo-box-shadow: 0 12px 28px -4px rgba(99, 102, 241, 0.45); + --login-logo-halo-background: linear-gradient(135deg, #3b82f6, #8b5cf6); + --login-title-background: linear-gradient(135deg, #1f2430, #334155); + --login-field-background: #ffffff; + --login-field-border: 1.5px solid #cbd5e1; + --login-field-color: #1f2430; + --login-field-placeholder-color: #596579; + --login-field-hover-border-color: #9ba8bd; + --login-field-hover-background: #ffffff; + --login-field-focus-border-color: #6366f1; + --login-field-focus-background: #ffffff; + --login-field-focus-box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.2), 0 4px 20px rgba(99, 102, 241, 0.18); + --login-field-icon-color: #596579; + --login-field-icon-focus-color: #4f46e5; + --login-field-eye-color: #596579; + --login-field-eye-hover-color: #4f46e5; + --login-submit-background: linear-gradient(135deg, #3b82f6 0%, #6366f1 50%, #8b5cf6 100%); + --login-submit-color: white; + --login-submit-box-shadow: 0 8px 24px -4px rgba(99, 102, 241, 0.45); + --login-submit-hover-box-shadow: 0 12px 30px -4px rgba(99, 102, 241, 0.65), 0 4px 12px rgba(59, 130, 246, 0.4); + --login-error-background: #fff0f0; + --login-error-border: 1px solid #f4b8bd; + --login-error-color: #b91c1c; + --login-error-box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25); + --login-logo-color: #ffffff; + --login-detail-color: #596579; + --login-link-color: #4338ca; + --login-badge-bg: rgba(79,70,229,.08); + --login-badge-border: rgba(79,70,229,.25); + --login-divider: rgba(67,56,202,.12); +} + +[data-theme="dark"] { + --login-ambient-opacity: .55; + --login-ambient-blend: screen; + --login-grid-mask: radial-gradient(ellipse 80% 80% at 50% 50%, #000 60%, transparent 100%); + --login-particle-1: #6366f1; + --login-particle-2: #06b6d4; + color-scheme: dark; + --bg: #0b0c0f; + --surface: #121317; + --surface-2: #1a1c21; + --surface-hover: color-mix(in srgb, var(--text) 6%, transparent); + --overlay: color-mix(in srgb, var(--bg) 78%, transparent); + --border: #353945; + --border-strong: #798191; + --text: #ececf0; + --text-2: #b7bdca; + --text-3: #9da6b7; + --text-on-accent: #ffffff; + --accent: #6259dd; + --accent-hover: #574dd0; + --accent-soft: #262341; + --accent-text: #b9b4ff; + --success: #34d399; + --success-soft: #122e26; + --success-text: #6ee7b7; + --warning: #fbbf24; + --warning-soft: #342a16; + --warning-text: #fcd34d; + --danger: #f87171; + --danger-soft: #361e23; + --danger-text: #fca5a5; + --info: #60a5fa; + --info-soft: #172a43; + --info-text: #93c5fd; + --chart-grid: #353945; + --chart-tick: #9da6b7; + --chart-1: #60a5fa; + --chart-2: #34d399; + --chart-3: #a78bfa; + --chart-4: #fbbf24; + --shadow-sm: 0 1px 2px color-mix(in srgb, var(--text) 8%, transparent); + --shadow-md: 0 4px 12px color-mix(in srgb, var(--text) 10%, transparent); + --shadow-lg: 0 12px 32px color-mix(in srgb, var(--text) 14%, transparent); + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + --space-1: 4px; + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; + --space-5: 20px; + --space-6: 24px; + --space-8: 32px; + --space-10: 40px; + --space-12: 48px; + --space-16: 64px; + --radius-pill: 999px; + --focus-ring: var(--accent-text); + --control-disabled-bg: var(--surface-2); + --control-disabled-text: var(--text-3); + --scrollbar-track: transparent; + --scrollbar-thumb: #798191; + --scrollbar-thumb-hover: #9da6b7; + --scrollbar-thumb-active: var(--accent); + --on-success: #09251b; + --on-warning: #302306; + --on-danger: #310e15; + --on-info: #0c203a; + --text-main: var(--text); + --bg-item-hover: var(--surface-hover); + --avatar-1: var(--accent); + --avatar-2: #0369a1; + --avatar-3: #166534; + --avatar-4: #9f1239; + /* Shared announcement engine integration; player tokens are unchanged. */ + --bg-card: var(--surface); + --border-dim: var(--border); + --text-muted: var(--text-2); + --text-secondary: var(--text-2); + --accent-primary: var(--accent-text); + --field-bg: var(--surface); + --field-border: #454d60; + --field-focus-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); + --bg-panel: var(--surface); + --text-dim: var(--text-3); + --notification-button-bg: var(--accent); + --notification-button-text: var(--text-on-accent); + --login-scene-background: #090d1a; + --login-aurora-1-background: radial-gradient(circle, #2563eb 0%, rgba(79, 70, 229, 0.45) 50%, transparent 70%); + --login-aurora-2-background: radial-gradient(circle, #7c3aed 0%, rgba(147, 51, 234, 0.4) 50%, transparent 70%); + --login-aurora-3-background: radial-gradient(circle, #06b6d4 0%, rgba(14, 165, 233, 0.35) 50%, transparent 70%); + --login-grid-background-image: linear-gradient(to right, rgba(255, 255, 255, 0.035) 1px, transparent 1px), + linear-gradient(to bottom, rgba(255, 255, 255, 0.035) 1px, transparent 1px); + --login-note-color: rgba(255, 255, 255, 0.12); + --login-note-text-shadow: 0 0 12px rgba(99, 102, 241, 0.35); + --login-cursor-background: radial-gradient(circle, rgba(99, 102, 241, 0.13) 0%, rgba(6, 182, 212, 0.04) 45%, transparent 70%); + --login-card-background: rgba(15, 22, 38, 0.8); + --login-card-border: 1px solid rgba(255, 255, 255, 0.12); + --login-card-box-shadow: 0 30px 60px -12px rgba(0, 0, 0, 0.75), + 0 18px 36px -18px rgba(0, 0, 0, 0.85), + inset 0 1px 0 rgba(255, 255, 255, 0.18), + inset 0 0 24px rgba(99, 102, 241, 0.06); + --login-logo-background: linear-gradient(135deg, #3b82f6 0%, #6366f1 50%, #8b5cf6 100%); + --login-logo-box-shadow: 0 12px 28px -4px rgba(99, 102, 241, 0.45); + --login-logo-halo-background: linear-gradient(135deg, #3b82f6, #8b5cf6); + --login-title-background: linear-gradient(135deg, #fff 0%, #e2e8f0 50%, #93c5fd 100%); + --login-field-background: rgba(11, 16, 30, 0.75); + --login-field-border: 1.5px solid rgba(255, 255, 255, 0.09); + --login-field-color: #fff; + --login-field-placeholder-color: rgba(156, 163, 175, 0.6); + --login-field-hover-border-color: rgba(255, 255, 255, 0.2); + --login-field-hover-background: rgba(16, 23, 42, 0.85); + --login-field-focus-border-color: #6366f1; + --login-field-focus-background: rgba(18, 26, 48, 0.95); + --login-field-focus-box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.2), 0 4px 20px rgba(99, 102, 241, 0.18); + --login-field-icon-color: rgba(156, 163, 175, 0.65); + --login-field-icon-focus-color: #818cf8; + --login-field-eye-color: rgba(156, 163, 175, 0.65); + --login-field-eye-hover-color: #818cf8; + --login-submit-background: linear-gradient(135deg, #3b82f6 0%, #6366f1 50%, #8b5cf6 100%); + --login-submit-color: white; + --login-submit-box-shadow: 0 8px 24px -4px rgba(99, 102, 241, 0.45); + --login-submit-hover-box-shadow: 0 12px 30px -4px rgba(99, 102, 241, 0.65), 0 4px 12px rgba(59, 130, 246, 0.4); + --login-error-background: rgba(136, 19, 55, 0.45); + --login-error-border: 1px solid rgba(244, 63, 94, 0.35); + --login-error-color: #fda4af; + --login-error-box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25); + --login-logo-color: #ffffff; + --login-detail-color: #94a3b8; + --login-link-color: #a5b4fc; + --login-badge-bg: rgba(99,102,241,.1); + --login-badge-border: rgba(99,102,241,.25); + --login-divider: rgba(255,255,255,.05); +} diff --git a/public/dash.js b/public/dash.js new file mode 100644 index 00000000..5d91b11a --- /dev/null +++ b/public/dash.js @@ -0,0 +1,88 @@ +(function () { + var esc = function (s) { return String(s).replace(/[&<>"']/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; }); }; + var $ = function (id) { return document.getElementById(id); }; + var fmt = function (n) { if (n >= 1073741824) return (n / 1073741824).toFixed(1) + ' GB'; if (n >= 1048576) return (n / 1048576).toFixed(0) + ' MB'; return (n / 1024).toFixed(0) + ' KB'; }; + function meter(label, used, total, hint) { + var p = total ? Math.min(100, used / total * 100) : 0, cls = p > 90 ? 'bad' : p > 75 ? 'warn' : ''; + return '
' + label + '' + hint + '
'; + } + function badge(on, onText, offText) { return '' + (on ? onText : offText) + ''; } + function tm(v) { if (!v) return ''; var d = new Date(v); return isNaN(d) ? '' : ('0' + (d.getMonth() + 1)).slice(-2) + '/' + ('0' + d.getDate()).slice(-2) + ' ' + ('0' + d.getHours()).slice(-2) + ':' + ('0' + d.getMinutes()).slice(-2); } + function sub(v, p) { var t = tm(v); return t ? '' + p + ' ' + t + '' : ''; } + function olSub(r) { if (!r) return ''; var l = r.lastRun, x = ''; if (l) x = '最近 ' + tm(l.at) + ' ↑' + (l.uploaded || 0) + ' ↓' + (l.downloaded || 0) + (l.errors && l.errors.length ? ' ⚠' : ''); var n = tm(r.nextSyncAt); if (n) x += (x ? ' · ' : '') + '下次 ' + n; return x ? '' + x + '' : ''; } + function render(o, users, bk) { + var t = o.logs.totals; + $('dash-log-error').textContent = t.error; $('dash-log-warn').textContent = t.warn; $('dash-log-info').textContent = t.info; + var h = $('dash-health'); h.className = 'dash-health ' + (t.error ? 'bad' : t.warn > 20 ? 'warn' : 'ok'); + h.textContent = t.error ? '存在错误' : t.warn > 20 ? '需关注' : '运行健康'; + var max = Math.max.apply(null, o.logs.buckets.map(function (b) { return b.info + b.warn + b.error; }).concat([1])); + $('dash-log-bars').innerHTML = o.logs.buckets.map(function (b) { + var tot = b.info + b.warn + b.error, d = new Date(b.t), hh = ('0' + d.getHours()).slice(-2); + var seg = function (n, c) { return n ? '' : ''; }; + return '
' + seg(b.error, 'e') + seg(b.warn, 'w') + seg(b.info, 'i') + '' + hh + '
'; + }).join(''); + var s = o.services; + $('dash-services').innerHTML = + '
  • WebDAV 云备份' + sub(bk.webdav && bk.webdav.nextSyncAt, '下次') + '' + badge(s.webdav, bk.webdav && bk.webdav.running ? '备份中' : '已启用', '未启用') + '
  • ' + + '
  • OpenList 备份' + olSub(bk.openlist) + '' + badge(s.openlist, bk.openlist && bk.openlist.running ? '同步中' : '已启用', '未启用') + '
  • ' + + '
  • 公共收藏' + badge(s.publicFavorites, '已开启', '已关闭') + '
  • ' + + '
  • 恢复维护模式' + badge(!s.maintenance, '正常', '维护中') + '
  • '; + var m = ''; + if (o.disk) m += meter('数据盘', o.disk.total - o.disk.free, o.disk.total, fmt(o.disk.total - o.disk.free) + ' / ' + fmt(o.disk.total)); + m += meter('进程内存 (RSS)', o.heap.used, o.heap.total, fmt(o.heap.used) + ' / ' + fmt(o.heap.total)); + $('dash-meters').innerHTML = m; + $('dash-node-info').textContent = 'Node ' + o.node + ' · PID ' + o.pid; + var online = users.filter(function (u) { return u.online; }).length, real = users.filter(function (u) { return u.name !== '_open'; }); + $('dash-users-sub').textContent = real.length + ' 个用户 · ' + online + ' 个在线'; + $('dash-users').innerHTML = real.slice(0, 6).map(function (u) { + return '
  • ' + esc(u.name) + '' + + (u.disabled ? '已禁用' : '') + + '' + (u.online ? u.onlineDevices + ' 台设备' : '离线') + '
  • '; + }).join('') || '
  • 暂无用户
  • '; + $('dash-events').innerHTML = o.logs.recent.map(function (e) { + return '
  • ' + (e.level === 'error' ? 'ERR' : 'WARN') + '' + esc(e.msg) + '
  • '; + }).join('') || '
  • 近 24 小时没有警告或错误 ✓
  • '; + } + var busy = false; + async function refresh() { + var view = $('view-dashboard'); + if (busy || !view || !view.classList.contains('active') || document.hidden || typeof app === 'undefined' || !app.password) return; + busy = true; + try { + var r = await Promise.all([app.request('/api/dashboard/overview'), app.request('/api/users'), app.request('/api/backup/status').catch(function () { return {}; })]); + if (r[0] && r[0].success) render(r[0], Array.isArray(r[1]) ? r[1] : [], r[2] || {}); + } catch (e) { /* silent: dashboard keeps last data */ } + busy = false; + } + document.addEventListener('click', function (e) { + var a = e.target.closest('[data-go]'); if (a && window.app) app.switchView(a.getAttribute('data-go')); + }); + setInterval(refresh, 10000); setTimeout(refresh, 800); + document.addEventListener('click', function (e) { if (e.target.closest('.nav-item')) setTimeout(refresh, 400); }); + + /* KPI sparklines sampled from the live stat values */ + var SP = { 'stat-cpu': [], 'stat-memory-percent': [] }; + function spark(id) { + var el = $(id); if (!el) return; var card = el.closest('.stat-card'); if (!card) return; + var v = parseFloat(el.textContent); if (isNaN(v)) return; var a = SP[id]; a.push(v); if (a.length > 40) a.shift(); + if (a.length < 2) return; var lo = Math.min.apply(null, a), hi = Math.max.apply(null, a), r = Math.max(hi - lo, 4); + var pts = a.map(function (y, i) { return (i / (a.length - 1) * 100).toFixed(1) + ',' + (28 - (y - lo) / r * 24).toFixed(1); }).join(' '); + var svg = card.querySelector('svg.spark'); + if (!svg) { svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('class', 'spark'); svg.setAttribute('viewBox', '0 0 100 30'); svg.setAttribute('preserveAspectRatio', 'none'); svg.innerHTML = ''; card.appendChild(svg); } + svg.firstChild.setAttribute('points', pts); + } + setInterval(function () { Object.keys(SP).forEach(spark); }, 3000); + + /* chart time-window switch */ + function addWindow() { + var grid = document.querySelector('#view-dashboard .monitor-grid'); if (!grid || document.getElementById('dash-win')) return; + var bar = document.createElement('div'); bar.id = 'dash-win'; bar.className = 'dash-win'; + var STEP = 3000 / 1000; + [['1 分钟', 60], ['5 分钟', 300], ['15 分钟', 900]].forEach(function (w, i) { + var b = document.createElement('button'); b.textContent = w[0]; b.className = i ? '' : 'on'; + b.onclick = function () { app.monitorWindow = Math.round(w[1] / STEP); bar.querySelectorAll('button').forEach(function (x) { x.classList.remove('on'); }); b.classList.add('on'); }; + bar.appendChild(b); + }); + grid.parentNode.insertBefore(bar, grid); + } + setTimeout(addWindow, 600);})(); diff --git a/public/index.html b/public/index.html index 115d12e0..e055ec6f 100644 --- a/public/index.html +++ b/public/index.html @@ -45,7 +45,10 @@ posthog.init('phc_vvaqX7sm3RqmiqRbMCjETTc67XKjgqeMgmYHvsZr30f', { api_host: 'https://us.i.posthog.com' }) } + + + + diff --git a/public/js/config.js b/public/js/config.js index 11ccc0cf..fc701c7e 100644 --- a/public/js/config.js +++ b/public/js/config.js @@ -2,6 +2,6 @@ // 其余配置由服务端在运行时动态注入 (环境变量 > config.js > defaultConfig.ts) // 服务端拦截 /js/config.js 请求, 使用 package.json 版本号并合并服务端配置后返回 window.CONFIG = { - buildHash: '3b28c79', - version: 'v2.1.4', + buildHash: '04a6a9b', + version: 'v2.2.0', }; diff --git a/public/js/notification-engine.js b/public/js/notification-engine.js index 95d80b12..d6408a67 100644 --- a/public/js/notification-engine.js +++ b/public/js/notification-engine.js @@ -414,8 +414,8 @@ ">${cancel_text}` : ''}
    diff --git a/public/js/ui-utils.js b/public/js/ui-utils.js index 2e8459d8..0f6eb618 100644 --- a/public/js/ui-utils.js +++ b/public/js/ui-utils.js @@ -1,92 +1,21 @@ -/* +/* UI Utilities for LX Sync Server Management Console Standardizes notifications (Toasts) and Dialogs Requires: Tailwind CSS, FontAwesome */ (function () { - // 1. Inject enhanced premium CSS - const style = document.createElement('style'); - style.textContent = ` - @keyframes toast-in { from { transform: translateX(100%) scale(0.9); opacity: 0; } to { transform: translateX(0) scale(1); opacity: 1; } } - @keyframes modal-in { from { transform: scale(0.95) translateY(10px); opacity: 0; } to { transform: scale(1) translateY(0); opacity: 1; } } - @keyframes marquee { 0% { transform: translateX(0); } 100% { transform: translateX(-50%); } } - - .lx-toast-in { animation: toast-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards; } - .lx-modal-in { animation: modal-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards; } - .animate-marquee { display: inline-block; animation: marquee 10s linear infinite; } - .pause-animation { animation-play-state: paused; } - - /* Premium Solid Style for Toasts (Better Legibility) */ - .lx-toast-card { - font-family: 'Outfit', sans-serif !important; - background: #1a1a1a !important; - border: 1px solid rgba(255, 255, 255, 0.1) !important; - box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.05) !important; - color: #ffffff !important; - } + // Presentation lives in style.css; helpers below only manage dialog state. - /* Premium Glassmorphism Overrides */ - .lx-glass { - font-family: 'Outfit', sans-serif !important; - background: rgba(255, 255, 255, 0.08) !important; - backdrop-filter: blur(20px) saturate(180%) !important; - -webkit-backdrop-filter: blur(20px) saturate(180%) !important; - border: 1px solid rgba(255, 255, 255, 0.12) !important; - box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37), inset 0 0 0 1px rgba(255, 255, 255, 0.05) !important; - } - - .lx-btn { - font-family: 'Outfit', sans-serif !important; - transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); - position: relative; - overflow: hidden; - } - .lx-btn::after { - content: ''; - position: absolute; - top: 0; left: 0; width: 100%; height: 100%; - background: linear-gradient(rgba(255,255,255,0.1), transparent); - opacity: 0; transition: opacity 0.2s; - } - .lx-btn:hover::after { opacity: 1; } - .lx-btn:active { transform: scale(0.96); } - - .lx-input { - background: rgba(0, 0, 0, 0.2) !important; - border: 1px solid rgba(255, 255, 255, 0.1) !important; - color: #fff !important; - transition: border-color 0.3s, box-shadow 0.3s; - } - .lx-input:focus { - border-color: rgba(16, 185, 129, 0.5) !important; - box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.1) !important; - } - .lx-close-btn { - background: transparent !important; - border: none !important; - box-shadow: none !important; - outline: none !important; - cursor: pointer !important; - transition: all 0.2s !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - } - .lx-close-btn:hover { - background: rgba(255, 255, 255, 0.1) !important; - } - `; - document.head.appendChild(style); /** * Fresh Toast Notification (Solid Card Style) */ function showToast(type, message, duration = 3000) { const config = { - success: { gradient: 'from-emerald-400 to-teal-500', icon: 'fa-check-circle' }, - info: { gradient: 'from-blue-400 to-indigo-500', icon: 'fa-info-circle' }, - error: { gradient: 'from-rose-400 to-red-500', icon: 'fa-exclamation-circle' } + success: { tone: 'tone-fill-success ', icon: 'fa-check-circle' }, + info: { tone: 'tone-fill-info ', icon: 'fa-info-circle' }, + error: { tone: 'tone-fill-danger ', icon: 'fa-exclamation-circle' } }; const conf = config[type] || config.info; @@ -95,13 +24,13 @@ toast.innerHTML = `
    -
    +
    -
    ${message}
    +
    ${message}
    -
    @@ -161,35 +90,35 @@ danger = false } = options; - const accentGradient = danger ? 'from-rose-400 to-red-500 shadow-rose-500/30' : 'from-emerald-400 to-teal-500 shadow-emerald-500/30'; + const accentTone = danger ? 'tone-fill-danger ' : 'tone-fill-success '; const icon = danger ? 'fa-exclamation-triangle' : 'fa-question-circle'; - const iconClass = danger ? 'text-rose-400' : 'text-emerald-400'; + const iconClass = danger ? 'tone-danger' : 'tone-success'; return new Promise((resolve) => { const modal = document.createElement('div'); modal.className = "fixed inset-0 z-[3000] flex items-center justify-center p-6 animate-fade-in"; modal.innerHTML = ` -
    +
    -
    -
    +
    -

    ${title}

    -

    ${message.replace(/\n/g, '
    ')}

    +

    ${title}

    +

    ${message.replace(/\n/g, '
    ')}

    - -
    @@ -235,30 +164,30 @@ const modal = document.createElement('div'); modal.className = "fixed inset-0 z-[3000] flex items-center justify-center p-6 animate-fade-in"; modal.innerHTML = ` -
    +
    -
    -
    +
    -

    ${title}

    -

    ${message}

    - ${title} +

    ${message}

    +
    - -
    @@ -310,7 +239,7 @@ const gap = ''; el.classList.remove('truncate'); el.classList.add('overflow-hidden'); - const maskStyle = 'mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%); -webkit-mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);'; + const maskStyle = ''; // Solid edges use the container's overflow clipping. el.innerHTML = `
    diff --git a/public/music/app.js b/public/music/app.js index 3457f55f..cb988ecc 100644 --- a/public/music/app.js +++ b/public/music/app.js @@ -71,6 +71,7 @@ window.addEventListener('lx-cast-track', event => { // Initialize Unified Search for Global (Favorites/Search) window.goToPage = function (page) { + if (isPagedNetworkResult(window.viewingPlaylist)) return window.goToNetworkResultPage(page); currentPage = page; window.currentPage = page; // 歌曲搜索的结果已一次性取回,跳页只需本地重渲染;歌手/专辑仍要向后端追加 @@ -158,9 +159,10 @@ const DEFAULT_SETTINGS = { detailVisualizerStyle: 'pulse', visualizerOpacity: 0.5, visualizerGlobalStyle: 'blocks', - vuMeterTheme: 'vintage-amber', + vuMeterTheme: 'study-nocturne', // Cache Settings enableServerCache: true, // 开启服务器缓存 + preferLocalLyrics: true, // 先读取服务器 music/cache/自定义曲库的 LRC enableServerLyricCache: true, // 开启服务器歌词文件缓存 embedLyricToFile: true, // 下载时将歌词嵌入文件(标签+.lrc) serverCacheLocation: 'root', // 缓存位置: 'data' (synced) or 'root' (local) @@ -202,6 +204,10 @@ function normalizeStoredSettings(nextSettings) { nextSettings.serverCacheNamingPattern = validPatterns.includes(nextSettings.serverCacheNamingPattern) ? nextSettings.serverCacheNamingPattern : 'simple'; + const vuThemes = ['study-nocturne', 'study-heritage', 'study-atelier', 'study-riviera', 'study-cobalt']; + if (!vuThemes.includes(nextSettings.vuMeterTheme)) { + nextSettings.vuMeterTheme = 'study-nocturne'; + } return nextSettings; } @@ -1175,6 +1181,7 @@ async function ensureUserAuthToken(options = {}) { headers: { 'x-user-token': token }, cache: 'no-store', signal: AbortSignal.timeout(5000) }); const result = response.ok ? await response.json() : null; + if ((localStorage.getItem('lx_sync_user') || '') !== username) return false; if (result?.valid && result.user === username) { userToken = token; return true; @@ -1202,6 +1209,7 @@ async function ensureUserAuthToken(options = {}) { const result = await response.json(); if (!result.success || !result.token) return false; + if ((localStorage.getItem('lx_sync_user') || '') !== username) return false; userToken = result.token; localStorage.setItem('lx_user_token', userToken); @@ -1219,20 +1227,34 @@ async function ensureUserAuthToken(options = {}) { } window.ensureUserAuthToken = ensureUserAuthToken; -async function restorePlaybackSession(allowRefresh = true) { - if (!isUserLoggedIn()) return false; +async function restorePlaybackSession(allowRefresh = true, strict = false) { + const expectedUser = localStorage.getItem('lx_sync_user') || ''; + const loggedIn = isUserLoggedIn(); + if (!loggedIn && !strict) return false; + const checkAccount = () => { + if ((localStorage.getItem('lx_sync_user') || '') !== expectedUser) { + throw Object.assign(new Error('账号已切换,请重新发起下载'), { status: 409 }); + } + }; let valid; try { // Check the cookie alone: native audio cannot attach the API token header. const response = await fetch('/api/music/auth/verify', { cache: 'no-store', signal: AbortSignal.timeout(5000) }); - if (!response.ok) return false; + if (!response.ok) { + if (strict) throw new Error('无法确认登录状态,请稍后重试'); + return false; + } const session = await response.json(); - valid = session.valid === true && session.user === localStorage.getItem('lx_sync_user'); - } catch (_) { return false; } + valid = session.valid === true && (!loggedIn || session.user === expectedUser); + } catch (error) { + if (strict) throw error; + return false; + } + if (strict) checkAccount(); if (valid !== false) return false; - if (allowRefresh) { + if (allowRefresh && loggedIn) { const token = (typeof userToken !== 'undefined' && userToken) || localStorage.getItem('lx_user_token'); if (token) { try { @@ -1241,13 +1263,27 @@ async function restorePlaybackSession(allowRefresh = true) { headers: { 'x-user-token': token }, cache: 'no-store', signal: AbortSignal.timeout(5000) }); const session = response.ok ? await response.json() : null; - if (session?.valid && session.user === localStorage.getItem('lx_sync_user')) return true; - } catch (_) { } + if (strict) checkAccount(); + if (session?.valid && session.user === expectedUser) return true; + } catch (error) { if (error.status === 409) throw error; } + } + if (strict) checkAccount(); + if (localStorage.getItem('lx_sync_pass') && await ensureUserAuthToken({ force: true })) { + if (strict) checkAccount(); + return true; } - if (localStorage.getItem('lx_sync_pass') && await ensureUserAuthToken({ force: true })) return true; } throw Object.assign(new Error('登录已失效,请重新登录'), { status: 401 }); } +window.ensureDownloadSession = async () => { + const account = localStorage.getItem('lx_sync_user') || ''; + const refreshed = await restorePlaybackSession(true, true); + // Verify that the browser actually accepted the replacement cookie. + if (refreshed) await restorePlaybackSession(false, true); + if ((localStorage.getItem('lx_sync_user') || '') !== account) { + throw Object.assign(new Error('账号已切换,请重新发起下载'), { status: 409 }); + } +}; /** * 更新顶部栏的用户状态显示 (登录按钮/用户名) @@ -1428,6 +1464,7 @@ async function handleHeaderLogout(e) { } catch (err) { } const agreementAccepted = localStorage.getItem('lx_agreement_accepted'); localStorage.clear(); + window.dispatchEvent(new Event('ratingaccountchange')); sessionStorage.clear(); if (agreementAccepted) localStorage.setItem('lx_agreement_accepted', agreementAccepted); window.location.reload(); @@ -1522,6 +1559,7 @@ async function handleLogout() { const agreementAccepted = localStorage.getItem('lx_agreement_accepted'); localStorage.clear(); + window.dispatchEvent(new Event('ratingaccountchange')); sessionStorage.clear(); if (agreementAccepted) localStorage.setItem('lx_agreement_accepted', agreementAccepted); @@ -1815,6 +1853,7 @@ function changeQualityPreference(quality) { // Tab Switching function switchTab(tabId) { + clearSearchDetailView(); // Favorites entry opens the default favorite list (love / default) or guidance view if (tabId === 'favorites') { exitListSecondaryModes(); @@ -1847,6 +1886,7 @@ function switchTab(tabId) { if (tabId !== 'search') window.SearchRequests?.cancel(); activeView.classList.remove('hidden'); + window.refreshSearchStatusVisibility?.(); // small delay to allow display block to apply before opacity transition setTimeout(() => { activeView.classList.remove('opacity-0'); @@ -1897,11 +1937,10 @@ function switchTab(tabId) { if (tabId === 'search') { initGlobalListSearch(); // [New] 强制重置 ListSearch 为 'global' 模式 currentSearchScope = 'network'; + window.refreshSearchStatusVisibility?.(); document.getElementById('search-source').classList.remove('hidden'); document.getElementById('search-type').classList.remove('hidden'); document.getElementById('page-title').innerText = "搜索音乐"; - if (typeof hideAlbumDetailHeader === 'function') hideAlbumDetailHeader(); - if (typeof hideArtistDetailHeader === 'function') hideArtistDetailHeader(); const searchInput = document.getElementById('search-input'); const searchTypeEl = document.getElementById('search-type'); @@ -2121,7 +2160,7 @@ document.addEventListener('DOMContentLoaded', () => { // ==================== 播放队列 (Queue) 逻辑 ==================== let isQueueRendered = false; -function toggleQueueDrawer() { +function toggleQueueDrawer(targetIndex = null) { const drawer = document.getElementById('queue-drawer'); if (!drawer) return; @@ -2131,7 +2170,8 @@ function toggleQueueDrawer() { renderQueue(); // 自动定位当前歌曲 setTimeout(() => { - scrollToCurrentSongInQueue(false); + if (Number.isInteger(targetIndex)) scrollToSongInQueue(targetIndex); + else scrollToCurrentSongInQueue(false); }, 350); // 等待抽屉打开动画完成 // Hide sidebar if open on mobile @@ -2148,6 +2188,21 @@ function toggleQueueDrawer() { } window.toggleQueueDrawer = toggleQueueDrawer; +function scrollToSongInQueue(index) { + const item = document.getElementById('queue-list')?.children[index]; + if (!item) return; + item.scrollIntoView({ behavior: 'smooth', block: 'center' }); + item.classList.add('ring-2', 'ring-emerald-500/50', 'ring-inset'); + setTimeout(() => item.classList.remove('ring-2', 'ring-emerald-500/50', 'ring-inset'), 1000); +} + +function revealSongInQueue(index) { + const drawer = document.getElementById('queue-drawer'); + if (!drawer) return; + if (drawer.classList.contains('translate-x-full')) toggleQueueDrawer(index); + else scrollToSongInQueue(index); +} + /** * 定位播放队列中当前播放的歌曲 * @param {boolean} flash 是否显示闪烁提醒效果 @@ -2214,7 +2269,7 @@ function renderQueue() {
    ${createMarqueeHtml(song.name, 'text-sm font-bold ' + (isActive ? 'text-emerald-500' : 't-text-main'))} -
    +
    ${getSourceTag(song.source)} ${getQualityTags(song)} ${createMarqueeHtml(song.singer, 'text-[10px] t-text-muted flex-1')} @@ -2349,6 +2404,7 @@ window.clearQueue = clearQueue; // Search Logic function handleSearchKeyPress(e) { + if (e.isComposing || e.keyCode === 229 || window.SearchSuggestions?.composing) return; if (e.key === 'Enter') { if (typeof hideSearchSuggestions === 'function') hideSearchSuggestions(); doSearch(); @@ -2381,6 +2437,7 @@ function performSearch(query, source = null) { // 重置搜索范围到全网搜索 if (typeof currentSearchScope !== 'undefined') { currentSearchScope = 'network'; + window.refreshSearchStatusVisibility?.(); } // 设置搜索框内容 @@ -2419,6 +2476,120 @@ window.handleSearchTypeChange = handleSearchTypeChange; const SOURCES = ['kw', 'kg', 'tx', 'wy', 'mg']; +function isPagedNetworkResult(list) { + const detailVisible = ['artist-detail-header', 'album-detail-header'].some(id => { + const header = document.getElementById(id); + return header && !header.classList.contains('hidden'); + }); + return window.currentSearchScope === 'network' && !!window.networkSongSearch && + !detailVisible && !window.glSortMode && window.networkSongSearch.state.list === list; +} + +function renderNetworkSearchFooter(visible) { + const footer = document.getElementById('network-search-more'); + if (!footer) return; + footer.classList.toggle('hidden', !visible); + if (!visible) return; + const state = window.networkSongSearch.state; + const remaining = state.providers.filter(provider => !provider.done); + const failed = state.providers.filter(provider => provider.error); + const info = document.getElementById('network-search-count'); + if (info) info.textContent = `已加载 ${state.list.length} 首` + (failed.length ? ` · ${failed.map(p => getSourceName(p.source)).join('、')}暂未加载成功` : ''); + const button = document.getElementById('network-search-load-more'); + if (button) { + button.disabled = state.loading || window.networkSongSearch.navigating || !remaining.length; + button.textContent = state.loading ? '正在加载…' : !remaining.length ? '已加载全部可用结果' : failed.length ? '加载更多 / 重试失败音源' : '加载更多'; + } + footer.setAttribute('aria-busy', String(state.loading)); + if (state.loading || window.networkSongSearch.navigating) { + const next = document.getElementById('search-btn-next'); + if (next) next.disabled = true; + } +} + +async function searchNetworkSongs(keyword, source, headers, request, append = false) { + const search = append ? window.networkSongSearch : window.PagedMusicSearch.create({ + keyword, sources: source === 'all' ? SOURCES : [source], limit: 30, + }); + if (!append) window.networkSongSearch = search; + const current = () => request.current() && window.networkSongSearch === search && + window.currentSearchScope === 'network'; + window.serverPagedSearch = false; + window.lastSearchMeta = null; + const loading = search.load({ + current, + fetchPage: (provider, page, limit) => request.json(`${API_BASE}/search?name=${encodeURIComponent(keyword)}&source=${provider}&page=${page}&limit=${limit}&type=song`, headers, 10000), + progress: (done, total) => window.setSearchStatus(`正在加载:${done}/${total} 个音源已完成`, true), + }); + renderNetworkSearchFooter(append); + await loading; + if (!current()) { + if (isPagedNetworkResult(window.viewingPlaylist)) renderNetworkSearchFooter(true); + return; + } + const state = search.state; + const failed = state.providers.filter(provider => provider.error); + const message = `已加载 ${state.list.length} 首 · ${state.providers.filter(p => p.page > 0).length}/${state.providers.length} 个音源已返回` + + (failed.length ? ` · ${failed.map(p => getSourceName(p.source)).join('、')}可重试` : ''); + window.setSearchStatus(message, false); + if (!append) currentPage = 1; + window.currentPage = currentPage; + window.savedNetworkSearchState = { + keyword, source, type: 'song', list: state.list, page: currentPage, meta: null, serverPagedSearch: false, + }; + // The new round stays behind existing rows. Preserve scroll and selected IDs. + const container = document.getElementById('search-results'); + const scrollTop = container?.scrollTop || 0; + window.viewingPlaylist = state.list; + if (append && window.ListSearch?.state.active) window.ListSearch.handleSearch(); + else renderResults(state.list); + if (!state.list.length && failed.length && container) container.textContent = '暂未获得结果,可点击下方按钮重试失败音源。'; + if (append && container) container.scrollTop = scrollTop; + renderNetworkSearchFooter(true); +} + +window.loadMoreNetworkSongs = async function ({ navigation = false } = {}) { + const search = window.networkSongSearch; + if (!search || search.state.loading || (search.navigating && !navigation) || !isPagedNetworkResult(window.viewingPlaylist)) return; + if (document.getElementById('search-input')?.value.trim() !== search.state.keyword) return; + const request = window.SearchRequests.begin(); + const headers = { ...getUserAuthHeaders() }; + if (typeof authToken !== 'undefined' && authToken) headers['x-user-token'] = authToken; + await searchNetworkSongs(search.state.keyword, window.savedNetworkSearchState?.source || 'all', headers, request, true); + return request.current() && window.networkSongSearch === search; +}; + +window.goToNetworkResultPage = async function (page) { + const search = window.networkSongSearch; + if (!search || search.state.loading || search.navigating || !isPagedNetworkResult(window.viewingPlaylist)) return; + const displayCount = () => window.ListSearch ? window.ListSearch.getDisplayList(search.state.list).length : search.state.list.length; + const showAll = settings.itemsPerPage === 'all'; + const size = Math.max(1, parseInt(settings.itemsPerPage) || 20); + const target = Math.max(1, Math.floor(Number(page) || 1)); + const originPage = currentPage; + const current = () => window.networkSongSearch === search && isPagedNetworkResult(window.viewingPlaylist); + search.navigating = true; + try { + // Fill a partial page before moving onto it, so newly fetched rows are + // not silently skipped between the old last page and the next one. + while ((showAll || displayCount() < target * size) && search.state.providers.some(p => !p.done)) { + const before = search.state.list.length; + const beforeDisplay = displayCount(); + const committed = await window.loadMoreNetworkSongs({ navigation: true }); + if (!committed || !current() || currentPage !== originPage) return; + if (showAll || search.state.list.length === before || displayCount() === beforeDisplay || search.state.providers.some(p => p.error)) break; + } + if (!current()) return; + const maxPage = showAll ? 1 : Math.max(1, Math.ceil(displayCount() / size)); + goToResultPage(Math.min(target, maxPage)); + } finally { + search.navigating = false; + if (current()) { + renderResults(search.state.list); + renderNetworkSearchFooter(true); + } + } +}; //搜索歌曲 async function doSearch(page = 1, append = false) { @@ -2429,6 +2600,7 @@ async function doSearch(page = 1, append = false) { } const request = window.SearchRequests.begin(); window.setSearchStatus(); + renderNetworkSearchFooter(false); const typeEl = document.getElementById('search-type'); const type = typeEl ? typeEl.value : 'song'; @@ -2502,7 +2674,7 @@ async function doSearch(page = 1, append = false) { const source = document.getElementById('search-source').value; localStorage.setItem('search-source', source); - // 只有歌手/专辑/歌单搜索还是「一次一页」,需要靠下一页追加;歌曲搜索一次取回全量后纯本地分页 + // 歌手/专辑/歌单沿用原分页;网络歌曲由独立状态管理器分批加载。 const appendableServerSearch = type !== 'song'; window.serverPagedSearch = appendableServerSearch; @@ -2533,6 +2705,12 @@ async function doSearch(page = 1, append = false) { if (typeof authToken !== 'undefined' && authToken) headers['x-user-token'] = authToken; Object.assign(headers, getUserAuthHeaders()); + if (type === 'song') { + clearSearchDetailView(); + await searchNetworkSongs(input, source, headers, request); + return; + } + const pageInfoEl = document.getElementById('page-info'); let list = []; if (source === 'all') { @@ -2754,7 +2932,7 @@ function renderHotSearch(data) { if (!container || !data || !data.list || data.list.length === 0 || settings.hotSearchLimit === 0) { // 显示默认空白状态 container.innerHTML = ` -
    +

    输入关键词开始搜索音乐

    @@ -2846,7 +3024,7 @@ function switchHotSearchTab(source) { const container = document.getElementById('search-results'); if (container) { container.innerHTML = ` -
    +

    正在加载热门搜索...

    @@ -2872,8 +3050,11 @@ function handleHotSearchClick(keyword) { function showInitialSearchState() { window.showInitialSearchState = showInitialSearchState; + hideSearchSuggestions(); const initialRequest = window.SearchRequests?.begin(); window.setSearchStatus?.(); + window.networkSongSearch = null; + renderNetworkSearchFooter(false); document.getElementById('search-pagination-bar')?.classList.add('hidden'); const container = document.getElementById('search-results'); const header = document.getElementById('search-results-header'); @@ -2885,7 +3066,7 @@ function showInitialSearchState() { // 显示加载状态 container.innerHTML = ` -
    +

    正在加载热门搜索...

    @@ -2903,7 +3084,7 @@ function showInitialSearchState() { console.error('[HotSearch] 显示热搜失败:', err); // 失败时显示默认状态 container.innerHTML = ` -
    +

    输入关键词开始搜索音乐

    @@ -3154,10 +3335,14 @@ window.currentArtistSource = 'wy'; window.currentArtistOrder = 'hot'; async function enterArtist(id, source = 'wy', order = 'hot', tab = 'songs', isBack = false) { + window.SearchRequests?.cancel(); + renderNetworkSearchFooter(false); + rememberSearchDetailOrigin(); const typeEl = document.getElementById('search-type'); // 记录返回状态 (仅当从非歌手列表进入 且 不是从子页面返回时) - if (!isBack && document.getElementById('artist-detail-header') === null) { + const existingArtistHeader = document.getElementById('artist-detail-header'); + if (!isBack && (!existingArtistHeader || existingArtistHeader.classList.contains('hidden'))) { lastSearchType = typeEl ? typeEl.value : 'singer'; lastSearchResultList = [...(window.viewingPlaylist || [])]; currentArtistInfo = null; // 重置缓存 @@ -3758,7 +3943,7 @@ function renderArtistSongsUI(list, page) {
    ${item.name}
    -
    +
    ${getSourceTag ? getSourceTag(item.source) : ''} ${getQualityTags ? getQualityTags(item) : ''} @@ -3783,6 +3968,7 @@ function renderArtistSongsUI(list, page) {
    + @@ -3829,6 +4015,7 @@ function renderArtistSongsUI(list, page) { // Init Marquee if needed (though we use truncate here) if (window.applyMarqueeChecks) applyMarqueeChecks(); if (typeof initArtistScrollListener === 'function') initArtistScrollListener(); + content.querySelectorAll('[data-rating-index]').forEach(button => window.RatingManager?.attach(button, list[Number(button.dataset.ratingIndex)])); window.ServerFileState?.paintBadges(content); } window.renderArtistSongsUI = renderArtistSongsUI; @@ -3989,7 +4176,7 @@ function renderArtistAlbumsUI(list) {
    -
    +
    @@ -4244,6 +4431,27 @@ function hideAlbumDetailHeader() { } window.hideAlbumDetailHeader = hideAlbumDetailHeader; +// Sidebar destinations reuse the search view, including its sticky detail bars. +function clearSearchDetailView() { + window.searchDetailOrigin = null; + hideAlbumDetailHeader(); + hideArtistDetailHeader(); + window.tempAlbumContext = null; + window.tempArtistContext = null; + const backBtn = document.getElementById('search-back-btn'); + if (backBtn) backBtn.classList.add('hidden'); +} + +function rememberSearchDetailOrigin() { + if (window.searchDetailOrigin) return; + window.searchDetailOrigin = { + scope: currentSearchScope, + listId: window.currentViewingListId, + list: [...(window.viewingPlaylist || [])], + type: document.getElementById('search-type')?.value, + }; +} + function updateAlbumHeaderFavButton(favorited) { // Hero Favorite Button const btn = document.getElementById('album-header-fav-btn'); @@ -4578,27 +4786,33 @@ async function navigateToAlbumArtist(singerName, source, artistId) { window.navigateToAlbumArtist = navigateToAlbumArtist; window.searchSingerByName = navigateToAlbumArtist; -async function enterAlbum(id, source = 'wy', initialInfo = null) { +async function enterAlbum(id, source = 'wy', initialInfo = null, isBack = false) { + window.SearchRequests?.cancel(); + renderNetworkSearchFooter(false); + rememberSearchDetailOrigin(); // 保存进入专辑前的上下文,如果是从歌手页进入,则记录歌手 ID const artistHeader = document.getElementById('artist-detail-header'); - if (artistHeader) { + const isArtistDetailVisible = artistHeader && !artistHeader.classList.contains('hidden'); + if (isArtistDetailVisible) { window.tempArtistContext = { id: window.currentArtistId, source: window.currentArtistSource, tab: window.currentArtistTab || 'albums', order: window.currentArtistOrder || 'hot' }; - artistHeader.remove(); // 进入专辑详情时移除歌手头部,保持界面整洁 } else if (!window.tempAlbumContext) { window.tempArtistContext = null; } const typeEl = document.getElementById('search-type'); - if (!artistHeader && !window.tempAlbumContext) { + if (!isArtistDetailVisible && !window.tempAlbumContext) { lastSearchType = typeEl ? typeEl.value : 'album'; lastSearchResultList = [...(window.viewingPlaylist || [])]; } - window.history.pushState({ ...window.history.state, page: 'search-detail' }, ''); + if (!isBack) window.history.pushState({ ...window.history.state, page: 'search-detail' }, ''); + + // This header is a permanent layout node required when reopening an artist. + hideArtistDetailHeader(); const resultsContainer = document.getElementById('search-results'); resultsContainer.innerHTML = '
    '; @@ -4685,6 +4899,7 @@ function goBackToSearch(fromPopState = false) { if (!fromPopState && window.history.state && window.history.state.page === 'search-detail') { try { window.history.back(); + return; // Restore once, in popstate, after the history entry changes. } catch (e) { console.warn('history.back error:', e); } @@ -4699,7 +4914,7 @@ function goBackToSearch(fromPopState = false) { const ctx = window.tempAlbumContext; window.tempAlbumContext = null; if (ctx.id) { - enterAlbum(ctx.id, ctx.source || 'wy', ctx.initialInfo); + enterAlbum(ctx.id, ctx.source || 'wy', ctx.initialInfo, true); return; } } @@ -4715,6 +4930,21 @@ function goBackToSearch(fromPopState = false) { const backBtn = document.getElementById('search-back-btn'); if (backBtn) backBtn.classList.add('hidden'); + const origin = window.searchDetailOrigin; + window.searchDetailOrigin = null; + if (origin) { + currentSearchScope = origin.scope; + window.refreshSearchStatusVisibility?.(); + lastSearchResultList = origin.list; + lastSearchType = origin.type; + currentArtistId = null; + if (origin.scope === 'lib_artists') return handleArtistLibraryClick(); + if (origin.scope === 'lib_albums') return handleAlbumLibraryClick(); + if (origin.listId === 'dislike_artists') return handleDislikeArtistsClick(); + if (origin.listId === 'dislike_albums') return handleDislikeAlbumsClick(); + if (origin.scope === 'local_list' && origin.listId) return handleListClick(origin.listId, true, true); + } + // 恢复搜索/收藏结果列表 if (lastSearchResultList && lastSearchResultList.length > 0) { const header = document.getElementById('search-results-header'); @@ -4729,7 +4959,11 @@ function goBackToSearch(fromPopState = false) { } else if (lastSearchType === 'album') { renderAlbumResults(lastSearchResultList); } else { - renderResults(lastSearchResultList); + const savedSongs = window.networkSongSearch?.state.list; + const restorePaged = currentSearchScope === 'network' && savedSongs && + savedSongs.length === lastSearchResultList.length && + savedSongs.every((song, index) => song.id === lastSearchResultList[index].id); + renderResults(restorePaged ? savedSongs : lastSearchResultList); } const pageInfoEl = document.getElementById('page-info'); @@ -4984,6 +5218,8 @@ function renderResults(list) { // 搜索歌曲时恢复底部分页栏显示(非排序模式下) const paginationBar = document.getElementById('search-pagination-bar'); if (paginationBar && !window.glSortMode) paginationBar.classList.remove('hidden'); + const pagedNetwork = isPagedNetworkResult(list); + renderNetworkSearchFooter(pagedNetwork); // 重置歌手详情分页(进入歌曲搜索视图时清空) window.artistSongsPage = 1; // Update Header @@ -5004,7 +5240,11 @@ function renderResults(list) { window.viewingPlaylist = list; if (!list || list.length === 0) { - container.innerHTML = '
    未找到相关结果
    '; + const emptyDislike = window.currentViewingListId === 'dislike_songs' && !document.getElementById('gl-local-search-input')?.value?.trim() && !document.getElementById('search-input')?.value?.trim(); + if (emptyDislike) { + if (header) header.classList.add('hidden'); + container.innerHTML = '

    还没有不喜欢任何歌曲

    在歌曲操作菜单中点击“不喜欢”即可添加
    '; + } else container.innerHTML = '
    未找到相关结果
    '; updatePaginationInfo(0, 0, 0, 1, 1); return; } @@ -5108,7 +5348,7 @@ function renderResults(list) {
    ${createMarqueeHtml(item.name)}
    -
    +
    ${getSourceTag(item.source)} ${getQualityTags(item)} @@ -5174,6 +5414,12 @@ function renderResults(list) { menu.addEventListener('click', event => event.stopPropagation()); menu.querySelector('[data-queue-next]').onclick = () => { enqueueSong(item, true); menu.open = false; }; menu.querySelector('[data-queue-add]').onclick = () => { enqueueSong(item); menu.open = false; }; + const rateButton = document.createElement('button'); + rateButton.type = 'button'; + rateButton.className = 'rating-menu-item'; + menu.querySelector('.queue-actions-menu').appendChild(rateButton); + window.RatingManager?.attach(rateButton, item); + window.RatingManager?.attachTag(row.querySelector('[data-rating-badges]'), item); const popup = menu.querySelector('.queue-actions-menu'); if (typeof popup.showPopover === 'function') { popup.setAttribute('popover', 'auto'); @@ -5341,7 +5587,7 @@ function applyMarqueeChecks(root = document) { wrapper.className = 'w-full relative'; wrapper.setAttribute('style', maskStyle); const track = document.createElement('div'); - track.className = 'inline-block whitespace-nowrap animate-marquee hover:pause-animation'; + track.className = 'inline-block whitespace-nowrap animate-marquee'; const firstText = document.createElement('span'); firstText.textContent = text; const firstGap = document.createElement('span'); @@ -5451,7 +5697,8 @@ function getSourceTypeText(sourceType, folder) { const map = { 'server_cache': folder === 'music' ? '已下载文件' : '服务器缓存', 'cache': '浏览器链接缓存', - 'normal': '在线解析' + 'normal': '在线解析', + 'openlist': 'OpenList 已备份文件' }; return map[sourceType] || '解析成功'; } @@ -5488,6 +5735,7 @@ async function probeUrl(url) { } const prefetchManager = { + generation: 0, cache: new Map(), // Map bufferer: new Audio(), // 隐藏的缓冲器,用于预加载数据流 @@ -5496,12 +5744,17 @@ const prefetchManager = { this.bufferer.preload = 'auto'; // 强制浏览器尽可能多地预缓冲 }, - set(songId, data) { - this.cache.set(songId, { ...data, timestamp: Date.now() }); + key(song, quality) { + const cleaned = cleanSongData(song); + return JSON.stringify([cleaned.source || '', String(cleaned.id), quality]); + }, + + set(song, quality, data) { + this.cache.set(this.key(song, quality), { ...data, timestamp: Date.now() }); // 核心升级:触发数据流预加载 if (data.url) { - console.log(`[Prefetch] Pre-loading data stream for ID: ${songId}`); + console.log(`[Prefetch] Pre-loading data stream for ID: ${song.id}`); this.bufferer.src = data.url; this.bufferer.load(); // 诱导浏览器开始填充缓冲区 } @@ -5511,20 +5764,66 @@ const prefetchManager = { this.cache.delete(oldestKey); } }, - get(songId) { - const data = this.cache.get(songId); + get(song, quality) { + const key = this.key(song, quality); + const data = this.cache.get(key); if (data && (Date.now() - data.timestamp < 30 * 60 * 1000)) { + const explicitLocal = song.isLocal || song.url?.startsWith('/api/music/cache/file/'); + if (settings.preferServerCache === false && !explicitLocal && + (data.sourceType === 'server_cache' || data.sourceType === 'openlist')) { + this.cache.delete(key); + return null; + } return data; } + this.cache.delete(key); return null; }, + invalidate(song, quality, expected) { + const key = this.key(song, quality); + // 只清除本次播放使用的记录,保留并发预读写入的新结果。 + if (expected && this.cache.get(key) === expected) this.cache.delete(key); + }, + stop() { + this.generation++; + this.bufferer.removeAttribute('src'); + this.bufferer.load(); + }, clear() { + this.stop(); this.cache.clear(); - this.bufferer.src = ''; } }; prefetchManager.init(); // 立即初始化缓冲器 +async function refreshPrefetchedCache(song, result) { + if (settings.preferServerCache === false || result.sourceType === 'server_cache') return result; + + // 预读解析后,后台下载可能已经完成;播放接管前重新检查文件缓存。 + const playbackSong = result.switchedSource && result.songInfo ? result.songInfo : song; + try { + // 这里只补查本地已完成的文件,不等待远端 OpenList 探测。 + const cached = await checkServerCache(cleanSongData(playbackSong), result.quality, false, false, AbortSignal.timeout(1200)); + if (!cached.exists || cached.isCollision) return result; + const url = await applyAutoProxy(cached.url, playbackSong); + const quality = cached.quality || result.quality; + checkAndTriggerBackgroundCacheUpgrade(cleanSongData(playbackSong), result.quality, quality); + return { + ...result, + url, + quality, + sourceType: cached.origin === 'openlist' ? 'openlist' : 'server_cache', + folder: cached.folder, + sourceName: undefined, + sourceId: undefined, + hasMoreSources: false + }; + } catch (e) { + console.warn('[Prefetch] Cache refresh failed, keeping preloaded URL:', e); + return result; + } +} + // --- URL Fetching Logic (Unified Resolution) --- /** @@ -5537,7 +5836,7 @@ async function resolveSongUrl(song, quality, isSilent = false, isRetry = false, if (result.errorMsg) throw new Error(result.errorMsg); return result; } catch (error) { - if (disableFallback) { + if (disableFallback || error.errorCode === 'NO_AVAILABLE_SOURCE') { throw error; } @@ -5625,6 +5924,7 @@ async function resolveDownloadSongUrl(song, quality, isSilent = true) { if (result.errorMsg) throw new Error(result.errorMsg); return result; } catch (err) { + if (err.errorCode === 'NO_AVAILABLE_SOURCE') throw err; lastError = err; console.warn(`[DownloadResolve] 解析失败: ${candidateSong.name} via ${candidateSong.source} (${targetQuality})`, err); return null; @@ -6001,7 +6301,7 @@ async function fetchSongUrl(song, quality, isRetry = false, isSilent = false, op const allowServerCache = settings.preferServerCache !== false && !shouldBypassServerCache; if (allowServerCache) { - let cacheResult = await checkServerCache(cleanedSong, quality, !!isRetry); + let cacheResult = await checkServerCache(cleanedSong, quality, !!isRetry, !isRetry); if (cacheResult.exists && !cacheResult.isCollision) { const actualQuality = cacheResult.quality || quality; console.log(`[Cache] Server Hit: ${cleanedSong.name} (${actualQuality})`); @@ -6011,7 +6311,7 @@ async function fetchSongUrl(song, quality, isRetry = false, isSilent = false, op if (typeof checkAndTriggerBackgroundCacheUpgrade === 'function') { checkAndTriggerBackgroundCacheUpgrade(cleanedSong, quality, actualQuality); } - return { url: serverCacheUrl, sourceType: 'server_cache', quality: actualQuality, folder: cacheResult.folder }; + return { url: serverCacheUrl, sourceType: cacheResult.origin === 'openlist' ? 'openlist' : 'server_cache', quality: actualQuality, folder: cacheResult.folder }; } } @@ -6099,7 +6399,7 @@ async function fetchSongUrl(song, quality, isRetry = false, isSilent = false, op result = await res.json(); if (result.error) errorMsg = result.error; } catch (e) { } - throw { message: errorMsg, status: res.status, attempts: result.attempts }; + throw { message: errorMsg, status: res.status, errorCode: result.errorCode, attempts: result.attempts }; } const result = await res.json(); @@ -6177,8 +6477,19 @@ function getNextIndex() { } // Prefetch next song helper (Recursive Discovery) -async function prefetchNextSong(startFromIndex = null, depth = 0) { +async function prefetchNextSong(startFromIndex = null, depth = 0, generation = null) { if (settings.enablePreloader === false || depth > 5) return; + if (generation === null) { + prefetchManager.stop(); + generation = prefetchManager.generation; + } + if (generation !== prefetchManager.generation) return; + const playlist = currentPlaylist.slice(); + const originIndex = currentIndex; + const originMode = playMode; + const isCurrent = () => settings.enablePreloader !== false && + generation === prefetchManager.generation && originIndex === currentIndex && originMode === playMode && + playlist.length === currentPlaylist.length && playlist.every((song, index) => song === currentPlaylist[index]); let targetIndex = startFromIndex; if (targetIndex === null) { @@ -6195,41 +6506,53 @@ async function prefetchNextSong(startFromIndex = null, depth = 0) { try { const targetQual = window.QualityManager.getBestQuality(nextSong, settings.preferredQuality || 'flac'); + const canCommit = () => isCurrent() && targetQual === window.QualityManager.getBestQuality(nextSong, settings.preferredQuality || 'flac'); // 1. 检查内存缓存 - let result = prefetchManager.get(nextSong.id); + let result = prefetchManager.get(nextSong, targetQual); if (result) { - if (await probeUrl(result.url)) return; - prefetchManager.cache.delete(nextSong.id); + const alive = await probeUrl(result.url); + if (!canCommit()) return; + if (alive) { + prefetchManager.set(nextSong, targetQual, result); + return; + } + prefetchManager.invalidate(nextSong, targetQual, result); } // 2. 复用统一解析逻辑 (resolveSongUrl),且开启静默模式 result = await resolveSongUrl(nextSong, targetQual, true); + if (!canCommit()) return; // 3. 探活获取到的链接 - if (!(await probeUrl(result.url))) { + const alive = await probeUrl(result.url); + if (!canCommit()) return; + if (!alive) { localStorage.removeItem(`lx_url_${cleanSongData(nextSong).id}_${targetQual}`); result = await resolveSongUrl(nextSong, targetQual, true, true); + if (!canCommit()) return; } - prefetchManager.set(nextSong.id, result); + prefetchManager.set(nextSong, targetQual, result); const sourceDesc = getSourceTypeText(result.sourceType, result.folder); console.log(`[Prefetch] Readied: ${nextSong.name} (${result.quality} / ${sourceDesc})`); } catch (e) { + if (!isCurrent()) return; // 预读失败可能只是临时断网,不能据此让前台播放永久跳过这首歌。 console.warn(`[Prefetch] Could not preload [${nextSong.name}]:`, e.message); + if (e.errorCode === 'NO_AVAILABLE_SOURCE') return; // 递归探测 const followingIndex = (targetIndex + 1) >= currentPlaylist.length ? 0 : targetIndex + 1; if (followingIndex !== currentIndex) { - return prefetchNextSong(followingIndex, depth + 1); + return prefetchNextSong(followingIndex, depth + 1, generation); } } } // --- Server Cache Helpers --- -async function checkServerCache(song, quality, exactQuality = false) { +async function checkServerCache(song, quality, exactQuality = false, includeOpenlist = false, signal = undefined) { try { const username = currentListData?.username || ''; const params = new URLSearchParams({ @@ -6238,13 +6561,14 @@ async function checkServerCache(song, quality, exactQuality = false) { source: song.source, songmid: song.songmid || (song.meta && (song.meta.songmid || song.meta.songId)) || '', songId: song.songId || (song.meta && song.meta.songId) || song.id, - quality: quality || '' + quality: quality || '', + includeOpenlist: includeOpenlist ? '1' : '0' }); if (exactQuality) params.append('exactQuality', '1'); const headers = {}; Object.assign(headers, getUserAuthHeaders()); - const res = await fetch(`/api/music/cache/check?${params}`, { headers }); + const res = await fetch(`/api/music/cache/check?${params}`, { headers, signal }); if (res.ok) { const data = await res.json(); return data; // 返回完整数据对象,包含 exists, isCollision, url 等 @@ -6558,6 +6882,8 @@ async function triggerServerCache(song, url, quality) { url, quality, namingPattern: window.settings?.serverCacheNamingPattern || 'simple', + enableOnlyDownloadMode: !!window.settings?.enableOnlyDownloadMode, + cacheLyric: window.settings?.enableServerLyricCache !== false, embedLyric: !!(window.settings?.embedLyricToFile ?? true) }) }); @@ -6752,7 +7078,7 @@ async function showServerDirectories() {
    -
    • 缓存位置:选择「DATA_PATH」会将文件存放于 WebDAV 同步目录下,备份时可一并打包;选择「运行目录」则仅保存在当前服务器本地。
    +
    • 缓存位置:选择「DATA_PATH」将文件放在数据目录下;常规备份只包含所选 data/music 内容,始终排除 cache。运行目录及自定义外部曲库需另行备份。
    • 仅下载模式:开启后下载路径变更为 music${sep}${data.username};关闭时统一保存在 cache${sep}${data.username}。
    @@ -6809,6 +7135,43 @@ window.showServerDirectories = showServerDirectories; */ function enqueueSong(song, next = false) { if (!song) return; + // Default-list entries have source-prefixed IDs and provider IDs in meta. + // Normalize both sides exactly as addToDefaultList does. + const queueSongId = item => { + if (!item || !(item.meta?.songId || item.songId || item.songmid || item.id != null)) return null; + return cleanSongData(item).id; + }; + const targetId = queueSongId(song); + const matchesSong = item => item === song || ( + targetId != null && queueSongId(item) != null && + String(queueSongId(item)) === String(targetId) && item.source === song.source + ); + const existingIndex = currentPlaylist.findIndex(matchesSong); + if (existingIndex !== -1) { + if (!next) { + showInfo('该歌曲已在播放队列中'); + revealSongInQueue(existingIndex); + return; + } + if (matchesSong(currentPlaylist[currentIndex])) { + showInfo('该歌曲正在播放'); + revealSongInQueue(currentIndex); + return; + } + // Keep the playing song selected when moving an entry from before it. + const queuedSong = currentPlaylist[existingIndex]; + for (let i = currentPlaylist.length - 1; i >= 0; i--) { + if (!matchesSong(currentPlaylist[i])) continue; + currentPlaylist.splice(i, 1); + if (i < currentIndex) currentIndex--; + } + currentPlaylist.splice(Math.max(0, currentIndex + 1), 0, queuedSong); + preSelectedNextIndex = null; + renderQueue(); + savePlaybackState(); + showSuccess('已移至下一首播放'); + return; + } if (currentPlaylist.length >= 1000) { showInfo('播放队列最多 1000 首歌曲'); return; } const position = next ? Math.max(0, currentIndex + 1) : currentPlaylist.length; currentPlaylist.splice(position, 0, song); @@ -6858,6 +7221,21 @@ async function runRecoveryFlow(error, savedResumeTime, state = currentRecoverySt } if (state !== currentRecoveryState) return; + // 无源是账号配置问题,换音质、换歌曲均无法修复;保留已完成的本地/缓存尝试。 + if (error?.errorCode === 'NO_AVAILABLE_SOURCE') { + if (window._autoSkipTimer) clearTimeout(window._autoSkipTimer); + window._autoSkipTimer = null; + shouldAutoRecoverPlayback = false; + setPlayerStatus('暂无可用音源,请在设置中添加或启用'); + RecoveryToast.error(error.message); + updatePlayButton(false); + return; + } + + if (error?.errorCode === 'PLATFORM_SOURCE_UNAVAILABLE') { + state.samePlatformExhausted = true; + } + const currentPlatform = state.currentSong?.source || state.originalSong?.source; // 1. 记录当前失败的音源脚本 @@ -6951,6 +7329,11 @@ async function runRecoveryFlow(error, savedResumeTime, state = currentRecoverySt console.log(`[Recovery] Executing recovery step: ${currentStep} (${currentStepIndex + 1}/${steps.length})`); if (currentStep === 'degrade') { + if (error?.errorCode === 'PLATFORM_SOURCE_UNAVAILABLE') { + state.currentStepIndex++; + await runRecoveryFlow(error, effectiveResumeTime, state); + return; + } const nextQuality = window.QualityManager.getNextLowerQuality(state.currentQuality, state.currentSong); if (nextQuality && !state.triedQualities.includes(nextQuality)) { state.currentQuality = nextQuality; @@ -7061,6 +7444,7 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr } const thisRequestId = ++loadingRequestCounter; + prefetchManager.stop(); currentLoadingSongId = thisRequestSongId; currentLoadingRequestId = thisRequestId; shouldAutoRecoverPlayback = false; @@ -7180,16 +7564,20 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr let targetQuality = forceQuality; let isPrefetchFound = false; let urlResult = null; + let prefetchedResult = null; + let prefetchQuality = null; // 提前检查预读缓存,以便淡出逻辑使用 if (!targetQuality && !isRetry) { - urlResult = prefetchManager.get(song.id); + prefetchQuality = window.QualityManager.getBestQuality(song, settings.preferredQuality || 'flac'); + urlResult = prefetchManager.get(song, prefetchQuality); + prefetchedResult = urlResult; if (urlResult) { urlResult.isPrefetch = true; isPrefetchFound = true; // [Optimize] 既然主播放器即将接管该 URL,立即清空缓冲器 src 以停止其后台加载 - prefetchManager.bufferer.src = ''; + // stop() 已在本次播放请求开始时停止预缓冲并作废旧任务。 } } @@ -7198,12 +7586,27 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr await fadeVolume(0, 300); } + if (currentLoadingRequestId !== thisRequestId) return; if (!noPlay) { try { audio.pause(); } catch (e) { } } + // A fresh request must not expose the previous track's position while resolving. + // Keep _resumeInfo intact: refresh/reconnect resumes are validated by song below. + if (!isRetry) { + try { audio.currentTime = 0; } catch (e) { } + lastKnownPlaybackTime = 0; + document.getElementById('time-current').innerText = formatTime(0); + document.getElementById('progress-bar').style.width = '0%'; + } updatePlayButton(false); + let sourceAssigned = false; try { + if (urlResult) { + urlResult = await refreshPrefetchedCache(song, urlResult); + if (currentLoadingRequestId !== thisRequestId) return; + } + // 1. 智能音质选择与 URL 解析 if (!urlResult) { if (!targetQuality) { @@ -7231,6 +7634,7 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr if (urlResult.isPrefetch) { let detail = '解析成功'; if (urlResult.sourceType === 'cache') detail = '命中缓存链接'; + else if (urlResult.sourceType === 'openlist') detail = '命中 OpenList 文件'; else if (urlResult.sourceType === 'server_cache') detail = urlResult.folder === 'music' ? '命中已下载文件' : '命中服务器缓存'; else if (sourceName) detail = `${sourceName} 解析成功`; showSuccess(`[预读] ${song.name} ${detail}`); @@ -7294,6 +7698,7 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr // needs an explicit recovery entry point. currentPlaybackErrorHandler = async () => { if (!currentRecoveryState || currentRecoveryState.thisRequestId !== thisRequestId) return; + prefetchManager.invalidate(song, prefetchQuality, prefetchedResult); currentPlaybackErrorHandler = null; const savedTime = Math.max(audio.currentTime || 0, lastKnownPlaybackTime || 0); @@ -7327,6 +7732,7 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr audio.addEventListener('error', currentPlaybackErrorHandler, { once: true }); audio.src = finalUrl; + sourceAssigned = true; if (window._resumeInfo && window._resumeInfo.time > 0) { const resumeInfo = window._resumeInfo; @@ -7436,6 +7842,7 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr console.warn('[Player] Playback blocked by browser autoplay policy:', playError); setPlayerStatus('请点击播放按钮'); } else if (currentRecoveryState && currentRecoveryState.thisRequestId === thisRequestId) { + prefetchManager.invalidate(song, prefetchQuality, prefetchedResult); shouldAutoRecoverPlayback = false; playbackRecoveryTriggeredForRequestId = thisRequestId; console.error('[Player] Playback failed:', playError); @@ -7445,14 +7852,17 @@ async function playSong(song, index, forceQuality = null, noPlay = false, isRetr } // [Trigger Prefetch] 确保即便 play() 被拦截也尝试发起下一首预读 - prefetchNextSong(); + if (currentLoadingRequestId === thisRequestId) prefetchNextSong(); } catch (error) { if (currentLoadingRequestId !== thisRequestId) return; console.error('[Player] Error:', error); if (currentRecoveryState && currentRecoveryState.thisRequestId === thisRequestId && !noPlay) { - const savedTime = Math.max(audio.currentTime || 0, lastKnownPlaybackTime || 0); + // Before src is assigned the media element still belongs to the old song. + const savedTime = sourceAssigned + ? Math.max(audio.currentTime || 0, lastKnownPlaybackTime || 0) + : (currentRecoveryState.savedResumeTime || 0); await runRecoveryFlow(error, savedTime); } else { setPlayerStatus('播放失败'); @@ -7522,6 +7932,8 @@ function setPlayerStatus(status, isPlaying = null, isLoading = false) { statusText += ' 【缓存链接】'; } else if (currentSourceType === 'server_cache') { statusText += currentCacheFolder === 'music' ? ' 【已下载】' : ' 【缓存】'; + } else if (currentSourceType === 'openlist') { + statusText += ' 【OpenList】'; } statusEl.innerText = statusText; @@ -7718,6 +8130,7 @@ window.setImg = (id, src) => { }; function updatePlayerInfo(song, actualQuality) { + window.RatingManager?.current(song); // Bottom Player - 更新标题 const titleEl = document.getElementById('player-title'); if (titleEl) { @@ -8137,7 +8550,7 @@ audio.addEventListener('pause', () => { // ======================================== function savePlaybackState() { - if (!currentPlayingSong && !currentPlaylist?.length) return; + if (!currentPlayingSong && !currentPlaylist?.length) return null; try { const state = { song: currentPlayingSong, @@ -8153,8 +8566,10 @@ function savePlaybackState() { timestamp: Date.now() }; localStorage.setItem('lx_playback_state', JSON.stringify(state)); + return true; } catch (e) { console.error('[Resume] 无法保存播放状态:', e); + return false; } } @@ -8226,8 +8641,6 @@ async function restorePlaybackState() { if (searchView && !searchView.classList.contains('hidden')) { renderResults(currentPlaylist); } - } else if (state.scope === 'local_list' || state.scope === 'local_all') { - window._pendingResumeListId = state.listId || 'default'; } // 初始化音频源但不立即播放(除非设置了自动播放,当前 playSong handles resumeTime) @@ -9040,6 +9453,7 @@ async function updateSetting(key, value) { } // 实时同步 UI 并应用效果 syncSettingsUI(key, value); + if (key === 'preferLocalLyrics' && currentPlayingSong) { lastLyricSongId = null; void fetchLyric(currentPlayingSong, currentQuality); } if (key === 'networkListAutoCheckInterval' || key === 'autoUpdateNetworkList') { setupNetworkListAutoCheck(); if (typeof refreshTaskScheduleStatus === 'function') { @@ -9097,7 +9511,10 @@ const SETTINGS_UI_MAP = { enableAutoSkipOnError: { id: 'setting-auto-skip-on-error', type: 'checkbox' }, enableAutoDegradeQuality: { id: 'setting-auto-degrade-quality', type: 'checkbox' }, playbackErrorPriority: { id: 'setting-playback-error-priority', type: 'value' }, - enablePreloader: { id: 'setting-enable-preloader', type: 'checkbox' }, + enablePreloader: { + id: 'setting-enable-preloader', type: 'checkbox', + action: (enabled) => { if (!enabled) prefetchManager.clear(); } + }, disablePreloadSkip: { id: 'setting-disable-preload-skip', type: 'checkbox', normalize: () => true, readOnly: true }, deduplicatePlaylistByQuality: { id: 'setting-deduplicate-playlist', type: 'checkbox' }, enableSmtcLyric: { @@ -9259,6 +9676,7 @@ const SETTINGS_UI_MAP = { enableLyricCache: { id: 'setting-enable-lyric-cache', type: 'checkbox' }, enableSongUrlCache: { id: 'setting-enable-url-cache', type: 'checkbox' }, enableServerCache: { id: 'setting-enable-server-cache', type: 'checkbox' }, + preferLocalLyrics: { id: 'setting-prefer-local-lyrics', type: 'checkbox' }, enableServerLyricCache: { id: 'setting-enable-server-lyric-cache', type: 'checkbox' }, embedLyricToFile: { id: 'setting-embed-lyric-to-file', type: 'checkbox' }, preferServerCache: { id: 'setting-prefer-server-cache', type: 'checkbox' }, @@ -10204,9 +10622,11 @@ let scrollLockTimeout = null; // 滚动锁定计时器 let isProgrammaticScroll = false; // 标记是否为程序自动滚动 const SCROLL_LOCK_DURATION = 5000; // 5秒后解除锁定 +let lyricHistoryClosing = false; function toggleLyrics(fromPopState = false) { if (!fromPopState && isLyricViewOpen) { if (window.history.state && window.history.state.page === 'player-detail') { + lyricHistoryClosing = true; window.history.back(); } } @@ -10270,6 +10690,11 @@ function toggleLyrics(fromPopState = false) { // 监听浏览器返回,用于在移动端通过物理返回键/手势关闭歌词详情页及搜索详情页 window.addEventListener('popstate', (e) => { + // Closing the player overlay does not navigate the underlying library again. + if (isLyricViewOpen || lyricHistoryClosing) { + e.playerDetailHandled = true; + lyricHistoryClosing = false; + } // 1. 优先处理歌词页 if (isLyricViewOpen) { toggleLyrics(true); @@ -10283,7 +10708,9 @@ window.addEventListener('popstate', (e) => { const backBtn = document.getElementById('search-back-btn'); const isBackBtnVisible = backBtn && !backBtn.classList.contains('hidden'); - if (isAlbumOpen || artistHeader || isBackBtnVisible) { + const isArtistOpen = artistHeader && !artistHeader.classList.contains('hidden'); + if (isAlbumOpen || isArtistOpen || isBackBtnVisible) { + e.searchDetailHandled = true; goBackToSearch(true); } }); @@ -10397,6 +10824,23 @@ async function fetchLyric(song, quality = null) { source = song.meta.source; } + if (settings.preferLocalLyrics !== false && song.isLocal && song._localFilename) { + try { + const params = new URLSearchParams({ filename: song._localFilename, folder: song._isCustomDir ? 'custom' : (song._localFolder || song.folder || 'cache') }); + if (['root', 'data'].includes(song._localLocation)) params.set('browseLocation', song._localLocation); + const response = await fetch(`${API_BASE}/cache/lyric?${params}`, { headers: getUserAuthHeaders() }); + if (!isCurrent()) return; + if (response.ok) { + const data = (await response.json()).data; + if (!isCurrent()) return; + if (data?.lyric || data?.lrc) { + currentRawLrc = data.lyric || data.lrc; currentRawTlrc = data.tlyric || ''; currentRawRlrc = data.rlyric || ''; currentRawKlrc = data.klyric || data.lxlyric || ''; + lastLyricSongId = getLyricSongKey(song); initLyricPlayer(); applyLyricUpdate(); return; + } + } + } catch (error) { if (!isCurrent()) return; console.warn('[Lyric] 本地同名 LRC 读取失败:', error); } + } + // 如果还是没有必要的数据,检查是否为本地文件(可尝试读取内嵌歌词) if (!songmid || !source) { // [新增] 本地未关联歌曲:优先尝试读取音频文件内嵌歌词 @@ -10427,36 +10871,10 @@ async function fetchLyric(song, quality = null) { currentRawKlrc = ''; lastLyricSongId = null; - // ===== 1. 尝试读取浏览器本地缓存 (最高优先级) ===== const cacheKey = `lx_lyric_${source}_${songmid}`; - if (settings.enableLyricCache !== false) { - try { - const cached = localStorage.getItem(cacheKey); - if (cached) { - const data = JSON.parse(cached); - currentRawLrc = data.lrc || ''; - currentRawTlrc = data.tlyric || ''; - currentRawRlrc = data.rlyric || ''; - currentRawKlrc = data.klyric || data.lxlyric || ''; - lastLyricSongId = currentLyricKey; - - console.log(`[Lyric] 使用浏览器本地缓存歌词: ${songmid}`); - initLyricPlayer(); - applyLyricUpdate(); - return; // 命中缓存,直接返回 - } - } catch (e) { - console.warn('[Lyric] 读取浏览器本地缓存失败:', e); - localStorage.removeItem(cacheKey); - } - } - - // ===== 2. 尝试读取服务器端缓存歌词 ===== - const username = currentListData?.username || ''; - const headers = {}; - Object.assign(headers, getUserAuthHeaders()); - - if (settings.enableServerLyricCache !== false) { + const headers = { ...getUserAuthHeaders() }; + // ===== 1. 优先读取服务器本地 LRC(独立于保存开关) ===== + if (settings.preferLocalLyrics !== false) { try { const serverCacheUrl = `${API_BASE}/cache/lyric?source=${source}&songmid=${songmid}&songId=${encodeURIComponent(song.id || '')}&name=${encodeURIComponent(song.name || '')}&singer=${encodeURIComponent(song.singer || '')}`; const scRes = await fetch(serverCacheUrl, { headers }); @@ -10491,6 +10909,29 @@ async function fetchLyric(song, quality = null) { } } + // ===== 2. 尝试读取浏览器歌词缓存 ===== + if (settings.enableLyricCache !== false) { + try { + const cached = localStorage.getItem(cacheKey); + if (cached) { + const data = JSON.parse(cached); + currentRawLrc = data.lrc || ''; + currentRawTlrc = data.tlyric || ''; + currentRawRlrc = data.rlyric || ''; + currentRawKlrc = data.klyric || data.lxlyric || ''; + lastLyricSongId = currentLyricKey; + + console.log(`[Lyric] 使用浏览器本地缓存歌词: ${songmid}`); + initLyricPlayer(); + applyLyricUpdate(); + return; // 命中缓存,直接返回 + } + } catch (e) { + console.warn('[Lyric] 读取浏览器本地缓存失败:', e); + localStorage.removeItem(cacheKey); + } + } + // ===== 3. 从网络抓取最新歌词 ===== try { const params = new URLSearchParams({ @@ -10507,6 +10948,7 @@ async function fetchLyric(song, quality = null) { trcUrl: song.trcUrl || '' }); + params.set('preferLocal', settings.preferLocalLyrics !== false ? '1' : '0'); const url = `${API_BASE}/lyric?${params.toString()}`; // [优化] 使用低优先级 fetch 获取歌词,避免阻塞主进程加载和 PWA 安装按钮出现 const res = await fetch(url, { headers, priority: 'low' }); @@ -11744,7 +12186,7 @@ function renderLibraryArtists(list, isDislike = false) { if (!list || list.length === 0) { container.innerHTML = ` -
    +

    ${isDislike ? '还没有不喜欢任何歌手' : '还没有收藏任何歌手'}

    在搜索结果中点击 ${isDislike ? '🚫 不喜欢歌手' : '♥ 收藏歌手'}

    @@ -11777,7 +12219,7 @@ function renderLibraryArtists(list, isDislike = false) { const grid = container.querySelector('#lib-artist-grid'); list.forEach(singer => { const div = document.createElement('div'); - div.className = 'group relative flex flex-col items-center p-2 md:p-4 rounded-2xl transition-all hover:t-bg-panel hover:shadow-md cursor-pointer border border-transparent hover:border-emerald-500/30'; + div.className = 'group relative flex flex-col items-center p-2.5 md:p-4 rounded-2xl transition-all t-bg-main border t-border-main shadow-sm hover:shadow-lg cursor-pointer hover:border-emerald-500/30 active:scale-[0.98]'; div.dataset.libArtistId = singer.id; div.dataset.libArtistSource = singer.source; div.onclick = (e) => { @@ -11790,7 +12232,7 @@ function renderLibraryArtists(list, isDislike = false) { }; div.innerHTML = `
    -
    +
    @@ -11798,7 +12240,7 @@ function renderLibraryArtists(list, isDislike = false) { -
    -
    `; +
    `; const grid = container.querySelector('#lib-album-grid'); list.forEach(item => { const div = document.createElement('div'); - div.className = 'group relative flex flex-col p-3 rounded-2xl transition-all hover:t-bg-panel hover:shadow-lg cursor-pointer border border-transparent hover:border-emerald-500/20'; + div.className = 'group relative flex flex-col p-2 md:p-3 rounded-2xl transition-all t-bg-main border t-border-main shadow-sm hover:shadow-lg cursor-pointer hover:border-emerald-500/30 active:scale-[0.98]'; div.dataset.libAlbumId = item.id; div.dataset.libAlbumSource = item.source; div.onclick = (e) => { @@ -11874,7 +12316,7 @@ function renderLibraryAlbums(list, isDislike = false) { enterAlbum(item.id, item.source || 'wy', item); }; div.innerHTML = ` -
    +
    @@ -11882,29 +12324,29 @@ function renderLibraryAlbums(list, isDislike = false) {
    ${!isDislike ? ` -
    - -
    ` : ` -
    -
    `}
    - ${item.name} -
    - + ${item.name} +
    + ${item.artistName || '未知歌手'} ${item.list && item.list.length ? `(${item.list.length} 首)` : ''} - ${getSourceTag ? getSourceTag(item.source) : ''} + ${getSourceTag ? getSourceTag(item.source) : ''}
    `; const downloadButton = div.querySelector('.lib-album-download-btn'); downloadButton?.addEventListener('click', async event => { @@ -11918,6 +12360,8 @@ window.renderLibraryAlbums = renderLibraryAlbums; /** 点击侧边栏"收藏歌手",切换到展示视图 */ function handleArtistLibraryClick() { + if (window.innerWidth < 1025) { const sb = document.getElementById('main-sidebar'); if (sb && !sb.classList.contains('-translate-x-full')) toggleSidebar(); } + clearSearchDetailView(); exitListSecondaryModes && exitListSecondaryModes(); document.querySelectorAll('[id^="view-"]').forEach(el => el.classList.add('hidden')); const activeView = document.getElementById('view-search'); @@ -11942,6 +12386,7 @@ function handleArtistLibraryClick() { document.getElementById('search-type').classList.add('hidden'); currentSearchScope = 'lib_artists'; + window.refreshSearchStatusVisibility?.(); window.currentViewingListId = '__lib_artists__'; renderLibraryArtists(window.libraryData.artists); } @@ -11949,6 +12394,8 @@ window.handleArtistLibraryClick = handleArtistLibraryClick; /** 点击侧边栏"收藏专辑",切换到展示视图 */ function handleAlbumLibraryClick() { + if (window.innerWidth < 1025) { const sb = document.getElementById('main-sidebar'); if (sb && !sb.classList.contains('-translate-x-full')) toggleSidebar(); } + clearSearchDetailView(); exitListSecondaryModes && exitListSecondaryModes(); document.querySelectorAll('[id^="view-"]').forEach(el => el.classList.add('hidden')); const activeView = document.getElementById('view-search'); @@ -11973,6 +12420,7 @@ function handleAlbumLibraryClick() { document.getElementById('search-type').classList.add('hidden'); currentSearchScope = 'lib_albums'; + window.refreshSearchStatusVisibility?.(); window.currentViewingListId = '__lib_albums__'; renderLibraryAlbums(window.libraryData.albums); } @@ -12433,6 +12881,7 @@ async function handleSyncLogout(skipConfirm = false) { // 7. 彻底清空 localStorage & sessionStorage const agreementAccepted = localStorage.getItem('lx_agreement_accepted'); localStorage.clear(); + window.dispatchEvent(new Event('ratingaccountchange')); sessionStorage.clear(); if (agreementAccepted) { localStorage.setItem('lx_agreement_accepted', agreementAccepted); @@ -12519,6 +12968,7 @@ async function handleLocalLogin() { } if (!tokenReady) throw new Error('登录已失效,请重新登录'); localStorage.setItem('lx_sync_user', user); + window.dispatchEvent(new Event('ratingaccountchange')); // [新增] 显示并加载 Token 管理面板 const tokenSection = document.getElementById('token-management-section'); @@ -12544,6 +12994,7 @@ async function handleLocalLogin() { // Save credentials to localStorage (Simple version) localStorage.setItem('lx_sync_mode', 'local'); // [Fix] Save mode localStorage.setItem('lx_sync_user', user); + window.dispatchEvent(new Event('ratingaccountchange')); if (pass) { localStorage.setItem('lx_sync_pass', pass); } @@ -13326,15 +13777,9 @@ function renderMyLists(data) { initFavoriteSidebarSortable(container); refreshFavoritesChildrenHeight(); - // [Resume] 处理本地列表的自动恢复跳转或默认入口自动打开 - if (window._pendingResumeListId) { - const listId = window._pendingResumeListId; - delete window._pendingResumeListId; - delete window._pendingDefaultFavoriteEntry; - console.log('[Resume] 正在同步本地播放列表上下文:', listId); - // 调用 handleListClick 以加载真实的列表数据并应用高亮 - handleListClick(listId); - } else if (window._pendingDefaultFavoriteEntry) { + // Only an explicitly requested favorites entry may navigate when data arrives. + // Playback restoration must not override a later user search. + if (window._pendingDefaultFavoriteEntry) { delete window._pendingDefaultFavoriteEntry; const targetListId = (Array.isArray(data.loveList) && data.loveList.length > 0) ? 'love' @@ -13351,6 +13796,8 @@ function handleListClick(listId, skipAutoUpdate = false, preservePage = false) { if (!currentListData) return; + clearSearchDetailView(); + if (!skipAutoUpdate) { // Selections belong to the previously rendered list. Keeping them here makes // the toolbar count include invisible songs after opening another favorite list. @@ -13371,6 +13818,7 @@ function handleListClick(listId, skipAutoUpdate = false, preservePage = false) { // Set current viewing list ID for batch operations window.currentViewingListId = listId; currentSearchScope = 'local_list'; + window.refreshSearchStatusVisibility?.(); let list = []; let title = ''; @@ -13413,6 +13861,7 @@ function handleListClick(listId, skipAutoUpdate = false, preservePage = false) { // Set Scope currentSearchScope = 'local_list'; + window.refreshSearchStatusVisibility?.(); document.getElementById('search-source').classList.add('hidden'); // Hide selector document.getElementById('search-type').classList.add('hidden'); @@ -13569,7 +14018,7 @@ async function handleRefreshList(listId, event, silent = false) { await pushDataChange(); renderMyLists(currentListData); - if (window.currentViewingListId === listId) { + if (currentSearchScope === 'local_list' && window.currentViewingListId === listId) { handleListClick(listId, true); // skipAutoUpdate=true 避免递归 } @@ -13641,9 +14090,12 @@ function handleDislikeSongsClick() { } function handleDislikeArtistsClick() { + clearSearchDetailView(); exitListSecondaryModes(); window.currentViewingListId = 'dislike_artists'; + const pageTitle = document.getElementById('page-title'); if (pageTitle) pageTitle.textContent = '不喜欢歌手'; currentSearchScope = 'local_list'; + window.refreshSearchStatusVisibility?.(); // Highlight Header document.querySelectorAll('[id^="tab-"]').forEach(el => { @@ -13680,9 +14132,12 @@ function handleDislikeArtistsClick() { } function handleDislikeAlbumsClick() { + clearSearchDetailView(); exitListSecondaryModes(); window.currentViewingListId = 'dislike_albums'; + const pageTitle = document.getElementById('page-title'); if (pageTitle) pageTitle.textContent = '不喜欢专辑'; currentSearchScope = 'local_list'; + window.refreshSearchStatusVisibility?.(); // Highlight Header document.querySelectorAll('[id^="tab-"]').forEach(el => { @@ -15814,6 +16269,7 @@ function cleanSongData(song) { if (song.url) cleanSong.url = song.url; if (song.folder) cleanSong.folder = song.folder; if (song.filename) cleanSong.filename = song.filename; + for (const key of ['_localFilename', '_localFolder', '_localLocation', '_localUsername', '_isCustomDir']) if (song[key] !== undefined) cleanSong[key] = song[key]; // Remove undefined keys const removeUndefined = (obj) => { @@ -17038,7 +17494,7 @@ function showToast(type, message, duration = 3000) { // 封装旧 API function showSuccess(message) { showToast('success', message, 2000); } -function showInfo(message) { showToast('info', message, 2000); } +function showInfo(message, duration = 2000) { showToast('info', message, duration); } function showError(message) { showToast('error', message, 2000); } /** @@ -17845,164 +18301,15 @@ window.togglePlay = function () { document.addEventListener('click', initAudioEngine, { once: true }); -// --- Search Suggestions Logic --- -let searchTipsDebounceTimer = null; -let currentSelectedTipIndex = -1; -let currentTipAbortController = null; - -function initSearchTips() { - const searchInput = document.getElementById('search-input'); - const suggestionsContainer = document.getElementById('search-suggestions'); - - if (!searchInput || !suggestionsContainer) return; - - searchInput.addEventListener('input', (e) => { - const query = e.target.value.trim(); - clearTimeout(searchTipsDebounceTimer); - - if (!query) { - hideSearchSuggestions(); - return; - } - - searchTipsDebounceTimer = setTimeout(() => { - fetchSearchTips(query); - }, 300); - }); - - searchInput.addEventListener('focus', () => { - const query = searchInput.value.trim(); - if (query) { - suggestionsContainer.classList.remove('hidden'); - } - }); - - searchInput.addEventListener('keydown', (e) => { - const list = document.getElementById('search-suggestions-list'); - const items = list ? list.querySelectorAll('.search-tip-item') : []; - - if (e.key === 'ArrowDown') { - e.preventDefault(); - if (items.length === 0) return; - currentSelectedTipIndex = (currentSelectedTipIndex + 1) % items.length; - updateTipSelection(items); - } else if (e.key === 'ArrowUp') { - e.preventDefault(); - if (items.length === 0) return; - currentSelectedTipIndex = (currentSelectedTipIndex - 1 + items.length) % items.length; - updateTipSelection(items); - } else if (e.key === 'Enter') { - if (currentSelectedTipIndex >= 0 && items[currentSelectedTipIndex]) { - e.preventDefault(); - const text = items[currentSelectedTipIndex].textContent.trim(); - searchInput.value = text; - hideSearchSuggestions(); - doSearch(); - } - } else if (e.key === 'Escape') { - hideSearchSuggestions(); - } - }); - - document.addEventListener('click', (e) => { - if (!searchInput.contains(e.target) && !suggestionsContainer.contains(e.target)) { - hideSearchSuggestions(); - } - }); -} - -function updateTipSelection(items) { - items.forEach((item, index) => { - if (index === currentSelectedTipIndex) { - item.classList.add('t-bg-muted'); - item.scrollIntoView({ block: 'nearest' }); - } else { - item.classList.remove('t-bg-muted'); - } - }); -} - -async function fetchSearchTips(query) { - if (currentTipAbortController) currentTipAbortController.abort(); - currentTipAbortController = new AbortController(); - const signal = currentTipAbortController.signal; - - const source = (document.getElementById('search-source')) ? document.getElementById('search-source').value : 'kw'; - try { - const resp = await fetch(`/api/music/tipSearch?name=${encodeURIComponent(query)}&source=${source}`, { signal }); - if (!resp.ok) return; - const tips = await resp.json(); - renderSearchTips(tips); - } catch (err) { - if (err.name === 'AbortError') return; - console.error('[TipSearch] Fetch error:', err); - } finally { - if (currentTipAbortController && currentTipAbortController.signal === signal) { - currentTipAbortController = null; - } - } -} - -function renderSearchTips(tips) { - const container = document.getElementById('search-suggestions'); - const list = document.getElementById('search-suggestions-list'); - const input = document.getElementById('search-input'); - if (!container || !list || !input) return; - - // 如果输入框已失去焦点(除非是操作建议列表),或者已经触发了正式搜索,则不再渲染 - if (document.activeElement !== input) { - container.classList.add('hidden'); - return; - } - - list.innerHTML = ''; - currentSelectedTipIndex = -1; - - if (!tips || tips.length === 0) { - container.classList.add('hidden'); - return; - } - - tips.forEach((tip, index) => { - const div = document.createElement('div'); - div.className = 'search-tip-item px-4 py-2.5 hover:t-bg-muted cursor-pointer transition-colors text-sm flex items-center gap-3'; - div.innerHTML = `${tip}`; - div.onclick = (e) => { - e.stopPropagation(); // 防止触发 document click - input.value = tip; - hideSearchSuggestions(); - doSearch(); - }; - list.appendChild(div); - }); - - container.classList.remove('hidden'); -} - +// Search suggestion requests, IME and keyboard interactions live in their own module. function hideSearchSuggestions() { - const container = document.getElementById('search-suggestions'); - if (container) container.classList.add('hidden'); - currentSelectedTipIndex = -1; - - // 清除待执行的防抖定时器 - if (searchTipsDebounceTimer) { - clearTimeout(searchTipsDebounceTimer); - searchTipsDebounceTimer = null; - } - - // 中止正在进行的请求 - if (currentTipAbortController) { - currentTipAbortController.abort(); - currentTipAbortController = null; - } + window.SearchSuggestions?.hide(); } -// Ensure initSearchTips runs on load -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initSearchTips); -} else { - initSearchTips(); +function handleSearchSubmit(event) { + window.SearchSuggestions.submit(event); } + // ===== 持久化 Token 管理逻辑 ===== /** @@ -18618,7 +18925,8 @@ window.CustomSelectManager = { if (opt.disabled) return; const li = document.createElement('li'); li.className = 'cs-option' + (opt.selected ? ' selected' : ''); - li.innerHTML = `${opt.text}`; + const optionLabel = document.createElement('span'); optionLabel.textContent = opt.text; + const check = document.createElement('i'); check.className = 'fas fa-check'; li.append(optionLabel, check); li.onclick = (e) => { e.stopPropagation(); @@ -18631,7 +18939,9 @@ window.CustomSelectManager = { }); dropdown.appendChild(optionsList); - document.body.appendChild(dropdown); + const dialogHost = select.closest('dialog[open]'); + (dialogHost || document.body).appendChild(dropdown); + dropdown._dialogHost = dialogHost; // 计算位置 this.reposition(trigger, dropdown); @@ -18647,17 +18957,23 @@ window.CustomSelectManager = { reposition(trigger, dropdown) { const rect = trigger.getBoundingClientRect(); dropdown.style.width = rect.width + 'px'; - dropdown.style.left = rect.left + 'px'; + const host = dropdown._dialogHost; + const hostRect = host?.getBoundingClientRect(); + dropdown.style.left = (host ? rect.left - hostRect.left + host.scrollLeft - host.clientLeft : rect.left + window.scrollX) + 'px'; // 检查空间,自动决定向上还是向下展开 - const spaceBelow = window.innerHeight - rect.bottom; + const spaceBelow = (hostRect ? Math.min(window.innerHeight, hostRect.bottom) : window.innerHeight) - rect.bottom; const dropdownHeight = dropdown.offsetHeight || 260; - if (spaceBelow < dropdownHeight && rect.top > dropdownHeight) { - dropdown.style.top = (rect.top + window.scrollY - dropdownHeight - 6) + 'px'; + const offsetTop = host ? -hostRect.top + host.scrollTop - host.clientTop : window.scrollY; + const spaceAbove = rect.top - (hostRect?.top || 0); + if (spaceBelow < dropdownHeight && spaceAbove > spaceBelow) { + dropdown.style.maxHeight = Math.max(80, Math.min(280, spaceAbove - 10)) + 'px'; + dropdown.style.top = (rect.top + offsetTop - Math.min(dropdownHeight, Math.max(80, spaceAbove - 10)) - 6) + 'px'; dropdown.classList.add('open-up'); } else { - dropdown.style.top = (rect.bottom + window.scrollY + 4) + 'px'; + dropdown.style.maxHeight = Math.max(80, Math.min(280, spaceBelow - 10)) + 'px'; + dropdown.style.top = (rect.bottom + offsetTop + 4) + 'px'; dropdown.classList.remove('open-up'); } }, diff --git a/public/music/css/app.css b/public/music/css/app.css index ba6a4c42..6ca31a56 100644 --- a/public/music/css/app.css +++ b/public/music/css/app.css @@ -681,6 +681,18 @@ aside { animation: marquee 10s linear infinite; } +/* Hover-capable desktops start from the beginning on each text hover. */ +@media (min-width: 768px) and (hover: hover) and (pointer: fine) { + .dynamic-marquee .animate-marquee { + animation: none; + transform: translateX(0); + } + + .dynamic-marquee:hover .animate-marquee { + animation: marquee 10s linear infinite; + } +} + /* Default: Always Run (for Touch/Mobile) */ .hover-scroll-paused { animation-play-state: running; @@ -702,6 +714,11 @@ aside { visibility: visible !important; } +/* Keep checked switch tracks above Tailwind's dark background utilities. */ +input.peer[type="checkbox"]:checked + .peer[class~="peer-checked:bg-emerald-500"] { + background-color: var(--c-500, #10b981); +} + /* 菜单选中项样式 */ .active-option { background-color: var(--c-500) !important; @@ -881,6 +898,128 @@ aside { box-shadow: 0 0 0 3px color-mix(in srgb, var(--c-500) 15%, transparent); } +/* ===== Lyric Card Modern UI ===== */ +#lyric-card-modal-content { + border-radius: 28px; + border: 1px solid rgba(255, 255, 255, .14); + box-shadow: 0 30px 80px -20px rgba(0, 0, 0, .55), 0 0 0 1px rgba(255, 255, 255, .04) inset; +} + +/* 预览区:柔和光晕 + 点阵背景 */ +#lyric-card-modal-content > div:nth-child(2) > div:first-child { + background: + radial-gradient(circle at 50% 40%, color-mix(in srgb, var(--c-500) 14%, transparent), transparent 60%), + radial-gradient(rgba(255, 255, 255, .06) 1px, transparent 1px) 0 0 / 18px 18px; +} + +#lyric-card-preview-wrapper { + border-radius: 20px; + box-shadow: 0 25px 60px -15px rgba(0, 0, 0, .6), 0 0 0 1px rgba(255, 255, 255, .08); + transition: box-shadow .4s ease, transform .4s cubic-bezier(.16, 1, .3, 1); +} +#lyric-card-preview-wrapper:hover { + box-shadow: 0 35px 80px -15px rgba(0, 0, 0, .7), 0 0 40px -10px color-mix(in srgb, var(--c-500) 40%, transparent); +} + +/* 选项按钮:胶囊卡片 */ +.lc-layout-btn, +.lc-color-btn, +.lc-lines-btn { + position: relative; + border-radius: 14px !important; + border: 1px solid rgba(255, 255, 255, .1) !important; + background: linear-gradient(180deg, rgba(255, 255, 255, .06), rgba(255, 255, 255, .02)) !important; + backdrop-filter: blur(8px); + transition: transform .25s cubic-bezier(.16, 1, .3, 1), box-shadow .25s, border-color .25s, background .25s, color .25s; +} +.lc-layout-btn:hover, +.lc-color-btn:hover, +.lc-lines-btn:hover { + transform: translateY(-2px); + box-shadow: 0 8px 18px -8px rgba(0, 0, 0, .5); +} +.lc-layout-btn:active, +.lc-color-btn:active, +.lc-lines-btn:active { transform: scale(.96); } + +.lc-btn-active { + background: linear-gradient(135deg, color-mix(in srgb, var(--c-500) 28%, transparent), color-mix(in srgb, var(--c-500) 8%, transparent)) !important; + box-shadow: 0 0 0 1px var(--c-500), 0 8px 24px -8px color-mix(in srgb, var(--c-500) 70%, transparent) !important; +} + +/* 复选框 */ +#lyric-card-modal input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 20px; height: 20px; + border-radius: 7px; + border: 1.5px solid rgba(255, 255, 255, .25); + background: rgba(255, 255, 255, .05); + display: grid; place-content: center; + transition: all .2s; +} +#lyric-card-modal input[type="checkbox"]::after { + content: ""; + width: 10px; height: 6px; + border-left: 2px solid #fff; border-bottom: 2px solid #fff; + transform: rotate(-45deg) scale(0) translate(1px, -1px); + transition: transform .18s cubic-bezier(.34, 1.56, .64, 1); +} +#lyric-card-modal input[type="checkbox"]:checked { + background: linear-gradient(135deg, var(--c-400), var(--c-600)); + border-color: transparent; + box-shadow: 0 4px 12px -3px color-mix(in srgb, var(--c-500) 70%, transparent); +} +#lyric-card-modal input[type="checkbox"]:checked::after { + transform: rotate(-45deg) scale(1) translate(1px, -1px); +} + +/* 下载 / 复制按钮 */ +#lyric-card-modal .space-y-3 > button { + border-radius: 16px !important; + letter-spacing: .04em; + position: relative; + overflow: hidden; +} +#lyric-card-modal .space-y-3 > button:first-child { + border: none; + box-shadow: 0 12px 28px -10px color-mix(in srgb, var(--c-500) 80%, transparent), inset 0 1px 0 rgba(255, 255, 255, .3); +} +#lyric-card-modal .space-y-3 > button:first-child::before { + content: ""; + position: absolute; inset: 0; + background: linear-gradient(110deg, transparent 30%, rgba(255, 255, 255, .28) 50%, transparent 70%); + transform: translateX(-120%); + transition: transform .7s ease; +} +#lyric-card-modal .space-y-3 > button:first-child:hover::before { transform: translateX(120%); } +#lyric-card-modal .space-y-3 > button:last-child { + border: 1px solid rgba(255, 255, 255, .14) !important; + background: rgba(255, 255, 255, .04) !important; + backdrop-filter: blur(8px); +} + +/* 滑块 */ +#lyric-card-modal input[type="range"] { + -webkit-appearance: none; appearance: none; + height: 6px; border-radius: 99px; + background: rgba(255, 255, 255, .12); +} +#lyric-card-modal input[type="range"]::-webkit-slider-thumb { + -webkit-appearance: none; + width: 18px; height: 18px; border-radius: 50%; + background: #fff; + border: 4px solid var(--c-500); + box-shadow: 0 2px 10px rgba(0, 0, 0, .4); + cursor: pointer; + transition: transform .2s; +} +#lyric-card-modal input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.2); } +#lyric-card-modal input[type="range"]::-moz-range-thumb { + width: 12px; height: 12px; border-radius: 50%; + background: #fff; border: 4px solid var(--c-500); +} + /* Lyric Card - 主题色滑动条 */ .lc-range-input { accent-color: var(--c-500); @@ -2156,6 +2295,12 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli /* Active Effect Tags in Status Bar */ .effects-tags-wrapper { min-height: 24px; } +.effects-mode-tag { display: inline-flex; align-items: center; padding: 4px 9px; border: 1px solid #38bdf880; border-radius: 6px; background: #38bdf81a; color: var(--text-main); font-size: 12px; font-weight: 700; } +.effects-mode-tag[hidden] { display: none; } +.effects-controls { min-width: 0; margin: 0; padding: 0; border: 0; } +.effects-controls:disabled { opacity: .7; } +.effects-controls:disabled :is(input, button, label), .effects-tags-wrapper button:disabled { cursor: not-allowed; } +.effects-tags-wrapper button:disabled { opacity: .35; pointer-events: none; } .effects-tag { display: inline-flex; align-items: center; @@ -2193,7 +2338,7 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli margin-right: -2px; border-radius: 4px; background: transparent; - color: currentColor; + color: currentcolor; opacity: 0.65; border: none; cursor: pointer; @@ -2254,18 +2399,19 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli #sound-effects-content input[type="range"] { min-width: 0; height: 4px; accent-color: var(--c-500, #10b981); } #sound-effects-content input[type="checkbox"] { accent-color: var(--c-500, #10b981); } #sound-effects-content #reverb-list { gap: 4px; } -#reverb-list > label { min-width: 0; padding: 3px 7px; font-size: 11px; border: 1px solid var(--border-main); border-radius: 6px; font-weight: 500; transition: all .15s; } -#reverb-list > label { position: relative; } +#reverb-list > label { position: relative; min-width: 0; padding: 3px 7px; font-size: 11px; border: 1px solid var(--border-main); border-radius: 6px; font-weight: 500; transition: all .15s; } #reverb-list > label > div { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; } #reverb-list > label:hover { border-color: var(--c-500, #10b981); } -#reverb-list > label:has(input:checked) { color: var(--c-500, #10b981); border-color: currentColor; background: rgba(16, 185, 129, 0.1); } +#reverb-list > label:has(input:checked) { color: var(--c-500, #10b981); border-color: currentcolor; background: rgba(16, 185, 129, 0.1); } #eq-sliders-container { min-height: 0; margin-bottom: 8px; } #eq-sliders-container > div { gap: 6px 14px; padding: 0; } #eq-sliders-container > div > div { gap: 8px; min-width: 0; } #eq-presets button { padding: 3px 8px; font-size: 11px; border-radius: 6px; font-weight: 500; } + @media (pointer: coarse), (max-width: 767px) { .effects-tag-remove { opacity: 0.85; width: 18px; height: 18px; font-size: 11px; margin-left: 3px; } } + @media (max-width: 767px) { .effects-status-bar { top: -14px; margin: -14px -14px 14px; padding: 12px; } #sound-effects-modal { padding: 8px; } @@ -2276,3 +2422,20 @@ button:focus-visible, summary:focus-visible, a:focus-visible { outline: 2px soli #sound-effects-content section > .flex:first-child { flex-wrap: wrap; gap: 8px; } #sound-effects-content h4 { flex: 1 1 50%; } } +.network-search-more { + flex-shrink: 0; + border-top: 1px solid var(--border-color, rgba(128, 128, 128, .2)); + padding: .65rem 1rem calc(.65rem + env(safe-area-inset-bottom, 0px)); + text-align: center; + font-size: .8rem; +} +.network-search-more:not(.hidden) { display: flex; align-items: center; justify-content: center; gap: .75rem; flex-wrap: wrap; } +.network-search-more button { padding: .5rem 1.25rem; border: 1px solid #10b981; border-radius: .65rem; color: #10b981; font-weight: 600; } +.network-search-more button:hover:not(:disabled) { background: rgba(16, 185, 129, .12); } +.network-search-more button:disabled { opacity: .55; cursor: default; } + +#search-suggestions-list { + max-height: min(24rem, 45dvh); + overflow-y: auto; + overscroll-behavior: contain; +} diff --git a/public/music/css/rating.css b/public/music/css/rating.css new file mode 100644 index 00000000..55b2f6d3 --- /dev/null +++ b/public/music/css/rating.css @@ -0,0 +1,188 @@ +/* 评分 UI:动态星星选择器 + 我的评分面板。桌面居中弹窗,手机端底部抽屉。 */ +.rating-chip, .player-rating-button { width: fit-content; color: #d89b19; background: transparent; border: 0; font-size: 12px; padding: 2px 0; cursor: pointer; transition: transform .15s, color .15s; } +.rating-chip:hover { transform: none; } +.rating-chip.is-rated { font-weight: 600; text-shadow: 0 0 8px rgba(251,191,36,.35); } + +.web-rating-dialog { width: min(440px, calc(100vw - 28px)); max-height: 88vh; padding: 22px; border-radius: 22px; border: 1px solid #64748b55; background: var(--bg-panel, #1f2937); color: var(--text-main, #f1f5f9); box-shadow: 0 24px 60px rgba(0,0,0,.45); overflow: auto; margin: auto; } +.web-rating-dialog[open] { animation: rd-in .22s cubic-bezier(.2,.9,.3,1.2); } +.web-rating-dialog::backdrop { background: rgba(2,6,23,.55); backdrop-filter: blur(6px); } +.web-rating-dialog h3 { font-size: 18px; margin: 0; overflow-wrap: anywhere; } +.web-rating-dialog p { font-size: 13px; margin: 8px 0; } +.web-rating-dialog .rating-hint { opacity: .6; line-height: 1.6; font-size: 12px; } +.rd-grip { display: none; width: 40px; height: 4px; border-radius: 4px; background: #94a3b855; margin: -8px auto 14px; } +.rd-head { display: flex; gap: 14px; align-items: center; } +.rd-art { width: 64px; height: 64px; flex: 0 0 64px; border-radius: 14px; background: linear-gradient(135deg, #f59e0b55, #8b5cf655); display: grid; place-items: center; font-size: 26px; overflow: hidden; position: relative; } +.rd-art img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.rd-meta { min-width: 0; } +.rd-singer { opacity: .65; margin: 4px 0 0 !important; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.rs-picker { display: flex; flex-direction: column; align-items: center; gap: 8px; margin: 22px 0 6px; } +.rs-picker.busy { opacity: .6; pointer-events: none; } +.rs-row { display: flex; gap: 6px; touch-action: none; user-select: none; } +.rs-star { font-size: 44px; line-height: 1; padding: 4px; background: transparent; border: 0; color: #64748b66; cursor: pointer; transition: transform .15s, color .15s, filter .15s; -webkit-tap-highlight-color: transparent; } +.rs-star.on { color: #fbbf24; filter: drop-shadow(0 0 10px rgba(251,191,36,.55)); transform: scale(1.08); } +.rs-star:hover { transform: scale(1.22); } +.rs-star:focus-visible { outline: 2px solid #fbbf24; outline-offset: 2px; border-radius: 10px; } +.rs-star.pop { animation: rs-pop .5s cubic-bezier(.2,1.4,.4,1); } +.rs-caption { font-size: 14px; font-weight: 600; color: #fbbf24; min-height: 20px; } +.rs-caption.dim { color: inherit; opacity: .55; font-weight: 400; } +.rs-clear { background: transparent; border: 0; color: inherit; opacity: .55; font-size: 12px; text-decoration: underline; cursor: pointer; padding: 4px 8px; } +.rs-clear:hover { opacity: 1; } +.rd-status { text-align: center; opacity: .7; min-height: 18px; } +.rating-close { width: 100%; margin-top: 12px; border: 1px solid #64748b66; background: transparent; color: inherit; border-radius: 12px; padding: 10px 12px; min-height: 44px; cursor: pointer; } +.rating-close:hover { background: #64748b22; } + +/* 我的评分面板 */ +.web-rating-dialog.is-collection { width: min(640px, calc(100vw - 28px)); } +.rc-head { display: flex; justify-content: space-between; gap: 12px; align-items: flex-start; } +.rc-sub { opacity: .6; } +.rc-x { background: #64748b22; border: 0; color: inherit; width: 34px; height: 34px; border-radius: 50%; cursor: pointer; flex: 0 0 34px; } +.rc-summary { display: flex; gap: 18px; align-items: center; margin: 14px 0; padding: 14px; border-radius: 16px; background: #64748b1a; } +.rc-avg { display: flex; flex-direction: column; align-items: center; min-width: 84px; } +.rc-avg b { font-size: 34px; line-height: 1; color: #fbbf24; } +.rc-avg span { font-size: 11px; opacity: .6; margin-top: 4px; white-space: nowrap; } +.rc-bars { flex: 1; display: grid; gap: 4px; min-width: 0; } +.rc-bar { display: grid; grid-template-columns: 24px 1fr 28px; gap: 8px; align-items: center; font-size: 11px; } +.rc-bar em { font-style: normal; opacity: .7; } +.rc-bar span { height: 6px; border-radius: 6px; background: #64748b33; overflow: hidden; } +.rc-bar i { display: block; height: 100%; background: linear-gradient(90deg, #f59e0b, #fbbf24); border-radius: 6px; transition: width .4s; } +.rc-bar small { text-align: right; opacity: .6; } +.rc-tools { display: flex; gap: 8px; } +.rc-search, .rc-sort { background: transparent; color: inherit; border: 1px solid #64748b66; border-radius: 10px; padding: 9px 12px; min-width: 0; font-size: 13px; } +.rc-search { flex: 1; } +.rc-sort option { color: #111; } +.rc-chips { display: flex; gap: 6px; overflow-x: auto; padding: 10px 0; scrollbar-width: none; } +.rc-chips::-webkit-scrollbar { display: none; } +.rc-chip { flex: 0 0 auto; background: transparent; color: inherit; border: 1px solid #64748b55; border-radius: 999px; padding: 5px 12px; font-size: 12px; cursor: pointer; } +.rc-chip.on { background: #fbbf24; border-color: #fbbf24; color: #1f2937; font-weight: 600; } +.rc-list { max-height: 44vh; overflow: auto; } +.rc-row { display: flex; align-items: center; gap: 10px; padding: 10px 4px; border-bottom: 1px solid #64748b33; } +.rc-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.rc-info b { font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rc-info span { font-size: 12px; opacity: .6; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rc-stars { display: flex; align-items: center; gap: 6px; background: transparent; border: 0; cursor: pointer; color: inherit; flex: 0 0 auto; padding: 6px; border-radius: 10px; } +.rc-stars:hover { background: #64748b22; } +.rc-star-text { --v: 0; font-size: 16px; letter-spacing: 1px; background: linear-gradient(90deg, #fbbf24 calc(var(--v) / 5 * 100%), #64748b55 0); -webkit-background-clip: text; background-clip: text; color: transparent; } +.rc-stars { --v: 0; } +.rc-stars .rc-star-text { --v: inherit; } +.rc-stars small { color: #fbbf24; font-weight: 700; } +.rc-empty { text-align: center; padding: 32px 8px; opacity: .6; font-size: 13px; } +.rc-more { width: 100%; margin-top: 8px; padding: 9px; border: 1px solid #64748b55; background: transparent; color: inherit; border-radius: 10px; cursor: pointer; } + +@keyframes rd-in { from { opacity: 0; transform: translateY(12px) scale(.96); } to { opacity: 1; transform: none; } } +@keyframes rs-pop { 0% { transform: scale(1); } 40% { transform: scale(1.6) rotate(-12deg); } 100% { transform: scale(1.08); } } +@media (prefers-reduced-motion: reduce) { .web-rating-dialog[open], .rs-star.pop { animation: none; } .rs-star, .rc-bar i { transition: none; } } + +/* 手机端:底部抽屉 */ +@media (max-width: 600px) { + .web-rating-dialog, .web-rating-dialog.is-collection { width: 100vw; max-width: 100vw; margin: auto 0 0; border-radius: 22px 22px 0 0; padding: 18px 18px calc(18px + env(safe-area-inset-bottom)); max-height: 92vh; } + .web-rating-dialog[open] { animation: rd-sheet .28s cubic-bezier(.2,.9,.3,1); } + .rd-grip { display: block; } + .rs-star { font-size: 48px; padding: 2px; } + .rs-row { gap: 4px; } + .rc-summary { flex-direction: column; align-items: stretch; gap: 10px; } + .rc-avg { flex-direction: row; gap: 10px; align-items: baseline; justify-content: center; } + .rc-list { max-height: 40vh; } +} +@keyframes rd-sheet { from { transform: translateY(100%); } to { transform: none; } } + +.web-rating-dialog.is-collection { width: min(720px, calc(100vw - 28px)); } +.rd-tags, .rc-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; } +.rd-tags span, .rc-tags span { font-size: 11px; padding: 1px 7px; border-radius: 5px; border: 1px solid #64748b66; opacity: .8; } +.rc-tags span.k { background: #64748b33; opacity: 1; } +.rc-kinds { display: flex; gap: 4px; border-bottom: 1px solid #64748b44; margin-bottom: 12px; overflow-x: auto; scrollbar-width: none; } +.rc-kind { background: transparent; border: 0; color: inherit; opacity: .6; padding: 8px 14px; font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; white-space: nowrap; } +.rc-kind.on { opacity: 1; border-bottom-color: #fbbf24; font-weight: 600; } +.rc-tools { flex-wrap: wrap; } +.rc-search { flex: 1 1 200px; } +.rc-cover { position: relative; width: 52px; height: 52px; flex: 0 0 52px; border-radius: 8px; background: #64748b30; display: grid; place-items: center; overflow: hidden; opacity: .95; } +.rc-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; } +.rc-row.kind-artist .rc-cover { border-radius: 50%; } +.rc-info { gap: 3px; } +.rc-stars:disabled { cursor: default; opacity: .9; } +.rc-stars:disabled:hover { background: transparent; } +@media (max-width: 600px) { + .rc-row { align-items: flex-start; flex-wrap: wrap; } + .rc-info { flex-basis: calc(100% - 70px); } + .rc-stars { margin-left: 62px; padding-top: 0; } + .rc-tools select { flex: 1 1 45%; } +} + +/* 我的评分:复用播放器下拉组件,保留模态弹窗的顶层菜单。 */ +.rc-head { display: grid; grid-template-columns: minmax(0,1fr) auto auto; align-items: start; } +.rc-export, .rc-reset { border: 1px solid var(--border-main, #64748b55); background: transparent; color: inherit; border-radius: 10px; padding: 9px 13px; font: inherit; font-size: 12px; cursor: pointer; white-space: nowrap; transition: background .15s ease; } +.rc-export:hover, .rc-reset:hover { background: #64748b20; } +.rc-tools { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr) auto; gap: 8px; } +.rc-tools .rc-search { grid-column: 1 / -1; width: 100%; min-width: 0; box-sizing: border-box; } +.rc-tools .cs-wrapper { min-width: 0; width: 100%; } +.rc-tools .cs-trigger { min-height: 40px; padding: 8px 12px; border-radius: 10px; box-sizing: border-box; font-size: 12px; } +.rc-tools .rc-reset { grid-column: 3; grid-row: 2 / span 2; align-self: stretch; } +.rc-tools .cs-wrapper:last-of-type { grid-column: 1 / span 2; } +.rc-source-help { margin: 12px 0 6px; font-size: 12px; color: var(--text-muted, #94a3b8); line-height: 1.7; } +.rc-source-help summary, .rc-record-detail summary { cursor: pointer; width: fit-content; } +.rc-source-help p { margin-top: 8px; } +.rc-result-status { font-size: 12px; color: var(--text-muted, #94a3b8); margin: 8px 0; } +.rc-record-detail { font-size: 11px; color: var(--text-muted, #94a3b8); margin-top: 5px; } +.rc-record-detail p { overflow-wrap: anywhere; white-space: normal; margin-top: 6px; } +button.rc-bar { width: 100%; border: 0; padding: 3px 0; color: inherit; background: transparent; cursor: pointer; text-align: left; font: inherit; } +button.rc-bar:hover { background: #64748b15; border-radius: 5px; } +.web-rating-dialog .cs-dropdown { max-width: calc(100% - 20px); } +.rc-head > div { min-width: 0; } +.rc-export:focus-visible, .rc-reset:focus-visible, button.rc-bar:focus-visible { outline: 2px solid #fbbf24; outline-offset: 3px; } +@media (max-width: 600px) { .rc-head { grid-template-columns: minmax(0,1fr) auto; } .rc-head .rc-export { grid-column: 1; grid-row: 2; justify-self: start; } .rc-head .rc-x { grid-column: 2; grid-row: 1; } .rc-tools { grid-template-columns: minmax(0,1fr) minmax(0,1fr); } .rc-tools .rc-reset { grid-column: 2; grid-row: auto; } .rc-tools .cs-wrapper:last-of-type { grid-column: auto; } } +@media (prefers-reduced-motion: reduce) { .rc-export, .rc-reset, .web-rating-dialog .cs-dropdown { transition: none; } } + +/* 当前用户评分偏好:不依赖动态 Tailwind 类,不影响其他设置卡片。 */ +.rating-preference-card { display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 10px 20px; padding: 18px 20px; border: 1px solid var(--border-main, #64748b44); border-radius: 12px; background: var(--bg-panel, transparent); } +.rating-preference-heading { display: flex; align-items: center; gap: 4px; min-width: 0; font-size: 14px; font-weight: 600; color: var(--text-main, inherit); } +.rating-preference-heading .setting-tooltip-trigger { border: 0; padding: 0; background: transparent; font: inherit; flex-shrink: 0; } +.rating-preference-heading .setting-tooltip-content { font-weight: 400; } +.rating-preference-switch { display: inline-flex; position: relative; width: 44px; height: 24px; flex-shrink: 0; cursor: pointer; } +.rating-preference-switch input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; z-index: 1; cursor: pointer; } +.rating-preference-switch > span { width: 44px; height: 24px; border-radius: 999px; background: #64748b80; transition: background .18s ease; } +.rating-preference-switch > span::after { content: ''; position: absolute; left: 3px; top: 3px; width: 18px; height: 18px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px #0002; transition: transform .18s ease; } +.rating-preference-switch input:checked + span { background: #10b981; } +.rating-preference-switch input:checked + span::after { transform: translateX(20px); } +.rating-preference-switch input:disabled { cursor: wait; } +.rating-preference-switch input:disabled + span { opacity: .5; } +.rating-preference-switch input:focus-visible + span { outline: 2px solid #10b981; outline-offset: 3px; } +.rating-preference-status { grid-column: 1 / -1; margin: 0; font-size: 12px; color: var(--text-muted, #94a3b8); } +.rating-preference-status[hidden] { display: none; } +@media (max-width:600px) { .rating-preference-card { padding: 16px; } } +@media (prefers-reduced-motion:reduce) { .rating-preference-switch > span, .rating-preference-switch > span::after { transition: none; } } + +/* 评分与平台、音质标签并列;不占未评分歌曲的空间。 */ +[data-rating-badges] { min-width: 0; } +.rating-chip.rating-score-tag { display: inline-flex; align-items: center; justify-content: center; gap: 3px; flex: 0 0 auto; padding: 2px 6px; min-height: 18px; border-radius: 6px; border: 1px solid #e6ab3b55; background: #e6ab3b15; color: #d99b20; font-size: 10px; line-height: 1.2; font-weight: 700; white-space: nowrap; letter-spacing: .02em; text-shadow: none; vertical-align: middle; } +.rating-score-tag[hidden] { display: none !important; } +.rating-score-tag:hover { background: #e6ab3b28; border-color: #e6ab3b90; } +.rating-chip { white-space: nowrap; flex-shrink: 0; } +.rating-preference-card { min-height: 72px; box-sizing: border-box; } +@media (max-width: 640px) { + .rating-chip:not(.rating-score-tag):not(#player-rating) { font-size: 0; min-width: 28px; min-height: 32px; display: inline-flex; align-items: center; justify-content: center; padding: 3px; line-height: 1; } + .rating-chip:not(.rating-score-tag):not(#player-rating)::before { content: '☆'; font-size: 17px; white-space: nowrap; } + .rating-chip.is-rated:not(.rating-score-tag):not(#player-rating)::before { content: '★'; } + .rating-chip.rating-score-tag { font-size: 9px; padding: 2px 5px; min-height: 17px; } + [data-rating-badges] { column-gap: 4px; } + .rating-preference-card { padding: 16px; min-height: 68px; } + .rating-preference-heading { font-size: 13px; } +} + +/* 所有桌面/手机操作区仅显示星形入口;分数标签尺寸与平台标签接近。 */ +.rating-chip:not(.rating-score-tag):not(#player-rating) { display: inline-flex; align-items: center; justify-content: center; min-width: 28px; min-height: 28px; padding: 3px; font-size: 18px; line-height: 1; } +.rating-chip:hover, .rating-chip.rating-score-tag:hover { transform: none; } +.rating-chip.rating-score-tag { padding: 2px 7px; min-height: 20px; font-size: 11px; line-height: 1.2; border-radius: 6px; } +.queue-actions-menu .rating-chip { justify-content: flex-start; } +.dislike-empty-state { min-height: 280px; height: 100%; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 16px; padding: 32px 20px; text-align: center; color: var(--text-muted, #94a3b8); } +.dislike-empty-state > i { font-size: 56px; opacity: .22; } +.dislike-empty-state > p { margin: 0; } +.dislike-empty-state > small { font-size: 12px; line-height: 1.6; } +@media(max-width:640px) { + .rating-chip:not(.rating-score-tag):not(#player-rating) { font-size: 0; min-height: 32px; } + .rating-chip.rating-score-tag { min-height: 18px; padding: 2px 6px; font-size: 10px; } + .dislike-empty-state { min-height: 240px; } +} + +/* 菜单评分项保留动作名称与分数,手机端也不折叠为单图标。 */ +.rating-chip.rating-menu-item:not(.rating-score-tag):not(#player-rating) { width: 100%; justify-content: flex-start; padding: 10px 12px; font-size: 14px; line-height: 1.5; text-shadow: none; gap: 6px; } +.rating-chip.rating-menu-item::before { content: none !important; } diff --git a/public/music/index.html b/public/music/index.html index bd031bec..985550e8 100644 --- a/public/music/index.html +++ b/public/music/index.html @@ -1,4 +1,4 @@ - + @@ -43,7 +43,10 @@ + + @@ -119,6 +122,7 @@

    LX MUSIC

    class="fas fa-chevron-down text-xs transition-transform duration-200">
      +
    • @@ -360,18 +364,19 @@

      LX MUSIC

      -
      +
      @@ -906,7 +915,7 @@

      暂无介绍

      @@ -2563,6 +2572,11 @@

      本地缓存与重置

      +
      +
      优先使用服务器本地歌词开启:当前用户 music / cache 的 LRC → 浏览器歌词缓存 → 在线歌词;关闭:浏览器缓存 → 在线歌词。自定义曲库支持同名 LRC,本地未关联歌曲可用内嵌歌词兜底。只控制读取,不控制保存。
      + +
      +
      @@ -2571,7 +2585,7 @@

      本地缓存与重置

      将歌词文件缓存到同服务器配置的缓存目录下,优先供多端同步极速加载。 + class="setting-tooltip-content">只控制写入:在线获取歌词及后台保存音频时,是否保存独立 LRC;已有音频时歌词与其同目录同名。读取已有歌词由“优先使用服务器本地歌词”控制。
      @@ -2593,8 +2607,7 @@

      本地缓存与重置

      开启后,下载或缓存歌曲时将自动获取歌词并写入音频文件标签(USLT),同时保存 - .lrc 文件。支持浏览器下载与服务器缓存两种模式。 + class="setting-tooltip-content">开启后,下载或缓存歌曲时将自动获取歌词并写入音频文件标签(USLT)。独立 .lrc 文件由“缓存歌词文件”开关单独控制。支持浏览器下载与服务器缓存两种模式。
      @@ -2611,11 +2624,11 @@

      本地缓存与重置

      - 优先播放缓存歌曲 + 优先播放服务器已有歌曲 如果歌曲已缓存,则优先从本地加载。即使“缓存歌曲文件”关闭,已存在的缓存仍可使用。 + class="setting-tooltip-content">按当前用户查询 music 下载目录和 cache 缓存目录(使用设置的运行目录或 DATA_PATH)。同音质先 music 后 cache;未命中且后台允许时查 OpenList,再用浏览器链接缓存、在线音源。关闭缓存歌曲文件只停止新写入,不禁止读取已有文件。自定义曲库通过本地音乐入口播放。
      @@ -2668,7 +2681,7 @@

      本地缓存与重置

      + class="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-emerald-500 transition-all">
      @@ -3180,11 +3193,11 @@

    @@ -3438,6 +3451,21 @@

    播放队列与列表

    +
    +
    + 不喜欢自动补分 + +
    + + +
    +
    @@ -4155,6 +4183,7 @@

    选择一首歌曲播放

    +
    @@ -5537,6 +5566,7 @@

    均衡器与音效调节

    音效已开启 +
    @@ -5555,9 +5585,9 @@

    均衡器与音效调节

    + onclick="window.soundEffects.restoreNativePlayback()">恢复原生播放 / AirPlay
    -
    +
    @@ -5672,7 +5702,7 @@

    快速预

    -
    +
    @@ -6269,7 +6299,9 @@

    项目使用声明< + + @@ -6280,8 +6312,9 @@

    项目使用声明< + + - diff --git a/public/music/js/download/download_manager.js b/public/music/js/download/download_manager.js index 02811a26..b08aefcd 100644 --- a/public/music/js/download/download_manager.js +++ b/public/music/js/download/download_manager.js @@ -429,15 +429,18 @@ class DownloadManager { const batchResults = await Promise.all(batches.map(async (batch) => { try { - const resp = await fetch(`/api/music/cache/progress?ids=${encodeURIComponent(batch.join(','))}`); + const resp = await fetch(`/api/music/cache/progress?ids=${encodeURIComponent(batch.join(','))}`, { headers: this.getServerQueueHeaders() }); + if (!resp.ok) return null; const result = await resp.json(); - return result.success ? (result.data || {}) : {}; + return result.success ? (result.data || {}) : null; } catch (e) { console.warn('[DownloadManager] Batch progress poll failed:', e); - return {}; + return null; } })); - const data = Object.assign({}, ...batchResults); + // Failed queries are not evidence that a download has no progress. + const queriedIds = new Set(batches.flatMap((batch, index) => batchResults[index] === null ? [] : batch)); + const data = Object.assign({}, ...batchResults.filter(Boolean)); // 处理有进度数据的任务 Object.keys(data).forEach(rawId => { @@ -501,7 +504,7 @@ class DownloadManager { // [Fix] 处理没有进度数据的任务:key 已被删除 = 下载完成或从未开始 tasksToPoll.forEach(task => { const rawId = task.isServer ? this.getTaskServerSongKey(task) : task.id; - if (data[rawId] === undefined && (task.status === 'downloading' || task.status === 'tagging')) { + if (queriedIds.has(rawId) && data[rawId] === undefined && (task.status === 'downloading' || task.status === 'tagging')) { // 没有进度条目 + 状态是 downloading/tagging // → 如果之前进度很高或在嵌入中,说明已从内存队列移除,逻辑上视为已完成 console.log(`[DownloadManager] Missing progress info for ${task.id}, status: ${task.status}, prog: ${task.progress}`); @@ -515,9 +518,9 @@ class DownloadManager { } else if (task.nativeDownloadDispatched) { task.missingProgressCount = (task.missingProgressCount || 0) + 1; if (task.missingProgressCount >= 6) { - task.status = 'error'; + task.status = 'handedoff'; task.speed = 0; - task.errorMsg = '浏览器未启动下载'; + task.errorMsg = ''; this.renderTask(task); this.saveTasks(); this.processQueue(); @@ -659,6 +662,8 @@ class DownloadManager { const normalizedCover = cover && cover !== '/music/assets/logo.svg' ? cover : ''; return { ...song, + source: song.source || song.meta?.source || '', + songmid: song.songmid || song.songId || song.meta?.songmid || song.meta?.songId || song.id || '', img: song.img || normalizedCover, meta: { ...(song.meta || {}), @@ -684,6 +689,10 @@ class DownloadManager { types: song.types, _types: song._types, strMediaMid: song.strMediaMid, + copyrightId: song.copyrightId || song.meta?.copyrightId, + lrcUrl: song.lrcUrl || song.meta?.lrcUrl, + mrcUrl: song.mrcUrl || song.meta?.mrcUrl, + trcUrl: song.trcUrl || song.meta?.trcUrl, hash: song.hash, meta: song.meta ? { songmid: song.meta.songmid, @@ -886,6 +895,7 @@ class DownloadManager { // Start a specific download task async startDownload(task) { + task.song = this.getSongInfoForServer(task.song); task.status = 'downloading'; task.errorMsg = ''; @@ -912,12 +922,12 @@ class DownloadManager { } else { const downloadResolver = await this.waitForDownloadResolver(); const resolveData = await downloadResolver(task.song, quality, true); - if (!resolveData || !resolveData.url) throw new Error('No download URL found'); + if (!resolveData || !resolveData.url) throw new Error('未获取到下载地址'); const resolvedSong = resolveData.songInfo || task.song; const resolvedQuality = resolveData.quality || resolveData.type || quality; if (resolvedSong !== task.song) { - task.song = resolvedSong; + task.song = this.getSongInfoForServer(resolvedSong); } task.quality = resolvedQuality; this.renderTask(task); @@ -972,6 +982,10 @@ class DownloadManager { (task.song.songmid || task.song.id) ? `songmid=${encodeURIComponent(task.song.songmid || task.song.id)}` : '', task.song.hash ? `hash=${encodeURIComponent(task.song.hash)}` : '', task.song.interval ? `interval=${encodeURIComponent(task.song.interval)}` : '', + ...['copyrightId', 'albumId', 'lrcUrl', 'mrcUrl', 'trcUrl'].map(key => { + const value = task.song[key] || task.song.meta?.[key]; + return value ? `${key}=${encodeURIComponent(value)}` : ''; + }), (window.settings?.embedLyricToFile !== false) ? 'lyric=1' : '' ].filter(Boolean).join('&'); @@ -982,23 +996,40 @@ class DownloadManager { } if (window.authorizeMediaUrl) finalUrl = window.authorizeMediaUrl(finalUrl); + const ownMediaUrl = finalUrl.startsWith('/api/music/') || (() => { + try { + const parsed = new URL(finalUrl, window.location.href); + return parsed.origin === window.location.origin && parsed.pathname.startsWith('/api/music/'); + } catch { return false; } + })(); if (task.useNativeDownload) { + if (ownMediaUrl) { + if (typeof window.ensureDownloadSession !== 'function') throw new Error('下载认证组件未就绪,请刷新页面'); + await window.ensureDownloadSession(); + } if (task.controller.signal.aborted) throw new DOMException('Download paused', 'AbortError'); this.triggerNativeDownload(finalUrl, filename); task.nativeDownloadDispatched = true; - task.status = 'downloading'; + // Cached files have no proxy progress entry. Do not wait for one. + task.status = isLocalCache ? 'handedoff' : 'downloading'; task.progress = 0; task.speed = 0; task.controller = null; this.renderTask(task); this.saveTasks(); + this.processQueue(); return; } // 2. Fetch the actual file using Streams to track progress - const response = await fetch(finalUrl, { signal: task.controller.signal }); - if (!response.ok) throw new Error(`HTTP ${response.status} when fetching file`); + const response = await fetch(finalUrl, { + signal: task.controller.signal, + ...(ownMediaUrl ? { headers: window.getUserAuthHeaders?.() || {} } : {}) + }); + if (!response.ok) throw Object.assign(new Error(response.status === 401 + ? '登录已失效,请重新登录后重试下载' + : `下载请求失败(HTTP ${response.status})`), { status: response.status }); const contentLength = response.headers.get('content-length'); if (contentLength) { @@ -1082,10 +1113,11 @@ class DownloadManager { task.errorMsg = '已暂停'; } else { console.error(`Download error for ${task.song.name}:`, error); - task.errorMsg = error.message; + task.errorMsg = error.message === 'Failed to fetch' ? '下载请求失败,请检查网络或服务端日志' : (error.message || '下载失败'); + task.speed = 0; // Retry logic - if (task.retryCount < task.maxRetries) { + if (![401, 403, 409].includes(error.status) && task.retryCount < task.maxRetries) { task.retryCount++; task.status = 'error'; // Show error momentarily this.renderTask(task); @@ -1476,7 +1508,7 @@ class DownloadManager { if (this.progressTextEl) { const overallProgress = pctCount > 0 ? Math.round(pctTotal / pctCount) : 0; - this.progressTextEl.innerText = `${overallProgress}%`; + this.progressTextEl.innerText = pctCount ? `${overallProgress}%` : (this.tasks.some(t => t.status === 'handedoff') ? '请查看浏览器下载记录' : '0%'); } } @@ -1519,7 +1551,7 @@ class DownloadManager { const hasRealProgress = isServerTask && (task.totalBytes > 0 || task.progress > 0); statusText = isServerTask ? (hasRealProgress ? `云端 ${progressWidth}%` : '云端下载中') - : (task.nativeDownloadDispatched ? `浏览器 ${progressWidth}%` : `${progressWidth}%`); + : (task.nativeDownloadDispatched ? (task.totalBytes > 0 || task.progress > 0 ? `服务端 ${progressWidth}%` : '等待下载进度') : `${progressWidth}%`); speedText = `${this.formatSize(Math.max(0, task.speed || 0))}/s`; if (!isServerTask && !task.nativeDownloadDispatched) { @@ -1549,9 +1581,12 @@ class DownloadManager { `; + } else if (task.status === 'handedoff') { + statusBg = 'bg-blue-100 dark:bg-blue-500/15 text-blue-600 dark:text-blue-400'; + statusText = '已交给浏览器,请查看下载记录'; } else if (task.status === 'finished' || task.status === 'exists') { statusBg = 'bg-emerald-100 dark:bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'; - statusText = task.status === 'exists' ? '已存在' : (isServerTask ? '已存云端' : '已完成'); + statusText = task.status === 'exists' ? '已存在' : (isServerTask ? '已存云端' : '已交给浏览器保存'); progressWidth = 100; } else if (task.status === 'waiting') { statusText = isServerTask ? '云端排队' : '等待中'; @@ -1582,7 +1617,7 @@ class DownloadManager { return `
    - ${task.status !== 'waiting' && task.status !== 'error' ? ` + ${task.status !== 'waiting' && task.status !== 'error' && task.status !== 'handedoff' ? `
    ` : ''} @@ -1741,7 +1776,7 @@ class DownloadManager { const hasRealProgress = isServerTask && (task.totalBytes > 0 || task.progress > 0); statusBadge.textContent = isServerTask ? (hasRealProgress ? `云端 ${progressWidth}%` : '云端下载中') - : (task.nativeDownloadDispatched ? `浏览器 ${progressWidth}%` : `${progressWidth}%`); + : (task.nativeDownloadDispatched ? (task.totalBytes > 0 || task.progress > 0 ? `服务端 ${progressWidth}%` : '等待下载进度') : `${progressWidth}%`); } } diff --git a/public/music/js/library/dislike_manager.js b/public/music/js/library/dislike_manager.js index 03699f24..80e73f5e 100644 --- a/public/music/js/library/dislike_manager.js +++ b/public/music/js/library/dislike_manager.js @@ -15,6 +15,7 @@ window.DislikeManager = (function () { const API_BASE = '/api/music/dislike'; const state = { + scoped: {}, initialized: false, loading: false, exact: new Set(), @@ -30,7 +31,7 @@ window.DislikeManager = (function () { }; /** 归一化:与服务端一致(@ → #、去空格、小写) */ - const normalize = (v) => String(v ?? '').replaceAll('@', '#').trim().toLowerCase(); + const normalize = (v) => String(v ?? '').replaceAll('@', '#').trim().toLowerCase().normalize('NFKC').replace(/\s*([()])\s*/g, '$1'); /** 拆分歌手:「A、B」「A,B」「A/B」「A feat.B」「A & B」 */ const splitSingers = (singer) => @@ -84,6 +85,7 @@ window.DislikeManager = (function () { } function applyData(data, options) { + state.scoped = data?.scoped || {}; state.exact = new Set(data?.exact || []); state.musicNames = new Set(data?.musicNames || []); state.singerNames = new Set(data?.singerNames || []); @@ -105,7 +107,7 @@ window.DislikeManager = (function () { state.loading = true; try { const r = await request(API_BASE); - if (r && r.success) applyData(r.data, r.options); + if (r && r.success) { applyData(r.data, r.options); if (r.warning) window.showInfo?.(r.warning); window.dispatchEvent(new Event('ratingdatachange')); } } catch (e) { console.error('[Dislike] load failed:', e); } finally { @@ -115,15 +117,19 @@ window.DislikeManager = (function () { } /** 判定一首歌是否命中不喜欢规则 */ - function isDisliked(song) { + function isDisliked(song, selected) { if (!song) return false; + const active = selected || state; + for (const [source, rules] of Object.entries(active.scoped || {})) { + if ((active.crossSource || song.source === source) && isDisliked(song, { ...active, scoped: {}, exact: new Set(rules.exact), musicNames: new Set(rules.musicNames), singerNames: new Set(rules.singerNames), albums: rules.albums })) return true; + } const name = normalize(song.name); const allSingers = splitSingers(song.singer); // primary 模式只看主唱(第一位歌手) - const singers = state.duetMode === 'primary' ? allSingers.slice(0, 1) : allSingers; + const singers = active.duetMode === 'primary' ? allSingers.slice(0, 1) : allSingers; // 候选歌名:原名 + 去后缀名(去重),提升 Live / Remix 版本召回 const candidateNames = Array.from( - new Set([name, state.normalizeName ? normalizeSongName(song.name) : ''].filter(Boolean)) + new Set([name, active.normalizeName ? normalizeSongName(song.name) : ''].filter(Boolean)) ); // 1. 专辑维度(专辑名 + 歌手 双重校验) @@ -131,15 +137,15 @@ window.DislikeManager = (function () { const albumName = getAlbumName(song); if (albumName) { const an = normalize(albumName); - for (const alb of state.albums) { + for (const alb of active.albums) { if (normalize(alb.albumName ?? '') !== an) continue; if (alb.singers && alb.singers.length) { if (!singers.length) { // 歌曲没有歌手信息:requireSinger 时无法确认,保守放过 - if (state.requireSinger) continue; + if (active.requireSinger) continue; } else { const albumSingers = alb.singers.map((s) => - state.normalizeName ? normalizeSongName(s) : normalize(s) + active.normalizeName ? normalizeSongName(s) : normalize(s) ); const hit = singers.some((x) => albumSingers.some((a) => x === a || x.includes(a) || a.includes(x)) @@ -153,12 +159,12 @@ window.DislikeManager = (function () { // 2. 歌手维度 if (singers.length) { - if (state.duetMode === 'all') { + if (active.duetMode === 'all') { // all:所有歌手都被屏蔽才算命中,避免牵连合作者 - if (singers.every((s) => state.singerNames.has(s))) return true; + if (singers.every((s) => active.singerNames.has(s))) return true; } else { for (const s of singers) { - if (state.singerNames.has(s)) return true; + if (active.singerNames.has(s)) return true; } } } @@ -167,13 +173,13 @@ window.DislikeManager = (function () { for (const n of candidateNames) { if (!n) continue; // 纯歌名规则:requireSinger 时不单独命中,避免同名歌被误杀 - if (state.musicNames.has(n) && !state.requireSinger) return true; + if (active.musicNames.has(n) && !active.requireSinger) return true; if (singers.length) { - if (state.duetMode === 'all') { - if (singers.every((s) => state.exact.has(`${n}@${s}`))) return true; + if (active.duetMode === 'all') { + if (singers.every((s) => active.exact.has(`${n}@${s}`))) return true; } else { for (const s of singers) { - if (state.exact.has(`${n}@${s}`)) return true; + if (active.exact.has(`${n}@${s}`)) return true; } } } @@ -184,7 +190,7 @@ window.DislikeManager = (function () { async function mutate(action, payload) { const r = await request(`${API_BASE}/${action}`, payload); - if (r && r.success) applyData(r.data, r.options); + if (r && r.success) { applyData(r.data, r.options); if (r.warning) window.showInfo?.(r.warning); window.dispatchEvent(new Event('ratingdatachange')); } return !!(r && r.success); } diff --git a/public/music/js/library/leaderboard_manager.js b/public/music/js/library/leaderboard_manager.js index dcb08a48..c658dd29 100644 --- a/public/music/js/library/leaderboard_manager.js +++ b/public/music/js/library/leaderboard_manager.js @@ -235,7 +235,7 @@ window.LeaderboardManager = (function () {
    ${window.createMarqueeHtml ? window.createMarqueeHtml(song.name) : `${song.name}`}
    -
    +
    ${window.getSourceTag ? window.getSourceTag(song.source || state.source) : ''} ${window.getQualityTags ? window.getQualityTags(song) : ''} @@ -277,6 +277,7 @@ window.LeaderboardManager = (function () { onclick="event.stopPropagation(); window.LeaderboardManager.addSongToPlaylist(${index})"> + +
    -
    +
    ${safeSinger} ${this.escapeHtml(qualityName || '标准')} ${item.bitrate ? `` : ''} @@ -1037,6 +1124,14 @@ window.CustomDirManager = { container.innerHTML = html; // 绑定封面加载失败兜底 + container.querySelectorAll('[data-lm-action="playlist"]').forEach(anchor => { + const item = this.displayData[Number(anchor.dataset.lmIndex)]; + if (!item) return; + const button = document.createElement('button'); button.type = 'button'; + button.className = 'text-xs text-amber-500 px-1 shrink-0'; + anchor.parentElement.insertBefore(button, anchor); + window.RatingManager?.attach(button, { ...item.songInfo, name: item.name || item.songInfo?.name, singer: item.singer || item.songInfo?.singer, isLocal: true, _localFilename: item.filename, _localFolder: true ? 'custom' : item.folder || 'cache', _localLocation: true ? undefined : document.getElementById('lm-location-select')?.value || 'root', _isCustomDir: true }); + }); container.querySelectorAll('.lm-cover-image').forEach(img => { img.addEventListener('error', () => { const index = parseInt(img.dataset.lmCoverIndex || '', 10); @@ -1267,10 +1362,8 @@ window.CustomDirManager = { }); const result = await res.json(); if (result.success) { - if (typeof showInfo === 'function') showInfo(`成功删除 ${result.deletedCount || filenames.length} 个文件`); - for (const f of filenames) this.selectedItems.delete(f); - this.updateBatchUI(); - await this.refresh(); + if (typeof showInfo === 'function') showInfo(`成功删除 ${result.deletedCount ?? 0} 个文件`); + await this.reloadItems(filenames); } else { throw new Error(result.message || '删除失败'); } @@ -1416,7 +1509,7 @@ window.CustomDirManager = { if (typeof showInfo === 'function') { showInfo(`补全操作完成。成功 ${success} 项,失败/未关联 ${fail} 项`); } - await this.refresh(); + await this.reloadItems(targets.map(item => item.filename)); }, // 批量嵌入歌词到音频文件 USLT 标签 @@ -1463,7 +1556,7 @@ window.CustomDirManager = { } } } - await this.refresh(); + await this.reloadItems(targetFilenames); } else { throw new Error(result.message || '服务器返回错误'); } @@ -1501,7 +1594,7 @@ window.CustomDirManager = { const result = await res.json(); if (result.success) { if (typeof showInfo === 'function') showInfo(`元信息补全完成。成功 ${result.successCount} 项,失败 ${result.failCount} 项`); - await this.refresh(); + await this.reloadItems(targetFilenames); } else { throw new Error(result.message || '服务器返回错误'); } diff --git a/public/music/js/local/local_music.js b/public/music/js/local/local_music.js index d1259ac8..ecf9cf16 100644 --- a/public/music/js/local/local_music.js +++ b/public/music/js/local/local_music.js @@ -793,8 +793,7 @@ window.LocalMusicManager = { } if (tabId === 'localmusic') { if (window.CustomDirManager && window.CustomDirManager.isActive) { - window.CustomDirManager.loadFilters(); - window.CustomDirManager.fetchData(true); + window.CustomDirManager.ensureLoaded(); } else { window.LocalMusicManager.syncLocationSelector(); window.LocalMusicManager.loadFilters(); @@ -1426,7 +1425,7 @@ window.LocalMusicManager = {
    -
    +
    ${safeSinger} ${this.escapeHtml(qualityName || '标准')} ${item.bitrate ? `` : ''} @@ -1539,6 +1538,14 @@ window.LocalMusicManager = { window.unobserveLazyImages(container); } container.innerHTML = html; + container.querySelectorAll('[data-lm-action="playlist"]').forEach(anchor => { + const item = this.displayData[Number(anchor.dataset.lmIndex)]; + if (!item) return; + const button = document.createElement('button'); button.type = 'button'; + button.className = 'text-xs text-amber-500 px-1 shrink-0'; + anchor.parentElement.insertBefore(button, anchor); + window.RatingManager?.attach(button, { ...item.songInfo, name: item.name || item.songInfo?.name, singer: item.singer || item.songInfo?.singer, isLocal: true, _localFilename: item.filename, _localFolder: false ? 'custom' : item.folder || 'cache', _localLocation: false ? undefined : document.getElementById('lm-location-select')?.value || 'root', _isCustomDir: false }); + }); container.querySelectorAll('.lm-cover-image').forEach(img => { img.addEventListener('error', () => { const index = parseInt(img.dataset.lmCoverIndex || '', 10); @@ -2567,16 +2574,18 @@ window.LocalMusicManager = { }, body: JSON.stringify({ filename: this.manualIndexTargetItem.filename, + folder: this.manualIndexTargetItem.folder, songInfo: onlineItem }) }); const result = await res.json(); if (result.success) { + window.dispatchEvent(new Event('ratingidentitychange')); if (typeof showInfo === 'function') showInfo('关联成功!文件名及标签已更新'); this.closeManualIndexModal(); if (isCustom && window.CustomDirManager) { - window.CustomDirManager.refresh(); + await window.CustomDirManager.reloadItems([result.data.filename]); } else { this.refresh(); } diff --git a/public/music/js/player/airplay.js b/public/music/js/player/airplay.js index a7bb38bd..1ecf5f19 100644 --- a/public/music/js/player/airplay.js +++ b/public/music/js/player/airplay.js @@ -1,10 +1,11 @@ // Safari's picker must be opened synchronously from the user's click. (() => { - const audio = document.getElementById('audio-player'); + const audio = window.LXPlayback || document.getElementById('audio-player'); const button = document.getElementById('btn-airplay'); if (!audio || !button || typeof audio.webkitShowPlaybackTargetPicker !== 'function') return; let available = false; + let warnedCapturedRoute = false; function update() { const wireless = !!audio.webkitCurrentPlaybackTargetIsWireless; button.hidden = !available && !wireless; @@ -13,16 +14,24 @@ button.title = wireless ? 'AirPlay connected' : 'AirPlay'; button.classList.toggle('text-emerald-500', wireless); window.iOSBackgroundAudio?.setWirelessPlayback(wireless); + if (!wireless) warnedCapturedRoute = false; + // Control Center can select AirPlay without going through our picker. + if (wireless && !warnedCapturedRoute && window.soundEffects?.needsNativeRestore()) { + warnedCapturedRoute = true; + window.soundEffects.open(); + window.showInfo?.('请点击“恢复原生播放 / AirPlay”后,重新选择 AirPlay 设备。', 6500); + } } audio.addEventListener('webkitplaybacktargetavailabilitychanged', event => { available = event.availability === 'available'; update(); }); audio.addEventListener('webkitcurrentplaybacktargetiswirelesschanged', update); + window.addEventListener('lx-audio-replaced', () => { available = false; warnedCapturedRoute = false; update(); }); button.addEventListener('click', () => { - if (window.soundEffects?.needsNativeReload()) { + if (window.soundEffects?.needsNativeRestore()) { window.soundEffects.open(); - window.showInfo?.('请先点击“刷新并恢复原生播放 / AirPlay”,再选择 AirPlay 设备。', 5000); + window.showInfo?.('请先点击“恢复原生播放 / AirPlay”,再选择 AirPlay 设备。', 5000); return; } try { audio.webkitShowPlaybackTargetPicker(); } diff --git a/public/music/js/player/playback.js b/public/music/js/player/playback.js index d9e45882..bc6ddcd5 100644 --- a/public/music/js/player/playback.js +++ b/public/music/js/player/playback.js @@ -1,27 +1,92 @@ // Keep the player's existing media controls/events independent of the output route. (() => { - const element = document.getElementById('audio-player'); + let element = document.getElementById('audio-player'); if (!element) return; const events = new EventTarget(); let remote = null; let localVolumeState = null; const emit = type => events.dispatchEvent(new Event(type)); - for (const type of ['play', 'playing', 'pause', 'ended', 'error', 'timeupdate', 'loadedmetadata', - 'durationchange', 'canplay', 'seeked', 'seeking', 'waiting', 'stalled', 'ratechange', 'volumechange']) { - element.addEventListener(type, () => { if (!remote) emit(type); }); + const mediaEvents = ['play', 'playing', 'pause', 'ended', 'error', 'timeupdate', 'loadedmetadata', + 'durationchange', 'canplay', 'seeked', 'seeking', 'waiting', 'stalled', 'ratechange', 'volumechange', + 'webkitplaybacktargetavailabilitychanged', 'webkitcurrentplaybacktargetiswirelesschanged']; + function bindElement(media) { + for (const type of mediaEvents) media.addEventListener(type, event => { + if (remote || media !== element) return; + const forwarded = new Event(type); + if ('availability' in event) forwarded.availability = event.availability; + events.dispatchEvent(forwarded); + }); + } + bindElement(element); + let seekVersion = 0; + let pendingSeek = null; + function replaceLocalElement(beforeResume) { + if (remote) return false; + const previous = element; + const source = previous.src || previous.currentSrc; + const position = pendingSeek?.element === previous ? pendingSeek.position : previous.currentTime; + const playbackRate = previous.playbackRate; + const resume = !previous.paused && !previous.ended; + const next = previous.cloneNode(false); + next.removeAttribute('src'); + next.srcObject = null; + next.volume = previous.volume; + next.muted = previous.muted; + next.defaultPlaybackRate = previous.defaultPlaybackRate; + next.playbackRate = playbackRate; + next.loop = previous.loop; + for (const key of ['preservesPitch', 'webkitPreservesPitch', 'mozPreservesPitch']) { + if (key in previous) next[key] = previous[key]; + } + next.autoplay = false; + previous.replaceWith(next); + element = next; + const version = ++seekVersion; + pendingSeek = source ? { element: next, position, duration: previous.duration } : null; + bindElement(next); + // Old pause/error events must never reach queue recovery or auto-advance. + previous.pause(); + previous.removeAttribute('src'); + previous.srcObject = null; + previous.load(); + beforeResume?.(); + window.dispatchEvent(new CustomEvent('lx-audio-replaced')); + if (source) { + next.addEventListener('loadedmetadata', () => { + if (next !== element || version !== seekVersion) return; + if (Number.isFinite(position) && position > 0) { + next.currentTime = Number.isFinite(next.duration) ? Math.min(position, next.duration) : position; + } + pendingSeek = null; + }, { once: true }); + next.src = source; + next.load(); + // load() resets playbackRate to defaultPlaybackRate in native media elements. + next.playbackRate = playbackRate; + // Call play in the original click gesture, without waiting for metadata. + if (resume) next.play().catch(error => { + if (next !== element || version !== seekVersion || error.name === 'AbortError') return; + emit('pause'); + window.showInfo?.('已恢复原生播放,请点击播放继续。'); + }); + } + return true; } const properties = new Set(['src', 'currentSrc', 'currentTime', 'duration', 'paused', 'ended', 'volume', 'muted', 'playbackRate', 'error', 'readyState']); window.LXPlayback = new Proxy(element, { get(target, key) { + if (key === 'replaceLocalElement') return replaceLocalElement; if (key === 'isRemote') return !!remote; if (key === 'emit') return emit; if (key === 'addEventListener' || key === 'removeEventListener') return events[key].bind(events); if (key === 'connectRemote') return output => { - const snapshot = { src: element.currentSrc || element.src, currentTime: element.currentTime, + const snapshot = { src: element.src || element.currentSrc, currentTime: pendingSeek?.position ?? element.currentTime, paused: element.paused, volume: element.volume, muted: element.muted, playbackRate: element.playbackRate }; if (!remote) localVolumeState = { volume: element.volume, muted: element.muted }; remote = output; + seekVersion++; + pendingSeek = null; element.pause(); // Suppressed locally; the receiver will supply playback events. return snapshot; }; @@ -48,12 +113,19 @@ const value = remote[key]; return typeof value === 'function' ? value.bind(remote) : value; } - const value = Reflect.get(target, key, target); - return typeof value === 'function' ? value.bind(target) : value; + if (pendingSeek?.element === element) { + if (key === 'currentTime') return pendingSeek.position; + if (key === 'duration') return pendingSeek.duration; + } + const value = Reflect.get(element, key, element); + return typeof value === 'function' ? value.bind(element) : value; }, set(target, key, value) { if (remote && properties.has(key)) remote[key] = value; - else Reflect.set(target, key, value, target); + else { + if (key === 'src' || key === 'currentTime') { seekVersion++; pendingSeek = null; } + Reflect.set(element, key, value, element); + } return true; } }); diff --git a/public/music/js/player/sound-effects.js b/public/music/js/player/sound-effects.js index d56164ad..83b14bf1 100644 --- a/public/music/js/player/sound-effects.js +++ b/public/music/js/player/sound-effects.js @@ -56,6 +56,16 @@ window.soundEffects = (function () { let isGraphBuilt = false; // Mobile starts native on every page load. Only a local effect edit opts into DSP. let mobileEffectsEnabled = false; + // Native restoration must survive later playback, resize and reload events. + let preferNativePlayback = false; + try { + preferNativePlayback = sessionStorage.getItem('lx_native_audio_mode') === '1' || + sessionStorage.getItem('lx_native_audio_restore') === '1'; + } catch (_) { } + + function needsNativeRestore() { + return !!(mediaSource || window._sharedAudioSourceNode); + } function hasActiveEffects() { if (settings.enabled === false) return false; @@ -87,7 +97,7 @@ window.soundEffects = (function () { function canProcessLocally() { // Capturing a mobile media element cannot be undone by disconnecting Web Audio. // Keep native playback until an explicit local effect edit opts into DSP. - return (isGraphBuilt || mobileEffectsEnabled || !isMobileDevice()) && + return !preferNativePlayback && (isGraphBuilt || mobileEffectsEnabled || !isMobileDevice()) && !document.getElementById('audio-player')?.webkitCurrentPlaybackTargetIsWireless && !window.LXPlayback?.isRemote; } @@ -207,10 +217,12 @@ window.soundEffects = (function () { if (pitchShifterNode) return pitchShifterNode; if (pitchShifterInitPromise) return pitchShifterInitPromise; + const context = audioContext; pitchShifterInitPromise = (async () => { try { console.log('[SoundEffects] Loading Pitch Shifter Module from /music/js/player/pitch-shifter/phase-vocoder.js'); - await audioContext.audioWorklet.addModule('/music/js/player/pitch-shifter/phase-vocoder.js'); + await context.audioWorklet.addModule('/music/js/player/pitch-shifter/phase-vocoder.js'); + if (context !== audioContext) return null; if (!canProcessLocally()) { pitchShifterInitPromise = null; return null; @@ -237,6 +249,7 @@ window.soundEffects = (function () { } return pitchShifterNode; } catch (e) { + if (context !== audioContext) return null; console.error('[SoundEffects] Failed to initialize pitch shifter:', e); pitchShifterInitPromise = null; return null; @@ -257,7 +270,7 @@ window.soundEffects = (function () { pitchShifterNode.connect(pannerNode); } else { initPitchShifter().then(node => { - if (canProcessLocally() && node && settings.pitch !== 1.0) { + if (canProcessLocally() && node && node === pitchShifterNode && settings.pitch !== 1.0) { try { mixerNode.disconnect(pannerNode); } catch (e) { } try { mixerNode.disconnect(node); } catch (e) { } try { node.disconnect(pannerNode); } catch (e) { } @@ -384,6 +397,7 @@ window.soundEffects = (function () { const impulseBuffers = new Map(); function updateReverb() { if (!canProcessLocally() || !convolverNode || !audioContext) return; + const context = audioContext; const request = ++reverbRequest; const rev = reverbOptions.find(r => r.id === settings.reverb.id); @@ -399,8 +413,11 @@ window.soundEffects = (function () { if (!r.ok) throw new Error(`Impulse response HTTP ${r.status}`); return r.arrayBuffer(); }) - .then(data => audioContext.decodeAudioData(data)) - .catch(error => { impulseBuffers.delete(rev.source); throw error; }); + .then(data => context.decodeAudioData(data)) + .catch(error => { + if (impulseBuffers.get(rev.source) === pending) impulseBuffers.delete(rev.source); + throw error; + }); impulseBuffers.set(rev.source, pending); } impulseBuffers.get(rev.source) @@ -409,7 +426,9 @@ window.soundEffects = (function () { convolverNode.buffer = buffer; if (dryGainNode) dryGainNode.gain.value = settings.reverb.mainGain; if (wetGainNode) wetGainNode.gain.value = settings.reverb.sendGain; - }).catch(error => console.warn('[SoundEffects] Reverb load failed:', error)); + }).catch(error => { + if (context === audioContext) console.warn('[SoundEffects] Reverb load failed:', error); + }); } } @@ -465,29 +484,6 @@ window.soundEffects = (function () { } } - function animateSlider(id, targetValue, duration = 300) { - const slider = document.getElementById(id); - if (!slider) return; - const startValue = parseFloat(slider.value); - const startTime = performance.now(); - - function update(currentTime) { - const elapsed = currentTime - startTime; - const progress = Math.min(elapsed / duration, 1); - const ease = 1 - Math.pow(1 - progress, 3); // easeOutCubic - const currentValue = startValue + (targetValue - startValue) * ease; - slider.value = currentValue; - - // Trigger the oninput handler to update labels and logic - if (slider.oninput) slider.oninput({ target: slider }); - - if (progress < 1) { - requestAnimationFrame(update); - } - } - requestAnimationFrame(update); - } - function applyPitch() { if (canProcessLocally() && pitchFactorParam) { // Use setTargetAtTime for immediate and smooth Web Audio update @@ -498,13 +494,25 @@ window.soundEffects = (function () { } function renderStatus() { - const enabled = settings.enabled !== false; + const remote = !!document.getElementById('audio-player')?.webkitCurrentPlaybackTargetIsWireless || !!window.LXPlayback?.isRemote; + const native = preferNativePlayback || (isMobileDevice() && !isGraphBuilt && !mobileEffectsEnabled); + const enabled = settings.enabled !== false && !native && !remote; const toggle = document.getElementById('sound-effects-enable'); - if (toggle) toggle.checked = enabled; + if (toggle) { + toggle.checked = enabled; + toggle.disabled = remote; + } const title = document.getElementById('sound-effects-state'); - if (title) title.textContent = enabled ? '音效已开启' : '音效已暂停'; + if (title) title.textContent = enabled ? '音效已开启' : '音效已关闭'; const switchLabel = document.getElementById('sound-effects-switch-label'); if (switchLabel) switchLabel.textContent = enabled ? '音效开' : '音效关'; + const mode = document.getElementById('sound-effects-mode'); + if (mode) { + mode.hidden = enabled; + mode.textContent = remote ? '无线投播 · 本机音效未启用' : native ? '原生播放 · 音效未接管' : '音效已旁路'; + } + const controls = document.getElementById('sound-effects-controls'); + if (controls) controls.disabled = !enabled; const tagsContainer = document.getElementById('sound-effects-tags'); if (tagsContainer) { @@ -587,24 +595,31 @@ window.soundEffects = (function () { } tagsContainer.innerHTML = html; } + tagsContainer.querySelectorAll('button').forEach(button => { button.disabled = !enabled; }); } } function renderUI() { renderStatus(); - const remote = !!document.getElementById('audio-player')?.webkitCurrentPlaybackTargetIsWireless || !!window.LXPlayback?.isRemote; + const wireless = !!document.getElementById('audio-player')?.webkitCurrentPlaybackTargetIsWireless; + const remote = wireless || !!window.LXPlayback?.isRemote; + const captured = needsNativeRestore(); const status = document.getElementById('sound-effects-status'); - if (status) status.textContent = remote - ? 'AirPlay / 投放中:仅保存音效配置' + if (status) status.textContent = wireless && captured + ? '请恢复原生播放后,重新选择 AirPlay 设备' + : remote + ? '已保留音效配置;结束投播后可开启本机音效' + : preferNativePlayback || (isMobileDevice() && !isGraphBuilt && !mobileEffectsEnabled) + ? '已保留全部配置,仅供预览;打开音效开关后按当前配置接管本机音频' : settings.enabled === false - ? '正在播放原始音色,重新开启即可恢复以上设置' - : isGraphBuilt - ? (isMobileDevice() ? '本机音效已开启' : '') + ? '已保留全部配置,仅供预览;打开音效开关后恢复当前配置' + : captured + ? '本机音效或可视化已开启;使用 AirPlay 前请恢复原生播放' : isMobileDevice() ? '选择音效后立即生效' : ''; const nativeButton = document.getElementById('sound-effects-native'); - if (nativeButton) nativeButton.hidden = !isGraphBuilt || !isMobileDevice(); + if (nativeButton) nativeButton.hidden = !captured; // EQ Presets const presetContainer = document.getElementById('eq-presets'); if (presetContainer) { @@ -734,10 +749,19 @@ window.soundEffects = (function () { } } - function ensureLocalGraph() { + function ensureLocalGraph(explicitEnable = false) { const audio = document.getElementById('audio-player'); if (audio?.webkitCurrentPlaybackTargetIsWireless || window.LXPlayback?.isRemote) return false; - if (isMobileDevice() && hasActiveEffects()) mobileEffectsEnabled = true; + if ((explicitEnable || hasActiveEffects()) && preferNativePlayback) { + // Only an explicit local edit opts back into processing. Remote edits + // and saved/server-loaded settings must leave native output alone. + try { + sessionStorage.removeItem('lx_native_audio_mode'); + sessionStorage.removeItem('lx_native_audio_restore'); + } catch (_) { } + preferNativePlayback = false; + } + if (isMobileDevice() && (explicitEnable || hasActiveEffects())) mobileEffectsEnabled = true; if (!canProcessLocally()) return false; if (!audioContext || !isGraphBuilt) init(true); if (audioContext && ['suspended', 'interrupted'].includes(audioContext.state)) { @@ -746,13 +770,41 @@ window.soundEffects = (function () { return !!audioContext && isGraphBuilt; } + function releaseAudioGraph() { + ++reverbRequest; + impulseBuffers.clear(); + if (pannerInfo.interval) clearInterval(pannerInfo.interval); + pannerInfo.interval = null; + window.iOSBackgroundAudio?.dispose(); + window.musicVisualizer?.releaseAudio(); + for (const node of [mediaSource, analyser, ...eqFilters, convolverNode, dryGainNode, + wetGainNode, mixerNode, pannerNode, effectsGain, bypassGain, pitchShifterNode]) { + try { node?.disconnect(); } catch (_) { } + } + const context = audioContext || window._sharedAudioContext; + audioContext = mediaSource = analyser = null; + convolverNode = dryGainNode = wetGainNode = mixerNode = pannerNode = null; + effectsGain = bypassGain = pitchShifterNode = pitchFactorParam = null; + pitchShifterInitPromise = null; + eqFilters = []; + isGraphBuilt = mobileEffectsEnabled = false; + for (const key of ['_sharedAudioContext', '_sharedAudioSourceNode', '_sharedAudioAnalyser', + '_sharedSplitterNode', '_sharedAnalyserL', '_sharedAnalyserR', '_soundEffectsGains']) window[key] = null; + try { context?.close()?.catch(() => {}); } catch (_) { } + } + const manager = { init, setEnabled: function (enabled) { if (!isGraphBuilt) loadSettings(); settings.enabled = !!enabled; saveSettings(); - if (enabled) ensureLocalGraph(); + if (!enabled && !window.LXPlayback?.isRemote) { + this.restoreNativePlayback(); + renderUI(); + return; + } + if (enabled) ensureLocalGraph(true); if (canProcessLocally() && isGraphBuilt) { applyMaster(); updatePanner(); @@ -834,11 +886,10 @@ window.soundEffects = (function () { if (rev) { settings.reverb.mainGain = rev.main; settings.reverb.sendGain = rev.send; - animateSlider('reverb-main-gain', rev.main * 100); - animateSlider('reverb-send-gain', rev.send * 100); } saveSettings(); - if (ensureLocalGraph()) updateReverb(); + // Disabling one effect must not activate other saved effects in native mode. + if (id === 'none' ? canProcessLocally() && isGraphBuilt : ensureLocalGraph()) updateReverb(); renderUI(); }, resetReverb: function () { @@ -849,8 +900,6 @@ window.soundEffects = (function () { settings.reverb.id = 'none'; settings.reverb.mainGain = 1.0; settings.reverb.sendGain = 0; - animateSlider('reverb-main-gain', 100); - animateSlider('reverb-send-gain', 0); settings.eq = Array(10).fill(0); activePresetName = ''; settings.pitch = 1.0; @@ -911,13 +960,26 @@ window.soundEffects = (function () { renderUI(); }, isGraphBuilt: () => isGraphBuilt, - needsNativeReload: () => isMobileDevice() && isGraphBuilt, + isNativePlaybackPreferred: () => preferNativePlayback, + needsNativeRestore, restoreNativePlayback: function () { - if (!this.needsNativeReload()) return; - // Disconnecting nodes cannot release a captured media element. - window.savePlaybackState?.(); - sessionStorage.setItem('lx_native_audio_restore', '1'); - window.location.reload(); + if (window.LXPlayback?.isRemote) return false; + const captured = needsNativeRestore(); + if (captured && !window.LXPlayback?.replaceLocalElement) { + window.showError?.('无法切换音频输出,请重新打开播放器后重试。'); + return false; + } + preferNativePlayback = true; + // Persistence is optional: live handoff must work even if storage is blocked. + try { + sessionStorage.setItem('lx_native_audio_mode', '1'); + sessionStorage.removeItem('lx_native_audio_restore'); + } catch (_) { } + if (captured) { + window.LXPlayback.replaceLocalElement(releaseAudioGraph); + } + renderUI(); + return true; }, getContext: () => audioContext || window._sharedAudioContext || null, getSourceNode: () => mediaSource || window._sharedAudioSourceNode || null, @@ -1015,7 +1077,8 @@ window.soundEffects = (function () { if (isGraphBuilt && canProcessLocally()) applySettings(); renderUI(); }; - document.getElementById('audio-player')?.addEventListener('webkitcurrentplaybacktargetiswirelesschanged', restoreLocalEffects); + (window.LXPlayback || document.getElementById('audio-player'))?.addEventListener('webkitcurrentplaybacktargetiswirelesschanged', restoreLocalEffects); + window.addEventListener('lx-audio-replaced', renderUI); window.addEventListener('lx-cast-route', restoreLocalEffects); // Reverb Gain Sliders const mainGain = document.getElementById('reverb-main-gain'); diff --git a/public/music/js/player/visualizer.js b/public/music/js/player/visualizer.js index d594d15c..0059d47f 100644 --- a/public/music/js/player/visualizer.js +++ b/public/music/js/player/visualizer.js @@ -4,25 +4,13 @@ */ /** - * LX Studio Reference Dual VU Meter Animation - * Bespoke audiophile stereo VU meter console designed for LX Music. - * Features brushed dark titanium faceplate, ambient incandescent lamp glow, - * high-precision dB scales, dynamic peak overload LEDs, and IEC ballistic needles. - * Fully stretchable and responsive for mobile and desktop window resizing with zero clipping. - */ -/** - * LX Studio Reference Dual VU Meter Animation - * Restrained modern audiophile stereo VU meter console designed for LX Music. - * Features a broad shallow sweeping arc, hidden pivot below bezel, high-contrast ivory needle, - * calibrated 0 VU = -18 dBFS ANSI C16.5 300ms ballistics, dedicated digital clip indicators, - * and WCAG-compliant legible typography with zero channel/needle collisions. - */ -/** - * Per-profile dial & pointer designs. - * Every colour profile (vuMeterTheme) owns a complete design: dial geometry, - * scale style, tick style, typography, decorations, pointer shape and pivot cap. + * LX Studio dual VU meter. + * Each profile (vuMeterTheme) is a complete printed instrument ported from the approved + * industrial design study: machined frame, black well, printed face, glass, elliptical scale, + * typography, pointer and pivot hub. Geometry lives in a 620×326 design space. + * Calibration is shared: mapVuToNorm puts 0 VU at norm 0.80, -20 VU at 0.08 and +3 VU at 1.0, + * and every design maps -20 VU to its scale start and +3 VU to its scale end. * Add a new profile with LXStudioVUMeterAnimation.registerDesign(id, design). - * The calibration is shared by all designs; each profile selects its own scale density. */ const VU_NORM_ZERO = 0.8; const VU_SCALE_ANCHORS = [ @@ -52,134 +40,110 @@ const mapVuToNorm = vu => { } return 0; }; -const makeVuTicks = points => points.map(([vu, label, major]) => ({ - label, + +// Printed scale position (0 = -20 VU at the scale start, 1 = +3 VU at the scale end). +// The small overrun lets the pointer travel onto the stop pins. +const VU_SCALE_NORM_MIN = mapVuToNorm(-20); +const VU_SCALE_P_MIN = -0.03; +const VU_SCALE_P_MAX = 1.03; +const vuNormToScale = norm => Math.max(VU_SCALE_P_MIN, Math.min(VU_SCALE_P_MAX, (norm - VU_SCALE_NORM_MIN) / (1 - VU_SCALE_NORM_MIN))); +const vuMarks = points => points.map(([vu, label]) => ({ + vu, + label: label || null, norm: mapVuToNorm(vu), - major: !!major, - type: major || label ? 'med' : 'min', - isRed: vu >= 0 + major: !!label, + hot: vu >= 0 })); -const VU_TICK_PROFILES = { - essential: { - compact: makeVuTicks([[-20, '-20', true], [-10, '-10', true], [-5, '-5', true], [0, '0', true], [3, '+3', true]]), - full: makeVuTicks([[-20, '-20', true], [-15], [-10, '-10', true], [-5, '-5', true], [-3], [0, '0', true], [1], [2], [3, '+3', true]]) - }, - broadcast: { - compact: makeVuTicks([[-20, '-20', true], [-15], [-10, '-10', true], [-7], [-5, '-5', true], [-3], [0, '0', true], [1], [2], [3, '+3', true]]), - full: makeVuTicks([[-20, '-20', true], [-18], [-15, '-15', true], [-12], [-10, '-10', true], [-8], [-7, '-7'], [-6], [-5, '-5', true], [-4], [-3, '-3'], [-2], [-1], [0, '0', true], [1, '+1'], [2, '+2'], [3, '+3', true]]) - }, - precision: { - compact: makeVuTicks([[-20, '-20', true], [-15], [-10, '-10', true], [-7], [-5, '-5', true], [-3], [0, '0', true], [1], [2], [3, '+3', true]]), - full: makeVuTicks([[-20, '-20', true], [-18, '-18'], [-15, '-15', true], [-12, '-12'], [-10, '-10', true], [-8, '-8'], [-7], [-6, '-6'], [-5, '-5', true], [-4, '-4'], [-3, '-3'], [-2, '-2'], [-1], [0, '0', true], [0.5], [1, '+1'], [1.5], [2, '+2'], [2.5], [3, '+3', true]]) - }, - atelier: { - compact: makeVuTicks([[-20, '-20', true], [-15], [-10, '-10', true], [-5, '-5', true], [-3], [0, '0', true], [3, '+3', true]]), - full: makeVuTicks([[-20, '-20', true], [-15, '-15'], [-12], [-10, '-10', true], [-8], [-5, '-5', true], [-3, '-3'], [-1], [0, '0', true], [1, '+1'], [2], [3, '+3', true]]) - }, - studio: { - compact: makeVuTicks([[-20, '-20', true], [-10, '-10', true], [-5, '-5', true], [0, '0', true], [3, '+3', true]]), - full: makeVuTicks([[-20, '-20', true], [-15], [-10, '-10', true], [-7], [-5, '-5', true], [-3], [-1], [0, '0', true], [1], [2], [3, '+3', true]]) - } -}; +const vuPowerMarks = points => points.map(([vu, label]) => ({ vu, label, norm: mapVuToNorm(vu), hot: vu > 0 })); + +const LX_VU_FONT_SANS = '"Avenir Next", Avenir, "Segoe UI", "Helvetica Neue", Arial, sans-serif'; +const LX_VU_FONT_SERIF = 'Georgia, "Times New Roman", serif'; +const LX_VU_DEFAULT_THEME = 'study-nocturne'; const LX_VU_DIAL_DESIGNS = { - // Classic broadcast meter: warm lamp-lit plate, serif numerals, double scale rail, - // tapered black blade with a red painted tip, brass corner screws, domed pivot hub. - 'vintage-amber': { - name: 'Vintage Amber', - scaleProfile: 'broadcast', - geom: { span: 0.07, baseY: { c: 0.66, f: 0.61 }, sag: { c: 0.07, f: 0.08 }, curve: 2, pivot: { c: 1.95, f: 2.25 } }, - font: { family: 'Georgia, "Times New Roman", serif', weight: '500', style: 'normal', scale: 1.0 }, - tag: ch => ch === 'L' ? 'LEFT' : 'RIGHT', tagAlign: 'center', mark: 'VU', markStyle: 'italic', - face: { - kind: 'radial', cy: 0.45, - light: [[0, '#fffdfa'], [0.40, '#fef8ea'], [0.80, '#fdecc4'], [1, '#fbd38d']], - dark: [[0, '#ffe8a3'], [0.35, '#f6c343'], [0.75, '#d99b1a'], [1, '#9c6600']], - vig: { light: ['rgba(180, 120, 20, 0.08)', 'rgba(160, 100, 10, 0.16)'], dark: ['rgba(80, 40, 10, 0.25)', 'rgba(60, 30, 5, 0.38)'], clear: 'rgba(80, 40, 10, 0)' } - }, - arc: 'double', tick: { style: 'bar', len: 1, lwMajor: 1.3, lwMinor: 0.8 }, - needle: { type: 'blade', reach: 1.2, width: 1 }, cap: 'screw', peak: null, sheen: false, deco: 'vintage', lamp: ['255, 186, 96', 1], - light: { ink: '#2b2118', muted: 'rgba(60, 45, 25, 0.7)', arc: '#3a2d1e', red: '#c81e3a', needle: '#16120d', tip: '#c81e3a', glow: 'rgba(255, 200, 90, 0.5)', frame: 'rgba(90, 60, 20, 0.35)', rim: 'rgba(148, 163, 184, 0.55)' }, - dark: { ink: '#2b2118', muted: 'rgba(60, 45, 25, 0.7)', arc: '#3a2d1e', red: '#c81e3a', needle: '#16120d', tip: '#c81e3a', glow: 'rgba(255, 200, 90, 0.5)', frame: 'rgba(90, 60, 20, 0.45)', rim: '#bfa15f' } + // Smoked-titanium reference meter: wide shallow sweep, hairline nickel pointer, warm diffuser. + 'study-nocturne': { + key: 'nocturne', name: 'Nocturne', scale: 'essential', + face: ['#323536', '#202425', '#15191a'], rim: ['#f1e4c8', '#706d66', '#171817'], + ink: '#e5ded0', softInk: '#9da09c', accent: '#c5a36d', hot: '#c9654f', + arc: 'double', tick: 'hair', pointer: 'hairline', + geometry: { start: -148, sweep: 116, radius: 185, pivotY: 257, scaleX: 1.72 }, + type: LX_VU_FONT_SANS, weight: 600, diffuser: { color: '216, 196, 159', alpha: 0.12 }, + caption: 'IEC 60268-17', + marks: vuMarks([[-20, '-20'], [-15], [-10, '-10'], [-7], [-5, '-5'], [-3], [-1], [0, '0'], [1], [2], [3, '+3']]), + secondary: null }, - // Blue-lit hi-fi power meter: deep arc on a backlit blue card, translucent scale band, - // fine bar ticks and a slim pale blade pointer over a jewel hub. - 'ocean-blue': { - name: 'Ocean Blue', - scaleProfile: 'precision', - geom: { span: 0.055, baseY: { c: 0.60, f: 0.57 }, sag: { c: 0.12, f: 0.14 }, curve: 1.6, pivot: { c: 1.75, f: 2.0 } }, - font: { family: '"Avenir Next", "Inter", "Segoe UI", Roboto, sans-serif', weight: '600', style: 'normal', scale: 1.0 }, - tag: ch => 'CH · ' + ch, tagAlign: 'center', mark: 'VU', markStyle: 'normal', - face: { - kind: 'radial', cy: 0.40, - light: [[0, '#f8fafc'], [0.45, '#e0f2fe'], [0.85, '#bae6fd'], [1, '#7dd3fc']], - dark: [[0, '#42d2ff'], [0.35, '#12a4df'], [0.75, '#0369a1'], [1, '#073b64']], - vig: { light: ['rgba(3, 105, 161, 0.08)', 'rgba(2, 132, 199, 0.15)'], dark: ['rgba(0, 30, 65, 0.30)', 'rgba(0, 20, 50, 0.45)'], clear: 'rgba(0, 30, 65, 0)' } - }, - arc: 'band', arcBand: 3.6, tick: { style: 'bar', len: 1, lwMajor: 1.3, lwMinor: 0.8 }, - needle: { type: 'blade', reach: 1.0, width: 0.85 }, cap: 'jewel', peak: null, sheen: true, deco: 'ocean', lamp: ['160, 220, 255', 0.8], - light: { ink: '#0c4a6e', muted: 'rgba(12, 74, 110, 0.65)', arc: '#0369a1', red: '#e11d48', needle: '#075985', tip: '#0ea5e9', glow: 'rgba(14, 165, 233, 0.55)', wave: 'rgba(3, 105, 161, 0.35)', rim: 'rgba(148, 163, 184, 0.55)' }, - dark: { ink: '#f0f9ff', muted: 'rgba(224, 242, 254, 0.75)', arc: 'rgba(240, 249, 255, 0.9)', red: '#ff7a93', needle: '#f0f9ff', tip: '#ffffff', glow: 'rgba(186, 230, 253, 0.95)', wave: 'rgba(255, 255, 255, 0.45)', rim: '#0e5c8f' } + // Broadcast meter on lamp-lit ivory: double rail, serif numerals, tapered blade, watts sub-scale. + 'study-heritage': { + key: 'heritage', name: 'Heritage', scale: 'broadcast', + face: ['#fffaf0', '#f0e2c5', '#d9c39d'], rim: ['#f5e1b5', '#b39765', '#4b3b24'], + ink: '#332a1e', softInk: '#75664f', accent: '#826843', hot: '#bf4436', + arc: 'double', tick: 'bar', pointer: 'blade', + geometry: { start: -155, sweep: 130, radius: 194, pivotY: 269, scaleX: 1.5 }, + type: LX_VU_FONT_SERIF, weight: 500, diffuser: null, + caption: '8 Ω · 100 W REF @ 0 VU', + marks: vuMarks([[-20, '-20'], [-18], [-15, '-15'], [-12], [-10, '-10'], [-8], [-7, '-7'], [-6], [-5, '-5'], [-4], [-3, '-3'], [-2], [-1], [0, '0'], [1, '+1'], [2, '+2'], [3, '+3']]), + secondary: vuPowerMarks([[-20, '1'], [-10, '10'], [-5, '32'], [-3, '50'], [0, '100'], [3, '200']]) }, - // Modern reference meter: flat wide arc on a black card with cyan-lit printing, - // geometric sans type, translucent scale band and a hairline pointer. - 'cyber-cyan': { - name: 'Cyber Cyan', - scaleProfile: 'essential', - geom: { span: 0.045, baseY: { c: 0.68, f: 0.64 }, sag: { c: 0.05, f: 0.06 }, curve: 4, pivot: { c: 2.2, f: 2.6 } }, - font: { family: '"Futura", "Century Gothic", "Avenir Next", "Segoe UI", sans-serif', weight: '500', style: 'normal', scale: 1.0 }, - tag: ch => ch === 'L' ? 'LEFT' : 'RIGHT', tagAlign: 'center', mark: 'VU', markStyle: 'normal', - face: { - kind: 'linear', - light: [[0, '#ffffff'], [0.60, '#f0fdfa'], [1, '#ccfbf1']], - dark: [[0, '#0c1015'], [0.5, '#070a0e'], [1, '#030508']], - glow: { color: '6, 182, 212', a0: 0.14, a1: 0.03 } - }, - arc: 'band', arcBand: 2.4, tick: { style: 'bar', len: 1, lwMajor: 1.3, lwMinor: 0.8 }, - needle: { type: 'hair', reach: 1.0, width: 1 }, cap: 'ring', peak: null, sheen: true, deco: 'cyber', lamp: ['80, 230, 255', 0.6], - light: { ink: '#115e59', muted: 'rgba(17, 94, 89, 0.65)', arc: '#0f766e', red: '#e11d48', needle: '#0f766e', tip: '#06b6d4', glow: 'rgba(6, 182, 212, 0.5)', grid: 'rgba(15, 118, 110, 0.35)', rim: 'rgba(148, 163, 184, 0.55)' }, - dark: { ink: '#22d3ee', muted: 'rgba(34, 211, 238, 0.6)', arc: 'rgba(34, 211, 238, 0.9)', red: '#ff2d6f', needle: '#7df9ff', tip: '#00f5ff', glow: '#00f5ff', grid: 'rgba(34, 211, 238, 0.5)', rim: '#1e3040' } + // Mastering meter in a champagne-brass frame: single rail, wedge graduations, counterweighted pointer. + 'study-atelier': { + key: 'atelier', name: 'Atelier', scale: 'studio', + face: ['#292824', '#1a1b19', '#121311'], rim: ['#f2deb4', '#a7844b', '#28231b'], + ink: '#e7d3ae', softInk: '#a18f70', accent: '#d0ad73', hot: '#cf674f', + arc: 'single', tick: 'wedge', pointer: 'counterweight', + geometry: { start: -158, sweep: 136, radius: 191, pivotY: 270, scaleX: 1.48 }, + type: LX_VU_FONT_SANS, weight: 600, diffuser: null, + caption: 'IEC 60268-17', + marks: vuMarks([[-20, '-20'], [-17], [-13, '-13'], [-10], [-8, '-8'], [-6], [-5, '-5'], [-4], [-3, '-3'], [-2], [-1], [0, '0'], [1, '+1'], [2, '+2'], [3, '+3']]), + secondary: null }, - // Champagne-faced luxury meter: soft sunburst, double gold frame with corner lozenges, - // wedge ticks capped with pearls, radial serif numerals and a slim black blade with red tip. - 'champagne-gold': { - name: 'Champagne Gold', - scaleProfile: 'atelier', - geom: { span: 0.09, baseY: { c: 0.64, f: 0.60 }, sag: { c: 0.10, f: 0.11 }, curve: 2.6, pivot: { c: 1.9, f: 2.2 } }, - font: { family: '"Didot", "Bodoni 72", "Playfair Display", Georgia, serif', weight: '700', style: 'normal', scale: 1.0 }, - tag: ch => '— ' + ch + ' —', tagAlign: 'center', mark: 'VU', markStyle: 'italic', rotateLabels: true, - face: { - kind: 'radial', cy: 0.45, - light: [[0, '#ffffff'], [0.40, '#fefce8'], [0.80, '#fef08a'], [1, '#fde047']], - dark: [[0, '#fff4cc'], [0.35, '#fae39b'], [0.70, '#dfba63'], [1, '#9b7625']], - vig: { light: ['rgba(113, 63, 18, 0.06)', 'rgba(113, 63, 18, 0.14)'], dark: ['rgba(60, 45, 20, 0.25)', 'rgba(50, 35, 10, 0.38)'], clear: 'rgba(60, 45, 20, 0)' } - }, - arc: 'double', arcInset: 2.4, tick: { style: 'wedge', len: 1, lwMajor: 1.6, lwMinor: 0.9 }, - needle: { type: 'blade', reach: 1.0, width: 0.9 }, cap: 'rosette', peak: null, sheen: true, deco: 'champagne', lamp: ['255, 228, 170', 0.9], - light: { ink: '#3b2a0c', muted: 'rgba(80, 58, 20, 0.7)', arc: '#5a4214', red: '#a61b1b', needle: '#2a1d06', tip: '#a61b1b', edge: '#7a5a12', glow: 'rgba(255, 240, 180, 0.6)', ray: 'rgba(255, 230, 150, 0.22)', frame: '#9c7a2a', rim: 'rgba(180, 140, 60, 0.6)' }, - dark: { ink: '#3b2a0c', muted: 'rgba(80, 58, 20, 0.7)', arc: '#5a4214', red: '#a61b1b', needle: '#2a1d06', tip: '#a61b1b', edge: '#7a5a12', glow: 'rgba(255, 240, 180, 0.6)', ray: 'rgba(255, 255, 255, 0.16)', frame: '#8a6a1f', rim: '#dab978' } + // Porcelain reference meter: open shallow sweep, fine charcoal indices, blue-steel hairline. + 'study-riviera': { + key: 'riviera', name: 'Riviera', scale: 'open', + face: ['#f1f0e9', '#d9d9d1', '#c5c6c0'], rim: ['#fafaf5', '#a9aaa5', '#4b4d4a'], + ink: '#252923', softInk: '#777b75', accent: '#6d7771', hot: '#bd5947', + arc: 'double', tick: 'hair', pointer: 'hairline', pointerColors: ['#172128', '#26363f', '#10171b'], + geometry: { start: -152, sweep: 124, radius: 194, pivotY: 267, scaleX: 1.5 }, + type: LX_VU_FONT_SANS, weight: 600, diffuser: null, + caption: 'IEC 60268-17', + marks: vuMarks([[-20, '-20'], [-15], [-10, '-10'], [-5], [-3], [0, '0'], [3, '+3']]), + secondary: null }, - // Studio monitor meter: crisp white card, fine double rail, left-aligned channel letter, - // hairline rule, long thin ticks and a spear-tipped hairline pointer. - 'studio-monochrome': { - name: 'Studio Monochrome', - scaleProfile: 'studio', - geom: { span: 0.10, baseY: { c: 0.67, f: 0.63 }, sag: { c: 0.035, f: 0.04 }, curve: 2, pivot: { c: 2.1, f: 2.4 } }, - font: { family: '"Helvetica Neue", "Inter", Arial, sans-serif', weight: '600', style: 'normal', scale: 1.05 }, - tag: ch => ch, tagAlign: 'left', mark: 'VU', markStyle: 'normal', - face: { - kind: 'radial', cy: 0.38, - light: [[0, '#ffffff'], [0.65, '#f8fafc'], [1, '#edf2f7']], - dark: [[0, '#191b22'], [0.60, '#101217'], [1, '#08090d']], - vig: { light: ['rgba(0, 0, 0, 0.03)', 'rgba(0, 0, 0, 0.08)'], dark: ['rgba(0, 0, 0, 0.35)', 'rgba(0, 0, 0, 0.50)'], clear: 'rgba(0, 0, 0, 0)' } - }, - arc: 'double', arcInset: 1.6, tick: { style: 'bar', len: 1.15, lwMajor: 1.1, lwMinor: 0.7 }, - needle: { type: 'hair', reach: 1.0, width: 1 }, cap: 'dot', peak: null, sheen: false, deco: 'mono', lamp: ['235, 240, 255', 0.3], - light: { ink: '#0f172a', muted: 'rgba(15, 23, 42, 0.55)', arc: '#334155', red: '#f43f5e', needle: '#0f172a', tip: '#f43f5e', rule: 'rgba(15, 23, 42, 0.25)', rim: 'rgba(148, 163, 184, 0.55)' }, - dark: { ink: '#f8fafc', muted: 'rgba(226, 232, 240, 0.6)', arc: 'rgba(241, 245, 249, 0.85)', red: '#f43f5e', needle: '#e2e8f0', tip: '#f43f5e', rule: 'rgba(226, 232, 240, 0.25)', rim: '#334155' } + // Cobalt mastering instrument: dense dB scale, 8 Ω power arc, vermilion-tipped blade. + 'study-cobalt': { + key: 'cobalt', name: 'Cobalt', scale: 'mastering', + face: ['#16345a', '#0c2343', '#08172d'], rim: ['#c9d6df', '#576d82', '#111923'], + ink: '#dce9ef', softInk: '#9cb6c5', accent: '#80bad1', hot: '#ff785f', + arc: 'single', tick: 'bar', pointer: 'blade', + geometry: { start: -158, sweep: 136, radius: 191, pivotY: 270, scaleX: 1.48 }, + type: LX_VU_FONT_SANS, weight: 600, diffuser: { color: '83, 170, 255', alpha: 0.25 }, + caption: '8 Ω · 100 W REF @ 0 VU', + marks: vuMarks([[-20, '-20'], [-18], [-15, '-15'], [-12, '-12'], [-10], [-9, '-9'], [-8], [-7], [-6, '-6'], [-5], [-4, '-4'], [-3, '-3'], [-2, '-2'], [-1], [0, '0'], [1, '+1'], [2, '+2'], [3, '+3']]), + secondary: vuPowerMarks([[-18, '1.6'], [-12, '6'], [-6, '25'], [-3, '50'], [0, '100'], [3, '200']]) } }; +const LX_VU_RAD = Math.PI / 180; +// Same projection as the design study: an ellipse around (cx, cy) stretched horizontally by scaleX. +const vuDesignPoint = (deg, radius, cx, cy, scaleX) => [ + cx + Math.cos(deg * LX_VU_RAD) * radius * scaleX, + cy + Math.sin(deg * LX_VU_RAD) * radius +]; + +const normalizeVuTheme = theme => { + if (theme && Object.prototype.hasOwnProperty.call(LX_VU_DIAL_DESIGNS, theme)) return theme; + return LX_VU_DEFAULT_THEME; +}; +// Phones get no VU meter: the instrument needs desktop width to stay legible. +const isVuMobileViewport = () => typeof window !== 'undefined' && typeof window.innerWidth === 'number' && window.innerWidth > 0 && window.innerWidth < 768; +// Design-space window drawn per pod. The compact footer crops to the scale band and hides the pivot. +const LX_VU_VIEWPORT = { full: { x: 0, y: 0, w: 620, h: 326 }, compact: { x: 0, y: 36, w: 620, h: 214 } }; +const vuHexA = (hex, a) => { + const v = parseInt(hex.slice(1), 16); + return `rgba(${(v >> 16) & 255}, ${(v >> 8) & 255}, ${v & 255}, ${a})`; +}; +const vuLabelPriority = vu => (vu === 0 ? 0 : (vu === -20 || vu === 3) ? 1 : vu % 5 === 0 ? 2 : 3); +const vuBoxesOverlap = (a, b) => a.x0 < b.x1 && b.x0 < a.x1 && a.y0 < b.y1 && b.y0 < a.y1; class LXStudioVUMeterAnimation { constructor(options = {}) { @@ -219,12 +183,6 @@ class LXStudioVUMeterAnimation { return mapVuToNorm(vu); } - getScaleTicks(theme, compactLike) { - const profile = this.getDesign(theme).scaleProfile || 'broadcast'; - const ticks = VU_TICK_PROFILES[profile] || VU_TICK_PROFILES.broadcast; - return compactLike ? ticks.compact : ticks.full; - } - draw(data, ctx) { if (!ctx || !ctx.canvas) return; const width = ctx.canvas.width; @@ -360,14 +318,50 @@ class LXStudioVUMeterAnimation { this.renderConsole(ctx, width, height, this.needleNormL, this.needleNormR, this.isCompact); } + getDesign(theme) { + return LX_VU_DIAL_DESIGNS[normalizeVuTheme(theme)]; + } + + resolveTheme() { + const saved = typeof window !== 'undefined' && window.settings && window.settings.vuMeterTheme; + return normalizeVuTheme(saved || this.options.theme); + } + + getScaleMarks(theme) { + return this.getDesign(theme).marks; + } + + // Angle (design degrees) of a calibrated norm on the profile's printed scale + scaleAngle(theme, norm) { + const g = this.getDesign(theme).geometry; + return g.start + g.sweep * vuNormToScale(norm); + } + + static registerDesign(id, design) { + if (id && design) LX_VU_DIAL_DESIGNS[id] = design; + } + + static normalizeTheme(theme) { + return normalizeVuTheme(theme); + } + + pixelRatio(ctx) { + const c = ctx && ctx.canvas; + if (c && c.clientWidth > 0 && c.width > 0) return Math.max(1, c.width / c.clientWidth); + return (typeof window !== 'undefined' && window.devicePixelRatio) || 1; + } + renderConsole(ctx, width, height, levelL, levelR, isCompact) { ctx.save(); - - // 1. 彻底清空并绘制 100% 实体防透光底板 (Solid Anti-bleed Baseplate) ctx.clearRect(0, 0, width, height); + if (isVuMobileViewport()) { + ctx.restore(); + return; + } - const theme = (window.settings && window.settings.vuMeterTheme) || this.options.theme || 'vintage-amber'; + const D = this.getDesign(this.resolveTheme()); const isLight = this.isLightMode(); + const dpr = this.pixelRatio(ctx); const marginX = isCompact ? 3 : 6; const marginY = isCompact ? 2 : 5; @@ -375,15 +369,10 @@ class LXStudioVUMeterAnimation { const panelH = height - marginY * 2; const panelX = marginX; const panelY = marginY; - - const isMobilePod = isCompact && (panelW < 440 || (typeof window !== 'undefined' && window.innerWidth < 768)); - const scale = isMobilePod - ? Math.min(panelW / 320, panelH / 40) - : Math.min(panelW / (isCompact ? 500 : 840), panelH / (isCompact ? 68 : 210)); + const scale = Math.min(panelW / (isCompact ? 500 : 840), panelH / (isCompact ? 68 : 210)); const cornerR = (isCompact ? 10 : 12) * scale; if (isCompact) { - // 播放栏紧凑模式:柔和磨砂卡片,与播放栏融为一体,避免硬件外壳的突兀感 ctx.fillStyle = isLight ? 'rgba(255, 255, 255, 0.96)' : 'rgba(17, 20, 26, 0.96)'; this.roundRect(ctx, panelX, panelY, panelW, panelH, cornerR); ctx.fill(); @@ -392,1015 +381,451 @@ class LXStudioVUMeterAnimation { this.roundRect(ctx, panelX + 0.5, panelY + 0.5, panelW - 1, panelH - 1, cornerR); ctx.stroke(); } else { - // 100% 实体防穿透遮蔽基底 - ctx.fillStyle = isLight ? '#f8fafc' : '#080a0f'; - this.roundRect(ctx, panelX - 1, panelY - 1, panelW + 2, panelH + 2, cornerR); - ctx.fill(); - - // 2. 现代机架母带硬件外壳 (Precision Aircraft Aluminum / Obsidian Chassis) - if (isLight) { - ctx.shadowColor = 'rgba(0, 0, 0, 0.08)'; - ctx.shadowBlur = 8 * scale; - ctx.shadowOffsetY = 2 * scale; - const faceGrad = ctx.createLinearGradient(panelX, panelY, panelX, panelY + panelH); - faceGrad.addColorStop(0, '#ffffff'); - faceGrad.addColorStop(0.35, '#f8fafc'); - faceGrad.addColorStop(0.75, '#f1f5f9'); - faceGrad.addColorStop(1, '#e2e8f0'); - ctx.fillStyle = faceGrad; + const chassis = ctx.createLinearGradient(panelX, panelY, panelX, panelY + panelH); + const stops = isLight ? ['#ffffff', '#f1f5f9', '#e2e8f0'] : ['#1a1d24', '#121419', '#090a0d']; + chassis.addColorStop(0, stops[0]); + chassis.addColorStop(0.5, stops[1]); + chassis.addColorStop(1, stops[2]); + ctx.fillStyle = chassis; this.roundRect(ctx, panelX, panelY, panelW, panelH, cornerR); ctx.fill(); - ctx.shadowColor = 'transparent'; - - // Chamfer Inset Highlight - ctx.strokeStyle = 'rgba(255, 255, 255, 0.95)'; - ctx.lineWidth = 1 * scale; - this.roundRect(ctx, panelX + 0.5, panelY + 0.5, panelW - 1, panelH - 1, cornerR); - ctx.stroke(); - - // Precision Hairline Contour - ctx.strokeStyle = 'rgba(148, 163, 184, 0.40)'; + ctx.strokeStyle = isLight ? 'rgba(148, 163, 184, 0.40)' : 'rgba(255, 255, 255, 0.09)'; ctx.lineWidth = 0.8 * scale; - this.roundRect(ctx, panelX, panelY, panelW, panelH, cornerR); - ctx.stroke(); - } else { - const faceGrad = ctx.createLinearGradient(panelX, panelY, panelX, panelY + panelH); - if (theme === 'champagne-gold') { - faceGrad.addColorStop(0, '#2b251d'); - faceGrad.addColorStop(0.5, '#1e1a14'); - faceGrad.addColorStop(1, '#14110d'); - } else { - faceGrad.addColorStop(0, '#1a1d24'); - faceGrad.addColorStop(0.5, '#121419'); - faceGrad.addColorStop(1, '#090a0d'); - } - ctx.fillStyle = faceGrad; - this.roundRect(ctx, panelX, panelY, panelW, panelH, cornerR); - ctx.fill(); - - // Chamfer Edge - ctx.strokeStyle = theme === 'champagne-gold' ? 'rgba(218, 185, 120, 0.25)' : 'rgba(255, 255, 255, 0.09)'; - ctx.lineWidth = 1 * scale; this.roundRect(ctx, panelX + 0.5, panelY + 0.5, panelW - 1, panelH - 1, cornerR); ctx.stroke(); - - // Outer Rim - ctx.strokeStyle = 'rgba(0, 0, 0, 0.65)'; - ctx.lineWidth = 0.8 * scale; - this.roundRect(ctx, panelX, panelY, panelW, panelH, cornerR); - ctx.stroke(); - } } - // 3. 黄金比例空间排版 - const padX = (isCompact ? (isMobilePod ? 4 : 6) : 12) * scale; - const padY = (isCompact ? (isMobilePod ? 3 : 5) : 8) * scale; + const padX = (isCompact ? 4 : 12) * scale; + const padY = (isCompact ? 3 : 8) * scale; const innerX = panelX + padX; const innerY = panelY + padY; const innerW = panelW - padX * 2; const innerH = panelH - padY * 2; - - if (isMobilePod) { - // 手机移动端:自适应单表盘·同轴双指针 (Dual-Needle Studio VU) - this.drawSingleStereoPod(ctx, innerX, innerY, innerW, innerH, levelL, levelR, scale, theme, isLight); - } else { - // PC 紧凑端与详情页:双表头与中桥 - const centerW = (isCompact ? 26 : 72) * scale; - const gap = (isCompact ? 3 : 10) * scale; - const meterW = (innerW - centerW - gap * 2) / 2; - const meterH = innerH; - - const leftX = innerX; - const centerX = leftX + meterW + gap; - const rightX = centerX + centerW + gap; - - this.drawMeterPod(ctx, leftX, innerY, meterW, meterH, levelL, scale, 'L', isCompact, theme, isLight); - this.drawCenterConsole(ctx, centerX, innerY, centerW, meterH, scale, isCompact, theme, isLight, levelL, levelR); - this.drawMeterPod(ctx, rightX, innerY, meterW, meterH, levelR, scale, 'R', isCompact, theme, isLight); - } - + const centerW = (isCompact ? 26 : 72) * scale; + const gap = (isCompact ? 3 : 10) * scale; + const meterW = (innerW - centerW - gap * 2) / 2; + const leftX = innerX; + const centerX = leftX + meterW + gap; + const rightX = centerX + centerW + gap; + const o = { compact: !!isCompact, dpr }; + + this.drawMeterPod(ctx, leftX, innerY, meterW, innerH, levelL, 'L', D, o); + this.drawCenterConsole(ctx, centerX, innerY, centerW, innerH, scale, isCompact, D, isLight, levelL, levelR); + this.drawMeterPod(ctx, rightX, innerY, meterW, innerH, levelR, 'R', D, o); ctx.restore(); } - getDesign(theme) { - return LX_VU_DIAL_DESIGNS[theme] || LX_VU_DIAL_DESIGNS['vintage-amber']; - } - - // Cached paper-grain pattern for printed dial cards (null when no offscreen canvas is available) - getGrain(ctx) { - if (this._grain !== undefined) return this._grain; - this._grain = null; - try { - if (typeof document === 'undefined' || typeof document.createElement !== 'function' || typeof ctx.createPattern !== 'function') return null; - const c = document.createElement('canvas'); - c.width = c.height = 64; - const g = c.getContext && c.getContext('2d'); - if (!g || typeof g.createImageData !== 'function') return null; - const img = g.createImageData(64, 64); - let seed = 1337; - for (let i = 0; i < img.data.length; i += 4) { - seed = (seed * 1664525 + 1013904223) >>> 0; - const v = (seed >>> 24) & 0xff; - img.data[i] = img.data[i + 1] = img.data[i + 2] = v; - img.data[i + 3] = 255; + // Maps the 620×326 design space onto a pod. Horizontal stretch is limited so the dial keeps its wide look. + podLayout(D, x, y, w, h, compact) { + const vp = compact ? LX_VU_VIEWPORT.compact : LX_VU_VIEWPORT.full; + let kx = w / vp.w; + let ky = h / vp.h; + if (kx > ky * 1.3) kx = ky * 1.3; + if (ky > kx * 1.05) ky = kx * 1.05; + const rw = vp.w * kx; + const rh = vp.h * ky; + const rx = x + (w - rw) / 2; + const ry = y + (h - rh) / 2; + const k = Math.min(kx, ky); + const X = dx => rx + (dx - vp.x) * kx; + const Y = dy => ry + (dy - vp.y) * ky; + const g = D.geometry; + const inset = d => ({ x: rx + d * k, y: ry + d * k, w: rw - 2 * d * k, h: rh - 2 * d * k }); + return { + compact, vp, rx, ry, rw, rh, kx, ky, k, X, Y, inset, + frame: inset(3), well: inset(9), face: inset(15), clip: inset(16), + pivot: [X(310), Y(g.pivotY)], + angleOf: p => g.start + g.sweep * p, + pt: (deg, r) => { + const [dx, dy] = vuDesignPoint(deg, r, 310, g.pivotY, g.scaleX); + return [X(dx), Y(dy)]; } - g.putImageData(img, 0, 0); - this._grain = ctx.createPattern(c, 'repeat') || null; - } catch (_) { - this._grain = null; - } - return this._grain; + }; } - static registerDesign(id, design) { - if (id && design) LX_VU_DIAL_DESIGNS[id] = design; + frameGradient(ctx, D, x, y, w, h) { + const grad = ctx.createLinearGradient(x, y, x + w, y + h); + grad.addColorStop(0, D.rim[0]); + grad.addColorStop(0.18, D.rim[1]); + grad.addColorStop(0.49, D.rim[2]); + grad.addColorStop(0.77, D.rim[1]); + grad.addColorStop(1, D.rim[0]); + return grad; } - // Single-channel pod (desktop / detail view) - drawMeterPod(ctx, x, y, w, h, level, scale, channelTitle, isCompact, theme, isLight) { - this.drawDialPod(ctx, x, y, w, h, { - needles: [{ level, key: channelTitle }], - scale, isCompact, theme, isLight, title: channelTitle, stereo: false - }); + setLetterSpacing(ctx, px) { + if ('letterSpacing' in ctx) ctx.letterSpacing = `${px}px`; } - // Mobile single dial with coaxial L/R needles - drawSingleStereoPod(ctx, x, y, w, h, levelL, levelR, scale, theme, isLight) { - this.drawDialPod(ctx, x, y, w, h, { - needles: [{ level: levelL, key: 'L', channel: 'L' }, { level: levelR, key: 'R', channel: 'R' }], - scale, isCompact: true, theme, isLight, title: '', stereo: true - }); + // Printed text: letter spacing trails the last glyph, so shift centred text back by half a space. + printText(ctx, text, x, y, font, color, spacing = 0) { + ctx.font = font; + ctx.fillStyle = color; + ctx.textAlign = 'center'; + this.setLetterSpacing(ctx, spacing); + ctx.fillText(text, x + spacing / 2, y); + this.setLetterSpacing(ctx, 0); } - // Shared dial renderer: geometry, scale, ticks, pointer and decorations all come from the profile's design - drawDialPod(ctx, x, y, w, h, o) { - const D = this.getDesign(o.theme); - const P = o.isLight ? D.light : D.dark; - const { scale, isCompact, isLight, stereo } = o; - const compactLike = isCompact || stereo; - const k = compactLike ? 'c' : 'f'; - const G = D.geom; - const podR = 8 * scale; - const nowMs = performance.now(); - const t = nowMs / 1000; - const sz = stereo ? { maj: 5.5, med: 3.8, min: 2.2, font: 9.2, gap: 4.5, cap: 3.6, tag: 8.5, mark: 10 } - : isCompact ? { maj: 6, med: 4, min: 2.5, font: 9.8, gap: 5, cap: 3, tag: 8.5, mark: 10.5 } - : { maj: 11, med: 7, min: 4.5, font: 13, gap: 8, cap: 5, tag: 11, mark: 18.5 }; - const tl = D.tick.len; - const majLen = sz.maj * scale * tl; - const medLen = sz.med * scale * tl; - const minLen = sz.min * scale * tl; - const fam = D.font.family; - - // Scale geometry (arc shape differs per design) - const fontPx = Math.max(stereo ? 8.0 : 8.5, Math.round(sz.font * scale * D.font.scale)); - const labelInset = majLen + sz.gap * scale + fontPx * 1.1 + 2 * scale; - const spanX = Math.max(w * G.span, labelInset); - const sx = x + spanX; - const ex = x + w - spanX; - const sw = ex - sx; - const baseY = y + h * G.baseY[k]; - const sag = h * G.sag[k]; - const pivotX = x + w / 2; - const pivotY = y + h * G.pivot[k]; - const getScalePoint = (norm, off = 0) => { - const bx = sx + norm * sw; - const u = Math.abs((norm - 0.5) * 2); - const by = baseY + Math.pow(u, G.curve) * sag; - const dx = bx - pivotX; - const dy = by - pivotY; - const dist = Math.sqrt(dx * dx + dy * dy); - const ux = dx / dist; - const uy = dy / dist; - return { bx: bx + ux * off, by: by + uy * off, ux, uy }; + // Places scale numerals radially inside the graduations; any numeral that would touch a + // neighbour or leave the face is dropped (important numerals are placed first). + layoutScaleLabels(ctx, D, L, dpr = 1, withSecondary = false) { + const g = D.geometry; + const k = L.k; + const minFont = (L.compact ? 8 : 9) * dpr; + const innerR = D.arc === 'single' ? g.radius - (D.tick === 'wedge' ? 12 : 7) : g.radius - 15; + const gap = Math.max(2 * dpr, 3 * k); + const pad = 1.5 * dpr; + const face = L.clip; + const placed = []; + const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]); + const place = (mark, labelR, innerDist, size, color, group) => { + ctx.font = `${D.weight} ${size.toFixed(2)}px ${D.type}`; + const tw = ctx.measureText(mark.label).width; + const th = size * 0.74; + const deg = L.angleOf(vuNormToScale(mark.norm)); + const base = L.pt(deg, g.radius); + const u0 = base[0] - L.pivot[0]; + const u1 = base[1] - L.pivot[1]; + const ul = Math.hypot(u0, u1) || 1; + const ux = u0 / ul; + const uy = u1 / ul; + const half = Math.abs(ux) * tw / 2 + Math.abs(uy) * th / 2; + const d = Math.max(dist(base, L.pt(deg, labelR)), innerDist(deg) + gap + half); + const lx = base[0] - ux * d; + const ly = base[1] - uy * d; + if (lx - tw / 2 < face.x || lx + tw / 2 > face.x + face.w || ly - th / 2 < face.y || ly + th / 2 > face.y + face.h) return; + const box = { x0: lx - tw / 2 - pad, x1: lx + tw / 2 + pad, y0: ly - th / 2 - pad, y1: ly + th / 2 + pad }; + if (placed.some(p => vuBoxesOverlap(p.box, box))) return; + placed.push({ text: mark.label, vu: mark.vu, x: lx, y: ly, size, color, font: ctx.font, box, group }); }; - - ctx.save(); - ctx.save(); - this.roundRect(ctx, x, y, w, h, podR); - ctx.clip(); - - // 1. Dial face - const stops = isLight ? D.face.light : D.face.dark; - let faceFill; - if (D.face.kind === 'radial') { - const cy = y + h * D.face.cy; - faceFill = ctx.createRadialGradient(x + w / 2, cy, 12 * scale, x + w / 2, cy, Math.max(w * 0.65, h * 1.1)); - } else { - faceFill = ctx.createLinearGradient(x, y, x, y + h); + const minTick = L.compact ? 4.5 * dpr : 0; + const main = D.marks.map((m, i) => ({ m, i })).filter(c => c.m.label) + .sort((a, b) => vuLabelPriority(a.m.vu) - vuLabelPriority(b.m.vu) || a.i - b.i); + for (const { m } of main) { + const size = Math.max(minFont, ((m.label === '-20' || m.label === '+3') ? 11 : 12) * k); + place(m, g.radius - 28, deg => Math.max(dist(L.pt(deg, g.radius), L.pt(deg, innerR)), minTick), size, m.hot ? D.hot : D.ink, 'main'); } - stops.forEach(s => faceFill.addColorStop(s[0], s[1])); - ctx.fillStyle = faceFill; - ctx.fillRect(x, y, w, h); - - if (D.face.vig) { - const v = isLight ? D.face.vig.light : D.face.vig.dark; - const vg = ctx.createLinearGradient(x, y, x, y + h); - vg.addColorStop(0, v[0]); - vg.addColorStop(0.3, D.face.vig.clear); - vg.addColorStop(0.7, D.face.vig.clear); - vg.addColorStop(1, v[1]); - ctx.fillStyle = vg; - ctx.fillRect(x, y, w, h); - } - if (D.face.glow && !isLight) { - const gl = ctx.createRadialGradient(x + w / 2, y + h * 0.5, 10 * scale, x + w / 2, y + h * 0.5, w * 0.65); - gl.addColorStop(0, `rgba(${D.face.glow.color}, ${D.face.glow.a0})`); - gl.addColorStop(0.6, `rgba(${D.face.glow.color}, ${D.face.glow.a1})`); - gl.addColorStop(1, 'rgba(0, 0, 0, 0)'); - ctx.fillStyle = gl; - ctx.fillRect(x, y, w, h); + if (withSecondary && D.secondary) { + const size = Math.max(7 * dpr, 9 * k); + for (const m of D.secondary) { + place(m, g.radius - 101, deg => dist(L.pt(deg, g.radius), L.pt(deg, g.radius - 82)), size, m.hot ? D.hot : D.softInk, 'secondary'); + } } + return placed.sort((a, b) => (a.group === b.group ? a.vu - b.vu : a.group === 'main' ? -1 : 1)); + } - // 1b. Printed-card realism: paper grain, aged edges and a recessed window - const grain = this.getGrain(ctx); - if (grain) { - ctx.save(); - ctx.globalAlpha = isLight ? 0.10 : 0.07; - ctx.fillStyle = grain; - ctx.fillRect(x, y, w, h); - ctx.restore(); - } - const warm = D.deco === 'vintage' || D.deco === 'champagne'; - const age = ctx.createRadialGradient(x + w / 2, y + h * 0.5, Math.min(w, h) * 0.2, x + w / 2, y + h * 0.5, Math.max(w * 0.62, h * 1.1)); - age.addColorStop(0, 'rgba(0, 0, 0, 0)'); - age.addColorStop(1, warm ? 'rgba(90, 50, 10, 0.28)' : (isLight ? 'rgba(0, 0, 0, 0.10)' : 'rgba(0, 0, 0, 0.30)')); - ctx.fillStyle = age; - ctx.fillRect(x, y, w, h); - const rec = Math.min(10 * scale, h * 0.2); - const recTop = ctx.createLinearGradient(x, y, x, y + rec); - recTop.addColorStop(0, 'rgba(0, 0, 0, 0.38)'); - recTop.addColorStop(1, 'rgba(0, 0, 0, 0)'); - ctx.fillStyle = recTop; - ctx.fillRect(x, y, w, rec); - const recLeft = ctx.createLinearGradient(x, y, x + rec, y); - recLeft.addColorStop(0, 'rgba(0, 0, 0, 0.28)'); - recLeft.addColorStop(1, 'rgba(0, 0, 0, 0)'); - ctx.fillStyle = recLeft; - ctx.fillRect(x, y, rec, h); - const recRight = ctx.createLinearGradient(x + w, y, x + w - rec * 0.6, y); - recRight.addColorStop(0, 'rgba(0, 0, 0, 0.14)'); - recRight.addColorStop(1, 'rgba(0, 0, 0, 0)'); - ctx.fillStyle = recRight; - ctx.fillRect(x + w - rec * 0.6, y, rec * 0.6, h); - - // 1c. Lamp behind the translucent dial card - if (D.lamp) { + drawMeterPod(ctx, x, y, w, h, level, channel, D, o = {}) { + const compact = !!o.compact; + const dpr = o.dpr || 1; + const L = this.podLayout(D, x, y, w, h, compact); + const { k, pt, angleOf } = L; + const g = D.geometry; + const showDetail = !compact && k >= 0.55 * dpr; + const lw = (v, min) => Math.max(v * k, min * dpr); + const rr = (r, rad) => this.roundRect(ctx, r.x, r.y, r.w, r.h, rad * k); + + ctx.save(); + // Machined frame, black well and printed face + ctx.fillStyle = this.frameGradient(ctx, D, L.frame.x, L.frame.y, L.frame.w, L.frame.h); + rr(L.frame, 8); + ctx.fill(); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.28)'; + ctx.lineWidth = lw(1.2, 0.5); + ctx.stroke(); + ctx.fillStyle = '#080909'; + rr(L.well, 5); + ctx.fill(); + ctx.strokeStyle = 'rgba(0, 0, 0, 0.86)'; + ctx.lineWidth = lw(2, 0.5); + ctx.stroke(); + const F = L.face; + const faceGrad = ctx.createLinearGradient(F.x, F.y, F.x + F.w * 0.15, F.y + F.h); + faceGrad.addColorStop(0, D.face[0]); + faceGrad.addColorStop(0.56, D.face[1]); + faceGrad.addColorStop(1, D.face[2]); + ctx.fillStyle = faceGrad; + rr(F, 3); + ctx.fill(); + ctx.strokeStyle = vuHexA(D.accent, 0.46); + ctx.lineWidth = lw(0.8, 0.5); + ctx.stroke(); + + // Glass reflection across the top of the window (bounding-box radial gradient) + { + const gx0 = F.x + 2 * k; + const gw = F.w - 4 * k; + const gy = d => F.y + (d - 15) * L.ky; + const top = gy(11.5); + const bh = gy(72) - top; ctx.save(); - const lx = x + w / 2; - const ly = y + h * 1.02; - const gain = D.lamp[1]; - const lg = ctx.createRadialGradient(lx, ly, 2 * scale, lx, ly, Math.max(w * 0.6, h * 1.05)); - lg.addColorStop(0, `rgba(${D.lamp[0]}, ${(isLight ? 0.30 : 0.55) * gain})`); - lg.addColorStop(0.45, `rgba(${D.lamp[0]}, ${(isLight ? 0.10 : 0.18) * gain})`); - lg.addColorStop(1, `rgba(${D.lamp[0]}, 0)`); - if (!isLight) ctx.globalCompositeOperation = 'screen'; - ctx.fillStyle = lg; - ctx.fillRect(x, y, w, h); + ctx.globalAlpha = 0.8; + ctx.translate(gx0 + gw / 2, top); + ctx.scale(gw, bh); + const nx = px => (px - (gx0 + gw / 2)) / gw; + const ny = py => (py - top) / bh; + ctx.beginPath(); + ctx.moveTo(-0.5, ny(gy(20))); + ctx.quadraticCurveTo(0, ny(gy(3)), 0.5, ny(gy(20))); + ctx.lineTo(0.5, ny(gy(72))); + ctx.quadraticCurveTo(0, ny(gy(45)), -0.5, ny(gy(72))); + ctx.closePath(); + const glass = ctx.createRadialGradient(0, 0, 0, 0, 0, 1); + glass.addColorStop(0, 'rgba(255, 255, 255, 0.13)'); + glass.addColorStop(0.5, 'rgba(255, 255, 255, 0.025)'); + glass.addColorStop(1, 'rgba(0, 0, 0, 0.15)'); + ctx.fillStyle = glass; + ctx.fill(); ctx.restore(); + void nx; } - this.drawDialFace(ctx, D, P, { x, y, w, h, scale, isLight, compactLike, gsp: getScalePoint, majLen, gap: sz.gap * scale, fontPx, baseY, pivotX, pivotY }); - - // 2. Profile-specific decorations - this.drawDialDeco(ctx, D, P, { x, y, w, h, scale, t, isLight, isCompact, stereo, pivotX, pivotY, sx, ex, o }); - - // 3. Channel tag / stereo legend ctx.save(); - const tagSize = Math.max(7.5, Math.round(sz.tag * scale * D.font.scale)); - const tagY = y + (stereo ? 4.5 : (isCompact ? 4.5 : 7.5)) * scale; - ctx.font = `700 ${tagSize}px ${fam}`; - ctx.fillStyle = P.muted; - ctx.textBaseline = 'top'; - ctx.letterSpacing = '1px'; - if (stereo) { - ctx.textAlign = 'center'; - ctx.fillText('DUAL VU · STEREO', x + w / 2, tagY); - ctx.textAlign = 'left'; - ctx.fillStyle = '#10b981'; - ctx.beginPath(); - ctx.arc(sx, tagY + 4 * scale, 2 * scale, 0, Math.PI * 2); - ctx.fill(); - ctx.fillStyle = P.muted; - ctx.fillText('L', sx + 5 * scale, tagY); - ctx.textAlign = 'right'; - ctx.fillText('R', ex - 5 * scale, tagY); - ctx.fillStyle = P.red; + rr(L.clip, 2); + ctx.clip(); + + if (D.diffuser) { + // Ellipse cx 310, cy 20, 300×130 with its gradient centred on the top of its bounding box + ctx.save(); + ctx.translate(L.X(310), L.Y(-110)); + ctx.scale(600 * L.kx, 260 * L.ky); + const dg = ctx.createRadialGradient(0, 0, 0, 0, 0, 1); + dg.addColorStop(0, `rgba(${D.diffuser.color}, ${D.diffuser.alpha})`); + dg.addColorStop(0.48, `rgba(${D.diffuser.color}, 0.055)`); + dg.addColorStop(1, `rgba(${D.diffuser.color}, 0)`); + ctx.fillStyle = dg; ctx.beginPath(); - ctx.arc(ex, tagY + 4 * scale, 2 * scale, 0, Math.PI * 2); + ctx.arc(0, 0.5, 0.5, 0, Math.PI * 2); ctx.fill(); - } else if (D.tagAlign === 'left') { - ctx.textAlign = 'left'; - ctx.fillText(D.tag(o.title), sx, tagY); - } else { - ctx.textAlign = 'center'; - ctx.fillText(D.tag(o.title), x + w / 2, tagY); + ctx.restore(); } - ctx.restore(); - // 4. Scale rail - const normZero = VU_NORM_ZERO; - const maxLevel = Math.max(0, ...o.needles.map(n => Math.min(1, n.level || 0))); - const strokeRail = (n0, n1, off, color, lw, steps) => { + const strokeArc = (r, alpha, width) => { ctx.beginPath(); - for (let i = 0; i <= steps; i++) { - const p = getScalePoint(n0 + (n1 - n0) * (i / steps), off); - if (i === 0) ctx.moveTo(p.bx, p.by); - else ctx.lineTo(p.bx, p.by); + const n = 72; + for (let i = 0; i <= n; i++) { + const [px, py] = pt(angleOf(i / n), r); + if (i) ctx.lineTo(px, py); else ctx.moveTo(px, py); } - ctx.strokeStyle = color; - ctx.lineWidth = lw; + ctx.strokeStyle = vuHexA(D.accent, alpha); + ctx.lineWidth = lw(width, 0.5); ctx.stroke(); }; - const railW = (compactLike ? 1.0 : 1.4) * scale; - const redW = (compactLike ? 1.6 : 2.2) * scale; - const peaks = this.peaks || (this.peaks = {}); - const peakInfo = o.needles.map(n => { - const key = (stereo ? 'S' : 'P') + n.key + (compactLike ? 'c' : 'f'); - const pk = peaks[key] || (peaks[key] = { v: 0, t: nowMs, last: nowMs }); - const norm = Math.max(0, Math.min(1, n.level || 0)); - const dt = Math.min(0.1, (nowMs - pk.last) / 1000); - pk.last = nowMs; - if (norm >= pk.v) { pk.v = norm; pk.t = nowMs; } - else if (nowMs - pk.t > 700) pk.v = Math.max(norm, pk.v - dt * 0.5); - return pk.v; - }); - if (D.arc === 'double') { - const off = (D.arcInset || 2.0) * scale; - strokeRail(0, normZero, 0, P.arc, railW, 40); - strokeRail(0, normZero, -off, P.arc, railW * 0.55, 40); - strokeRail(normZero, 1, 0, P.red, redW, 15); - strokeRail(normZero, 1, -off, P.red, railW * 0.55, 15); - } else if (D.arc === 'band') { - const bw = D.arcBand * scale; - const band = (n0, n1, color, alpha, steps) => { - ctx.beginPath(); - for (let i = 0; i <= steps; i++) { - const p = getScalePoint(n0 + (n1 - n0) * (i / steps), 0); - if (i === 0) ctx.moveTo(p.bx, p.by); else ctx.lineTo(p.bx, p.by); - } - for (let i = steps; i >= 0; i--) { - const p = getScalePoint(n0 + (n1 - n0) * (i / steps), -bw); - ctx.lineTo(p.bx, p.by); - } - ctx.closePath(); - ctx.globalAlpha = alpha; - ctx.fillStyle = color; - ctx.fill(); - ctx.globalAlpha = 1; - }; - band(0, normZero, P.arc, isLight ? 0.22 : 0.20, 40); - band(normZero, 1, P.red, 0.5, 15); - strokeRail(0, normZero, 0, P.arc, railW * 0.8, 40); - strokeRail(normZero, 1, 0, P.red, redW * 0.8, 15); - } else if (D.arc === 'segments') { - const N = compactLike ? 24 : 32; - const segW = (compactLike ? 1.9 : 2.6) * scale; - const peakSeg = Math.min(N - 1, Math.floor(Math.max(...peakInfo) * N)); - ctx.lineCap = 'butt'; - for (let i = 0; i < N; i++) { - const c = (i + 0.5) / N; - const lit = c <= maxLevel + 0.5 / N; - const hot = c > normZero; - ctx.save(); - if (lit || i === peakSeg) { - ctx.globalAlpha = 1; - if (!isLight) { ctx.shadowColor = hot ? P.red : P.glow; ctx.shadowBlur = 4 * scale; } - } else { - ctx.globalAlpha = 0.2; - } - const col = (i === peakSeg && maxLevel < c) ? P.needle : (hot ? P.red : P.tip); - strokeRail(i / N + 0.004, (i + 0.66) / N, 0, col, segW, 3); - ctx.restore(); - } - } else if (D.arc === 'none') { - strokeRail(normZero, 1, 0, P.red, redW, 15); + strokeArc(g.radius + 12, 0.62, 0.65); + strokeArc(g.radius - 15, 0.34, 0.6); + } else if (D.arc === 'single') { + strokeArc(g.radius + 10, 0.8, 1.15); + } else { + strokeArc(g.radius + 9, 0.48, 0.8); + strokeArc(g.radius - 15, 0.28, 0.55); } - // 5. Ticks - const ticks = this.getScaleTicks(o.theme, compactLike); - ticks.forEach(tk => { - const p = getScalePoint(tk.norm); - const len = tk.major ? majLen : (tk.type === 'med' ? medLen : minLen); - const col = tk.isRed ? P.red : P.arc; - const lw = (tk.major ? (tk.isRed ? D.tick.lwMajor * 1.15 : D.tick.lwMajor) : D.tick.lwMinor) * scale; - ctx.fillStyle = col; - ctx.strokeStyle = col; - if (D.tick.style === 'dots') { - const r = (tk.major ? 1.5 : 0.85) * scale * (compactLike ? 0.85 : 1); - const d = tk.major ? len : len * 0.6; - ctx.beginPath(); - ctx.arc(p.bx + p.ux * d, p.by + p.uy * d, r, 0, Math.PI * 2); - ctx.fill(); - } else if (D.tick.style === 'wedge') { - const hw = lw * (tk.major ? 0.9 : 0.6); - ctx.beginPath(); - ctx.moveTo(p.bx - p.uy * hw, p.by + p.ux * hw); - ctx.lineTo(p.bx + p.uy * hw, p.by - p.ux * hw); - ctx.lineTo(p.bx + p.ux * len, p.by + p.uy * len); - ctx.closePath(); - ctx.fill(); - if (tk.major) { - ctx.beginPath(); - ctx.arc(p.bx + p.ux * (len + 1.6 * scale), p.by + p.uy * (len + 1.6 * scale), 0.9 * scale, 0, Math.PI * 2); - ctx.fill(); - } + // Graduations + ctx.lineCap = 'round'; + for (const m of D.marks) { + const deg = angleOf(vuNormToScale(m.norm)); + const outer = pt(deg, g.radius + (m.major ? 2 : 0)); + const ref = pt(deg, g.radius - 7); + const u0 = outer[0] - L.pivot[0]; + const u1 = outer[1] - L.pivot[1]; + const ul = Math.hypot(u0, u1) || 1; + const ux = u0 / ul; + const uy = u1 / ul; + const len = Math.max(Math.hypot(outer[0] - ref[0], outer[1] - ref[1]), compact ? (m.major ? 4.5 : 3) * dpr : 0); + const inner = [outer[0] - ux * len, outer[1] - uy * len]; + ctx.strokeStyle = m.hot ? D.hot : (m.major ? D.ink : D.softInk); + ctx.beginPath(); + if (D.tick === 'wedge' && !m.major) { + const wl = Math.max(5 * k, 2.5 * dpr); + ctx.lineWidth = lw(1.15, 0.8); + ctx.moveTo(inner[0], inner[1]); + ctx.lineTo(inner[0] - ux * wl, inner[1] - uy * wl); } else { - ctx.lineWidth = lw; - ctx.beginPath(); - ctx.moveTo(p.bx, p.by); - ctx.lineTo(p.bx + p.ux * len, p.by + p.uy * len); - ctx.stroke(); + ctx.lineWidth = D.tick === 'hair' ? lw(m.major ? 1.35 : 0.65, m.major ? 0.9 : 0.6) : lw(m.major ? 1.7 : 0.9, m.major ? 1 : 0.7); + ctx.moveTo(inner[0], inner[1]); + ctx.lineTo(outer[0], outer[1]); } - }); + ctx.stroke(); + } - // 6. Scale labels - const fontSize = Math.max(stereo ? 8.0 : 8.5, Math.round(sz.font * scale * D.font.scale)); - ctx.font = `${D.font.style === 'italic' ? 'italic ' : ''}${D.font.weight} ${fontSize}px ${fam}`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - const labelGap = sz.gap * scale + (D.tick.style === 'wedge' ? 2 * scale : 0); - ticks.forEach(tk => { - if (!tk.label) return; - const p = getScalePoint(tk.norm); - const lx = p.bx + p.ux * (majLen + labelGap); - const ly = p.by + p.uy * (majLen + labelGap); - ctx.fillStyle = tk.isRed ? P.red : P.ink; - if (D.rotateLabels) { - ctx.save(); - ctx.translate(lx, ly); - ctx.rotate(Math.atan2(p.uy, p.ux) + Math.PI / 2); - ctx.fillText(tk.label, 0, 0); - ctx.restore(); - } else { - ctx.fillText(tk.label, lx, ly); + // Secondary 8 Ω power arc (detail size only) + if (showDetail && D.secondary) { + strokeArc(g.radius - 77, 0.42, 0.7); + ctx.lineWidth = lw(0.8, 0.6); + for (const m of D.secondary) { + const deg = angleOf(vuNormToScale(m.norm)); + const a = pt(deg, g.radius - 82); + const b = pt(deg, g.radius - 72); + ctx.strokeStyle = m.hot ? D.hot : D.softInk; + ctx.beginPath(); + ctx.moveTo(a[0], a[1]); + ctx.lineTo(b[0], b[1]); + ctx.stroke(); } - }); + ctx.textBaseline = 'alphabetic'; + this.printText(ctx, 'WATTS · 8 Ω', L.X(310), L.Y(197), `400 ${Math.max(7 * k, 6 * dpr).toFixed(2)}px ${D.type}`, D.softInk, 1.6 * k); + } - // 7. Centre mark - ctx.save(); - const markSize = Math.max(7.5, Math.round((compactLike ? 9.2 : 13.0) * scale * D.font.scale)); - ctx.font = `${D.markStyle === 'italic' ? 'italic ' : ''}800 ${markSize}px ${fam}`; - ctx.fillStyle = P.muted; + // Numerals ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; - ctx.letterSpacing = '2px'; - ctx.fillText(D.mark, x + w / 2, baseY + sz.mark * scale); - ctx.restore(); - - // 8. Peak-hold markers (designs that opt in) - if (D.peak === 'tick') { - o.needles.forEach((n, i) => { - const norm = Math.max(0, Math.min(1, n.level || 0)); - if (peakInfo[i] <= norm + 0.02) return; - const p = getScalePoint(Math.min(1, peakInfo[i])); - ctx.strokeStyle = P.tip; - ctx.lineWidth = 1.8 * scale; - ctx.beginPath(); - ctx.moveTo(p.bx - p.ux * 1.5 * scale, p.by - p.uy * 1.5 * scale); - ctx.lineTo(p.bx + p.ux * (majLen + 2 * scale), p.by + p.uy * (majLen + 2 * scale)); - ctx.stroke(); - }); + for (const lbl of this.layoutScaleLabels(ctx, D, L, dpr, showDetail)) { + ctx.font = lbl.font; + ctx.fillStyle = lbl.color; + ctx.fillText(lbl.text, lbl.x, lbl.y); } - // 9. Pointers - const baseNY = y + h + 2 * scale; - const drawNeedle = (norm, tipColor, shadowSide) => { - const tp = getScalePoint(norm); - const dx = tp.bx - pivotX; - const dy = tp.by - pivotY; - const dist = Math.sqrt(dx * dx + dy * dy); - const nux = dx / dist; - const nuy = dy / dist; - const npX = -nuy; - const npY = nux; - const reach = majLen + 1.2 * scale * D.needle.reach; - const tx = tp.bx + nux * reach; - const ty = tp.by + nuy * reach; - const tB = (baseNY - pivotY) / nuy; - const bx = pivotX + tB * nux; - const by = baseNY; - const pt = (r) => ({ x: bx + (tx - bx) * r, y: by + (ty - by) * r }); - const poly = (r0, w0, r1, w1, fill) => { - const a = pt(r0); - const b = pt(r1); - ctx.beginPath(); - ctx.moveTo(a.x - npX * w0, a.y - npY * w0); - ctx.lineTo(a.x + npX * w0, a.y + npY * w0); - ctx.lineTo(b.x + npX * w1, b.y + npY * w1); - ctx.lineTo(b.x - npX * w1, b.y - npY * w1); - ctx.closePath(); - ctx.fillStyle = fill; - ctx.fill(); - }; - const bw = (compactLike ? 0.9 : 1.3) * scale * D.needle.width; - const tw = (compactLike ? 0.25 : 0.35) * scale; - const wAt = (r, b0, t0) => b0 + (t0 - b0) * r; - const type = D.needle.type; + // Stop pins just beyond the needle's travel + for (const p of [VU_SCALE_P_MIN - 0.015, VU_SCALE_P_MAX + 0.015]) { + const [px, py] = pt(angleOf(p), g.radius - 11); + const r = lw(2.6, 1.6); + ctx.fillStyle = this.frameGradient(ctx, D, px - r, py - r, r * 2, r * 2); + ctx.strokeStyle = D.accent; + ctx.lineWidth = lw(0.65, 0.5); + ctx.beginPath(); + ctx.arc(px, py, r, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.fillStyle = vuHexA(D.ink, 0.72); + ctx.beginPath(); + ctx.arc(px, py - 0.45 * k, lw(0.7, 0.45), 0, Math.PI * 2); + ctx.fill(); + } - ctx.save(); - if (type === 'lumen' || type === 'neon') { - ctx.shadowColor = P.glow; - ctx.shadowBlur = (type === 'neon' ? 8 : 6) * scale; - } else { - // Pointer floats above the card: a crisp, offset cast shadow (lit from the upper left) - ctx.shadowColor = isLight ? 'rgba(30, 20, 10, 0.38)' : 'rgba(0, 0, 0, 0.60)'; - ctx.shadowBlur = (compactLike ? 1.2 : 1.8) * scale; - ctx.shadowOffsetX = (stereo ? 1.6 + shadowSide * 0.6 : 2.2) * scale; - ctx.shadowOffsetY = (compactLike ? 1.8 : 2.6) * scale; + if (!compact) { + ctx.textBaseline = 'alphabetic'; + this.printText(ctx, 'VU', L.X(310), L.Y(241), `600 ${Math.max(12 * k, 8 * dpr).toFixed(2)}px ${D.type}`, D.ink, 3.2 * k); + if (showDetail) { + this.printText(ctx, D.caption, L.X(310), L.Y(300), `400 ${Math.max(7 * k, 6 * dpr).toFixed(2)}px ${D.type}`, D.softInk, 1.3 * k); } + } - if (type === 'blade') { - const b0 = bw * 1.15; - const tipStart = 0.88; - poly(0, b0, tipStart, wAt(tipStart, b0, tw), P.needle); - poly(tipStart, wAt(tipStart, b0, tw), 1, tw, tipColor); - } else if (type === 'lumen') { - const a = pt(0); - const b = pt(0.94); - ctx.lineCap = 'round'; - ctx.strokeStyle = P.needle; - ctx.lineWidth = (compactLike ? 1.3 : 1.9) * scale; - ctx.beginPath(); - ctx.moveTo(a.x, a.y); - ctx.lineTo(b.x, b.y); - ctx.stroke(); - ctx.shadowBlur = 0; - ctx.strokeStyle = isLight ? 'rgba(255,255,255,0.75)' : 'rgba(14,116,144,0.55)'; - ctx.lineWidth = 0.5 * scale; - ctx.beginPath(); - ctx.moveTo(a.x, a.y); - ctx.lineTo(b.x, b.y); - ctx.stroke(); - ctx.shadowColor = P.glow; - ctx.shadowBlur = 7 * scale; - ctx.fillStyle = tipColor; - ctx.beginPath(); - ctx.arc(b.x, b.y, (compactLike ? 1.9 : 2.8) * scale, 0, Math.PI * 2); - ctx.fill(); - } else if (type === 'neon') { - const a = pt(0); - const b = pt(0.92); - ctx.lineCap = 'butt'; - ctx.strokeStyle = P.needle; - ctx.lineWidth = (compactLike ? 0.9 : 1.2) * scale; - ctx.beginPath(); - ctx.moveTo(a.x, a.y); - ctx.lineTo(b.x, b.y); - ctx.stroke(); - const dz = (compactLike ? 2.4 : 3.4) * scale; - const c = pt(0.96); - ctx.fillStyle = tipColor; - ctx.beginPath(); - ctx.moveTo(c.x + nux * dz, c.y + nuy * dz); - ctx.lineTo(c.x + npX * dz * 0.55, c.y + npY * dz * 0.55); - ctx.lineTo(c.x - nux * dz, c.y - nuy * dz); - ctx.lineTo(c.x - npX * dz * 0.55, c.y - npY * dz * 0.55); - ctx.closePath(); - ctx.fill(); - } else if (type === 'deco') { - const b0 = bw * 1.5; - poly(0, b0, 0.8, b0 * 0.32, P.needle); - poly(0.8, b0 * 0.32, 1, tw * 0.6, P.needle); - const c = pt(0.8); - const L = (compactLike ? 3.0 : 4.4) * scale; - const W = (compactLike ? 1.5 : 2.1) * scale; - ctx.beginPath(); - ctx.moveTo(c.x + nux * L, c.y + nuy * L); - ctx.lineTo(c.x + npX * W, c.y + npY * W); - ctx.lineTo(c.x - nux * L, c.y - nuy * L); - ctx.lineTo(c.x - npX * W, c.y - npY * W); - ctx.closePath(); - ctx.fillStyle = tipColor; - ctx.fill(); - ctx.strokeStyle = P.edge; - ctx.lineWidth = 0.6 * scale; - ctx.stroke(); - } else { - const hw = (compactLike ? 0.5 : 0.7) * scale; - poly(0, hw, 0.78, hw * 0.75, P.needle); - poly(0.78, (compactLike ? 1.0 : 1.4) * scale, 1, tw * 0.7, tipColor); - } - ctx.restore(); - return { bx, by }; - }; - - let lastBase = { bx: pivotX, by: baseNY }; - o.needles.forEach((n) => { - const norm = Math.max(0, Math.min(1.0, n.level || 0)); - const color = stereo ? (n.channel === 'L' ? '#10b981' : P.red) : P.tip; - lastBase = drawNeedle(norm, color, n.channel === 'L' ? -1 : 1); - }); + this.drawNeedle(ctx, D, L, level, dpr); + this.drawHub(ctx, D, L, dpr); + ctx.restore(); - // 10. Pivot cap - const capX = stereo ? pivotX : lastBase.bx; - const capY = baseNY; - const capR = sz.cap * scale; - ctx.save(); - const disc = (r) => { ctx.beginPath(); ctx.arc(capX, capY, r, 0, Math.PI * 2); }; - if (D.cap === 'screw' || D.cap === 'rosette' || D.cap === 'jewel') { - const cg = ctx.createRadialGradient(capX - 0.5, capY - 0.5, 0.5, capX, capY, capR); - if (D.cap === 'rosette') { - cg.addColorStop(0, '#fff3c4'); cg.addColorStop(0.5, '#d4a93c'); cg.addColorStop(1, '#6b4a0c'); - } else if (D.cap === 'jewel') { - cg.addColorStop(0, '#e0f7ff'); cg.addColorStop(0.45, isLight ? '#38bdf8' : '#7dd3fc'); cg.addColorStop(1, '#075985'); - } else if (isLight) { - cg.addColorStop(0, '#ffffff'); cg.addColorStop(0.5, '#cbd5e1'); cg.addColorStop(1, '#64748b'); - } else { - cg.addColorStop(0, '#d9c28a'); cg.addColorStop(0.55, '#8a6b2c'); cg.addColorStop(1, '#3b2a0c'); - } - ctx.fillStyle = cg; - disc(capR); - ctx.fill(); - if (D.cap === 'screw') { - ctx.strokeStyle = 'rgba(30, 20, 5, 0.6)'; - ctx.lineWidth = 0.7 * scale; + // Furniture outside the printed clip + const hiFont = Math.max(9 * k, 7 * dpr); + ctx.textBaseline = 'middle'; + if (compact) { + ctx.font = `600 ${Math.max(9 * k, 7.5 * dpr).toFixed(2)}px ${D.type}`; + ctx.fillStyle = D.ink; + ctx.textAlign = 'left'; + ctx.fillText(channel, L.X(40), L.Y(70)); + } else { + for (const sx of [29, 591]) { + ctx.fillStyle = vuHexA(D.accent, 0.72); ctx.beginPath(); - ctx.moveTo(capX - capR * 0.6, capY); - ctx.lineTo(capX + capR * 0.6, capY); - ctx.stroke(); - } else if (D.cap === 'rosette') { - ctx.strokeStyle = P.edge; - ctx.lineWidth = 0.6 * scale; - disc(capR); - ctx.stroke(); - ctx.fillStyle = P.edge; - disc(capR * 0.3); + ctx.arc(L.X(sx), L.Y(28), lw(1.7, 1), 0, Math.PI * 2); ctx.fill(); } - } else if (D.cap === 'ring') { - ctx.shadowColor = P.glow; - ctx.shadowBlur = isLight ? 0 : 5 * scale; - ctx.strokeStyle = P.needle; - ctx.lineWidth = 1.1 * scale; - disc(capR); - ctx.stroke(); - ctx.fillStyle = P.tip; - disc(capR * 0.35); - ctx.fill(); - } else { - ctx.fillStyle = P.needle; - disc(capR * 0.8); - ctx.fill(); - } - ctx.restore(); - - // 10b. Lacquered movement shroud: hides the pivot like a real meter and carries the badge - if (!compactLike) { - const shTop = y + h * 0.84; - const shEdge = y + h * 0.94; - const warmShroud = D.deco === 'vintage' || D.deco === 'champagne'; - const curve = (first) => { - for (let i = 0; i <= 24; i++) { - const u = i / 24; - const v = 2 * u - 1; - const px = x + w * u; - const py = shTop + (shEdge - shTop) * v * v; - if (i === 0 && first) ctx.moveTo(px, py); else ctx.lineTo(px, py); - } - }; - ctx.save(); - ctx.beginPath(); - ctx.moveTo(x, y + h + 1); - curve(false); - ctx.lineTo(x + w, y + h + 1); - ctx.closePath(); - const shg = ctx.createLinearGradient(x, shTop, x, y + h); - shg.addColorStop(0, warmShroud ? '#3a2a18' : '#2a2f38'); - shg.addColorStop(0.35, warmShroud ? '#1c140b' : '#14171c'); - shg.addColorStop(1, '#050505'); - ctx.shadowColor = 'rgba(0, 0, 0, 0.45)'; - ctx.shadowBlur = 3 * scale; - ctx.shadowOffsetY = -1 * scale; - ctx.fillStyle = shg; - ctx.fill(); - ctx.shadowColor = 'transparent'; - ctx.beginPath(); - curve(true); - ctx.strokeStyle = 'rgba(255, 255, 255, 0.22)'; - ctx.lineWidth = 0.7 * scale; - ctx.stroke(); - ctx.font = `600 ${Math.max(6, Math.round(7.5 * scale))}px ${fam}`; - ctx.fillStyle = warmShroud ? 'rgba(230, 200, 140, 0.75)' : 'rgba(220, 226, 235, 0.65)'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.letterSpacing = '3px'; - ctx.fillText('LX AUDIO', x + w / 2, (shTop + y + h) / 2 + 1 * scale); - ctx.restore(); + this.printText(ctx, channel === 'L' ? 'LEFT' : 'RIGHT', L.X(310), L.Y(47), `600 ${hiFont.toFixed(2)}px ${D.type}`, D.ink, 3.6 * k); } - // 11. Glass reflection - const glass = ctx.createLinearGradient(x, y, x, y + h * 0.45); - glass.addColorStop(0, isLight ? 'rgba(255, 255, 255, 0.25)' : 'rgba(255, 255, 255, 0.08)'); - glass.addColorStop(0.5, isLight ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.02)'); - glass.addColorStop(1, 'rgba(255, 255, 255, 0)'); - ctx.fillStyle = glass; - ctx.fillRect(x, y, w, h * 0.45); - if (D.sheen) { - const sg = ctx.createLinearGradient(x, y, x + w, y + h); - sg.addColorStop(0.28, 'rgba(255, 255, 255, 0)'); - sg.addColorStop(0.42, isLight ? 'rgba(255, 255, 255, 0.22)' : 'rgba(255, 255, 255, 0.10)'); - sg.addColorStop(0.55, 'rgba(255, 255, 255, 0)'); - ctx.fillStyle = sg; - ctx.fillRect(x, y, w, h); - } - // Curved crystal reflection across the upper glass + // Clip / peak lamp + const lampOn = vuNormToScale(level) > 0.94; + const [lx, ly] = compact ? [L.X(580), L.Y(70)] : [L.X(562), L.Y(47)]; ctx.save(); - ctx.beginPath(); - ctx.moveTo(x, y); - ctx.lineTo(x + w, y); - ctx.lineTo(x + w, y + h * 0.22); - for (let i = 1; i <= 24; i++) { - const u = 1 - i / 24; - const v = 2 * u - 1; - ctx.lineTo(x + w * u, y + h * (0.36 - 0.14 * v * v)); + if (lampOn) { + ctx.shadowColor = D.hot; + ctx.shadowBlur = lw(6, 3); } - ctx.closePath(); - const cr = ctx.createLinearGradient(x, y, x, y + h * 0.36); - cr.addColorStop(0, isLight ? 'rgba(255, 255, 255, 0.22)' : 'rgba(255, 255, 255, 0.09)'); - cr.addColorStop(1, 'rgba(255, 255, 255, 0)'); - ctx.fillStyle = cr; + ctx.globalAlpha = lampOn ? 0.95 : 0.22; + ctx.fillStyle = D.hot; + ctx.beginPath(); + ctx.arc(lx, ly, lw(3, 1.6), 0, Math.PI * 2); ctx.fill(); ctx.restore(); - ctx.restore(); // end clip - - // 12. Bezel - if (isCompact || stereo) { - ctx.strokeStyle = isLight ? 'rgba(15, 23, 42, 0.08)' : 'rgba(255, 255, 255, 0.08)'; - ctx.lineWidth = 1; - this.roundRect(ctx, x + 0.5, y + 0.5, w - 1, h - 1, podR); - ctx.stroke(); - } else { - // Machined metal bezel: brushed gradient ring with inner shadow lip and a specular top edge - const warmMetal = D.deco === 'vintage' || D.deco === 'champagne'; - const ringW = 3.2 * scale; - const mg = ctx.createLinearGradient(x, y - ringW, x, y + h + ringW); - if (warmMetal) { - mg.addColorStop(0, '#f4dfa4'); mg.addColorStop(0.25, '#b68a3a'); mg.addColorStop(0.55, '#6e4f17'); mg.addColorStop(0.8, '#c9a557'); mg.addColorStop(1, '#4a3410'); - } else if (D.deco === 'mono') { - mg.addColorStop(0, '#f1f5f9'); mg.addColorStop(0.3, '#94a3b8'); mg.addColorStop(0.6, '#475569'); mg.addColorStop(1, '#1e293b'); - } else { - mg.addColorStop(0, '#e2e8f0'); mg.addColorStop(0.3, '#7c8da3'); mg.addColorStop(0.6, '#3b4a5e'); mg.addColorStop(1, '#141c27'); - } - ctx.strokeStyle = mg; - ctx.lineWidth = ringW; - this.roundRect(ctx, x - ringW / 2, y - ringW / 2, w + ringW, h + ringW, podR + ringW / 2); - ctx.stroke(); - ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)'; - ctx.lineWidth = 0.6 * scale; - this.roundRect(ctx, x - ringW, y - ringW, w + ringW * 2, h + ringW * 2, podR + ringW); - ctx.stroke(); - ctx.strokeStyle = 'rgba(0, 0, 0, 0.75)'; - ctx.lineWidth = 1.0 * scale; - this.roundRect(ctx, x + 0.5, y + 0.5, w - 1, h - 1, podR); - ctx.stroke(); - ctx.strokeStyle = P.rim; - ctx.lineWidth = 0.6 * scale; - this.roundRect(ctx, x - 0.5, y - 0.5, w + 1, h + 1, podR); - ctx.stroke(); - } ctx.restore(); } - // Per-profile dial face layout: scale window, printed zones and panel structure (under scale and pointer) - drawDialFace(ctx, D, P, c) { - const { x, y, w, h, scale, isLight, compactLike, gsp, majLen, gap, fontPx, baseY, pivotX, pivotY } = c; - const outer = majLen + gap + fontPx * 0.75; - const inner = -(compactLike ? 3 : 5) * scale; - const small = Math.max(6, Math.round(7 * scale)); - const dash = (a) => { if (typeof ctx.setLineDash === 'function') ctx.setLineDash(a); }; - const band = (n0, n1, a, b, steps = 36) => { + // Needle is drawn in pixel space from the pivot to the elliptical tip, so its width never deforms. + drawNeedle(ctx, D, L, level, dpr) { + const g = D.geometry; + const k = L.k; + const tip = L.pt(L.angleOf(vuNormToScale(level)), g.radius - 9); + const [cx, cy] = L.pivot; + const len = Math.hypot(tip[0] - cx, tip[1] - cy) || 1; + const ux = (tip[0] - cx) / len; + const uy = (tip[1] - cy) / len; + const nx = -uy; + const ny = ux; + const blade = D.pointer === 'blade'; + const hw = (v, min) => Math.max(v * k, min * dpr); + const section = (from, to, w0, w1) => { + const at = (t, s) => [cx + ux * t + nx * s, cy + uy * t + ny * s]; + const mid = (from + to) / 2; + const wm = (w0 + w1) / 2; + const pts = [at(from, w0), at(mid, wm), at(to, w1), at(to, -w1), at(mid, -wm), at(from, -w0)]; ctx.beginPath(); - for (let i = 0; i <= steps; i++) { - const p = gsp(n0 + (n1 - n0) * (i / steps), a); - if (i === 0) ctx.moveTo(p.bx, p.by); else ctx.lineTo(p.bx, p.by); - } - for (let i = steps; i >= 0; i--) { - const p = gsp(n0 + (n1 - n0) * (i / steps), b); - ctx.lineTo(p.bx, p.by); - } - ctx.closePath(); - }; - const lower = (n0, n1) => { - ctx.beginPath(); - for (let i = 0; i <= 36; i++) { - const p = gsp(n0 + (n1 - n0) * (i / 36), inner); - if (i === 0) ctx.moveTo(p.bx, p.by); else ctx.lineTo(p.bx, p.by); - } - ctx.lineTo(x + w, y + h); - ctx.lineTo(x, y + h); + pts.forEach(([px, py], i) => (i ? ctx.lineTo(px, py) : ctx.moveTo(px, py))); ctx.closePath(); + ctx.fill(); }; + const colors = D.pointerColors || (blade ? ['#41311f', '#171717', '#56442c'] : ['#596064', '#f3eee1', '#71777a']); + const w0 = hw(blade ? 2.15 : 1.15, blade ? 1.1 : 0.6); + const w1 = hw(blade ? 0.45 : 0.32, blade ? 0.35 : 0.3); + const grad = ctx.createLinearGradient(cx - nx * w0, cy - ny * w0, cx + nx * w0, cy + ny * w0); + grad.addColorStop(0, colors[0]); + grad.addColorStop(0.47, colors[1]); + grad.addColorStop(1, colors[2]); + ctx.save(); - if (D.deco === 'vintage') { - // Cream scale window set into a darker lacquered housing, red zone printed as a tint - lower(-0.045, 1.045); - ctx.fillStyle = isLight ? 'rgba(90, 55, 15, 0.12)' : 'rgba(50, 28, 5, 0.28)'; - ctx.fill(); - band(-0.045, 1.045, inner, outer); - const wg = ctx.createLinearGradient(x, y, x, y + h); - wg.addColorStop(0, isLight ? 'rgba(255, 253, 242, 0.80)' : 'rgba(255, 249, 228, 0.62)'); - wg.addColorStop(1, isLight ? 'rgba(255, 244, 214, 0.40)' : 'rgba(255, 232, 165, 0.28)'); - ctx.fillStyle = wg; - ctx.fill(); - band(VU_NORM_ZERO, 1.045, inner, outer, 12); - ctx.fillStyle = 'rgba(200, 30, 58, 0.11)'; - ctx.fill(); - ctx.strokeStyle = P.frame; - ctx.lineWidth = 1.0 * scale; - band(-0.045, 1.045, inner, outer); - ctx.stroke(); - ctx.lineWidth = 0.5 * scale; - band(-0.03, 1.03, inner + 1.6 * scale, outer - 1.6 * scale); - ctx.stroke(); - if (!compactLike) { - ctx.font = `700 ${small}px ${D.font.family}`; - ctx.fillStyle = P.muted; - ctx.textBaseline = 'middle'; - ctx.letterSpacing = '2px'; - ctx.textAlign = 'left'; - ctx.fillText('LX AUDIO', x + 12 * scale, y + h - 9 * scale); - ctx.textAlign = 'right'; - ctx.fillText('PRECISION METER', x + w - 12 * scale, y + h - 9 * scale); - } - } else if (D.deco === 'ocean' || D.deco === 'cyber') { - // Backlit translucent scale window with a fine double rule and printed red zone - band(-0.035, 1.035, inner, outer); - const sg = ctx.createLinearGradient(x, y, x, y + h); - sg.addColorStop(0, isLight ? 'rgba(255, 255, 255, 0.45)' : 'rgba(255, 255, 255, 0.10)'); - sg.addColorStop(1, isLight ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.02)'); - ctx.fillStyle = sg; - ctx.fill(); - ctx.strokeStyle = P.arc; - ctx.globalAlpha = 0.5; - ctx.lineWidth = 0.8 * scale; - ctx.stroke(); - ctx.lineWidth = 0.45 * scale; - band(-0.02, 1.02, inner + 1.6 * scale, outer - 1.6 * scale); - ctx.stroke(); - ctx.globalAlpha = 0.12; - band(VU_NORM_ZERO, 1.035, inner, outer, 12); - ctx.fillStyle = P.red; - ctx.fill(); - ctx.globalAlpha = 1; - } else if (D.deco === 'champagne') { - // Arched cartouche with pearl-studded border above a pinstriped gold pedestal - lower(-0.05, 1.05); - const pg = ctx.createLinearGradient(x, y + h * 0.6, x, y + h); - pg.addColorStop(0, isLight ? 'rgba(150, 105, 20, 0.10)' : 'rgba(110, 70, 10, 0.18)'); - pg.addColorStop(1, isLight ? 'rgba(120, 80, 10, 0.26)' : 'rgba(70, 40, 5, 0.42)'); - ctx.fillStyle = pg; - ctx.fill(); - ctx.save(); - lower(-0.05, 1.05); - ctx.clip(); - ctx.strokeStyle = P.frame; - ctx.globalAlpha = 0.18; - ctx.lineWidth = 0.5 * scale; - const step = Math.max(2.5, 3 * scale); - ctx.beginPath(); - for (let px = x; px <= x + w; px += step) { ctx.moveTo(px, y); ctx.lineTo(px, y + h); } - ctx.stroke(); - ctx.restore(); - band(-0.05, 1.05, inner, outer); - const cg = ctx.createLinearGradient(x, y, x, y + h); - cg.addColorStop(0, isLight ? 'rgba(255, 255, 250, 0.75)' : 'rgba(255, 252, 236, 0.60)'); - cg.addColorStop(1, isLight ? 'rgba(255, 248, 215, 0.35)' : 'rgba(255, 236, 170, 0.25)'); - ctx.fillStyle = cg; - ctx.fill(); - ctx.strokeStyle = P.frame; - ctx.lineWidth = 1.1 * scale; - ctx.stroke(); - ctx.lineWidth = 0.5 * scale; - band(-0.035, 1.035, inner + 1.8 * scale, outer - 1.8 * scale); - ctx.stroke(); - ctx.fillStyle = P.frame; - const pearls = compactLike ? 14 : 22; - for (let i = 0; i <= pearls; i++) { - const p = gsp(-0.035 + 1.07 * (i / pearls), outer); - ctx.beginPath(); - ctx.arc(p.bx, p.by, (compactLike ? 0.6 : 0.85) * scale, 0, Math.PI * 2); - ctx.fill(); - } - if (!compactLike) { - ctx.font = `italic 700 ${small}px ${D.font.family}`; - ctx.fillStyle = P.muted; - ctx.textBaseline = 'middle'; - ctx.letterSpacing = '3px'; - ctx.textAlign = 'left'; - ctx.fillText('LX AUDIO', x + 12 * scale, y + h - 9 * scale); - ctx.textAlign = 'right'; - ctx.fillText('MASTER SERIES', x + w - 12 * scale, y + h - 9 * scale); - } - } else if (D.deco === 'mono') { - // Bauhaus layout: solid overload block, heavy top bar and a baseline rule - band(VU_NORM_ZERO, 1.03, inner, outer, 12); - ctx.fillStyle = P.red; - ctx.globalAlpha = isLight ? 0.10 : 0.16; - ctx.fill(); - ctx.globalAlpha = 1; - ctx.fillStyle = P.ink; - ctx.fillRect(x, y, w, (compactLike ? 1.8 : 3) * scale); - const by = y + h - (compactLike ? 4 : 8) * scale; - ctx.strokeStyle = P.rule; - ctx.lineWidth = 0.6 * scale; - ctx.beginPath(); - ctx.moveTo(x + 8 * scale, by); - ctx.lineTo(x + w - 8 * scale, by); - ctx.stroke(); - if (!compactLike) { - ctx.font = `600 ${small}px ${D.font.family}`; - ctx.fillStyle = P.muted; - ctx.textBaseline = 'bottom'; - ctx.letterSpacing = '2px'; - ctx.textAlign = 'left'; - ctx.fillText('dB', x + 10 * scale, by - 2 * scale); - ctx.textAlign = 'right'; - ctx.fillText('LX / 01', x + w - 10 * scale, by - 2 * scale); - } + ctx.shadowColor = 'rgba(0, 0, 0, 0.28)'; + ctx.shadowBlur = Math.max(2.7 * k, 1.2 * dpr); + ctx.shadowOffsetX = 1.5 * k; + ctx.shadowOffsetY = 2 * k; + ctx.fillStyle = grad; + if (D.pointer === 'counterweight') section(-26 * k, -6 * k, hw(1.25, 0.8), hw(1.8, 1.1)); + section(5 * k, len * (blade ? 0.88 : 1), w0, w1); + if (blade) { + ctx.fillStyle = D.hot; + section(len * 0.88, len, w1, hw(0.16, 0.2)); } ctx.restore(); - } - // Decorative layer unique to each profile (drawn on the face, under scale and pointer) - drawDialDeco(ctx, D, P, c) { - const { x, y, w, h, scale, t, isLight } = c; ctx.save(); - if (D.deco === 'vintage') { - const g = ctx.createRadialGradient(x + w / 2, y + h * 0.35, 4 * scale, x + w / 2, y + h * 0.35, w * 0.5); - g.addColorStop(0, P.glow); - g.addColorStop(1, 'rgba(255, 200, 90, 0)'); - ctx.globalAlpha = isLight ? 0.35 : 0.55; - ctx.fillStyle = g; - ctx.fillRect(x, y, w, h); - ctx.globalAlpha = 1; - const inset = 2.5 * scale; - ctx.strokeStyle = P.frame; - ctx.lineWidth = 0.9 * scale; - this.roundRect(ctx, x + inset, y + inset, w - inset * 2, h - inset * 2, 5 * scale); - ctx.stroke(); - const r = 1.4 * scale; - [[x + 6 * scale, y + 6 * scale], [x + w - 6 * scale, y + 6 * scale], [x + 6 * scale, y + h - 6 * scale], [x + w - 6 * scale, y + h - 6 * scale]].forEach(([px, py]) => { - ctx.fillStyle = '#b08d57'; - ctx.beginPath(); - ctx.arc(px, py, r, 0, Math.PI * 2); - ctx.fill(); - ctx.strokeStyle = 'rgba(40, 25, 5, 0.7)'; - ctx.lineWidth = 0.5 * scale; - ctx.beginPath(); - ctx.moveTo(px - r * 0.7, py - r * 0.7); - ctx.lineTo(px + r * 0.7, py + r * 0.7); - ctx.stroke(); - }); - } else if (D.deco === 'champagne') { - const R = (c.pivotY - y) + h; - const half = Math.atan2(w * 0.6, c.pivotY - y); - const n = 18; - const step = (half * 2) / n; - ctx.fillStyle = P.ray; - for (let i = 0; i < n; i += 2) { - const a0 = -Math.PI / 2 - half + i * step; - const a1 = a0 + step; - ctx.beginPath(); - ctx.moveTo(c.pivotX, c.pivotY); - ctx.lineTo(c.pivotX + R * Math.cos(a0), c.pivotY + R * Math.sin(a0)); - ctx.lineTo(c.pivotX + R * Math.cos(a1), c.pivotY + R * Math.sin(a1)); - ctx.closePath(); - ctx.fill(); - } - ctx.strokeStyle = P.frame; - ctx.lineWidth = 0.9 * scale; - this.roundRect(ctx, x + 2.2 * scale, y + 2.2 * scale, w - 4.4 * scale, h - 4.4 * scale, 5 * scale); - ctx.stroke(); - ctx.lineWidth = 0.5 * scale; - this.roundRect(ctx, x + 4.2 * scale, y + 4.2 * scale, w - 8.4 * scale, h - 8.4 * scale, 4 * scale); - ctx.stroke(); - const d = 2.6 * scale; - ctx.fillStyle = P.frame; - [[x + 8 * scale, y + 8 * scale], [x + w - 8 * scale, y + 8 * scale], [x + 8 * scale, y + h - 8 * scale], [x + w - 8 * scale, y + h - 8 * scale]].forEach(([px, py]) => { - ctx.beginPath(); - ctx.moveTo(px, py - d); - ctx.lineTo(px + d, py); - ctx.lineTo(px, py + d); - ctx.lineTo(px - d, py); - ctx.closePath(); - ctx.fill(); - }); - } else if (D.deco === 'mono') { - const ry = y + (c.isCompact || c.stereo ? 15 : 24) * scale; - ctx.strokeStyle = P.rule; - ctx.lineWidth = 0.6 * scale; - ctx.beginPath(); - ctx.moveTo(c.sx, ry); - ctx.lineTo(c.ex, ry); - ctx.stroke(); - } + ctx.strokeStyle = 'rgba(255, 255, 255, 0.55)'; + ctx.lineWidth = Math.max(0.55 * k, 0.4 * dpr); + ctx.lineCap = 'round'; + const off = 0.35 * k; + const s1 = len * (blade ? 0.82 : 0.75); + ctx.beginPath(); + ctx.moveTo(cx + ux * 13 * k + nx * off, cy + uy * 13 * k + ny * off); + ctx.lineTo(cx + ux * s1 + nx * off, cy + uy * s1 + ny * off); + ctx.stroke(); ctx.restore(); } + drawHub(ctx, D, L, dpr) { + const k = L.k; + const [cx, cy] = L.pivot; + const blade = D.pointer === 'blade'; + const r0 = Math.max((blade ? 11 : 9) * k, 4 * dpr); + const r1 = r0 * (blade ? 6.5 / 11 : 5.5 / 9); + const r2 = r0 * 2 / (blade ? 11 : 9); + ctx.beginPath(); + ctx.arc(cx, cy, r0, 0, Math.PI * 2); + ctx.fillStyle = this.frameGradient(ctx, D, cx - r0, cy - r0, r0 * 2, r0 * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(0, 0, 0, 0.85)'; + ctx.lineWidth = Math.max(1.3 * k, 0.6 * dpr); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(cx, cy, r1, 0, Math.PI * 2); + ctx.fillStyle = blade ? '#b4935c' : '#1d2020'; + ctx.fill(); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.38)'; + ctx.lineWidth = Math.max(0.8 * k, 0.5 * dpr); + ctx.stroke(); + ctx.beginPath(); + ctx.arc(cx, cy, r2, 0, Math.PI * 2); + ctx.fillStyle = blade ? '#312719' : D.accent; + ctx.fill(); + } - drawCenterConsole(ctx, x, y, w, h, scale, isCompact, theme, isLight, levelL, levelR) { + drawCenterConsole(ctx, x, y, w, h, scale, isCompact, D, isLight, levelL, levelR) { if (isCompact) { this.drawCompactBridge(ctx, x, y, w, h, scale, isLight, levelL, levelR); return; @@ -1422,11 +847,11 @@ class LXStudioVUMeterAnimation { this.roundRect(ctx, x + 0.5, y + 0.5, w - 1, h - 1, cornerR); ctx.stroke(); } else { - ctx.fillStyle = theme === 'champagne-gold' ? '#181510' : '#0e1014'; + ctx.fillStyle = '#0e1014'; this.roundRect(ctx, x, y, w, h, cornerR); ctx.fill(); - ctx.strokeStyle = theme === 'champagne-gold' ? 'rgba(218, 185, 120, 0.18)' : 'rgba(255, 255, 255, 0.06)'; + ctx.strokeStyle = vuHexA(D.accent, 0.18); ctx.lineWidth = 0.8 * scale; this.roundRect(ctx, x + 0.5, y + 0.5, w - 1, h - 1, cornerR); ctx.stroke(); @@ -1439,7 +864,7 @@ class LXStudioVUMeterAnimation { ctx.save(); const logoSize = Math.max(7.5, Math.round((isCompact ? 8.8 : 12.0) * scale)); ctx.font = `700 ${logoSize}px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Inter", sans-serif`; - ctx.fillStyle = isLight ? '#059669' : ((theme === 'vintage-amber' || theme === 'champagne-gold') ? '#d4af37' : '#10b981'); + ctx.fillStyle = isLight ? '#059669' : D.accent; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.letterSpacing = '1.5px'; @@ -1650,7 +1075,7 @@ const musicVisualizer = (function () { let standaloneFooterVU = null; let standaloneDetailVU = null; - const audio = document.getElementById('audio-player'); + const audio = window.LXPlayback || document.getElementById('audio-player'); const footerCanvas = document.getElementById('footer-visualizer'); const detailCanvas = document.getElementById('detail-visualizer'); const playerFooter = document.getElementById('player-footer'); @@ -1853,13 +1278,18 @@ const musicVisualizer = (function () { const isAnyVisualizerWanted = !isSuppressed && (isFooterOn || isDetailOn); const hasEffectsAnalyser = window.soundEffects && window.soundEffects.getAnalyser && window.soundEffects.getAnalyser(); + if (window.soundEffects?.isNativePlaybackPreferred?.()) { + triggerStandaloneLoop(); + return; + } + if (!isAnyVisualizerWanted && !hasEffectsAnalyser) { console.log('[Visualizer] Visualizer disabled or suppressed by iOS adaptive setting. Skipping AudioContext creation.'); return; } // AirPlay / 无线投送期间本地不创建音频流 - if (audio.webkitCurrentPlaybackTargetIsWireless) { + if (audio.webkitCurrentPlaybackTargetIsWireless || window.LXPlayback?.isRemote) { return; } @@ -1875,7 +1305,7 @@ const musicVisualizer = (function () { audioSource = window.soundEffects.getSourceNode ? window.soundEffects.getSourceNode() : window._sharedAudioSourceNode; console.log('[Visualizer] Using analyser from SoundEffectsManager (exclusive output)'); } else { - audioSource = window._sharedAudioSourceNode || (window._sharedAudioSourceNode = audioContext.createMediaElementSource(audio)); + audioSource = window._sharedAudioSourceNode || (window._sharedAudioSourceNode = audioContext.createMediaElementSource(document.getElementById('audio-player'))); audioAnalyser = window._sharedAudioAnalyser || (window._sharedAudioAnalyser = audioContext.createAnalyser()); try { audioSource.disconnect(); } catch (_) { } @@ -1964,7 +1394,7 @@ const musicVisualizer = (function () { const isMobile = window.innerWidth < 1025; const style = s.footerVisualizerStyle || 'bars'; const isVumeter = style === 'vumeter'; - const shouldShow = !!s.showFooterVisualizer && !isVisualizerSuppressedByIOS(); + const shouldShow = !!s.showFooterVisualizer && !isVisualizerSuppressedByIOS() && !(isVuMobileViewport() && s.footerVisualizerStyle === 'vumeter'); playerFooter.classList.toggle('has-footer-visualizer', shouldShow); container.style.display = shouldShow ? 'flex' : 'none'; @@ -2028,8 +1458,8 @@ const musicVisualizer = (function () { const visualizerContainer = document.getElementById('visualizer-container'); const progressRow = document.getElementById('player-progress-row'); - const shouldShowFooter = !!s.showFooterVisualizer && !isVisualizerSuppressedByIOS(); - const shouldShowDetail = !!s.showDetailVisualizer && !isVisualizerSuppressedByIOS(); + const shouldShowFooter = !!s.showFooterVisualizer && !isVisualizerSuppressedByIOS() && !(isVuMobileViewport() && s.footerVisualizerStyle === 'vumeter'); + const shouldShowDetail = !!s.showDetailVisualizer && !isVisualizerSuppressedByIOS() && !(isVuMobileViewport() && s.detailVisualizerStyle === 'vumeter'); if (!isInitialized) { if (!shouldShowFooter && !shouldShowDetail) { @@ -2274,7 +1704,21 @@ const musicVisualizer = (function () { if (vContainer) resizeObserver.observe(vContainer); } - return { init, applySettings, syncSize, clear }; + function releaseAudio() { + waveFooter?.dispose(); + waveDetail?.dispose(); + if (standaloneRafId) cancelAnimationFrame(standaloneRafId); + standaloneRafId = null; + for (const node of [audioSource, audioAnalyser, splitterNode, analyserL, analyserR]) { + try { node?.disconnect(); } catch (_) { } + } + audioContext = audioSource = audioAnalyser = null; + splitterNode = analyserL = analyserR = null; + waveFooter = waveDetail = null; + isInitialized = false; + } + window.addEventListener('lx-audio-replaced', () => { init(); applySettings(); }); + return { init, applySettings, syncSize, clear, releaseAudio }; })(); window.musicVisualizer = musicVisualizer; diff --git a/public/music/js/ui/batch_pagination.js b/public/music/js/ui/batch_pagination.js index 53af87e1..baebe8b7 100644 --- a/public/music/js/ui/batch_pagination.js +++ b/public/music/js/ui/batch_pagination.js @@ -39,9 +39,12 @@ function refreshBatchUI() { // Check if song list detail is open const slDetail = document.getElementById('songlist-detail-view'); const artistHeader = document.getElementById('artist-detail-header'); - if (slDetail && !slDetail.classList.contains('hidden')) { + const leaderboard = document.getElementById('view-leaderboard'); + if (leaderboard && !leaderboard.classList.contains('hidden')) { + if (window.LeaderboardManager) window.LeaderboardManager.renderSongs(); + } else if (slDetail && !slDetail.classList.contains('hidden')) { if (window.SongListManager) window.SongListManager.renderDetail(); - } else if (artistHeader) { + } else if (artistHeader && !artistHeader.classList.contains('hidden')) { // Artist Detail Mode if (window.currentArtistSongsCache) { renderArtistSongsUI(window.currentArtistSongsCache); @@ -108,9 +111,6 @@ function clearSelection() { // 重新渲染UI refreshBatchUI(); - if (window.LeaderboardManager && document.getElementById('view-leaderboard') && !document.getElementById('view-leaderboard').classList.contains('hidden')) { - window.LeaderboardManager.renderSongs(); - } updateBatchToolbar(); } @@ -336,13 +336,14 @@ function scrollToSearchResultsTop() { } function updatePaginationInfo(start, end, total, current, totalPages) { + const pagedNetwork = typeof isPagedNetworkResult === 'function' && isPagedNetworkResult(window.viewingPlaylist); const infoEl = document.getElementById('pagination-info'); if (infoEl) { if (total === 0) { infoEl.textContent = '无结果'; } else { // 显示 第 X / 共 Y 页 (Z 条) - infoEl.textContent = `第 ${current || 1} / ${totalPages || 1} 页 (${total} 条)`; + infoEl.textContent = pagedNetwork ? `第 ${current || 1} / 已加载 ${totalPages || 1} 页 (${total} 首)` : `第 ${current || 1} / ${totalPages || 1} 页 (${total} 条)`; } } @@ -367,9 +368,12 @@ function updatePaginationInfo(start, end, total, current, totalPages) { if (firstBtn) firstBtn.disabled = pageNum <= 1; if (prevBtn) prevBtn.disabled = pageNum <= 1; // 歌手/专辑搜索还能向后端追加下一页,下一页按钮要一直可用;歌曲搜索已一次取回,末页就禁用 - const canAppendFromServer = isNetwork && window.serverPagedSearch !== false; - if (nextBtn) nextBtn.disabled = pageNum >= pageCount && !canAppendFromServer; + const canLoadSongs = pagedNetwork && window.networkSongSearch.state.providers.some(p => !p.done); + const busy = pagedNetwork && (window.networkSongSearch.state.loading || window.networkSongSearch.navigating); + const canAppendFromServer = isNetwork && (window.serverPagedSearch !== false || canLoadSongs); + if (nextBtn) nextBtn.disabled = busy || (pageNum >= pageCount && !canAppendFromServer); if (lastBtn) lastBtn.disabled = pageNum >= pageCount; + if (lastBtn) lastBtn.title = pagedNetwork ? '已加载结果的末页' : '末页'; } function goToResultPage(page) { @@ -390,10 +394,12 @@ function lastPage() { const totalItems = window.viewingPlaylist ? window.viewingPlaylist.length : 0; const itemsPerPage = settings.itemsPerPage === 'all' ? totalItems : parseInt(settings.itemsPerPage); const totalPages = Math.max(1, Math.ceil((totalItems || 1) / (itemsPerPage || 1))); + if (typeof isPagedNetworkResult === 'function' && isPagedNetworkResult(window.viewingPlaylist)) return window.goToNetworkResultPage(totalPages); if (currentPage !== totalPages) goToResultPage(totalPages); } async function nextPage() { + if (typeof isPagedNetworkResult === 'function' && isPagedNetworkResult(window.viewingPlaylist)) return window.goToNetworkResultPage(currentPage + 1); const totalItems = window.viewingPlaylist ? window.viewingPlaylist.length : 0; const itemsPerPage = settings.itemsPerPage === 'all' ? totalItems : parseInt(settings.itemsPerPage); const totalPages = Math.ceil((totalItems || 1) / (itemsPerPage || 1)); @@ -428,9 +434,7 @@ async function nextPage() { function prevPage() { if (currentPage > 1) { - currentPage--; - renderResults(viewingPlaylist); - scrollToSearchResultsTop(); + goToResultPage(currentPage - 1); } } @@ -443,6 +447,11 @@ function jumpToPage() { const itemsPerPage = settings.itemsPerPage === 'all' ? totalItems : parseInt(settings.itemsPerPage); const totalPages = Math.ceil((totalItems || 1) / (itemsPerPage || 1)); + if (typeof isPagedNetworkResult === 'function' && isPagedNetworkResult(window.viewingPlaylist)) { + const canLoad = window.networkSongSearch.state.providers.some(p => !p.done); + return window.goToNetworkResultPage(Math.min(Math.max(1, page || 1), Math.max(1, totalPages + (canLoad ? 1 : 0)))); + } + if (isNaN(page) || page < 1) page = 1; if (page > totalPages) page = totalPages; diff --git a/public/music/js/ui/common_ui.js b/public/music/js/ui/common_ui.js index 59f113b6..4ea96c26 100644 --- a/public/music/js/ui/common_ui.js +++ b/public/music/js/ui/common_ui.js @@ -212,12 +212,22 @@ function sortSearchResults(list, keyword, isSong = true) { // 1. 去重 (歌曲基于 id / songmid / hash;歌单基于 id / playListId) const seen = new Set(); const uniqueList = list.filter(item => { - const key = item.id || item.songmid || item.hash || item.playListId || `${item.name}_${item.singer || item.author || ''}`; + const key = `${item.source || ''}:${item.id || item.songmid || item.hash || item.playListId || `${item.name}_${item.singer || item.author || ''}`}`; if (seen.has(key)) return false; seen.add(key); return true; }); + if (isSong && window.PagedMusicSearch) { + const groups = new Map(); + uniqueList.forEach(item => { + const source = item.source || ''; + if (!groups.has(source)) groups.set(source, []); + groups.get(source).push(item); + }); + return window.PagedMusicSearch.rankBatch([...groups.values()], keyword); + } + // 2. 计算匹配相似度并排序 const kw = keyword.trim().toLowerCase(); return uniqueList.map(item => { diff --git a/public/music/js/ui/theme_manager.js b/public/music/js/ui/theme_manager.js index 2a7b1d2f..61fc4567 100644 --- a/public/music/js/ui/theme_manager.js +++ b/public/music/js/ui/theme_manager.js @@ -102,6 +102,7 @@ function updateAppearanceUI(activeMode) { } function switchSettingsTab(tabName) { + if (tabName === 'logic') window.RatingManager?.loadPreference(); const panels = ['system', 'display', 'logic', 'logs']; // Deactivate all first diff --git a/public/music/js/ui/ui_accessibility.js b/public/music/js/ui/ui_accessibility.js index 345629de..b8c70022 100644 --- a/public/music/js/ui/ui_accessibility.js +++ b/public/music/js/ui/ui_accessibility.js @@ -248,9 +248,13 @@ } }); window.addEventListener('popstate', event => { + if (event.playerDetailHandled) return; + if (event.searchDetailHandled) return; if (!event.state?.lxMain || event.state.page) return; const state = event.state.lxMain; setTimeout(() => { + if (event.playerDetailHandled) return; + if (event.searchDetailHandled) return; restoring = true; try { current = state; diff --git a/public/music/js/vendor/wave.js b/public/music/js/vendor/wave.js index 15a8f1e5..b0937d2b 100644 --- a/public/music/js/vendor/wave.js +++ b/public/music/js/vendor/wave.js @@ -1 +1 @@ -!function(t,o){if("object"==typeof exports&&"object"==typeof module)module.exports=o();else if("function"==typeof define&&define.amd)define([],o);else{var i=o();for(var n in i)("object"==typeof exports?exports:t)[n]=i[n]}}(this,(()=>(()=>{"use strict";var t={935:function(t,o,i){var n=this&&this.__assign||function(){return n=Object.assign||function(t){for(var o,i=1,n=arguments.length;i{Object.defineProperty(o,"__esModule",{value:!0}),o.AudioData=void 0;var i=function(){function t(t){this.data=t}return t.prototype.setFrequencyBand=function(t){var o=Math.floor(.0625*this.data.length),i=Math.floor(.0625*this.data.length),n=Math.floor(.375*this.data.length),s={base:this.data.slice(0,o),lows:this.data.slice(o+1,o+i),mids:this.data.slice(o+i+1,o+i+n),highs:this.data.slice(o+i+n+1)};this.data=s[t]},t.prototype.scaleData=function(t){t<255&&(this.data=this.data.map((function(o){var i=Math.round(o/255*100)/100;return t*i})))},t}();o.AudioData=i},426:function(t,o){var i=this&&this.__awaiter||function(t,o,i,n){return new(i||(i=Promise))((function(s,a){function e(t){try{h(n.next(t))}catch(t){a(t)}}function r(t){try{h(n.throw(t))}catch(t){a(t)}}function h(t){var o;t.done?s(t.value):(o=t.value,o instanceof i?o:new i((function(t){t(o)}))).then(e,r)}h((n=n.apply(t,o||[])).next())}))},n=this&&this.__generator||function(t,o){var i,n,s,a,e={label:0,sent:function(){if(1&s[0])throw s[1];return s[1]},trys:[],ops:[]};return a={next:r(0),throw:r(1),return:r(2)},"function"==typeof Symbol&&(a[Symbol.iterator]=function(){return this}),a;function r(a){return function(r){return function(a){if(i)throw new TypeError("Generator is already executing.");for(;e;)try{if(i=1,n&&(s=2&a[0]?n.return:a[0]?n.throw||((s=n.return)&&s.call(n),0):n.next)&&!(s=s.call(n,a[1])).done)return s;switch(n=0,s&&(a=[2&a[0],s.value]),a[0]){case 0:case 1:s=a;break;case 4:return e.label++,{value:a[1],done:!1};case 5:e.label++,n=a[1],a=[0];continue;case 7:a=e.ops.pop(),e.trys.pop();continue;default:if(!((s=(s=e.trys).length>0&&s[s.length-1])||6!==a[0]&&2!==a[0])){e=0;continue}if(3===a[0]&&(!s||a[1]>s[0]&&a[1]{var t=n;Object.defineProperty(t,"__esModule",{value:!0}),t.Wave=void 0;var o=i(935),s=i(519),a=i(938),e=i(540),r=i(522),h=i(658),u=i(817),c=i(123),l=i(270),p=i(857),d=function(){function t(t,i,n){void 0===n&&(n=!1);var d=this;this.animations={Arcs:o.Arcs,Circles:s.Circles,Cubes:a.Cubes,Flower:e.Flower,Glob:r.Glob,Lines:h.Lines,Shine:u.Shine,Square:c.Square,Turntable:l.Turntable,Wave:p.Wave},this._activeAnimations=[],this._canvasElement=i,this._canvasContext=this._canvasElement.getContext("2d"),this._muteAudio=n,this._interacted=!1,t instanceof HTMLAudioElement?(this._audioElement=t,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)?["touchstart","touchmove","touchend","mouseup","click"].forEach((function(t){document.body.addEventListener(t,(function(){return d.connectAnalyser()}),{once:!0})})):this._audioElement.addEventListener("play",(function(){return d.connectAnalyser()}),{once:!0})):t instanceof AnalyserNode?(this._audioAnalyser=t,this._audioContext=null,this._audioSource=null,this._play()):t&&(this._audioContext=t.context,this._audioSource=t.source,this._audioAnalyser=this._audioContext.createAnalyser(),this._play())}return t.prototype.connectAnalyser=function(){this._interacted||(this._interacted=!0,this._audioContext=new AudioContext,this._audioSource=this._audioContext.createMediaElementSource(this._audioElement),this._audioAnalyser=this._audioContext.createAnalyser(),this._play())},t.prototype._play=function(){var t=this;this._audioSource&&(this._audioSource.connect(this._audioAnalyser),this._muteAudio||this._audioSource.connect(this._audioContext.destination)),this._audioAnalyser.smoothingTimeConstant=.85,this._audioAnalyser.fftSize=1024;var o=new Uint8Array(this._audioAnalyser.frequencyBinCount),i=function(){t._audioAnalyser.getByteFrequencyData(o),t._canvasContext.clearRect(0,0,t._canvasContext.canvas.width,t._canvasContext.canvas.height),t._activeAnimations.forEach((function(i){i.draw(o,t._canvasContext)})),window.requestAnimationFrame(i)};i()},t.prototype.addAnimation=function(t){this._activeAnimations.push(t)},t.prototype.clearAnimations=function(){this._activeAnimations=[]},t}();t.Wave=d})(),n})())); \ No newline at end of file +!function(t,o){if("object"==typeof exports&&"object"==typeof module)module.exports=o();else if("function"==typeof define&&define.amd)define([],o);else{var i=o();for(var n in i)("object"==typeof exports?exports:t)[n]=i[n]}}(this,(()=>(()=>{"use strict";var t={935:function(t,o,i){var n=this&&this.__assign||function(){return n=Object.assign||function(t){for(var o,i=1,n=arguments.length;i{Object.defineProperty(o,"__esModule",{value:!0}),o.AudioData=void 0;var i=function(){function t(t){this.data=t}return t.prototype.setFrequencyBand=function(t){var o=Math.floor(.0625*this.data.length),i=Math.floor(.0625*this.data.length),n=Math.floor(.375*this.data.length),s={base:this.data.slice(0,o),lows:this.data.slice(o+1,o+i),mids:this.data.slice(o+i+1,o+i+n),highs:this.data.slice(o+i+n+1)};this.data=s[t]},t.prototype.scaleData=function(t){t<255&&(this.data=this.data.map((function(o){var i=Math.round(o/255*100)/100;return t*i})))},t}();o.AudioData=i},426:function(t,o){var i=this&&this.__awaiter||function(t,o,i,n){return new(i||(i=Promise))((function(s,a){function e(t){try{h(n.next(t))}catch(t){a(t)}}function r(t){try{h(n.throw(t))}catch(t){a(t)}}function h(t){var o;t.done?s(t.value):(o=t.value,o instanceof i?o:new i((function(t){t(o)}))).then(e,r)}h((n=n.apply(t,o||[])).next())}))},n=this&&this.__generator||function(t,o){var i,n,s,a,e={label:0,sent:function(){if(1&s[0])throw s[1];return s[1]},trys:[],ops:[]};return a={next:r(0),throw:r(1),return:r(2)},"function"==typeof Symbol&&(a[Symbol.iterator]=function(){return this}),a;function r(a){return function(r){return function(a){if(i)throw new TypeError("Generator is already executing.");for(;e;)try{if(i=1,n&&(s=2&a[0]?n.return:a[0]?n.throw||((s=n.return)&&s.call(n),0):n.next)&&!(s=s.call(n,a[1])).done)return s;switch(n=0,s&&(a=[2&a[0],s.value]),a[0]){case 0:case 1:s=a;break;case 4:return e.label++,{value:a[1],done:!1};case 5:e.label++,n=a[1],a=[0];continue;case 7:a=e.ops.pop(),e.trys.pop();continue;default:if(!((s=(s=e.trys).length>0&&s[s.length-1])||6!==a[0]&&2!==a[0])){e=0;continue}if(3===a[0]&&(!s||a[1]>s[0]&&a[1]{var t=n;Object.defineProperty(t,"__esModule",{value:!0}),t.Wave=void 0;var o=i(935),s=i(519),a=i(938),e=i(540),r=i(522),h=i(658),u=i(817),c=i(123),l=i(270),p=i(857),d=function(){function t(t,i,n){void 0===n&&(n=!1);var d=this;this.animations={Arcs:o.Arcs,Circles:s.Circles,Cubes:a.Cubes,Flower:e.Flower,Glob:r.Glob,Lines:h.Lines,Shine:u.Shine,Square:c.Square,Turntable:l.Turntable,Wave:p.Wave},this._activeAnimations=[],this._canvasElement=i,this._canvasContext=this._canvasElement.getContext("2d"),this._muteAudio=n,this._interacted=!1,t instanceof HTMLAudioElement?(this._audioElement=t,/^((?!chrome|android).)*safari/i.test(navigator.userAgent)?["touchstart","touchmove","touchend","mouseup","click"].forEach((function(t){document.body.addEventListener(t,(function(){return d.connectAnalyser()}),{once:!0})})):this._audioElement.addEventListener("play",(function(){return d.connectAnalyser()}),{once:!0})):t instanceof AnalyserNode?(this._audioAnalyser=t,this._audioContext=null,this._audioSource=null,this._play()):t&&(this._audioContext=t.context,this._audioSource=t.source,this._audioAnalyser=this._audioContext.createAnalyser(),this._play())}return t.prototype.connectAnalyser=function(){this._interacted||(this._interacted=!0,this._audioContext=new AudioContext,this._audioSource=this._audioContext.createMediaElementSource(this._audioElement),this._audioAnalyser=this._audioContext.createAnalyser(),this._play())},t.prototype._play=function(){var t=this;this._audioSource&&(this._audioSource.connect(this._audioAnalyser),this._muteAudio||this._audioSource.connect(this._audioContext.destination)),this._audioAnalyser.smoothingTimeConstant=.85,this._audioAnalyser.fftSize=1024;var o=new Uint8Array(this._audioAnalyser.frequencyBinCount),i=function(){if(t._disposed)return;t._audioAnalyser.getByteFrequencyData(o),t._canvasContext.clearRect(0,0,t._canvasContext.canvas.width,t._canvasContext.canvas.height),t._activeAnimations.forEach((function(i){i.draw(o,t._canvasContext)})),t._rafId=window.requestAnimationFrame(i)};i()},t.prototype.dispose=function(){this._disposed=!0,window.cancelAnimationFrame(this._rafId),this._activeAnimations=[]},t.prototype.addAnimation=function(t){this._activeAnimations.push(t)},t.prototype.clearAnimations=function(){this._activeAnimations=[]},t}();t.Wave=d})(),n})())); \ No newline at end of file diff --git a/public/music/sw.js b/public/music/sw.js index f4c1bf06..086404f6 100644 --- a/public/music/sw.js +++ b/public/music/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'lx-music-web-v58'; +const CACHE_NAME = 'lx-music-web-v65'; // Refresh cached assets after organizing JavaScript modules by feature. const ASSETS_TO_CACHE = [ './', @@ -10,6 +10,8 @@ const ASSETS_TO_CACHE = [ './css/utilities.css', './css/app.css', './js/library/search_requests.js', + './js/library/paged_music_search.js', + './js/library/search_suggestions.js', './js/ui/ui_accessibility.js', './assets/fontawesome/css/all.min.css', // 核心 JS diff --git a/public/style.css b/public/style.css index 9189b202..f8a23260 100644 --- a/public/style.css +++ b/public/style.css @@ -1,21 +1,5 @@ -:root { - --bg-primary: #0a0e27; - --bg-secondary: #121729; - --bg-tertiary: #1a1f3a; - --text-primary: #e2e8f0; - --text-secondary: #94a3b8; - --text-muted: #64748b; - --accent-primary: #3b82f6; - --accent-secondary: #8b5cf6; - --accent-success: #10b981; - --accent-warning: #f59e0b; - --accent-error: #ef4444; - --glass-bg: rgba(26, 31, 58, 0.7); - --glass-border: rgba(255, 255, 255, 0.1); - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); - --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.3); - --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4); - --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.5); +#mobile-menu-btn.mobile-only { + display: none; } * { @@ -26,33 +10,33 @@ body { font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); - color: var(--text-primary); + background: var(--bg); + color: var(--text); min-height: 100vh; overflow-x: hidden; } .hidden { + /* Visibility utility must override component display and inline layout. */ display: none !important; } .glass { - background: var(--glass-bg); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border: 1px solid var(--glass-border); - border-radius: 16px; - box-shadow: var(--shadow-lg); + background: var(--surface); + backdrop-filter: none; + -webkit-backdrop-filter: none; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); } -/* =============== 现代化后台登录界面 =============== */ .overlay { position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; - background: #090d1a; + background: var(--bg); display: flex; justify-content: center; align-items: center; @@ -65,6 +49,26 @@ body { user-select: text; } +/* =============== 现代化后台登录界面 =============== */ +#login-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: var(--login-scene-background); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + overflow: hidden; + user-select: none; +} + +#login-overlay input { + user-select: text; +} + /* 动态氛围背景 */ .admin-ambient-canvas { position: fixed; @@ -78,15 +82,15 @@ body { position: absolute; border-radius: 50%; filter: blur(85px); - opacity: 0.55; - mix-blend-mode: screen; + opacity: var(--login-ambient-opacity); + mix-blend-mode: var(--login-ambient-blend); animation: floatAuroraAdmin 18s ease-in-out infinite alternate; } .admin-aurora-1 { width: 540px; height: 540px; - background: radial-gradient(circle, #2563eb 0%, rgba(79, 70, 229, 0.45) 50%, transparent 70%); + background: var(--login-aurora-1-background); top: -12%; left: -8%; animation-duration: 20s; @@ -95,7 +99,7 @@ body { .admin-aurora-2 { width: 600px; height: 600px; - background: radial-gradient(circle, #7c3aed 0%, rgba(147, 51, 234, 0.4) 50%, transparent 70%); + background: var(--login-aurora-2-background); bottom: -18%; right: -10%; animation-duration: 24s; @@ -105,7 +109,7 @@ body { .admin-aurora-3 { width: 440px; height: 440px; - background: radial-gradient(circle, #06b6d4 0%, rgba(14, 165, 233, 0.35) 50%, transparent 70%); + background: var(--login-aurora-3-background); top: 42%; left: 36%; animation-duration: 16s; @@ -128,12 +132,11 @@ body { .admin-bg-grid-mask { position: fixed; inset: 0; - background-image: - linear-gradient(to right, rgba(255, 255, 255, 0.035) 1px, transparent 1px), - linear-gradient(to bottom, rgba(255, 255, 255, 0.035) 1px, transparent 1px); + background-image: + var(--login-grid-background-image); background-size: 40px 40px; - mask-image: radial-gradient(ellipse 80% 80% at 50% 50%, #000 60%, transparent 100%); - -webkit-mask-image: radial-gradient(ellipse 80% 80% at 50% 50%, #000 60%, transparent 100%); + mask-image: var(--login-grid-mask); + -webkit-mask-image: var(--login-grid-mask); pointer-events: none; z-index: 1; } @@ -153,8 +156,8 @@ body { top: auto; opacity: 0; pointer-events: none; - color: rgba(255, 255, 255, 0.12); - text-shadow: 0 0 12px rgba(99, 102, 241, 0.35); + color: var(--login-note-color); + text-shadow: var(--login-note-text-shadow); will-change: transform, opacity; animation: adminNoteAscend 13s cubic-bezier(0.4, 0, 0.2, 1) infinite; } @@ -198,7 +201,7 @@ body { width: 460px; height: 460px; border-radius: 50%; - background: radial-gradient(circle, rgba(99, 102, 241, 0.13) 0%, rgba(6, 182, 212, 0.04) 45%, transparent 70%); + background: var(--login-cursor-background); pointer-events: none; transform: translate(-50%, -50%); transition: opacity 0.3s ease; @@ -209,15 +212,12 @@ body { .admin-login-card { position: relative; z-index: 10; - background: rgba(15, 22, 38, 0.8); + background: var(--login-card-background); backdrop-filter: blur(28px) saturate(190%); -webkit-backdrop-filter: blur(28px) saturate(190%); - border: 1px solid rgba(255, 255, 255, 0.12); - box-shadow: - 0 30px 60px -12px rgba(0, 0, 0, 0.75), - 0 18px 36px -18px rgba(0, 0, 0, 0.85), - inset 0 1px 0 rgba(255, 255, 255, 0.18), - inset 0 0 24px rgba(99, 102, 241, 0.06); + border: var(--login-card-border); + box-shadow: + var(--login-card-box-shadow); border-radius: 28px; padding: 2.75rem 2.25rem; width: 430px; @@ -235,8 +235,8 @@ body { display: flex; align-items: center; justify-content: center; - background: linear-gradient(135deg, #3b82f6 0%, #6366f1 50%, #8b5cf6 100%); - box-shadow: 0 12px 28px -4px rgba(99, 102, 241, 0.45); + background: var(--login-logo-background); + box-shadow: var(--login-logo-box-shadow); transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); } @@ -249,7 +249,7 @@ body { position: absolute; inset: -4px; border-radius: 26px; - background: linear-gradient(135deg, #3b82f6, #8b5cf6); + background: var(--login-logo-halo-background); filter: blur(14px); opacity: 0.6; z-index: -1; @@ -265,7 +265,7 @@ body { font-size: 1.65rem; font-weight: 800; letter-spacing: -0.025em; - background: linear-gradient(135deg, #fff 0%, #e2e8f0 50%, #93c5fd 100%); + background: var(--login-title-background); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; @@ -280,11 +280,11 @@ body { .admin-input-group .admin-input { width: 100%; - background: rgba(11, 16, 30, 0.75); - border: 1.5px solid rgba(255, 255, 255, 0.09); + background: var(--login-field-background); + border: var(--login-field-border); border-radius: 14px; padding: 13px 44px; - color: #fff; + color: var(--login-field-color); font-size: 14.5px; outline: none; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); @@ -292,18 +292,18 @@ body { } .admin-input-group .admin-input::placeholder { - color: rgba(156, 163, 175, 0.6); + color: var(--login-field-placeholder-color); } .admin-input-group .admin-input:hover { - border-color: rgba(255, 255, 255, 0.2); - background: rgba(16, 23, 42, 0.85); + border-color: var(--login-field-hover-border-color); + background: var(--login-field-hover-background); } .admin-input-group .admin-input:focus { - border-color: #6366f1; - background: rgba(18, 26, 48, 0.95); - box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.2), 0 4px 20px rgba(99, 102, 241, 0.18); + border-color: var(--login-field-focus-border-color); + background: var(--login-field-focus-background); + box-shadow: var(--login-field-focus-box-shadow); } .admin-input-group .input-icon-left { @@ -311,7 +311,7 @@ body { left: 16px; top: 50%; transform: translateY(-50%); - color: rgba(156, 163, 175, 0.65); + color: var(--login-field-icon-color); pointer-events: none; transition: color 0.25s ease, transform 0.25s ease; font-size: 14px; @@ -319,7 +319,7 @@ body { .admin-input-group .admin-input:focus + .input-icon-left, .admin-input-group:focus-within .input-icon-left { - color: #818cf8; + color: var(--login-field-icon-focus-color); transform: translateY(-50%) scale(1.1); } @@ -328,7 +328,7 @@ body { right: 12px; top: 50%; transform: translateY(-50%); - color: rgba(156, 163, 175, 0.65); + color: var(--login-field-eye-color); cursor: pointer; padding: 6px; background: transparent; @@ -341,22 +341,22 @@ body { } .admin-input-group .input-eye-btn:hover { - color: #818cf8; + color: var(--login-field-eye-hover-color); transform: translateY(-50%) scale(1.15); } .admin-btn-submit { width: 100%; padding: 13.5px 20px; - background: linear-gradient(135deg, #3b82f6 0%, #6366f1 50%, #8b5cf6 100%); + background: var(--login-submit-background); background-size: 200% 200%; - color: white; + color: var(--login-submit-color); border: none; border-radius: 14px; font-size: 14.5px; font-weight: 700; cursor: pointer; - box-shadow: 0 8px 24px -4px rgba(99, 102, 241, 0.45); + box-shadow: var(--login-submit-box-shadow); transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); display: flex; align-items: center; @@ -368,7 +368,7 @@ body { .admin-btn-submit:hover:not(:disabled) { background-position: 100% 0%; - box-shadow: 0 12px 30px -4px rgba(99, 102, 241, 0.65), 0 4px 12px rgba(59, 130, 246, 0.4); + box-shadow: var(--login-submit-hover-box-shadow); transform: translateY(-1.5px) scale(1.01); } @@ -383,9 +383,9 @@ body { } .admin-login-error { - background: rgba(136, 19, 55, 0.45); - border: 1px solid rgba(244, 63, 94, 0.35); - color: #fda4af; + background: var(--login-error-background); + border: var(--login-error-border); + color: var(--login-error-color); font-size: 12.5px; padding: 10px 14px; border-radius: 12px; @@ -394,7 +394,7 @@ body { align-items: center; justify-content: center; gap: 8px; - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25); + box-shadow: var(--login-error-box-shadow); animation: fadeInSlide 0.25s ease forwards; } @@ -421,13 +421,42 @@ body { } } +.login-logo-mark { + color: var(--login-logo-color); +} + +#access-password:-webkit-autofill { + -webkit-text-fill-color: var(--login-field-color); + -webkit-box-shadow: 0 0 0 1000px var(--login-field-focus-background) inset; + caret-color: var(--login-field-color); +} + +.login-security-badge { + color: var(--login-link-color); + background: var(--login-badge-bg); + border-color: var(--login-badge-border); +} + +.login-footer { + color: var(--login-detail-color); + border-color: var(--login-divider); +} + +.login-player-link { + color: var(--login-link-color); +} + +.login-player-link:hover { + text-decoration: underline; +} + .btn-primary { width: auto; padding: 0.875rem 1.5rem; - background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); - color: white; + background: var(--accent); + color: var(--text-on-accent); border: none; - border-radius: 12px; + border-radius: var(--radius-lg); font-size: 0.95rem; font-weight: 600; cursor: pointer; @@ -436,7 +465,7 @@ body { align-items: center; justify-content: center; gap: 0.5rem; - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.2); + box-shadow: var(--shadow-sm); white-space: nowrap; } @@ -445,9 +474,10 @@ body { } .btn-primary:hover { - transform: translateY(-2px); - box-shadow: 0 8px 20px rgba(59, 130, 246, 0.4); - filter: brightness(1.1); + transform: none; + box-shadow: var(--shadow-sm); + filter: none; + background: var(--accent-hover); } .btn-primary:active { @@ -456,10 +486,10 @@ body { .btn-secondary { padding: 0.875rem 1.5rem; - background: rgba(255, 255, 255, 0.08); - border: 1px solid var(--glass-border); - border-radius: 12px; - color: var(--text-primary); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + color: var(--text); font-weight: 600; cursor: pointer; display: flex; @@ -471,8 +501,8 @@ body { } .btn-secondary:hover { - background: rgba(255, 255, 255, 0.15); - border-color: rgba(255, 255, 255, 0.2); + background: var(--surface-2); + border-color: var(--border); transform: translateY(-1px); } @@ -489,11 +519,13 @@ body { /* =============== 侧边栏 =============== */ .sidebar { width: 260px; + flex-shrink: 0; padding: 2rem 1rem; margin: 1rem; display: flex; flex-direction: column; - gap: 2rem; + gap: 1rem; + /* [修复] 原 2rem 间距在菜单项较多时白白占用约 32px 纵向空间,导致列表更容易溢出 */ position: sticky; top: 1rem; height: calc(100vh - 2rem); @@ -509,7 +541,7 @@ body { .sidebar-header .logo-icon { width: 32px; height: 32px; - color: var(--accent-primary); + color: var(--accent-text); } .sidebar-header h2 { @@ -524,15 +556,28 @@ body { gap: 0.5rem; overflow-y: auto; min-height: 0; - /* Hide scrollbar for Chrome, Safari and Opera */ - -ms-overflow-style: none; - /* IE and Edge */ - scrollbar-width: none; - /* Firefox */ + padding-right: 4px; + /* [修复] 菜单项多达 10 条时常溢出,此前滚动条被隐藏导致用户以为「菜单变少」。 + 改为细滚动条:既保留视觉简洁,又让溢出可被发现、可滚动。 */ + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--text) 5%, transparent) transparent; } .sidebar-nav::-webkit-scrollbar { - display: none; + width: 6px; +} + +.sidebar-nav::-webkit-scrollbar-track { + background: transparent; +} + +.sidebar-nav::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--text) 5%, transparent); + border-radius: var(--radius-sm); +} + +.sidebar-nav::-webkit-scrollbar-thumb:hover { + background: var(--surface-hover); } .nav-item { @@ -540,8 +585,8 @@ body { align-items: center; gap: 0.75rem; padding: 0.875rem 1rem; - border-radius: 12px; - color: var(--text-secondary); + border-radius: var(--radius-lg); + color: var(--text-2); text-decoration: none; font-weight: 500; transition: all 0.3s ease; @@ -555,19 +600,31 @@ body { } .nav-item:hover { - background: rgba(255, 255, 255, 0.05); - color: var(--text-primary); + background: var(--surface-hover); + color: var(--text); } .nav-item.active { - background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); - color: white; - box-shadow: var(--shadow-md); + background: var(--accent); + color: var(--text-on-accent); + box-shadow: var(--shadow-sm); +} + +/* [修复] 视口较矮时(笔记本常见 700~800px)侧栏进一步收紧,避免底部菜单被挤出可视区 */ +@media (max-height: 900px) { + .sidebar { + padding: 1.25rem 1rem; + gap: 0.75rem; + } + + .nav-item { + padding: 0.625rem 1rem; + } } .sidebar-divider { height: 1px; - background: var(--glass-border); + background: var(--border); margin: 0.5rem 0; flex-shrink: 0; } @@ -581,16 +638,16 @@ body { .sidebar-footer { padding-top: 1rem; - border-top: 1px solid var(--glass-border); + border-top: 1px solid var(--border); } .btn-logout { width: 100%; padding: 0.875rem 1rem; - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(239, 68, 68, 0.2); - border-radius: 12px; - color: var(--accent-error); + background: var(--danger-soft); + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); + border-radius: var(--radius-lg); + color: var(--danger-text); display: flex; align-items: center; justify-content: center; @@ -606,13 +663,14 @@ body { } .btn-logout:hover { - background: rgba(239, 68, 68, 0.2); - border-color: rgba(239, 68, 68, 0.3); + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); } /* =============== 主内容区 =============== */ .main-content { - flex: 1; + flex: 1 1 0%; + min-width: 0; padding: 1rem 1rem 1rem 0; display: flex; flex-direction: column; @@ -621,16 +679,16 @@ body { .top-bar { position: sticky; - top: 0; + top: 1rem; z-index: 1000; padding: 1.25rem 2rem; display: flex; align-items: center; justify-content: space-between; - background: var(--glass-bg); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border-bottom: 1px solid var(--glass-border); + background: var(--surface); + backdrop-filter: none; + -webkit-backdrop-filter: none; + border-bottom: 1px solid var(--border); margin-bottom: 2rem; } @@ -656,15 +714,15 @@ body { align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; - background: rgba(16, 185, 129, 0.1); - border-radius: 8px; + background: var(--success-soft); + border-radius: var(--radius-md); } .status-dot { width: 12px; height: 12px; border-radius: 50%; - background: var(--accent-success); + background: var(--success); animation: pulse 2s ease-in-out infinite; } @@ -682,6 +740,7 @@ body { .content-wrapper { flex: 1; + min-width: 0; padding: 0 2rem 2rem; overflow-y: auto; } @@ -717,13 +776,14 @@ body { display: flex; align-items: center; gap: 1.15rem; - background: rgba(255, 255, 255, 0.035); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 18px; - backdrop-filter: blur(16px); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + backdrop-filter: none; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); position: relative; overflow: hidden; + box-shadow: var(--shadow-sm); } .stat-card::before { @@ -733,20 +793,21 @@ body { left: 0; right: 0; height: 1px; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent); + background: color-mix(in srgb, var(--text) 5%, transparent); + display: none; } .stat-card:hover { - transform: translateY(-4px); - background: rgba(255, 255, 255, 0.06); - border-color: rgba(255, 255, 255, 0.18); - box-shadow: 0 16px 36px rgba(0, 0, 0, 0.35); + transform: none; + background: var(--surface-hover); + border-color: var(--border-strong); + box-shadow: var(--shadow-md); } .stat-icon-wrap { width: 44px; height: 44px; - border-radius: 14px; + border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center; @@ -760,38 +821,38 @@ body { } .icon-blue-glow { - background: rgba(59, 130, 246, 0.15); - border: 1px solid rgba(59, 130, 246, 0.3); - color: #60a5fa; - box-shadow: 0 0 20px rgba(59, 130, 246, 0.2); + background: var(--info-soft); + border: 1px solid color-mix(in srgb, var(--info) 35%, var(--border)); + color: var(--info-text); + box-shadow: var(--shadow-sm); } .icon-purple-glow { - background: rgba(168, 85, 247, 0.15); - border: 1px solid rgba(168, 85, 247, 0.3); - color: #c084fc; - box-shadow: 0 0 20px rgba(168, 85, 247, 0.2); + background: var(--accent-soft); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); + color: var(--accent-text); + box-shadow: var(--shadow-sm); } .icon-cyan-glow { - background: rgba(6, 182, 212, 0.15); - border: 1px solid rgba(6, 182, 212, 0.3); - color: #22d3ee; - box-shadow: 0 0 20px rgba(6, 182, 212, 0.2); + background: var(--success-soft); + border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); + color: var(--success-text); + box-shadow: var(--shadow-sm); } .icon-amber-glow { - background: rgba(245, 158, 11, 0.15); - border: 1px solid rgba(245, 158, 11, 0.3); - color: #fbbf24; - box-shadow: 0 0 20px rgba(245, 158, 11, 0.2); + background: var(--warning-soft); + border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--border)); + color: var(--warning-text); + box-shadow: var(--shadow-sm); } .icon-emerald-glow { - background: rgba(16, 185, 129, 0.15); - border: 1px solid rgba(16, 185, 129, 0.3); - color: #34d399; - box-shadow: 0 0 20px rgba(16, 185, 129, 0.2); + background: var(--success-soft); + border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); + color: var(--success-text); + box-shadow: var(--shadow-sm); } .stat-label-wrap { @@ -805,35 +866,47 @@ body { margin: 0; font-size: 0.82rem; font-weight: 500; - color: var(--text-secondary); + color: var(--text); } .stat-mini-badge { font-size: 0.65rem; padding: 0.1rem 0.4rem; - border-radius: 4px; - background: rgba(59, 130, 246, 0.15); - color: #93c5fd; - border: 1px solid rgba(59, 130, 246, 0.25); + border-radius: var(--radius-sm); + background: var(--info-soft); + color: var(--info-text); + border: 1px solid color-mix(in srgb, var(--info) 35%, var(--border)); line-height: 1.2; } .stat-split-tag { font-size: 0.65rem; padding: 0.1rem 0.4rem; - border-radius: 4px; - background: rgba(255, 255, 255, 0.06); - color: var(--text-muted); - border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--text) 5%, transparent); + color: var(--text-3); + border: 1px solid var(--border); +} + +.stat-pill-online.is-ready { + color: var(--success-text); + background: var(--success-soft); + border-color: var(--success); +} + +.stat-pill-online.is-unconfigured { + color: var(--warning-text); + background: var(--warning-soft); + border-color: var(--warning); } .stat-pill-online { font-size: 0.65rem; padding: 0.1rem 0.45rem; - border-radius: 9999px; - background: rgba(16, 185, 129, 0.15); - color: #34d399; - border: 1px solid rgba(16, 185, 129, 0.3); + border-radius: var(--radius-pill); + background: var(--success-soft); + color: var(--success-text); + border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); font-weight: 600; } @@ -841,8 +914,8 @@ body { width: 6px; height: 6px; border-radius: 50%; - background: #34d399; - box-shadow: 0 0 8px #34d399; + background: var(--success); + box-shadow: none; animation: livePulse 2s infinite; } @@ -852,12 +925,12 @@ body { } .stat-highlight-purple { - color: #c084fc; + color: var(--accent-text); font-weight: 600; } .stat-highlight-blue { - color: #60a5fa; + color: var(--info-text); font-weight: 600; } @@ -868,11 +941,11 @@ body { } .stat-card-dual { - display: flex !important; + display: flex; align-items: center; justify-content: space-between; - padding: 1.15rem 1rem !important; - gap: 0.6rem !important; + padding: 1.15rem 1rem; + gap: 0.6rem; } .stat-card-dual .stat-icon-wrap { @@ -889,14 +962,14 @@ body { font-size: 1.55rem; font-weight: 700; letter-spacing: -0.02em; - color: var(--text-primary); + color: var(--text); line-height: 1.2; } .stat-value-small { margin: 4px 0 0; font-size: 0.74rem; - color: var(--text-muted); + color: var(--text-3); } /* =============== 实时监控与折线图面板 =============== */ @@ -911,13 +984,14 @@ body { padding: 1.35rem 1.5rem; display: flex; flex-direction: column; - background: rgba(255, 255, 255, 0.035); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 20px; - backdrop-filter: blur(16px); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + backdrop-filter: none; transition: all 0.3s cubic-bezier(0.16, 1, 0.3, 1); position: relative; overflow: hidden; + box-shadow: var(--shadow-sm); } .monitor-card::before { @@ -927,13 +1001,15 @@ body { left: 0; right: 0; height: 1px; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.18), transparent); + background: color-mix(in srgb, var(--text) 5%, transparent); + display: none; } .monitor-card:hover { - background: rgba(255, 255, 255, 0.055); - border-color: rgba(255, 255, 255, 0.16); - box-shadow: 0 16px 36px rgba(0, 0, 0, 0.35); + background: var(--surface-hover); + border-color: var(--border-strong); + box-shadow: var(--shadow-md); + transform: none; } .monitor-card-header { @@ -954,7 +1030,7 @@ body { .monitor-title-icon { width: 36px; height: 36px; - border-radius: 10px; + border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; @@ -964,20 +1040,20 @@ body { .monitor-main-title { font-size: 0.95rem; font-weight: 600; - color: var(--text-primary); + color: var(--text); display: block; } .monitor-sub-info { font-size: 0.72rem; - color: var(--text-muted); + color: var(--text-3); margin-top: 2px; } .chart-legend-row { display: flex; align-items: center; - gap: 0.9rem; + gap: 10px; } .legend-item { @@ -985,11 +1061,11 @@ body { align-items: center; gap: 0.35rem; font-size: 0.75rem; - color: var(--text-secondary); + color: var(--text-2); } .legend-item b { - color: var(--text-primary); + color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; } @@ -1004,8 +1080,8 @@ body { .progress-container-dual { width: 100%; height: 4px; - background: rgba(255, 255, 255, 0.06); - border-radius: 9999px; + background: color-mix(in srgb, var(--text) 5%, transparent); + border-radius: var(--radius-pill); overflow: hidden; margin-bottom: 1rem; } @@ -1013,17 +1089,17 @@ body { .progress-bar-fill { height: 100%; transition: width 0.4s ease; - border-radius: 9999px; + border-radius: var(--radius-pill); } .progress-cpu-sys { - background: linear-gradient(90deg, #3b82f6, #60a5fa); - box-shadow: 0 0 10px rgba(59, 130, 246, 0.5); + background: var(--chart-1); + box-shadow: var(--shadow-sm); } .progress-mem-sys { - background: linear-gradient(90deg, #10b981, #34d399); - box-shadow: 0 0 10px rgba(16, 185, 129, 0.5); + background: var(--chart-2); + box-shadow: var(--shadow-sm); } .chart-canvas-wrapper { @@ -1036,12 +1112,12 @@ body { /* 可点击的统计卡片 */ .stat-clickable { cursor: pointer; - transition: transform 0.2s ease, box-shadow 0.2s ease !important; + transition: transform 0.2s ease, box-shadow 0.2s ease; } .stat-clickable:hover { - transform: translateY(-4px) !important; - box-shadow: 0 12px 32px rgba(59, 130, 246, 0.2) !important; + transform: translateY(-4px); + box-shadow: var(--shadow-sm); } /* 分区标题 */ @@ -1055,7 +1131,7 @@ body { .dash-section-header h3 { font-size: 1.1rem; font-weight: 600; - color: var(--text-primary); + color: var(--text); margin: 0; } @@ -1063,25 +1139,25 @@ body { font-size: 0.7rem; font-weight: 600; padding: 0.2rem 0.6rem; - border-radius: 9999px; - background: rgba(59, 130, 246, 0.12); - color: var(--accent-primary); - border: 1px solid rgba(59, 130, 246, 0.2); + border-radius: var(--radius-pill); + background: var(--surface-2); + color: var(--text-3); + border: 1px solid var(--border); letter-spacing: 0.03em; text-transform: uppercase; } /* 快速操作卡片(现代横向布局) */ .quick-actions { - display: grid; + display: flex; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - gap: 1rem; + gap: 8px;flex-wrap:wrap; } .action-btn { padding: 1.1rem 1.25rem; - background: transparent; - border: none; + background: var(--surface); + border: 1px solid var(--border); cursor: pointer; display: flex; flex-direction: row; @@ -1089,23 +1165,23 @@ body { gap: 1rem; text-align: left; transition: all 0.3s ease; - border-radius: 16px; + border-radius: 10px; } .action-btn-icon { width: 44px; height: 44px; - border-radius: 12px; + border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center; - flex-shrink: 0; + flex-shrink: 0;background:var(--surface-2);color:var(--accent-text);border:1px solid var(--border); } .action-btn-icon svg { width: 22px; height: 22px; - color: white; + color: var(--text);stroke:currentColor; } .action-btn-text { @@ -1118,18 +1194,19 @@ body { .action-btn-title { font-size: 0.9rem; font-weight: 600; - color: var(--text-primary); + color: var(--text); } .action-btn-desc { font-size: 0.75rem; - color: var(--text-secondary); + color: var(--text-2); } .action-btn:hover { - transform: translateY(-3px); - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); - background: rgba(255, 255, 255, 0.03); + transform: none; + box-shadow: var(--shadow-sm); + background: var(--surface-hover); + border-color: var(--accent); } /* =============== 用户网格 =============== */ @@ -1140,16 +1217,16 @@ body { } .user-card { - background: var(--glass-bg); - border: 1px solid var(--glass-border); - border-radius: 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 1.5rem; transition: all 0.3s ease; } .user-card:hover { transform: translateY(-4px); - box-shadow: var(--shadow-xl); + box-shadow: var(--shadow-sm); } .user-card-header { @@ -1162,7 +1239,7 @@ body { .user-name { font-size: 1.125rem; font-weight: 600; - color: var(--text-primary); + color: var(--text); } .user-actions { @@ -1173,7 +1250,7 @@ body { .user-actions button { width: 32px; height: 32px; - border-radius: 8px; + border-radius: var(--radius-md); border: none; cursor: pointer; display: flex; @@ -1188,21 +1265,21 @@ body { } .btn-edit { - background: rgba(59, 130, 246, 0.1); - color: var(--accent-primary); + background: var(--info-soft); + color: var(--accent-text); } .btn-edit:hover { - background: rgba(59, 130, 246, 0.2); + background: var(--info-soft); } .btn-delete { - background: rgba(239, 68, 68, 0.1); - color: var(--accent-error); + background: var(--danger-soft); + color: var(--danger-text); } .btn-delete:hover { - background: rgba(239, 68, 68, 0.2); + background: var(--danger-soft); } .user-info { @@ -1218,11 +1295,11 @@ body { } .user-info-label { - color: var(--text-secondary); + color: var(--text-2); } .user-info-value { - color: var(--text-primary); + color: var(--text); font-weight: 500; } @@ -1235,9 +1312,9 @@ body { } .data-stat-card { - background: var(--glass-bg); - border: 1px solid var(--glass-border); - border-radius: 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 1.1rem 1.25rem; transition: all 0.25s ease; cursor: pointer; @@ -1247,15 +1324,15 @@ body { .data-stat-card:hover { transform: translateY(-3px); - border-color: rgba(59, 130, 246, 0.4); - box-shadow: var(--shadow-lg); - background: rgba(30, 36, 68, 0.75); + border-color: color-mix(in srgb, var(--info) 35%, var(--border)); + box-shadow: var(--shadow-sm); + background: var(--surface); } .data-stat-card.active-tab { - border-color: var(--accent-primary); - background: rgba(59, 130, 246, 0.12); - box-shadow: 0 0 16px rgba(59, 130, 246, 0.25); + border-color: var(--accent); + background: var(--info-soft); + box-shadow: var(--shadow-sm); } .stat-card-header { @@ -1268,7 +1345,7 @@ body { .stat-card-header h4 { font-size: 0.825rem; font-weight: 500; - color: var(--text-secondary); + color: var(--text); margin: 0; } @@ -1280,7 +1357,7 @@ body { .data-stat-card .value { font-size: 1.6rem; font-weight: 700; - color: var(--text-primary); + color: var(--text); letter-spacing: -0.02em; line-height: 1.2; } @@ -1294,10 +1371,10 @@ body { .data-tabs-nav { display: inline-flex; gap: 0.35rem; - background: rgba(18, 23, 41, 0.75); + background: var(--surface); padding: 5px; - border-radius: 12px; - border: 1px solid var(--glass-border); + border-radius: var(--radius-lg); + border: 1px solid var(--border); box-shadow: var(--shadow-sm); flex-wrap: wrap; } @@ -1307,10 +1384,10 @@ body { align-items: center; gap: 0.5rem; padding: 0.5rem 1rem; - border-radius: 8px; + border-radius: var(--radius-md); border: 1px solid transparent; background: transparent; - color: var(--text-secondary); + color: var(--text-2); font-size: 0.875rem; font-weight: 500; cursor: pointer; @@ -1318,29 +1395,29 @@ body { } .data-tab-btn:hover { - color: var(--text-primary); - background: rgba(255, 255, 255, 0.05); + color: var(--text); + background: var(--surface-hover); } .data-tab-btn.active { - color: #fff; - background: linear-gradient(135deg, #3b82f6, #6366f1); - box-shadow: 0 2px 10px rgba(59, 130, 246, 0.35); + color: var(--on-info); + background: var(--info); + box-shadow: var(--shadow-sm); font-weight: 600; - border-color: rgba(255, 255, 255, 0.15); + border-color: var(--border-strong); } .data-tab-count { font-size: 0.75rem; font-weight: 600; padding: 1px 7px; - border-radius: 10px; - background: rgba(255, 255, 255, 0.12); + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--text) 5%, transparent); color: inherit; } .data-tab-btn.active .data-tab-count { - background: rgba(255, 255, 255, 0.25); + background: var(--surface-hover); } /* Section Headers */ @@ -1350,7 +1427,7 @@ body { align-items: center; margin-bottom: 1.25rem; padding-bottom: 0.75rem; - border-bottom: 1px solid var(--glass-border); + border-bottom: 1px solid var(--border); } .data-section-header.with-margin-top { @@ -1366,12 +1443,12 @@ body { .section-icon-box { width: 34px; height: 34px; - border-radius: 10px; + border-radius: var(--radius-md); display: flex; align-items: center; justify-content: center; - color: #fff; - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25); + color: var(--text); + box-shadow: var(--shadow-sm); flex-shrink: 0; } @@ -1383,13 +1460,13 @@ body { .section-title-group h3 { font-size: 1.125rem; font-weight: 600; - color: var(--text-primary); + color: var(--text); line-height: 1.2; } .section-subtitle { font-size: 0.8rem; - color: var(--text-secondary); + color: var(--text-2); display: block; margin-top: 2px; } @@ -1398,10 +1475,10 @@ body { font-size: 0.78rem; font-weight: 500; padding: 0.3rem 0.75rem; - border-radius: 20px; - background: rgba(255, 255, 255, 0.08); - border: 1px solid var(--glass-border); - color: var(--text-secondary); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + color: var(--text-2); } /* Albums Grid & Cards */ @@ -1413,9 +1490,9 @@ body { } .album-card { - background: var(--glass-bg); - border: 1px solid var(--glass-border); - border-radius: 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 0.75rem; display: flex; flex-direction: column; @@ -1427,18 +1504,18 @@ body { .album-card:hover { transform: translateY(-6px); - box-shadow: 0 16px 30px rgba(0, 0, 0, 0.45); - border-color: rgba(99, 102, 241, 0.4); + box-shadow: var(--shadow-sm); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); } .album-cover-box { width: 100%; aspect-ratio: 1 / 1; - border-radius: 10px; + border-radius: var(--radius-md); overflow: hidden; position: relative; - background: rgba(10, 14, 39, 0.6); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + background: var(--surface); + box-shadow: var(--shadow-sm); } .album-cover-img { @@ -1459,8 +1536,8 @@ body { display: flex; align-items: center; justify-content: center; - background: radial-gradient(circle, #2d3748 0%, #1a202c 100%); - color: var(--text-secondary); + background: var(--surface); + color: var(--text-2); } .album-cover-fallback svg { @@ -1472,8 +1549,8 @@ body { .album-card-overlay { position: absolute; inset: 0; - background: rgba(10, 14, 39, 0.5); - backdrop-filter: blur(2px); + background: var(--overlay); + backdrop-filter: none; display: flex; flex-direction: column; align-items: center; @@ -1491,12 +1568,12 @@ body { width: 44px; height: 44px; border-radius: 50%; - background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + background: var(--accent); display: flex; align-items: center; justify-content: center; - color: #fff; - box-shadow: 0 4px 14px rgba(59, 130, 246, 0.5); + color: var(--text-on-accent); + box-shadow: var(--shadow-sm); transform: scale(0.9); transition: transform 0.2s ease; } @@ -1513,7 +1590,7 @@ body { .album-hover-text { font-size: 0.75rem; - color: #fff; + color: var(--text); font-weight: 500; letter-spacing: 0.5px; } @@ -1522,15 +1599,15 @@ body { position: absolute; bottom: 8px; right: 8px; - background: rgba(10, 14, 39, 0.75); - backdrop-filter: blur(8px); - -webkit-backdrop-filter: blur(8px); - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 20px; + background: var(--surface); + backdrop-filter: none; + -webkit-backdrop-filter: none; + border: 1px solid var(--border); + border-radius: var(--radius-lg); font-size: 0.72rem; font-weight: 600; padding: 2px 7px; - color: #e2e8f0; + color: var(--text); z-index: 2; } @@ -1541,19 +1618,19 @@ body { font-size: 0.68rem; font-weight: 600; padding: 2px 6px; - border-radius: 6px; - color: #fff; - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); + border-radius: var(--radius-sm); + color: var(--text); + backdrop-filter: none; + -webkit-backdrop-filter: none; z-index: 2; } -.source-tx { background: rgba(0, 194, 80, 0.85); } -.source-wy { background: rgba(236, 65, 65, 0.85); } -.source-kw { background: rgba(255, 170, 0, 0.85); } -.source-kg { background: rgba(36, 136, 255, 0.85); } -.source-mg { background: rgba(229, 0, 109, 0.85); } -.source-local { background: rgba(139, 92, 246, 0.85); } +.source-tx { background: var(--success); color: var(--on-success); } +.source-wy { background: var(--danger); color: var(--on-danger); } +.source-kw { background: var(--warning); color: var(--on-danger); } +.source-kg { background: var(--info); color: var(--on-info); } +.source-mg { background: var(--danger); color: var(--on-danger); } +.source-local { background: var(--accent); color: var(--text-on-accent); } .album-meta-info { display: flex; @@ -1565,7 +1642,7 @@ body { .album-title { font-size: 0.95rem; font-weight: 600; - color: var(--text-primary); + color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -1574,7 +1651,7 @@ body { .album-artist { font-size: 0.8rem; - color: var(--text-secondary); + color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -1592,9 +1669,9 @@ body { } .artist-card { - background: var(--glass-bg); - border: 1px solid var(--glass-border); - border-radius: 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 1.25rem 0.75rem; display: flex; flex-direction: column; @@ -1607,8 +1684,8 @@ body { .artist-card:hover { transform: translateY(-5px); - box-shadow: 0 16px 28px rgba(0, 0, 0, 0.4); - border-color: rgba(16, 185, 129, 0.4); + box-shadow: var(--shadow-sm); + border-color: color-mix(in srgb, var(--success) 35%, var(--border)); } .artist-avatar-box { @@ -1616,11 +1693,12 @@ body { height: 90px; border-radius: 50%; padding: 3px; - background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); - box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); + background: var(--accent); + box-shadow: var(--shadow-sm); position: relative; overflow: hidden; flex-shrink: 0; + color: var(--text-on-accent); } .artist-avatar-img { @@ -1643,8 +1721,8 @@ body { display: flex; align-items: center; justify-content: center; - background: radial-gradient(circle, #334155 0%, #1e293b 100%); - color: var(--text-secondary); + background: var(--surface); + color: var(--text-2); } .artist-avatar-fallback svg { @@ -1660,9 +1738,9 @@ body { font-size: 0.65rem; font-weight: 600; padding: 1px 5px; - border-radius: 8px; - color: #fff; - border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: var(--radius-md); + color: var(--text); + border: 1px solid var(--border); } .artist-meta-info { @@ -1677,7 +1755,7 @@ body { .artist-name { font-size: 0.95rem; font-weight: 600; - color: var(--text-primary); + color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -1687,7 +1765,7 @@ body { .artist-sub { font-size: 0.75rem; - color: var(--text-muted); + color: var(--text-3); } /* Album Detail Header */ @@ -1696,18 +1774,18 @@ body { gap: 1.5rem; align-items: center; padding: 1.25rem; - background: rgba(18, 23, 41, 0.5); - border-radius: 14px; - border: 1px solid var(--glass-border); + background: var(--surface); + border-radius: var(--radius-lg); + border: 1px solid var(--border); margin-bottom: 1.5rem; } .album-detail-cover { width: 120px; height: 120px; - border-radius: 12px; + border-radius: var(--radius-lg); object-fit: cover; - box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4); + box-shadow: var(--shadow-sm); flex-shrink: 0; } @@ -1722,7 +1800,7 @@ body { .album-detail-title { font-size: 1.5rem; font-weight: 700; - color: var(--text-primary); + color: var(--text); line-height: 1.2; } @@ -1731,14 +1809,14 @@ body { align-items: center; gap: 0.75rem; font-size: 0.875rem; - color: var(--text-secondary); + color: var(--text-2); flex-wrap: wrap; } .data-content { - background: var(--glass-bg); - border: 1px solid var(--glass-border); - border-radius: 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 1.5rem; } @@ -1757,25 +1835,25 @@ body { display: block; font-size: 0.875rem; font-weight: 500; - color: var(--text-secondary); + color: var(--text-2); margin-bottom: 0.5rem; } .form-input { width: 100%; padding: 0.75rem 1rem; - background: rgba(255, 255, 255, 0.05); - border: 1px solid var(--glass-border); - border-radius: 8px; - color: var(--text-primary); + background: var(--field-bg); + border: 1px solid var(--field-border); + border-radius: var(--radius-md); + color: var(--text); font-size: 0.95rem; transition: all 0.3s ease; } .form-input:focus { outline: none; - border-color: var(--accent-primary); - background: rgba(255, 255, 255, 0.08); + border-color: var(--accent); + background: var(--field-bg); } .checkbox-label { @@ -1796,7 +1874,7 @@ body { gap: 1rem; margin-top: 2rem; padding-top: 2rem; - border-top: 1px solid var(--glass-border); + border-top: 1px solid var(--border); } .form-actions .btn-primary, @@ -1821,7 +1899,7 @@ body { /* 卡片主体 */ .config-card { - border-radius: 16px; + border-radius: var(--radius-lg); overflow: visible; position: relative; transition: box-shadow 0.3s ease, transform 0.2s ease; @@ -1829,12 +1907,12 @@ body { .config-card:hover { transform: translateY(-2px); - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3); + box-shadow: var(--shadow-sm); } .config-card:has(.lx-select.is-open) { z-index: 60; - transform: none !important; + transform: none; } /* 卡片标题栏 */ @@ -1844,14 +1922,14 @@ body { align-items: center; gap: 1rem; padding: 1.25rem 1.5rem; - border-bottom: 1px solid var(--glass-border); - background: rgba(255, 255, 255, 0.02); + border-bottom: 1px solid var(--border); + background: var(--surface); } .config-card-icon { width: 42px; height: 42px; - border-radius: 12px; + border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center; @@ -1861,19 +1939,20 @@ body { .config-card-icon svg { width: 20px; height: 20px; - color: white; + color: inherit; + stroke: currentColor; } .config-card-title { font-size: 0.975rem; - font-weight: 600; - color: var(--text-primary); + font-weight: 650; + color: var(--text); margin: 0 0 2px; } .config-card-desc { font-size: 0.775rem; - color: var(--text-secondary); + color: var(--text-2); margin: 0; } @@ -1899,19 +1978,19 @@ body { gap: 2px; font-size: 0.8rem; font-weight: 500; - color: var(--text-secondary); + color: var(--text-2); letter-spacing: 0.01em; } .config-input { - font-size: 0.9rem !important; - padding: 0.65rem 0.9rem !important; - border-radius: 10px !important; + font-size: 0.9rem; + padding: 0.65rem 0.9rem; + border-radius: var(--radius-md); } .config-hint { font-size: 0.75rem; - color: var(--text-secondary); + color: var(--text-2); opacity: 0.8; margin-top: 2px; } @@ -1935,8 +2014,8 @@ body { } .config-inline-btn { - padding: 0.55rem 0.85rem !important; - font-size: 0.8rem !important; + padding: 0.55rem 0.85rem; + font-size: 0.8rem; white-space: nowrap; display: flex; align-items: center; @@ -1947,7 +2026,7 @@ body { /* 分隔线 */ .config-divider { height: 1px; - background: var(--glass-border); + background: var(--border); opacity: 0.6; margin: 0.15rem 0; } @@ -1970,13 +2049,13 @@ body { .config-toggle-label { font-size: 0.875rem; font-weight: 500; - color: var(--text-primary); + color: var(--text); line-height: 1.3; } .config-toggle-desc { font-size: 0.75rem; - color: var(--text-secondary); + color: var(--text-2); line-height: 1.4; } @@ -1999,36 +2078,37 @@ body { .toggle-track { width: 44px; height: 24px; - border-radius: 12px; - background: rgba(255, 255, 255, 0.12); - border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: var(--radius-lg); + background: var(--surface-2); + border: 1px solid var(--border); transition: background 0.25s ease, border-color 0.25s ease; display: flex; align-items: center; padding: 2px; + border-color: var(--border-strong); } .toggle-thumb { width: 18px; height: 18px; border-radius: 50%; - background: rgba(255, 255, 255, 0.5); + background: var(--text-2); transition: transform 0.25s ease, background 0.25s ease; flex-shrink: 0; } .toggle-switch input:checked+.toggle-track { - background: var(--accent-primary); - border-color: var(--accent-primary); + background: var(--accent); + border-color: var(--accent); } .toggle-switch input:checked+.toggle-track .toggle-thumb { transform: translateX(20px); - background: white; + background: var(--text-on-accent); } .toggle-switch:hover .toggle-track { - border-color: rgba(255, 255, 255, 0.3); + border-color: var(--border); } /* Tag 选择徽章组组件 (单选 / 多选) */ @@ -2044,10 +2124,10 @@ body { align-items: center; gap: 6px; padding: 6px 14px; - border-radius: 20px; - background: rgba(255, 255, 255, 0.06); - border: 1px solid var(--glass-border); - color: var(--text-secondary); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + color: var(--text-2); font-size: 0.85rem; font-weight: 500; cursor: pointer; @@ -2056,17 +2136,17 @@ body { } .tag-select-item:hover { - background: rgba(255, 255, 255, 0.12); - color: var(--text-primary); - border-color: rgba(255, 255, 255, 0.2); + background: var(--surface-hover); + color: var(--text); + border-color: var(--border); transform: translateY(-1px); } .tag-select-item.active { - background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + background: var(--accent); border-color: transparent; - color: #fff; - box-shadow: 0 2px 10px rgba(59, 130, 246, 0.35); + color: var(--text-on-accent); + box-shadow: var(--shadow-sm); font-weight: 600; } @@ -2090,7 +2170,7 @@ body { margin-top: 6px; min-height: 38px; padding: 4px; - border-radius: 8px; + border-radius: var(--radius-md); transition: background-color 0.2s ease; } @@ -2110,7 +2190,7 @@ body { width: 18px; height: 18px; border-radius: 50%; - background: rgba(255, 255, 255, 0.2); + background: color-mix(in srgb, var(--text) 5%, transparent); font-size: 0.72rem; font-weight: 700; margin-right: 2px; @@ -2127,28 +2207,28 @@ body { } .tag-sortable-item.drag-over { - border-color: var(--accent-primary, #3b82f6); - box-shadow: 0 0 0 2px var(--accent-primary, #3b82f6); + border-color: var(--accent); + box-shadow: var(--shadow-sm); } /* 禁用/未启用状态的 Tag(灰色置底) */ .tag-sortable-item.disabled { - background: rgba(255, 255, 255, 0.04); - border: 1px dashed rgba(255, 255, 255, 0.15); - color: var(--text-tertiary, #64748b); - box-shadow: none; + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px dashed var(--border); + color: var(--text-3); + box-shadow: var(--shadow-sm); opacity: 0.65; } .tag-sortable-item.disabled:hover { - background: rgba(255, 255, 255, 0.08); - color: var(--text-secondary, #94a3b8); + background: var(--surface-hover); + color: var(--text-2); opacity: 0.9; } .tag-sortable-item.disabled .tag-index-badge { - background: rgba(255, 255, 255, 0.08); - color: var(--text-tertiary, #64748b); + background: color-mix(in srgb, var(--text) 5%, transparent); + color: var(--text-3); } .tag-sortable-item .tag-toggle-btn { @@ -2163,12 +2243,12 @@ body { } .tag-sortable-item .tag-toggle-btn:hover { - background: rgba(255, 255, 255, 0.2); + background: var(--surface-hover); transform: scale(1.15); } .tag-sortable-item.disabled .tag-toggle-btn:hover { - background: rgba(255, 255, 255, 0.12); + background: var(--surface-hover); } /* =============== 日志查看 =============== */ @@ -2177,12 +2257,12 @@ body { max-height: 620px; min-height: 360px; overflow-y: auto; - background: rgba(11, 15, 30, 0.85); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 16px; - box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.5); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: inset 0 2px 10px color-mix(in srgb, var(--text) 50%, transparent); scrollbar-width: thin; - scrollbar-color: rgba(255, 255, 255, 0.15) transparent; + scrollbar-color: color-mix(in srgb, var(--text) 5%, transparent) transparent; } .logs-content::-webkit-scrollbar { @@ -2195,12 +2275,12 @@ body { } .logs-content::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.15); - border-radius: 9999px; + background: color-mix(in srgb, var(--text) 5%, transparent); + border-radius: var(--radius-pill); } .logs-content::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.25); + background: var(--surface-hover); } .log-line { @@ -2208,8 +2288,8 @@ body { font-size: 0.84rem; padding: 0.35rem 0.6rem; margin-bottom: 2px; - border-radius: 6px; - color: #cbd5e1; + border-radius: var(--radius-sm); + color: var(--text); line-height: 1.55; word-break: break-all; white-space: pre-wrap; @@ -2217,40 +2297,40 @@ body { } .log-line:hover { - background: rgba(255, 255, 255, 0.04); + background: var(--surface-hover); } .log-time-tag { - color: #64748b; + color: var(--text-2); margin-right: 0.5rem; font-size: 0.78rem; } .log-tag-info { - color: #38bdf8; - background: rgba(56, 189, 248, 0.12); + color: var(--info-text); + background: var(--info-soft); padding: 0.1rem 0.4rem; - border-radius: 4px; + border-radius: var(--radius-sm); font-weight: 600; margin-right: 0.4rem; font-size: 0.75rem; } .log-tag-warn { - color: #fbbf24; - background: rgba(251, 191, 36, 0.12); + color: var(--warning-text); + background: var(--warning-soft); padding: 0.1rem 0.4rem; - border-radius: 4px; + border-radius: var(--radius-sm); font-weight: 600; margin-right: 0.4rem; font-size: 0.75rem; } .log-tag-error { - color: #f87171; - background: rgba(248, 113, 113, 0.15); + color: var(--danger-text); + background: var(--danger-soft); padding: 0.1rem 0.4rem; - border-radius: 4px; + border-radius: var(--radius-sm); font-weight: 600; margin-right: 0.4rem; font-size: 0.75rem; @@ -2261,8 +2341,8 @@ body { align-items: center; justify-content: space-between; padding: 0.75rem 1.25rem; - background: rgba(18, 23, 41, 0.95); - border: 1px solid rgba(255, 255, 255, 0.08); + background: var(--surface); + border: 1px solid var(--border); border-bottom: none; border-radius: 16px 16px 0 0; } @@ -2274,28 +2354,28 @@ body { } .dot-red { - background: #ef4444; - box-shadow: 0 0 6px rgba(239, 68, 68, 0.6); + background: var(--danger); + box-shadow: var(--shadow-sm); } .dot-amber { - background: #f59e0b; - box-shadow: 0 0 6px rgba(245, 158, 11, 0.6); + background: var(--warning); + box-shadow: var(--shadow-sm); } .terminal-title { font-family: 'JetBrains Mono', Monaco, monospace; font-size: 0.8rem; - color: var(--text-secondary); + color: var(--text); font-weight: 500; } .terminal-badge { font-size: 0.7rem; padding: 0.15rem 0.5rem; - border-radius: 4px; - background: rgba(255, 255, 255, 0.06); - color: var(--text-muted); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--text) 5%, transparent); + color: var(--text-3); } #view-logs .logs-content { @@ -2315,23 +2395,23 @@ body { align-items: center; gap: 0.6rem; padding: 0.5rem 0.85rem; - background: rgba(255, 255, 255, 0.04); - border: 1px solid var(--glass-border); - border-radius: 12px; + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius-lg); cursor: pointer; user-select: none; transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); } .pill-toggle-switch:hover { - background: rgba(255, 255, 255, 0.08); - border-color: rgba(255, 255, 255, 0.18); + background: var(--surface-hover); + border-color: var(--border); } .pill-toggle-label { font-size: 0.82rem; font-weight: 500; - color: var(--text-secondary); + color: var(--text-2); white-space: nowrap; display: flex; align-items: center; @@ -2339,25 +2419,26 @@ body { } .pill-toggle-switch input[type="checkbox"]:checked ~ .pill-toggle-label { - color: var(--text-primary); + color: var(--text); } .pill-toggle-track { width: 34px; height: 18px; - border-radius: 9999px; - background: rgba(255, 255, 255, 0.12); - border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: var(--radius-pill); + background: var(--surface-2); + border: 1px solid var(--border); position: relative; transition: all 0.25s ease; flex-shrink: 0; + border-color: var(--border-strong); } .pill-toggle-thumb { width: 12px; height: 12px; border-radius: 50%; - background: rgba(255, 255, 255, 0.6); + background: var(--text-2); position: absolute; top: 2px; left: 2px; @@ -2373,14 +2454,14 @@ body { } .pill-toggle-switch input[type="checkbox"]:checked + .pill-toggle-track { - background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + background: var(--accent); border-color: transparent; - box-shadow: 0 0 10px rgba(59, 130, 246, 0.4); + box-shadow: var(--shadow-sm); } .pill-toggle-switch input[type="checkbox"]:checked + .pill-toggle-track .pill-toggle-thumb { transform: translateX(16px); - background: #fff; + background: var(--text-on-accent); } /* =============== 选择输入 =============== */ @@ -2389,15 +2470,15 @@ select.form-input, #data-user-select, #log-type-select { padding: 0.75rem 2.5rem 0.75rem 1rem; - background: rgba(255, 255, 255, 0.05); - border: 1px solid var(--glass-border); - border-radius: 12px; - color: var(--text-primary); + background: var(--field-bg); + border: 1px solid var(--field-border); + border-radius: var(--radius-lg); + color: var(--text); font-size: 0.95rem; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23ffffff' fill-opacity='0.6' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + appearance: auto; + background-image: none; background-repeat: no-repeat; background-position: right 1rem center; background-size: 10px; @@ -2409,9 +2490,9 @@ select.form-input, select.form-input:hover, #data-user-select:hover, #log-type-select:hover { - background: rgba(255, 255, 255, 0.1); - border-color: var(--accent-primary); - box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1); + background: var(--field-bg); + border-color: var(--accent); + box-shadow: none; } .select-input:focus, @@ -2419,9 +2500,9 @@ select.form-input:focus, #data-user-select:focus, #log-type-select:focus { outline: none; - background: rgba(255, 255, 255, 0.08); - border-color: var(--accent-primary); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); + background: var(--field-bg); + border-color: var(--accent); + box-shadow: var(--field-focus-shadow); } /* 选择框选项样式 */ @@ -2429,15 +2510,15 @@ select.form-input:focus, select.form-input option, #data-user-select option, #log-type-select option { - background: rgb(20, 20, 35); - color: var(--text-primary); + background: var(--field-bg); + color: var(--text); padding: 0.5rem; } .select-input option:checked, select.form-input option:checked { - background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); - color: white; + background: var(--field-bg); + color: var(--text-on-accent); } /* =============== 自定义用户选择器 =============== */ @@ -2452,36 +2533,36 @@ select.form-input option:checked { align-items: center; gap: 0.65rem; padding: 0.65rem 1rem; - background: rgba(255, 255, 255, 0.04); - border: 1px solid var(--glass-border); - border-radius: 12px; + background: var(--field-bg); + border: 1px solid var(--field-border); + border-radius: var(--radius-lg); cursor: pointer; transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); - color: var(--text-primary); + color: var(--text); } .selector-trigger:hover { - background: rgba(255, 255, 255, 0.08); - border-color: rgba(59, 130, 246, 0.4); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + background: var(--field-bg); + border-color: color-mix(in srgb, var(--info) 35%, var(--border)); + box-shadow: none; } .custom-user-selector.open .selector-trigger { - border-color: var(--accent-primary); - background: rgba(59, 130, 246, 0.08); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); + border-color: var(--accent); + background: var(--field-bg); + box-shadow: var(--field-focus-shadow); } .selector-icon { width: 16px; height: 16px; - color: var(--accent-primary); + color: var(--accent-text); } .chevron-icon { width: 15px; height: 15px; - color: var(--text-secondary); + color: var(--text-2); margin-left: auto; transition: transform 0.25s ease; } @@ -2500,12 +2581,12 @@ select.form-input option:checked { top: calc(100% + 8px); left: 0; min-width: 100%; - background: rgba(18, 23, 41, 0.95); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 14px; - box-shadow: 0 16px 36px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05); + background: var(--surface-2); + backdrop-filter: none; + -webkit-backdrop-filter: none; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); z-index: 100; overflow: hidden; animation: dropdownFade 0.2s cubic-bezier(0.16, 1, 0.3, 1); @@ -2529,34 +2610,34 @@ select.form-input option:checked { align-items: center; gap: 10px; cursor: pointer; - border-radius: 8px; + border-radius: var(--radius-md); transition: all 0.2s ease; - color: var(--text-secondary); + color: var(--text-2); font-size: 0.88rem; } .dropdown-item:hover { - background: rgba(255, 255, 255, 0.06); - color: var(--text-primary); + background: var(--surface-hover); + color: var(--text); padding-left: 1rem; } .dropdown-item.active { - background: rgba(59, 130, 246, 0.15); - color: #60a5fa; + background: var(--info-soft); + color: var(--info-text); font-weight: 600; } .dropdown-avatar { width: 24px; height: 24px; - background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + background: var(--accent); border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 10px; - color: white; + color: var(--text-on-accent); } /* =============== 用户选择网格 =============== */ @@ -2573,23 +2654,23 @@ select.form-input option:checked { justify-content: space-between; margin-bottom: 1.25rem; padding: 0.75rem 1.25rem; - background: rgba(255, 255, 255, 0.025); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 14px; + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius-lg); } .user-select-hint { font-size: 0.84rem; - color: var(--text-secondary); + color: var(--text-2); display: flex; align-items: center; gap: 0.5rem; } .user-select-card { - background: rgba(255, 255, 255, 0.035); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 20px; + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 2.25rem 1.5rem; text-align: center; cursor: pointer; @@ -2609,28 +2690,28 @@ select.form-input option:checked { left: 0; right: 0; height: 1px; - background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.15), transparent); + background: color-mix(in srgb, var(--text) 5%, transparent); } .user-select-card:hover { - background: rgba(255, 255, 255, 0.06); - border-color: rgba(59, 130, 246, 0.4); + background: var(--surface-hover); + border-color: color-mix(in srgb, var(--info) 35%, var(--border)); transform: translateY(-6px); - box-shadow: 0 16px 36px rgba(0, 0, 0, 0.4), 0 0 20px rgba(59, 130, 246, 0.15); + box-shadow: var(--shadow-sm); } .user-select-card .avatar { width: 68px; height: 68px; - background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); - border-radius: 22px; + background: var(--accent); + border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center; font-size: 26px; font-weight: 700; - color: white; - box-shadow: 0 10px 24px rgba(59, 130, 246, 0.35); + color: var(--text-on-accent); + box-shadow: var(--shadow-sm); transition: transform 0.3s ease; } @@ -2641,18 +2722,18 @@ select.form-input option:checked { .user-select-card .name { font-size: 1.15rem; font-weight: 700; - color: var(--text-primary); + color: var(--text); letter-spacing: -0.01em; } .user-select-card .role { font-size: 0.78rem; font-weight: 600; - color: var(--text-secondary); - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.08); + color: var(--text-2); + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); padding: 0.25rem 0.85rem; - border-radius: 9999px; + border-radius: var(--radius-pill); } /* 加载动画 */ @@ -2673,8 +2754,8 @@ select.form-input option:checked { width: 30px; height: 30px; margin: -15px 0 0 -15px; - border: 3px solid rgba(255, 255, 255, 0.1); - border-top-color: var(--accent-primary); + border: 3px solid var(--border); + border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; z-index: 10; @@ -2716,14 +2797,10 @@ select.form-input option:checked { /* 骨架屏效果 */ .skeleton { - background: linear-gradient(90deg, - rgba(255, 255, 255, 0.05) 25%, - rgba(255, 255, 255, 0.1) 50%, - rgba(255, 255, 255, 0.05) 75% - ); + background: color-mix(in srgb, var(--text) 5%, transparent); background-size: 200% 100%; animation: skeleton-loading 1.5s infinite; - border-radius: 8px; + border-radius: var(--radius-md); } @keyframes skeleton-loading { @@ -2738,9 +2815,9 @@ select.form-input option:checked { left: 0; width: 100%; height: 100%; - background: rgba(10, 14, 39, 0.75); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); + background: var(--surface); + backdrop-filter: none; + -webkit-backdrop-filter: none; display: flex; align-items: center; justify-content: center; @@ -2755,9 +2832,9 @@ select.form-input option:checked { flex-direction: column; padding: 2rem 2.25rem; box-sizing: border-box; - border-radius: 20px; - border: 1px solid rgba(255, 255, 255, 0.12); - box-shadow: 0 24px 48px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05); + border-radius: var(--radius-lg); + border: 1px solid var(--border); + box-shadow: var(--shadow-sm); animation: fadeInUp 0.3s ease-out; } @@ -2772,24 +2849,24 @@ select.form-input option:checked { align-items: center; margin-bottom: 1.25rem; padding-bottom: 1rem; - border-bottom: 1px solid var(--glass-border); + border-bottom: 1px solid var(--border); flex-shrink: 0; } .modal-header h3 { font-size: 1.25rem; font-weight: 700; - color: var(--text-primary); + color: var(--text); margin: 0; } .modal-header .modal-close { width: 34px; height: 34px; - border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(255, 255, 255, 0.05); - color: var(--text-secondary); + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: color-mix(in srgb, var(--text) 5%, transparent); + color: var(--text-2); font-size: 1.25rem; cursor: pointer; display: flex; @@ -2799,14 +2876,14 @@ select.form-input option:checked { } .modal-header .modal-close:hover { - color: #fff; - background: rgba(239, 68, 68, 0.15); - border-color: rgba(239, 68, 68, 0.3); + color: var(--text); + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); transform: scale(1.05); } .modal-body { - color: var(--text-primary); + color: var(--text); flex: 1 1 auto; overflow-y: auto; min-height: 0; @@ -2824,12 +2901,12 @@ select.form-input option:checked { } .modal-body::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.18); - border-radius: 4px; + background: color-mix(in srgb, var(--text) 5%, transparent); + border-radius: var(--radius-sm); } .modal-body::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.32); + background: var(--surface-hover); } .modal-footer { @@ -2838,7 +2915,7 @@ select.form-input option:checked { gap: 0.85rem; margin-top: 1.25rem; padding-top: 1.25rem; - border-top: 1px solid var(--glass-border); + border-top: 1px solid var(--border); flex-shrink: 0; } @@ -2846,9 +2923,9 @@ select.form-input option:checked { .modal-footer .btn-secondary { width: auto; min-width: 100px; - padding: 0.75rem 1.4rem !important; + padding: 0.75rem 1.4rem; font-size: 0.92rem; - border-radius: 10px; + border-radius: var(--radius-md); } /* =============== 动画 =============== */ @@ -2878,13 +2955,13 @@ select.form-input option:checked { /* 优化 view-header 按钮样式 */ .view-header button { - padding: 0.625rem 1.25rem !important; - font-size: 0.875rem !important; + padding: 0.625rem 1.25rem; + font-size: 0.875rem; } .view-header button svg { - width: 16px !important; - height: 16px !important; + width: 16px; + height: 16px; } /* 改进的响应式设计 */ @@ -2912,25 +2989,25 @@ select.form-input option:checked { } .dot-blue { - background: var(--accent-primary); - box-shadow: 0 0 10px var(--accent-primary); + background: var(--accent); + box-shadow: var(--shadow-sm); } .dot-emerald { - background: var(--accent-success); - box-shadow: 0 0 10px var(--accent-success); + background: var(--success); + box-shadow: var(--shadow-sm); } .section-badge-title { font-size: 0.95rem; font-weight: 700; - color: var(--text-primary); + color: var(--text); letter-spacing: 0.02em; } .section-badge-desc { font-size: 0.8rem; - color: var(--text-muted); + color: var(--text-3); margin-left: 0.25rem; } @@ -2942,13 +3019,13 @@ select.form-input option:checked { /* 动态任务进度中心卡片 */ .task-progress-card { - border-radius: 20px; + border-radius: var(--radius-lg); padding: 1.75rem 2rem; position: relative; overflow: hidden; - border: 1px solid rgba(59, 130, 246, 0.35); - background: linear-gradient(135deg, rgba(30, 41, 59, 0.7) 0%, rgba(15, 23, 42, 0.85) 100%); - box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4), inset 0 1px 0 rgba(255, 255, 255, 0.1); + border: 1px solid color-mix(in srgb, var(--info) 35%, var(--border)); + background: var(--surface); + box-shadow: 0 12px 40px color-mix(in srgb, var(--text) 40%, transparent), inset 0 1px 0 color-mix(in srgb, var(--text) 10%, transparent); transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); animation: taskCardSlideDown 0.35s ease-out; } @@ -2980,8 +3057,8 @@ select.form-input option:checked { .task-spinner { width: 28px; height: 28px; - border: 3px solid rgba(59, 130, 246, 0.2); - border-top-color: var(--accent-primary); + border: 3px solid color-mix(in srgb, var(--info) 35%, var(--border)); + border-top-color: var(--accent); border-radius: 50%; animation: taskSpin 0.9s cubic-bezier(0.55, 0.15, 0.45, 0.85) infinite; flex-shrink: 0; @@ -2995,13 +3072,13 @@ select.form-input option:checked { margin: 0; font-size: 1.15rem; font-weight: 700; - color: #fff; + color: var(--text); letter-spacing: -0.01em; } .task-subtext { font-size: 0.82rem; - color: var(--text-secondary); + color: var(--text-2); margin-top: 0.2rem; } @@ -3013,19 +3090,19 @@ select.form-input option:checked { .task-progress-badge { padding: 0.35rem 0.85rem; - border-radius: 9999px; - background: rgba(59, 130, 246, 0.15); - border: 1px solid rgba(59, 130, 246, 0.3); - color: #60a5fa; + border-radius: var(--radius-pill); + background: var(--info-soft); + border: 1px solid color-mix(in srgb, var(--info) 35%, var(--border)); + color: var(--info-text); font-family: Monaco, Menlo, monospace; font-size: 0.95rem; font-weight: 700; } .task-progress-close-btn { - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.12); - color: var(--text-muted); + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + color: var(--text-3); width: 30px; height: 30px; border-radius: 50%; @@ -3038,9 +3115,9 @@ select.form-input option:checked { } .task-progress-close-btn:hover { - background: rgba(239, 68, 68, 0.2); - border-color: rgba(239, 68, 68, 0.4); - color: #f87171; + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); + color: var(--danger-text); transform: scale(1.08); } @@ -3051,9 +3128,9 @@ select.form-input option:checked { justify-content: space-between; margin-bottom: 1.5rem; padding: 0.75rem 1rem; - background: rgba(0, 0, 0, 0.25); - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.05); + background: var(--surface-2); + border-radius: var(--radius-lg); + border: 1px solid var(--border); } .task-step { @@ -3061,25 +3138,25 @@ select.form-input option:checked { align-items: center; gap: 0.5rem; font-size: 0.8rem; - color: var(--text-muted); + color: var(--text-3); transition: all 0.3s ease; } .task-step.active { - color: #fff; + color: var(--text); font-weight: 600; } .task-step.done { - color: var(--accent-success); + color: var(--success-text); } .step-dot { width: 22px; height: 22px; border-radius: 50%; - background: rgba(255, 255, 255, 0.1); - color: var(--text-secondary); + background: color-mix(in srgb, var(--text) 5%, transparent); + color: var(--text-2); display: flex; align-items: center; justify-content: center; @@ -3089,52 +3166,54 @@ select.form-input option:checked { } .task-step.active .step-dot { - background: var(--accent-primary); - color: #fff; - box-shadow: 0 0 12px var(--accent-primary); + background: var(--accent); + color: var(--text-on-accent); + box-shadow: var(--shadow-sm); transform: scale(1.1); } .task-step.done .step-dot { - background: var(--accent-success); - color: #fff; - box-shadow: 0 0 10px rgba(16, 185, 129, 0.5); + background: var(--success); + color: var(--text-on-accent); + box-shadow: var(--shadow-sm); } .step-line { flex: 1; height: 2px; - background: rgba(255, 255, 255, 0.1); + background: color-mix(in srgb, var(--text) 5%, transparent); margin: 0 0.75rem; - border-radius: 1px; + border-radius: var(--radius-sm); transition: background 0.3s ease; } .step-line.active { - background: linear-gradient(90deg, var(--accent-primary), var(--accent-secondary)); + background: var(--accent); + color: var(--text-on-accent); } .step-line.done { - background: var(--accent-success); + background: var(--success); + color: var(--on-success); } /* 平滑流动进度条 */ .task-progress-track { width: 100%; height: 10px; - background: rgba(0, 0, 0, 0.35); - border-radius: 6px; + background: var(--surface-2); + border-radius: var(--radius-sm); overflow: hidden; position: relative; - border: 1px solid rgba(255, 255, 255, 0.05); + border: 1px solid var(--border); } .task-progress-fill { width: 0%; height: 100%; - background: linear-gradient(90deg, #3b82f6 0%, #8b5cf6 50%, #10b981 100%); + background: var(--info); background-size: 200% 100%; - border-radius: 6px; + border-radius: var(--radius-sm); transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1); animation: progressShimmer 2s linear infinite; position: relative; @@ -3154,7 +3233,7 @@ select.form-input option:checked { } .task-progress-detail { - color: #cbd5e1; + color: var(--text); font-family: Monaco, Menlo, monospace; max-width: 75%; white-space: nowrap; @@ -3163,7 +3242,7 @@ select.form-input option:checked { } .task-progress-meta { - color: var(--text-muted); + color: var(--text-3); font-size: 0.78rem; font-family: Monaco, Menlo, monospace; } @@ -3177,8 +3256,8 @@ select.form-input option:checked { .action-card { padding: 1.75rem 1.25rem 1.4rem; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.08); + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); cursor: pointer; display: flex; flex-direction: column; @@ -3186,16 +3265,16 @@ select.form-input option:checked { gap: 0.85rem; text-align: center; transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); - border-radius: 18px; + border-radius: var(--radius-lg); position: relative; overflow: hidden; - backdrop-filter: blur(12px); + backdrop-filter: none; } .action-card:hover { transform: translateY(-5px); - box-shadow: 0 16px 32px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.15); - background: rgba(255, 255, 255, 0.05); + box-shadow: var(--shadow-sm); + background: var(--surface-hover); } .action-card:active { @@ -3216,19 +3295,19 @@ select.form-input option:checked { } .glow-blue { - background: radial-gradient(circle, rgba(59, 130, 246, 0.15) 0%, transparent 60%); + background: var(--info-soft); } .glow-emerald { - background: radial-gradient(circle, rgba(16, 185, 129, 0.15) 0%, transparent 60%); + background: var(--success-soft); } .glow-amber { - background: radial-gradient(circle, rgba(245, 158, 11, 0.15) 0%, transparent 60%); + background: var(--warning-soft); } .glow-purple { - background: radial-gradient(circle, rgba(139, 92, 246, 0.15) 0%, transparent 60%); + background: var(--accent-soft); } .action-card:hover .action-card-glow { @@ -3241,7 +3320,7 @@ select.form-input option:checked { display: flex; align-items: center; justify-content: center; - border-radius: 16px; + border-radius: var(--radius-lg); margin-bottom: 0.25rem; transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); position: relative; @@ -3249,32 +3328,32 @@ select.form-input option:checked { } .icon-blue { - background: rgba(59, 130, 246, 0.12) !important; - color: #60a5fa !important; - border: 1px solid rgba(59, 130, 246, 0.25) !important; + background: var(--info-soft); + color: var(--info-text); + border: 1px solid color-mix(in srgb, var(--info) 35%, var(--border)); } .icon-emerald { - background: rgba(16, 185, 129, 0.12) !important; - color: #34d399 !important; - border: 1px solid rgba(16, 185, 129, 0.25) !important; + background: var(--success-soft); + color: var(--success-text); + border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); } .icon-amber { - background: rgba(245, 158, 11, 0.12) !important; - color: #fbbf24 !important; - border: 1px solid rgba(245, 158, 11, 0.25) !important; + background: var(--warning-soft); + color: var(--warning-text); + border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--border)); } .icon-purple { - background: rgba(139, 92, 246, 0.12) !important; - color: #a78bfa !important; - border: 1px solid rgba(139, 92, 246, 0.25) !important; + background: var(--accent-soft); + color: var(--accent-text); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); } .action-card:hover .action-card-icon { transform: scale(1.12); - box-shadow: 0 0 20px currentcolor; + box-shadow: var(--shadow-sm); } .action-card-icon svg { @@ -3285,7 +3364,7 @@ select.form-input option:checked { .action-card h4 { font-size: 1.05rem; font-weight: 700; - color: var(--text-primary); + color: var(--text); margin: 0; letter-spacing: -0.01em; position: relative; @@ -3294,7 +3373,7 @@ select.form-input option:checked { .action-card p { font-size: 0.82rem; - color: var(--text-secondary); + color: var(--text-2); line-height: 1.5; margin: 0; position: relative; @@ -3305,33 +3384,33 @@ select.form-input option:checked { margin-top: auto; font-size: 0.72rem; padding: 0.25rem 0.65rem; - border-radius: 9999px; - background: rgba(255, 255, 255, 0.05); - color: var(--text-muted); - border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--text) 5%, transparent); + color: var(--text-3); + border: 1px solid var(--border); position: relative; z-index: 1; } .card-action-tag.tag-emerald { - background: rgba(16, 185, 129, 0.1); - color: #34d399; - border-color: rgba(16, 185, 129, 0.2); + background: var(--success-soft); + color: var(--success-text); + border-color: color-mix(in srgb, var(--success) 35%, var(--border)); } .card-action-tag.tag-amber { - background: rgba(245, 158, 11, 0.1); - color: #fbbf24; - border-color: rgba(245, 158, 11, 0.2); + background: var(--warning-soft); + color: var(--warning-text); + border-color: color-mix(in srgb, var(--warning) 35%, var(--border)); } .card-featured { - border-color: rgba(59, 130, 246, 0.4); - background: linear-gradient(180deg, rgba(59, 130, 246, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%); + border-color: color-mix(in srgb, var(--info) 35%, var(--border)); + background: var(--info-soft); } .glow-btn { - box-shadow: 0 0 24px rgba(59, 130, 246, 0.5); + box-shadow: var(--shadow-sm); font-weight: 600; } @@ -3351,7 +3430,7 @@ select.form-input option:checked { .sync-status-section, .sync-logs-section { padding: 1.5rem; - border-radius: 18px; + border-radius: var(--radius-lg); display: flex; flex-direction: column; } @@ -3362,7 +3441,7 @@ select.form-input option:checked { justify-content: space-between; margin-bottom: 1rem; padding-bottom: 0.75rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); + border-bottom: 1px solid var(--border); } .sync-section-hd-title { @@ -3375,7 +3454,7 @@ select.form-input option:checked { margin: 0; font-size: 1.05rem; font-weight: 700; - color: var(--text-primary); + color: var(--text); } .status-indicator-pill { @@ -3384,19 +3463,20 @@ select.form-input option:checked { gap: 0.45rem; font-size: 0.75rem; padding: 0.2rem 0.65rem; - border-radius: 9999px; - background: rgba(16, 185, 129, 0.12); - color: #34d399; - border: 1px solid rgba(16, 185, 129, 0.25); + border-radius: var(--radius-pill); + background: var(--success-soft); + color: var(--success-text); + border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); } .live-beacon { width: 6px; height: 6px; border-radius: 50%; - background: #10b981; - box-shadow: 0 0 8px #10b981; + background: var(--success); + box-shadow: var(--shadow-sm); animation: livePulse 1.8s infinite; + color: var(--on-success); } @keyframes livePulse { @@ -3410,18 +3490,18 @@ select.form-input option:checked { gap: 0.35rem; font-size: 0.7rem; padding: 0.15rem 0.55rem; - border-radius: 9999px; - background: rgba(139, 92, 246, 0.15); - color: #c4b5fd; - border: 1px solid rgba(139, 92, 246, 0.25); + border-radius: var(--radius-pill); + background: var(--accent-soft); + color: var(--accent-text); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); } .realtime-dot { width: 5px; height: 5px; border-radius: 50%; - background: #a78bfa; - box-shadow: 0 0 6px #a78bfa; + background: var(--accent); + box-shadow: var(--shadow-sm); animation: livePulse 1.5s infinite; } @@ -3431,7 +3511,7 @@ select.form-input option:checked { align-items: center; margin-bottom: 1rem; padding-bottom: 0.75rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); + border-bottom: 1px solid var(--border); } .logs-header-actions { @@ -3440,8 +3520,8 @@ select.form-input option:checked { } .btn-sm { - padding: 0.35rem 0.85rem !important; - font-size: 0.8rem !important; + padding: 0.35rem 0.85rem; + font-size: 0.8rem; } .sync-logs-list { @@ -3455,9 +3535,9 @@ select.form-input option:checked { .sync-log-item { padding: 0.75rem 1rem; - border-radius: 12px; - background: rgba(0, 0, 0, 0.2); - border: 1px solid rgba(255, 255, 255, 0.04); + border-radius: var(--radius-lg); + background: var(--surface-2); + border: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; @@ -3465,8 +3545,8 @@ select.form-input option:checked { } .sync-log-item:hover { - background: rgba(255, 255, 255, 0.03); - border-color: rgba(255, 255, 255, 0.09); + background: var(--surface-hover); + border-color: var(--border); transform: translateX(3px); } @@ -3475,8 +3555,8 @@ select.form-input option:checked { } @keyframes logFlash { - 0% { background: rgba(59, 130, 246, 0.25); border-color: rgba(59, 130, 246, 0.5); } - 100% { background: rgba(0, 0, 0, 0.2); border-color: rgba(255, 255, 255, 0.04); } + 0% { background: var(--info-soft); border-color: color-mix(in srgb, var(--info) 35%, var(--border)); } + 100% { background: var(--surface-2); border-color: var(--border); } } .log-info { @@ -3487,14 +3567,14 @@ select.form-input option:checked { .log-file { font-family: Monaco, Menlo, monospace; font-size: 0.82rem; - color: var(--text-primary); + color: var(--text); word-break: break-all; } .log-type { display: inline-block; padding: 0.2rem 0.55rem; - border-radius: 6px; + border-radius: var(--radius-sm); font-size: 0.72rem; font-weight: 700; margin-right: 0.5rem; @@ -3502,51 +3582,51 @@ select.form-input option:checked { } .log-type-upload { - background: rgba(59, 130, 246, 0.15); - color: #60a5fa; - border: 1px solid rgba(59, 130, 246, 0.3); + background: var(--info-soft); + color: var(--info-text); + border: 1px solid color-mix(in srgb, var(--info) 35%, var(--border)); } .log-type-download { - background: rgba(139, 92, 246, 0.15); - color: #a78bfa; - border: 1px solid rgba(139, 92, 246, 0.3); + background: var(--accent-soft); + color: var(--accent-text); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); } .log-type-backup { - background: rgba(16, 185, 129, 0.15); - color: #34d399; - border: 1px solid rgba(16, 185, 129, 0.3); + background: var(--success-soft); + color: var(--success-text); + border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); } .log-type-restore { - background: rgba(245, 158, 11, 0.15); - color: #fbbf24; - border: 1px solid rgba(245, 158, 11, 0.3); + background: var(--warning-soft); + color: var(--warning-text); + border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--border)); } .log-status { font-size: 0.72rem; padding: 0.2rem 0.55rem; - border-radius: 6px; + border-radius: var(--radius-sm); font-weight: 600; } .log-status-success { - background: rgba(16, 185, 129, 0.15); - color: #34d399; - border: 1px solid rgba(16, 185, 129, 0.25); + background: var(--success-soft); + color: var(--success-text); + border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); } .log-status-error { - background: rgba(239, 68, 68, 0.15); - color: #f87171; - border: 1px solid rgba(239, 68, 68, 0.25); + background: var(--danger-soft); + color: var(--danger-text); + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); } .log-time { font-size: 0.75rem; - color: var(--text-muted); + color: var(--text-3); } /* 云端恢复选项卡片与备份列表 */ @@ -3554,19 +3634,19 @@ select.form-input option:checked { display: flex; gap: 0.75rem; margin-bottom: 1.25rem; - background: rgba(0, 0, 0, 0.25); + background: var(--surface-2); padding: 4px; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: var(--radius-lg); + border: 1px solid var(--border); } .restore-tab-btn { flex: 1; padding: 0.65rem 1rem; border: none; - border-radius: 9px; + border-radius: var(--radius-md); background: transparent; - color: var(--text-secondary); + color: var(--text-2); font-size: 0.88rem; font-weight: 600; cursor: pointer; @@ -3578,14 +3658,14 @@ select.form-input option:checked { } .restore-tab-btn.active { - background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); - color: #fff; - box-shadow: 0 4px 12px rgba(59, 130, 246, 0.35); + background: var(--accent); + color: var(--text-on-accent); + box-shadow: var(--shadow-sm); } .restore-tab-btn:hover:not(.active) { - color: #fff; - background: rgba(255, 255, 255, 0.05); + color: var(--text); + background: var(--surface-hover); } .backup-zip-list { @@ -3600,11 +3680,11 @@ select.form-input option:checked { .backup-zip-card { padding: 1rem 1.25rem; - border-radius: 14px; - background: rgba(0, 0, 0, 0.25); - border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--radius-lg); + background: var(--surface-2); + border: 1px solid var(--border); display: flex; - flex-direction: row !important; + flex-direction: row; justify-content: space-between; align-items: center; gap: 1.25rem; @@ -3612,14 +3692,14 @@ select.form-input option:checked { } .backup-zip-card:hover { - background: rgba(59, 130, 246, 0.08); - border-color: rgba(59, 130, 246, 0.35); + background: var(--info-soft); + border-color: color-mix(in srgb, var(--info) 35%, var(--border)); transform: translateX(3px); } .backup-zip-card.latest { - border-color: rgba(16, 185, 129, 0.35); - background: linear-gradient(135deg, rgba(16, 185, 129, 0.09) 0%, rgba(0, 0, 0, 0.25) 100%); + border-color: color-mix(in srgb, var(--success) 35%, var(--border)); + background: var(--success-soft); } .backup-zip-info { @@ -3634,7 +3714,7 @@ select.form-input option:checked { font-family: Monaco, Menlo, Consolas, monospace; font-size: 0.88rem; font-weight: 600; - color: var(--text-primary); + color: var(--text); word-break: break-all; display: flex; align-items: center; @@ -3647,16 +3727,16 @@ select.form-input option:checked { font-size: 0.68rem; font-family: Inter, sans-serif; padding: 0.15rem 0.55rem; - border-radius: 9999px; - background: rgba(16, 185, 129, 0.2); - color: #34d399; - border: 1px solid rgba(16, 185, 129, 0.35); + border-radius: var(--radius-pill); + background: var(--success-soft); + color: var(--success-text); + border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); flex-shrink: 0; } .backup-zip-meta { font-size: 0.78rem; - color: var(--text-muted); + color: var(--text-3); display: flex; flex-wrap: wrap; gap: 1.25rem; @@ -3670,14 +3750,14 @@ select.form-input option:checked { } .restore-btn-single { - width: auto !important; - min-width: 108px !important; - padding: 0.55rem 1.15rem !important; - font-size: 0.84rem !important; - font-weight: 600 !important; - border-radius: 10px !important; - flex-shrink: 0 !important; - white-space: nowrap !important; + width: auto; + min-width: 108px; + padding: 0.55rem 1.15rem; + font-size: 0.84rem; + font-weight: 600; + border-radius: var(--radius-md); + flex-shrink: 0; + white-space: nowrap; letter-spacing: 0.02em; } @@ -3695,19 +3775,69 @@ select.form-input option:checked { padding: 0.55rem 0.85rem; font-size: 0.82rem; font-weight: 500; - border-radius: 8px; - background: rgba(239, 68, 68, 0.08); - border: 1px solid rgba(239, 68, 68, 0.25); - color: #f87171; + border-radius: var(--radius-md); + background: var(--danger-soft); + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); + color: var(--danger-text); cursor: pointer; transition: all 0.2s ease; white-space: nowrap; } +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + margin-left: var(--space-4); + padding: 0; + flex-shrink: 0; +} + +.theme-toggle svg { + display: block; + width: 18px; + height: 18px; + stroke: currentColor; +} + +.theme-toggle .i-sun { + display: none; +} + +[data-theme="dark"] .theme-toggle .i-sun { + display: block; +} + +[data-theme="dark"] .theme-toggle .i-moon { + display: none; +} + +.login-theme-toggle { + position: absolute; + top: max(var(--space-5), env(safe-area-inset-top)); + left: max(var(--space-5), env(safe-area-inset-left)); + z-index: 20; + width: 44px; + height: 44px; + margin: 0; + border: var(--login-card-border); + border-radius: var(--radius-lg); + background: var(--login-card-background); + color: var(--login-link-color); + box-shadow: var(--shadow-sm); + cursor: pointer; +} + +.login-theme-toggle:hover { + border-color: var(--accent); +} + .backup-del-btn:hover { - background: rgba(239, 68, 68, 0.2); - border-color: rgba(239, 68, 68, 0.5); - color: #fca5a5; + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); + color: var(--danger-text); transform: translateY(-1px); } @@ -3735,20 +3865,20 @@ select.form-input option:checked { } .file-breadcrumb a { - color: var(--text-secondary); + color: var(--text-2); text-decoration: none; font-size: 0.875rem; transition: color 0.3s ease; } .file-breadcrumb a:hover { - color: var(--accent-primary); + color: var(--accent-text); } .file-breadcrumb a:not(:last-child)::after { content: '/'; margin-left: 0.5rem; - color: var(--text-muted); + color: var(--text-3); } .file-toolbar-actions { @@ -3766,10 +3896,10 @@ select.form-input option:checked { grid-template-columns: 2fr 120px 150px 200px; gap: 1rem; padding: 1rem 1.5rem; - border-bottom: 1px solid var(--glass-border); + border-bottom: 1px solid var(--border); font-weight: 600; font-size: 0.875rem; - color: var(--text-secondary); + color: var(--text-2); } .file-item { @@ -3777,13 +3907,13 @@ select.form-input option:checked { grid-template-columns: 2fr 120px 150px 200px; gap: 1rem; padding: 1rem 1.5rem; - border-bottom: 1px solid var(--glass-border); + border-bottom: 1px solid var(--border); align-items: center; transition: background 0.3s ease; } .file-item:hover { - background: rgba(255, 255, 255, 0.02); + background: var(--surface-hover); } .file-item:last-child { @@ -3795,12 +3925,12 @@ select.form-input option:checked { align-items: center; gap: 0.5rem; font-weight: 500; - color: var(--text-primary); + color: var(--text); cursor: pointer; } .file-name:hover { - color: var(--accent-primary); + color: var(--accent-text); } .file-icon { @@ -3810,12 +3940,12 @@ select.form-input option:checked { .file-size { font-size: 0.875rem; - color: var(--text-secondary); + color: var(--text-2); } .file-date { font-size: 0.875rem; - color: var(--text-secondary); + color: var(--text-2); } .file-item-actions { @@ -3826,17 +3956,17 @@ select.form-input option:checked { .file-item-actions button { padding: 0.375rem 0.75rem; font-size: 0.75rem; - border: 1px solid var(--glass-border); - background: rgba(255, 255, 255, 0.05); - color: var(--text-primary); - border-radius: 6px; + border: 1px solid var(--border); + background: color-mix(in srgb, var(--text) 5%, transparent); + color: var(--text); + border-radius: var(--radius-sm); cursor: pointer; transition: all 0.3s ease; } .file-item-actions button:hover { - background: rgba(255, 255, 255, 0.1); - border-color: var(--accent-primary); + background: var(--surface-hover); + border-color: var(--accent); } /* 文件编辑器模态框 */ @@ -3846,7 +3976,7 @@ select.form-input option:checked { left: 0; width: 100%; height: 100%; - background: rgba(0, 0, 0, 0.8); + background: var(--surface-2); display: flex; align-items: center; justify-content: center; @@ -3863,7 +3993,7 @@ select.form-input option:checked { .file-editor-header { padding: 1.5rem; - border-bottom: 1px solid var(--glass-border); + border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; @@ -3878,11 +4008,11 @@ select.form-input option:checked { .file-editor-textarea { width: 100%; height: 100%; - background: rgba(0, 0, 0, 0.3); - border: 1px solid var(--glass-border); - border-radius: 8px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-md); padding: 1rem; - color: var(--text-primary); + color: var(--text); font-family: Monaco, Menlo, 'Courier New', monospace; font-size: 0.875rem; line-height: 1.6; @@ -3891,7 +4021,7 @@ select.form-input option:checked { .file-editor-footer { padding: 1.5rem; - border-top: 1px solid var(--glass-border); + border-top: 1px solid var(--border); display: flex; gap: 1rem; justify-content: flex-end; @@ -3962,7 +4092,7 @@ select.form-input option:checked { .playlist-card:hover { transform: translateY(-4px); - box-shadow: var(--shadow-xl); + box-shadow: var(--shadow-sm); } .playlist-card-header { @@ -3980,7 +4110,7 @@ select.form-input option:checked { .playlist-name { font-size: 1.125rem; font-weight: 600; - color: var(--text-primary); + color: var(--text); margin-bottom: 0.5rem; overflow: hidden; text-overflow: ellipsis; @@ -3993,7 +4123,7 @@ select.form-input option:checked { width: 100%; gap: 1rem; font-size: 0.875rem; - color: var(--text-secondary); + color: var(--text-2); min-width: 0; } @@ -4007,7 +4137,7 @@ select.form-input option:checked { } .playlist-count { - color: var(--accent-primary); + color: var(--accent-text); font-weight: 500; flex-shrink: 0; white-space: nowrap; @@ -4017,14 +4147,14 @@ select.form-input option:checked { display: flex; gap: 0.5rem; padding-top: 1rem; - border-top: 1px solid var(--glass-border); + border-top: 1px solid var(--border); } .btn-view, .btn-delete-playlist { flex: 1; padding: 0.5rem 1rem; - border-radius: 6px; + border-radius: var(--radius-sm); border: none; font-size: 0.875rem; font-weight: 500; @@ -4037,21 +4167,21 @@ select.form-input option:checked { } .btn-view { - background: rgba(59, 130, 246, 0.15); - color: var(--accent-primary); + background: var(--info-soft); + color: var(--accent-text); } .btn-view:hover { - background: rgba(59, 130, 246, 0.25); + background: var(--info-soft); } .btn-delete-playlist { - background: rgba(239, 68, 68, 0.15); - color: var(--accent-error); + background: var(--danger-soft); + color: var(--danger-text); } .btn-delete-playlist:hover { - background: rgba(239, 68, 68, 0.25); + background: var(--danger-soft); } .btn-view svg, @@ -4067,10 +4197,10 @@ select.form-input option:checked { .btn-back { padding: 0.5rem 1rem; - background: rgba(255, 255, 255, 0.05); - border: 1px solid var(--glass-border); - border-radius: 6px; - color: var(--text-primary); + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text); cursor: pointer; display: inline-flex; align-items: center; @@ -4080,8 +4210,8 @@ select.form-input option:checked { } .btn-back:hover { - background: rgba(255, 255, 255, 0.1); - border-color: var(--accent-primary); + background: var(--surface-hover); + border-color: var(--accent); } .btn-back svg { @@ -4098,7 +4228,7 @@ select.form-input option:checked { display: flex; gap: 1.5rem; font-size: 0.875rem; - color: var(--text-secondary); + color: var(--text-2); } /* =============== 歌曲表格 =============== */ @@ -4112,8 +4242,8 @@ select.form-input option:checked { .data-stat-card.clickable:hover { transform: translateY(-2px); - box-shadow: var(--shadow-lg); - border-color: var(--accent-primary); + box-shadow: var(--shadow-sm); + border-color: var(--accent); } .data-stat-card.clickable:active { @@ -4135,10 +4265,10 @@ select.form-input option:checked { .btn-edit-name { width: 32px; height: 32px; - border-radius: 6px; + border-radius: var(--radius-sm); border: none; - background: rgba(59, 130, 246, 0.1); - color: var(--accent-primary); + background: var(--info-soft); + color: var(--accent-text); cursor: pointer; display: flex; align-items: center; @@ -4147,7 +4277,7 @@ select.form-input option:checked { } .btn-edit-name:hover { - background: rgba(59, 130, 246, 0.2); + background: var(--info-soft); transform: scale(1.05); } @@ -4163,8 +4293,8 @@ select.form-input option:checked { align-items: center; margin-bottom: 1rem; padding: 1rem; - background: rgba(255, 255, 255, 0.02); - border-radius: 12px; + background: color-mix(in srgb, var(--text) 5%, transparent); + border-radius: var(--radius-lg); gap: 1rem; flex-wrap: wrap; } @@ -4177,10 +4307,10 @@ select.form-input option:checked { .btn-batch { padding: 0.5rem 0.875rem; - background: rgba(255, 255, 255, 0.05); - border: 1px solid var(--glass-border); - border-radius: 8px; - color: var(--text-primary); + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius-md); + color: var(--text); cursor: pointer; font-size: 0.85rem; transition: all 0.3s ease; @@ -4188,16 +4318,16 @@ select.form-input option:checked { } .btn-batch:hover { - background: rgba(255, 255, 255, 0.1); - border-color: var(--accent-primary); + background: var(--surface-hover); + border-color: var(--accent); } .btn-batch-delete { padding: 0.5rem 1.25rem; - background: rgba(239, 68, 68, 0.1); - border: 1px solid rgba(239, 68, 68, 0.2); - border-radius: 8px; - color: var(--accent-error); + background: var(--danger-soft); + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); + border-radius: var(--radius-md); + color: var(--danger-text); display: flex; align-items: center; gap: 0.5rem; @@ -4208,12 +4338,12 @@ select.form-input option:checked { } .btn-batch-delete:hover:not(:disabled) { - background: rgba(239, 68, 68, 0.25); - border-color: var(--accent-error); + background: var(--danger-soft); + border-color: var(--danger); } .btn-batch-delete:disabled { - opacity: 0.5; + opacity: 1; cursor: not-allowed; } @@ -4236,9 +4366,9 @@ select.form-input option:checked { appearance: none; width: 18px; height: 18px; - border: 2px solid var(--glass-border); - border-radius: 5px; - background: rgba(255, 255, 255, 0.05); + border: 2px solid var(--border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--text) 5%, transparent); cursor: pointer; position: relative; flex-shrink: 0; @@ -4247,9 +4377,10 @@ select.form-input option:checked { .song-col-checkbox input[type="checkbox"]:checked, #select-all-checkbox:checked { - background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + background: var(--accent); border-color: transparent; - box-shadow: 0 0 8px rgba(59, 130, 246, 0.4); + box-shadow: var(--shadow-sm); + color: var(--text-on-accent); } .song-col-checkbox input[type="checkbox"]:checked::after, @@ -4260,15 +4391,15 @@ select.form-input option:checked { top: 1px; width: 4px; height: 9px; - border: solid white; + border: solid var(--border); border-width: 0 2px 2px 0; transform: rotate(45deg); } .song-col-checkbox input[type="checkbox"]:indeterminate, #select-all-checkbox:indeterminate { - background: rgba(59, 130, 246, 0.4); - border-color: var(--accent-primary); + background: var(--info-soft); + border-color: var(--accent); } .song-col-checkbox input[type="checkbox"]:indeterminate::after, @@ -4279,14 +4410,14 @@ select.form-input option:checked { top: 6px; width: 8px; height: 2px; - background: white; - border-radius: 1px; + background: var(--surface-2); + border-radius: var(--radius-sm); } .song-col-checkbox input[type="checkbox"]:hover, #select-all-checkbox:hover { - border-color: var(--accent-primary); - background: rgba(255, 255, 255, 0.1); + border-color: var(--accent); + background: var(--surface-hover); } /* 歌曲表格列布局统一由下方「歌曲列表 UI 美化」区块管理 */ @@ -4310,24 +4441,24 @@ select.form-input option:checked { .search-box input { width: 100%; padding: 0.75rem 1rem 0.75rem 2.75rem; - background: rgba(255, 255, 255, 0.04); - border: 1px solid var(--glass-border); - border-radius: 12px; - color: var(--text-primary); + background: var(--field-bg); + border: 1px solid var(--field-border); + border-radius: var(--radius-lg); + color: var(--text); font-size: 0.92rem; transition: all 0.25s ease; } .search-box input:hover { - background: rgba(255, 255, 255, 0.07); - border-color: rgba(255, 255, 255, 0.2); + background: var(--field-bg); + border-color: var(--border); } .search-box input:focus { outline: none; - border-color: var(--accent-primary); - background: rgba(255, 255, 255, 0.09); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); + border-color: var(--accent); + background: var(--field-bg); + box-shadow: var(--field-focus-shadow); } .search-box svg { @@ -4337,26 +4468,26 @@ select.form-input option:checked { transform: translateY(-50%); width: 17px; height: 17px; - color: var(--text-secondary); + color: var(--text-2); pointer-events: none; transition: color 0.2s ease; } .search-box input:focus + svg { - color: var(--accent-primary); + color: var(--accent-text); } .sort-select { padding: 0.75rem 2.5rem 0.75rem 1rem; - background: rgba(255, 255, 255, 0.05); - border: 1px solid var(--glass-border); - border-radius: 12px; - color: var(--text-primary); + background: var(--field-bg); + border: 1px solid var(--field-border); + border-radius: var(--radius-lg); + color: var(--text); font-size: 0.95rem; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23ffffff' fill-opacity='0.6' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + appearance: auto; + background-image: none; background-repeat: no-repeat; background-position: right 1rem center; background-size: 10px; @@ -4364,15 +4495,15 @@ select.form-input option:checked { } .sort-select:hover { - background: rgba(255, 255, 255, 0.1); - border-color: var(--accent-primary); - box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.1); + background: var(--field-bg); + border-color: var(--accent); + box-shadow: none; } .sort-select:focus { outline: none; - border-color: var(--accent-primary); - box-shadow: 0 0 0 4px rgba(59, 130, 246, 0.2); + border-color: var(--accent); + box-shadow: var(--field-focus-shadow); } /* 文本截断已统一 */ @@ -4404,9 +4535,9 @@ select.form-input option:checked { } #view-users .header-main-actions .btn-primary { - width: auto !important; - padding: 0.65rem 1.25rem !important; - font-size: 0.875rem !important; + width: auto; + padding: 0.65rem 1.25rem; + font-size: 0.875rem; flex-shrink: 0; } @@ -4425,7 +4556,7 @@ select.form-input option:checked { #view-users .users-list-wrapper { background: transparent; border: none; - box-shadow: none; + box-shadow: var(--shadow-sm); overflow: visible; } @@ -4435,12 +4566,12 @@ select.form-input option:checked { gap: 1.25rem; padding: 0.85rem 1.4rem; margin: 0 0.5rem 0.65rem; - background: rgba(255, 255, 255, 0.025); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 12px; + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius-lg); font-weight: 700; font-size: 0.72rem; - color: var(--text-secondary); + color: var(--text-2); text-transform: uppercase; letter-spacing: 0.08em; } @@ -4451,19 +4582,19 @@ select.form-input option:checked { gap: 1.25rem; padding: 0.9rem 1.4rem; margin: 0 0.5rem 0.5rem; - background: var(--glass-bg); - border: 1px solid var(--glass-border); - border-radius: 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); align-items: center; transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); box-shadow: var(--shadow-sm); } .user-row:hover { - background: rgba(255, 255, 255, 0.05); + background: var(--surface-hover); transform: translateY(-2px); - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.35); - border-color: rgba(59, 130, 246, 0.3); + box-shadow: var(--shadow-sm); + border-color: color-mix(in srgb, var(--info) 35%, var(--border)); } /* 自定义复选框样式 */ @@ -4478,18 +4609,19 @@ select.form-input option:checked { appearance: none; width: 20px; height: 20px; - border: 2px solid var(--glass-border); - border-radius: 6px; - background: rgba(255, 255, 255, 0.05); + border: 2px solid var(--border); + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--text) 5%, transparent); cursor: pointer; position: relative; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); } #view-users input[type="checkbox"]:checked { - background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + background: var(--accent); border-color: transparent; - box-shadow: 0 0 10px rgba(59, 130, 246, 0.4); + box-shadow: var(--shadow-sm); + color: var(--text-on-accent); } #view-users input[type="checkbox"]:checked::after { @@ -4499,20 +4631,20 @@ select.form-input option:checked { top: 2px; width: 5px; height: 10px; - border: solid white; + border: solid var(--border); border-width: 0 2px 2px 0; transform: rotate(45deg); } #view-users input[type="checkbox"]:hover { - border-color: var(--accent-primary); - background: rgba(255, 255, 255, 0.1); + border-color: var(--accent); + background: var(--surface-hover); } /* 状态徽章 */ .status-badge { padding: 0.4rem 0.8rem; - border-radius: 10px; + border-radius: var(--radius-md); font-size: 0.75rem; font-weight: 600; display: inline-flex; @@ -4522,32 +4654,32 @@ select.form-input option:checked { } .status-badge.active { - background: rgba(16, 185, 129, 0.1); - color: var(--accent-success); - border: 1px solid rgba(16, 185, 129, 0.2); + background: var(--success-soft); + color: var(--success-text); + border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); } .status-badge.active::before { content: ''; width: 6px; height: 6px; - background: var(--accent-success); + background: var(--success); border-radius: 50%; - box-shadow: 0 0 10px var(--accent-success); + box-shadow: var(--shadow-sm); animation: pulse-dot-active 2s infinite; } .status-badge.disabled { - background: rgba(239, 68, 68, 0.1); - color: #ef4444; - border: 1px solid rgba(239, 68, 68, 0.2); + background: var(--danger-soft); + color: var(--danger-text); + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); } .status-badge.disabled::before { content: ''; width: 6px; height: 6px; - background: #ef4444; + background: var(--danger); border-radius: 50%; opacity: 0.6; } @@ -4583,14 +4715,14 @@ select.form-input option:checked { } .user-online-dot.online { - background: #10b981; - box-shadow: 0 0 6px #10b981, 0 0 10px rgba(16, 185, 129, 0.4); + background: var(--success); + box-shadow: var(--shadow-sm); animation: livePulse 2s infinite; } .user-online-dot.offline { - background: #ef4444; - box-shadow: 0 0 5px rgba(239, 68, 68, 0.35); + background: var(--danger); + box-shadow: var(--shadow-sm); opacity: 0.7; } @@ -4602,7 +4734,7 @@ select.form-input option:checked { /* 密码列样式 */ .col-password { font-family: inherit; - color: var(--text-secondary); + color: var(--text-2); display: flex; align-items: center; gap: 0.5rem; @@ -4620,11 +4752,11 @@ select.form-input option:checked { .btn-icon { width: 32px; height: 32px; - background: rgba(255, 255, 255, 0.05); - border: 1px solid var(--glass-border); - color: var(--text-secondary); + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + color: var(--text-2); cursor: pointer; - border-radius: 8px; + border-radius: var(--radius-md); transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); display: flex; align-items: center; @@ -4632,22 +4764,22 @@ select.form-input option:checked { } .btn-icon:hover { - color: var(--accent-primary); - background: rgba(59, 130, 246, 0.1); - border-color: rgba(59, 130, 246, 0.2); + color: var(--accent-text); + background: var(--info-soft); + border-color: color-mix(in srgb, var(--info) 35%, var(--border)); transform: translateY(-1px); } /* 批量删除按钮样式已在 index.html 中定义为 btn-danger,这里确保样式存在 */ .btn-danger { - background: rgba(239, 68, 68, 0.1); - color: var(--accent-error); - border: 1px solid rgba(239, 68, 68, 0.2); + background: var(--danger-soft); + color: var(--danger-text); + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); white-space: nowrap; } .btn-danger:hover { - background: rgba(239, 68, 68, 0.2); + background: var(--danger-soft); } /* =============== 模态框底部按钮布局修复 =============== */ @@ -4660,22 +4792,22 @@ input:-webkit-autofill, input:-webkit-autofill:hover, input:-webkit-autofill:focus, input:-webkit-autofill:active { - -webkit-box-shadow: 0 0 0 1000px #1a1f3a inset !important; + -webkit-box-shadow: 0 0 0 1000px var(--field-bg) inset; /* 增加阴影大小以确保覆盖 */ - -webkit-text-fill-color: #e2e8f0 !important; + -webkit-text-fill-color: var(--text); transition: background-color 5000s ease-in-out 0s; - caret-color: #e2e8f0; + caret-color: var(--text); /* 确保光标颜色可见 */ } /* Input focus effect consolidated */ .form-input:focus, .login-box input:focus { - background: rgba(255, 255, 255, 0.1); - color: #fff; + background: var(--field-bg); + color: var(--text); outline: none; - border-color: var(--accent-primary); - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); + border-color: var(--accent); + box-shadow: var(--field-focus-shadow); } /* =============== 用户列表布局修复 =============== */ @@ -4714,12 +4846,12 @@ input:-webkit-autofill:active { width: 32px; height: 32px; border-radius: 50%; - background: linear-gradient(135deg, var(--accent-primary), var(--accent-secondary)); + background: var(--accent); display: flex; align-items: center; justify-content: center; font-weight: 600; - color: white; + color: var(--text-on-accent); flex-shrink: 0; /* 防止头像被压缩 */ } @@ -4732,16 +4864,16 @@ input:-webkit-autofill:active { display: flex; align-items: center; justify-content: center; - border-radius: 8px; + border-radius: var(--radius-md); border: none; cursor: pointer; - background: rgba(239, 68, 68, 0.1); - color: var(--accent-error); + background: var(--danger-soft); + color: var(--danger-text); transition: all 0.2s; } .col-actions .btn-delete:hover { - background: rgba(239, 68, 68, 0.2); + background: var(--danger-soft); } .col-actions .btn-delete svg { @@ -4751,10 +4883,10 @@ input:-webkit-autofill:active { /* =============== 快照管理 =============== */ .snapshots-container { - border-radius: 16px; + border-radius: var(--radius-lg); background: transparent; border: none; - box-shadow: none; + box-shadow: var(--shadow-sm); } .snapshots-table-header { @@ -4763,12 +4895,12 @@ input:-webkit-autofill:active { gap: 1.25rem; padding: 0.85rem 1.4rem; margin: 0 0.5rem 0.65rem; - background: rgba(255, 255, 255, 0.025); - border: 1px solid rgba(255, 255, 255, 0.06); - border-radius: 12px; + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius-lg); font-weight: 700; font-size: 0.72rem; - color: var(--text-secondary); + color: var(--text-2); text-transform: uppercase; letter-spacing: 0.08em; } @@ -4779,50 +4911,63 @@ input:-webkit-autofill:active { gap: 1.25rem; padding: 0.85rem 1.4rem; margin: 0 0.5rem 0.5rem; - background: var(--glass-bg); - border: 1px solid var(--glass-border); - border-radius: 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); align-items: center; transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); box-shadow: var(--shadow-sm); } .snapshot-row:hover { - background: rgba(255, 255, 255, 0.05); + background: var(--surface-hover); transform: translateY(-2px); - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.35); - border-color: rgba(59, 130, 246, 0.3); + box-shadow: var(--shadow-sm); + border-color: color-mix(in srgb, var(--info) 35%, var(--border)); } .snapshot-row:last-child { - border-bottom: 1px solid var(--glass-border); + border-bottom: 1px solid var(--border); } .col-time { font-size: 0.88rem; - color: var(--text-primary); + color: var(--text); font-weight: 500; } .col-id { font-family: 'JetBrains Mono', Monaco, monospace; font-size: 0.8rem; - color: var(--text-secondary); - background: rgba(255, 255, 255, 0.04); + color: var(--text-2); + background: color-mix(in srgb, var(--text) 5%, transparent); padding: 0.25rem 0.5rem; - border-radius: 6px; - border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: var(--radius-sm); + border: 1px solid var(--border); + display: flex; + flex-wrap: wrap; /* 窄视口下让类型/来源标签换行,而不是被 overflow 裁掉 */ + align-items: center; + gap: 0.4rem; + min-width: 0; + overflow: hidden; +} + +/* ID 文本单独截断,避免挤掉后面的类型标签 */ +.col-id .col-id-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - display: inline-block; - max-width: fit-content; + min-width: 0; +} + +.col-id .kind-tag { + flex-shrink: 0; } .col-size { font-family: 'JetBrains Mono', Monaco, monospace; font-size: 0.82rem; - color: #94a3b8; + color: var(--text-2); font-weight: 500; } @@ -4839,7 +4984,7 @@ input:-webkit-autofill:active { padding: 0.45rem 0.8rem; width: auto; height: auto; - border-radius: 8px; + border-radius: var(--radius-md); border: 1px solid transparent; font-size: 0.8rem; font-weight: 500; @@ -4852,38 +4997,38 @@ input:-webkit-autofill:active { } .btn-download { - background: rgba(59, 130, 246, 0.12); - border-color: rgba(59, 130, 246, 0.25); - color: #60a5fa; + background: var(--info-soft); + border-color: color-mix(in srgb, var(--info) 35%, var(--border)); + color: var(--info-text); } .btn-download:hover { - background: rgba(59, 130, 246, 0.22); - border-color: rgba(59, 130, 246, 0.4); + background: var(--info-soft); + border-color: color-mix(in srgb, var(--info) 35%, var(--border)); transform: translateY(-1px); } .btn-restore { - background: rgba(245, 158, 11, 0.12); - border-color: rgba(245, 158, 11, 0.25); - color: #fbbf24; + background: var(--warning-soft); + border-color: color-mix(in srgb, var(--warning) 35%, var(--border)); + color: var(--warning-text); } .btn-restore:hover { - background: rgba(245, 158, 11, 0.22); - border-color: rgba(245, 158, 11, 0.4); + background: var(--warning-soft); + border-color: color-mix(in srgb, var(--warning) 35%, var(--border)); transform: translateY(-1px); } .snapshot-actions .btn-delete { - background: rgba(239, 68, 68, 0.12); - border-color: rgba(239, 68, 68, 0.25); - color: #f87171; + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); + color: var(--danger-text); } .snapshot-actions .btn-delete:hover { - background: rgba(239, 68, 68, 0.22); - border-color: rgba(239, 68, 68, 0.4); + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); transform: translateY(-1px); } @@ -4905,41 +5050,47 @@ input:-webkit-autofill:active { /* 隐藏表头,改为卡片式布局 */ .snapshots-table-header { - display: none !important; + display: none; } /* 快照行改为垂直布局 */ .snapshot-row { - display: flex !important; + display: flex; flex-direction: column; - align-items: stretch !important; + align-items: stretch; gap: 0.75rem; - padding: 1rem !important; - height: auto !important; + padding: 1rem; + height: auto; } /* 时间显示优化 */ .col-time { font-size: 1rem; font-weight: 600; - color: var(--text-primary); + color: var(--text); text-align: left; } /* 快照ID显示优化 */ .col-id { - display: block !important; + display: flex; + flex-wrap: wrap; font-family: monospace; font-size: 0.85rem; - color: var(--text-secondary); - word-break: break-all; - white-space: normal; + color: var(--text-2); margin-top: -0.25rem; } + .col-id .col-id-text { + overflow: visible; + text-overflow: unset; + white-space: normal; + word-break: break-all; + } + /* 操作按钮区域:使用网格布局,一行三个按钮 */ .snapshot-actions { - display: grid !important; + display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; width: 100%; @@ -4950,15 +5101,15 @@ input:-webkit-autofill:active { .snapshot-actions .btn-delete { width: 100%; justify-content: center; - padding: 0.6rem 0.2rem !important; - font-size: 0.8rem !important; + padding: 0.6rem 0.2rem; + font-size: 0.8rem; /* 如果希望图标和文字垂直排列(图标在上),取消下面这行的注释 */ /* flex-direction: column; gap: 0.25rem !important; */ } /* 确保删除按钮显示出来 */ .snapshot-actions .btn-delete { - display: flex !important; + display: flex; } } @@ -4974,7 +5125,7 @@ input:-webkit-autofill:active { gap: 1rem; align-items: center; padding: 0.75rem 1.5rem; - border-bottom: 1px solid var(--glass-border); + border-bottom: 1px solid var(--border); transition: background 0.2s ease; } @@ -4998,11 +5149,11 @@ input:-webkit-autofill:active { } .song-row:hover { - background: rgba(255, 255, 255, 0.03); + background: var(--surface-hover); } .song-col-index { - color: var(--text-muted); + color: var(--text-3); font-size: 0.875rem; text-align: center; } @@ -5011,7 +5162,7 @@ input:-webkit-autofill:active { .song-col-album, .song-col-playlist, .song-col-source { - color: var(--text-secondary); + color: var(--text-2); font-size: 0.875rem; overflow: hidden; text-overflow: ellipsis; @@ -5026,10 +5177,10 @@ input:-webkit-autofill:active { .btn-delete-song { width: 32px; height: 32px; - border-radius: 6px; + border-radius: var(--radius-sm); border: none; - background: rgba(239, 68, 68, 0.1); - color: var(--accent-error); + background: var(--danger-soft); + color: var(--danger-text); cursor: pointer; display: flex; align-items: center; @@ -5038,7 +5189,7 @@ input:-webkit-autofill:active { } .btn-delete-song:hover { - background: rgba(239, 68, 68, 0.2); + background: var(--danger-soft); } .btn-delete-song svg { @@ -5057,8 +5208,8 @@ input:-webkit-autofill:active { .song-cover { width: 48px; height: 48px; - border-radius: 6px; - background-color: rgba(255, 255, 255, 0.05); + border-radius: var(--radius-sm); + background-color: color-mix(in srgb, var(--text) 5%, transparent); /* 占位背景色 */ flex-shrink: 0; object-fit: cover; @@ -5082,7 +5233,7 @@ input:-webkit-autofill:active { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - color: var(--text-primary); + color: var(--text); } .song-meta-tags { @@ -5096,7 +5247,7 @@ input:-webkit-autofill:active { .song-singer-mobile { display: none; font-size: 0.78rem; - color: var(--text-secondary); + color: var(--text-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -5106,7 +5257,7 @@ input:-webkit-autofill:active { .tag { font-size: 0.7rem; padding: 0.1rem 0.35rem; - border-radius: 4px; + border-radius: var(--radius-sm); font-weight: 500; line-height: normal; display: inline-block; @@ -5115,31 +5266,31 @@ input:-webkit-autofill:active { /* 音质标签 */ .tag-quality { - background: rgba(255, 255, 255, 0.05); - color: var(--text-secondary); - border-color: rgba(255, 255, 255, 0.1); + background: color-mix(in srgb, var(--text) 5%, transparent); + color: var(--text-2); + border-color: var(--border); } .tag-quality.high { /* 320k */ - color: var(--accent-success); - background: rgba(16, 185, 129, 0.1); - border-color: rgba(16, 185, 129, 0.2); + color: var(--success-text); + background: var(--success-soft); + border-color: color-mix(in srgb, var(--success) 35%, var(--border)); } .tag-quality.lossless { /* flac, flac24bit */ - color: var(--accent-warning); - background: rgba(245, 158, 11, 0.1); - border-color: rgba(245, 158, 11, 0.2); + color: var(--warning-text); + background: var(--warning-soft); + border-color: color-mix(in srgb, var(--warning) 35%, var(--border)); } .tag-quality.hr { /* flac24bit / Hi-Res */ - color: #f43f5e; + color: var(--danger-text); /* Rose */ - background: rgba(244, 63, 94, 0.1); - border-color: rgba(244, 63, 94, 0.2); + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); } /* 来源标签 */ @@ -5149,53 +5300,53 @@ input:-webkit-autofill:active { .tag-source.wy { /* 网易云 */ - color: #ef4444; + color: var(--danger-text); /* Red */ - background: rgba(239, 68, 68, 0.1); - border-color: rgba(239, 68, 68, 0.2); + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); } .tag-source.kw { /* 酷我 */ - color: #eab308; + color: var(--warning-text); /* Yellow */ - background: rgba(234, 179, 8, 0.1); - border-color: rgba(234, 179, 8, 0.2); + background: var(--warning-soft); + border-color: color-mix(in srgb, var(--warning) 35%, var(--border)); } .tag-source.kg { /* 酷狗 */ - color: #3b82f6; + color: var(--info-text); /* Blue */ - background: rgba(59, 130, 246, 0.1); - border-color: rgba(59, 130, 246, 0.2); + background: var(--info-soft); + border-color: color-mix(in srgb, var(--info) 35%, var(--border)); } .tag-source.tx { /* QQ音乐 */ - color: #22c55e; + color: var(--success-text); /* Green */ - background: rgba(34, 197, 94, 0.1); - border-color: rgba(34, 197, 94, 0.2); + background: var(--success-soft); + border-color: color-mix(in srgb, var(--success) 35%, var(--border)); } .tag-source.mg { /* 咪咕 */ - color: #ec4899; + color: var(--danger-text); /* Pink */ - background: rgba(236, 72, 153, 0.1); - border-color: rgba(236, 72, 153, 0.2); + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); } .tag-source.local { /* 本地 */ - color: var(--text-secondary); - background: rgba(255, 255, 255, 0.1); + color: var(--text-2); + background: color-mix(in srgb, var(--text) 5%, transparent); } /* 时长标签 */ .tag-interval { - color: var(--text-secondary); + color: var(--text-2); font-family: 'JetBrains Mono', Monaco, monospace; font-size: 0.75rem; opacity: 0.6; @@ -5208,22 +5359,22 @@ input:-webkit-autofill:active { /* --- Tablet (1200px) --- */ @media (max-width: 1200px) { .top-bar-actions { - gap: 0.5rem !important; + gap: 0.5rem; } .top-bar-actions a span { - display: none !important; + display: none; } .top-bar-actions a svg { - margin: 0 !important; + margin: 0; } .top-bar-actions a { - padding: 0.5rem !important; + padding: 0.5rem; width: 38px; height: 38px; - border-radius: 10px; + border-radius: var(--radius-md); } .stats-grid { @@ -5250,14 +5401,14 @@ input:-webkit-autofill:active { } #restart-server-btn span { - display: none !important; + display: none; } #restart-server-btn { - padding: 0.5rem !important; + padding: 0.5rem; width: 38px; height: 38px; - border-radius: 10px; + border-radius: var(--radius-md); } .songs-table-header, @@ -5274,19 +5425,19 @@ input:-webkit-autofill:active { /* --- Mobile (768px-900px) --- */ @media (max-width: 900px) { #install-pwa-btn span { - display: none !important; + display: none; } #install-pwa-btn { - padding: 0.5rem !important; + padding: 0.5rem; width: 38px; height: 38px; - border-radius: 10px; - margin-right: 0.5rem !important; + border-radius: var(--radius-md); + margin-right: 0.5rem; } #server-status { - display: none !important; + display: none; } .status-indicator { @@ -5295,8 +5446,8 @@ input:-webkit-autofill:active { display: flex; align-items: center; justify-content: center; - padding: 0 !important; - border-radius: 10px; + padding: 0; + border-radius: var(--radius-md); } } @@ -5307,9 +5458,9 @@ input:-webkit-autofill:active { align-items: center; gap: 1.5rem; padding: 1.25rem 1.5rem; - background: var(--glass-bg); - border: 1px solid var(--glass-border); - border-radius: 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); margin-top: 1.5rem; grid-column: 1 / -1; box-sizing: border-box; @@ -5331,7 +5482,7 @@ input:-webkit-autofill:active { display: flex; align-items: center; gap: 0.75rem; - color: var(--text-secondary); + color: var(--text-2); font-size: 0.875rem; line-height: 1.5; } @@ -5355,10 +5506,7 @@ input:-webkit-autofill:active { } .welcome-section .greeting-text { - background: linear-gradient(135deg, var(--text-primary), var(--text-secondary)); - -webkit-background-clip: text; - background-clip: text; - -webkit-text-fill-color: transparent; + color: var(--text); } @@ -5373,7 +5521,7 @@ input:-webkit-autofill:active { .stat-dual-divider { width: 1px; height: 40px; - background: rgba(255, 255, 255, 0.1); + background: color-mix(in srgb, var(--text) 5%, transparent); } .stat-value-pair { @@ -5391,15 +5539,15 @@ input:-webkit-autofill:active { .setup-guide-card { padding: 3rem 2rem; text-align: center; - border: 1px dashed var(--glass-border); - border-radius: 20px; + border: 1px dashed var(--border); + border-radius: var(--radius-lg); margin: 1.5rem 0; } .sync-empty-tip { padding: 2rem; text-align: center; - color: var(--text-secondary); + color: var(--text-2); opacity: 0.6; } @@ -5413,9 +5561,9 @@ input:-webkit-autofill:active { } .main-content { - padding: 0 1rem !important; /* 唯一真实的左右边距 */ - margin-left: 0 !important; - width: 100% !important; + padding: 0 1rem; /* 唯一真实的左右边距 */ + margin-left: 0; + width: 100%; height: 100vh; overflow-y: hidden; display: flex; @@ -5429,13 +5577,13 @@ input:-webkit-autofill:active { margin: 1rem 0; /* 仅上下外边距,左右外边距由 main-content padding 提供 */ width: 100%; padding: 1.25rem 1.5rem; - background: rgba(10, 14, 39, 0.9); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); + background: var(--surface); + backdrop-filter: none; + -webkit-backdrop-filter: none; flex-direction: column; align-items: flex-start; gap: 1rem; - border-radius: 16px; + border-radius: var(--radius-lg); } .top-bar h2 { @@ -5444,7 +5592,7 @@ input:-webkit-autofill:active { } .mobile-header-row #console-version { - display: inline-block !important; /* 在手机端强制显示版本号 */ + display: inline-block; /* 在手机端强制显示版本号 */ margin-left: 0.5rem; } @@ -5471,7 +5619,7 @@ input:-webkit-autofill:active { flex: 1; overflow-y: auto; padding: 0; /* 移除内边距,依靠 main-content 的 padding */ - padding-bottom: 10rem !important; + padding-bottom: 10rem; -webkit-overflow-scrolling: touch; } @@ -5486,18 +5634,18 @@ input:-webkit-autofill:active { padding: 1.25rem 0.75rem; gap: 0.75rem; z-index: 1000; - background: rgba(18, 23, 41, 0.95); - backdrop-filter: blur(30px); + background: var(--surface); + backdrop-filter: none; transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); - border-right: 1px solid var(--glass-border); + border-right: 1px solid var(--border); border-radius: 0 16px 16px 0; transform: none; - box-shadow: none; + box-shadow: var(--shadow-sm); } .sidebar.active { transform: translateX(280px); - box-shadow: 10px 0 30px rgba(0, 0, 0, 0.5); + box-shadow: var(--shadow-sm); } .mobile-sidebar-overlay { @@ -5506,23 +5654,23 @@ input:-webkit-autofill:active { left: 0; width: 100%; height: 100%; - background: rgba(0, 0, 0, 0.6); + background: var(--overlay); z-index: 999; opacity: 0; pointer-events: none; transition: opacity 0.3s ease; - backdrop-filter: blur(4px); + backdrop-filter: none; display: none; } .mobile-sidebar-overlay.active { - display: block !important; + display: block; opacity: 1; pointer-events: auto; } - .mobile-only { - display: flex !important; + #mobile-menu-btn.mobile-only { + display: flex; } /* 移动端侧边栏导航压缩 */ @@ -5546,80 +5694,80 @@ input:-webkit-autofill:active { /* Selection & View Header */ .view-header { - flex-direction: column !important; - align-items: stretch !important; - gap: 1rem !important; - padding-bottom: 1.25rem !important; - margin-bottom: 1.5rem !important; + flex-direction: column; + align-items: stretch; + gap: 1rem; + padding-bottom: 1.25rem; + margin-bottom: 1.5rem; } .header-main-actions, .header-side-actions { - width: 100% !important; - display: flex !important; - gap: 0.5rem !important; + width: 100%; + display: flex; + gap: 0.5rem; } .search-box { - flex: 1 !important; - min-width: 0 !important; - max-width: none !important; + flex: 1; + min-width: 0; + max-width: none; } .view-header button, .custom-user-selector { flex: 1; - font-size: 0.85rem !important; + font-size: 0.85rem; } /* 顶部栏图标精简 - 移除手机端隐藏 GitHub 图标的限制 */ /* .top-bar-actions a:last-child { - display: none !important; + display: none !important; } */ /* 配置中心强制单列 (修复卡片大小不一) */ .config-panels-layout { - display: flex !important; - flex-direction: column !important; - gap: 1.25rem !important; - width: 100% !important; - padding: 0 !important; /* 确保没有额外内边距 */ + display: flex; + flex-direction: column; + gap: 1.25rem; + width: 100%; + padding: 0; /* 确保没有额外内边距 */ } .config-card { - width: 100% !important; - margin: 0 0 1.25rem !important; /* 统一卡片间距 */ + width: 100%; + margin: 0 0 1.25rem; /* 统一卡片间距 */ } .config-field-row { - flex-direction: column !important; - align-items: stretch !important; - gap: 0.75rem !important; + flex-direction: column; + align-items: stretch; + gap: 0.75rem; } .config-input-group { - width: 100% !important; + width: 100%; } /* 监控网格优化 */ .monitor-grid { - grid-template-columns: 1fr !important; + grid-template-columns: 1fr; gap: 1rem; } /* 用户/数据选择卡片优化 (修复图2/4) */ .user-selection-grid { - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)) !important; - gap: 0.75rem !important; - padding-top: 0.5rem !important; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 0.75rem; + padding-top: 0.5rem; } .user-select-card { - padding: 1.25rem 0.75rem !important; + padding: 1.25rem 0.75rem; } .user-select-card .avatar { - width: 48px !important; - height: 48px !important; - font-size: 1.2rem !important; + width: 48px; + height: 48px; + font-size: 1.2rem; } /* 手机端吸底配置栏 */ @@ -5629,35 +5777,35 @@ input:-webkit-autofill:active { left: 0; right: 0; margin: 0; - padding: 1.25rem 1rem !important; - background: rgba(15, 23, 42, 0.98); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); + padding: 1.25rem 1rem; + background: var(--surface); + backdrop-filter: none; + -webkit-backdrop-filter: none; border-radius: 24px 24px 0 0; - border-top: 1px solid rgba(255, 255, 255, 0.1); - display: flex !important; - flex-direction: column !important; /* 改为垂直布局 */ - gap: 1rem !important; + border-top: 1px solid var(--border); + display: flex; + flex-direction: column; /* 改为垂直布局 */ + gap: 1rem; z-index: 900; - box-shadow: 0 -10px 30px rgba(0, 0, 0, 0.5); + box-shadow: var(--shadow-sm); } .config-actions-btns { - width: 100% !important; - display: flex !important; - gap: 0.75rem !important; + width: 100%; + display: flex; + gap: 0.75rem; } .config-actions-btns button { - flex: 1 !important; - justify-content: center !important; - padding: 0.8rem 0.5rem !important; - font-size: 0.95rem !important; + flex: 1; + justify-content: center; + padding: 0.8rem 0.5rem; + font-size: 0.95rem; } .config-actions-hint { - font-size: 0.85rem !important; - align-items: flex-start !important; + font-size: 0.85rem; + align-items: flex-start; gap: 8px; line-height: 1.4; } @@ -5668,216 +5816,216 @@ input:-webkit-autofill:active { /* 仪表盘统计卡片修复 (解决图2文字挤压) */ .stats-grid { - grid-template-columns: 1fr !important; - gap: 1.25rem !important; + grid-template-columns: 1fr; + gap: 1.25rem; } .stat-card-dual { - flex-direction: row !important; /* 手机端恢复左右并排样式 */ - padding: 1rem 0.75rem !important; - gap: 0.5rem !important; - justify-content: space-between !important; + flex-direction: row; /* 手机端恢复左右并排样式 */ + padding: 1rem 0.75rem; + gap: 0.5rem; + justify-content: space-between; } .stat-dual-section { flex: 1; - justify-content: center !important; - gap: 0.5rem !important; - width: auto !important; + justify-content: center; + gap: 0.5rem; + width: auto; } .stat-dual-section .stat-icon { - width: 32px !important; - height: 32px !important; + width: 32px; + height: 32px; } .stat-dual-section .stat-icon svg { - width: 18px !important; - height: 18px !important; + width: 18px; + height: 18px; } .stat-dual-divider { - display: block !important; /* 恢复分割线 */ - height: 30px !important; + display: block; /* 恢复分割线 */ + height: 30px; opacity: 0.1; } .stat-info h3 { - white-space: nowrap !important; - font-size: 0.9rem !important; + white-space: nowrap; + font-size: 0.9rem; } /* 用户管理卡片精修 — 紧凑横向布局 */ .user-row { display: grid; - grid-template: - "check info status actions" auto - "check pwd pwd actions" auto / 32px 1fr auto auto !important; - gap: 0.4rem 0.6rem !important; - padding: 0.875rem 1rem !important; - margin: 0 0.75rem 0.75rem !important; + grid-template: + "check info status actions" auto + "check pwd pwd actions" auto / 32px 1fr auto auto; + gap: 0.4rem 0.6rem; + padding: 0.875rem 1rem; + margin: 0 0.75rem 0.75rem; align-items: center; position: relative; overflow: hidden; } .user-row .col-checkbox { - grid-area: check !important; - position: static !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; + grid-area: check; + position: static; + display: flex; + align-items: center; + justify-content: center; align-self: center; } .user-row .col-name { - grid-area: info !important; - display: flex !important; - flex-direction: row !important; - align-items: center !important; - gap: 0.6rem !important; - margin-top: 0 !important; + grid-area: info; + display: flex; + flex-direction: row; + align-items: center; + gap: 0.6rem; + margin-top: 0; min-width: 0; } .user-avatar { - width: 36px !important; - height: 36px !important; - font-size: 1rem !important; + width: 36px; + height: 36px; + font-size: 1rem; flex-shrink: 0; border: none; - box-shadow: none; + box-shadow: var(--shadow-sm); } .user-row .col-password { - grid-area: pwd !important; - display: flex !important; - align-items: center !important; - justify-content: flex-start !important; - gap: 0.5rem !important; - background: transparent !important; - padding: 0 !important; - border-radius: 0 !important; - margin: 0 !important; + grid-area: pwd; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 0.5rem; + background: transparent; + padding: 0; + border-radius: 0; + margin: 0; font-size: 0.8rem; } .password-text { - font-family: 'JetBrains Mono', monospace !important; - letter-spacing: 0.1em !important; - font-size: 0.8rem !important; - color: var(--text-secondary) !important; + font-family: 'JetBrains Mono', monospace; + letter-spacing: 0.1em; + font-size: 0.8rem; + color: var(--text-2); } .user-row .col-status { - grid-area: status !important; - display: flex !important; - align-items: center !important; - justify-content: flex-end !important; + grid-area: status; + display: flex; + align-items: center; + justify-content: flex-end; } .user-row .col-status .status-badge { - padding: 0.22rem 0.55rem !important; - font-size: 0.7rem !important; - border-radius: 8px !important; - gap: 0.35rem !important; - white-space: nowrap !important; + padding: 0.22rem 0.55rem; + font-size: 0.7rem; + border-radius: var(--radius-md); + gap: 0.35rem; + white-space: nowrap; } .user-row .col-actions { - grid-area: actions !important; - display: flex !important; - flex-direction: column !important; - justify-content: center !important; - align-items: center !important; - gap: 0.4rem !important; - border-top: none !important; - padding-top: 0 !important; - margin-top: 0 !important; - width: auto !important; + grid-area: actions; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 0.4rem; + border-top: none; + padding-top: 0; + margin-top: 0; + width: auto; align-self: center; } .btn-delete { - width: 36px !important; - height: 36px !important; - border-radius: 10px !important; - background: rgba(239, 68, 68, 0.1) !important; - border: 1px solid rgba(239, 68, 68, 0.2) !important; - display: flex !important; - align-items: center !important; - justify-content: center !important; - padding: 0 !important; + width: 36px; + height: 36px; + border-radius: var(--radius-md); + background: var(--danger-soft); + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); + display: flex; + align-items: center; + justify-content: center; + padding: 0; } .btn-icon { - width: 30px !important; - height: 30px !important; - border-radius: 8px !important; + width: 30px; + height: 30px; + border-radius: var(--radius-md); } /* Table Transformation to Cards */ .users-table-header, .snapshots-table-header, .songs-table-header { - display: none !important; + display: none; } .batch-actions { - flex-direction: row !important; - align-items: center !important; - padding: 0.6rem !important; - gap: 0.4rem !important; - background: rgba(255, 255, 255, 0.03); - border-radius: 12px; + flex-direction: row; + align-items: center; + padding: 0.6rem; + gap: 0.4rem; + background: color-mix(in srgb, var(--text) 5%, transparent); + border-radius: var(--radius-lg); margin-bottom: 1rem; overflow: hidden; } .batch-select-btns { - display: flex !important; - flex: 3 !important; - gap: 4px !important; + display: flex; + flex: 3; + gap: 4px; } .batch-select-btns button { flex: 1; - padding: 0.5rem 2px !important; - font-size: 0.75rem !important; + padding: 0.5rem 2px; + font-size: 0.75rem; white-space: nowrap; - border-radius: 6px !important; + border-radius: var(--radius-sm); } .btn-batch-delete { - flex: 1.5 !important; + flex: 1.5; justify-content: center; - padding: 0.5rem 4px !important; - font-size: 0.75rem !important; - background: rgba(239, 68, 68, 0.15) !important; - border-radius: 6px !important; - margin: 0 !important; + padding: 0.5rem 4px; + font-size: 0.75rem; + background: var(--danger-soft); + border-radius: var(--radius-sm); + margin: 0; } /* ===== 手机端歌曲卡片布局 ===== */ .song-row { display: flex; - flex-direction: row !important; - align-items: stretch !important; - gap: 0 !important; - padding: 0.85rem 0.75rem !important; - background: rgba(255, 255, 255, 0.02); - border-radius: 12px; + flex-direction: row; + align-items: stretch; + gap: 0; + padding: 0.85rem 0.75rem; + background: color-mix(in srgb, var(--text) 5%, transparent); + border-radius: var(--radius-lg); margin-bottom: 0.75rem; - border: 1px solid var(--glass-border); + border: 1px solid var(--border); position: static; /* 桌面端的 grid 在这里全部重置 */ - grid-template-columns: unset !important; - grid-template-areas: unset !important; + grid-template-columns: unset; + grid-template-areas: unset; } /* checkbox 区域:左侧竖向居中 */ .song-col-checkbox { - display: flex !important; - align-items: center !important; - justify-content: center !important; + display: flex; + align-items: center; + justify-content: center; flex-shrink: 0; width: 28px; margin-right: 8px; @@ -5885,31 +6033,31 @@ input:-webkit-autofill:active { /* 歌曲名列:内部 flex(cover + info),占主体空间 */ .song-col-name { - flex: 1 !important; - display: flex !important; - align-items: center !important; - gap: 0.6rem !important; + flex: 1; + display: flex; + align-items: center; + gap: 0.6rem; overflow: hidden; - padding: 0 !important; + padding: 0; min-width: 0; } /* 封面图:正常流,不再绝对定位 */ .song-col-name .song-cover { - position: static !important; - transform: none !important; - width: 44px !important; - height: 44px !important; + position: static; + transform: none; + width: 44px; + height: 44px; flex-shrink: 0; - border-radius: 8px; + border-radius: var(--radius-md); } /* 歌曲信息容器:flex 列,占剩余宽度 */ .song-col-name .song-info-wrapper { - padding-left: 0 !important; - display: flex !important; - flex-direction: column !important; - gap: 3px !important; + padding-left: 0; + display: flex; + flex-direction: column; + gap: 3px; overflow: hidden; flex: 1; min-width: 0; @@ -5917,7 +6065,7 @@ input:-webkit-autofill:active { /* 歌手列:藏起来,歌手名由 song-singer-mobile 在 info-wrapper 内显示 */ .song-col-artist { - display: none !important; + display: none; } /* 歌手名:手机端改为在 info-wrapper 内显示 */ @@ -5927,24 +6075,24 @@ input:-webkit-autofill:active { /* 删除按钮列:右侧竖向居中 */ .song-col-actions { - display: flex !important; - align-items: center !important; - justify-content: flex-end !important; + display: flex; + align-items: center; + justify-content: flex-end; flex-shrink: 0; margin-left: 8px; - position: static !important; + position: static; } .song-col-index { - display: none !important; + display: none; } /* 删除按钮本体尺寸优化 */ .song-col-actions .btn-delete-song { - width: 34px !important; - height: 34px !important; - padding: 0 !important; - border-radius: 8px !important; + width: 34px; + height: 34px; + padding: 0; + border-radius: var(--radius-md); } } @@ -5958,23 +6106,23 @@ input:-webkit-autofill:active { padding: 0.42rem 0.9rem; font-size: 0.8rem; font-weight: 500; - color: #93c5fd; - background: rgba(59, 130, 246, 0.12); - border: 1px solid rgba(59, 130, 246, 0.28); - border-radius: 9999px; + color: var(--info-text); + background: var(--info-soft); + border: 1px solid color-mix(in srgb, var(--info) 35%, var(--border)); + border-radius: var(--radius-pill); cursor: pointer; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); white-space: nowrap; user-select: none; - backdrop-filter: blur(8px); + backdrop-filter: none; } .btn-help-guide:hover { - background: rgba(59, 130, 246, 0.22); - border-color: rgba(96, 165, 250, 0.55); - color: #bfdbfe; + background: var(--info-soft); + border-color: color-mix(in srgb, var(--info) 35%, var(--border)); + color: var(--info-text); transform: translateY(-1px); - box-shadow: 0 4px 14px rgba(59, 130, 246, 0.25); + box-shadow: var(--shadow-sm); } .btn-help-guide svg { @@ -5987,15 +6135,15 @@ input:-webkit-autofill:active { } .usage-modal-dialog { - max-width: 860px !important; - width: 94% !important; - max-height: 88vh !important; + max-width: 860px; + width: 94%; + max-height: 88vh; display: flex; flex-direction: column; - padding: 0 !important; - overflow: hidden !important; - border: 1px solid rgba(255, 255, 255, 0.14) !important; - box-shadow: 0 24px 60px rgba(0, 0, 0, 0.6), 0 0 40px rgba(59, 130, 246, 0.12) !important; + padding: 0; + overflow: hidden; + border: 1px solid var(--border); + box-shadow: var(--shadow-sm); } .usage-modal-header { @@ -6003,8 +6151,8 @@ input:-webkit-autofill:active { align-items: center; justify-content: space-between; padding: 1.25rem 1.5rem; - background: linear-gradient(135deg, rgba(30, 41, 59, 0.8), rgba(15, 23, 42, 0.95)); - border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: var(--surface); + border-bottom: 1px solid var(--border); } .usage-modal-body { @@ -6020,8 +6168,8 @@ input:-webkit-autofill:active { } .usage-modal-body::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.18); - border-radius: 4px; + background: color-mix(in srgb, var(--text) 5%, transparent); + border-radius: var(--radius-sm); } .usage-grid-two { @@ -6031,9 +6179,9 @@ input:-webkit-autofill:active { } .usage-card { - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius-lg); padding: 1.15rem; display: flex; flex-direction: column; @@ -6042,8 +6190,8 @@ input:-webkit-autofill:active { } .usage-card:hover { - background: rgba(255, 255, 255, 0.05); - border-color: rgba(255, 255, 255, 0.15); + background: var(--surface-hover); + border-color: var(--border); transform: translateY(-2px); } @@ -6057,7 +6205,7 @@ input:-webkit-autofill:active { .usage-card-title { font-size: 0.95rem; font-weight: 600; - color: var(--text-primary); + color: var(--text); display: flex; align-items: center; gap: 0.45rem; @@ -6066,62 +6214,62 @@ input:-webkit-autofill:active { .usage-tag { font-size: 0.7rem; padding: 0.15rem 0.5rem; - border-radius: 6px; + border-radius: var(--radius-sm); font-weight: 500; line-height: 1.2; } .usage-tag-blue { - background: rgba(59, 130, 246, 0.15); - color: #93c5fd; - border: 1px solid rgba(59, 130, 246, 0.3); + background: var(--info-soft); + color: var(--info-text); + border: 1px solid color-mix(in srgb, var(--info) 35%, var(--border)); } .usage-tag-purple { - background: rgba(168, 85, 247, 0.15); - color: #d8b4fe; - border: 1px solid rgba(168, 85, 247, 0.3); + background: var(--accent-soft); + color: var(--accent-text); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); } .usage-tag-emerald { - background: rgba(16, 185, 129, 0.15); - color: #6ee7b7; - border: 1px solid rgba(16, 185, 129, 0.3); + background: var(--success-soft); + color: var(--success-text); + border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); } .usage-tag-amber { - background: rgba(245, 158, 11, 0.15); - color: #fcd34d; - border: 1px solid rgba(245, 158, 11, 0.3); + background: var(--warning-soft); + color: var(--warning-text); + border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--border)); } .usage-scenario-item { - background: rgba(255, 255, 255, 0.025); - border-radius: 10px; + background: color-mix(in srgb, var(--text) 5%, transparent); + border-radius: var(--radius-md); padding: 0.9rem 1.1rem; - border: 1px solid rgba(255, 255, 255, 0.06); - border-left: 3px solid #3b82f6; + border: 1px solid var(--border); + border-left: 3px solid var(--info); display: flex; flex-direction: column; gap: 0.35rem; } .usage-scenario-item.border-purple { - border-left-color: #a855f7; + border-left-color: var(--accent); } .usage-scenario-item.border-amber { - border-left-color: #f59e0b; + border-left-color: var(--warning); } .usage-scenario-item.border-emerald { - border-left-color: #10b981; + border-left-color: var(--success); } .usage-scenario-title { font-size: 0.88rem; font-weight: 600; - color: var(--text-primary); + color: var(--text); display: flex; align-items: center; gap: 0.4rem; @@ -6129,7 +6277,7 @@ input:-webkit-autofill:active { .usage-scenario-desc { font-size: 0.8rem; - color: var(--text-secondary); + color: var(--text-2); line-height: 1.55; margin: 0; } @@ -6148,9 +6296,9 @@ input:-webkit-autofill:active { align-items: center; padding: 4px; gap: 4px; - background: rgba(15, 23, 42, 0.6); - border: 1px solid var(--glass-border); - border-radius: 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); } .backup-tab-btn { @@ -6159,9 +6307,9 @@ input:-webkit-autofill:active { gap: 8px; padding: 8px 16px; border: none; - border-radius: 8px; + border-radius: var(--radius-md); background: transparent; - color: var(--text-secondary); + color: var(--text-2); font-size: 0.875rem; font-weight: 500; cursor: pointer; @@ -6170,14 +6318,14 @@ input:-webkit-autofill:active { } .backup-tab-btn:hover { - color: var(--text-primary); - background: rgba(255, 255, 255, 0.05); + color: var(--text); + background: var(--surface-hover); } .backup-tab-btn.active { - color: #fff; - background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary) 100%); - box-shadow: 0 2px 8px rgba(59, 130, 246, 0.35); + color: var(--text-on-accent); + background: var(--accent); + box-shadow: var(--shadow-sm); } .backup-tab-dot { @@ -6185,13 +6333,13 @@ input:-webkit-autofill:active { width: 7px; height: 7px; border-radius: 50%; - background: #64748b; + background: var(--surface); transition: background-color 0.2s ease; } .backup-tab-dot.dot-active { - background: #10b981; - box-shadow: 0 0 6px rgba(16, 185, 129, 0.8); + background: var(--success); + box-shadow: var(--shadow-sm); } .backup-tab-pane { @@ -6229,7 +6377,7 @@ input:-webkit-autofill:active { .config-backup-meta-item .meta-label { font-size: 0.75rem; - color: var(--text-muted); + color: var(--text-3); font-weight: 500; text-transform: uppercase; letter-spacing: 0.04em; @@ -6238,7 +6386,7 @@ input:-webkit-autofill:active { .config-backup-meta-item .meta-value { font-size: 0.925rem; font-weight: 600; - color: var(--text-primary); + color: var(--text); display: flex; align-items: center; gap: 6px; @@ -6254,7 +6402,7 @@ input:-webkit-autofill:active { white-space: nowrap; font-family: monospace; font-size: 0.85rem; - color: #93c5fd; + color: var(--info-text); } .config-backup-actions-wrap { @@ -6268,32 +6416,32 @@ input:-webkit-autofill:active { align-items: center; gap: 4px; padding: 2px 8px; - border-radius: 6px; + border-radius: var(--radius-sm); font-size: 0.725rem; font-weight: 500; line-height: 1.2; } .backup-type-tag.tag-auto { - background: rgba(59, 130, 246, 0.15); - color: #93c5fd; - border: 1px solid rgba(59, 130, 246, 0.3); + background: var(--info-soft); + color: var(--info-text); + border: 1px solid color-mix(in srgb, var(--info) 35%, var(--border)); } .backup-type-tag.tag-manual { - background: rgba(168, 85, 247, 0.15); - color: #d8b4fe; - border: 1px solid rgba(168, 85, 247, 0.3); + background: var(--accent-soft); + color: var(--accent-text); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); } .badge-dot.dot-purple { - background: #a855f7; - box-shadow: 0 0 6px rgba(168, 85, 247, 0.6); + background: var(--accent); + box-shadow: var(--shadow-sm); } .badge-dot.dot-blue { - background: #3b82f6; - box-shadow: 0 0 6px rgba(59, 130, 246, 0.6); + background: var(--info); + box-shadow: var(--shadow-sm); } @media (max-width: 768px) { @@ -6335,11 +6483,11 @@ input:-webkit-autofill:active { } .backup-table-wrapper { - background: rgba(15, 23, 42, 0.45); - border: 1px solid var(--glass-border); - border-radius: 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); overflow: hidden; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + box-shadow: var(--shadow-sm); } .backup-policy-table { @@ -6349,15 +6497,15 @@ input:-webkit-autofill:active { } .backup-policy-table thead { - background: rgba(255, 255, 255, 0.03); + background: color-mix(in srgb, var(--text) 5%, transparent); } .backup-policy-table th { padding: 12px 18px; font-size: 0.8rem; font-weight: 600; - color: var(--text-secondary); - border-bottom: 1px solid var(--glass-border); + color: var(--text); + border-bottom: 1px solid var(--border); letter-spacing: 0.02em; } @@ -6378,11 +6526,11 @@ input:-webkit-autofill:active { } .backup-policy-table tbody tr:not(:last-child) td { - border-bottom: 1px solid rgba(255, 255, 255, 0.05); + border-bottom: 1px solid var(--border); } .backup-policy-table tbody tr:hover { - background: rgba(255, 255, 255, 0.035); + background: var(--surface-hover); } .backup-policy-table td { @@ -6393,14 +6541,14 @@ input:-webkit-autofill:active { .backup-type-title { font-size: 0.885rem; font-weight: 600; - color: var(--text-primary); + color: var(--text); line-height: 1.4; margin-bottom: 3px; } .backup-type-desc { font-size: 0.775rem; - color: var(--text-secondary); + color: var(--text-2); line-height: 1.5; } @@ -6414,19 +6562,19 @@ input:-webkit-autofill:active { align-items: center; gap: 10px; padding: 10px 14px; - background: rgba(56, 189, 248, 0.08); - border: 1px solid rgba(56, 189, 248, 0.18); - border-radius: 10px; + background: var(--info-soft); + border: 1px solid color-mix(in srgb, var(--info) 35%, var(--border)); + border-radius: var(--radius-md); font-size: 0.775rem; - color: #bae6fd; + color: var(--info-text); line-height: 1.55; } .backup-policy-banner code { - background: rgba(0, 0, 0, 0.3); + background: var(--surface-2); padding: 2px 6px; - border-radius: 4px; - color: #38bdf8; + border-radius: var(--radius-sm); + color: var(--info-text); font-family: inherit; font-size: 0.75rem; } @@ -6434,7 +6582,7 @@ input:-webkit-autofill:active { .backup-banner-icon { width: 16px; height: 16px; - color: #38bdf8; + color: var(--info-text); flex-shrink: 0; } @@ -6443,9 +6591,9 @@ input:-webkit-autofill:active { flex-direction: column; gap: 0.75rem; padding: 1rem 1.25rem; - background: rgba(255, 255, 255, 0.02); - border: 1px solid var(--glass-border); - border-radius: 12px; + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + border-radius: var(--radius-lg); } .backup-policy-actions { @@ -6469,9 +6617,9 @@ input:-webkit-autofill:active { gap: 8px; padding: 1.5rem; justify-content: center; - background: rgba(15, 23, 42, 0.3); - border: 1px dashed var(--glass-border); - border-radius: 12px; + background: var(--surface); + border: 1px dashed var(--border); + border-radius: var(--radius-lg); } @media (max-width: 640px) { @@ -6503,24 +6651,24 @@ input:-webkit-autofill:active { border-radius: 50%; cursor: pointer; user-select: none; - background: rgba(59, 130, 246, 0.18); - border: 1px solid rgba(59, 130, 246, 0.35); - color: #60a5fa; + background: var(--info-soft); + border: 1px solid color-mix(in srgb, var(--info) 35%, var(--border)); + color: var(--info-text); font-size: 10px; font-weight: 700; line-height: 1; vertical-align: middle; transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); + box-shadow: var(--shadow-sm); flex-shrink: 0; } .help-icon:hover { - background: var(--accent-primary, #6366f1); - border-color: var(--accent-primary, #6366f1); - color: #fff; + background: var(--accent); + border-color: var(--accent); + color: var(--text-on-accent); transform: scale(1.12); - box-shadow: 0 0 10px rgba(99, 102, 241, 0.4); + box-shadow: var(--shadow-sm); } .help-pop { @@ -6528,16 +6676,16 @@ input:-webkit-autofill:active { z-index: 9999; max-width: 320px; width: max-content; - background: rgba(15, 23, 42, 0.94); - backdrop-filter: blur(14px); - -webkit-backdrop-filter: blur(14px); - color: #f1f5f9; - border: 1px solid rgba(255, 255, 255, 0.14); - border-radius: 10px; + background: var(--surface); + backdrop-filter: none; + -webkit-backdrop-filter: none; + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); padding: 0.65rem 0.85rem; font-size: 0.8rem; line-height: 1.55; - box-shadow: 0 14px 36px rgba(0, 0, 0, 0.55), 0 0 0 1px rgba(255, 255, 255, 0.04); + box-shadow: var(--shadow-sm); white-space: normal; animation: helpPopFadeIn 0.15s ease-out; } @@ -6548,181 +6696,555 @@ input:-webkit-autofill:active { } /* ========================================================= - OpenList 操作底栏与用户卡片 + OpenList 现代化 UI 组件(标签栏 / 复选框 / 按钮 / 卡片 / 表格) ========================================================= */ -.openlist-actions-bar { - display: flex; - align-items: center; - justify-content: flex-start; - flex-wrap: wrap; - gap: 0.85rem; - padding: 0.85rem 1.25rem; - margin-top: 1.25rem; - border-radius: 14px; - background: rgba(15, 23, 42, 0.45); - border: 1px solid var(--glass-border); -} -.openlist-actions-bar .actions-group { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 0.65rem; +/* 顶部胶囊分段导航栏 */ +.openlist-tabs-bar.backup-segmented-tabs { + align-self: flex-start; + width: auto; + max-width: 100%; + padding: 6px; + gap: 6px; + border-radius: var(--radius-lg); + background: var(--surface); + border: 1px solid var(--border); + box-shadow: 0 8px 24px color-mix(in srgb, var(--text) 22%, transparent), inset 0 1px 0 color-mix(in srgb, var(--text) 5%, transparent); + overflow-x: auto; + scrollbar-width: none; } -.openlist-actions-bar .actions-divider-v { - width: 1px; - height: 24px; - background: var(--glass-border); - margin: 0 0.35rem; +.openlist-tabs-bar.backup-segmented-tabs::-webkit-scrollbar { + display: none; } -.openlist-actions-bar button { - width: auto; - display: inline-flex; - align-items: center; - gap: 7px; - padding: 0.65rem 1.15rem; - font-size: 0.86rem; - font-weight: 500; - border-radius: 10px; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +.openlist-tabs-bar .backup-tab-btn { + padding: 0.55rem 1.05rem; + border-radius: var(--radius-lg); + font-size: 0.85rem; + font-weight: 600; + gap: 8px; + white-space: nowrap; + color: var(--text-2); + transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1); } -.openlist-actions-bar button svg { - flex-shrink: 0; +.openlist-tabs-bar .backup-tab-btn svg { + opacity: 0.78; + transition: transform 0.2s ease, opacity 0.2s ease; } -.openlist-actions-bar .btn-sync-highlight { - background: rgba(14, 165, 233, 0.12); - border-color: rgba(14, 165, 233, 0.35); - color: #38bdf8; +.openlist-tabs-bar .backup-tab-btn:hover:not(.active) { + color: var(--text); + background: var(--surface-hover); } -.openlist-actions-bar .btn-sync-highlight:hover { - background: rgba(14, 165, 233, 0.22); - border-color: #38bdf8; - color: #fff; - box-shadow: 0 0 16px rgba(14, 165, 233, 0.35); +.openlist-tabs-bar .backup-tab-btn.active { + background: var(--accent); + color: var(--text-on-accent); + box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 40%, transparent), inset 0 1px 0 color-mix(in srgb, var(--text) 22%, transparent); } -/* OpenList 用户独立配置卡片 */ -.openlist-user-card { - background: rgba(255, 255, 255, 0.02); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; - padding: 1rem 1.15rem; - margin-top: 0.75rem; - transition: all 0.2s ease; +.openlist-tabs-bar .backup-tab-btn.active svg { + opacity: 1; + transform: scale(1.05); } -.openlist-user-card:hover { - background: rgba(255, 255, 255, 0.035); - border-color: rgba(99, 102, 241, 0.3); +/* 自定义现代复选框 (.lx-checkbox) 与单选框 (.lx-radio) */ +input[type="checkbox"].lx-checkbox, +input[type="checkbox"].olb-sel, +#olb-selall { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + margin: 0; + border-radius: var(--radius-sm); + border: 1.5px solid var(--border); + background: var(--surface-2); + cursor: pointer; + position: relative; + display: inline-grid; + place-content: center; + vertical-align: middle; + flex-shrink: 0; + transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: inset 0 1px 2px color-mix(in srgb, var(--text) 35%, transparent); } -.openlist-user-header { - display: flex; - align-items: center; - justify-content: space-between; +input[type="checkbox"].lx-checkbox:hover, +input[type="checkbox"].olb-sel:hover, +#olb-selall:hover { + border-color: var(--accent); + background: var(--accent-soft); + box-shadow: var(--shadow-sm); } -.openlist-user-badge { - display: inline-flex; - align-items: center; - gap: 6px; - font-size: 0.85rem; - color: var(--text-primary); - background: rgba(99, 102, 241, 0.15); - border: 1px solid rgba(99, 102, 241, 0.3); - padding: 3px 10px; - border-radius: 6px; +input[type="checkbox"].lx-checkbox:checked, +input[type="checkbox"].olb-sel:checked, +#olb-selall:checked, +#olb-selall:indeterminate { + background: var(--accent); + border-color: var(--accent); + box-shadow: var(--shadow-sm); + color: var(--text-on-accent); } -.openlist-user-badge svg { - color: #818cf8; +input[type="checkbox"].lx-checkbox::after, +input[type="checkbox"].olb-sel::after, +#olb-selall::after { + content: ""; + width: 9px; + height: 5px; + border-left: 2px solid var(--border); + border-bottom: 2px solid var(--border); + transform: rotate(-45deg) translate(0.5px, -1px) scale(0); + transition: transform 0.15s cubic-bezier(0.4, 0, 0.2, 1); } -@media (max-width: 768px) { - .openlist-actions-bar { - flex-direction: column; - align-items: stretch; - } - .openlist-actions-bar .actions-divider-v { - width: 100%; - height: 1px; - margin: 0.25rem 0; - } - .openlist-actions-bar .actions-group { - width: 100%; - flex-direction: column; - } - .openlist-actions-bar button { - width: 100%; - justify-content: center; - } +input[type="checkbox"].lx-checkbox:checked::after, +input[type="checkbox"].olb-sel:checked::after, +#olb-selall:checked::after { + transform: rotate(-45deg) translate(0.5px, -1px) scale(1); } -/* ========================================================= - OpenList 文件记录面板 (Status Cards & Records Table) - ========================================================= */ -.olb-status-cards { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); - gap: 0.75rem; - margin-bottom: 1.15rem; +#olb-selall:indeterminate::after { + width: 8px; + height: 2px; + border: none; + background: var(--surface); + border-radius: var(--radius-sm); + transform: scale(1); } -.olb-stat-card { - position: relative; +input[type="radio"].lx-radio { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + margin: 0; + border-radius: 50%; + border: 1.5px solid var(--border); + background: var(--surface-2); cursor: pointer; - padding: 0.75rem 0.95rem; - border-radius: 12px; - background: rgba(15, 23, 42, 0.45); - border: 1px solid var(--glass-border); - transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1); - user-select: none; - overflow: hidden; + position: relative; + display: inline-grid; + place-content: center; + flex-shrink: 0; + transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1); } -.olb-stat-card:hover { - background: rgba(255, 255, 255, 0.04); - border-color: var(--stat-color, #64748b); - transform: translateY(-2px); - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); +input[type="radio"].lx-radio:hover { + border-color: var(--accent); + box-shadow: var(--shadow-sm); } -.olb-stat-card.active { - background: rgba(255, 255, 255, 0.05); - border-color: var(--stat-color, #64748b); - box-shadow: 0 0 0 1px var(--stat-color, #64748b), 0 4px 18px rgba(0, 0, 0, 0.35); +input[type="radio"].lx-radio:checked { + border-color: var(--accent); + background: var(--accent-soft); + box-shadow: var(--shadow-sm); } -.olb-stat-header { +input[type="radio"].lx-radio::after { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent); + transform: scale(0); + transition: transform 0.16s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +input[type="radio"].lx-radio:checked::after { + transform: scale(1); +} + +/* OpenList 分区小标题 */ +.ol-section-title { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 0.82rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--accent-text); + margin-bottom: 0.85rem; + padding: 4px 10px; + border-radius: var(--radius-md); + background: var(--accent-soft); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); +} + +.ol-section-title svg { + width: 14px; + height: 14px; + color: var(--accent-text); + flex-shrink: 0; +} + +/* OpenList 统一精致按钮 (.olb-btn) */ +.olb-btn { + width: auto; + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + padding: 0 1.05rem; + font-size: 0.84rem; + font-weight: 500; + border-radius: var(--radius-md); + white-space: nowrap; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: var(--shadow-sm); +} + +.olb-btn svg { + flex-shrink: 0; +} + +.olb-btn.olb-btn-primary { + background: var(--accent); + border: 1px solid var(--border); + color: var(--text-on-accent); + box-shadow: 0 4px 14px color-mix(in srgb, var(--accent) 32%, transparent), inset 0 1px 0 color-mix(in srgb, var(--text) 18%, transparent); +} + +.olb-btn.olb-btn-primary:not(:disabled):hover { + transform: translateY(-1px); + box-shadow: 0 6px 20px color-mix(in srgb, var(--accent) 48%, transparent), inset 0 1px 0 color-mix(in srgb, var(--text) 25%, transparent); + filter: brightness(1.06); +} + +.olb-btn.btn-secondary { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); +} + +.olb-btn.btn-secondary:not(:disabled):hover { + background: var(--surface); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.olb-btn:disabled { + opacity: 1; + cursor: not-allowed; + transform: none; + box-shadow: var(--shadow-sm); +} + +.olb-btn.olb-btn-sm { + min-height: 36px; + padding: 0 0.9rem; + font-size: 0.81rem; + border-radius: var(--radius-md); +} + +/* OpenList 操作底栏与用户卡片 */ +.openlist-actions-bar { display: flex; align-items: center; - gap: 6px; - margin-bottom: 0.35rem; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.85rem; + padding: 0.95rem 1.35rem; + margin-top: 1.25rem; + border-radius: var(--radius-lg); + background: var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow-sm); +} + +.openlist-actions-bar .actions-group { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.65rem; +} + +.openlist-actions-bar .actions-divider-v { + width: 1px; + height: 26px; + background: color-mix(in srgb, var(--text) 5%, transparent); + margin: 0 0.25rem; +} + +.openlist-actions-bar .btn-sync-highlight { + background: var(--info-soft); + border-color: color-mix(in srgb, var(--info) 35%, var(--border)); + color: var(--info-text); +} + +.openlist-actions-bar .btn-sync-highlight:hover { + background: var(--info-soft); + border-color: var(--info); + color: var(--text); + box-shadow: var(--shadow-sm); +} + +/* OpenList 用户独立配置卡片网格 */ +.openlist-user-dirs-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); + gap: 0.9rem; + margin-top: 0.75rem; +} + +.openlist-user-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.05rem 1.2rem; + transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: var(--shadow-sm); +} + +.openlist-user-card:hover { + background: var(--surface); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + box-shadow: var(--shadow-sm); +} + +.openlist-user-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.65rem; + padding-bottom: 0.85rem; + margin-bottom: 0.85rem; + border-bottom: 1px solid var(--border); +} + +.openlist-user-badge { + display: inline-flex; + align-items: center; + gap: 7px; + font-size: 0.85rem; + color: var(--text); + background: var(--accent-soft); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); + padding: 4px 11px; + border-radius: var(--radius-md); +} + +.openlist-user-badge svg { + color: var(--accent-text); +} + +.openlist-user-toggle-wrap { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.openlist-user-toggle-label { + font-size: 0.8rem; + color: var(--text-2); + font-weight: 500; +} + +.openlist-mode-pill { + margin-left: auto; + font-size: 0.72rem; + font-weight: 600; + padding: 2px 8px; + border-radius: var(--radius-pill); + background: var(--accent-soft); + color: var(--accent-text); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); +} + +.openlist-mode-pill[data-mode="upload-delete"] { + background: var(--warning-soft); + color: var(--warning-text); + border-color: color-mix(in srgb, var(--warning) 35%, var(--border)); +} + +.openlist-mode-pill[data-mode="download"] { + background: var(--info-soft); + color: var(--info-text); + border-color: color-mix(in srgb, var(--info) 35%, var(--border)); +} + +.openlist-mode-pill[data-mode="sync"] { + background: var(--success-soft); + color: var(--success-text); + border-color: color-mix(in srgb, var(--success) 35%, var(--border)); +} + +@media (max-width: 768px) { + .openlist-actions-bar { + flex-direction: column; + align-items: stretch; + } + .openlist-actions-bar .actions-divider-v { + width: 100%; + height: 1px; + margin: 0.25rem 0; + } + .openlist-actions-bar .actions-group { + width: 100%; + flex-direction: column; + } + .openlist-actions-bar button { + width: 100%; + } + .openlist-user-dirs-grid { + grid-template-columns: 1fr; + } +} + +/* ========================================================= + OpenList 文件记录面板 (Header / Status Cards / Toolbar / Table) + ========================================================= */ +.olb-card-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 1rem; +} + +.olb-header-main { + display: flex; + align-items: center; + gap: 1rem; +} + +.olb-header-summary { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.olb-summary-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 11px; + border-radius: var(--radius-pill); + font-size: 0.78rem; + color: var(--text-2); + background: var(--surface); + border: 1px solid var(--border); +} + +.olb-summary-pill b { + color: var(--text); + font-family: monospace; + font-size: 0.84rem; +} + +.olb-summary-pill.is-verified { + background: var(--success-soft); + border-color: color-mix(in srgb, var(--success) 35%, var(--border)); + color: var(--success-text); +} + +.olb-summary-pill.is-verified b { + color: var(--success-text); +} + +.olb-summary-pill.is-failed { + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); + color: var(--danger-text); +} + +.olb-summary-pill.is-failed b { + color: var(--danger-text); +} + +/* 状态统计卡片 */ +.olb-status-cards { + display: grid; + grid-template-columns: repeat(7, minmax(100px, 1fr)); + gap: 0.75rem; + margin-bottom: 1.15rem; + min-width: 0; + overflow-x: auto; + padding: 3px; + scrollbar-width: thin; +} + +.olb-stat-card { + position: relative; + cursor: pointer; + padding: 0.85rem 1.05rem; + border-radius: var(--radius-lg); + background: var(--surface); + border: 1px solid var(--border); + transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1); + user-select: none; + overflow: hidden; +} + +.olb-stat-card::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 2px; + background: var(--stat-color, var(--surface)); + opacity: 0.45; + transition: opacity 0.2s ease, height 0.2s ease; +} + +.olb-stat-card:hover { + background: var(--surface); + border-color: var(--stat-color, var(--border)); + transform: translateY(-2px); + box-shadow: var(--shadow-sm); +} + +.olb-stat-card:hover::before { + opacity: 1; +} + +.olb-stat-card.active { + background: var(--surface); + border-color: var(--stat-color, var(--border-strong)); + box-shadow: var(--shadow-sm); +} + +.olb-stat-card.active::before { + opacity: 1; + height: 3px; +} + +.olb-stat-header { + display: flex; + align-items: center; + gap: 7px; + margin-bottom: 0.4rem; } .olb-stat-dot { - width: 7px; - height: 7px; + width: 8px; + height: 8px; border-radius: 50%; flex-shrink: 0; + box-shadow: var(--shadow-sm); } .olb-stat-label { - font-size: 0.76rem; - color: var(--text-secondary); + font-size: 0.78rem; + color: var(--text-2); font-weight: 500; } .olb-stat-num { - font-size: 1.45rem; + font-size: 1.55rem; font-weight: 700; line-height: 1.1; letter-spacing: -0.02em; + font-family: Inter, system-ui, -apple-system, sans-serif; } /* 工具栏与筛选区 */ @@ -6731,381 +7253,3353 @@ input:-webkit-autofill:active { flex-direction: column; gap: 0.85rem; margin-bottom: 1.15rem; - padding: 0.9rem 1.1rem; - background: rgba(255, 255, 255, 0.015); - border: 1px solid var(--glass-border); - border-radius: 12px; + padding: 1rem 1.15rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--text) 3%, transparent); +} + +.olb-actions-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; + padding-bottom: 0.85rem; + border-bottom: 1px solid var(--border); +} + +.olb-actions-primary, +.olb-actions-batch { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.olb-actions-divider { + display: none; +} + +.olb-filters-row { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.olb-filters-row .lx-select { + width: auto; + min-width: 128px; + flex: 0 1 auto; +} + +.olb-search-field { + position: relative; + display: flex; + align-items: center; + flex: 1 1 160px; + min-width: 150px; +} + +.olb-search-icon { + position: absolute; + left: 11px; + width: 14px; + height: 14px; + color: var(--text-2); + pointer-events: none; + opacity: 0.75; +} + +.olb-select, +.olb-input { + height: 36px; + font-size: 0.82rem; + padding: 0.45rem 0.75rem; + border-radius: var(--radius-md); + background: var(--field-bg); + border: 1px solid var(--field-border); + transition: all 0.18s ease; +} + +.olb-search-field .olb-input { + width: 100%; + padding-left: 2rem; +} + +.olb-input:focus { + border-color: var(--accent); + background: var(--field-bg); + box-shadow: var(--field-focus-shadow); +} + +.olb-filter-btns { + display: flex; + align-items: center; + gap: 0.5rem; + margin-left: auto; +} + +.btn-danger-soft { + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); + color: var(--danger-text); +} + +.btn-danger-soft:not(:disabled):hover { + background: var(--danger-soft); + border-color: var(--danger); + color: var(--text); + box-shadow: var(--shadow-sm); +} + +.olb-selcount-badge { + display: none; + align-items: center; + padding: 4px 10px; + border-radius: var(--radius-pill); + font-size: 0.78rem; + font-weight: 600; + color: var(--accent-text); + background: var(--accent-soft); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); +} + +.olb-selcount-badge.has-selection { + display: inline-flex; +} + +/* 空状态 */ +.olb-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3.5rem 1.5rem; + text-align: center; + background: var(--surface); + border: 1px dashed var(--border); + border-radius: var(--radius-lg); +} + +.olb-empty-icon { + width: 44px; + height: 44px; + color: var(--text-2); + opacity: 0.5; + margin-bottom: 0.75rem; +} + +.olb-empty-title { + font-size: 0.92rem; + font-weight: 600; + color: var(--text); + margin-bottom: 0.35rem; +} + +.olb-empty-desc { + font-size: 0.78rem; + color: var(--text-2); +} + +/* 状态徽章与表格细节 */ +.olb-table-wrapper { + max-height: 520px; + border-radius: var(--radius-lg); + border: 1px solid var(--border); + background: var(--surface); + max-width: 100%; + overflow: auto; + scrollbar-width: thin; +} + +.olb-records-table { + font-size: 0.82rem; + min-width: 920px; +} + +#ol-integrity-results .olb-records-table th:last-child { + width: 260px; +} + +#ol-integrity-results .olb-records-table td:last-child { + overflow-wrap: anywhere; + white-space: normal; +} + +.olb-records-table th { + position: sticky; + top: 0; + z-index: 2; + padding: 12px; + white-space: nowrap; + background: var(--surface); + backdrop-filter: none; + font-size: 0.76rem; + font-weight: 600; + letter-spacing: 0.02em; + color: var(--text); + border-bottom: 1px solid var(--border); +} + +.olb-records-table td { + padding: 11px 12px; + vertical-align: middle; + border-bottom: 1px solid var(--border); + transition: background 0.15s ease; +} + +.olb-records-table tbody tr:hover td { + background: var(--surface-hover); +} + +.olb-records-table tbody tr.is-selected td { + background: var(--accent-soft); +} + +.olb-records-table tbody tr.is-selected:hover td { + background: var(--accent-soft); +} + +.olb-col-check { + text-align: center; + width: 44px; +} + +.olb-col-name { + max-width: 280px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 500; + color: var(--text); +} + +.olb-filename-text { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + vertical-align: bottom; +} + +.olb-col-size { + white-space: nowrap; + font-family: monospace; + font-size: 0.8rem; + color: var(--text-2); +} + +.olb-col-err { + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.olb-err-text { + display: inline-block; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--danger-text); + font-size: 0.75rem; + padding: 2px 7px; + border-radius: var(--radius-sm); + background: var(--danger-soft); + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); +} + +.olb-muted-dash { + color: var(--text-3); + font-size: 0.8rem; +} + +.olb-col-time { + white-space: nowrap; + color: var(--text-2); + font-size: 0.78rem; + font-family: monospace; +} + +.olb-col-actions { + text-align: center; + white-space: nowrap; +} + +.olb-row-actions { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + white-space: nowrap; +} + +.olb-act-btn { + width: auto; + display: inline-flex; + align-items: center; + justify-content: center; + height: 26px; + padding: 0 9px; + font-size: 0.74rem; + font-weight: 500; + border-radius: var(--radius-sm); + color: var(--text); + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + cursor: pointer; + white-space: nowrap; + transition: all 0.16s ease; +} + +.olb-act-btn:hover { + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + color: var(--text); +} + +.olb-act-btn.is-retry { + color: var(--info-text); + background: var(--info-soft); + border-color: color-mix(in srgb, var(--info) 35%, var(--border)); +} + +.olb-act-btn.is-retry:hover { + background: var(--info-soft); + border-color: var(--info); + color: var(--text); +} + +.olb-type-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: 0.74rem; + font-weight: 500; + white-space: nowrap; + color: var(--text-2); + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); +} + +.olb-local-badge { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 3px; + padding: 2px 8px; + border-radius: var(--radius-pill); + font-size: 0.72rem; + font-weight: 600; + white-space: nowrap; +} + +.olb-local-badge.is-yes { + color: var(--success-text); + background: var(--success-soft); + border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); +} + +.olb-local-badge.is-no { + color: var(--danger-text); + background: var(--danger-soft); + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); +} + +.olb-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 9px; + border-radius: var(--radius-pill); + font-size: 0.73rem; + font-weight: 600; + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + color: var(--badge-color, var(--text)); +} + +.olb-badge-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--badge-color, currentColor); + box-shadow: var(--shadow-sm); +} + +/* ==================== 数据恢复中心现代化设计 ==================== */ +.ol-restore-form { + display: flex; + flex-direction: column; + gap: 1.15rem; +} + +/* 顶部恢复源 Banner */ +.ol-restore-source-banner { + display: flex; + align-items: center; + gap: 12px; + padding: 0.8rem 1.1rem; + border-radius: var(--radius-lg); + background: var(--surface); + border: 1px solid var(--border); +} + +.ol-source-icon { + width: 34px; + height: 34px; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-2); +} + +.ol-source-icon svg { + width: 18px; + height: 18px; +} + +.ol-source-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.ol-source-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-3); + font-weight: 600; +} + +.ol-source-path { + font-size: 0.84rem; + color: var(--text); + font-weight: 500; + word-break: break-all; + margin: 0; +} + +.ol-source-badge { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 9px; + border-radius: var(--radius-pill); + background: var(--success-soft); + border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); + font-size: 0.72rem; + color: var(--success-text); + font-weight: 500; + white-space: nowrap; +} + +/* 分步卡片容器 */ +.ol-restore-section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.15rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.95rem; + position: relative; +} + +.ol-section-header { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.ol-section-num { + width: 22px; + height: 22px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + color: var(--text-2); + font-size: 0.74rem; + font-weight: 600; + font-family: ui-monospace, SFMono-Regular, monospace; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.ol-step-title { + font-size: 0.95rem; + font-weight: 600; + color: var(--text); + margin: 0; + line-height: 1.3; +} + +.ol-step-desc { + font-size: 0.78rem; + color: var(--text-2); + margin: 2px 0 0; + line-height: 1.45; +} + +/* 版本选择器 */ +.ol-version-picker-box { + display: flex; + flex-direction: column; + gap: 8px; + padding: 0.9rem 1.1rem; + border-radius: var(--radius-lg); + background: var(--surface); + border: 1px solid var(--border); +} + +.ol-version-label { + display: flex; + align-items: center; + gap: 7px; + font-size: 0.82rem; + font-weight: 600; + color: var(--text); +} + +.ol-version-label svg { + width: 15px; + height: 15px; + color: var(--accent-text); +} + +.ol-version-select-wrap { + position: relative; +} + +.ol-version-select { + width: 100%; + cursor: pointer; + font-size: 0.86rem; + background: var(--surface-2); + border-color: var(--border); + padding-right: 2rem; +} + +.ol-version-select:hover { + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); +} + +.ol-version-hint { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.75rem; + color: var(--text-3); + line-height: 1.4; +} + +.ol-version-hint svg { + width: 13px; + height: 13px; + flex-shrink: 0; + color: var(--text-3); +} + +/* 数据类型网格 */ +.ol-restore-types { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); + gap: 0.85rem; +} + +.ol-restore-type { + display: flex; + align-items: flex-start; + gap: 11px; + cursor: pointer; + padding: 0.95rem 1.05rem; + border-radius: var(--radius-lg); + background: var(--surface); + border: 1px solid var(--border); + transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1); + user-select: none; + position: relative; +} + +.ol-restore-type:hover { + background: var(--surface); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.ol-restore-type:has(input:checked), +.ol-restore-type.is-checked { + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + box-shadow: var(--shadow-sm); +} + +.ol-restore-type input[type="checkbox"] { + margin-top: 2px; + flex-shrink: 0; + cursor: pointer; +} + +.ol-restore-type-icon { + width: 34px; + height: 34px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text); + box-shadow: var(--shadow-sm); +} + +.ol-restore-type-icon svg { + width: 17px; + height: 17px; +} + +.ol-restore-type-info { + display: flex; + flex-direction: column; + gap: 3px; + flex: 1; + min-width: 0; +} + +.ol-restore-type-title { + font-size: 0.88rem; + font-weight: 600; + color: var(--text); + line-height: 1.3; +} + +.ol-restore-type-desc { + display: block; + font-size: 0.76rem; + color: var(--text-2); + line-height: 1.45; +} + +/* 同名文件策略卡片 */ +.ol-restore-choices-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 0.85rem; +} + +.ol-restore-choice { + display: flex; + align-items: flex-start; + gap: 12px; + cursor: pointer; + padding: 1.05rem 1.15rem; + border-radius: var(--radius-lg); + background: var(--surface); + border: 1px solid var(--border); + transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1); + user-select: none; + position: relative; +} + +.ol-restore-choice:hover { + background: var(--surface); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.ol-restore-choice:has(input:checked) { + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + box-shadow: var(--shadow-sm); +} + +.ol-restore-choice input { + margin-top: 3px; + flex-shrink: 0; + cursor: pointer; +} + +.ol-choice-body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.ol-choice-head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.ol-choice-title { + font-size: 0.88rem; + font-weight: 600; + color: var(--text); +} + +.ol-choice-badge { + display: inline-flex; + align-items: center; + padding: 1px 7px; + border-radius: var(--radius-pill); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.02em; +} + +.ol-choice-badge.is-recommend { + background: var(--success-soft); + color: var(--success-text); + border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); +} + +.ol-choice-badge.is-warning { + background: var(--warning-soft); + color: var(--warning-text); + border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--border)); +} + +.ol-choice-badge.is-safe { + background: var(--info-soft); + color: var(--info-text); + border: 1px solid color-mix(in srgb, var(--info) 35%, var(--border)); +} + +.ol-choice-desc { + margin: 0; + font-size: 0.77rem; + color: var(--text-2); + line-height: 1.5; +} + +/* 安全回退卡片 */ +.ol-restore-safety-card { + background: var(--success-soft); + border-color: color-mix(in srgb, var(--success) 35%, var(--border)); +} + +.ol-restore-safety-card:hover { + border-color: color-mix(in srgb, var(--success) 35%, var(--border)); + background: var(--success-soft); +} + +.ol-restore-safety-card:has(input:checked) { + background: var(--success-soft); + border-color: color-mix(in srgb, var(--success) 35%, var(--border)); + box-shadow: var(--shadow-sm); +} + +.ol-safety-icon { + width: 36px; + height: 36px; + border-radius: var(--radius-md); + background: var(--success-soft); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--success-text); +} + +.ol-safety-icon svg { + width: 19px; + height: 19px; +} + +/* 提示条 */ +.ol-restore-notice { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 0.7rem 0.95rem; + border-radius: var(--radius-md); + background: var(--surface); + border: 1px solid var(--border); + font-size: 0.76rem; + color: var(--text-2); + line-height: 1.5; +} + +.ol-restore-notice svg { + width: 15px; + height: 15px; + color: var(--text-2); + flex-shrink: 0; + margin-top: 1px; +} + +/* 操作控制条 */ +.ol-restore-actions-bar { + display: flex; + align-items: center; + gap: 0.85rem; + flex-wrap: wrap; +} + +.ol-action-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0.65rem 1.35rem; + font-size: 0.86rem; + font-weight: 600; + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; + white-space: nowrap; +} + +.ol-action-btn svg { + width: 16px; + height: 16px; +} + +.ol-scan-btn { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); +} + +.ol-scan-btn:hover { + background: var(--surface); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); + color: var(--text); + box-shadow: var(--shadow-sm); +} + +.ol-run-btn { + background: var(--success); + border: none; + color: var(--on-success); + box-shadow: var(--shadow-sm); +} + +.ol-run-btn:hover:not(:disabled) { + box-shadow: var(--shadow-sm); + transform: translateY(-1px); +} + +.ol-run-btn:disabled { + opacity: 1; + cursor: not-allowed; + box-shadow: var(--shadow-sm); + transform: none; +} + +/* 扫描分析结果展示区 */ +.ol-restore-result-box { + border-radius: var(--radius-lg); + background: var(--surface); + border: 1px solid var(--border); + padding: 1.15rem 1.25rem; + line-height: 1.6; + overflow-wrap: anywhere; +} + +.ol-preview-empty { + display: flex; + align-items: center; + gap: 14px; + padding: 0.5rem 0.25rem; +} + +.ol-preview-empty-icon { + width: 44px; + height: 44px; + border-radius: var(--radius-lg); + background: var(--accent-soft); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--accent-text); +} + +.ol-preview-empty-icon svg { + width: 22px; + height: 22px; +} + +.ol-preview-empty-text strong { + display: block; + font-size: 0.88rem; + color: var(--text); + margin-bottom: 2px; +} + +.ol-preview-empty-text p { + margin: 0; + font-size: 0.78rem; + color: var(--text-2); +} + +.ol-preview-loading { + display: flex; + align-items: center; + gap: 14px; + padding: 0.6rem 0.25rem; + color: var(--text); +} + +.ol-preview-loading strong { + display: block; + font-size: 0.88rem; + color: var(--accent-text); +} + +.ol-preview-loading p { + margin: 2px 0 0; + font-size: 0.77rem; + color: var(--text-2); +} + +/* 指标卡片网格 */ +.ol-preview-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); + gap: 0.65rem; + margin-bottom: 0.85rem; +} + +.ol-summary-stat { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 0.75rem 0.6rem; + border-radius: var(--radius-md); + background: var(--surface); + border: 1px solid var(--border); +} + +.ol-summary-stat .ol-stat-num { + font-size: 1.45rem; + font-weight: 700; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} + +.ol-summary-stat .ol-stat-label { + font-size: 0.72rem; + color: var(--text-2); + margin-top: 3px; + font-weight: 500; +} + +.ol-summary-stat.is-add { border-color: color-mix(in srgb, var(--success) 35%, var(--border)); } +.ol-summary-stat.is-add .ol-stat-num { color: var(--success-text); } + +.ol-summary-stat.is-overwrite { border-color: color-mix(in srgb, var(--warning) 35%, var(--border)); } +.ol-summary-stat.is-overwrite .ol-stat-num { color: var(--warning-text); } + +.ol-summary-stat.is-merge { border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); } +.ol-summary-stat.is-merge .ol-stat-num { color: var(--accent-text); } + +.ol-summary-stat.is-skip { border-color: var(--border); } +.ol-summary-stat.is-skip .ol-stat-num { color: var(--text-2); } + +.ol-summary-note-strip { + display: flex; + align-items: center; + gap: 7px; + padding: 0.55rem 0.85rem; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--text) 5%, transparent); + font-size: 0.78rem; + color: var(--text-2); + margin-bottom: 0.75rem; +} + +.ol-summary-note-strip svg { + width: 14px; + height: 14px; + color: var(--accent-text); + flex-shrink: 0; +} + +.ol-preview-alert { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 0.8rem 1rem; + border-radius: var(--radius-md); + font-size: 0.8rem; + margin-top: 0.65rem; + line-height: 1.5; +} + +.ol-preview-alert svg { + width: 17px; + height: 17px; + flex-shrink: 0; + margin-top: 1px; +} + +.ol-preview-alert.is-error { + background: var(--danger-soft); + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); + color: var(--danger-text); +} + +.ol-preview-alert.is-error strong { color: var(--danger-text); } + +.ol-preview-alert.is-warn { + background: var(--warning-soft); + border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--border)); + color: var(--warning-text); +} + +.ol-preview-alert.is-success { + background: var(--success-soft); + border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); + color: var(--success-text); +} + +.ol-preview-alert.is-success strong { color: var(--success-text); } + +.ol-restore-files { + max-height: 320px; + overflow-y: auto; + margin-top: 0.75rem; + border-radius: var(--radius-md); + border: 1px solid var(--border); + background: var(--surface); +} + +.olb-detail-table { + width: 100%; + border-collapse: collapse; + font-size: 0.82rem; +} + +.olb-detail-table th { + text-align: left; + padding: 0.65rem 0.85rem; + background: var(--surface); + color: var(--text-2); + font-size: 0.75rem; + font-weight: 600; + border-bottom: 1px solid var(--border); +} + +.olb-detail-table td { + padding: 0.55rem 0.85rem; + vertical-align: middle; + border-bottom: 1px solid var(--border); +} + +.ol-restore-act-badge { + display: inline-block; + padding: 2px 8px; + border-radius: var(--radius-sm); + font-size: 0.72rem; + font-weight: 600; + text-align: center; +} + +.ol-restore-act-badge.is-add { color: var(--success-text); background: var(--success-soft); border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); } +.ol-restore-act-badge.is-overwrite { color: var(--warning-text); background: var(--warning-soft); border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--border)); } +.ol-restore-act-badge.is-merge { color: var(--accent-text); background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); } +.ol-restore-act-badge.is-skip { color: var(--text-2); background: var(--surface); } + +.ol-file-code { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.78rem; + color: var(--text); + background: var(--surface-2); + padding: 2px 6px; + border-radius: var(--radius-sm); +} + +/* ==================== 恢复任务与本地副本模块 ==================== */ +.ol-restore-tasks-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.25rem 1.35rem; + display: flex; + flex-direction: column; + gap: 1rem; + box-shadow: var(--shadow-sm); +} + +.ol-restore-tasks-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + padding-bottom: 0.85rem; + border-bottom: 1px solid var(--border); +} + +.ol-tasks-header-left { + display: flex; + align-items: center; + gap: 12px; +} + +.ol-tasks-icon { + width: 36px; + height: 36px; + border-radius: var(--radius-md); + background: var(--info-soft); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--info-text); +} + +.ol-tasks-icon svg { + width: 19px; + height: 19px; +} + +.ol-tasks-title { + font-size: 0.98rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.ol-tasks-subtitle { + font-size: 0.77rem; + color: var(--text-2); + margin: 2px 0 0; +} + +.ol-refresh-tasks-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0.45rem 0.95rem; + font-size: 0.78rem; + border-radius: var(--radius-md); + background: var(--surface); + border: 1px solid var(--border); + color: var(--text); + cursor: pointer; + transition: all 0.2s ease; +} + +.ol-refresh-tasks-btn svg { + width: 14px; + height: 14px; + transition: transform 0.3s ease; +} + +.ol-refresh-tasks-btn:hover { + background: var(--surface); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); +} + +.ol-refresh-tasks-btn:hover svg { + transform: rotate(180deg); +} + +.ol-restore-tasks-list { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +/* 任务空状态 */ +.ol-tasks-empty { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 2.2rem 1rem; + border-radius: var(--radius-lg); + background: var(--surface); + border: 1px dashed var(--border); + color: var(--text-2); + font-size: 0.82rem; +} + +.ol-tasks-empty svg { + width: 18px; + height: 18px; + color: var(--text-3); +} + +/* 任务卡片 */ +.ol-task-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 1.15rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.9rem; + box-shadow: var(--shadow-sm); + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.ol-task-card:hover { + border-color: var(--border); + box-shadow: var(--shadow-sm); +} + +.ol-task-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + flex-wrap: wrap; +} + +.ol-task-meta { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.ol-task-time { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.84rem; + font-weight: 600; + color: var(--text); +} + +.ol-task-time svg { + width: 14px; + height: 14px; + color: var(--accent-text); +} + +.ol-task-id-badge { + padding: 2px 7px; + border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + font-family: ui-monospace, SFMono-Regular, monospace; + font-size: 0.72rem; + color: var(--text-2); +} + +/* 任务状态 Badge */ +.ol-task-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: var(--radius-pill); + font-size: 0.74rem; + font-weight: 600; +} + +.ol-task-badge svg { + width: 13px; + height: 13px; +} + +.ol-task-badge.is-completed { + background: var(--success-soft); + color: var(--success-text); + border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); +} + +.ol-task-badge.is-running, +.ol-task-badge.is-committing { + background: var(--info-soft); + color: var(--info-text); + border: 1px solid color-mix(in srgb, var(--info) 35%, var(--border)); +} + +.ol-task-badge.is-ready { + background: var(--success-soft); + color: var(--success-text); + border: 1px solid color-mix(in srgb, var(--success) 35%, var(--border)); +} + +.ol-task-badge.is-failed { + background: var(--danger-soft); + color: var(--danger-text); + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); +} + +.ol-task-badge.is-danger { + background: var(--danger-soft); + color: var(--danger-text); + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); +} + +.ol-task-badge.is-interrupted, +.ol-task-badge.is-cancelled { + background: var(--danger-soft); + color: var(--warning-text); + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); +} + +.ol-task-badge.is-rolled-back { + background: var(--surface); + color: var(--text-2); + border: 1px solid var(--border); +} + +.ol-task-spinner-sm { + width: 11px; + height: 11px; + border: 2px solid currentcolor; + border-top-color: transparent; + border-radius: 50%; + display: inline-block; + animation: olSpin 0.8s linear infinite; +} + +@keyframes olSpin { + to { transform: rotate(360deg); } +} + +/* 任务进度条 */ +.ol-task-progress-box { + display: flex; + flex-direction: column; + gap: 6px; +} + +.ol-task-progress-head { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.77rem; + color: var(--text-2); +} + +.ol-task-progress-label { + font-weight: 500; +} + +.ol-task-progress-num strong { + color: var(--text); +} + +.ol-task-progress-track { + width: 100%; + height: 7px; + border-radius: var(--radius-pill); + background: color-mix(in srgb, var(--text) 5%, transparent); + overflow: hidden; +} + +.ol-task-progress-fill { + height: 100%; + border-radius: var(--radius-pill); + transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1); + background: var(--accent); +} + +.ol-task-progress-fill.is-completed { + background: var(--success); +} + +.ol-task-progress-fill.is-failed { + background: var(--danger); +} + +.ol-task-metrics-grid { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.ol-task-metric-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: var(--radius-md); + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + font-size: 0.76rem; + color: var(--text-2); +} + +.ol-task-metric-pill .ol-metric-title { + color: var(--text); +} + +.ol-task-metric-pill .ol-metric-value { + color: var(--text); + font-weight: 600; +} + +.ol-task-metric-pill.is-safe { + background: var(--success-soft); + border-color: color-mix(in srgb, var(--success) 35%, var(--border)); + color: var(--success-text); +} + +.ol-task-metric-pill.is-safe svg { + width: 14px; + height: 14px; + color: var(--success-text); +} + +.ol-task-errors { + padding: 0.65rem 0.85rem; + border-radius: var(--radius-md); + background: var(--danger-soft); + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); + display: flex; + flex-direction: column; + gap: 4px; +} + +.ol-task-error-item { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.76rem; + color: var(--danger-text); + margin: 0; +} + +.ol-task-error-item svg { + width: 13px; + height: 13px; + flex-shrink: 0; +} + +/* 任务操作按钮组 */ +.ol-task-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding-top: 0.75rem; + border-top: 1px solid var(--border); +} + +.ol-task-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0.42rem 0.85rem; + font-size: 0.76rem; + font-weight: 600; + border-radius: var(--radius-md); + cursor: pointer; + transition: all 0.2s ease; +} + +.ol-task-btn svg { + width: 13px; + height: 13px; +} + +.ol-task-btn.is-cancel { + background: var(--surface); + border: 1px solid var(--border); + color: var(--text-2); +} + +.ol-task-btn.is-cancel:hover { + background: var(--surface); + color: var(--text); +} + +.ol-task-btn.is-resume { + background: var(--info); + border: none; + color: var(--on-info); + box-shadow: var(--shadow-sm); +} + +.ol-task-btn.is-resume:hover { + box-shadow: var(--shadow-sm); + transform: translateY(-1px); +} + +.ol-task-btn.is-rollback { + background: var(--warning-soft); + border: 1px solid color-mix(in srgb, var(--warning) 35%, var(--border)); + color: var(--warning-text); +} + +.ol-task-btn.is-rollback:hover { + background: var(--warning-soft); + border-color: color-mix(in srgb, var(--warning) 35%, var(--border)); + box-shadow: var(--shadow-sm); +} + +.ol-task-btn.is-delete { + background: var(--danger-soft); + border: 1px solid color-mix(in srgb, var(--danger) 35%, var(--border)); + color: var(--danger-text); +} + +.ol-task-btn.is-delete:hover { + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); + color: var(--text); + box-shadow: var(--shadow-sm); +} + +.olb-detail-key { + width: 96px; + white-space: nowrap; + color: var(--text-2); + font-weight: 500; +} + +.olb-detail-val { + color: var(--text); +} + +.olb-user-tag { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--radius-sm); + background: var(--accent-soft); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); + color: var(--accent-text); + font-size: 0.75rem; + font-family: monospace; + font-weight: 500; +} + +/* 分页栏 */ +.olb-pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-top: 1rem; + padding-top: 0.75rem; + border-top: 1px solid var(--border); + flex-wrap: wrap; + font-size: 0.82rem; + color: var(--text-2); +} + +.olb-pager-left, +.olb-pager-right { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; +} + +.olb-pager-btn { + width: auto; + height: 32px; + padding: 0 0.75rem; + font-size: 0.78rem; + border-radius: var(--radius-md); + background: var(--surface); + border: 1px solid var(--border); +} + +.olb-pager-btn:disabled { + opacity: 1; + cursor: not-allowed; +} + +.olb-pager-page-pill { + display: inline-flex; + align-items: center; + justify-content: center; + height: 32px; + padding: 0 0.75rem; + border-radius: var(--radius-md); + font-family: monospace; + font-size: 0.78rem; + font-weight: 600; + color: var(--text); + background: var(--accent-soft); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); +} + +.olb-pager-info { + font-size: 0.8rem; + color: var(--text-2); +} + +.olb-pager-info b { + color: var(--text); +} + +.lx-select.select-pager { + width: auto; + min-width: 112px; +} + +.lx-select.select-pager .lx-select-trigger { + min-height: 32px; + padding: 0.3rem 0.65rem; + font-size: 0.78rem; + border-radius: var(--radius-md); +} + +@media (max-width: 900px) { + .olb-filter-btns { + margin-left: 0; + width: 100%; + justify-content: flex-end; + } + .olb-pager { + flex-direction: column; + align-items: flex-start; + } +} + +/* ========================================================= + 现代化圆角毛玻璃自定义下拉框样式 (.lx-select) + ========================================================= */ +.lx-select { + position: relative; + width: 100%; + user-select: none; + font-size: 0.9rem; +} + +.lx-select.is-disabled { + opacity: 0.5; + pointer-events: none; +} + +.lx-select-native { + position: absolute; + opacity: 0; + pointer-events: none; + width: 0; + height: 0; + margin: 0; + padding: 0; + border: none; + overflow: hidden; +} + +.lx-select-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + min-height: 40px; + padding: 0.65rem 0.95rem; + background: var(--field-bg); + border: 1px solid var(--field-border); + border-radius: var(--radius-md); + color: var(--text); + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + box-sizing: border-box; + box-shadow: none; +} + +.lx-select:hover .lx-select-trigger { + background: var(--field-bg); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); +} + +.lx-select.is-open .lx-select-trigger { + border-color: var(--accent); + background: var(--field-bg); + box-shadow: var(--field-focus-shadow); +} + +.lx-select-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: left; + font-weight: 500; +} + +.lx-select-arrow { + width: 16px; + height: 16px; + color: var(--text-2); + flex-shrink: 0; + transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.lx-select.is-open .lx-select-arrow { + transform: rotate(180deg); + color: var(--accent-text); +} + +/* 真正的圆角毛玻璃弹出菜单 */ +.lx-select-menu { + position: absolute; + top: calc(100% + 6px); + left: 0; + right: 0; + min-width: 150px; + z-index: 1002; + background: var(--surface-2); + backdrop-filter: none; + -webkit-backdrop-filter: none; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: 6px; + max-height: 280px; + overflow-y: auto; + display: none; + flex-direction: column; + gap: 2px; + animation: lxSelectFadeIn 0.15s ease-out; + /* 去除侧边下拉滚动条,鼠标滚轮依然保持丝滑滚动 */ + scrollbar-width: none; + -ms-overflow-style: none; +} + +.lx-select-menu::-webkit-scrollbar { + width: 0; + height: 0; + display: none; +} + +.lx-select.drop-up .lx-select-menu { + top: auto; + bottom: calc(100% + 6px); +} + +.lx-select.is-open .lx-select-menu { + display: flex; +} + +@keyframes lxSelectFadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.lx-select-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0.55rem 0.85rem; + border-radius: var(--radius-md); + font-size: 0.86rem; + color: var(--text-2); + cursor: pointer; + transition: all 0.15s ease; +} + +.lx-select-item-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.lx-select-check { + width: 14px; + height: 14px; + color: var(--accent-text); + flex-shrink: 0; + opacity: 0; + transform: scale(0.8); + transition: all 0.15s ease; +} + +.lx-select-item:hover { + background: var(--surface-hover); + color: var(--text); +} + +.lx-select-item.is-selected { + background: var(--accent-soft); + color: var(--accent-text); + font-weight: 600; +} + +.lx-select-item.is-selected .lx-select-check { + opacity: 1; + transform: scale(1); +} + +/* 小号选择器 */ +.lx-select.select-sm .lx-select-trigger { + min-height: 36px; + padding: 0.42rem 0.75rem; + font-size: 0.82rem; + border-radius: var(--radius-md); +} +.lx-select.select-sm .lx-select-item { + padding: 0.44rem 0.75rem; + font-size: 0.81rem; +} + +/* 彻底去除主页面侧边滑动条(保留滚轮顺畅滑动页面) */ +html, +body, +#app, +.main-content, +.content-wrapper { + scrollbar-width: none; + -ms-overflow-style: none; +} + +html::-webkit-scrollbar, +body::-webkit-scrollbar, +#app::-webkit-scrollbar, +.main-content::-webkit-scrollbar, +.content-wrapper::-webkit-scrollbar { + width: 0; + height: 0; + display: none; +} + +/* 局部弹窗和日志等容器的极简微型滚动条 */ +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: var(--scrollbar-track); + border-radius: var(--radius-pill); +} + +::-webkit-scrollbar-thumb { + background: var(--scrollbar-thumb); + border-radius: var(--radius-pill); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--scrollbar-thumb-hover); +} + +::-webkit-scrollbar-thumb:active { + background: var(--scrollbar-thumb-active); +} + +::-webkit-scrollbar-corner { + background: var(--scrollbar-track); +} + +::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; +} + +.ol-restore-task { + display: none; +} + +/* 内容校验现代化面板样式 */ +.ol-integrity-controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; + margin: 1.25rem 0 1rem; +} + +.ol-integrity-field { + display: flex; + flex-direction: column; + gap: 6px; + color: var(--text-2); +} + +.ol-integrity-label { + font-size: 0.82rem; + font-weight: 500; + color: var(--text-2); +} + +.ol-integrity-actions { + display: flex; + align-items: center; + gap: 0.65rem; + flex-wrap: wrap; + margin: 1.15rem 0 0.85rem; +} + +.ol-integrity-progress-wrap { + margin: 1rem 0; + padding: 1rem 1.15rem; + border-radius: var(--radius-lg); + background: var(--surface); + border: 1px solid var(--border); +} + +.ol-integrity-progress-wrap progress { + margin-top: 8px; + height: 8px; + border-radius: var(--radius-pill); + overflow: hidden; + background: color-mix(in srgb, var(--text) 5%, transparent); +} + +.ol-integrity-progress-wrap progress::-webkit-progress-bar { + background: color-mix(in srgb, var(--text) 5%, transparent); + border-radius: var(--radius-pill); +} + +.ol-integrity-progress-wrap progress::-webkit-progress-value { + background: var(--info); + border-radius: var(--radius-pill); +} + +.ol-integrity-filter-bar { + display: flex; + align-items: flex-end; + gap: 1rem; + margin-top: 1.25rem; + flex-wrap: wrap; +} + +.ol-integrity-summary-line { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; + padding: 0.75rem 1rem; + margin-bottom: 0.75rem; + border-radius: var(--radius-md); + background: var(--surface); + border: 1px solid var(--border); +} + +.ol-integrity-stat-pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border-radius: var(--radius-pill); + font-size: 0.76rem; + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); +} + +.ol-integrity-badge { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 9px; + border-radius: var(--radius-pill); + font-size: 0.74rem; + font-weight: 600; + white-space: nowrap; +} + +.ol-integrity-badge-matched, +.ol-integrity-stat-pill.ol-integrity-badge-matched { + color: var(--success-text); + background: var(--success-soft); + border-color: color-mix(in srgb, var(--success) 35%, var(--border)); +} + +.ol-integrity-badge-mismatch, +.ol-integrity-stat-pill.ol-integrity-badge-mismatch { + color: var(--danger-text); + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); +} + +.ol-integrity-badge-missing, +.ol-integrity-stat-pill.ol-integrity-badge-missing { + color: var(--danger-text); + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); +} + +.ol-integrity-badge-error, +.ol-integrity-stat-pill.ol-integrity-badge-error { + color: var(--danger-text); + background: var(--danger-soft); + border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); +} + +.ol-integrity-badge-unavailable, +.ol-integrity-stat-pill.ol-integrity-badge-unavailable { + color: var(--text-2); + background: var(--surface); + border-color: var(--border); +} + +.ol-integrity-filename { + font-size: 0.84rem; + font-weight: 500; + color: var(--text); + margin-bottom: 2px; +} + +/* ========================================================= + 记录详情弹窗 · 现代卡片与元数据 Tag / 封面预览样式 + ========================================================= */ +.ol-meta-tags-wrapper { + display: flex; + flex-direction: column; + gap: 0.85rem; + padding: 0.25rem 0; +} + +.ol-meta-cover-preview { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + width: fit-content; +} + +.ol-meta-cover-img { + width: 68px; + height: 68px; + border-radius: var(--radius-md); + object-fit: cover; + box-shadow: var(--shadow-sm); + border: 1px solid var(--border); + transition: transform 0.2s ease; +} + +.ol-meta-cover-img:hover { + transform: scale(1.05); +} + +.ol-meta-cover-label { + font-size: 0.78rem; + font-weight: 600; + color: var(--accent-text); + letter-spacing: 0.02em; +} + +.ol-meta-tags-grid { + display: flex; + flex-wrap: wrap; + gap: 6px; + max-height: 340px; + overflow-y: auto; + padding-right: 4px; +} + +.ol-meta-tag { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 9px; + border-radius: var(--radius-md); + font-size: 0.75rem; + line-height: 1.25; + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px solid var(--border); + transition: all 0.15s ease; +} + +.ol-meta-tag:hover { + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.ol-meta-tag-key { + color: var(--text-2); + opacity: 0.85; +} + +.ol-meta-tag-val { + font-weight: 600; + color: var(--text); + max-width: 220px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 颜色分类 Tag 风格 */ +.ol-meta-tag-primary { + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); +} +.ol-meta-tag-primary .ol-meta-tag-key { color: var(--accent-text); } +.ol-meta-tag-primary .ol-meta-tag-val { color: var(--text); } + +.ol-meta-tag-tech { + background: var(--info-soft); + border-color: color-mix(in srgb, var(--info) 35%, var(--border)); +} +.ol-meta-tag-tech .ol-meta-tag-key { color: var(--info-text); } +.ol-meta-tag-tech .ol-meta-tag-val { color: var(--text); font-family: monospace; } + +.ol-meta-tag-info { + background: var(--accent-soft); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border)); +} +.ol-meta-tag-info .ol-meta-tag-key { color: var(--accent-text); } +.ol-meta-tag-info .ol-meta-tag-val { color: var(--text); font-family: monospace; } + +.ol-meta-tag-bool-true { + background: var(--success-soft); + border-color: color-mix(in srgb, var(--success) 35%, var(--border)); +} +.ol-meta-tag-bool-true .ol-meta-tag-key { color: var(--success-text); } +.ol-meta-tag-bool-true .ol-meta-tag-val { color: var(--success-text); } + +.ol-meta-tag-bool-false { + background: var(--surface); + border-color: var(--border); +} +.ol-meta-tag-bool-false .ol-meta-tag-key { color: var(--text-2); } +.ol-meta-tag-bool-false .ol-meta-tag-val { color: var(--text); } + +.ol-meta-tag-default { + background: color-mix(in srgb, var(--text) 5%, transparent); + border-color: var(--border); +} + +/* =============== 备份管理:统一入口(概览 / 本地 / 云端 / 恢复 / 记录 / 播放缓存) =============== */ +.bm-container { max-width: none; width: 100%; min-width: 0; } +.bm-tabs { display: flex; max-width: 100%; overflow-x: auto; scrollbar-width: thin; -webkit-overflow-scrolling: touch; } +.bm-tabs .backup-tab-btn { flex: 0 0 auto; } +.bm-pane, .bm-restore-panel, .bm-rec-section { display: none; flex-direction: column; gap: 1.25rem; min-width: 0; max-width: 100%; } +.bm-pane.active, .bm-restore-panel.active, .bm-rec-section.active { display: flex; animation: fadeIn 0.25s ease; } +.bm-pane > .config-card, .bm-restore-panel > .config-card, .bm-rec-section > .config-card { margin: 0; } +.bm-sub-title { margin: 0 0 0.75rem; font-size: 0.95rem; font-weight: 600; color: var(--text); } +.bm-section-title { margin: 0.5rem 0 -0.25rem; } +.bm-group-title { margin: 0.25rem 0 0.75rem; padding-left: 10px; border-left: 3px solid var(--accent); font-size: 0.9rem; font-weight: 600; color: var(--text); } +.bm-subnav { display: flex; gap: 0.5rem; flex-wrap: wrap; } +.bm-subnav a { padding: 4px 12px; border: 1px solid var(--border); border-radius: var(--radius-pill); font-size: 0.8rem; color: var(--text-2); text-decoration: none; } +.bm-subnav a:hover { color: var(--text); border-color: var(--accent); } +.bm-ov-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.25rem; align-items: start; } +.bm-ov-card { padding: 1.25rem 1.4rem; margin: 0; display: flex; flex-direction: column; border: 1px solid var(--border); transition: border-color 0.18s ease; border-radius: var(--radius-lg); background: var(--surface); gap: 1.2rem; } +.bm-ov-card:hover { border-color: var(--border); } +.bm-ov-head { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; } +.bm-ov-badge { display: inline-flex; align-items: center; gap: 5px; padding: 2px 9px; border-radius: var(--radius-pill); font-size: 0.73rem; font-weight: 500; background: var(--surface); color: var(--text-2); border: 1px solid var(--border); white-space: nowrap; } +.bm-ov-badge.is-on { background: var(--success-soft); border-color: color-mix(in srgb, var(--success) 35%, var(--border)); color: var(--success-text); } +.bm-ov-badge.is-warn { background: var(--warning-soft); border-color: color-mix(in srgb, var(--warning) 35%, var(--border)); color: var(--warning-text); } +.bm-ov-badge.is-error { background: var(--danger-soft); border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); color: var(--danger-text); } +.bm-ov-list { display: grid; gap: 0.6rem; margin: 0; } +.bm-ov-list > div { display: flex; justify-content: space-between; gap: 1rem; font-size: 0.85rem; } +.bm-ov-list dt { color: var(--text-2); flex: 0 0 auto; } +.bm-ov-list dd { margin: 0; text-align: right; overflow-wrap: anywhere; color: var(--text); } +.bm-ov-actions { margin-top: auto; } +.bm-purpose-list { margin: 0; padding-left: 1.2rem; display: grid; gap: 0.4rem; font-size: 0.85rem; line-height: 1.6; color: var(--text-2); } +.bm-scope-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; } +.bm-scope-grid > div { padding: 0.75rem 0.9rem; border: 1px solid var(--border); border-radius: var(--radius-md); background: color-mix(in srgb, var(--text) 5%, transparent); } +.bm-scope-grid p { margin: 0.35rem 0 0; font-size: 0.8rem; color: var(--text-2); line-height: 1.55; } +.bm-source-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 0.85rem; margin-bottom: 1rem; } +.bm-source-card { display: flex; gap: 0.7rem; align-items: flex-start; padding: 0.85rem 1rem; border: 1px solid var(--border); border-radius: var(--radius-lg); cursor: pointer; background: color-mix(in srgb, var(--text) 5%, transparent); transition: border-color 0.2s, background 0.2s; } +.bm-source-card:hover { border-color: var(--accent); } +.bm-source-card.is-selected { border-color: var(--accent); background: var(--info-soft); } +.bm-source-card small { display: block; margin-top: 2px; color: var(--text-2); font-size: 0.78rem; } +.bm-filter-row { display: flex; gap: 1rem; flex-wrap: wrap; } +.bm-filter-row .ol-integrity-field { min-width: 220px; flex: 1 1 220px; } +.bm-banner-danger { background: var(--danger-soft); border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; } +.bm-save-status:empty { display: none; } +.bm-save-status.is-error { color: var(--danger-text); } +.bm-save-status.is-dirty { color: var(--warning-text); } +.bm-empty-hint { text-align: center; padding: 1.25rem 0; color: var(--text-2); } +.bm-path-help { margin: 0.6rem 0 1rem; font-size: 0.8rem; color: var(--text-2); line-height: 1.6; } +.bm-path-help summary { cursor: pointer; color: var(--text); } +.bm-op-block { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px dashed var(--border); } +.bm-tab-unsaved::after { content: ' ●'; color: var(--warning-text); font-size: 0.7em; } + +@media (max-width: 640px) { + .bm-ov-grid, .bm-source-cards { grid-template-columns: 1fr; } + .bm-ov-list > div { flex-direction: column; gap: 0.1rem; } + .bm-ov-list dd { text-align: left; } + .bm-tabs .backup-tab-btn { padding: 8px 12px; } +} +/* Local backup history shares the available pane width and keeps controls outside tables. */ +.bm-local-lists-header { flex-wrap: wrap; gap: 1rem; } +.bm-local-lists-header .bm-ov-title-group { display: flex; align-items: center; gap: 1rem; min-width: 0; flex: 1 1 320px; } +.bm-local-lists-header .backup-segmented-tabs { margin-left: auto; flex-shrink: 0; } +#bm-pane-local .snapshots-table-header, +#bm-pane-local .snapshot-row { grid-template-columns: minmax(145px, .8fr) minmax(0, 1.7fr) 85px minmax(260px, auto); gap: 1rem; } +#bm-pane-local .snapshots-table-header .col-id { background: none; border: 0; padding: 0; font: inherit; } +#bm-pane-local .snapshot-row > * { min-width: 0; } +#snapshot-toolbar-actions { gap: .6rem; } +#snapshot-toolbar-actions .lx-select { width: auto; min-width: 120px; } + +@media (max-width: 1100px) and (min-width: 769px) { + #bm-pane-local .snapshots-table-header, + #bm-pane-local .snapshot-row { grid-template-columns: 145px minmax(0, 1fr) 70px; } + #bm-pane-local .snapshots-table-header .col-actions { display: none; } + #bm-pane-local .snapshot-actions { grid-column: 1 / -1; justify-content: flex-end; } +} + +@media (max-width: 768px) { + .bm-local-lists-header .backup-segmented-tabs { margin-left: 0; flex-wrap: wrap; } + #snapshot-toolbar-actions { width: 100%; justify-content: flex-start; } +} + +/* Match the snapshot type control to the neighboring account selector. */ +#snapshot-toolbar-actions .lx-select-trigger { + min-height: 42px; + padding: .65rem 1rem; + border-radius: var(--radius-lg); + background: var(--field-bg); + border-color: var(--border); + font-size: .9rem; +} + +.backup-scope-help { min-width: 0; line-height: 1.7; } +.backup-scope-help p { margin: 0 0 .6rem; } +.backup-scope-help p:last-child { margin-bottom: 0; } +.usage-path-table-wrap { overflow-x: auto; margin: .8rem 0; } +.usage-path-table { width: 100%; border-collapse: collapse; font-size: .82rem; color: var(--text-2); } +.usage-path-table th, .usage-path-table td { padding: .7rem .8rem; border: 1px solid var(--border); text-align: left; vertical-align: top; } +.usage-path-table th { color: var(--text); background: color-mix(in srgb, var(--text) 5%, transparent); } +.usage-path-table td:first-child { width: 38%; overflow-wrap: anywhere; } + +.bm-ov-card [hidden] { /* Visibility utility must override component display and inline layout. */ display: none !important; } + +/* OpenList record facets: one panel for all record conditions. */ +.olb-filter-panel { display: grid; gap: 1rem; padding-top: 1rem; border-top: 1px solid var(--border); } +.olb-filter-top { display: flex; align-items: center; gap: 1rem; } +.olb-filter-search { display: flex; align-items: center; flex: 1; gap: 1rem; min-width: 0; } +.olb-filter-search input { width: 100%; min-width: 0; } +.olb-filter-facet { display: flex; gap: 1rem; align-items: flex-start; } +.olb-filter-label, .olb-filter-search > span { color: var(--text-2); font-size: .82rem; white-space: nowrap; padding-top: .6rem; } +.olb-filter-chips { display: flex; flex-wrap: wrap; gap: .5rem; } +.olb-filter-chip { padding: .5rem .85rem; border: 1px solid var(--border); border-radius: var(--radius-md); background: color-mix(in srgb, var(--text) 5%, transparent); color: var(--text-2); cursor: pointer; } +.olb-filter-chip[aria-pressed="true"] { color: var(--accent-text); background: var(--accent-soft); border-color: var(--accent); } +.olb-filter-chip:hover { border-color: var(--accent); } +.olb-filter-fields { display: grid; grid-template-columns: repeat(auto-fit,minmax(150px,1fr)); gap: 1rem; } +.olb-filter-fields > label { display: flex; flex-direction: column; gap: .45rem; min-width: 0; color: var(--text-2); font-size: .8rem; } +.olb-filter-advanced summary { cursor: pointer; color: var(--text-2); font-size: .82rem; } +.olb-filter-advanced[open] .olb-filter-fields { margin-top: .85rem; } +.olb-filter-feedback { color: var(--text-2); font-size: .8rem; display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; } +.olb-active-filter { border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--accent-soft); color: var(--text); padding: .25rem .65rem; cursor: pointer; } +.olb-native-filter[hidden] { /* Visibility utility must override component display and inline layout. */ display: none !important; } + +@media (max-width: 640px) { .olb-filter-top { flex-wrap: wrap; } .olb-filter-search { flex-basis: 100%; } .olb-filter-facet { flex-direction: column; gap: .5rem; } } + +button.olb-stat-card { text-align: left; font: inherit; } +button.olb-stat-card:focus-visible, .olb-filter-chip:focus-visible, .olb-active-filter:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } + +.bm-record-source-row .ol-integrity-field { flex: 0 1 480px; max-width: 100%; } + +/* 宽记录表只在自身容器滚动,不参与撑宽主内容或压缩侧栏。 */ +#view-backups .config-card, +#view-backups .config-card-body, +#openlist-backups-list { min-width: 0; max-width: 100%; } + +/* OpenList 记录状态、轻量检查与可排序表头。 */ +.olb-file-records { width: 100%; min-width: 1620px; table-layout: fixed; } +.olb-file-records td { overflow: hidden; } +.olb-file-records .olb-filename-text { display: block; } +.olb-file-records .olb-col-state { min-width: 300px; text-align: left; } +.olb-status-line { display: flex; align-items: center; gap: 6px; min-height: 30px; flex-wrap: nowrap; white-space: nowrap; } +.olb-status-line .olb-badge { flex-shrink: 0; } +.olb-state-tag { display: inline-flex; align-items: center; flex-shrink: 0; gap: 4px; padding: 3px 7px; border: 1px solid var(--border); border-radius: var(--radius-pill); font-size: .7rem; line-height: 1.4; white-space: nowrap; color: var(--text-2); background: var(--surface); } +.olb-state-tag.is-success { color: var(--success-text); border-color: color-mix(in srgb, var(--success) 35%, var(--border)); background: var(--success-soft); } +.olb-state-tag.is-warning { color: var(--warning-text); border-color: color-mix(in srgb, var(--warning) 35%, var(--border)); background: var(--warning-soft); } +.olb-state-tag.is-danger { color: var(--danger-text); border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); background: var(--danger-soft); } +.olb-state-icon { display: block; width: 14px; height: 14px; max-width: 14px; max-height: 14px; flex: 0 0 14px; } +.olb-state-icon.is-local { color: var(--info-text); } +.olb-state-icon.is-remote { color: var(--accent-text); } +.olb-sort-heading { display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; cursor: pointer; white-space: nowrap; } +.olb-sort-heading:hover, .olb-sort-heading:focus-visible { color: var(--text); } +.olb-sort-heading span { color: var(--accent-text); } +.olb-act-btn.is-delete { color: var(--danger-text); border-color: color-mix(in srgb, var(--danger) 35%, var(--border)); } +.olb-act-btn.is-delete:hover { background: var(--danger-soft); } +.olb-record-filter-grid { grid-template-columns: repeat(auto-fit, minmax(175px, 1fr)); gap: .8rem; } +.olb-record-filter-grid .form-input { min-height: 38px; } +.olb-filter-note { font-size: .7rem; font-weight: normal; color: var(--text-2); opacity: .8; } +.olb-check-time { font-size: .7rem; color: var(--accent-text); line-height: 1.5; overflow-wrap: anywhere; } +.olb-check-progress { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; color: var(--text-2); font-size: .8rem; margin-top: 10px; } +.olb-check-progress:empty { display: none; } + +@media (max-width: 700px) { .olb-record-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } } + +@media (max-width: 420px) { .olb-record-filter-grid { grid-template-columns: 1fr; } } + +/* 备份概览:常用指标与配置详情分层展示。 */ +.bm-ov-card .bm-ov-title-group { display: flex; align-items: center; gap: 12px; min-width: 0; } +.bm-ov-card .bm-ov-icon { width: 42px; height: 42px; border-radius: var(--radius-lg); flex: 0 0 42px; margin: 0; } +.bm-ov-card .config-card-title { font-size: .97rem; line-height: 1.5; } +.bm-ov-card .config-card-desc { font-size: .75rem; line-height: 1.6; margin-top: 3px; } +.bm-ov-card .bm-ov-list { gap: 0; } +.bm-ov-card .bm-ov-metric { padding: 10px 0; border-bottom: 1px solid var(--border); align-items: flex-start; gap: 12px; font-size: .8rem; line-height: 1.65; } +.bm-ov-card .bm-ov-metric:last-child { border-bottom: 0; } +.bm-ov-card .bm-ov-list dd { min-width: 0; } +.bm-ov-tags { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 5px; } +.bm-ov-tag { display: inline-flex; align-items: center; padding: 2px 8px; border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); border-radius: var(--radius-md); background: var(--accent-soft); color: var(--accent-text); font-size: .73rem; line-height: 1.6; } +.bm-ov-tag.is-good { color: var(--success-text); border-color: color-mix(in srgb, var(--success) 35%, var(--border)); background: var(--success-soft); } +.bm-ov-time { display: block; font-variant-numeric: tabular-nums; } +.bm-ov-note { display: block; margin-top: 4px; color: var(--text-2); font-size: .72rem; } +.bm-ov-path { font-family: ui-monospace, monospace; font-size: .74rem; } +.bm-ov-details { border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface); } +.bm-ov-details summary { display: flex; justify-content: space-between; align-items: center; list-style: none; cursor: pointer; padding: 11px 12px; color: var(--text-2); font-size: .78rem; } +.bm-ov-details summary::-webkit-details-marker { display: none; } +.bm-ov-details[open] summary { border-bottom: 1px solid var(--border); color: var(--text); } +.bm-ov-details[open] summary > span { transform: rotate(180deg); } +.bm-ov-extra-list { padding: 0 12px; } +.bm-ov-card .bm-ov-actions { gap: 8px; padding-top: 4px; } +.bm-ov-card .bm-ov-actions button { padding: 9px 12px; font-size: .77rem; border-radius: var(--radius-md); } + +@media (max-width: 640px) { .bm-ov-card .bm-ov-tags { justify-content: flex-start; } } + +/* 手机端备份记录:按任务分组,按钮保持可点按尺寸。 */ +.olb-mobile-label { display: none; } + +@media (max-width: 768px) { + #view-backups .olb-toolbar { padding: 12px; gap: 12px; border-radius: var(--radius-lg); } + #view-backups .olb-actions-row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 12px; padding-bottom: 12px; } + #view-backups .olb-actions-primary { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; gap: 8px; } + #view-backups .olb-actions-batch { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); width: 100%; gap: 8px; padding-top: 12px; border-top: 1px solid var(--border); } + #view-backups .olb-actions-primary .olb-btn, + #view-backups .olb-actions-batch .olb-btn { width: 100%; min-width: 0; min-height: 44px; justify-content: center; padding: 9px 7px; gap: 5px; font-size: .76rem; line-height: 1.4; } + #view-backups .olb-actions-batch .olb-btn svg { display: none; } + #view-backups .olb-desktop-label { display: none; } + #view-backups .olb-mobile-label { display: inline; } + #view-backups .olb-selcount { grid-column: 1 / -1; justify-self: start; } + #view-backups .olb-filter-panel { padding-top: 0; border-top: 0; gap: 14px; } + #view-backups .olb-filter-top { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } + #view-backups .olb-filter-search { grid-column: 1 / -1; width: 100%; } + #view-backups .olb-filter-search > span { display: none; } + #view-backups .olb-filter-search input { min-height: 44px; padding: 10px 12px; font-size: 16px; } + #view-backups .olb-filter-top > button { min-width: 0; min-height: 44px; padding: 8px 5px; font-size: .76rem; border-radius: var(--radius-md); } + #view-backups .olb-filter-facet { display: flex; flex-direction: column; gap: 7px; } + #view-backups .olb-filter-label { padding-top: 0; font-size: .75rem; } + #view-backups .olb-filter-chips { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); width: 100%; gap: 6px; } + #view-backups .olb-filter-chip { min-width: 0; min-height: 40px; padding: 8px 4px; font-size: .74rem; text-align: center; white-space: nowrap; } + #view-backups .olb-record-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 8px; } + #view-backups .olb-record-filter-grid > label:last-child { grid-column: 1 / -1; } + #view-backups .olb-filter-fields > label { font-size: .75rem; gap: 5px; } + #view-backups .olb-filter-fields .lx-select-trigger { min-height: 44px; padding: 9px 10px; font-size: .8rem; } + #view-backups .olb-filter-advanced summary { padding: 4px 0; font-size: .75rem; line-height: 1.6; } + #view-backups .olb-filter-advanced .olb-filter-fields { grid-template-columns: minmax(0, 1fr); gap: 12px; } + #view-backups .olb-check-progress { font-size: .73rem; line-height: 1.7; padding: 9px 10px; margin: 0; background: var(--accent-soft); border-radius: var(--radius-md); } + #view-backups .olb-check-progress:empty { display: none; } + #view-backups .olb-filter-feedback { font-size: .73rem; } + #view-backups .olb-status-cards { gap: 8px; margin-bottom: 14px; } + #view-backups .olb-stat-card { padding: 10px 12px; border-radius: var(--radius-lg); } + #view-backups .olb-stat-label { font-size: .75rem; } + #view-backups .olb-stat-num { font-size: 1.5rem; } + #view-backups .olb-pager-right { gap: 6px; } + #view-backups .olb-pager-btn { min-height: 40px; padding: 0 9px; } +} + +/* 备份管理移动端:统一层级、触控尺寸、路径与历史记录布局。 */ +@media (max-width: 768px) { + #view-backups { min-width: 0; } + #view-backups .bm-pane, + #view-backups .bm-restore-panel, + #view-backups .bm-rec-section { gap: 14px; } + #view-backups .config-card { border-radius: var(--radius-lg); } + #view-backups .config-card-header { padding: 14px; gap: 10px; flex-wrap: wrap; } + #view-backups .config-card-body { padding: 14px; } + #view-backups .config-card-icon { width: 36px; height: 36px; flex: 0 0 36px; border-radius: var(--radius-lg); } + #view-backups .config-card-title { font-size: .95rem; line-height: 1.5; } + #view-backups .config-card-desc { font-size: .75rem; line-height: 1.6; } + #view-backups .config-grid-2, + #view-backups .bm-scope-grid, + #view-backups .bm-source-cards, + #view-backups .ol-restore-choices-grid, + #view-backups .ol-restore-types { grid-template-columns: minmax(0, 1fr); } + #view-backups .form-input { min-width: 0; max-width: 100%; min-height: 44px; font-size: 16px; } + #view-backups .config-label { font-size: .8rem; line-height: 1.5; } + #view-backups .config-hint { font-size: .73rem; line-height: 1.7; overflow-wrap: anywhere; } + #view-backups .bm-tabs { flex-wrap: nowrap; overflow-x: auto; gap: 4px; scrollbar-width: thin; } + #view-backups .bm-tabs .backup-tab-btn { flex: 0 0 auto; min-height: 42px; padding: 8px 11px; font-size: .78rem; } + #view-backups .backup-policy-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } + #view-backups .backup-policy-actions > button { min-width: 0; min-height: 44px; padding: 10px 8px; font-size: .76rem; white-space: normal; line-height: 1.5; justify-content: center; } + #view-backups .backup-policy-banner { padding: 11px; font-size: .74rem; line-height: 1.7; align-items: flex-start; overflow-wrap: anywhere; } + #view-backups .backup-segmented-tabs { display: flex; width: 100%; min-width: 0; } + #view-backups .backup-segmented-tabs > button { flex: 1; min-width: 0; min-height: 42px; padding: 8px 6px; font-size: .75rem; } + #view-backups .bm-ov-grid { grid-template-columns: minmax(0, 1fr); gap: 14px; } + #view-backups .bm-ov-card { padding: 14px; } + #view-backups .bm-ov-head { align-items: flex-start; } + #view-backups .bm-ov-card .bm-ov-metric { padding: 8px 0; } + #view-backups .bm-ov-card .bm-ov-actions > button:last-child:nth-child(odd) { grid-column: 1 / -1; } + #view-backups .bm-filter-row .ol-integrity-field { flex: 1 1 100%; min-width: 0; } + #view-backups .ol-integrity-controls { grid-template-columns: minmax(0, 1fr); gap: 12px; } + #view-backups .config-backup-info-card { padding: 14px; gap: 14px; border-radius: var(--radius-lg); } + #view-backups .config-backup-status-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; gap: 12px; } + #view-backups .config-backup-meta-item { min-width: 0; } + #view-backups .config-backup-meta-item.meta-item-path { grid-column: 1 / -1; } + #view-backups .config-backup-meta-item .meta-label { font-size: .72rem; } + #view-backups .config-backup-meta-item .meta-value { font-size: .8rem; line-height: 1.6; white-space: normal; overflow-wrap: anywhere; } + #view-backups .config-backup-actions-wrap { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; gap: 8px; } + #view-backups .config-backup-actions-wrap > button { width: 100%; min-width: 0; min-height: 44px; padding: 9px 6px; font-size: .77rem; justify-content: center; } + #view-backups #bm-pane-local .snapshots-table-header { display: none; } + #view-backups #bm-pane-local .snapshot-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px 8px; padding: 13px; border-radius: var(--radius-lg); } + #view-backups #bm-pane-local .snapshot-row .col-time { grid-column: 1; font-size: .8rem; line-height: 1.5; font-variant-numeric: tabular-nums; } + #view-backups #bm-pane-local .snapshot-row .col-size { display: block; grid-column: 2; font-size: .72rem; color: var(--text-2); align-self: center; } + #view-backups #bm-pane-local .snapshot-row .col-id { grid-column: 1 / -1; min-width: 0; font-size: .74rem; line-height: 1.7; overflow-wrap: anywhere; } + #view-backups #bm-pane-local .snapshot-row .col-id > div { min-width: 0; gap: 5px; } + #view-backups #bm-pane-local .snapshot-row .col-id span { max-width: 100%; overflow-wrap: anywhere; } + #view-backups #bm-pane-local .snapshot-actions { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; width: 100%; } + #view-backups #bm-pane-local .snapshot-actions > button { display: inline-flex; width: 100%; min-width: 0; min-height: 42px; height: auto; padding: 8px 5px; font-size: .74rem; justify-content: center; border-radius: var(--radius-md); } + #view-backups #snapshot-toolbar-actions { display: flex; flex-wrap: wrap; gap: 7px; width: 100%; } + #view-backups #snapshot-toolbar-actions .lx-select { flex: 1 1 120px; min-width: 0; } + #view-backups #snapshot-toolbar-actions > button { flex: 1 1 70px; min-height: 42px; padding: 8px; font-size: .75rem; } + #view-backups .ol-restore-form { gap: 14px; } + #view-backups .ol-restore-section { padding: 13px; border-radius: var(--radius-lg); } + #view-backups .ol-section-header { align-items: flex-start; gap: 9px; } + #view-backups .ol-restore-choice, + #view-backups .ol-restore-type { padding: 12px; gap: 9px; } + #view-backups .ol-restore-actions-bar, + #view-backups .ol-restore-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } + #view-backups .ol-restore-actions-bar .ol-action-btn { width: 100%; min-width: 0; min-height: 46px; padding: 9px 6px; font-size: .75rem; line-height: 1.5; white-space: normal; } + #view-backups .ol-restore-result-box { padding: 12px; border-radius: var(--radius-lg); } + #view-backups .ol-preview-empty { align-items: flex-start; gap: 10px; padding: 2px; } + #view-backups .ol-preview-empty-icon { width: 32px; height: 32px; border-radius: var(--radius-md); } + #view-backups .ol-preview-empty-text { min-width: 0; } + #view-backups .ol-preview-empty-text strong { font-size: .8rem; line-height: 1.6; } + #view-backups .ol-preview-empty-text p { font-size: .73rem; line-height: 1.7; } + #view-backups .ol-preview-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } + #view-backups .ol-restore-tasks-card { padding: 13px; gap: 12px; border-radius: var(--radius-lg); } + #view-backups .ol-tasks-header-left { align-items: flex-start; min-width: 0; gap: 9px; } + #view-backups .ol-tasks-title { font-size: .87rem; } + #view-backups .ol-tasks-subtitle { font-size: .72rem; line-height: 1.7; } + #view-backups .ol-restore-tasks-header > button { width: 100%; min-height: 42px; justify-content: center; } + #view-backups .ol-tasks-empty { padding: 16px 12px; font-size: .75rem; line-height: 1.7; } + #view-backups .ol-task-actions { display: flex; flex-wrap: wrap; gap: 7px; } + #view-backups .ol-task-btn { min-height: 42px; flex: 1 1 90px; padding: 8px; justify-content: center; } + #view-backups .ol-task-id-badge { max-width: 100%; overflow-wrap: anywhere; } + #view-backups .backup-table-wrapper, + #view-backups .ol-restore-files { max-width: 100%; overflow-x: auto; } + #modal .modal-content { width: calc(100% - 24px); max-width: 560px; max-height: calc(100dvh - 32px); padding: 18px 15px; border-radius: var(--radius-lg); } + #modal .modal-header { margin-bottom: 14px; gap: 10px; } + #modal .modal-header h3 { font-size: 1rem; line-height: 1.5; overflow-wrap: anywhere; } + #modal .modal-body { min-width: 0; } + #modal .form-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } + #modal .form-actions > button { min-width: 0; min-height: 44px; padding: 10px 6px; font-size: .8rem; } + #modal .form-input { min-height: 44px; font-size: 16px; } + #modal .olb-detail-table { font-size: .75rem; } + #modal .olb-detail-key { width: 80px; } + #modal .olb-detail-table td { padding: 8px 5px; overflow-wrap: anywhere; } +} + +@media (max-width: 768px) { + #view-backups #ol-integrity-results { min-width: 0; max-width: 100%; overflow-x: auto; } + #view-backups .ol-integrity-filter-bar { flex-wrap: wrap; gap: 10px; } + #view-backups .ol-integrity-filter-bar > * { min-width: 0; flex: 1 1 100%; } + #view-backups .ol-integrity-summary-line { flex-wrap: wrap; gap: 7px; } + #view-backups .ol-restore-source-banner { padding: 10px; gap: 8px; font-size: .75rem; } + #view-backups .bm-subnav { gap: 6px; } + #view-backups .bm-subnav a { min-height: 38px; display: inline-flex; align-items: center; padding: 6px 10px; font-size: .75rem; } +} +.admin-overflow-label { + overflow: hidden; + white-space: nowrap; + text-overflow: clip; + min-width: 0; +} + +.admin-overflow-track { + display: inline-block; + width: max-content; + max-width: none; + vertical-align: bottom; + transform: translateX(0); +} + +@keyframes admin-list-overflow { + 0%, 15% { transform: translateX(0); } + 85%, 100% { transform: translateX(var(--admin-overflow-distance)); } +} + +.admin-is-overflowing > .admin-overflow-track { + animation: admin-list-overflow var(--admin-overflow-duration) linear infinite alternate; +} + +@media (min-width: 768px) and (hover: hover) and (pointer: fine) { + .admin-is-overflowing > .admin-overflow-track { + animation: none; + } + + .admin-is-overflowing:hover > .admin-overflow-track { + animation: admin-list-overflow var(--admin-overflow-duration) linear infinite alternate; + } +} + +/* Standard fallback; Chromium/WebKit use the rounded pseudo-elements above. */ +:root * { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track); +} + +@supports selector(::-webkit-scrollbar) { + :root * { + scrollbar-width: auto; + scrollbar-color: auto; + } +} + +/* Record details need room for paths, hashes and metadata. */ +#modal .modal-content:has(.olb-record-detail-table) { + width: 1080px; + max-width: calc(100vw - 48px); + max-height: calc(100dvh - 48px); + padding: 28px 32px; + background: var(--surface); +} +#modal:has(.olb-record-detail-table) .modal-header { + align-items: flex-start; + gap: 24px; +} +#modal:has(.olb-record-detail-table) .modal-header h3 { + flex: 1; + min-width: 0; + font-size: 1.3rem; + line-height: 1.5; +} +.olb-detail-subtitle { + display: block; + margin-top: 6px; + color: var(--text-2); + font-size: .82rem; + font-weight: 400; + overflow-wrap: anywhere; +} +#modal:has(.olb-record-detail-table) .modal-close { flex-shrink: 0; } +#modal:has(.olb-record-detail-table) .modal-body { min-width: 0; } +.olb-record-detail-table { + table-layout: fixed; + line-height: 1.65; +} +#modal .olb-record-detail-table .olb-detail-key { + width: 180px; + white-space: normal; + vertical-align: top; + color: var(--text-2); +} +#modal .olb-record-detail-table td { + padding: 12px 16px; + overflow-wrap: anywhere; + white-space: normal; +} +.olb-record-detail-table tr:nth-child(odd) { background: var(--surface); } +.olb-record-detail-table .ol-meta-cover-preview, +.olb-record-detail-table .ol-meta-tag { max-width: 100%; min-width: 0; } +.olb-record-detail-table .ol-meta-tag-val { + min-width: 0; + white-space: normal; + overflow-wrap: anywhere; +} + +@media (max-width: 640px) { + #modal .modal-content:has(.olb-record-detail-table) { + width: calc(100% - 24px); + max-width: calc(100% - 24px); + max-height: calc(100dvh - 24px); + padding: 18px 14px; + } + #modal .olb-record-detail-table .olb-detail-key { width: 100px; } + #modal .olb-record-detail-table td { padding: 10px 8px; } +} + +/* 与 Subsonic 搜索说明一致的评分提示,正文适应窄屏。 */ +.subsonic-rating-note { + background: var(--danger-soft); + padding: 10px 12px; + border-radius: var(--radius-sm); + line-height: 1.6; + overflow-wrap: anywhere; +} +.subsonic-rating-note p { margin: 5px 0; } +.subsonic-rating-note [role="status"] { display: block; margin-top: 6px; } + +/* 数据查看:不喜欢 / 评分的基础布局与交互样式。 */ +.admin-dislikes { margin-top: 28px; min-width: 0; position: relative; margin-block: 26px; animation: admin-panel-in .28s ease-out; } +.admin-dislike-icon, .admin-flat-icon { background: var(--surface-hover); color: var(--text); border: 1px solid var(--border); } + +/* 统计卡片:共享基础样式,列数随视口调整。 */ +.admin-dislike-stats, .admin-flat-stats { flex-wrap: wrap; margin: 16px 0 12px; border-radius: var(--radius-md); display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 12px; border: 0; background: transparent; overflow: visible; margin-bottom: 18px; } +.admin-flat-stats > *, .admin-dislike-stat { flex: 1 1 110px; display: flex; flex-direction: column; gap: 2px; align-items: flex-start; border-right: 1px solid var(--border); color: inherit; font: inherit; cursor: pointer; position: relative; min-width: 0; padding: 18px; border: 1px solid var(--border); border-radius: var(--radius-lg); text-align: left; background: color-mix(in srgb, var(--surface) 92%, transparent); transition: border-color .18s ease, background .18s ease, transform .18s ease; } +.admin-flat-stats > :last-child, .admin-dislike-stat:last-child { border-right: 0; } +.admin-flat-stats > .static, .admin-dislike-stat.static { cursor: default; } +.admin-flat-stats b, .admin-dislike-stat .s-num { font-weight: 600; display: block; font-size: clamp(1.25rem, 2vw, 1.8rem); line-height: 1.2; font-variant-numeric: tabular-nums; letter-spacing: -.04em; } +.admin-flat-stats span, .admin-dislike-stat .s-label { font-size: .72rem; color: var(--text-2); } +.admin-dislike-stat .s-icon { display: none; position: absolute; top: 16px; right: 16px; color: var(--accent-text); opacity: .7; } +.admin-flat-stats > button:hover, .admin-dislike-stat:not(.static):hover { background: var(--surface-hover); transform: translateY(-2px); border-color: var(--accent); } +.admin-flat-stats > button.on, .admin-dislike-stat.on { box-shadow: inset 0 -2px 0 var(--accent); background: var(--surface-hover); } + +.admin-dist { display: grid; gap: 4px; max-width: none; margin: 0; } +.admin-dist-row { display: grid; grid-template-columns: 26px 1fr 34px; gap: 8px; align-items: center; font-size: .72rem; color: var(--text-2); } +.admin-dist-row i { background: var(--surface); border-radius: var(--radius-sm); overflow: hidden; height: 6px; } +.admin-dist-row u { display: block; height: 100%; text-decoration: none; border-radius: var(--radius-sm); transition: width .35s ease; background: var(--warning); color: var(--on-warning); } +.admin-dist-row em { font-style: normal; text-align: right; font-variant-numeric: tabular-nums; } + +.admin-dislike-toolbar { display: grid; grid-template-columns: minmax(200px, 2.2fr) repeat(4, minmax(110px, 1fr)) auto; gap: 8px; margin: 8px 0 10px; align-items: center; } +.admin-ratings .admin-dislike-toolbar { grid-template-columns: minmax(200px, 2.2fr) repeat(4, minmax(110px, 1fr)); } +.admin-dislike-toolbar > * { min-width: 0; } +.admin-dislike-toolbar select, .admin-dislike-search input { width: 100%; } +.admin-dislike-search { position: relative; } +.admin-dislike-search > span { display: none; } +.admin-view-toggle { display: inline-flex; border-radius: var(--radius-sm); overflow: hidden; border: 1px solid var(--border); } +.admin-view-toggle button { background: transparent; border: 0; color: inherit; padding: 8px 12px; cursor: pointer; } +.admin-view-toggle button.on { background: var(--surface-hover); } + +/* 行式列表(默认) */ +.admin-rows, .admin-dislike-grid.is-list { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; } +.admin-row { display: grid; grid-template-columns: 44px minmax(0, 1fr) auto auto; gap: 14px; align-items: center; border-bottom: 1px solid var(--border); padding: 16px; transition: background .16s ease; animation: admin-panel-in .22s ease-out; } +.admin-row:last-child { border-bottom: 0; } +.admin-row:hover { background: var(--surface-hover); } +.admin-row-cover { position: relative; background: var(--surface); display: grid; place-items: center; color: var(--text-2); overflow: hidden; width: 48px; height: 48px; border-radius: var(--radius-md); } +.admin-row-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; transition: transform .22s ease; } +.admin-row.kind-artist .admin-row-cover { border-radius: 50%; } +.admin-row-title { font-weight: 600; font-size: .9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.admin-row-sub { font-size: .76rem; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px; } +.admin-row-meta { display: flex; gap: 6px; flex-wrap: wrap; justify-content: flex-end; } +.admin-row-meta span { color: var(--text-2); border: 1px solid var(--border); white-space: nowrap; border-radius: var(--radius-sm); padding: 3px 7px; font-size: .68rem; background: var(--surface); } +.admin-row-meta span:first-child { color: inherit; } +.admin-row-score { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; min-width: 120px; } +.admin-row-score small { font-size: .68rem; color: var(--text-2); } + +/* 不喜欢:卡片视图保持克制 */ +.admin-dislike-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; } +.admin-dislike-grid.is-list { gap: 0; } +.admin-dislike-card { display: flex; align-items: flex-start; gap: 14px; min-width: 0; padding: 14px; border: 1px solid var(--border); border-radius: var(--radius-md); background: transparent; transition: border-color .18s ease, background .18s ease, transform .18s ease; animation: admin-panel-in .22s ease-out; } +.is-list .admin-dislike-card { border: 0; border-bottom: 1px solid var(--border); border-radius: 0; padding: 10px 14px; align-items: center; } +.is-list .admin-dislike-card:last-child { border-bottom: 0; } +.is-list .admin-dislike-cover { width: 44px; height: 44px; flex-basis: 44px; font-size: 18px; } +.admin-dislike-cover { width: 76px; height: 76px; flex: 0 0 76px; overflow: hidden; border-radius: var(--radius-sm); background: var(--surface); display: grid; place-items: center; color: var(--text-2); font-size: 26px; } +.admin-dislike-card.kind-artist .admin-dislike-cover { border-radius: 50%; } +.admin-dislike-cover a, .admin-dislike-cover img { display: block; width: 100%; height: 100%; object-fit: cover; } +.admin-dislike-main { flex: 1; min-width: 0; overflow-wrap: anywhere; } +.admin-dislike-main h4 { font-size: .95rem; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; margin: 0 0 5px; } +.admin-dislike-main p { color: var(--text-2); margin: 0 0 4px; font-size: .78rem; } +.admin-dislike-badges { display: flex; flex-wrap: wrap; gap: 5px; } +.admin-dislike-badges span { font-size: .7rem; padding: 1px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border); color: var(--text-2); } +.admin-dislike-badges span.k { color: inherit; } +.admin-dislike-rating { display: flex; align-items: center; gap: 6px; font-size: .78rem; margin-bottom: 2px; } +.admin-dislike-rating .muted { color: var(--text-2); opacity: .7; } +.admin-dislike-rating b { color: var(--warning-text); font-weight: 600; } +.admin-stars { --v: 0; display: inline-block; font-size: 14px; letter-spacing: 2px; line-height: 1; color: var(--border-strong); position: relative; white-space: nowrap; } +.admin-dislike-main summary { cursor: pointer; color: var(--text-2); font-size: .78rem; padding: 6px 0; } +.admin-dislike-main dl { margin: 8px 0; font-size: .78rem; } +.admin-dislike-main dl > div { display: grid; grid-template-columns: 72px minmax(0,1fr); gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--border); } +.admin-dislike-main dt { color: var(--text-2); } +.admin-dislike-main dd { margin: 0; } +.admin-dislike-main pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 360px; overflow: auto; padding: 12px; background: var(--surface-2); border-radius: var(--radius-sm); font-size: .72rem; } +.admin-dislike-summary { font-size: .78rem; color: var(--text-2); margin: 12px 0 8px; } +.admin-dislike-empty { padding: 40px 12px; text-align: center; color: var(--text-2); border: 1px dashed var(--border); border-radius: var(--radius-md); } +.admin-dislike-empty .big { font-size: 28px; opacity: .4; } +.admin-dislike-pages { display: flex; justify-content: center; gap: 10px; margin: 16px 0; } + +@media (max-width: 1100px) { .admin-dislike-toolbar, .admin-ratings .admin-dislike-toolbar { grid-template-columns: repeat(2, minmax(0,1fr)); } .admin-dislike-search { grid-column: 1/-1; } } + +@media (max-width: 720px) { + .admin-row { grid-template-columns: 44px minmax(0, 1fr); row-gap: 6px; } + .admin-row-meta { grid-column: 2; justify-content: flex-start; } + .admin-row-score { grid-column: 2; flex-direction: row; align-items: center; justify-content: space-between; min-width: 0; } + .admin-flat-stats > *, .admin-dislike-stat { flex-basis: 33%; } + .admin-dislike-grid { grid-template-columns: minmax(0,1fr); } + .admin-dislike-card { padding: 12px; gap: 10px; } + .admin-dislike-cover { width: 56px; height: 56px; flex-basis: 56px; } +} + + +/* 用户音乐数据看板:复用主题变量和 lx-select,限定在数据查看区。 */ +.admin-dislikes:has(.lx-select.is-open) { z-index: 30; } +.admin-dislikes .data-section-header { margin-bottom: 20px; } +.admin-dislikes .data-tab-count { border-radius: var(--radius-pill); padding: 5px 12px; background: color-mix(in srgb, var(--accent) 10%, transparent); border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); } +.admin-dislike-stats { grid-template-columns: repeat(6, minmax(0, 1fr)); } +.admin-flat-stats > .on, .admin-dislike-stat.on { background: color-mix(in srgb, var(--accent) 12%, var(--surface)); border-color: var(--accent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 14%, transparent); } +.admin-flat-stats > * > span, .admin-dislike-stat .s-label { display: block; color: var(--text-2); margin-top: 8px; font-size: .76rem; } +.admin-rating-insights { display: grid; grid-template-columns: minmax(0, 1fr) minmax(200px, .65fr); gap: 24px; padding: 22px; margin-bottom: 18px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: color-mix(in srgb, var(--surface) 72%, transparent); } +.admin-eyebrow { display: block; color: var(--text-2); font-size: .75rem; letter-spacing: .06em; margin-bottom: 14px; } +button.admin-dist-row { width: 100%; border: 1px solid transparent; border-radius: var(--radius-md); padding: 6px 8px; background: transparent; text-align: left; cursor: pointer; font: inherit; font-size: .76rem; transition: background .18s ease; } +button.admin-dist-row:hover, button.admin-dist-row.on { background: color-mix(in srgb, var(--accent) 10%, transparent); } +button.admin-dist-row.on { border-color: color-mix(in srgb, var(--accent) 35%, transparent); } +.admin-insight-score { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; border-left: 1px solid var(--border); } +.admin-score-ring { width: 100px; height: 100px; border-radius: 50%; background: var(--surface); display: flex; flex-direction: column; justify-content: center; align-items: center; position: relative; isolation: isolate; } +.admin-score-ring svg { position: absolute; inset: 0; width: 100%; height: 100%; transform: rotate(-90deg); pointer-events: none; fill: none; stroke-width: 7px; } +.admin-score-track { stroke: var(--border); } +.admin-score-value { stroke: var(--warning); stroke-dasharray: calc(var(--score) / 5 * 100) 100; } +.admin-score-ring b { font-size: 1.6rem; font-variant-numeric: tabular-nums; } +.admin-score-ring span, .admin-insight-score small { font-size: .7rem; color: var(--text-2); } +.admin-insight-score p { margin: 12px 0 5px; font-size: .8rem; } +.admin-dislike-toolbar, .admin-ratings .admin-dislike-toolbar { grid-template-columns: minmax(200px, 1.7fr) repeat(5, minmax(110px, 1fr)); padding: 14px; border: 1px solid var(--border); border-radius: var(--radius-lg); background: color-mix(in srgb, var(--surface) 65%, transparent); } +.admin-extra-filters { display: flex; flex-wrap: wrap; gap: 12px; align-items: flex-end; margin: 12px 0; } +.admin-filter-field { display: flex; flex-direction: column; gap: 7px; min-width: 145px; flex: 1 1 145px; max-width: 240px; } +.admin-filter-field > span { color: var(--text-2); font-size: .73rem; } +.admin-extra-filters .lx-select { width: 100%; } +.admin-filter-reset { margin-left: auto; min-height: 44px; } +.admin-dislikes .lx-select-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.admin-rows { border-radius: var(--radius-lg); } +.admin-row-main { min-width: 0; display: flex; flex-direction: column; align-items: flex-start; } +.admin-row:hover .admin-row-cover img { transform: scale(1.06); } +.admin-rating-detail { margin-top: 7px; color: var(--text-2); font-size: .72rem; } +.admin-rating-detail summary { width: fit-content; cursor: pointer; color: var(--accent-text); } +.admin-rating-detail p { overflow-wrap: anywhere; } +.admin-rating-detail pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 260px; overflow: auto; padding: 12px; border-radius: var(--radius-md); background: var(--surface-2); } +.admin-dislike-grid:not(.is-list) .admin-dislike-card:hover { transform: translateY(-3px); } +.admin-dislikes button:focus-visible, .admin-rating-detail summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } + +@keyframes admin-panel-in { from { opacity: .4; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } } + +@media (max-width: 1400px) { .admin-dislike-toolbar, .admin-ratings .admin-dislike-toolbar { grid-template-columns: repeat(3, minmax(0, 1fr)); } .admin-dislike-search { grid-column: span 2; } } + +@media (max-width: 900px) { .admin-flat-stats, .admin-dislike-stats { grid-template-columns: repeat(3, minmax(0,1fr)); } .admin-rating-insights { gap: 16px; padding: 16px; } .admin-flat-stats > *, .admin-dislike-stat { padding: 14px; } } + +@media (max-width: 600px) { .admin-dislike-toolbar, .admin-ratings .admin-dislike-toolbar { grid-template-columns: repeat(2,minmax(0,1fr)); padding: 10px; } .admin-dislike-search { grid-column: 1 / -1; } .admin-flat-stats, .admin-dislike-stats { grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; } .admin-rating-insights { grid-template-columns: 1fr; } .admin-insight-score { border-left: 0; border-top: 1px solid var(--border); padding-top: 16px; } .admin-filter-field { max-width: none; } .admin-filter-reset { margin-left: 0; } } + +@media (prefers-reduced-motion: reduce) { .admin-dislikes, .admin-dislikes *, .admin-dislikes *::before { animation: none; transition: none; scroll-behavior: auto; } } + +.admin-dislikes .admin-dislike-stat .s-icon { display: block; } +.admin-dislikes .admin-flat-stats > :last-child, .admin-dislikes .admin-dislike-stat:last-child { border-right: 1px solid var(--border); } + +@media (min-width: 721px) { .admin-row { grid-template-columns: 48px minmax(0,1fr) auto auto; } } + +.admin-row-cover > a { position: absolute; inset: 0; z-index: 1; } +.adm-open { margin-top: 6px; padding: 4px 10px; font-size: .74rem; border: 1px solid var(--border); border-radius: var(--radius-sm); background: transparent; color: var(--text-2); cursor: pointer; } +.adm-open:hover { color: inherit; background: var(--surface-hover); } + +.adm-overlay { position: fixed; inset: 0; z-index: 10000; display: grid; place-items: center; padding: 20px; background: var(--overlay); backdrop-filter: none; animation: adm-fade .16s ease-out; } +.adm-overlay.closing { opacity: 0; transition: opacity .16s; } +.adm-panel { width: min(640px, 100%); max-height: min(86vh, 760px); display: flex; flex-direction: column; background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-lg); box-shadow: var(--shadow-sm); outline: 0; overflow: hidden; animation: adm-pop .2s cubic-bezier(.2,.9,.3,1.1); } +.adm-grip { display: none; } +.adm-head { display: flex; gap: 16px; align-items: flex-start; padding: 20px 20px 16px; border-bottom: 1px solid var(--border); position: relative; } +.adm-cover { flex: 0 0 96px; width: 96px; height: 96px; border-radius: var(--radius-md); overflow: hidden; background: var(--surface); display: grid; place-items: center; font-size: 34px; color: var(--text-2); } +.adm-cover img { width: 100%; height: 100%; object-fit: cover; display: block; } +.adm-title { min-width: 0; flex: 1; padding-right: 36px; } +.adm-title h3 { margin: 8px 0 2px; font-size: 1.15rem; line-height: 1.3; overflow-wrap: anywhere; } +.adm-title p { margin: 0 0 8px; font-size: .82rem; color: var(--text); overflow-wrap: anywhere; } +.adm-tags { display: flex; flex-wrap: wrap; gap: 5px; } +.adm-tags span { font-size: .7rem; padding: 1px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border); color: var(--text-2); } +.adm-tags span.k { color: inherit; background: var(--surface); } +.adm-score { display: flex; align-items: center; gap: 8px; } +.adm-score.muted { color: var(--text-2); font-size: .8rem; } +.adm-score b { color: var(--warning-text); } +.admin-stars.lg { font-size: 20px; } +.adm-x { position: absolute; top: 14px; right: 14px; width: 30px; height: 30px; border-radius: var(--radius-sm); border: 0; background: transparent; color: var(--text-2); cursor: pointer; } +.adm-x:hover { background: var(--surface-hover); color: inherit; } +.adm-body { overflow: auto; padding: 4px 20px 20px; } +.adm-section { padding-top: 16px; } +.adm-section h5 { margin: 0 0 8px; font-size: .7rem; letter-spacing: .08em; color: var(--text); font-weight: 600; } +.adm-section dl { margin: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; } +.adm-field { padding: 8px 0; border-bottom: 1px solid var(--border); min-width: 0; } +.adm-field dt { font-size: .7rem; color: var(--text-2); margin-bottom: 2px; } +.adm-field dd { margin: 0; display: flex; align-items: center; gap: 8px; font-size: .86rem; min-width: 0; } +.adm-val { overflow-wrap: anywhere; min-width: 0; } +.adm-val.mono { font-family: ui-monospace, Consolas, monospace; font-size: .78rem; } +.adm-copy { flex: none; font-size: .68rem; padding: 1px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: transparent; color: var(--text-2); cursor: pointer; } +.adm-copy:hover { color: inherit; } +.adm-chips { display: flex; flex-wrap: wrap; gap: 5px; } +.adm-chips span { font-size: .74rem; padding: 2px 8px; border-radius: var(--radius-sm); background: var(--surface); } +.adm-field:has(.adm-chips) { grid-column: 1 / -1; } +.adm-raw { margin-top: 18px; } +.adm-raw summary { cursor: pointer; font-size: .78rem; color: var(--text-2); } +.adm-raw-tools { margin: 8px 0; } +.adm-raw pre { max-height: 240px; overflow: auto; margin: 0; padding: 12px; border-radius: var(--radius-sm); background: var(--surface-2); font-size: .72rem; white-space: pre-wrap; overflow-wrap: anywhere; } + +@keyframes adm-fade { from { opacity: 0; } } + +@keyframes adm-pop { from { opacity: 0; transform: translateY(10px) scale(.97); } } + +@keyframes adm-sheet { from { transform: translateY(100%); } } + +@media (max-width: 600px) { + .adm-overlay { align-items: end; padding: 0; } + .adm-panel { width: 100%; max-height: 90vh; border-radius: 16px 16px 0 0; animation: adm-sheet .26s cubic-bezier(.2,.9,.3,1); } + .adm-grip { display: block; width: 36px; height: 4px; border-radius: var(--radius-sm); background: var(--surface); margin: 8px auto 0; } + .adm-head { padding: 12px 16px; gap: 12px; } + .adm-cover { flex-basis: 72px; width: 72px; height: 72px; } + .adm-body { padding: 0 16px calc(16px + env(safe-area-inset-bottom)); } + .adm-section dl { grid-template-columns: minmax(0, 1fr); } +} + +@media (prefers-reduced-motion: reduce) { .adm-overlay, .adm-panel { animation: none; } } + +/* Subsonic 播放缓存:独立布局,避免说明挤占配置项。 */ +.subsonic-cache-settings { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 18px 28px; align-items: center; } +.subsonic-cache-settings .config-toggle-row { min-width: 0; min-height: 48px; gap: 16px; margin: 0; } +.subsonic-cache-settings .config-toggle-info { min-width: 0; } +.subsonic-cache-settings .toggle-switch { flex-shrink: 0; } +.subsonic-cache-settings .config-field { min-width: 0; margin: 0; } +.subsonic-cache-help-row { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; padding-top: 14px; border-top: 1px solid var(--border); font-size: .8rem; color: var(--text-2); } +.subsonic-cache-help-row > span { display: inline-flex; align-items: center; gap: 6px; } +.subsonic-cache-help-row a { color: var(--accent-text); text-decoration: none; } +.subsonic-cache-help-row a:hover { text-decoration: underline; } + +@media(max-width:720px) { .subsonic-cache-settings { grid-template-columns: minmax(0,1fr); gap: 14px; } } + +/* User data: consistent dislike rows and compact cards. */ +.admin-dislike-score { min-width: 104px; text-align: right; } +.admin-dislike-score .admin-dislike-rating { justify-content: flex-end; gap: 8px; } +.admin-dislike-score small { color: var(--text-2); font-size: .72rem; } +.admin-dislike-grid.is-list .admin-dislike-card { display: grid; grid-template-columns: 56px minmax(180px, 1fr) minmax(160px, auto) 116px; gap: 18px; padding: 18px 20px; } +.admin-dislike-grid.is-list .admin-dislike-cover { width: 56px; height: 56px; } +.admin-dislike-grid.is-list .admin-dislike-badges { justify-content: flex-end; max-width: 360px; } +.admin-dislike-main .adm-open { margin-top: 6px; } +.admin-dislike-grid:not(.is-list) .admin-dislike-card { display: grid; grid-template-columns: 64px minmax(0, 1fr); align-items: start; } +.admin-dislike-grid:not(.is-list) .admin-dislike-cover { width: 64px; height: 64px; } +.admin-dislike-grid:not(.is-list) .admin-dislike-badges { grid-column: 1 / -1; } +.admin-dislike-grid:not(.is-list) .admin-dislike-score { grid-column: 1 / -1; display: flex; align-items: center; justify-content: space-between; text-align: left; } +.admin-rating-status-help { margin-left: 6px; border: 0; padding: 0; font: inherit; font-size: .75rem; vertical-align: middle; } + +@media (max-width: 1100px) { + .admin-dislike-grid.is-list .admin-dislike-card { grid-template-columns: 48px minmax(0, 1fr) 104px; gap: 12px; } + .admin-dislike-grid.is-list .admin-dislike-cover { width: 48px; height: 48px; } + .admin-dislike-grid.is-list .admin-dislike-badges { grid-column: 2; grid-row: 2; justify-content: flex-start; } + .admin-dislike-grid.is-list .admin-dislike-score { grid-column: 3; grid-row: 1 / 3; } +} + +@media (max-width: 540px) { + .admin-dislike-grid.is-list .admin-dislike-card { padding: 14px 12px; grid-template-columns: 44px minmax(0, 1fr); } + .admin-dislike-grid.is-list .admin-dislike-cover { width: 44px; height: 44px; } + .admin-dislike-grid.is-list .admin-dislike-score { grid-column: 2; grid-row: 3; display: flex; align-items: center; justify-content: space-between; gap: 8px; text-align: left; } + .admin-dislike-grid.is-list .admin-dislike-badges { grid-column: 2; } +} + +.admin-dislikes > .data-section-header > .data-tab-count { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; min-width: 48px; height: auto; line-height: 1.5; white-space: nowrap; background-image: none; overflow: visible; } +.admin-dislikes > .data-section-header > .data-tab-count::before, .admin-dislikes > .data-section-header > .data-tab-count::after { content: none; } + +.admin-section-count { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; min-width: 48px; padding: 5px 12px; border: 1px solid color-mix(in srgb, var(--info) 35%, var(--border)); border-radius: var(--radius-pill); background: var(--info-soft); color: var(--text-2); font-size: .75rem; font-weight: 600; line-height: 1.5; white-space: nowrap; } + + +/* Backup destination health is independent of historical file results. */ +.olb-connection { + display: flex; align-items: center; gap: 16px; margin: 18px 0; + padding: 20px 22px; border: 1px solid var(--border); + border-radius: var(--radius-lg); background: var(--surface-2); color: var(--text); +} +.olb-connection:empty { display: none; } +.olb-connection-symbol { display: grid; place-items: center; flex: 0 0 46px; height: 46px; border-radius: var(--radius-lg); font-size: 25px; color: var(--text-2); background: var(--surface); } +.olb-connection[data-state="online"] .olb-connection-symbol { color: var(--success-text); background: var(--success-soft); } +.olb-connection[data-state="unavailable"] .olb-connection-symbol, +.olb-connection[data-state="auth_error"] .olb-connection-symbol { color: var(--warning-text); background: var(--warning-soft); } +.olb-connection-copy { flex: 1; min-width: 0; } +.olb-connection-copy strong { font-size: 15px; color: var(--text); font-weight: 650; } +.olb-connection-copy p { margin: 6px 0; font-size: 13px; opacity: 1; line-height: 1.6; color: var(--text-2); } +.olb-connection-meta { font-size: 12px; opacity: 1; line-height: 1.7; color: var(--text-2); } +.olb-probe-button { flex-shrink: 0; min-width: 112px; border-radius: var(--radius-lg); } + +@media (max-width: 640px) { + .olb-connection { flex-wrap: wrap; padding: 16px; gap: 12px; } + .olb-connection-copy { flex-basis: calc(100% - 60px); } + .olb-probe-button { width: 100%; } +} + +.help-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + margin-left: .35rem; + border-radius: 50%; + cursor: pointer; + user-select: none; + background: var(--accent); + color: var(--text-on-accent); + font-size: 10px; + font-weight: 700; + line-height: 1; + vertical-align: middle; + border: none; + padding: 0; + font-family: inherit; +} + +.help-icon:hover { + opacity: .85; +} + +.help-pop { + position: absolute; + z-index: 9999; + max-width: 320px; + width: max-content; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: .55rem .7rem; + font-size: .8rem; + line-height: 1.55; + box-shadow: var(--shadow-sm); + white-space: normal; +} + +.config-toggle-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0 1.5rem; +} + +.config-toggle-grid .config-toggle-row { + margin-top: 0; +} + +@media (max-width:760px) { + .config-toggle-grid { + grid-template-columns: 1fr; + } +} + +.config-section-title { + font-weight: 600; + margin: 20px 0 12px; + padding-left: 10px; + border-left: 3px solid var(--accent); + color: var(--text); + font-size: .95rem; + line-height: 1.4; +} + +.config-grid-2 { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.1rem 1.5rem; + align-items: center; +} + +.config-grid-2>.config-toggle-row { + margin: 0; +} + +.config-grid-2>.config-field { + margin: 0; +} + +@media (max-width:760px) { + .config-grid-2 { + grid-template-columns: 1fr; + } +} + +.config-subpanel { + margin-top: 12px; + padding: 14px 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); +} + +.config-subpanel>.config-field { + margin-top: 14px; +} + +.config-subpanel>.config-field:first-child { + margin-top: 0; +} + +#subsonic-custom-playlist-options>.config-toggle-row { + min-height: 44px; +} + +#subsonic-custom-playlist-options>.config-toggle-row+.config-toggle-row { + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid var(--border); +} + +.tone-muted { + color: var(--text-3); +} + +.tone-info-soft { + background: var(--info-soft); + color: var(--info-text); +} + +.tone-fill-info { + background: var(--info); + color: var(--on-info); +} + +.tone-fill-accent { + background: var(--accent); + color: var(--text-on-accent); +} + +.tone-success-soft { + background: var(--success-soft); + color: var(--success-text); +} + +.tone-fill-success { + background: var(--success); + color: var(--on-success); +} + +.tone-fill-danger { + background: var(--danger); + color: var(--on-danger); +} + +.tone-secondary { + color: var(--text-2); +} + +.tone-text { + color: var(--text); +} + +.tone-inset-dashed { + background: color-mix(in srgb, var(--text) 5%, transparent); + border: 1px dashed var(--border); +} + +.tone-info { + color: var(--info-text); +} + +.tone-danger-soft { + color: var(--danger-text); + background: var(--danger-soft); +} + +.tone-accent-rule { + border-left: 2px solid var(--accent); +} + +.tone-danger { + color: var(--danger-text); +} + +.tone-accent { + color: var(--accent-text); +} + +.tone-danger-note { + background: var(--danger-soft); + color: var(--text-2); +} + +.badge-success { + --badge-color: var(--success); +} + +.rule-top { + border-top: 1px solid var(--border); +} + +.rule-block { + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); } -.olb-actions-row { - display: flex; - align-items: center; - gap: 0.65rem; - flex-wrap: wrap; +.rule-bottom { + border-bottom: 1px solid var(--border); } -.olb-filters-row { - display: flex; - align-items: center; - gap: 0.6rem; - flex-wrap: wrap; +.tone-accent-note { + background: var(--accent-soft); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); + color: var(--text-2); } -.olb-filters-row .lx-select { - width: auto; - min-width: 130px; - flex: 0 1 auto; +.rule-top-dashed { + border-top: 1px dashed var(--border); } -.olb-select, -.olb-input { - height: 36px; - font-size: 0.82rem !important; - padding: 0.45rem 0.75rem !important; - border-radius: 8px !important; - background: rgba(15, 23, 42, 0.5) !important; +.tone-accent-panel { + background: var(--accent-soft); + border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--border)); } -.olb-input { - min-width: 140px; - flex: 1 1 140px; +.tone-muted-inset { + background: var(--surface-2); + color: var(--text-3); } -.olb-filter-btns { - display: flex; - gap: 0.5rem; - margin-left: auto; +.tone-muted-rule { + border-top: 1px dashed var(--border); + color: var(--text-3); } -.olb-filter-btns button { - height: 36px; - display: inline-flex; - align-items: center; - gap: 5px; - padding: 0 0.85rem; - font-size: 0.82rem; - border-radius: 8px; +.tone-fill-info-shadow { + background: var(--info); + box-shadow: var(--shadow-sm); + color: var(--on-info); } -.btn-danger-soft { - background: rgba(239, 68, 68, 0.12); - border-color: rgba(239, 68, 68, 0.35); - color: #fca5a5; +.tone-surface-footer { + background: var(--surface); + border-top: 1px solid var(--border); } -.btn-danger-soft:not(:disabled):hover { - background: rgba(239, 68, 68, 0.25); - border-color: #ef4444; - color: #fff; - box-shadow: 0 0 12px rgba(239, 68, 68, 0.35); +.tone-success { + color: var(--success-text); } -.olb-selcount-badge { - font-size: 0.8rem; - color: var(--text-secondary); +.tone-inset { + background: var(--surface-2); } -/* 空状态 */ -.olb-empty-state { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 3.5rem 1.5rem; - text-align: center; - background: rgba(15, 23, 42, 0.35); - border: 1px dashed var(--glass-border); - border-radius: 12px; +.badge-accent { + color: var(--accent-text); + border: 1px solid var(--accent); } -.olb-empty-icon { - width: 44px; - height: 44px; - color: var(--text-secondary); - opacity: 0.5; - margin-bottom: 0.75rem; +.badge-neutral { + color: var(--text-2); + border: 1px solid var(--border); } -.olb-empty-title { - font-size: 0.92rem; - font-weight: 600; - color: var(--text-primary); - margin-bottom: 0.35rem; +.badge-danger { + color: var(--danger-text); + border: 1px solid var(--danger); } -.olb-empty-desc { - font-size: 0.78rem; - color: var(--text-secondary); +.badge-warning { + color: var(--warning-text); + border: 1px solid var(--warning); } -/* 状态徽章与表格细节 */ -.olb-table-wrapper { - max-height: 480px; - overflow: auto; +.badge-info { + color: var(--info-text); + border: 1px solid var(--info); } -.olb-records-table { - font-size: 0.82rem; +@keyframes toast-in { + from { + transform: translateX(100%) scale(0.9); + opacity: 0; + } + to { + transform: translateX(0) scale(1); + opacity: 1; + } } -.olb-records-table th { - position: sticky; - top: 0; - z-index: 2; - background: rgba(20, 27, 45, 0.95); - backdrop-filter: blur(8px); +@keyframes modal-in { + from { + transform: scale(0.95) translateY(10px); + opacity: 0; + } + to { + transform: scale(1) translateY(0); + opacity: 1; + } } -.olb-badge { - display: inline-flex; - align-items: center; - gap: 5px; - padding: 2px 8px; - border-radius: 6px; - font-size: 0.72rem; - font-weight: 500; - background: rgba(255, 255, 255, 0.06); - border: 1px solid rgba(255, 255, 255, 0.1); - color: var(--badge-color, #cbd5e1); +@keyframes marquee { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } } -.olb-badge-dot { - width: 5px; - height: 5px; - border-radius: 50%; - background: var(--badge-color, currentColor); +.lx-toast-in { + animation: toast-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards; } -.olb-user-tag { - display: inline-block; - padding: 1px 7px; - border-radius: 4px; - background: rgba(255, 255, 255, 0.05); - color: var(--text-primary); - font-size: 0.76rem; - font-family: monospace; +.lx-modal-in { + animation: modal-in 0.4s cubic-bezier(0.22, 1, 0.36, 1) forwards; } -/* 分页栏 */ -.olb-pager { - display: flex; - align-items: center; - justify-content: flex-end; - gap: 0.5rem; - margin-top: 0.85rem; - flex-wrap: wrap; - font-size: 0.82rem; - color: var(--text-secondary); +.animate-marquee { + display: inline-block; + animation: marquee 10s linear infinite; } -.olb-pager button { - height: 30px; - padding: 0 0.65rem; - font-size: 0.76rem; - border-radius: 6px; +.pause-animation { + animation-play-state: paused; } -.olb-pager-info { - margin: 0 0.35rem; +/* Premium Solid Style for Toasts (Better Legibility) */ + +.lx-toast-card { + font-family: 'Outfit', sans-serif; + background: var(--surface); + border: 1px solid var(--border); + box-shadow: var(--shadow-sm); + color: var(--text); } -@media (max-width: 900px) { - .olb-filter-btns { - margin-left: 0; - width: 100%; - justify-content: flex-end; - } +/* Premium Glassmorphism Overrides */ + +.lx-glass { + font-family: 'Outfit', sans-serif; + background: var(--surface); + backdrop-filter: none; + -webkit-backdrop-filter: none; + border: 1px solid var(--border); + box-shadow: var(--shadow-lg); + color: var(--text); } -/* ========================================================= - 现代化圆角毛玻璃自定义下拉框样式 (.lx-select) - ========================================================= */ -.lx-select { +.lx-btn { + font-family: 'Outfit', sans-serif; + transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); position: relative; + overflow: hidden; +} + +.lx-btn::after { + content: ''; + position: absolute; + top: 0; + left: 0; width: 100%; - user-select: none; - font-size: 0.9rem; + height: 100%; + background: color-mix(in srgb, var(--text) 5%, transparent); + opacity: 0; + transition: opacity 0.2s; + pointer-events: none; } -.lx-select-native { - position: absolute !important; - opacity: 0 !important; - pointer-events: none !important; - width: 0 !important; - height: 0 !important; - margin: 0 !important; - padding: 0 !important; - border: none !important; - overflow: hidden !important; +.lx-btn:hover::after { + opacity: 1; } -.lx-select-trigger { +.lx-btn:active { + transform: scale(0.96); +} + +.lx-input { + background: var(--field-bg); + border: 1px solid var(--field-border); + color: var(--text); + transition: border-color 0.3s, box-shadow 0.3s; +} + +.lx-input:focus { + border-color: color-mix(in srgb, var(--success) 35%, var(--border)); + box-shadow: var(--field-focus-shadow); +} + +.lx-close-btn { + background: transparent; + border: none; + box-shadow: none; + outline: none; + cursor: pointer; + transition: all 0.2s; display: flex; align-items: center; - justify-content: space-between; - gap: 8px; - width: 100%; - min-height: 40px; - padding: 0.65rem 0.95rem; - background: rgba(15, 23, 42, 0.55); - border: 1px solid var(--glass-border); - border-radius: 10px; - color: var(--text-primary); - cursor: pointer; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - box-sizing: border-box; + justify-content: center; + color: var(--text-2); } -.lx-select:hover .lx-select-trigger { - background: rgba(255, 255, 255, 0.04); - border-color: rgba(255, 255, 255, 0.25); +.lx-close-btn:hover { + background: var(--surface-hover); } -.lx-select.is-open .lx-select-trigger { - border-color: var(--accent-primary, #6366f1); - background: rgba(15, 23, 42, 0.75); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.25); +.avatar-public { + background: var(--success); + color: var(--on-success); + font-size: 1.5rem; } -.lx-select-text { - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - text-align: left; - font-weight: 500; +.dropdown-avatar.avatar-public { + font-size: 12px; } -.lx-select-arrow { - width: 16px; - height: 16px; - color: var(--text-secondary); - flex-shrink: 0; - transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); +:is(button, a, input, select, textarea, [tabindex]):focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 3px; } -.lx-select.is-open .lx-select-arrow { - transform: rotate(180deg); - color: var(--accent-primary, #6366f1); +.toggle-switch input:focus-visible + .toggle-track, .pill-toggle-switch input:focus-visible + .pill-toggle-track { + outline: 2px solid var(--focus-ring); + outline-offset: 3px; } -/* 真正的圆角毛玻璃弹出菜单 */ -.lx-select-menu { - position: absolute; - top: calc(100% + 6px); - left: 0; - right: 0; - z-index: 1002; - background: rgba(15, 23, 42, 0.96); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border: 1px solid rgba(255, 255, 255, 0.16); - border-radius: 12px; - box-shadow: 0 16px 36px rgba(0, 0, 0, 0.65), 0 0 0 1px rgba(255, 255, 255, 0.05); - padding: 6px; - max-height: 280px; - overflow-y: auto; - display: none; - flex-direction: column; - gap: 2px; - animation: lxSelectFadeIn 0.15s ease-out; - /* 去除侧边下拉滚动条,鼠标滚轮依然保持丝滑滚动 */ - scrollbar-width: none !important; - -ms-overflow-style: none !important; +::selection { + background: var(--accent); + color: var(--text-on-accent); } -.lx-select-menu::-webkit-scrollbar { - width: 0 !important; - height: 0 !important; - display: none !important; +input::placeholder, textarea::placeholder { + color: var(--text-3); + opacity: 1; } -.lx-select.drop-up .lx-select-menu { - top: auto; - bottom: calc(100% + 6px); +button:disabled, button:disabled:hover, input:disabled, select:disabled, textarea:disabled { + background: var(--control-disabled-bg); + color: var(--control-disabled-text); + border-color: var(--border); + box-shadow: none; + cursor: not-allowed; + opacity: 1; } -.lx-select.is-open .lx-select-menu { - display: flex; +.admin-stars::before { + content: "★★★★★"; + position: absolute; + inset: 0 auto 0 0; + width: calc(var(--v) / 5 * 100%); + overflow: hidden; + color: var(--warning-text); + white-space: nowrap; } -@keyframes lxSelectFadeIn { - from { opacity: 0; transform: translateY(-4px); } - to { opacity: 1; transform: translateY(0); } +.tone-accent-soft { + background: var(--accent-soft); + color: var(--accent-text); } -.lx-select-item { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 0.55rem 0.85rem; - border-radius: 8px; - font-size: 0.86rem; - color: var(--text-secondary); - cursor: pointer; - transition: all 0.15s ease; +.border-default { + border-color: var(--border); } -.lx-select-item:hover { - background: rgba(255, 255, 255, 0.06); - color: var(--text-primary); +.border-accent { + border-color: var(--accent); } -.lx-select-item.is-selected { - background: rgba(99, 102, 241, 0.18); - color: #a5b4fc; - font-weight: 600; +.surface-hover:hover { + background: var(--surface-hover); } -/* 小号选择器 */ -.lx-select.select-sm .lx-select-trigger { - min-height: 36px; - padding: 0.42rem 0.75rem; - font-size: 0.82rem; - border-radius: 8px; +.accent-hover:hover { + color: var(--accent-text); } -.lx-select.select-sm .lx-select-item { - padding: 0.42rem 0.75rem; - font-size: 0.8rem; + +.text-hover:hover { + color: var(--text); } -/* 彻底去除主页面侧边滑动条(保留滚轮顺畅滑动页面) */ -html, -body, -#app, -.main-content, -.content-wrapper { - scrollbar-width: none !important; - -ms-overflow-style: none !important; +.modal-backdrop { + background: var(--overlay); } -html::-webkit-scrollbar, -body::-webkit-scrollbar, -#app::-webkit-scrollbar, -.main-content::-webkit-scrollbar, -.content-wrapper::-webkit-scrollbar { - width: 0 !important; - height: 0 !important; - display: none !important; +.chart-series-1 { + background: var(--chart-1); } -/* 局部弹窗和日志等容器的极简微型滚动条 */ -::-webkit-scrollbar { - width: 6px; - height: 6px; +.chart-series-2 { + background: var(--chart-2); } -::-webkit-scrollbar-track { - background: transparent; +.chart-series-3 { + background: var(--chart-3); } -::-webkit-scrollbar-thumb { - background: rgba(255, 255, 255, 0.15); - border-radius: 999px; +.chart-series-4 { + background: var(--chart-4); } -::-webkit-scrollbar-thumb:hover { - background: rgba(255, 255, 255, 0.3); +.tone-fill-warning { + background: var(--warning); + color: var(--on-warning); +} + +.song-cover-placeholder { + display: flex; + align-items: center; + justify-content: center; + background: var(--surface-2); } diff --git a/public/sw.js b/public/sw.js index 76e9fbea..9e3fef86 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,8 +1,12 @@ -const CACHE_NAME = 'lx-sync-server-v4'; +const CACHE_NAME = 'lx-sync-server-v5'; const ASSETS_TO_CACHE = [ './', './index.html', './style.css', + './css/tokens.css', + './css/dashboard.css', + './theme.js', + './dash.js', './app.js', './js/ui-utils.js', './js/notification-engine.js', diff --git a/public/theme.js b/public/theme.js new file mode 100644 index 00000000..2106a85f --- /dev/null +++ b/public/theme.js @@ -0,0 +1,70 @@ +(function () { + 'use strict'; + const key = 'lx-theme'; + const root = document.documentElement; + const system = window.matchMedia('(prefers-color-scheme: dark)'); + function preference() { + try { + const value = localStorage.getItem(key); + return value === 'light' || value === 'dark' ? value : null; + } catch (_) { return null; } + } + let selected = preference(); + + // Canvas cannot consume var() directly. Resolve the same CSS tokens as the UI. + window.themeColors = function () { + const style = getComputedStyle(root); + const css = name => style.getPropertyValue(name).trim(); + return { + grid: css('--chart-grid'), tick: css('--chart-tick'), + series: [1, 2, 3, 4].map(n => css('--chart-' + n)), + tipBg: css('--surface'), tipText: css('--text'), border: css('--border'), + accent: css('--login-particle-1'), info: css('--login-particle-2'), + }; + }; + + function recolorCharts() { + if (!window.Chart || !window.Chart.instances) return; + const colors = themeColors(); + Object.values(window.Chart.instances).forEach(chart => { + Object.values(chart.options.scales || {}).forEach(axis => { + if (axis.grid) axis.grid.color = colors.grid; + if (axis.ticks) axis.ticks.color = colors.tick; + }); + const tooltip = chart.options.plugins && chart.options.plugins.tooltip; + if (tooltip) { + tooltip.backgroundColor = colors.tipBg; + tooltip.titleColor = tooltip.bodyColor = colors.tipText; + tooltip.borderColor = colors.border; + } + chart.data.datasets.forEach((dataset, i) => { + dataset.borderColor = colors.series[dataset.themeSeries ?? i % 4]; + dataset.backgroundColor = dataset.borderColor; + }); + chart.update('none'); + }); + } + + function apply() { + const theme = selected || (system.matches ? 'dark' : 'light'); + root.dataset.theme = theme; + document.querySelectorAll('[data-theme-toggle]').forEach(toggle => { + toggle.setAttribute('aria-pressed', String(theme === 'dark')); + toggle.setAttribute('aria-label', theme === 'dark' ? '切换到亮色主题' : '切换到暗色主题'); + }); + recolorCharts(); + } + // Loaded synchronously in head before any stylesheet or visible content. + apply(); + system.addEventListener('change', () => { if (!selected) apply(); }); + window.addEventListener('storage', event => { + if (event.key === key || event.key === null) { selected = preference(); apply(); } + }); + document.addEventListener('DOMContentLoaded', apply); + document.addEventListener('click', event => { + if (!event.target.closest('[data-theme-toggle]')) return; + selected = root.dataset.theme === 'dark' ? 'light' : 'dark'; + try { localStorage.setItem(key, selected); } catch (_) { /* Keep the choice for this page. */ } + apply(); + }); +})(); diff --git a/scripts/check-docs-parity.cjs b/scripts/check-docs-parity.cjs new file mode 100644 index 00000000..c05107e4 --- /dev/null +++ b/scripts/check-docs-parity.cjs @@ -0,0 +1,21 @@ +const fs=require('node:fs'); +const read=p=>fs.readFileSync(p,'utf8'); +const codes=s=>new Set([...s.matchAll(/`([^`]+)`/g)].map(m=>m[1])); +for(const p of ['guide/subsonic-local-library.md','api/subsonic.md']){ + const a=codes(read('docs/'+p)),b=codes(read('docs/en/'+p)); + const technical=x=>/^(?:subsonic\.|(?:get|save|create|update|delete|start|report)[A-Z]|scrobble$|star$|unstar$|stream$|download$|ping$|search[23]?$)/.test(x); + const missing=[...a].filter(technical).filter(x=>!b.has(x)); + const extra=[...b].filter(technical).filter(x=>!a.has(x)); + if(missing.length||extra.length)throw Error(p+' mismatches: '+JSON.stringify({missing,extra})); + console.log(p+': endpoint and configuration identifiers match'); +} +const routes=s=>new Set([...s.matchAll(/`(\/api\/[^`]+)`/g)].map(m=>m[1])); +const cn=routes(read('docs/api/routes.md')),en=routes(read('docs/en/api/routes.md')); +const delta={missingEnglish:[...cn].filter(x=>!en.has(x)),missingChinese:[...en].filter(x=>!cn.has(x))}; +if(delta.missingEnglish.length||delta.missingChinese.length)throw Error(JSON.stringify(delta)); +console.log('Route indexes match: '+cn.size+' paths'); +const source=read('src/server/server.ts'); +const literals=new Set([...source.matchAll(/pathname\s*===\s*['"](\/api\/[^'"]+)['"]/g)].map(m=>m[1])); +const absent=[...literals].filter(p=>!cn.has(p)); +console.log('Literal equality routes missing from index: '+JSON.stringify(absent)); +if(absent.length)process.exitCode=1; diff --git a/src/defaultConfig.ts b/src/defaultConfig.ts index c2fca4a7..477482ba 100644 --- a/src/defaultConfig.ts +++ b/src/defaultConfig.ts @@ -18,6 +18,7 @@ const config: LX.Config = { 'user.enableLoginCacheRestriction': false, // 是否启用登录用户缓存限制 'user.enableCacheSizeLimit': false, // 是否启用缓存空间限制 'user.cacheSizeLimit': 2000, // 缓存空间限制大小 (MB) + 'cache.maxAgeDays': 14, // 0 disables age-based cache cleanup maxSnapshotNum: 10, // 公共最大备份快照数 'list.addMusicLocationType': 'top', // 公共添加歌曲到我的列表时的位置 top | bottom,参考客户端的「设置 → 列表设置 → 添加歌曲到列表时的位置」 @@ -48,6 +49,8 @@ const config: LX.Config = { 'webdav.username': '', 'webdav.password': '', 'webdav.syncPath': '/lx-sync', // 增量同步远程路径 + 'webdav.retentionCount': 5, // ZIP 最多保留份数,始终保留最新一份 + 'webdav.retentionDays': 0, // 0 不按天数清理 'webdav.backupPath': '/lx-sync-backups', // 全量备份远程路径 'sync.interval': 60, // 同步间隔(分钟)默认1小时 'sync.backupInterval': 24, // 全量备份间隔(小时)默认24小时 @@ -63,6 +66,8 @@ const config: LX.Config = { // 本地配置备份(config.js 每日一份本地副本) 'configBackup.enable': true, // 是否启用本地配置备份 'configBackup.retentionDays': 7, // 本地备份保留天数 + 'configBackup.intervalHours': 24, // 自动备份周期(小时) + 'configBackup.scheduleTime': '00:00', // 锚点时间;空字符串表示从启动/保存时起计时 'configBackup.dir': '', // 备份目录,留空则用 /backups,相对路径基于 data 目录,绝对路径直接使用 // 歌单快照额外备份路径(留空则存于用户数据目录 list/snapshot,相对路径基于 data 目录,绝对路径直接使用) @@ -99,13 +104,14 @@ const config: LX.Config = { 'subsonic.onlineSearchSources': 'wy,tx,kw,kg,mg', // 在线搜索默认平台 'subsonic.localPlaylist': true, // 返回当前用户的本地歌曲只读歌单,排在歌单列表首位 'subsonic.customPlaylist': true, // 本地歌单开启时,单独返回自定义目录歌单 + 'subsonic.cachePlaylist': false, // 本地歌曲歌单是否包含 cache 目录歌曲 'subsonic.publicLeaderboards': false, // 是否在 Subsonic 中公开在线排行榜(只读虚拟播放列表) 'subsonic.leaderboardSource': 'tx', // 在线排行榜平台: tx | wy | kg | kw | mg 'subsonic.sharedListMode': 'leaderboard', // 共享歌单内容模式: leaderboard | playlist | both 'subsonic.sharedListSort': 'hot', // 共享歌单排序: hot | new 'subsonic.dislikeRating': 1, // 评分联动 dislike 阈值: 0 < rating <= 该值 视为不喜欢(写回原生 dislike 规则); 设为 0 关闭联动 + 'subsonic.ratingPlaylists': true, // 是否向 Subsonic 客户端暴露 1~5 星评星歌单(虚拟只读, 不落盘); 仅控制歌单显示,不改变联动开关 'subsonic.linkRatingToDislike': false, // 解耦开关(正向): 评星 -> 不喜欢 是否自动联动; false=不联动(仅记录评分) - 'subsonic.linkDislikeToRating': false, // 解耦开关(反向): 不喜欢 -> 评星 是否自动联动; false=不联动(仅记录不喜欢) 'subsonic.hideDisliked': true, // 是否在 Subsonic 列表中隐藏 dislike 命中的歌曲(关闭则只评分不剔除) 'subsonic.dislikeCrossSource': false, // dislike 是否跨平台同名命中(各平台 ID 不互通, 开启会误伤同名歌手/专辑) 'subsonic.dislikeNoRecommend': true, // 推荐类接口(每日推荐/随机/相似)是否排除 dislike 歌曲(与 hideDisliked 独立) @@ -132,15 +138,16 @@ const config: LX.Config = { 'system.allowUnsafeVM': false, // 是否允许运行 VM 模式自定义源脚本 // ===== OpenList 备份/同步 ===== + 'openlist.playback.enable': false, // 本地未命中时允许 Web/Subsonic 使用已登记的 OpenList 文件 'openlist.enable': false, // 是否启用 OpenList 备份/同步 'openlist.endpoint': '', // OpenList 地址(如 http://127.0.0.1:5244) 'openlist.username': '', // OpenList 管理员账号(用于 testConnection) 'openlist.password': '', // OpenList 密码 'openlist.token': '', // OpenList 用户令牌(与账号密码二选一) 'openlist.root': '/lx_music', // OpenList 中 lx-music 备份根目录 - 'openlist.keepLocalAfterUpload': true, // 上传 OpenList 后是否保留本地文件 'openlist.verifyInterval': 5, // 远端可用性校验周期(分钟) 'openlist.syncInterval': 60, // 双向同步周期(分钟) + 'openlist.historyRetentionDays': 7, // 云端历史版本保留天数 'openlist.userBackups': '[]', // 各用户备份目录配置(JSON 数组) 'openlist.includeNonAudio': false, // 是否同步非音频文件(图片/封面等) 'openlist.externalUrl': '', // OpenList 外网地址(留空则同 openlist.endpoint) diff --git a/src/index.ts b/src/index.ts index 15f7aa87..af04c1c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,6 +18,8 @@ import { initLogger } from '@/utils/log4js' import defaultConfig from './defaultConfig' import { ENV_PARAMS, File } from './constants' import { checkAndCreateDirSync } from './utils' +import { getNextConfigBackupTime, configBackupTimerDelay } from './utils/configBackupSchedule' +import { hasInterruptedRestore } from './server/openlist/openlistRestoreTasks' // Declare Env Params Type type ENV_PARAMS_Type = typeof ENV_PARAMS @@ -94,8 +96,8 @@ const getConfigHash = (filePath: string) => { const dataPath = envParams.DATA_PATH ?? path.join(__dirname, '../data') const resolvedConfigPath = process.env.CONFIG_PATH || path.join(dataPath, 'config.js') -// [本地配置备份] 每天一份 config-YYYY-MM-DD.js,保留可配置天数(见 configBackup.*) -let lastBackupDate = '' +// [本地配置备份] 按周期生成 config-<槽位>.js,保留可配置天数(见 configBackup.*) +let lastBackupSlot = '' const getConfigBackupDir = (): string => { const dir = global.lx?.config['configBackup.dir'] @@ -122,7 +124,37 @@ const getTimestampStr = () => { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}_${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}` } -// 清理超过保留期的本地配置备份(按文件 mtime 判断) +// 本地快照备份文件名:snapshot_-<日期_时分秒>[-序号]。每次落盘生成独立文件,不覆盖历史,可按时间点回溯。 +const snapshotBackupPrefix = (name: string) => `snapshot_${name}-` +const isSnapshotBackupOf = (file: string, name: string): boolean => { + if (file === `snapshot_${name}`) return true // 兼容旧版无时间戳的副本 + const prefix = snapshotBackupPrefix(name) + if (!file.startsWith(prefix)) return false + return /^\d{4}-\d{2}-\d{2}_\d{6}(?:-\d+)?$/.test(file.slice(prefix.length)) +} + +// 按同一保留期清理过期的本地快照副本(snapshots// 下的 snapshot_* 文件) +const cleanOldSnapshotBackups = (backupDir: string, cutoff: number) => { + const root = path.join(backupDir, 'snapshots') + if (!fs.existsSync(root)) return + for (const user of fs.readdirSync(root, { withFileTypes: true })) { + if (!user.isDirectory()) continue + const dir = path.join(root, user.name) + try { + for (const file of fs.readdirSync(dir)) { + if (!file.startsWith('snapshot_')) continue + const fp = path.join(dir, file) + try { + const stat = fs.statSync(fp) + if (stat.isFile() && stat.mtimeMs < cutoff) fs.unlinkSync(fp) + } catch { /* ignore */ } + } + if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir) + } catch { /* ignore */ } + } +} + +// 清理超过保留期的本地配置备份与快照备份(按文件 mtime 判断) const cleanOldConfigBackups = () => { const backupDir = getConfigBackupDir() try { @@ -135,6 +167,7 @@ const cleanOldConfigBackups = () => { if (fs.statSync(fp).mtimeMs < cutoff) fs.unlinkSync(fp) } catch { /* ignore */ } } + cleanOldSnapshotBackups(backupDir, cutoff) } catch (err) { console.error('[Config] Failed to clean old config backups:', err) } @@ -167,25 +200,69 @@ const migrateConfigBackups = (targetBackupDir: string) => { } } -// 每天自动备份一次(同日覆盖),备份后顺带清理过期文件 +// 按配置的备份周期生成槽位键:周期≥24h 按天(同日覆盖),否则带时分(每周期一个文件) +// 备份后顺带清理过期文件 const backupConfig = () => { + if (global.lx?.restoreMaintenance) return if (global.lx?.config['configBackup.enable'] === false) return const backupDir = getConfigBackupDir() migrateConfigBackups(backupDir) + const intervalHours = Math.max(1, Number(global.lx?.config?.['configBackup.intervalHours']) || 24) + const now = new Date() const dateStr = getTodayStr() - if (dateStr === lastBackupDate) return + const slotKey = intervalHours >= 24 + ? dateStr + : `${dateStr}-${pad2(now.getHours())}${pad2(now.getMinutes())}` + if (slotKey === lastBackupSlot) return try { if (!global.lx?.configPath || !fs.existsSync(global.lx.configPath)) return fs.mkdirSync(backupDir, { recursive: true }) - fs.copyFileSync(global.lx.configPath, path.join(backupDir, `config-${dateStr}.js`)) - lastBackupDate = dateStr - console.log(`[Config] Local backup saved to ${backupDir}/config-${dateStr}.js`) + fs.copyFileSync(global.lx.configPath, path.join(backupDir, `config-${slotKey}.js`)) + lastBackupSlot = slotKey + if (global.lx) { global.lx.lastConfigBackupAt = Date.now(); global.lx.lastConfigBackupError = '' } + console.log(`[Config] Local backup saved to ${backupDir}/config-${slotKey}.js`) } catch (err) { + global.lx.lastConfigBackupError = String(err) console.error('[Config] Failed to backup config:', err) } cleanOldConfigBackups() } +// 本地快照备份:当本地备份开关打开时,把刚落盘的歌单/不喜欢快照复制一份带时间戳的副本到备份目录。 +// 每次落盘都独立成文件(秒级时间戳 + 同秒序号),保证可按时间点回溯,不覆盖历史。 +// 复用 configBackup.enable 开关与 getConfigBackupDir 目录约定("跟配置走") +const backupSnapshotToLocal = (srcPath: string, user: string, name: string) => { + if (global.lx?.config['configBackup.enable'] === false) return + if (!srcPath || !fs.existsSync(srcPath) || !user || !name) return + const backupDir = getConfigBackupDir() + const destDir = path.join(backupDir, 'snapshots', user) + try { + fs.mkdirSync(destDir, { recursive: true }) + const base = `${snapshotBackupPrefix(name)}${getTimestampStr()}` + let dest = path.join(destDir, base) + let seq = 1 + while (fs.existsSync(dest)) dest = path.join(destDir, `${base}-${seq++}`) + fs.copyFileSync(srcPath, dest) + } catch (err) { + console.error('[Config] Failed to backup snapshot locally:', err) + } +} + +// 删除某个快照时,清理其所有本地历史副本(含旧版无时间戳副本),避免孤儿文件 +const removeSnapshotLocalBackups = (user: string, name: string) => { + if (!user || !name) return + const destDir = path.join(getConfigBackupDir(), 'snapshots', user) + if (!fs.existsSync(destDir)) return + try { + for (const file of fs.readdirSync(destDir)) { + if (!isSnapshotBackupOf(file, name)) continue + try { fs.unlinkSync(path.join(destDir, file)) } catch { /* ignore */ } + } + } catch (err) { + console.error('[Config] Failed to remove snapshot local backups:', err) + } +} + // 手动立即备份一次(精确到秒时间戳,不覆盖当天的自动备份) const backupConfigNow = (): { success: boolean, filename?: string, error?: string } => { const backupDir = getConfigBackupDir() @@ -194,8 +271,13 @@ const backupConfigNow = (): { success: boolean, filename?: string, error?: strin return { success: false, error: 'Config file not found' } } fs.mkdirSync(backupDir, { recursive: true }) - const filename = `config-manual-${getTimestampStr()}.js` + const base = `config-manual-${getTimestampStr()}` + let filename = `${base}.js` + let seq = 1 + while (fs.existsSync(path.join(backupDir, filename))) filename = `${base}-${seq++}.js` fs.copyFileSync(global.lx.configPath, path.join(backupDir, filename)) + global.lx.lastConfigBackupAt = Date.now() + global.lx.lastConfigBackupError = '' console.log(`[Config] Manual backup saved to ${backupDir}/${filename}`) return { success: true, filename } } catch (err: any) { @@ -205,6 +287,7 @@ const backupConfigNow = (): { success: boolean, filename?: string, error?: strin } const saveConfigToFile = () => { + if (global.lx?.restoreMaintenance) return const content = `module.exports = ${JSON.stringify(global.lx.config, null, 2)}` const temporary = `${global.lx.configPath}.${process.pid}.${crypto.randomUUID()}.tmp` try { @@ -233,9 +316,12 @@ global.lx = { config: defaultConfig, staticPath: process.env.STATIC_PATH ?? path.join(process.cwd(), 'public'), configPath: resolvedConfigPath, + restoreMaintenance: hasInterruptedRestore(dataPath) || require('@/server/backup/webdavRestoreTasks').hasInterruptedRestore(dataPath), saveConfig: saveConfigToFile, backupConfigNow, getConfigBackupDir, + backupSnapshotToLocal, + removeSnapshotLocalBackups, } const mergeConfigFileEnv = (config: Partial>) => { @@ -278,6 +364,14 @@ const margeConfig = (p: string) => { if (config[key] !== undefined) newConfig[key] = config[key] } + // [向后兼容] 旧版 Subsonic 独立端口靠 subsonic.port > 0 直接监听,无 standalone 开关概念。 + // 升级到引入 subsonic.standalone(默认 false)的版本后,旧配置缺失该键会按默认值静默关闭独立端口, + // 造成「升级前已有端口值却被关掉」。故对「原始 config 缺开关键且有端口值(>0)」的旧配置默认视为开启; + // 仅当原始 config 未显式设置该键时生效(用户显式设为 false 则尊重其意图,不被覆盖)。 + if (config['subsonic.standalone'] === undefined && (Number(config['subsonic.port']) || 0) > 0) { + newConfig['subsonic.standalone'] = true + } + console.log('[配置] 加载配置文件: ' + p) if (newConfig.users.length) { const users: LX.UserConfig[] = [] @@ -289,9 +383,11 @@ const margeConfig = (p: string) => { } newConfig.users = users } + if (config['openlist.playback.enable'] === undefined && config['subsonic.playCacheFirstUseOpenlist'] === true && config['subsonic.playCacheFirst'] === true) newConfig['openlist.playback.enable'] = true global.lx.config = newConfig mergeConfigFileEnv(config) + global.lx.rescheduleConfigBackup?.() return true } @@ -767,7 +863,7 @@ createModuleEvent() require('@/utils/migrate').default(global.lx.dataPath, global.lx.userPath) // eslint-disable-next-line @typescript-eslint/no-var-requires -const { startServer } = require('@/server') +const { startServer, reloadServerData } = require('@/server') // 初始化 WebDAV 同步 // eslint-disable-next-line @typescript-eslint/no-var-requires @@ -779,6 +875,8 @@ const webdavSync = new WebDAVSync({ password: global.lx.config['webdav.password'], syncPath: global.lx.config['webdav.syncPath'], backupPath: global.lx.config['webdav.backupPath'], + retentionCount: global.lx.config['webdav.retentionCount'], + retentionDays: global.lx.config['webdav.retentionDays'], interval: global.lx.config['sync.interval'], backupInterval: global.lx.config['sync.backupInterval'], excludeCache: global.lx.config['webdav.excludeCache'], @@ -791,62 +889,18 @@ const webdavSync = new WebDAVSync({ // 导出 webdavSync 实例供全局使用 global.lx.webdavSync = webdavSync -// 如果配置了 WebDAV,在启动时尝试从远程恢复 -if (webdavSync.isConfigured() && global.lx.config['backup.webdav.restoreOnStartup']) { - console.log('[WebDAV] 已配置 WebDAV,正在尝试从远端恢复数据...') - void webdavSync.restoreFromRemote().then(async (success: boolean) => { +// 启动恢复完成后才开放服务,避免边播放/写入边覆盖数据。 +const startupWebdavRestore = (async () => { + if (webdavSync.isConfigured() && global.lx.config['backup.webdav.restoreOnStartup'] && !global.lx.restoreMaintenance) { + const success = await webdavSync.restoreFromRemote({ startup: true }) if (success) { - console.log('[WebDAV] 数据已成功从 WebDAV 恢复') - - // 1. 重新从磁盘加载最新的 config.js 到内存 (解决实时生效问题) - const configPath = global.lx.configPath - if (fs.existsSync(configPath)) { - console.log('[WebDAV] 恢复完成后重新加载配置文件: ' + configPath) - // 清除 node require 缓存以强制重载 - try { - delete require.cache[require.resolve(configPath)] - margeConfig(configPath) - } catch (e) { - console.error('[WebDAV] 热重载配置文件失败:', e) - } - } - - // 2. 重新加载 users.json - const usersJsonPath = path.join(global.lx.dataPath, 'users.json') - if (fs.existsSync(usersJsonPath)) { - try { - const users = JSON.parse(fs.readFileSync(usersJsonPath, 'utf-8')) - if (Array.isArray(users)) { - console.log('[WebDAV] 从恢复的 users.json 重新加载用户列表') - global.lx.config.users = users.map(u => ({ ...u, dataPath: '' })) - - // 重新初始化用户目录 - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { getUserDirname } = require('@/user') - for (const user of global.lx.config.users) { - const dataPath = path.join(global.lx.userPath, getUserDirname(user.name)) - checkAndCreateDir(dataPath) - user.dataPath = dataPath - } - } - } catch (err) { - console.error('[WebDAV] 恢复后重新加载 users.json 失败:', err) - } - } - - // 3. 重新加载所有自定义源 (解决前端显示加载中/旧源问题) - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { initUserApis } = require('@/server/sources/userApi') - console.log('[WebDAV] 正在重新初始化自定义源...') - await initUserApis() - } - // 启动自动同步 - webdavSync.startAutoSync() - }) -} else { - if (webdavSync.isConfigured()) webdavSync.startAutoSync() - console.log('[WebDAV] 未开启启动恢复;保留本地数据,按配置执行自动备份') -} + await reloadServerData(true) + global.lx.restoreMaintenance = false + saveConfigToFile() + } else console.error('[WebDAV] 启动恢复失败,保留维护状态;请检查恢复记录后重启') + } + if (webdavSync.isConfigured() && !global.lx.restoreMaintenance) webdavSync.startAutoSync() +})() // [新增] 确保数据目录下的 _open 及 _open/library 目录存在 (用于公共受限资源 & 公开收藏) const openDir = path.join(global.lx.userPath, '_open') @@ -861,12 +915,48 @@ if (!fs.existsSync(openLibDir)) { // 启动前最后保存一次合并后的配置,确保环境变量被固化到 config.js 中 saveConfigToFile() -// 每日清理过期本地配置备份(保留最近 7 天) +// 本地配置备份调度:每 N 小时一次,可选锚点时间(HH:MM)对齐到该时刻首次触发 +let configBackupTimer: ReturnType | undefined +const scheduleConfigBackup = (previous?: number) => { + if (global.lx.config['configBackup.enable'] === false) { + global.lx.nextConfigBackupAt = 0 + return + } + const intervalHours = Math.min(8760, Math.max(1, Number(global.lx?.config?.['configBackup.intervalHours']) || 24)) + const intervalMs = intervalHours * 60 * 60 * 1000 + const anchor = String(global.lx?.config?.['configBackup.scheduleTime'] ?? '00:00').trim() + const now = Date.now() + const nextRun = getNextConfigBackupTime(anchor, intervalMs, now, previous) + if (global.lx) global.lx.nextConfigBackupAt = nextRun + const arm = () => { + configBackupTimer = setTimeout(() => { + if (Date.now() < nextRun) { arm(); return } + backupConfig() + scheduleConfigBackup(nextRun) + }, configBackupTimerDelay(nextRun, Date.now())) + configBackupTimer.unref() + } + arm() +} + +// 启动即备份一次,并按当前配置调度后续自动备份 +backupConfig() +scheduleConfigBackup() +if (global.lx) global.lx.rescheduleConfigBackup = () => { + if (configBackupTimer) clearTimeout(configBackupTimer) + scheduleConfigBackup() +} + +// 每日清理过期本地配置备份(保留最近 N 天;即便备份被禁用也照常执行) cleanOldConfigBackups() -const configBackupTimer = setInterval(cleanOldConfigBackups, 24 * 60 * 60 * 1000) -if (typeof configBackupTimer.unref === 'function') configBackupTimer.unref() +const configCleanupTimer = setInterval(cleanOldConfigBackups, 24 * 60 * 60 * 1000) +if (typeof configCleanupTimer.unref === 'function') configCleanupTimer.unref() -startServer(global.lx.config.port, '0.0.0.0') +void startupWebdavRestore.then(() => startServer(global.lx.config.port, '0.0.0.0')).catch(error => { + global.lx.restoreMaintenance = true + console.error('[WebDAV] 启动恢复加载失败:', error) + startServer(global.lx.config.port, '0.0.0.0') +}) // 监控配置文件变动以实现热重载 (由于 nodemon 已忽略该文件) const activeWatcherConfigPath = global.lx.configPath @@ -874,9 +964,11 @@ if (fs.existsSync(activeWatcherConfigPath)) { lastConfigHash = getConfigHash(activeWatcherConfigPath) let debounceTimer: NodeJS.Timeout | null = null fs.watch(activeWatcherConfigPath, (event) => { + if (global.lx.restoreMaintenance) return if (event === 'change') { if (debounceTimer) clearTimeout(debounceTimer) debounceTimer = setTimeout(() => { + if (global.lx.restoreMaintenance) return const currentHash = getConfigHash(activeWatcherConfigPath) // 如果内容未发生实质改变(如内部写配置触发的 fs.watch 事件),跳过热重载 if (currentHash && currentHash === lastConfigHash) return @@ -889,11 +981,14 @@ if (fs.existsSync(activeWatcherConfigPath)) { // 重新初始化各模块以使用新配置(如果需要) if (global.lx.webdavSync) { global.lx.webdavSync.updateConfig({ + enable: global.lx.config['webdav.enable'], url: global.lx.config['webdav.url'], username: global.lx.config['webdav.username'], password: global.lx.config['webdav.password'], syncPath: global.lx.config['webdav.syncPath'], backupPath: global.lx.config['webdav.backupPath'], + retentionCount: global.lx.config['webdav.retentionCount'], + retentionDays: global.lx.config['webdav.retentionDays'], interval: global.lx.config['sync.interval'], backupInterval: global.lx.config['sync.backupInterval'], }) diff --git a/src/modules/dislike/dislikeDataManage.ts b/src/modules/dislike/dislikeDataManage.ts index 142ffc52..1f5e74e3 100644 --- a/src/modules/dislike/dislikeDataManage.ts +++ b/src/modules/dislike/dislikeDataManage.ts @@ -1,8 +1,9 @@ +import { registerDislikeMigration } from '../../server/rating/ratingMigration' import fs from 'node:fs' import path from 'node:path' import { SPLIT_CHAR } from '@/constants' import { type SnapshotDataManage } from './snapshotDataManage' -import { filterRules, encodeAlbumRule } from './utils' +import { encodeAlbumRule, canonicalDislikeRule, scopedDislikeRule } from './utils' export class DislikeDataManage { snapshotDataManage: SnapshotDataManage @@ -10,6 +11,7 @@ export class DislikeDataManage { constructor(snapshotDataManage: SnapshotDataManage) { this.snapshotDataManage = snapshotDataManage + if (snapshotDataManage.dislikeDir) registerDislikeMigration(path.dirname(snapshotDataManage.dislikeDir), this) try { const latest = this.snapshotDataManage.snapshotInfo?.latest @@ -72,50 +74,88 @@ export class DislikeDataManage { // Returns all active rule strings separated by newline (used by dislikeCache for parsing) getDislikeRulesString = (): string => { return this.dislikeRules.dislikeList - .map(s => this.buildDislikeRule(s)) + .map(s => scopedDislikeRule(this.buildDislikeRule(s), (s as any).source)) .filter(Boolean) .join('\n') } + // 从指定快照内容恢复当前 dislike 数据(管理接口回滚用) + restore = async(data: LX.Dislike.DislikeRules) => { + this.dislikeRules = (data && Array.isArray(data.dislikeList)) ? { dislikeList: data.dislikeList } : { dislikeList: [] } + this.normalizeRules() + } + addDislikeInfo = async(infos: LX.Dislike.DislikeSongInfo[]) => { - // Check for duplicates - const currentRules = new Set(this.dislikeRules.dislikeList.map(s => this.buildDislikeRule(s))) - for (const info of infos) { - const rule = this.buildDislikeRule(info) - if (!rule) continue - if (!currentRules.has(rule)) { - info.dislikeRule = rule + const key = (item: any) => scopedDislikeRule(this.buildDislikeRule(item), item.source) + const current = new Map(this.dislikeRules.dislikeList.map(item => [key(item), item])) + for (const raw of infos) { + const info: any = { ...raw, dislikeRule: canonicalDislikeRule(this.buildDislikeRule(raw)) } + if (!info.dislikeRule) continue + info.provenance = info.provenance || { channel: 'unknown', at: Date.now() } + info.reasons = info.reasons || [info.provenance] + const existing: any = current.get(key(info)) + if (existing) { + // 只有身份不冲突时补字段;同名不同ID不能互相污染。 + const identity = (x: any) => String(x.songmid || x.id || '').replace(new RegExp('^' + (x.source || '') + '_'), '') + const reasons = existing.reasons || [existing.provenance || { channel: 'unknown', at: Date.now() }] + for (const reason of info.reasons) if (!reasons.some((x: any) => x.channel === reason.channel && x.ratingId === reason.ratingId)) reasons.push(reason) + existing.reasons = reasons + if (identity(existing) && identity(info) && identity(existing) !== identity(info)) continue + const fill = (target: any, incoming: any) => { + for (const [field, value] of Object.entries(incoming)) { + if (['dislikeRule', 'provenance', 'reasons', '__proto__', 'constructor', 'prototype'].includes(field) || value == null || value === '') continue + const old = target[field] + if (old == null || old === '' || (Array.isArray(old) && !old.length)) target[field] = value + else if (typeof old === 'object' && !Array.isArray(old) && typeof value === 'object' && !Array.isArray(value)) fill(old, value) + } + } + const before = JSON.stringify(existing) + fill(existing, info) + if (JSON.stringify(existing) !== before) existing.enrichedBy = info.provenance + } else { this.dislikeRules.dislikeList.push(info) - currentRules.add(rule) + current.set(key(info), info) } } return this.dislikeRules } + /** 同步读取当前规则并裁剪原因,避免预览期间新增的手动原因被旧副本覆盖。 */ + reconcileRatingReasons = (validIds: Set, preview = true) => { + const removed: any[] = [] + const next: LX.Dislike.DislikeSongInfo[] = [] + for (const item of this.dislikeRules.dislikeList) { + const reasons = item.reasons || [item.provenance || { channel: 'unknown', at: 0 }] + const remaining = reasons.filter(reason => { + const keep = !reason.ratingId || validIds.has(reason.ratingId) + if (!keep) removed.push({ rule: item.dislikeRule, source: item.source, ratingId: reason.ratingId }) + return keep + }) + if (remaining.length) next.push({ ...item, reasons: remaining, provenance: remaining[0] }) + } + const result = { removedReasons: removed.length, removedRules: this.dislikeRules.dislikeList.length - next.length, removals: removed } + if (!preview) this.dislikeRules = { dislikeList: next } + return result + } + + removeDislikeInfo = async(rule: string, source?: string) => { + const wanted = scopedDislikeRule(rule, source) + this.dislikeRules.dislikeList = this.dislikeRules.dislikeList.filter(item => scopedDislikeRule(this.buildDislikeRule(item), (item as any).source) !== wanted) + } + addDislikeAlbums = async(infos: LX.Dislike.DislikeAlbumInfo[]) => { if (!infos || infos.length === 0) return this.dislikeRules - const currentRules = new Set(this.dislikeRules.dislikeList.map(s => this.buildDislikeRule(s))) - const lines = infos.map(info => encodeAlbumRule(info.albumName, info.singer)) - - for (let i = 0; i < lines.length; i++) { - const rule = lines[i] - if (!currentRules.has(rule)) { - this.dislikeRules.dislikeList.push({ - name: '', - singer: '', - dislikeRule: rule - }) - currentRules.add(rule) - } - } + await this.addDislikeInfo(infos.map(info => ({ name: '', singer: '', + dislikeRule: encodeAlbumRule(info.albumName, info.singer), source: info.source, + provenance: info.provenance, reasons: info.reasons }))) return this.dislikeRules } overwirteDislikeInfo = async(rulesString: string) => { - const validRules = new Set(filterRules(rulesString)) + const validRules = new Set(rulesString.split('\n').map(r => r.trim()).filter(Boolean)) const existingMap = new Map() for (const item of this.dislikeRules.dislikeList) { - const r = this.buildDislikeRule(item) + const r = scopedDislikeRule(this.buildDislikeRule(item), (item as any).source) if (r && validRules.has(r)) { existingMap.set(r, item) } @@ -128,7 +168,8 @@ export class DislikeDataManage { newDislikeList.push({ name: '', singer: '', - dislikeRule: r, + dislikeRule: r.includes('::') ? r.split('::').slice(1).join('::') : r, + source: r.split('::')[0] === '*' ? undefined : r.split('::')[0], }) } } diff --git a/src/modules/dislike/manage.ts b/src/modules/dislike/manage.ts index 52294ce8..a166d6fb 100644 --- a/src/modules/dislike/manage.ts +++ b/src/modules/dislike/manage.ts @@ -21,7 +21,10 @@ export class DislikeManage { if (snapshotInfo.list.includes(md5)) { snapshotInfo.list.splice(snapshotInfo.list.indexOf(md5), 1) } else await this.snapshotDataManage.saveSnapshot(md5, listData) - if (snapshotInfo.latest) snapshotInfo.list.unshift(snapshotInfo.latest) + // 用户导入的备份不纳入系统快照 list/配额清理,避免被自动删除或误判为系统生成 + if (snapshotInfo.latest && !this.snapshotDataManage.isImported(snapshotInfo.latest)) { + snapshotInfo.list.unshift(snapshotInfo.latest) + } snapshotInfo.latest = md5 snapshotInfo.time = Date.now() this.snapshotDataManage.saveSnapshotInfo(snapshotInfo) @@ -51,5 +54,19 @@ export class DislikeManage { getDislikeRules = async() => { return await this.dislikeDataManage.getDislikeRules() } + + // 管理接口:将指定 dislike 快照恢复为当前数据(回滚) + restoreSnapshot = async(name: string) => { + const data = await this.snapshotDataManage.getSnapshot(name) + if (!data) throw new Error('Snapshot not found') + await this.dislikeDataManage.restore(data) + this.snapshotDataManage.clearClients() + this.snapshotDataManage.setLatest(name) + } + + /** 导入外部下载的 dislike 备份文件(保留原文件时间) */ + saveSnapshotWithTime = async(name: string, data: string, time: number) => { + await this.snapshotDataManage.saveSnapshotWithTime(name, data, time) + } } diff --git a/src/modules/dislike/match.ts b/src/modules/dislike/match.ts index ddf0bc76..5b558194 100644 --- a/src/modules/dislike/match.ts +++ b/src/modules/dislike/match.ts @@ -1,5 +1,5 @@ import { SPLIT_CHAR } from '@/constants' -import { parseAlbumRule } from './utils' +import { canonicalDislikeRule, parseAlbumRule } from './utils' import { normalizeText, normalizeSongName } from '@/server/utils/songVersion' /** @@ -20,6 +20,7 @@ export const splitSingers = (singer: unknown): string[] => export interface DislikeRuleSet { /** 精确规则:歌名@歌手 */ + scoped?: Map exact: Set /** 纯歌名规则 */ musicNames: Set @@ -43,7 +44,23 @@ export function parseDislikeRules(rules: string): DislikeRuleSet { } for (const raw of String(rules || '').split('\n')) { - const line = raw.trim() + let line = raw.trim() + const scope = line.indexOf('::') + if (scope >= 0) { + const source = line.slice(0, scope) + const body = line.slice(scope + 2) + if (source !== '*') { + set.scoped ||= new Map() + const prior = set.scoped.get(source) + const parsed = parseDislikeRules(body) + if (prior) { + for (const key of ['exact', 'musicNames', 'singerNames'] as const) for (const value of parsed[key]) prior[key].add(value) + for (const [name, singers] of parsed.albums) { const target = prior.albums.get(name) || new Set(); for (const singer of singers) target.add(singer); prior.albums.set(name, target) } + } else set.scoped.set(source, parsed) + continue + } + line = body + } if (!line) continue // 专辑维度(!<专辑名>@<歌手>,按歌手多条,聚合到专辑名 -> 歌手集合) @@ -60,7 +77,7 @@ export function parseDislikeRules(rules: string): DislikeRuleSet { // 歌曲 / 歌手维度 const [name, singer] = line.split(SPLIT_CHAR.DISLIKE_NAME) - const n = normalizeText(name) + const n = canonicalDislikeRule(normalizeText(name)) const s = normalizeText(singer) // 额外登记「去后缀」版本,让「晴天 (Live)」也能命中「晴天」规则(反之亦然)。 // 开关关闭时匹配端不会用归一化名,这些额外 key 自然不会被命中。 @@ -145,19 +162,24 @@ export function isDisliked( set: DislikeRuleSet, options: DislikeMatchOptions = {}, ): boolean { + if (set.scoped) { + for (const [source, rules] of set.scoped) { + if ((options.crossSource || source === input.source) && isDisliked(input, rules, options)) return true + } + } const crossSource = options.crossSource === true const duetMode = options.duetMode || 'any' const normalizeName = options.normalizeName !== false const requireSinger = options.requireSinger !== false - const name = normalizeText(input.name) + const name = canonicalDislikeRule(normalizeText(input.name)) const allSingers = splitSingers(input.singer) // primary 模式只看主唱(第一位歌手) const singers = duetMode === 'primary' ? allSingers.slice(0, 1) : allSingers const source = normalizeText(input.source) // 候选歌名:原名 + 去后缀名(去重),提升对 Live / Remix 等版本的召回 const candidateNames = Array.from(new Set( - [name, normalizeName ? normalizeSongName(input.name) : ''].filter(Boolean) + [name, normalizeName ? canonicalDislikeRule(normalizeSongName(input.name)) : ''].filter(Boolean) )) // 1. 专辑维度(专辑名 + 歌手集合,复用 duetMode 决定多歌手判定方式) @@ -221,3 +243,11 @@ export function filterDisliked( if (!items || items.length === 0) return items return items.filter(item => !isDisliked(toInput(item), set, options)) } + +export function scopeDislikeRules(set: DislikeRuleSet, source?: string): DislikeRuleSet { + if (!source) return set + set.scoped ||= new Map() + let rules = set.scoped.get(source) + if (!rules) { rules = parseDislikeRules(''); set.scoped.set(source, rules) } + return rules +} diff --git a/src/modules/dislike/snapshotDataManage.ts b/src/modules/dislike/snapshotDataManage.ts index 6a69f13f..8afcd83d 100644 --- a/src/modules/dislike/snapshotDataManage.ts +++ b/src/modules/dislike/snapshotDataManage.ts @@ -10,6 +10,8 @@ interface SnapshotInfo { latest: string | null time: number list: string[] + /** 由用户上传导入的快照 id;系统自动生成的快照不在此列 */ + imported: string[] clients: Record } export class SnapshotDataManage { @@ -21,6 +23,48 @@ export class SnapshotDataManage { clientSnapshotKeys: string[] private readonly saveSnapshotInfoThrottle: () => void + /** 兼容旧版 snapshotInfo.json(无 imported 字段) */ + private getImported = (): string[] => { + if (!Array.isArray(this.snapshotInfo.imported)) this.snapshotInfo.imported = [] + return this.snapshotInfo.imported + } + + /** + * 来源登记直接同步落盘,不走 saveSnapshotInfoThrottle: + * 该节流回调会触发 clearOldSnapshot,避免在清理路径里互相触发写入。 + */ + private persistSnapshotInfo = () => { + try { + fs.writeFileSync(this.snapshotInfoFilePath, JSON.stringify(this.snapshotInfo)) + } catch (err) { + syncLog.warn(err) + } + } + + /** 登记用户导入的备份,来源由服务端显式持久化,不靠文件名/latest 推断 */ + markImported = (name: string) => { + const imported = this.getImported() + if (imported.includes(name)) return + imported.push(name) + this.persistSnapshotInfo() + } + + /** 删除快照时移除来源登记 */ + unmarkImported = (name: string) => { + const imported = this.getImported() + const idx = imported.indexOf(name) + if (idx < 0) return + imported.splice(idx, 1) + this.persistSnapshotInfo() + } + + /** + * 判定是否为导入来源:优先读显式登记;兼容本次改动前已导入的备份—— + * 系统自动快照 id 恒为 32 位 md5,其余 id 只可能是外部导入的文件名。 + */ + isImported = (name: string): boolean => + this.getImported().includes(name) || !/^[0-9a-f]{32}$/.test(name) + isIncluedsDevice = (key: string) => { return this.clientSnapshotKeys.includes(key) } @@ -76,6 +120,19 @@ export class SnapshotDataManage { this.saveSnapshotInfoThrottle() } + // 回滚时清空客户端快照键,迫使其下次同步重新拉取最新数据 + clearClients = () => { + this.snapshotInfo.clients = {} + this.clientSnapshotKeys = [] + this.saveSnapshotInfoThrottle() + } + + // 将指定快照标记为 latest,使「当前 dislike 数据」回滚到该快照 + setLatest = (name: string) => { + this.snapshotInfo.latest = name + this.saveSnapshotInfoThrottle() + } + getSnapshot = async(name: string) => { const filePath = path.join(this.snapshotDir, `snapshot_${name}`) let listData: LX.Dislike.DislikeRules @@ -96,10 +153,35 @@ export class SnapshotDataManage { } saveSnapshot = async(name: string, data: LX.Dislike.DislikeRules) => { + if (global.lx?.restoreMaintenance) throw new Error('恢复维护中,请重启服务') syncLog.info('saveSnapshot', this.userDataManage.userName, name) const filePath = path.join(this.snapshotDir, `snapshot_${name}`) try { fs.writeFileSync(filePath, JSON.stringify(data, null, 2)) + // 不喜欢快照落盘后,防抖触发一次云端备份(与歌单快照、缓存落盘上传同一入口;按配置备份 snapshots 类型) + void (globalThis as any).lx?.scheduleOpenlistSync?.() + // 本地备份开关打开时,复制一份快照到备份目录("跟配置走",复用 configBackup.enable 与备份目录约定) + void (globalThis as any).lx?.backupSnapshotToLocal?.(filePath, this.userDataManage.userName, name) + } catch (err) { + syncLog.error(err) + throw err + } + } + + /** 导入外部下载的 dislike 备份:直接写入原始 JSON 内容并保留文件时间(与歌单快照 saveSnapshotWithTime 同构) */ + saveSnapshotWithTime = async(name: string, data: string, time: number) => { + syncLog.info('saveSnapshotWithTime', this.userDataManage.userName, name, time) + const filePath = path.join(this.snapshotDir, `snapshot_${name}`) + try { + fs.writeFileSync(filePath, data) + if (time) { + const date = new Date(time) + fs.utimesSync(filePath, date, date) + } + // 导入来源是显式登记的,避免下次自动快照把它误当成系统快照 + this.markImported(name) + void (globalThis as any).lx?.scheduleOpenlistSync?.() + void (globalThis as any).lx?.backupSnapshotToLocal?.(filePath, this.userDataManage.userName, name) } catch (err) { syncLog.error(err) throw err @@ -111,27 +193,109 @@ export class SnapshotDataManage { const filePath = path.join(this.snapshotDir, `snapshot_${name}`) try { fs.unlinkSync(filePath) + } catch (err) { + syncLog.error(err) + throw err + } + this.unmarkImported(name) + // 一并清理本地备份目录中该快照的所有带时间戳历史副本(含旧版无时间戳副本),避免孤儿文件 + void (globalThis as any).lx?.removeSnapshotLocalBackups?.(this.userDataManage.userName, name) + } + + /** 列出不喜欢快照(与歌单快照 getSnapshotListWithMeta 同构,供 /api/data/snapshots 合并展示) */ + getSnapshotListWithMeta = async (): Promise> => { + const list: Array<{ id: string, time: number, size: number, origin: 'imported' | 'auto' }> = [] + try { + const files = await fs.promises.readdir(this.snapshotDir) + for (const file of files) { + if (!file.startsWith('snapshot_')) continue + const name = file.replace('snapshot_', '') + const filePath = path.join(this.snapshotDir, file) + try { + const stat = await fs.promises.stat(filePath) + list.push({ id: name, time: stat.mtimeMs, size: stat.size, origin: this.isImported(name) ? 'imported' : 'auto' }) + } catch { /* ignore missing files */ } + } } catch (err) { syncLog.error(err) } + return list.sort((a, b) => b.time - a.time) } + /** + * 动态更新快照目录并迁移历史数据(无需重启即时生效) + * 与歌单快照(//snapshot)区分,dislike 快照使用 //dislike/snapshot + * @param targetBackupPath 新的 snapshot.backupPath 配置字符串 + */ + updateSnapshotDir = (targetBackupPath?: string) => { + const backupPathConf = (targetBackupPath !== undefined ? targetBackupPath : (global.lx.config['snapshot.backupPath'] || '')).trim() + const oldSnapshotDir = this.snapshotDir + let newSnapshotDir = '' + + if (backupPathConf) { + try { + const base = path.isAbsolute(backupPathConf) + ? backupPathConf + : path.join(global.lx.dataPath, backupPathConf) + newSnapshotDir = path.join(base, this.userDataManage.userName, File.dislikeDir, File.dislikeSnapshotDir) + checkAndCreateDirSync(newSnapshotDir) + } catch (err) { + syncLog.warn(`[DislikeSnapshot] 无法创建自定义快照目录 (${backupPathConf}),已安全回退到默认目录:`, err) + newSnapshotDir = path.join(this.dislikeDir, File.dislikeSnapshotDir) + } + } else { + newSnapshotDir = path.join(this.dislikeDir, File.dislikeSnapshotDir) + } + checkAndCreateDirSync(newSnapshotDir) + + // 迁移旧快照目录中的历史文件 + const sourceDir = oldSnapshotDir || path.join(this.dislikeDir, File.dislikeSnapshotDir) + if (newSnapshotDir !== sourceDir && fs.existsSync(sourceDir)) { + try { + let count = 0 + for (const name of fs.readdirSync(sourceDir)) { + const src = path.join(sourceDir, name) + const dst = path.join(newSnapshotDir, name) + if (!fs.existsSync(dst)) { + try { + fs.renameSync(src, dst) + count++ + } catch (e) { + syncLog.error(`[DislikeSnapshot] 迁移快照文件失败 (${name}):`, e) + } + } + } + if (count > 0) { + syncLog.info(`[DislikeSnapshot] 用户 ${this.userDataManage.userName} 不喜欢快照迁移成功 (共 ${count} 个文件 -> ${newSnapshotDir})`) + } + if (fs.existsSync(sourceDir) && fs.readdirSync(sourceDir).length === 0) { + try { fs.rmdirSync(sourceDir) } catch { } + } + } catch (err) { + syncLog.error(`[DislikeSnapshot] 用户 ${this.userDataManage.userName} 不喜欢快照迁移失败:`, err) + } + } + + this.snapshotDir = newSnapshotDir + } + constructor(userDataManage: UserDataManage) { this.userDataManage = userDataManage this.dislikeDir = path.join(userDataManage.userDir, File.dislikeDir) checkAndCreateDirSync(this.dislikeDir) - this.snapshotDir = path.join(this.dislikeDir, File.dislikeSnapshotDir) - checkAndCreateDirSync(this.snapshotDir) - + this.snapshotDir = '' this.snapshotInfoFilePath = path.join(this.dislikeDir, File.dislikeSnapshotInfoJSON) this.snapshotInfo = fs.existsSync(this.snapshotInfoFilePath) ? JSON.parse(fs.readFileSync(this.snapshotInfoFilePath).toString()) - : { latest: null, time: 0, list: [], clients: {} } + : { latest: null, time: 0, list: [], imported: [], clients: {} } + + this.updateSnapshotDir() this.saveSnapshotInfoThrottle = throttle(() => { + if (global.lx?.restoreMaintenance) return fs.writeFile(this.snapshotInfoFilePath, JSON.stringify(this.snapshotInfo), 'utf8', (err) => { if (err) console.error(err) void this.clearOldSnapshot() diff --git a/src/modules/dislike/utils.ts b/src/modules/dislike/utils.ts index 25644344..5406a487 100644 --- a/src/modules/dislike/utils.ts +++ b/src/modules/dislike/utils.ts @@ -70,3 +70,10 @@ export const filterRules = (rules: string) => { } return new Set(list) } + +/** 固定格式去重键;保留 Live 等版本语义,只消除大小写和括号邻接空格。 */ +export const canonicalDislikeRule = (rule: string): string => + rule.normalize('NFKC').trim().toLowerCase().replace(/\s*([()@])\s*/g, '$1') + +export const scopedDislikeRule = (rule: string, source?: string): string => + `${source || '*'}::${canonicalDislikeRule(rule)}` diff --git a/src/modules/list/manage.ts b/src/modules/list/manage.ts index 2386f8ba..66bb21e4 100644 --- a/src/modules/list/manage.ts +++ b/src/modules/list/manage.ts @@ -21,7 +21,10 @@ export class ListManage { if (snapshotInfo.list.includes(md5)) { snapshotInfo.list.splice(snapshotInfo.list.indexOf(md5), 1) } else await this.snapshotDataManage.saveSnapshot(md5, listData) - if (snapshotInfo.latest) snapshotInfo.list.unshift(snapshotInfo.latest) + // 用户导入的备份不纳入系统快照 list/配额清理,避免被自动删除或误判为系统生成 + if (snapshotInfo.latest && !this.snapshotDataManage.isImported(snapshotInfo.latest)) { + snapshotInfo.list.unshift(snapshotInfo.latest) + } snapshotInfo.latest = md5 snapshotInfo.time = Date.now() this.snapshotDataManage.saveSnapshotInfo(snapshotInfo) diff --git a/src/modules/list/snapshotDataManage.ts b/src/modules/list/snapshotDataManage.ts index 27bad221..5f30da6a 100644 --- a/src/modules/list/snapshotDataManage.ts +++ b/src/modules/list/snapshotDataManage.ts @@ -10,6 +10,8 @@ interface SnapshotInfo { latest: string | null time: number list: string[] + /** 由用户上传导入的快照 id;系统自动生成的快照不在此列 */ + imported: string[] clients: Record } export class SnapshotDataManage { @@ -21,6 +23,48 @@ export class SnapshotDataManage { clientSnapshotKeys: string[] private readonly saveSnapshotInfoThrottle: () => void + /** 兼容旧版 snapshotInfo.json(无 imported 字段) */ + private getImported = (): string[] => { + if (!Array.isArray(this.snapshotInfo.imported)) this.snapshotInfo.imported = [] + return this.snapshotInfo.imported + } + + /** + * 来源登记直接同步落盘,不走 saveSnapshotInfoThrottle: + * 该节流回调会触发 clearOldSnapshot,避免在清理路径里互相触发写入。 + */ + private persistSnapshotInfo = () => { + try { + fs.writeFileSync(this.snapshotInfoFilePath, JSON.stringify(this.snapshotInfo)) + } catch (err) { + syncLog.warn(err) + } + } + + /** 登记用户导入的备份,来源由服务端显式持久化,不靠文件名/latest 推断 */ + markImported = (name: string) => { + const imported = this.getImported() + if (imported.includes(name)) return + imported.push(name) + this.persistSnapshotInfo() + } + + /** 删除快照时移除来源登记 */ + unmarkImported = (name: string) => { + const imported = this.getImported() + const idx = imported.indexOf(name) + if (idx < 0) return + imported.splice(idx, 1) + this.persistSnapshotInfo() + } + + /** + * 判定是否为导入来源:优先读显式登记;兼容本次改动前已导入的备份—— + * 系统自动快照 id 恒为 32 位 md5,其余 id 只可能是外部导入的文件名。 + */ + isImported = (name: string): boolean => + this.getImported().includes(name) || !/^[0-9a-f]{32}$/.test(name) + isIncluedsDevice = (key: string) => { return this.clientSnapshotKeys.includes(key) } @@ -89,10 +133,15 @@ export class SnapshotDataManage { } saveSnapshot = async (name: string, data: string) => { + if (global.lx?.restoreMaintenance) throw new Error('恢复维护中,请重启服务') syncLog.info('saveSnapshot', this.userDataManage.userName, name) const filePath = path.join(this.snapshotDir, `snapshot_${name}`) try { fs.writeFileSync(filePath, data) + // 歌单快照落盘后,防抖触发一次云端备份(与缓存落盘上传同一入口;按配置备份 snapshots 类型) + void (globalThis as any).lx?.scheduleOpenlistSync?.() + // 本地备份开关打开时,复制一份快照到备份目录("跟配置走",复用 configBackup.enable 与备份目录约定) + void (globalThis as any).lx?.backupSnapshotToLocal?.(filePath, this.userDataManage.userName, name) } catch (err) { syncLog.error(err) throw err @@ -108,6 +157,12 @@ export class SnapshotDataManage { const date = new Date(time) fs.utimesSync(filePath, date, date) } + // 导入来源是显式登记的,避免下次自动快照把它误当成系统快照 + this.markImported(name) + // 歌单快照落盘后,防抖触发一次云端备份(与缓存落盘上传同一入口;按配置备份 snapshots 类型) + void (globalThis as any).lx?.scheduleOpenlistSync?.() + // 本地备份开关打开时,复制一份快照到备份目录("跟配置走",复用 configBackup.enable 与备份目录约定) + void (globalThis as any).lx?.backupSnapshotToLocal?.(filePath, this.userDataManage.userName, name) } catch (err) { syncLog.error(err) throw err @@ -121,11 +176,15 @@ export class SnapshotDataManage { fs.unlinkSync(filePath) } catch (err) { syncLog.error(err) + throw err } + this.unmarkImported(name) + // 一并清理本地备份目录中该快照的所有带时间戳历史副本(含旧版无时间戳副本),避免孤儿文件 + void (globalThis as any).lx?.removeSnapshotLocalBackups?.(this.userDataManage.userName, name) } - getSnapshotListWithMeta = async () => { - const list = [] + getSnapshotListWithMeta = async (): Promise> => { + const list: Array<{ id: string, time: number, size: number, origin: 'imported' | 'auto' }> = [] try { const files = await fs.promises.readdir(this.snapshotDir) for (const file of files) { @@ -138,6 +197,7 @@ export class SnapshotDataManage { id: name, time: stat.mtimeMs, size: stat.size, + origin: this.isImported(name) ? 'imported' : 'auto', }) } catch (e) { // ignore missing files @@ -247,10 +307,11 @@ export class SnapshotDataManage { this.listDir = path.join(userDataManage.userDir, File.listDir) checkAndCreateDirSync(this.listDir) - this.snapshotInfo = { latest: null, time: 0, list: [], clients: {} } + this.snapshotInfo = { latest: null, time: 0, list: [], imported: [], clients: {} } this.updateSnapshotDir() this.saveSnapshotInfoThrottle = throttle(() => { + if (global.lx?.restoreMaintenance) return fs.writeFile(this.snapshotInfoFilePath, JSON.stringify(this.snapshotInfo), 'utf8', (err) => { if (err) console.error(err) void this.clearOldSnapshot() diff --git a/src/modules/utils/musicSdk/index.js b/src/modules/utils/musicSdk/index.js index dc9526c8..c0a802c7 100644 --- a/src/modules/utils/musicSdk/index.js +++ b/src/modules/utils/musicSdk/index.js @@ -5,6 +5,7 @@ import wy from './wy/index' import mg from './mg/index' import xm from './xm' import { supportQuality } from './api-source' +import { versionChars } from './versionChars' export { dateFormat, dateFormat2, decodeName, sizeFormate, formatPlayTime, formatPlayCount } from '../index' @@ -116,7 +117,9 @@ export default { const fAlbumName = filterStr(albumName).toLowerCase() const fInterval = getIntv(interval) const isEqualsInterval = (intv) => Math.abs((fInterval || intv) - (intv || fInterval)) < 5 - const isIncludesName = (name) => (fMusicName.includes(name) || name.includes(fMusicName)) + // Legacy: const isIncludesName = name => fMusicName.includes(name) || name.includes(fMusicName) + const isIncludesName = name => (fMusicName.includes(name) || name.includes(fMusicName)) && + versionChars.every(char => name.includes(char) === fMusicName.includes(char)) const isIncludesSinger = (singer) => fSinger ? (fSinger.includes(singer) || singer.includes(fSinger)) : true const isEqualsAlbum = (album) => fAlbumName ? fAlbumName == album : true diff --git a/src/modules/utils/musicSdk/kg/leaderboard.js b/src/modules/utils/musicSdk/kg/leaderboard.js index 0250e696..2ac295af 100644 --- a/src/modules/utils/musicSdk/kg/leaderboard.js +++ b/src/modules/utils/musicSdk/kg/leaderboard.js @@ -132,6 +132,7 @@ export default { albumName: decodeName(item.remark), albumId: item.album_id, songmid: item.audio_id, + albumAudioId: item.album_audio_id, source: 'kg', interval: formatPlayTime(item.duration), img: (item.album_sizable_cover || item.trans_param?.union_cover || '').replace('{size}', '1000') || null, diff --git a/src/modules/utils/musicSdk/kg/musicInfo.js b/src/modules/utils/musicSdk/kg/musicInfo.js index 464ae6cc..a77ff900 100644 --- a/src/modules/utils/musicSdk/kg/musicInfo.js +++ b/src/modules/utils/musicSdk/kg/musicInfo.js @@ -85,6 +85,7 @@ export const filterMusicInfoList = (rawList) => { albumName: decodeName(item.album_info.album_name), albumId: item.album_info.album_id, songmid: item.audio_info.audio_id, + albumAudioId: item.audio_info.audio_group_id, source: 'kg', interval: formatPlayTime(parseInt(item.audio_info.timelength) / 1000), img: (item.img || item.album_info?.sizable_cover || item.audio_info?.trans_param?.union_cover || item.album_info?.pic || item.album_info?.img || item.album_info?.s_img || '').replace('{size}', '1000') || null, diff --git a/src/modules/utils/musicSdk/kg/musicSearch.js b/src/modules/utils/musicSdk/kg/musicSearch.js index 243665dc..5af5329c 100644 --- a/src/modules/utils/musicSdk/kg/musicSearch.js +++ b/src/modules/utils/musicSdk/kg/musicSearch.js @@ -13,8 +13,13 @@ export default { total: 0, page: 0, allPage: 1, + // Legacy implementation retained for reference; inactive. + // musicSearch(str, page, limit) { + // const searchRequest = httpFetch(`https://songsearch.kugou.com/song_search_v2?keyword=${encodeURIComponent(str)}&page=${page}&pagesize=${limit}&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1`) + // return searchRequest.promise.then(({ body }) => body) + // } musicSearch(str, page, limit) { - const searchRequest = httpFetch(`https://songsearch.kugou.com/song_search_v2?keyword=${encodeURIComponent(str)}&page=${page}&pagesize=${limit}&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1`) + const searchRequest = httpFetch(`http://songsearch.kugou.com/song_search_v2?platform=AndroidFilter&iscorrection=1&keyword=${encodeURIComponent(str)}&hifiquality=0&pagesize=${limit}&PrivilegeFilter=0&page=${page}`) return searchRequest.promise.then(({ body }) => body) }, filterData(rawData) { @@ -54,10 +59,11 @@ export default { } return { singer: decodeName(formatSingerName(rawData.Singers, 'name')), - name: decodeName(rawData.SongName), + name: decodeName(rawData.OriSongName ? `${rawData.OriSongName}${rawData.Suffix ? ` ${rawData.Suffix}` : ''}` : rawData.SongName), albumName: decodeName(rawData.AlbumName), albumId: rawData.AlbumID, songmid: rawData.Audioid, + albumAudioId: rawData.MixSongID, source: 'kg', interval: formatPlayTime(rawData.Duration), _interval: rawData.Duration, diff --git a/src/modules/utils/musicSdk/kg/songList.js b/src/modules/utils/musicSdk/kg/songList.js index 3223930c..3d7bb727 100644 --- a/src/modules/utils/musicSdk/kg/songList.js +++ b/src/modules/utils/musicSdk/kg/songList.js @@ -377,6 +377,34 @@ export default { } }, + // Legacy implementation retained for reference; inactive. + // async getUserListDetail3(chain, page) { + // const songInfo = await this.createHttp(`http://m.kugou.com/schain/transfer?pagesize=${this.listDetailLimit}&chain=${chain}&su=1&page=${page}&n=0.7928855356604456`, { + // headers: { + // 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', + // }, + // }) + // if (!songInfo.list) { + // if (songInfo.global_collection_id) return this.getUserListDetail2(songInfo.global_collection_id) + // else return this.getUserListDetail4(songInfo, chain, page).catch(() => this.getUserListDetail5(chain)) + // } + // let list = await this.getMusicInfos(songInfo.list) + // // console.log(info, songInfo) + // return { + // list, + // page: 1, + // limit: this.listDetailLimit, + // total: list.length, + // source: 'kg', + // info: { + // name: songInfo.info.name, + // img: songInfo.info.img, + // // desc: body.result.info.list_desc, + // author: songInfo.info.username, + // // play_count: formatPlayCount(info.count), + // }, + // } + // } async getUserListDetail3(chain, page) { const songInfo = await this.createHttp(`http://m.kugou.com/schain/transfer?pagesize=${this.listDetailLimit}&chain=${chain}&su=1&page=${page}&n=0.7928855356604456`, { headers: { @@ -384,8 +412,10 @@ export default { }, }) if (!songInfo.list) { - if (songInfo.global_collection_id) return this.getUserListDetail2(songInfo.global_collection_id) - else return this.getUserListDetail4(songInfo, chain, page).catch(() => this.getUserListDetail5(chain)) + if (songInfo.global_collection_id) { + return this.getListDetailByGcidChain(songInfo.global_collection_id, chain) + .catch(() => this.getUserListDetail2(songInfo.global_collection_id)) + } else return this.getUserListDetail4(songInfo, chain, page).catch(() => this.getUserListDetail5(chain)) } let list = await this.getMusicInfos(songInfo.list) // console.log(info, songInfo) @@ -490,6 +520,53 @@ export default { } return Promise.all(tasks).then(([...datas]) => datas.flat()) }, + async getListDetailByGcidChain(id, chain) { + const params = 'srcappid=2919&clientver=20000&clienttime=1789566751657&mid=1789566751657&uuid=1789566751657&dfid=-&listid=15&type=0&pagesize=50&global_collection_id=' + id + '&page=1&share_type=collect&appid=1058&chain=' + chain + const data = await this.createHttp(`https://m3ws.kugou.com/v2/zlist/list?${params}&signature=${signatureParams(params, 'web')}`, { + headers: { + mid: '1586163263991', + Referer: 'https://m3ws.kugou.com/share/index.php', + 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + dfid: '-', + clienttime: '1586163263991', + }, + }) + const info = data.info?.[0] + if (!info || !Array.isArray(data.list?.info)) throw new Error('get list error') + let tasks = [Promise.resolve(data.list.info)] + let page = 1 + let totalPage = Math.ceil(info.count / 50) + while (page < totalPage) { + const limit = 50 + page += 1 + const params = 'srcappid=2919&clientver=20000&clienttime=1789566751657&mid=1789566751657&uuid=1789566751657&dfid=-&listid=15&type=0&pagesize=' + limit + '&global_collection_id=' + id + '&page=' + page + '&share_type=collect&appid=1058&chain=' + chain + tasks.push(this.createHttp(`https://m3ws.kugou.com/v2/zlist/list?${params}&signature=${signatureParams(params, 'web')}`, { + headers: { + mid: '1586163263991', + Referer: 'https://m3ws.kugou.com/share/index.php', + 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1', + dfid: '-', + clienttime: '1586163263991', + }, + }).then(data => data.list.info)) + } + const hashs = await Promise.all(tasks).then(([...datas]) => datas.flat()) + let list = await this.getMusicInfos(hashs) + return { + list, + page: 1, + limit: this.listDetailLimit, + total: info.count, + source: 'kg', + info: { + name: info.name, + img: info.pic && info.pic.replace('{size}', '1000'), + desc: info.intro, + author: info.list_create_username, + // play_count: formatPlayCount(info.playcount), + }, + } + }, async getUserListDetail2(global_collection_id) { let id = global_collection_id if (id.length > 1000) throw new Error('get list error') @@ -610,10 +687,84 @@ export default { return result }, + // Legacy implementation retained for reference; inactive. + // async getUserListDetail(link, page, retryNum = 0) { + // if (retryNum > 3) return Promise.reject(new Error('link try max num')) + // if (link.includes('#')) link = link.replace(/#.*$/, '') + // if (link.includes('global_collection_id')) return this.getUserListDetail2(link.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')) + // if (link.includes('gcid_')) { + // let gcid = link.match(/gcid_\w+/)?.[0] + // if (gcid) { + // const global_collection_id = await this.decodeGcid(gcid) + // if (global_collection_id) return this.getUserListDetail2(global_collection_id) + // } + // } + // if (link.includes('chain=')) return this.getUserListDetail3(link.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page) + // if (link.includes('.html')) { + // if (link.includes('zlist.html')) { + // link = link.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list') + // if (link.includes('pagesize')) { + // link = link.replace('pagesize=30', 'pagesize=' + this.listDetailLimit).replace('page=1', 'page=' + page) + // } else { + // link += `&pagesize=${this.listDetailLimit}&page=${page}` + // } + // } else if (!link.includes('song.html')) return this.getUserListDetail3(link.replace(/.+\/(\w+).html(?:\?.*|&.*$|#.*$|$)/, '$1'), page) + // } + // + // const requestObj_listDetailLink = httpFetch(link, { + // headers: { + // 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1', + // Referer: link, + // }, + // }) + // const { headers: { location }, statusCode, body } = await requestObj_listDetailLink.promise + // // console.log(body, location) + // if (statusCode > 400) return this.getUserListDetail(link, page, ++retryNum) + // if (location) { + // // console.log(location) + // if (location.includes('global_collection_id')) return this.getUserListDetail2(location.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')) + // if (location.includes('gcid_')) { + // let gcid = link.match(/gcid_\w+/)?.[0] + // if (gcid) { + // const global_collection_id = await this.decodeGcid(gcid) + // if (global_collection_id) return this.getUserListDetail2(global_collection_id) + // } + // } + // if (location.includes('chain=')) return this.getUserListDetail3(location.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page) + // if (location.includes('.html')) { + // if (location.includes('zlist.html')) { + // let link = location.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list') + // if (link.includes('pagesize')) { + // link = link.replace('pagesize=30', 'pagesize=' + this.listDetailLimit).replace('page=1', 'page=' + page) + // } else { + // link += `&pagesize=${this.listDetailLimit}&page=${page}` + // } + // return this.getUserListDetail(link, page, ++retryNum) + // } else return this.getUserListDetail3(location.replace(/.+\/(\w+).html(?:\?.*|&.*$|#.*$|$)/, '$1'), page) + // } + // // console.log('location', location) + // return this.getUserListDetail(location, page, ++retryNum) + // } + // if (typeof body == 'string') { + // let global_collection_id = body.match(/"global_collection_id":"(\w+)"/)?.[1] + // if (!global_collection_id) { + // let gcid = body.match(/"encode_gic":"(\w+)"/)?.[1] + // if (!gcid) gcid = body.match(/"encode_src_gid":"(\w+)"/)?.[1] + // if (gcid) global_collection_id = await this.decodeGcid(gcid) + // } + // if (!global_collection_id) throw new Error('get list error') + // return this.getUserListDetail2(global_collection_id) + // } + // if (body.errcode !== 0) return this.getUserListDetail(link, page, ++retryNum) + // return this.getUserListDetailByLink(body, link) + // } async getUserListDetail(link, page, retryNum = 0) { if (retryNum > 3) return Promise.reject(new Error('link try max num')) if (link.includes('#')) link = link.replace(/#.*$/, '') - if (link.includes('global_collection_id')) return this.getUserListDetail2(link.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')) + if (link.includes('global_collection_id') && link.includes('chain')) { + return this.getListDetailByGcidChain(link.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'), link.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1')) + } + if (link.includes('chain=')) return this.getUserListDetail3(link.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page) if (link.includes('gcid_')) { let gcid = link.match(/gcid_\w+/)?.[0] if (gcid) { @@ -621,7 +772,6 @@ export default { if (global_collection_id) return this.getUserListDetail2(global_collection_id) } } - if (link.includes('chain=')) return this.getUserListDetail3(link.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page) if (link.includes('.html')) { if (link.includes('zlist.html')) { link = link.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list') @@ -632,6 +782,7 @@ export default { } } else if (!link.includes('song.html')) return this.getUserListDetail3(link.replace(/.+\/(\w+).html(?:\?.*|&.*$|#.*$|$)/, '$1'), page) } + if (link.includes('global_collection_id')) return this.getUserListDetail2(link.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')) const requestObj_listDetailLink = httpFetch(link, { headers: { @@ -643,16 +794,18 @@ export default { // console.log(body, location) if (statusCode > 400) return this.getUserListDetail(link, page, ++retryNum) if (location) { + if (location.includes('global_collection_id') && location.includes('chain')) { + return this.getListDetailByGcidChain(location.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1'), location.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1')) + } // console.log(location) - if (location.includes('global_collection_id')) return this.getUserListDetail2(location.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')) + if (location.includes('chain=')) return this.getUserListDetail3(location.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page) if (location.includes('gcid_')) { - let gcid = link.match(/gcid_\w+/)?.[0] + let gcid = location.match(/gcid_\w+/)?.[0] if (gcid) { const global_collection_id = await this.decodeGcid(gcid) if (global_collection_id) return this.getUserListDetail2(global_collection_id) } } - if (location.includes('chain=')) return this.getUserListDetail3(location.replace(/^.*?chain=(\w+)(?:&.*$|#.*$|$)/, '$1'), page) if (location.includes('.html')) { if (location.includes('zlist.html')) { let link = location.replace(/^(.*)zlist\.html/, 'https://m3ws.kugou.com/zlist/list') @@ -664,6 +817,7 @@ export default { return this.getUserListDetail(link, page, ++retryNum) } else return this.getUserListDetail3(location.replace(/.+\/(\w+).html(?:\?.*|&.*$|#.*$|$)/, '$1'), page) } + if (location.includes('global_collection_id')) return this.getUserListDetail2(location.replace(/^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)/, '$1')) // console.log('location', location) return this.getUserListDetail(location, page, ++retryNum) } @@ -740,6 +894,7 @@ export default { albumName: decodeName(item.album_name), albumId: item.album_id, songmid: item.audio_id, + albumAudioId: item.album_audio_id, source: 'kg', interval: formatPlayTime(item.duration / 1000), img: (item.img || item.album_img || '').replace('{size}', '1000') || null, @@ -802,6 +957,7 @@ export default { // albumName: decodeName(item.albuminfo.name), // albumId: item.albuminfo.id, // songmid: item.audio_id, + // albumAudioId: item.album_audio_id, // source: 'kg', // interval: formatPlayTime(item.timelen / 1000), // img: null, @@ -863,6 +1019,7 @@ export default { albumName: decodeName(item.album_info.album_name), albumId: item.album_info.album_id, songmid: item.audio_info.audio_id, + albumAudioId: item.audio_info.audio_group_id, source: 'kg', interval: formatPlayTime(parseInt(item.audio_info.timelength) / 1000), img: (item.img || item.album_info?.sizable_cover || item.audio_info?.trans_param?.union_cover || item.album_info?.pic || item.album_info?.img || item.album_info?.s_img || '').replace('{size}', '1000') || null, diff --git a/src/modules/utils/musicSdk/kw/lyric.js b/src/modules/utils/musicSdk/kw/lyric.js index 388ea4b8..2f4b11f4 100644 --- a/src/modules/utils/musicSdk/kw/lyric.js +++ b/src/modules/utils/musicSdk/kw/lyric.js @@ -233,35 +233,70 @@ export default { // }) // return requestObj // }, + // Legacy implementation retained for reference; inactive. + // getLyric(musicInfo, isGetLyricx = true) { + // // this.getLyric2(musicInfo) + // const requestObj = httpFetch(`http://newlyric.kuwo.cn/newlyric.lrc?${buildParams(musicInfo.songmid, isGetLyricx)}`) + // requestObj.promise = requestObj.promise.then(({ statusCode, body, raw }) => { + // if (statusCode != 200) return Promise.reject(new Error(JSON.stringify(body))) + // return decodeLyric({ lrcBase64: raw.toString('base64'), isGetLyricx }).then(base64Data => { + // let lrcInfo + // const lrcText = Buffer.from(base64Data, 'base64').toString() + // + // try { + // lrcInfo = this.parseLrc(lrcText) + // } catch (err) { + // return Promise.reject(new Error('Get lyric failed')) + // } + // + // if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '') + // try { + // lrcInfo.lxlyric = lrcTools.parse(lrcInfo.lyric) + // } catch { + // lrcInfo.lxlyric = '' + // } + // lrcInfo.lyric = lrcInfo.lyric.replace(lrcTools.rxps.wordTimeAll, '') + // + // if (!existTimeExp.test(lrcInfo.lyric)) { + // return Promise.reject(new Error('Get lyric failed')) + // } + // + // return lrcInfo + // }) + // }) + // return requestObj + // } getLyric(musicInfo, isGetLyricx = true) { // this.getLyric2(musicInfo) - const requestObj = httpFetch(`http://newlyric.kuwo.cn/newlyric.lrc?${buildParams(musicInfo.songmid, isGetLyricx)}`) - requestObj.promise = requestObj.promise.then(({ statusCode, body, raw }) => { + const requestObj = httpFetch(`http://mlyric.kuwo.cn/mobi.s?f=web&type=lyric&lrcx=${isGetLyricx ? 1 : 0}&rid=${musicInfo.songmid}&encode=utf8`) + requestObj.promise = requestObj.promise.then(async({ statusCode, body, raw }) => { if (statusCode != 200) return Promise.reject(new Error(JSON.stringify(body))) - return decodeLyric({ lrcBase64: raw.toString('base64'), isGetLyricx }).then(base64Data => { - let lrcInfo - const lrcText = Buffer.from(base64Data, 'base64').toString() - - try { - lrcInfo = this.parseLrc(lrcText) - } catch (err) { - return Promise.reject(new Error('Get lyric failed')) - } - - if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '') - try { - lrcInfo.lxlyric = lrcTools.parse(lrcInfo.lyric) - } catch { - lrcInfo.lxlyric = '' - } - lrcInfo.lyric = lrcInfo.lyric.replace(lrcTools.rxps.wordTimeAll, '') - - if (!existTimeExp.test(lrcInfo.lyric)) { - return Promise.reject(new Error('Get lyric failed')) - } - - return lrcInfo - }) + const lrcText = await decodeLyric(raw, isGetLyricx) + // console.log(lrcText) + // let lrcInfo + // try { + // lrcInfo = this.parseLrc(Buffer.from(base64Data, 'base64').toString()) + // } catch { + // return Promise.reject(new Error('Get lyric failed')) + // } + let lrcInfo + // console.log(Buffer.from(base64Data, 'base64').toString()) + try { + lrcInfo = this.parseLrc(lrcText) + } catch (err) { + return Promise.reject(new Error('Get lyric failed')) + } + // console.log(lrcInfo) + if (lrcInfo.tlyric) lrcInfo.tlyric = lrcInfo.tlyric.replace(lrcTools.rxps.wordTimeAll, '') + try { + lrcInfo.lxlyric = lrcTools.parse(lrcInfo.lyric) + } catch { + lrcInfo.lxlyric = '' + } + lrcInfo.lyric = lrcInfo.lyric.replace(lrcTools.rxps.wordTimeAll, '') + if (!existTimeExp.test(lrcInfo.lyric)) return Promise.reject(new Error('Get lyric failed')) + // console.log(lrcInfo) + return lrcInfo }) return requestObj }, diff --git a/src/modules/utils/musicSdk/kw/songList.js b/src/modules/utils/musicSdk/kw/songList.js index 6a601ef8..b1c703f2 100644 --- a/src/modules/utils/musicSdk/kw/songList.js +++ b/src/modules/utils/musicSdk/kw/songList.js @@ -144,23 +144,47 @@ export default { source: 'kw', })) }, + // Legacy implementation retained for reference; inactive. + // filterList2(rawData) { + // // console.log(rawData) + // const list = [] + // rawData.forEach(item => { + // if (!item.label) return + // list.push(...item.list.map(item => ({ + // play_count: item.play_count && this.formatPlayCount(item.listencnt), + // id: `digest-${item.digest}__${item.id}`, + // author: item.uname, + // name: item.name, + // total: item.total, + // // time: item.publish_time, + // img: formatPic(item.img), + // grade: item.favorcnt && item.favorcnt / 10, + // desc: item.desc, + // source: 'kw', + // }))) + // }) + // return list + // } filterList2(rawData) { - // console.log(rawData) const list = [] + const allowedTypes = ['songlist', 'list', 'album'] + // digest: 5 = list, 8 = songlist, 13 = album, 4 = artist rawData.forEach(item => { - if (!item.label) return - list.push(...item.list.map(item => ({ - play_count: item.play_count && this.formatPlayCount(item.listencnt), - id: `digest-${item.digest}__${item.id}`, - author: item.uname, - name: item.name, - total: item.total, - // time: item.publish_time, - img: formatPic(item.img), - grade: item.favorcnt && item.favorcnt / 10, - desc: item.desc, - source: 'kw', - }))) + (item.list || []).forEach(item => { + if (!allowedTypes.includes(item.type)) return + list.push({ + play_count: item.play_count && this.formatPlayCount(item.listencnt), + id: `digest-${item.digest}__${item.id}`, + author: item.uname, + name: item.name, + total: item.total, + // time: item.publish_time, + img: formatPic(item.img), + grade: item.favorcnt && item.favorcnt / 10, + desc: item.desc, + source: 'kw', + }) + }) }) return list }, diff --git a/src/modules/utils/musicSdk/kw/util.js b/src/modules/utils/musicSdk/kw/util.js index 24a9ee41..61c408fe 100644 --- a/src/modules/utils/musicSdk/kw/util.js +++ b/src/modules/utils/musicSdk/kw/util.js @@ -2,7 +2,7 @@ import { createCipheriv, createDecipheriv } from 'crypto' import { toMD5 } from '../utils' import { inflate } from 'zlib' -import iconv from 'iconv-lite' +// Legacy decoder import: import iconv from 'iconv-lite' // const kw_token = { // token: null, @@ -70,7 +70,49 @@ export const matchToken = headers => { // }) // Server-side implementation of decodeLyric (ported from desktop main process) -const handleInflate = async (data) => { +// Legacy implementation retained for reference; inactive. +// const handleInflate = async (data) => { +// return new Promise((resolve, reject) => { +// inflate(data, (err, result) => { +// if (err) { +// reject(err) +// return +// } +// resolve(result) +// }) +// }) +// } +// +// const buf_key = Buffer.from('yeelion') +// const buf_key_len = buf_key.length +// +// const decodeLyricInternal = async (buf, isGetLyricx) => { +// if (buf.toString('utf8', 0, 10) != 'tp=content') return '' +// const lrcData = await handleInflate(buf.subarray(buf.indexOf('\r\n\r\n') + 4)) +// +// if (!isGetLyricx) return iconv.decode(lrcData, 'gb18030') +// +// const buf_str = Buffer.from(lrcData.toString(), 'base64') +// const buf_str_len = buf_str.length +// const output = new Uint8Array(buf_str_len) +// let i = 0 +// while (i < buf_str_len) { +// let j = 0 +// while (j < buf_key_len && i < buf_str_len) { +// output[i] = buf_str[i] ^ buf_key[j] +// i++ +// j++ +// } +// } +// +// return iconv.decode(Buffer.from(output), 'gb18030') +// } +// +// export const decodeLyric = async ({ lrcBase64, isGetLyricx }) => { +// const lrc = await decodeLyricInternal(Buffer.from(lrcBase64, 'base64'), isGetLyricx) +// return Buffer.from(lrc).toString('base64') +// } +const handleInflate = async(data) => { return new Promise((resolve, reject) => { inflate(data, (err, result) => { if (err) { @@ -85,12 +127,11 @@ const handleInflate = async (data) => { const buf_key = Buffer.from('yeelion') const buf_key_len = buf_key.length -const decodeLyricInternal = async (buf, isGetLyricx) => { - if (buf.toString('utf8', 0, 10) != 'tp=content') return '' +export const decodeLyric = async(rawData, isGetLyricx) => { + const buf = Buffer.isBuffer(rawData) ? rawData : Buffer.from(rawData) + if (buf.toString('utf8', 0, 10).toLowerCase() !== 'tp=content') return '' const lrcData = await handleInflate(buf.subarray(buf.indexOf('\r\n\r\n') + 4)) - - if (!isGetLyricx) return iconv.decode(lrcData, 'gb18030') - + if (!isGetLyricx) return lrcData.toString('utf8') const buf_str = Buffer.from(lrcData.toString(), 'base64') const buf_str_len = buf_str.length const output = new Uint8Array(buf_str_len) @@ -104,13 +145,10 @@ const decodeLyricInternal = async (buf, isGetLyricx) => { } } - return iconv.decode(Buffer.from(output), 'gb18030') + return Buffer.from(output).toString('utf8') } -export const decodeLyric = async ({ lrcBase64, isGetLyricx }) => { - const lrc = await decodeLyricInternal(Buffer.from(lrcBase64, 'base64'), isGetLyricx) - return Buffer.from(lrc).toString('base64') -} + // export const tokenRequest = async(url, options = {}) => { // let token = kw_token.token @@ -148,8 +186,8 @@ export const lrcTools = { getWordInfo(str, str2, prevWord) { const offset = parseInt(str) const offset2 = parseInt(str2) - let startTime = Math.abs((offset + offset2) / (this.offset * 2)) - let endTime = Math.abs((offset - offset2) / (this.offset2 * 2)) + startTime + let startTime = Math.trunc(Math.abs((offset + offset2) / (this.offset * 2))) + let endTime = Math.trunc(Math.abs((offset - offset2) / (this.offset2 * 2)) + startTime) if (prevWord) { if (startTime < prevWord.endTime) { prevWord.endTime = startTime diff --git a/src/modules/utils/musicSdk/mg/lyric.js b/src/modules/utils/musicSdk/mg/lyric.js index 7e36290f..e35078b5 100644 --- a/src/modules/utils/musicSdk/mg/lyric.js +++ b/src/modules/utils/musicSdk/mg/lyric.js @@ -21,7 +21,7 @@ const mrcTools = { const startTime = parseInt(result[1]) let time = startTime - let ms = time % 1000 + let ms = (time % 1000).toString().padStart(3, '0') time /= 1000 let m = parseInt(time / 60).toString().padStart(2, '0') time %= 60 @@ -66,39 +66,43 @@ const mrcTools = { return this.parseLyric(decrypt(text)) }) }, + // Legacy implementation retained for reference; inactive. + // getLrc(url) { + // return this.getText(url).then(text => { + // // 咪咕的lrcUrl可能返回纯文本歌词(没有时间标签) + // // 需要检查并添加假时间标签 + // const lines = text.split('\n') + // const hasTimeTag = /^\[(\d+):(\d+)\.(\d+)\]/.test(text) + // + // // 如果已经有时间标签,直接返回 + // if (hasTimeTag) { + // const linesWithTime = lines.filter(line => /^\[(\d+):(\d+)\.(\d+)\]/.test(line)) + // // 如果大部分行都有时间标签(>50%),认为是标准LRC + // if (linesWithTime.length > lines.length * 0.5) { + // return { lxlyric: '', lyric: text } + // } + // } + // + // // 否则,为纯文本歌词添加假时间标签 + // let currentTime = 0 + // const lrcLines = lines.map((line, index) => { + // line = line.trim() + // if (!line || line.startsWith('@')) return '' // 跳过空行和头部 + // + // // 为每行添加时间标签,每行间隔3秒 + // const minutes = Math.floor(currentTime / 60) + // const seconds = currentTime % 60 + // const timeTag = `[${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.00]` + // currentTime += 3 + // + // return `${timeTag}${line}` + // }).filter(line => line) // 移除空行 + // + // return { lxlyric: '', lyric: lrcLines.join('\n') } + // }) + // } getLrc(url) { - return this.getText(url).then(text => { - // 咪咕的lrcUrl可能返回纯文本歌词(没有时间标签) - // 需要检查并添加假时间标签 - const lines = text.split('\n') - const hasTimeTag = /^\[(\d+):(\d+)\.(\d+)\]/.test(text) - - // 如果已经有时间标签,直接返回 - if (hasTimeTag) { - const linesWithTime = lines.filter(line => /^\[(\d+):(\d+)\.(\d+)\]/.test(line)) - // 如果大部分行都有时间标签(>50%),认为是标准LRC - if (linesWithTime.length > lines.length * 0.5) { - return { lxlyric: '', lyric: text } - } - } - - // 否则,为纯文本歌词添加假时间标签 - let currentTime = 0 - const lrcLines = lines.map((line, index) => { - line = line.trim() - if (!line || line.startsWith('@')) return '' // 跳过空行和头部 - - // 为每行添加时间标签,每行间隔3秒 - const minutes = Math.floor(currentTime / 60) - const seconds = currentTime % 60 - const timeTag = `[${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.00]` - currentTime += 3 - - return `${timeTag}${line}` - }).filter(line => line) // 移除空行 - - return { lxlyric: '', lyric: lrcLines.join('\n') } - }) + return this.getText(url).then(text => ({ lxlyric: '', lyric: text })) }, getTrc(url) { if (!url) return Promise.resolve('') @@ -106,7 +110,8 @@ const mrcTools = { }, async getMusicInfo(songInfo) { return songInfo.mrcUrl == null - ? getMusicInfo(songInfo.copyrightId) + // Legacy: getMusicInfo(songInfo.copyrightId) + ? getMusicInfo(songInfo.songmid) : songInfo }, getLyric(songInfo) { diff --git a/src/modules/utils/musicSdk/mg/musicInfo.js b/src/modules/utils/musicSdk/mg/musicInfo.js index c9924f45..72dd0cb1 100644 --- a/src/modules/utils/musicSdk/mg/musicInfo.js +++ b/src/modules/utils/musicSdk/mg/musicInfo.js @@ -160,5 +160,5 @@ export const getMusicInfo = async (copyrightId) => { } export const getMusicInfos = async (copyrightIds) => { - return filterMusicInfoList(await Promise.all(createGetMusicInfosTask(copyrightIds)).then(data => data.flat())) + return filterMusicInfoList(await createGetMusicInfosTask(copyrightIds).then(data => data.flat())) } diff --git a/src/modules/utils/musicSdk/mg/pic.js b/src/modules/utils/musicSdk/mg/pic.js index cf819ca8..e98c1f5c 100644 --- a/src/modules/utils/musicSdk/mg/pic.js +++ b/src/modules/utils/musicSdk/mg/pic.js @@ -1,28 +1,40 @@ -import { httpFetch } from '../../request' -import getSongId from './songId' +import { getMusicInfo } from './musicInfo' export default { - async getPicUrl(songId, tryNum = 0) { - let requestObj = httpFetch(`http://music.migu.cn/v3/api/music/audioPlayer/getSongPic?songId=${songId}`, { - headers: { - Referer: 'http://music.migu.cn/v3/music/player/audio?from=migu', - }, - }) - requestObj.promise.then(({ body }) => { - if (body.returnCode !== '000000') { - if (tryNum > 5) return Promise.reject(new Error('图片获取失败')) - let tryRequestObj = this.getPic(songId, ++tryNum) - requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj) - return tryRequestObj.promise - } - let url = body.largePic || body.mediumPic || body.smallPic - if (!/https?:/.test(url)) url = 'http:' + url - return url - }) - return requestObj - }, async getPic(songInfo) { - const songId = await getSongId(songInfo) - return this.getPicUrl(songId) + const info = await getMusicInfo(songInfo.songmid) + if (!info?.img) throw new Error('Migu image unavailable') + return info.img }, } + +// Legacy implementation retained for reference; inactive. +// import { httpFetch } from '../../request' +// import getSongId from './songId' +// +// export default { +// async getPicUrl(songId, tryNum = 0) { +// let requestObj = httpFetch(`http://music.migu.cn/v3/api/music/audioPlayer/getSongPic?songId=${songId}`, { +// headers: { +// Referer: 'http://music.migu.cn/v3/music/player/audio?from=migu', +// }, +// }) +// requestObj.promise.then(({ body }) => { +// if (body.returnCode !== '000000') { +// if (tryNum > 5) return Promise.reject(new Error('图片获取失败')) +// let tryRequestObj = this.getPic(songId, ++tryNum) +// requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj) +// return tryRequestObj.promise +// } +// let url = body.largePic || body.mediumPic || body.smallPic +// if (!/https?:/.test(url)) url = 'http:' + url +// return url +// }) +// return requestObj +// }, +// async getPic(songInfo) { +// const songId = await getSongId(songInfo) +// return this.getPicUrl(songId) +// }, +// } +// diff --git a/src/modules/utils/musicSdk/mg/songId.js b/src/modules/utils/musicSdk/mg/songId.js index 25197c28..7e74cacc 100644 --- a/src/modules/utils/musicSdk/mg/songId.js +++ b/src/modules/utils/musicSdk/mg/songId.js @@ -3,7 +3,8 @@ import { getMusicInfo } from './musicInfo' const getSongId = async(mInfo) => { if (mInfo.songmid != mInfo.copyrightId) return mInfo.songmid - const musicInfo = await getMusicInfo(mInfo.copyrightId) + // Legacy: getMusicInfo(mInfo.copyrightId) + const musicInfo = await getMusicInfo(mInfo.songmid) return musicInfo.songmid } diff --git a/src/modules/utils/musicSdk/tx/lyric.js b/src/modules/utils/musicSdk/tx/lyric.js index 6d01d2e4..b3a2f499 100644 --- a/src/modules/utils/musicSdk/tx/lyric.js +++ b/src/modules/utils/musicSdk/tx/lyric.js @@ -1,39 +1,328 @@ import { httpFetch } from '../../request' -// import getMusicInfo from './musicInfo' - -const decodeName = (str = '') => { - if (!str) return '' - return str.replace(/&#(\d+);/g, (match, dec) => { - return String.fromCharCode(dec) - }).replace(/&/g, '&') - .replace(/</g, '<') - .replace(/>/g, '>') - .replace(/"/g, '"') - .replace(/'/g, "'") -} +import getMusicInfo from './musicInfo' +import { decodeQrc } from './qrcDecode' -const b64DecodeUnicode = (str) => { - return Buffer.from(str, 'base64').toString('utf8') -} +const songIdMap = new Map() +const promises = new Map() +export const decodeLyric = async(lrc, tlrc, rlrc) => ({ + lyric: await decodeQrc(lrc), + tlyric: await decodeQrc(tlrc), + rlyric: await decodeQrc(rlrc), +}) -export default { - regexps: { - matchLrc: /.+"lyric":"([\w=+/]*)".+/, - }, - getLyric(songmid) { - const songId = songmid.songmid || songmid - const requestObj = httpFetch(`https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg?songmid=${songId}&g_tk=5381&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8&platform=yqq`, { - headers: { - Referer: 'https://y.qq.com/portal/player.html', - }, + +const parseTools = { + rxps: { + info: /^{"/, + lineTime: /^\[(\d+),\d+\]/, + lineTime2: /^\[([\d:.]+)\]/, + wordTime: /\(\d+,\d+\)/, + wordTimeAll: /(\(\d+,\d+\))/g, + timeLabelFixRxp: /(?:\.0+|0+)$/, + }, + msFormat(timeMs) { + if (Number.isNaN(timeMs)) return '' + let ms = (timeMs % 1000).toString().padStart(3, '0') + timeMs /= 1000 + let m = parseInt(timeMs / 60).toString().padStart(2, '0') + timeMs %= 60 + let s = parseInt(timeMs).toString().padStart(2, '0') + return `[${m}:${s}.${ms}]` + }, + parseLyric(lrc) { + lrc = lrc.trim() + lrc = lrc.replace(/\r/g, '') + if (!lrc) return { lyric: '', lxlyric: '' } + const lines = lrc.split('\n') + + const lxlrcLines = [] + const lrcLines = [] + + for (let line of lines) { + line = line.trim() + let result = this.rxps.lineTime.exec(line) + if (!result) { + if (line.startsWith('[offset')) { + lxlrcLines.push(line) + lrcLines.push(line) + } + if (this.rxps.lineTime2.test(line)) { + // lxlrcLines.push(line) + lrcLines.push(line) + } + continue + } + + const startMsTime = parseInt(result[1]) + const startTimeStr = this.msFormat(startMsTime) + if (!startTimeStr) continue + + let words = line.replace(this.rxps.lineTime, '') + + lrcLines.push(`${startTimeStr}${words.replace(this.rxps.wordTimeAll, '')}`) + + let times = words.match(this.rxps.wordTimeAll) + if (!times) continue + times = times.map(time => { + const result = /\((\d+),(\d+)\)/.exec(time) + return `<${Math.trunc(Math.max(parseInt(result[1]) - startMsTime, 0))},${result[2]}>` + }) + const wordArr = words.split(this.rxps.wordTime) + const newWords = times.map((time, index) => `${time}${wordArr[index]}`).join('') + lxlrcLines.push(`${startTimeStr}${newWords}`) + } + return { + lyric: lrcLines.join('\n'), + lxlyric: lxlrcLines.join('\n'), + } + }, + parseRlyric(lrc) { + lrc = lrc.trim() + lrc = lrc.replace(/\r/g, '') + if (!lrc) return { lyric: '', lxlyric: '' } + const lines = lrc.split('\n') + + const lrcLines = [] + + for (let line of lines) { + line = line.trim() + let result = this.rxps.lineTime.exec(line) + if (!result) continue + + const startMsTime = parseInt(result[1]) + const startTimeStr = this.msFormat(startMsTime) + if (!startTimeStr) continue + + let words = line.replace(this.rxps.lineTime, '') + + lrcLines.push(`${startTimeStr}${words.replace(this.rxps.wordTimeAll, '')}`) + } + return lrcLines.join('\n') + }, + removeTag(str) { + return str.replace(/^[\S\s]*?LyricContent="/, '').replace(/"\/>[\S\s]*?$/, '') + }, + getIntv(interval) { + if (!interval) return 0 + if (!interval.includes('.')) interval += '.0' + let arr = interval.split(/:|\./) + while (arr.length < 3) arr.unshift('0') + const [m, s, ms] = arr + return parseInt(m) * 3600000 + parseInt(s) * 1000 + parseInt(ms) + }, + fixRlrcTimeTag(rlrc, lrc) { + // console.log(lrc) + // console.log(rlrc) + const rlrcLines = rlrc.split('\n') + let lrcLines = lrc.split('\n') + // let temp = [] + let newLrc = [] + rlrcLines.forEach((line) => { + const result = this.rxps.lineTime2.exec(line) + if (!result) return + const words = line.replace(this.rxps.lineTime2, '') + if (!words.trim()) return + const t1 = this.getIntv(result[1]) + + while (lrcLines.length) { + const lrcLine = lrcLines.shift() + const lrcLineResult = this.rxps.lineTime2.exec(lrcLine) + if (!lrcLineResult) continue + const t2 = this.getIntv(lrcLineResult[1]) + if (Math.abs(t1 - t2) < 100) { + newLrc.push(line.replace(this.rxps.lineTime2, lrcLineResult[0])) + break + } + // temp.push(line) + } + // lrcLines = [...temp, ...lrcLines] + // temp = [] }) - requestObj.promise = requestObj.promise.then(({ body }) => { - if (body.code != 0 || !body.lyric) return Promise.reject(new Error('Get lyric failed')) - return { - lyric: decodeName(b64DecodeUnicode(body.lyric)), - tlyric: decodeName(b64DecodeUnicode(body.trans)), + return newLrc.join('\n') + }, + fixTlrcTimeTag(tlrc, lrc) { + // console.log(lrc) + // console.log(tlrc) + const tlrcLines = tlrc.split('\n') + let lrcLines = lrc.split('\n') + // let temp = [] + let newLrc = [] + tlrcLines.forEach((line) => { + const result = this.rxps.lineTime2.exec(line) + if (!result) return + const words = line.replace(this.rxps.lineTime2, '') + if (!words.trim()) return + let time = result[1] + if (time.includes('.')) { + time += ''.padStart(3 - time.split('.')[1].length, '0') } + const t1 = this.getIntv(time) + + while (lrcLines.length) { + const lrcLine = lrcLines.shift() + const lrcLineResult = this.rxps.lineTime2.exec(lrcLine) + if (!lrcLineResult) continue + const t2 = this.getIntv(lrcLineResult[1]) + if (Math.abs(t1 - t2) < 100) { + newLrc.push(line.replace(this.rxps.lineTime2, lrcLineResult[0])) + break + } + // temp.push(line) + } + // lrcLines = [...temp, ...lrcLines] + // temp = [] }) - return requestObj + return newLrc.join('\n') + }, + parse(lrc, tlrc, rlrc) { + const info = { + lyric: '', + tlyric: '', + rlyric: '', + lxlyric: '', + } + if (lrc) { + let { lyric, lxlyric } = this.parseLyric(this.removeTag(lrc)) + info.lyric = lyric + info.lxlyric = lxlyric + // console.log(lyric) + // console.log(lxlyric) + } + if (rlrc) info.rlyric = this.fixRlrcTimeTag(this.parseRlyric(this.removeTag(rlrc)), info.lyric) + if (tlrc) info.tlyric = this.fixTlrcTimeTag(tlrc, info.lyric) + // console.log(info.lyric) + // console.log(info.tlyric) + // console.log(info.rlyric) + + return info }, } + + +export default { + successCode: 0, + async getSongId({ songId, songmid }) { + if (songId) return songId + if (songIdMap.has(songmid)) return songIdMap.get(songmid) + if (!songmid) throw new Error('QQ song MID unavailable') + if (promises.has(songmid)) return promises.get(songmid) + const promise = getMusicInfo(songmid).then(info => { + if (!info?.songId) throw new Error('QQ song ID unavailable') + songIdMap.set(songmid, info.songId) + return info.songId + }) + promises.set(songmid, promise) + try { + return await promise + } finally { promises.delete(songmid) } + }, + async parseLyric(lrc, tlrc, rlrc) { + const { lyric, tlyric, rlyric } = await decodeLyric(lrc, tlrc, rlrc) + return parseTools.parse(lyric, tlyric, rlyric) + }, + getLyric(mInfo, retryNum = 0) { + if (typeof mInfo === 'string') mInfo = { songmid: mInfo } + else if (typeof mInfo === 'number') mInfo = { songId: mInfo } + if (retryNum > 3) return { promise: Promise.reject(new Error('Get lyric failed')), cancelHttp() {} } + + let activeRequest + let cancelled = false + const result = { + cancelHttp() { + cancelled = true + activeRequest?.cancelHttp() + }, + promise: this.getSongId(mInfo).then(songId => { + if (cancelled) throw new Error('Cancel Request') + const requestObj = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', { + method: 'post', + headers: { + referer: 'https://y.qq.com', + 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36', + }, + body: { + comm: { + ct: '19', + cv: '1859', + uin: '0', + }, + req: { + method: 'GetPlayLyricInfo', + module: 'music.musichallSong.PlayLyricInfo', + param: { + format: 'json', + crypt: 1, + ct: 19, + cv: 1873, + interval: 0, + lrc_t: 0, + qrc: 1, + qrc_t: 0, + roma: 1, + roma_t: 0, + songID: songId, + trans: 1, + trans_t: 0, + type: -1, + }, + }, + }, + }) + activeRequest = requestObj + return requestObj.promise.then(({ body }) => { + if (cancelled) throw new Error('Cancel Request') + if (body.code != this.successCode || body.req?.code != this.successCode) { + activeRequest = this.getLyric({ ...mInfo, songId }, ++retryNum) + return activeRequest.promise + } + const data = body.req.data + return this.parseLyric(data.lyric, data.trans, data.roma).then(info => { if (!info.lyric) throw new Error('Get lyric failed'); return info }) + }) + }), + } + return result + }, +} + + +// Legacy implementation retained for reference; inactive. +// import { httpFetch } from '../../request' +// // import getMusicInfo from './musicInfo' +// +// const decodeName = (str = '') => { +// if (!str) return '' +// return str.replace(/&#(\d+);/g, (match, dec) => { +// return String.fromCharCode(dec) +// }).replace(/&/g, '&') +// .replace(/</g, '<') +// .replace(/>/g, '>') +// .replace(/"/g, '"') +// .replace(/'/g, "'") +// } +// +// const b64DecodeUnicode = (str) => { +// return Buffer.from(str, 'base64').toString('utf8') +// } +// +// export default { +// regexps: { +// matchLrc: /.+"lyric":"([\w=+/]*)".+/, +// }, +// getLyric(songmid) { +// const songId = songmid.songmid || songmid +// const requestObj = httpFetch(`https://c.y.qq.com/lyric/fcgi-bin/fcg_query_lyric_new.fcg?songmid=${songId}&g_tk=5381&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8&platform=yqq`, { +// headers: { +// Referer: 'https://y.qq.com/portal/player.html', +// }, +// }) +// requestObj.promise = requestObj.promise.then(({ body }) => { +// if (body.code != 0 || !body.lyric) return Promise.reject(new Error('Get lyric failed')) +// return { +// lyric: decodeName(b64DecodeUnicode(body.lyric)), +// tlyric: decodeName(b64DecodeUnicode(body.trans)), +// } +// }) +// return requestObj +// }, +// } +// diff --git a/src/modules/utils/musicSdk/tx/musicSearch.js b/src/modules/utils/musicSdk/tx/musicSearch.js index aa85d871..5e692b86 100644 --- a/src/modules/utils/musicSdk/tx/musicSearch.js +++ b/src/modules/utils/musicSdk/tx/musicSearch.js @@ -1,4 +1,4 @@ -import { httpFetch } from '../../request' +import { signRequest } from './utils' import { formatPlayTime } from '../../index' import { formatSingerName } from '../utils' import { buildQualitys } from './quality' @@ -15,84 +15,133 @@ export default { page: 0, allPage: 1, successCode: 0, + // Legacy implementation retained for reference; inactive. + // musicSearch(str, page, limit, retryNum = 0) { + // if (retryNum > 5) return Promise.reject(new Error('搜索失败')) + // // searchRequest = httpFetch(`https://c.y.qq.com/soso/fcgi-bin/client_search_cp?ct=24&qqmusic_ver=1298&new_json=1&remoteplace=sizer.yqq.song_next&searchid=49252838123499591&t=0&aggr=1&cr=1&catZhida=1&lossless=0&flag_qc=0&p=${page}&n=${limit}&w=${encodeURIComponent(str)}&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8¬ice=0&platform=yqq&needNewCode=0`) + // // const searchRequest = httpFetch(`https://shc.y.qq.com/soso/fcgi-bin/client_search_cp?ct=24&qqmusic_ver=1298&remoteplace=txt.yqq.top&aggr=1&cr=1&catZhida=1&lossless=0&flag_qc=0&p=${page}&n=${limit}&w=${encodeURIComponent(str)}&cv=4747474&ct=24&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=yqq.json&needNewCode=0&uin=0&hostUin=0&loginUin=0`) + // // [修复] musicu.fcg 现只认 GET + data=URL 编码 JSON(POST 一律返回 {"code":500001}), + // // 否则 tx 源歌曲搜索结果恒为空(会被其他源结果掩盖,不易察觉)。 + // const searchPayload = { + // comm: { + // ct: '11', + // cv: '14090508', + // v: '14090508', + // tmeAppID: 'qqmusic', + // phonetype: 'EBG-AN10', + // deviceScore: '553.47', + // devicelevel: '50', + // newdevicelevel: '20', + // rom: 'HuaWei/EMOTION/EmotionUI_14.2.0', + // os_ver: '12', + // OpenUDID: '0', + // OpenUDID2: '0', + // QIMEI36: '0', + // udid: '0', + // chid: '0', + // aid: '0', + // oaid: '0', + // taid: '0', + // tid: '0', + // wid: '0', + // uid: '0', + // sid: '0', + // modeSwitch: '6', + // teenMode: '0', + // ui_mode: '2', + // nettype: '1020', + // v4ip: '', + // }, + // req: { + // module: 'music.search.SearchCgiService', + // method: 'DoSearchForQQMusicMobile', + // param: { + // search_type: 0, + // query: str, + // page_num: page, + // num_per_page: limit, + // highlight: 0, + // nqc_flag: 0, + // multi_zhida: 0, + // cat: 2, + // grp: 1, + // sin: 0, + // sem: 0, + // }, + // }, + // } + // // [备用/旧版请求方式 - POST 请求] + // // const searchRequest = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', { + // // method: 'post', + // // headers: { + // // 'User-Agent': 'QQMusic 14090508(android 12)', + // // }, + // // body: searchPayload, + // // }) + // + // // [当前方式 - GET 请求] + // const searchRequest = httpFetch(`https://u.y.qq.com/cgi-bin/musicu.fcg?format=json&data=${encodeURIComponent(JSON.stringify(searchPayload))}`, { + // headers: { + // 'User-Agent': 'QQMusic 14090508(android 12)', + // }, + // }) + // // searchRequest = httpFetch(`http://ioscdn.kugou.com/api/v3/search/song?keyword=${encodeURIComponent(str)}&page=${page}&pagesize=${this.limit}&showtype=10&plat=2&version=7910&tag=1&correct=1&privilege=1&sver=5`) + // return searchRequest.promise.then(({ body }) => { + // // console.log(body) + // if (body.code != this.successCode || body.req.code != this.successCode) return this.musicSearch(str, page, limit, ++retryNum) + // return body.req.data + // }) + // } musicSearch(str, page, limit, retryNum = 0) { if (retryNum > 5) return Promise.reject(new Error('搜索失败')) - // searchRequest = httpFetch(`https://c.y.qq.com/soso/fcgi-bin/client_search_cp?ct=24&qqmusic_ver=1298&new_json=1&remoteplace=sizer.yqq.song_next&searchid=49252838123499591&t=0&aggr=1&cr=1&catZhida=1&lossless=0&flag_qc=0&p=${page}&n=${limit}&w=${encodeURIComponent(str)}&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8¬ice=0&platform=yqq&needNewCode=0`) - // const searchRequest = httpFetch(`https://shc.y.qq.com/soso/fcgi-bin/client_search_cp?ct=24&qqmusic_ver=1298&remoteplace=txt.yqq.top&aggr=1&cr=1&catZhida=1&lossless=0&flag_qc=0&p=${page}&n=${limit}&w=${encodeURIComponent(str)}&cv=4747474&ct=24&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=yqq.json&needNewCode=0&uin=0&hostUin=0&loginUin=0`) - // [修复] musicu.fcg 现只认 GET + data=URL 编码 JSON(POST 一律返回 {"code":500001}), - // 否则 tx 源歌曲搜索结果恒为空(会被其他源结果掩盖,不易察觉)。 - const searchPayload = { + const searchRequest = signRequest({ comm: { - ct: '11', - cv: '14090508', - v: '14090508', + _channelid: '0', + _os_version: '6.2.9200-2', + ct: '19', + cv: '2151', + guid: '1F70E520B2EAA7D25E11760783C53CA9', + patch: '118', + psrf_access_token_expiresAt: 0, + psrf_qqaccess_token: '', + psrf_qqopenid: '', + psrf_qqunionid: '', tmeAppID: 'qqmusic', - phonetype: 'EBG-AN10', - deviceScore: '553.47', - devicelevel: '50', - newdevicelevel: '20', - rom: 'HuaWei/EMOTION/EmotionUI_14.2.0', - os_ver: '12', - OpenUDID: '0', - OpenUDID2: '0', - QIMEI36: '0', - udid: '0', - chid: '0', - aid: '0', - oaid: '0', - taid: '0', - tid: '0', - wid: '0', - uid: '0', - sid: '0', - modeSwitch: '6', - teenMode: '0', - ui_mode: '2', - nettype: '1020', - v4ip: '', + tmeLoginType: 0, + uin: '0', + wid: '7223299733393904640', }, - req: { + 'music.search.SearchCgiService': { module: 'music.search.SearchCgiService', - method: 'DoSearchForQQMusicMobile', + method: 'DoSearchForQQMusicDesktop', param: { - search_type: 0, - query: str, - page_num: page, - num_per_page: limit, - highlight: 0, - nqc_flag: 0, - multi_zhida: 0, - cat: 2, grp: 1, - sin: 0, - sem: 0, + num_per_page: limit, + page_num: page, + query: str, + remoteplace: 'txt.newclient.top', + search_type: 0, + searchid: this.getSearchId(), }, }, - } - // [备用/旧版请求方式 - POST 请求] - // const searchRequest = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', { - // method: 'post', - // headers: { - // 'User-Agent': 'QQMusic 14090508(android 12)', - // }, - // body: searchPayload, - // }) - - // [当前方式 - GET 请求] - const searchRequest = httpFetch(`https://u.y.qq.com/cgi-bin/musicu.fcg?format=json&data=${encodeURIComponent(JSON.stringify(searchPayload))}`, { - headers: { - 'User-Agent': 'QQMusic 14090508(android 12)', - }, }) - // searchRequest = httpFetch(`http://ioscdn.kugou.com/api/v3/search/song?keyword=${encodeURIComponent(str)}&page=${page}&pagesize=${this.limit}&showtype=10&plat=2&version=7910&tag=1&correct=1&privilege=1&sver=5`) - return searchRequest.promise.then(({ body }) => { + return searchRequest.then(({ body }) => { // console.log(body) - if (body.code != this.successCode || body.req.code != this.successCode) return this.musicSearch(str, page, limit, ++retryNum) - return body.req.data + const req = body?.['music.search.SearchCgiService'] ?? body?.req + if (!req || body.code != this.successCode || req.code != this.successCode) { + return this.musicSearch(str, page, limit, ++retryNum) + } + return req.data }) }, + getSearchId() { + let guid = '' + for (let i = 0; i < 32; i++) guid += Math.floor(Math.random() * 16).toString(16) + return guid.toUpperCase() + String(Math.floor(Math.random() * 100000)).padStart(5, '0') + }, handleResult(rawList) { // console.log(rawList) + if (!Array.isArray(rawList)) return [] const list = [] rawList.forEach(item => { if (!item.file?.media_mid) return @@ -108,7 +157,7 @@ export default { } list.push({ singer: formatSingerName(item.singer, 'name'), - name: item.name + (item.title_extra ?? ''), + name: item.title || item.name + (item.title_extra ?? ''), albumName, albumId, source: 'tx', @@ -128,13 +177,32 @@ export default { // console.log(list) return list }, + // Legacy implementation retained for reference; inactive. + // search(str, page = 1, limit) { + // if (limit == null) limit = this.limit + // // http://newlyric.kuwo.cn/newlyric.lrc?62355680 + // return this.musicSearch(str, page, limit).then(({ body, meta }) => { + // let list = this.handleResult(body.item_song) + // + // this.total = meta.estimate_sum + // this.page = page + // this.allPage = Math.ceil(this.total / limit) + // + // return Promise.resolve({ + // list, + // allPage: this.allPage, + // limit, + // total: this.total, + // source: 'tx', + // }) + // }) + // } search(str, page = 1, limit) { if (limit == null) limit = this.limit - // http://newlyric.kuwo.cn/newlyric.lrc?62355680 return this.musicSearch(str, page, limit).then(({ body, meta }) => { - let list = this.handleResult(body.item_song) + let list = this.handleResult(body.song.list) - this.total = meta.estimate_sum + this.total = meta.sum this.page = page this.allPage = Math.ceil(this.total / limit) diff --git a/src/modules/utils/musicSdk/tx/qrcDecode.js b/src/modules/utils/musicSdk/tx/qrcDecode.js new file mode 100644 index 00000000..ae5e8176 --- /dev/null +++ b/src/modules/utils/musicSdk/tx/qrcDecode.js @@ -0,0 +1,252 @@ +import { inflate, constants } from 'zlib' + +/** + * QRC 歌词解密(3DES-ECB + zlib inflate) + * + * 由原 C++ native 插件(qrc_decode.node)1:1 移植为纯 JS 实现。 + * 加密算法为标准 3DES(DES-EDE3,ECB 模式、无填充),密钥固定。 + * 数据流程:hex 字符串 -> 字节 -> 3DES 解密 -> zlib inflate -> UTF-8 文本。 + */ + +const DES_ENCRYPT = 1 +const DES_DECRYPT = 0 + +// DES S-box。注意:QRC 使用的是非标准 DES 变体, +// 其中 S2[23]=15、S4[53]=10 两处与标准 DES 不同(标准为 14、1)。 +// 这两个值与原 native 插件(qrc_decode.node)二进制完全一致,不可改为标准值, +// 否则解密结果错误。下表逐字节对应原 C++ 实现。 +const sbox = [ + [ + 14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8, + 4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0, 15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13, + ], + [ + 15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 3, 13, 4, 7, 15, 2, 8, 15, 12, 0, 1, 10, 6, 9, 11, 5, + 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, 13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9, + ], + [ + 10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1, + 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, 1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12, + ], + [ + 7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9, + 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4, 3, 15, 0, 6, 10, 10, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14, + ], + [ + 2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6, + 4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14, 11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3, + ], + [ + 12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8, + 9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6, 4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13, + ], + [ + 4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6, + 1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2, 6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12, + ], + [ + 13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2, + 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, 2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11, + ], +] + +const key_rnd_shift = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1] +const key_perm_c = [ + 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, +] +const key_perm_d = [ + 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, +] +const key_compression = [ + 13, 16, 10, 23, 0, 4, 2, 27, 14, 5, 20, 9, 22, 18, 11, 3, 25, 7, 15, 6, 26, 19, 12, 1, 40, 51, 30, 36, 46, + 54, 29, 39, 50, 44, 32, 47, 43, 48, 38, 55, 33, 52, 45, 41, 49, 35, 28, 31, +] + +// 全部以无符号 32 位处理,>>> 0 保证溢出行为与 C++ uint32_t 一致 +const bitnum = (a, b, c) => (((a[((b / 32) | 0) * 4 + 3 - (((b % 32) / 8) | 0)] >>> (7 - (b % 8))) & 1) << c) >>> 0 + +const bitnum_intr = (a, b, c) => (((a >>> (31 - b)) & 1) << c) >>> 0 + +const bitnum_intl = (a, b, c) => (((((a << b) >>> 0) & 0x80000000) >>> 0) >>> c) >>> 0 + +const sbox_bit = a => (a & 32) | ((a & 31) >>> 1) | ((a & 1) << 4) + +// 初始置换:返回 [s0, s1] +const initial_permutation = (input) => { + const s0 = ( + bitnum(input, 57, 31) | bitnum(input, 49, 30) | bitnum(input, 41, 29) | bitnum(input, 33, 28) | + bitnum(input, 25, 27) | bitnum(input, 17, 26) | bitnum(input, 9, 25) | bitnum(input, 1, 24) | + bitnum(input, 59, 23) | bitnum(input, 51, 22) | bitnum(input, 43, 21) | bitnum(input, 35, 20) | + bitnum(input, 27, 19) | bitnum(input, 19, 18) | bitnum(input, 11, 17) | bitnum(input, 3, 16) | + bitnum(input, 61, 15) | bitnum(input, 53, 14) | bitnum(input, 45, 13) | bitnum(input, 37, 12) | + bitnum(input, 29, 11) | bitnum(input, 21, 10) | bitnum(input, 13, 9) | bitnum(input, 5, 8) | + bitnum(input, 63, 7) | bitnum(input, 55, 6) | bitnum(input, 47, 5) | bitnum(input, 39, 4) | + bitnum(input, 31, 3) | bitnum(input, 23, 2) | bitnum(input, 15, 1) | bitnum(input, 7, 0) + ) >>> 0 + const s1 = ( + bitnum(input, 56, 31) | bitnum(input, 48, 30) | bitnum(input, 40, 29) | bitnum(input, 32, 28) | + bitnum(input, 24, 27) | bitnum(input, 16, 26) | bitnum(input, 8, 25) | bitnum(input, 0, 24) | + bitnum(input, 58, 23) | bitnum(input, 50, 22) | bitnum(input, 42, 21) | bitnum(input, 34, 20) | + bitnum(input, 26, 19) | bitnum(input, 18, 18) | bitnum(input, 10, 17) | bitnum(input, 2, 16) | + bitnum(input, 60, 15) | bitnum(input, 52, 14) | bitnum(input, 44, 13) | bitnum(input, 36, 12) | + bitnum(input, 28, 11) | bitnum(input, 20, 10) | bitnum(input, 12, 9) | bitnum(input, 4, 8) | + bitnum(input, 62, 7) | bitnum(input, 54, 6) | bitnum(input, 46, 5) | bitnum(input, 38, 4) | + bitnum(input, 30, 3) | bitnum(input, 22, 2) | bitnum(input, 14, 1) | bitnum(input, 6, 0) + ) >>> 0 + return [s0, s1] +} + +// 逆置换:将 s0/s1 写回 8 字节 out +const inverse_permutation = (s0, s1, out) => { + out[3] = (bitnum_intr(s1, 7, 7) | bitnum_intr(s0, 7, 6) | bitnum_intr(s1, 15, 5) | bitnum_intr(s0, 15, 4) | bitnum_intr(s1, 23, 3) | bitnum_intr(s0, 23, 2) | bitnum_intr(s1, 31, 1) | bitnum_intr(s0, 31, 0)) & 0xff + out[2] = (bitnum_intr(s1, 6, 7) | bitnum_intr(s0, 6, 6) | bitnum_intr(s1, 14, 5) | bitnum_intr(s0, 14, 4) | bitnum_intr(s1, 22, 3) | bitnum_intr(s0, 22, 2) | bitnum_intr(s1, 30, 1) | bitnum_intr(s0, 30, 0)) & 0xff + out[1] = (bitnum_intr(s1, 5, 7) | bitnum_intr(s0, 5, 6) | bitnum_intr(s1, 13, 5) | bitnum_intr(s0, 13, 4) | bitnum_intr(s1, 21, 3) | bitnum_intr(s0, 21, 2) | bitnum_intr(s1, 29, 1) | bitnum_intr(s0, 29, 0)) & 0xff + out[0] = (bitnum_intr(s1, 4, 7) | bitnum_intr(s0, 4, 6) | bitnum_intr(s1, 12, 5) | bitnum_intr(s0, 12, 4) | bitnum_intr(s1, 20, 3) | bitnum_intr(s0, 20, 2) | bitnum_intr(s1, 28, 1) | bitnum_intr(s0, 28, 0)) & 0xff + out[7] = (bitnum_intr(s1, 3, 7) | bitnum_intr(s0, 3, 6) | bitnum_intr(s1, 11, 5) | bitnum_intr(s0, 11, 4) | bitnum_intr(s1, 19, 3) | bitnum_intr(s0, 19, 2) | bitnum_intr(s1, 27, 1) | bitnum_intr(s0, 27, 0)) & 0xff + out[6] = (bitnum_intr(s1, 2, 7) | bitnum_intr(s0, 2, 6) | bitnum_intr(s1, 10, 5) | bitnum_intr(s0, 10, 4) | bitnum_intr(s1, 18, 3) | bitnum_intr(s0, 18, 2) | bitnum_intr(s1, 26, 1) | bitnum_intr(s0, 26, 0)) & 0xff + out[5] = (bitnum_intr(s1, 1, 7) | bitnum_intr(s0, 1, 6) | bitnum_intr(s1, 9, 5) | bitnum_intr(s0, 9, 4) | bitnum_intr(s1, 17, 3) | bitnum_intr(s0, 17, 2) | bitnum_intr(s1, 25, 1) | bitnum_intr(s0, 25, 0)) & 0xff + out[4] = (bitnum_intr(s1, 0, 7) | bitnum_intr(s0, 0, 6) | bitnum_intr(s1, 8, 5) | bitnum_intr(s0, 8, 4) | bitnum_intr(s1, 16, 3) | bitnum_intr(s0, 16, 2) | bitnum_intr(s1, 24, 1) | bitnum_intr(s0, 24, 0)) & 0xff +} + +// Feistel 轮函数 f(state, key) +const des_f = (state, key) => { + const t1 = ( + bitnum_intl(state, 31, 0) | (((state & 0xF0000000) >>> 0) >>> 1) | bitnum_intl(state, 4, 5) | + bitnum_intl(state, 3, 6) | (((state & 0x0F000000) >>> 0) >>> 3) | bitnum_intl(state, 8, 11) | + bitnum_intl(state, 7, 12) | (((state & 0x00F00000) >>> 0) >>> 5) | bitnum_intl(state, 12, 17) | + bitnum_intl(state, 11, 18) | (((state & 0x000F0000) >>> 0) >>> 7) | bitnum_intl(state, 16, 23) + ) >>> 0 + const t2 = ( + bitnum_intl(state, 15, 0) | (((state & 0x0000F000) << 15) >>> 0) | bitnum_intl(state, 20, 5) | + bitnum_intl(state, 19, 6) | (((state & 0x00000F00) << 13) >>> 0) | bitnum_intl(state, 24, 11) | + bitnum_intl(state, 23, 12) | (((state & 0x000000F0) << 11) >>> 0) | bitnum_intl(state, 28, 17) | + bitnum_intl(state, 27, 18) | (((state & 0x0000000F) << 9) >>> 0) | bitnum_intl(state, 0, 23) + ) >>> 0 + + const lrgstate = [ + (t1 >>> 24) & 0xFF, (t1 >>> 16) & 0xFF, (t1 >>> 8) & 0xFF, + (t2 >>> 24) & 0xFF, (t2 >>> 16) & 0xFF, (t2 >>> 8) & 0xFF, + ] + for (let i = 0; i < 6; i++) lrgstate[i] ^= key[i] + + let s = ( + (sbox[0][sbox_bit(lrgstate[0] >>> 2)] << 28) | + (sbox[1][sbox_bit(((lrgstate[0] & 0x03) << 4) | (lrgstate[1] >>> 4))] << 24) | + (sbox[2][sbox_bit(((lrgstate[1] & 0x0F) << 2) | (lrgstate[2] >>> 6))] << 20) | + (sbox[3][sbox_bit(lrgstate[2] & 0x3F)] << 16) | + (sbox[4][sbox_bit(lrgstate[3] >>> 2)] << 12) | + (sbox[5][sbox_bit(((lrgstate[3] & 0x03) << 4) | (lrgstate[4] >>> 4))] << 8) | + (sbox[6][sbox_bit(((lrgstate[4] & 0x0F) << 2) | (lrgstate[5] >>> 6))] << 4) | + sbox[7][sbox_bit(lrgstate[5] & 0x3F)] + ) >>> 0 + + return ( + bitnum_intl(s, 15, 0) | bitnum_intl(s, 6, 1) | bitnum_intl(s, 19, 2) | bitnum_intl(s, 20, 3) | + bitnum_intl(s, 28, 4) | bitnum_intl(s, 11, 5) | bitnum_intl(s, 27, 6) | bitnum_intl(s, 16, 7) | + bitnum_intl(s, 0, 8) | bitnum_intl(s, 14, 9) | bitnum_intl(s, 22, 10) | bitnum_intl(s, 25, 11) | + bitnum_intl(s, 4, 12) | bitnum_intl(s, 17, 13) | bitnum_intl(s, 30, 14) | bitnum_intl(s, 9, 15) | + bitnum_intl(s, 1, 16) | bitnum_intl(s, 7, 17) | bitnum_intl(s, 23, 18) | bitnum_intl(s, 13, 19) | + bitnum_intl(s, 31, 20) | bitnum_intl(s, 26, 21) | bitnum_intl(s, 2, 22) | bitnum_intl(s, 8, 23) | + bitnum_intl(s, 18, 24) | bitnum_intl(s, 12, 25) | bitnum_intl(s, 29, 26) | bitnum_intl(s, 5, 27) | + bitnum_intl(s, 21, 28) | bitnum_intl(s, 10, 29) | bitnum_intl(s, 3, 30) | bitnum_intl(s, 24, 31) + ) >>> 0 +} + +// 单次 DES 加/解密一个 8 字节分组(轮密钥 schedule 为 16x6) +const des_crypt = (input, schedule, output) => { + let [s0, s1] = initial_permutation(input) + for (let i = 0; i < 15; i++) { + const prev = s1 + s1 = (des_f(s1, schedule[i]) ^ s0) >>> 0 + s0 = prev + } + s0 = (des_f(s1, schedule[15]) ^ s0) >>> 0 + inverse_permutation(s0, s1, output) +} + +// 生成 16 轮子密钥 +const key_schedule = (key, mode) => { + const schedule = Array.from({ length: 16 }, () => new Uint8Array(6)) + let c = 0 + let d = 0 + for (let i = 0; i < 28; i++) { + c = (c | bitnum(key, key_perm_c[i], 31 - i)) >>> 0 + d = (d | bitnum(key, key_perm_d[i], 31 - i)) >>> 0 + } + for (let i = 0; i < 16; i++) { + c = ((((c << key_rnd_shift[i]) >>> 0) | (c >>> (28 - key_rnd_shift[i]))) & 0xFFFFFFF0) >>> 0 + d = ((((d << key_rnd_shift[i]) >>> 0) | (d >>> (28 - key_rnd_shift[i]))) & 0xFFFFFFF0) >>> 0 + const togen = mode === DES_DECRYPT ? 15 - i : i + for (let j = 0; j < 24; j++) { + schedule[togen][(j / 8) | 0] |= bitnum_intr(c, key_compression[j], 7 - (j % 8)) + } + for (let j = 24; j < 48; j++) { + schedule[togen][(j / 8) | 0] |= bitnum_intr(d, key_compression[j] - 27, 7 - (j % 8)) + } + } + return schedule +} + +// 3DES(EDE3)密钥编排,返回 3 组 schedule +const tripledes_key_setup = (key, mode) => { + if (mode === DES_ENCRYPT) { + return [ + key_schedule(key.subarray(0, 8), DES_ENCRYPT), + key_schedule(key.subarray(8, 16), DES_DECRYPT), + key_schedule(key.subarray(16, 24), DES_ENCRYPT), + ] + } + return [ + key_schedule(key.subarray(16, 24), DES_DECRYPT), + key_schedule(key.subarray(8, 16), DES_ENCRYPT), + key_schedule(key.subarray(0, 8), DES_DECRYPT), + ] +} + +// 3DES 加/解密一个 8 字节分组 +const tripledes_crypt = (input, schedule, output) => { + const buf = new Uint8Array(8) + des_crypt(input, schedule[0], buf) + des_crypt(buf, schedule[1], output) + des_crypt(output, schedule[2], buf) + output.set(buf) +} + +// QRC 固定密钥:!@#)(*$%123ZXC!@!@#)(NHL +const QRC_KEY = Buffer.from([ + 0x21, 0x40, 0x23, 0x29, 0x28, 0x2a, 0x24, 0x25, 0x31, 0x32, 0x33, 0x5a, + 0x58, 0x43, 0x21, 0x40, 0x21, 0x40, 0x23, 0x29, 0x28, 0x4e, 0x48, 0x4c, +]) + +const handleInflate = (data) => new Promise((resolve, reject) => { + // Z_SYNC_FLUSH:容忍尾部不完整的 zlib 流(对应原实现忽略 Z_BUF_ERROR 的行为) + inflate(data, { finishFlush: constants.Z_SYNC_FLUSH }, (err, result) => { + if (err) reject(err) + else resolve(result) + }) +}) +/** + * 解密腾讯 QRC 歌词。 + * @param {string} hexData 服务端返回的十六进制字符串 + * @returns {Promise} 解密并解压后的歌词文本(解析失败时返回空字符串) + */ +export const decodeQrc = async(hexData) => { + if (!hexData || hexData.length % 2 !== 0) return '' + const encrypted = Buffer.from(hexData, 'hex') + if (encrypted.length === 0) return '' + + const schedule = tripledes_key_setup(QRC_KEY, DES_DECRYPT) + + const block = new Uint8Array(8) + for (let i = 0; i + 8 <= encrypted.length; i += 8) { + tripledes_crypt(encrypted.subarray(i, i + 8), schedule, block) + encrypted.set(block, i) + } + + try { + const result = await handleInflate(encrypted) + return result.toString('utf8') + } catch { + return '' + } +} diff --git a/src/modules/utils/musicSdk/tx/singer.js b/src/modules/utils/musicSdk/tx/singer.js index e6f81ab6..993295d1 100644 --- a/src/modules/utils/musicSdk/tx/singer.js +++ b/src/modules/utils/musicSdk/tx/singer.js @@ -73,33 +73,92 @@ export default { * 获取歌手信息 * @param {*} id */ + // Legacy implementation retained for reference; inactive. + // getInfo(id) { + // return createMusicuFetch({ + // singer: { + // method: 'get_singer_detail_info', + // param: { + // sort: 5, + // singermid: id, + // sin: 0, + // num: 50, + // }, + // module: 'music.web_singer_info_svr', + // }, + // }).then(body => { + // if (body.singer.code != 0) throw new Error('get singer info faild.') + // + // const data = body.singer.data + // return { + // source: 'tx', + // id: data.singer_info.mid, + // info: { + // name: data.singer_info.name, + // desc: data.singer_info.desc || '', + // avatar: `https://y.gtimg.cn/music/photo_new/T001R300x300M000${data.singer_info.mid}.jpg`, + // }, + // count: { + // music: data.total_song || 0, + // album: data.total_album || 0, + // }, + // } + // }) + // } getInfo(id) { return createMusicuFetch({ - singer: { - method: 'get_singer_detail_info', + req_1: { + module: 'music.musichallSinger.SingerInfoInter', + method: 'GetSingerDetail', param: { - sort: 5, - singermid: id, - sin: 0, - num: 50, + singer_mids: [id], + ex_singer: 1, + wiki_singer: 1, + group_singer: 0, + pic: 1, + photos: 0, + }, + }, + req_2: { + module: 'music.musichallAlbum.AlbumListServer', + method: 'GetAlbumList', + param: { + singerMid: id, + order: 0, + begin: 0, + num: 1, + songNumTag: 0, + singerID: 0, + }, + }, + req_3: { + module: 'musichall.song_list_server', + method: 'GetSingerSongList', + param: { + singerMid: id, + order: 1, + begin: 0, + num: 1, }, - module: 'music.web_singer_info_svr', }, }).then(body => { - if (body.singer.code != 0) throw new Error('get singer info faild.') + if (body.req_1.code != 0 || body.req_2.code != 0 || body.req_3.code != 0) throw new Error('get singer info faild.') - const data = body.singer.data + const info = body.req_1.data.singer_list[0] + const music = body.req_3.data + const album = body.req_2.data return { source: 'tx', - id: data.singer_info.mid, + id: info.basic_info.singer_mid, info: { - name: data.singer_info.name, - desc: data.singer_info.desc || '', - avatar: `https://y.gtimg.cn/music/photo_new/T001R300x300M000${data.singer_info.mid}.jpg`, + name: info.basic_info.name, + desc: info.ex_info.desc, + avatar: info.pic.pic, + gender: info.ex_info.genre === 1 ? 'man' : 'woman', }, count: { - music: data.total_song || 0, - album: data.total_album || 0, + music: music.totalNum, + album: album.total, }, } }) @@ -124,28 +183,56 @@ export default { * @param {*} page * @param {*} limit */ - getAlbumList(id, page = 1, limit = 10, order = 'hot') { + // Legacy implementation retained for reference; inactive. + // getAlbumList(id, page = 1, limit = 10, order = 'hot') { + // return createMusicuFetch({ + // singerAlbum: { + // method: 'get_singer_album', + // param: { + // singermid: id, + // begin: (page - 1) * limit, + // num: limit, + // order: 'time', + // }, + // module: 'music.web_singer_info_svr', + // }, + // }).then(body => { + // if (body.singerAlbum.code != 0) throw new Error('get singer album faild.') + // + // const list = this.filterAlbumList(body.singerAlbum.data.list) + // return { + // source: 'tx', + // list, + // limit, + // page, + // total: body.singerAlbum.data.total, + // } + // }) + // } + getAlbumList(id, page = 1, limit = 10) { return createMusicuFetch({ - singerAlbum: { - method: 'get_singer_album', + req: { + module: 'music.musichallAlbum.AlbumListServer', + method: 'GetAlbumList', param: { - singermid: id, + singerMid: id, + order: 0, begin: (page - 1) * limit, num: limit, - order: 'time', + songNumTag: 0, + singerID: 0, }, - module: 'music.web_singer_info_svr', }, }).then(body => { - if (body.singerAlbum.code != 0) throw new Error('get singer album faild.') + if (body.req.code != 0) throw new Error('get singer album faild.') - const list = this.filterAlbumList(body.singerAlbum.data.list) + const list = this.filterAlbumList(body.req.data.albumList) return { source: 'tx', list, limit, page, - total: body.singerAlbum.data.total, + total: body.req.data.total, } }) }, diff --git a/src/modules/utils/musicSdk/tx/songList.js b/src/modules/utils/musicSdk/tx/songList.js index ac432544..34be3960 100644 --- a/src/modules/utils/musicSdk/tx/songList.js +++ b/src/modules/utils/musicSdk/tx/songList.js @@ -11,9 +11,13 @@ export default { limit_song: 100000, successCode: 0, sortList: [ + { + name: '推荐', + id: -1, + }, { name: '最热', - id: 5, + id: 3, }, { name: '最新', @@ -31,11 +35,41 @@ export default { }, tagsUrl: 'https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0&data=%7B%22tags%22%3A%7B%22method%22%3A%22get_all_categories%22%2C%22param%22%3A%7B%22qq%22%3A%22%22%7D%2C%22module%22%3A%22playlist.PlaylistAllCategoriesServer%22%7D%7D', hotTagUrl: 'https://c.y.qq.com/node/pc/wk_v15/category_playlist.html', - getListUrl(sortId, id, page) { - const order = Number(sortId) || 5 - if (id) { - id = parseInt(id) - return `https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0&data=${encodeURIComponent(JSON.stringify({ + // Legacy implementation retained for reference; inactive. + // getListUrl(sortId, id, page) { + // const order = Number(sortId) || 5 + // if (id) { + // id = parseInt(id) + // return `https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0&data=${encodeURIComponent(JSON.stringify({ + // comm: { cv: 1602, ct: 20 }, + // playlist: { + // method: 'get_category_content', + // param: { + // titleid: id, + // caller: '0', + // category_id: id, + // size: this.limit_list, + // page: page - 1, + // use_page: 1, + // order, + // sort: order, + // }, + // module: 'playlist.PlayListCategoryServer', + // }, + // }))}` + // } + // return `https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0&data=${encodeURIComponent(JSON.stringify({ + // comm: { cv: 1602, ct: 20 }, + // playlist: { + // method: 'get_playlist_by_tag', + // param: { id: 10000000, sin: this.limit_list * (page - 1), size: this.limit_list, order, cur_page: page }, + // module: 'playlist.PlayListPlazaServer', + // }, + // }))}` + // } + getListUrl(id, page) { + id = parseInt(id) + return `https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0&data=${encodeURIComponent(JSON.stringify({ comm: { cv: 1602, ct: 20 }, playlist: { method: 'get_category_content', @@ -46,21 +80,57 @@ export default { size: this.limit_list, page: page - 1, use_page: 1, - order, - sort: order, }, module: 'playlist.PlayListCategoryServer', }, - }))}` + }))}` + // return `https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8&data=${encodeURIComponent(JSON.stringify({ + // comm: { cv: 1602, ct: 20 }, + // playlist: { + // method: 'get_playlist_by_tag', + // param: { id: 10000000, sin: this.limit_list * (page - 1), size: this.limit_list, order: sortId, cur_page: page }, + // module: 'playlist.PlayListPlazaServer', + // }, + // }))}` + }, + async getRecommendList(page) { + const { body } = await httpFetch( + `https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0&data=${encodeURIComponent( + JSON.stringify({ + comm: { cv: 1602, ct: 20 }, + playlist: { + module: 'music.playlist.PlaylistSquare', + method: 'GetRecommendWhole', + param: { + IsReqFeed: true, + FeedReq: { + From: (page - 1) * this.limit_list, + Size: this.limit_list, + }, + }, + }, + }), + )}`, + ).promise + if (body.code !== this.successCode) throw new Error('tx getRecommendList failed') + return { + list: + body.playlist.data.FeedRsp.List?.map(({ Playlist }) => ({ + play_count: formatPlayCount(Playlist.basic.play_cnt), + id: String(Playlist.basic.tid), + author: decodeName(Playlist.basic.creator.nick), + name: decodeName(Playlist.basic.title), + time: Playlist.basic.modify_time ? dateFormat(Playlist.basic.modify_time * 1000, 'Y-M-D') : '', + img: Playlist.basic.cover.medium_url || Playlist.basic.cover.default_url, + total: Playlist.basic.song_cnt, + desc: decodeName(Playlist.basic.desc).replace(/
    /g, '\n'), + source: 'tx', + })) || [], + total: body.playlist.data.FeedRsp.FromLimit, + limit: this.limit_list, + page, + source: 'tx', } - return `https://u.y.qq.com/cgi-bin/musicu.fcg?loginUin=0&hostUin=0&format=json&inCharset=utf-8&outCharset=utf-8¬ice=0&platform=wk_v15.json&needNewCode=0&data=${encodeURIComponent(JSON.stringify({ - comm: { cv: 1602, ct: 20 }, - playlist: { - method: 'get_playlist_by_tag', - param: { id: 10000000, sin: this.limit_list * (page - 1), size: this.limit_list, order, cur_page: page }, - module: 'playlist.PlayListPlazaServer', - }, - }))}` }, getListDetailUrl(id) { return `https://c.y.qq.com/qzone/fcg-bin/fcg_ucc_getcdinfo_byids_cp.fcg?type=1&json=1&utf8=1&onlysong=0&new_format=1&disstid=${id}&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8¬ice=0&platform=yqq.json&needNewCode=0` @@ -117,34 +187,75 @@ export default { }, // 获取列表数据 + // Legacy implementation retained for reference; inactive. + // getList(sortId, tagId, page, tryNum = 0) { + // if (this._requestObj_list) this._requestObj_list.cancelHttp() + // if (tryNum > 2) return Promise.reject(new Error('try max num')) + // this._requestObj_list = httpFetch( + // this.getListUrl(sortId, tagId, page), + // ) + // // console.log(this.getListUrl(sortId, tagId, page)) + // return this._requestObj_list.promise.then(({ body }) => { + // if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum) + // return tagId ? this.filterList2(body.playlist.data, page) : this.filterList(body.playlist.data, page) + // }) + // } getList(sortId, tagId, page, tryNum = 0) { if (this._requestObj_list) this._requestObj_list.cancelHttp() if (tryNum > 2) return Promise.reject(new Error('try max num')) + // Server callers also use generic sort names and the former hot ID (5). + if (sortId === 'hot' || sortId == 5 || sortId == null || sortId === '') sortId = 3 + else if (sortId === 'new') sortId = 2 + if (!tagId) { + if (!tagId && sortId == -1) return this.getRecommendList(page) + tagId = sortId + } this._requestObj_list = httpFetch( - this.getListUrl(sortId, tagId, page), + this.getListUrl(tagId, page), ) // console.log(this.getListUrl(sortId, tagId, page)) return this._requestObj_list.promise.then(({ body }) => { if (body.code !== this.successCode) return this.getList(sortId, tagId, page, ++tryNum) - return tagId ? this.filterList2(body.playlist.data, page) : this.filterList(body.playlist.data, page) + return this.filterList(body.playlist.data, page) }) }, - filterList(data, page) { + // Legacy implementation retained for reference; inactive. + // filterList(data, page) { + // return { + // list: data.v_playlist.map(item => ({ + // play_count: formatPlayCount(item.access_num), + // id: String(item.tid), + // author: item.creator_info.nick, + // name: item.title, + // time: item.modify_time ? dateFormat(item.modify_time * 1000, 'Y-M-D') : '', + // img: item.cover_url_medium, + // // grade: item.favorcnt / 10, + // total: item.song_ids?.length, + // desc: decodeName(item.desc).replace(/
    /g, '\n'), + // source: 'tx', + // })), + // total: data.total, + // page, + // limit: this.limit_list, + // source: 'tx', + // } + // } + filterList({ content }, page) { + // console.log(content.v_item) return { - list: data.v_playlist.map(item => ({ - play_count: formatPlayCount(item.access_num), - id: String(item.tid), - author: item.creator_info.nick, - name: item.title, - time: item.modify_time ? dateFormat(item.modify_time * 1000, 'Y-M-D') : '', - img: item.cover_url_medium, - // grade: item.favorcnt / 10, - total: item.song_ids?.length, - desc: decodeName(item.desc).replace(/
    /g, '\n'), + list: content.v_item.map(({ basic }) => ({ + play_count: formatPlayCount(basic.play_cnt), + id: String(basic.tid), + author: basic.creator.nick, + name: basic.title, + // time: basic.publish_time, + img: basic.cover.medium_url || basic.cover.default_url, + // grade: basic.favorcnt / 10, + desc: decodeName(basic.desc).replace(/
    /g, '\n'), source: 'tx', })), - total: data.total, + total: content.total_cnt, page, limit: this.limit_list, source: 'tx', @@ -197,83 +308,143 @@ export default { return id }, // 获取歌曲列表内的音乐 + // Legacy implementation retained for reference; inactive. + // async getListDetail(id, tryNum = 0) { + // if (tryNum > 2) return Promise.reject(new Error('try max num')) + // + // id = await this.getListId(id) + // + // // [修复] 旧接口 fcg_ucc_getcdinfo_byids_cp 已被腾讯加隐私校验 + // //(返回 { code: 0, subcode: 4000, msg: 'check privacy error!' } 且不含 cdlist), + // // 会让 body.cdlist[0] 抛 "Cannot read properties of undefined (reading '0')",歌单整页打不开。 + // // 优先改用 musicu.fcg 的 music.srfDissInfo.DissInfo/CgiGetDiss(GET + data=URL 编码 JSON)。 + // try { + // const payload = { + // comm: { ct: 24, cv: 0, uin: '0', format: 'json' }, + // req: { + // module: 'music.srfDissInfo.DissInfo', + // method: 'CgiGetDiss', + // param: { + // disstid: Number(id), + // dirid: 0, + // tag: 1, + // song_num: 1000, + // page: 1, + // onlysonglist: 0, + // }, + // }, + // } + // const requestObj_v2 = httpFetch(`https://u.y.qq.com/cgi-bin/musicu.fcg?format=json&data=${encodeURIComponent(JSON.stringify(payload))}`, { + // headers: { + // 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)', + // }, + // }) + // const { body: bodyV2 } = await requestObj_v2.promise + // const detail = bodyV2?.req?.data + // if (detail && Array.isArray(detail.songlist) && detail.songlist.length) { + // const dir = detail.dirinfo || {} + // return { + // list: this.filterListDetail(detail.songlist), + // page: 1, + // limit: detail.songlist.length + 1, + // total: detail.songlist.length, + // source: 'tx', + // info: { + // name: dir.title || '', + // img: dir.picurl || dir.picurl2 || '', + // desc: decodeName(dir.desc || '').replace(/
    /g, '\n'), + // author: dir.host_nick || (dir.creator && dir.creator.nick) || '', + // play_count: formatPlayCount(dir.listennum || 0), + // }, + // } + // } + // } catch (err) { + // // 忽略:继续回退到旧接口 + // } + // + // // 回退:旧接口(腾讯若恢复可用仍可命中) + // const requestObj_listDetail = httpFetch(this.getListDetailUrl(id), { + // headers: { + // Origin: 'https://y.qq.com', + // Referer: `https://y.qq.com/n/yqq/playsquare/${id}.html`, + // }, + // }) + // const { body } = await requestObj_listDetail.promise + // + // if (body.code !== this.successCode) return this.getListDetail(id, ++tryNum) + // const cdlist = Array.isArray(body.cdlist) ? body.cdlist[0] : null + // if (!cdlist) return this.getListDetail(id, ++tryNum) + // return { + // list: this.filterListDetail(cdlist.songlist), + // page: 1, + // limit: cdlist.songlist.length + 1, + // total: cdlist.songlist.length, + // source: 'tx', + // info: { + // name: cdlist.dissname, + // img: cdlist.logo, + // desc: decodeName(cdlist.desc).replace(/
    /g, '\n'), + // author: cdlist.nickname, + // play_count: formatPlayCount(cdlist.visitnum), + // }, + // } + // } async getListDetail(id, tryNum = 0) { if (tryNum > 2) return Promise.reject(new Error('try max num')) id = await this.getListId(id) - - // [修复] 旧接口 fcg_ucc_getcdinfo_byids_cp 已被腾讯加隐私校验 - //(返回 { code: 0, subcode: 4000, msg: 'check privacy error!' } 且不含 cdlist), - // 会让 body.cdlist[0] 抛 "Cannot read properties of undefined (reading '0')",歌单整页打不开。 - // 优先改用 musicu.fcg 的 music.srfDissInfo.DissInfo/CgiGetDiss(GET + data=URL 编码 JSON)。 - try { - const payload = { - comm: { ct: 24, cv: 0, uin: '0', format: 'json' }, - req: { - module: 'music.srfDissInfo.DissInfo', - method: 'CgiGetDiss', + const requestObj_listDetail = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', { + method: 'post', + headers: { + Origin: 'https://y.qq.com', + Referer: `https://y.qq.com/n/yqq/playsquare/${id}.html`, + }, + body: { + comm: { + cv: 4747474, + ct: 24, + format: 'json', + inCharset: 'utf-8', + outCharset: 'utf-8', + platform: 'yqq.json', + needNewCode: 1, + uin: 0, + }, + req_1: { + module: 'music.srfDissInfo.aiDissInfo', + method: 'uniform_get_Dissinfo', param: { - disstid: Number(id), - dirid: 0, + disstid: parseInt(id), + userinfo: 1, tag: 1, - song_num: 1000, - page: 1, + orderlist: 1, + song_begin: 0, + song_num: this.limit_song, onlysonglist: 0, + enc_host_uin: '', }, }, - } - const requestObj_v2 = httpFetch(`https://u.y.qq.com/cgi-bin/musicu.fcg?format=json&data=${encodeURIComponent(JSON.stringify(payload))}`, { - headers: { - 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)', - }, - }) - const { body: bodyV2 } = await requestObj_v2.promise - const detail = bodyV2?.req?.data - if (detail && Array.isArray(detail.songlist) && detail.songlist.length) { - const dir = detail.dirinfo || {} - return { - list: this.filterListDetail(detail.songlist), - page: 1, - limit: detail.songlist.length + 1, - total: detail.songlist.length, - source: 'tx', - info: { - name: dir.title || '', - img: dir.picurl || dir.picurl2 || '', - desc: decodeName(dir.desc || '').replace(/
    /g, '\n'), - author: dir.host_nick || (dir.creator && dir.creator.nick) || '', - play_count: formatPlayCount(dir.listennum || 0), - }, - } - } - } catch (err) { - // 忽略:继续回退到旧接口 - } - - // 回退:旧接口(腾讯若恢复可用仍可命中) - const requestObj_listDetail = httpFetch(this.getListDetailUrl(id), { - headers: { - Origin: 'https://y.qq.com', - Referer: `https://y.qq.com/n/yqq/playsquare/${id}.html`, }, }) const { body } = await requestObj_listDetail.promise - + // console.log(body) if (body.code !== this.successCode) return this.getListDetail(id, ++tryNum) - const cdlist = Array.isArray(body.cdlist) ? body.cdlist[0] : null - if (!cdlist) return this.getListDetail(id, ++tryNum) + if (body.req_1.code !== this.successCode) throw new Error('failed') + + const result = body.req_1.data + const dirinfo = result.dirinfo return { - list: this.filterListDetail(cdlist.songlist), + list: this.filterListDetail(result.songlist), page: 1, - limit: cdlist.songlist.length + 1, - total: cdlist.songlist.length, + limit: this.limit_song, + total: result.total_song_num, source: 'tx', info: { - name: cdlist.dissname, - img: cdlist.logo, - desc: decodeName(cdlist.desc).replace(/
    /g, '\n'), - author: cdlist.nickname, - play_count: formatPlayCount(cdlist.visitnum), + name: dirinfo.title, + img: dirinfo.picurl, + desc: decodeName(dirinfo.desc ?? '').replace(/
    /g, '\n'), + author: dirinfo.host_nick, + play_count: formatPlayCount(dirinfo.listennum), }, } }, diff --git a/src/modules/utils/musicSdk/tx/utils/crypto.js b/src/modules/utils/musicSdk/tx/utils/crypto.js new file mode 100644 index 00000000..78618d12 --- /dev/null +++ b/src/modules/utils/musicSdk/tx/utils/crypto.js @@ -0,0 +1,28 @@ +import crypto from 'node:crypto' + +const PART_1_INDEXES = [23, 14, 6, 36, 16, 40, 7, 19] +const PART_2_INDEXES = [16, 1, 32, 12, 19, 27, 8, 5] +const SCRAMBLE_VALUES = [89, 39, 179, 150, 218, 82, 58, 252, 177, 52, 186, 123, 120, 64, 242, 133, 143, 161, 121, 179] + +async function hashSHA1(data) { + return crypto.createHash('sha1').update(data).digest('hex') +} + +function pickHashByIdx(hash, indexes) { + return indexes.map((idx) => hash[idx]).join('') +} + +function base64Encode(data) { + return Buffer.from(data) + .toString('base64') + .replace(/[\\/+=]/g, '') +} + +export async function zzcSign(text) { + const hash = await hashSHA1(text) + const part1 = pickHashByIdx(hash, PART_1_INDEXES) + const part2 = pickHashByIdx(hash, PART_2_INDEXES) + const part3 = SCRAMBLE_VALUES.map((value, i) => value ^ parseInt(hash.slice(i * 2, i * 2 + 2), 16)) + const b64Part = base64Encode(part3).replace(/[\\/+=]/g, '') + return `zzc${part1}${b64Part}${part2}`.toLowerCase() +} diff --git a/src/modules/utils/musicSdk/tx/utils/index.js b/src/modules/utils/musicSdk/tx/utils/index.js new file mode 100644 index 00000000..70ee425c --- /dev/null +++ b/src/modules/utils/musicSdk/tx/utils/index.js @@ -0,0 +1,16 @@ + +import { httpFetch } from '../../../request' +import { zzcSign } from './crypto' + +export const signRequest = async(data) => { + // console.log(data) + const sign = await zzcSign(JSON.stringify(data)) + // console.log('sign', sign) + return httpFetch(`https://u.y.qq.com/cgi-bin/musics.fcg?sign=${sign}`, { + method: 'post', + headers: { + 'User-Agent': 'QQMusic 14090508(android 12)', + }, + body: data, + }).promise +} diff --git a/src/modules/utils/musicSdk/versionChars.ts b/src/modules/utils/musicSdk/versionChars.ts new file mode 100644 index 00000000..e1e389f4 --- /dev/null +++ b/src/modules/utils/musicSdk/versionChars.ts @@ -0,0 +1,124 @@ +export const versionChars = [ + // 伴奏 / 无人声 + // instrumental: [ + 'instrumental', + 'inst', + 'karaoke', + 'backing track', + 'minus one', + 'minus vocal', + 'no vocal', + 'off vocal', + 'vocal removed', + '伴奏', + '伴唱', + '卡拉ok', + '纯音乐', + '无人声', + '去人声', + '消音', + 'オフボーカル', + 'インスト', + '반주', + '노래방', + // ], + + // 现场 + // live: [ + 'live', + 'concert', + 'tour', + 'festival', + 'stage', + 'session', + '现场', + '演唱会', + '巡演', + '音乐会', + 'ライブ', + '공연', + '라이브', + // ], + + // 翻唱 + // cover: [ + 'cover', + 'tribute', + 'rendition', + 'remake', + '翻唱', + '致敬', + '改编', + 'カバー', + '커버', + // ], + + // 混音 + // remix: [ + 'remix', + 'mix', + 'club', + 'dance', + 'extended', + 'house', + 'edm', + 'dj', + 'dub', + 'trap', + '混音', + '重混', + '电音', + 'リミックス', + '리믹스', + // ], + + // 原声 / 不插电 + // acoustic: [ + 'acoustic', + 'unplugged', + 'stripped', + '原声', + '不插电', + 'アコースティック', + '어쿠스틱', + // ], + + // Demo + // demo: [ + 'demo', + '小样', + '试听', + 'デモ', + '데모', + // ], + + // 速度变化 + // speed: [ + 'slowed', + 'slowed reverb', + 'sped up', + 'speed up', + 'nightcore', + '慢速', + '加速', + '变速', + 'ナイトコア', + // ], + + // 特殊乐器版 + // special_instrumental: [ + 'piano', + 'guitar', + 'orchestra', + 'symphony', + 'violin', + '钢琴', + '吉他', + '管弦乐', + '小提琴', + 'ピアノ', + 'ギター', + '피아노', + '기타', + // ] +] diff --git a/src/modules/utils/musicSdk/wy/lyric.js b/src/modules/utils/musicSdk/wy/lyric.js index 3d74f762..a0825615 100644 --- a/src/modules/utils/musicSdk/wy/lyric.js +++ b/src/modules/utils/musicSdk/wy/lyric.js @@ -57,19 +57,72 @@ const eapiRequest = (url, data) => { const parseTools = { rxps: { info: /^{"/, + timeMs2: /\[\d+:\d+\.\d{2}]/, + timeMs3: /\[\d+:\d+\.\d{3}]/, lineTime: /^\[(\d+),\d+\]/, wordTime: /\(\d+,\d+,\d+\)/, wordTimeAll: /(\(\d+,\d+,\d+\))/g, }, - msFormat(timeMs) { + // Legacy implementation retained for reference; inactive. + // msFormat(timeMs) { + // if (Number.isNaN(timeMs)) return '' + // let ms = timeMs % 1000 + // timeMs /= 1000 + // let m = parseInt(timeMs / 60).toString().padStart(2, '0') + // timeMs %= 60 + // let s = parseInt(timeMs).toString().padStart(2, '0') + // return `[${m}:${s}.${ms}]` + // } + msFormat(timeMs, pad3 = true) { if (Number.isNaN(timeMs)) return '' - let ms = timeMs % 1000 + let ms = (timeMs % 1000).toString().padStart(3, '0') + if (!pad3) ms = ms.slice(0, 2) timeMs /= 1000 let m = parseInt(timeMs / 60).toString().padStart(2, '0') timeMs %= 60 let s = parseInt(timeMs).toString().padStart(2, '0') return `[${m}:${s}.${ms}]` }, + // Legacy implementation retained for reference; inactive. + // parseLyric(lines) { + // const lxlrcLines = [] + // const lrcLines = [] + // + // for (let line of lines) { + // line = line.trim() + // let result = this.rxps.lineTime.exec(line) + // if (!result) { + // if (line.startsWith('[offset')) { + // lxlrcLines.push(line) + // lrcLines.push(line) + // } + // continue + // } + // + // const startMsTime = parseInt(result[1]) + // const startTimeStr = this.msFormat(startMsTime) + // if (!startTimeStr) continue + // + // let words = line.replace(this.rxps.lineTime, '') + // + // lrcLines.push(`${startTimeStr}${words.replace(this.rxps.wordTimeAll, '')}`) + // + // let times = words.match(this.rxps.wordTimeAll) + // if (!times) continue + // times = times.map(time => { + // const result = /\((\d+),(\d+),\d+\)/.exec(time) + // return `<${Math.max(parseInt(result[1]) - startMsTime, 0)},${result[2]}>` + // }) + // const wordArr = words.split(this.rxps.wordTime) + // wordArr.shift() + // const newWords = times.map((time, index) => `${time}${wordArr[index]}`).join('') + // lxlrcLines.push(`${startTimeStr}${newWords}`) + // } + // return { + // lyric: lrcLines.join('\n'), + // lxlyric: lxlrcLines.join('\n'), + // } + // } parseLyric(lines) { const lxlrcLines = [] const lrcLines = [] @@ -97,7 +150,7 @@ const parseTools = { if (!times) continue times = times.map(time => { const result = /\((\d+),(\d+),\d+\)/.exec(time) - return `<${Math.max(parseInt(result[1]) - startMsTime, 0)},${result[2]}>` + return `<${Math.trunc(Math.max(parseInt(result[1]) - startMsTime, 0))},${result[2]}>` }) const wordArr = words.split(this.rxps.wordTime) wordArr.shift() @@ -109,16 +162,34 @@ const parseTools = { lxlyric: lxlrcLines.join('\n'), } }, + // Legacy implementation retained for reference; inactive. + // parseHeaderInfo(str) { + // str = str.trim() + // str = str.replace(/\r/g, '') + // if (!str) return null + // const lines = str.split('\n') + // return lines.map(line => { + // if (!this.rxps.info.test(line)) return line + // try { + // const info = JSON.parse(line) + // const timeTag = this.msFormat(info.t) + // return timeTag ? `${timeTag}${info.c.map(t => t.tx).join('')}` : '' + // } catch { + // return '' + // } + // }) + // } parseHeaderInfo(str) { str = str.trim() str = str.replace(/\r/g, '') if (!str) return null + const isPad3 = this.rxps.timeMs3.test(str) || !this.rxps.timeMs2.test(str) const lines = str.split('\n') return lines.map(line => { if (!this.rxps.info.test(line)) return line try { const info = JSON.parse(line) - const timeTag = this.msFormat(info.t) + const timeTag = this.msFormat(info.t, isPad3) return timeTag ? `${timeTag}${info.c.map(t => t.tx).join('')}` : '' } catch { return '' diff --git a/src/modules/utils/musicSdk/wy/singer.js b/src/modules/utils/musicSdk/wy/singer.js index ffecdddd..97e49810 100644 --- a/src/modules/utils/musicSdk/wy/singer.js +++ b/src/modules/utils/musicSdk/wy/singer.js @@ -1,4 +1,4 @@ -import { weapiRequest } from './utils/index' +import { eapiRequest } from './utils/index' import { formatPlayTime } from '../../index' import { formatSingerName } from '../utils' import { buildQualitys } from './quality' @@ -8,18 +8,39 @@ export default { * 获取歌手信息 * @param {*} id */ + // Legacy implementation retained for reference; inactive. + // getInfo(id) { + // return weapiRequest('/artist/head/info/get', { id }).promise.then(({ body }) => { + // if (!body || body.code != 200) throw new Error('get singer info faild.') + // const data = body.data || {} + // return { + // source: 'wy', + // id: data.artist.id, + // info: { + // name: data.artist.name, + // desc: data.artist.briefDesc, + // avatar: (data.user && data.user.avatarUrl) || (data.artist && data.artist.picUrl) || '', + // gender: data.user ? (data.user.gender === 1 ? 'man' : 'woman') : 'man', + // }, + // count: { + // music: data.artist.musicSize, + // album: data.artist.albumSize, + // }, + // } + // }) + // } getInfo(id) { - return weapiRequest('/artist/head/info/get', { id }).promise.then(({ body }) => { + return eapiRequest('/api/artist/head/info/get', { id }).promise.then(({ body }) => { if (!body || body.code != 200) throw new Error('get singer info faild.') - const data = body.data || {} + const data = body.data || body return { source: 'wy', id: data.artist.id, info: { name: data.artist.name, desc: data.artist.briefDesc, - avatar: (data.user && data.user.avatarUrl) || (data.artist && data.artist.picUrl) || '', - gender: data.user ? (data.user.gender === 1 ? 'man' : 'woman') : 'man', + avatar: data.user?.avatarUrl || data.artist.picUrl || '', + gender: data.user?.gender === 1 ? 'man' : 'woman', }, count: { music: data.artist.musicSize, @@ -34,14 +55,33 @@ export default { * @param {*} page * @param {*} limit */ + // Legacy implementation retained for reference; inactive. + // getSongList(id, page = 1, limit = 100) { + // if (page === 1) page = 0 + // return weapiRequest('/v1/artist/songs', { + // id, + // limit, + // offset: limit * page, + // private_cloud: 'true', + // work_type: 1, + // }).promise.then(({ body }) => { + // if (!body.songs || body.code != 200) throw new Error('get singer song list faild.') + // + // const list = this.filterSongList(body.songs) + // return { + // list, + // limit, + // page, + // total: body.total, + // source: 'wy', + // } + // }) + // } getSongList(id, page = 1, limit = 100) { - if (page === 1) page = 0 - return weapiRequest('/v1/artist/songs', { + return eapiRequest('/api/v2/artist/songs', { id, limit, - offset: limit * page, - private_cloud: 'true', - work_type: 1, + offset: limit * (page - 1), }).promise.then(({ body }) => { if (!body.songs || body.code != 200) throw new Error('get singer song list faild.') @@ -61,22 +101,41 @@ export default { * @param {*} page * @param {*} limit */ + // Legacy implementation retained for reference; inactive. + // getAlbumList(id, page = 1, limit = 10) { + // if (page === 1) page = 0 + // return weapiRequest(`/artist/albums/${id}`, { + // limit, + // offset: limit * page, + // total: true, + // }).promise.then(({ body }) => { + // if (!body.hotAlbums || body.code != 200) throw new Error('get singer album list faild.') + // + // const list = this.filterAlbumList(body.hotAlbums) + // return { + // source: 'wy', + // list, + // limit, + // page, + // total: body.artist.albumSize, + // } + // }) + // } getAlbumList(id, page = 1, limit = 10) { - if (page === 1) page = 0 - return weapiRequest(`/artist/albums/${id}`, { + return eapiRequest(`/api/artist/albums/${id}`, { limit, - offset: limit * page, - total: true, + offset: limit * (page - 1), }).promise.then(({ body }) => { if (!body.hotAlbums || body.code != 200) throw new Error('get singer album list faild.') const list = this.filterAlbumList(body.hotAlbums) + const data = body.data || body return { source: 'wy', list, limit, page, - total: body.artist.albumSize, + total: data.artist.albumSize, } }) }, @@ -104,21 +163,21 @@ export default { const { types, _types } = buildQualitys({ ...item, - l: item.lMusic, - h: item.hMusic, - sq: item.sqMusic, - hr: item.hrMusic, + l: item.l || item.lMusic, + h: item.h || item.hMusic, + sq: item.sq || item.sqMusic, + hr: item.hr || item.hrMusic, }, item.privilege) list.push({ - singer: formatSingerName(item.artists), + singer: formatSingerName(item.ar || item.artists), name: item.name, - albumName: item.album.name, - albumId: item.album.id, + albumName: (item.al || item.album)?.name || '', + albumId: (item.al || item.album)?.id, songmid: item.id, source: 'wy', - interval: formatPlayTime(item.duration), - img: null, + interval: formatPlayTime((item.dt ?? item.duration) / 1000), + img: (item.al || item.album)?.picUrl || null, lrc: null, otherSource: null, types, diff --git a/src/modules/utils/musicSdk/wy/songList.js b/src/modules/utils/musicSdk/wy/songList.js index 8ada8ac5..f223ac82 100644 --- a/src/modules/utils/musicSdk/wy/songList.js +++ b/src/modules/utils/musicSdk/wy/songList.js @@ -215,6 +215,7 @@ export default { // 获取标签 getTag(tryNum = 0) { + if (tryNum > 2) return Promise.reject(new Error('try max num')) if (this._requestObj_tags) this._requestObj_tags.cancelHttp() this._requestObj_tags = httpFetch('https://music.163.com/weapi/playlist/catalogue', { method: 'post', diff --git a/src/server/backup/restoreDependencies.ts b/src/server/backup/restoreDependencies.ts new file mode 100644 index 00000000..1ba11c34 --- /dev/null +++ b/src/server/backup/restoreDependencies.ts @@ -0,0 +1,40 @@ +import fs from 'node:fs' +import path from 'node:path' +import type { RestoreFile } from './restoreTasks' + +/** Missing-only restores keep business data but must merge metadata describing newly added files. */ +export function markRestoreDependencies(files: RestoreFile[]) { + const added = files.filter(f => f.action === 'add') + for (const file of files) { + if (file.action !== 'skip') continue + if (file.relative.endsWith('/music_index.json') && added.some(f => path.posix.dirname(f.relative) === path.posix.dirname(file.relative))) file.action = 'merge' + if (file.relative.endsWith('/snapshotInfo.json')) { + const parent = path.posix.dirname(file.relative) + const base = parent.endsWith('/snapshot') ? parent + '/' : parent + '/snapshot/' + if (added.some(f => f.relative.startsWith(base) && path.posix.basename(f.relative).startsWith('snapshot_'))) file.action = 'merge' + } + } +} + +export function mergeRestoreDependency(file: RestoreFile, staged: string, files: RestoreFile[]) { + const local = JSON.parse(fs.readFileSync(file.local, 'utf8')) + const cloud = JSON.parse(fs.readFileSync(staged, 'utf8')) + if (!local || !cloud || Array.isArray(local) || Array.isArray(cloud)) throw new Error('索引格式无效,已取消恢复:' + file.relative) + if (file.relative.endsWith('/music_index.json')) { + const base = path.dirname(file.local) + for (const [id, value] of Object.entries(cloud)) { + const item = value as any + if (Object.hasOwn(local, id) || !item || typeof item.filename !== 'string') continue + const target = path.resolve(base, item.filename) + if (!target.startsWith(base + path.sep)) throw new Error('音乐索引文件名越界') + if (fs.existsSync(target) || files.some(f => f.action === 'add' && path.resolve(f.local) === target)) local[id] = item + } + } else { + if (!Array.isArray(local.list) || !Array.isArray(cloud.list)) throw new Error('快照索引格式无效') + const folder = path.dirname(file.local).endsWith(path.sep + 'snapshot') ? path.dirname(file.local) : path.join(path.dirname(file.local), 'snapshot') + const added = files.filter(f => f.action === 'add' && path.dirname(f.local) === folder).map(f => path.basename(f.local).replace(/^snapshot_/, '')) + // Keep local latest/clients/time; cloud latest becomes an available historical snapshot. + local.list = [...new Set([...local.list, ...cloud.list, cloud.latest, ...added])].filter(id => typeof id === 'string' && id !== local.latest && (fs.existsSync(path.join(folder, 'snapshot_' + id)) || added.includes(id))) + } + fs.writeFileSync(staged, JSON.stringify(local, null, 2)) +} diff --git a/src/server/backup/restoreTasks.ts b/src/server/backup/restoreTasks.ts new file mode 100644 index 00000000..c257d9e0 --- /dev/null +++ b/src/server/backup/restoreTasks.ts @@ -0,0 +1,199 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { backupFileVersion, hashBackupFile } from '@/utils/backupFiles' +import { backupLocalPath, isCloudRestoreActive, isCloudBackupRunning } from '@/utils/backupPolicy' + +export interface RestoreFile { + relative: string; local: string; remote: string; size: number; hash?: string + version: string; action: 'add' | 'overwrite' | 'skip' | 'merge' +} +export interface RestoreTask { + id: string; state: string; createdAt: number; updatedAt: number; source: string + options: any; files: RestoreFile[]; downloaded: string[]; downloadedHashes?: Record; installed: Record + errors: string[]; keepCopies: boolean; bytes: number +} +export function createRestoreTaskManager(namespace: string) { + if (!/^[a-z-]+$/.test(namespace)) throw new Error('恢复任务命名空间无效') + const controllers = new Map() + const pendingTasks = new Set() + const root = () => path.join(global.lx.dataPath, 'backups', namespace) + function hasInterruptedRestore(dataPath: string): boolean { + const base = path.join(dataPath, 'backups', namespace) + if (!fs.existsSync(base)) return false + return fs.readdirSync(base).filter(id => /^[0-9a-f-]{36}$/.test(id)).some(id => { + try { return ['committing', 'recovery-required'].includes(JSON.parse(fs.readFileSync(path.join(base, id, 'task.json'), 'utf8')).state) } catch { return false } + }) + } + const directory = (id: string) => { + if (!/^[0-9a-f-]{36}$/.test(id)) throw new Error('恢复任务 ID 无效') + const dir = path.join(root(), id) + if (fs.existsSync(dir) && fs.lstatSync(dir).isSymbolicLink()) throw new Error('任务目录不能为符号链接') + return dir + } + const save = (task: RestoreTask) => { + const dir = directory(task.id); fs.mkdirSync(dir, { recursive: true }) + task.updatedAt = Date.now() + const file = path.join(dir, 'task.json'), tmp = file + '.tmp' + fs.writeFileSync(tmp, JSON.stringify(task, null, 2), { mode: 0o600 }); fs.renameSync(tmp, file) + } + function readRestoreTask(id: string): RestoreTask { + const task = JSON.parse(fs.readFileSync(path.join(directory(id), 'task.json'), 'utf8')) as RestoreTask + if (task.id !== id || !Array.isArray(task.files)) throw new Error('恢复任务记录无效') + if (task.state === 'pending' && !pendingTasks.has(id)) { task.state = 'interrupted'; save(task) } + if (!controllers.has(id) && ['downloading', 'ready'].includes(task.state)) { task.state = 'interrupted'; save(task) } + if (!controllers.has(id) && task.state === 'committing') { task.state = 'recovery-required'; save(task) } + return task + } + function listRestoreTasks() { + if (!fs.existsSync(root())) return [] + return fs.readdirSync(root()).filter(id => /^[0-9a-f-]{36}$/.test(id)).map(id => { + try { + const task = readRestoreTask(id) + return { id, state: task.state, createdAt: task.createdAt, updatedAt: task.updatedAt, source: task.source, total: task.files.filter(f => f.action !== 'skip').length, downloaded: task.downloaded.length, applied: Object.keys(task.installed).length, bytes: task.bytes, errors: task.errors, canRollback: task.keepCopies && Object.keys(task.installed).length > 0 } + } catch { return null } + }).filter(Boolean).sort((a, b) => b!.createdAt - a!.createdAt) + } + function cancelRestoreTask(id: string) { + const controller = controllers.get(id) + if (!controller) { + const task = readRestoreTask(id) + if (task.state !== 'pending') throw new Error('任务未在运行') + task.state = 'cancelled'; pendingTasks.delete(id); save(task); return + } + controller.abort(new Error('用户取消恢复')) + } + function createPendingRestoreTask(options: any): string { + const task: RestoreTask = { id: crypto.randomUUID(), state: 'pending', createdAt: Date.now(), updatedAt: Date.now(), source: '', options, files: [], downloaded: [], installed: {}, errors: [], keepCopies: options.backupBeforeRestore !== false, bytes: 0 } + save(task); pendingTasks.add(task.id); return task.id + } + function failRestoreTask(id: string, error: unknown) { + pendingTasks.delete(id) + const task = readRestoreTask(id) + if (['completed', 'recovery-required', 'cancelled'].includes(task.state)) return + task.state = 'failed'; task.errors.push(error instanceof Error ? error.message : String(error)); save(task) + } + function validateTargets(task: RestoreTask) { + const context = { dataPath: global.lx.dataPath, configPath: global.lx.configPath, config: global.lx.config as any } + for (const file of task.files) if (path.resolve(backupLocalPath(file.relative, context)) !== path.resolve(file.local)) throw new Error('恢复目标配置已变化,请重新预览') + } + function rollback(task: RestoreTask) { + validateTargets(task) + const applied = task.files.filter(file => task.installed[file.relative]) + // Validate every destination before changing anything. Never erase subsequent user edits. + for (const file of applied) { + if (!fs.existsSync(file.local) || hashBackupFile(file.local) !== task.installed[file.relative]) throw new Error('恢复后文件已变化,不能自动回退:' + file.relative) + if (file.version !== 'missing' && !fs.existsSync(path.join(directory(task.id), 'files', file.relative))) throw new Error('恢复前副本已不存在') + } + task.state = 'recovery-required'; save(task) + for (const file of applied.reverse()) { + if (file.version === 'missing') fs.unlinkSync(file.local) + else { + const temp = file.local + '.' + crypto.randomUUID() + '.oltmp' + fs.copyFileSync(path.join(directory(task.id), 'files', file.relative), temp); fs.renameSync(temp, file.local) + } + delete task.installed[file.relative]; save(task) + } + task.installed = {}; task.state = 'rolled-back'; save(task) + } + function rollbackRestoreTask(id: string) { + if (isCloudRestoreActive() || isCloudBackupRunning()) throw new Error('请等待当前备份、校验或恢复完成') + if (controllers.size) throw new Error('请先等待当前恢复结束') + const task = readRestoreTask(id) + if (!task.keepCopies && task.state !== 'recovery-required') throw new Error('此任务未保留恢复前副本') + if (task.state === 'recovery-required') { + // A crash can occur between replacing a file and writing its journal entry. + for (const file of task.files) { + const staged = path.join(directory(id), 'stage', file.relative) + if (!task.installed[file.relative] && file.action !== 'skip' && fs.existsSync(staged) && fs.existsSync(file.local) && hashBackupFile(file.local) === hashBackupFile(staged) && backupFileVersion(file.local) !== file.version) task.installed[file.relative] = hashBackupFile(staged) + } + } + if (global.lx.pauseForRestore) { global.lx.restoreMaintenance = true; global.lx.pauseForRestore() } + rollback(task) + return { restartRequired: true } + } + function deleteRestoreTask(id: string) { + const task = readRestoreTask(id) + if (controllers.has(id) || ['committing', 'recovery-required'].includes(task.state)) throw new Error('任务仍在运行或等待故障恢复,不能删除') + const dir = directory(id) + if (path.dirname(dir) !== root()) throw new Error('任务目录无效') + fs.rmSync(dir, { recursive: true, force: true }) + } + + /** Downloads are restartable at file boundaries; local files are touched only after all downloads pass. */ + async function executeRestoreTransaction(input: { + files: RestoreFile[]; source: string; options: any; keepCopies: boolean; resumeId?: string + download: (file: RestoreFile, target: string, signal: AbortSignal) => Promise + merge: (file: RestoreFile, staged: string) => void + }) { + let task: RestoreTask + if (input.resumeId) { + task = readRestoreTask(input.resumeId) + if (!['pending', 'failed', 'cancelled', 'interrupted', 'rolled-back'].includes(task.state)) throw new Error('任务不可继续;中断的提交需先回退') + if (task.state !== 'pending' && task.files.length && (task.source !== input.source || JSON.stringify(task.files.map(f => [f.relative, f.remote, f.size, f.hash])) !== JSON.stringify(input.files.map(f => [f.relative, f.remote, f.size, f.hash])))) throw new Error('备份内容已变化,不能继续旧任务') + task.source = input.source + task.files = input.files; task.options = input.options; task.errors = []; task.installed = {}; task.keepCopies = input.keepCopies + } else { + task = { id: crypto.randomUUID(), state: 'downloading', createdAt: Date.now(), updatedAt: Date.now(), source: input.source, options: input.options, files: input.files, downloaded: [], installed: {}, errors: [], keepCopies: input.keepCopies, bytes: 0 } + } + validateTargets(task) + const controller = new AbortController(); controllers.set(task.id, controller) + pendingTasks.delete(task.id) + task.state = 'downloading'; save(task) + let applied = false + try { + for (const file of task.files) { + if (file.action === 'skip') continue + controller.signal.throwIfAborted() + const target = path.join(directory(task.id), 'stage', file.relative) + task.downloadedHashes ??= {} + const expectedHash = file.hash || task.downloadedHashes[file.relative] + const reusable = task.downloaded.includes(file.relative) && !!expectedHash && fs.existsSync(target) && fs.statSync(target).size === file.size && hashBackupFile(target) === expectedHash + if (!reusable) { + fs.mkdirSync(path.dirname(target), { recursive: true }) + await input.download(file, target, controller.signal) + } + task.downloadedHashes[file.relative] = hashBackupFile(target) + if (!task.downloaded.includes(file.relative)) task.downloaded.push(file.relative) + task.bytes = task.files.filter(f => task.downloaded.includes(f.relative)).reduce((n, f) => n + f.size, 0) + save(task) + } + controller.signal.throwIfAborted() + if (!task.files.some(file => file.action !== 'skip')) { task.state = 'completed'; save(task); return { taskId: task.id, restored: 0, backupDir: '', errors: [] as string[] } } + task.state = 'ready'; save(task) + for (const file of task.files.filter(f => f.action !== 'skip')) if (backupFileVersion(file.local) !== file.version) throw new Error('本地数据在下载期间发生变化,未替换任何文件:' + file.relative) + // No await from this point through commit/rollback: other JS writers cannot interleave. + if (global.lx.pauseForRestore) { global.lx.restoreMaintenance = true; global.lx.pauseForRestore() } + for (const file of task.files.filter(f => f.action !== 'skip')) { + if (file.version !== 'missing') { + const original = path.join(directory(task.id), 'files', file.relative) + fs.mkdirSync(path.dirname(original), { recursive: true }); fs.copyFileSync(file.local, original) + } + if (file.action === 'merge') input.merge(file, path.join(directory(task.id), 'stage', file.relative)) + } + fs.writeFileSync(path.join(directory(task.id), 'manifest.json'), JSON.stringify({ createdAt: task.createdAt, source: task.source, files: task.files.filter(f => f.action !== 'skip' && f.version !== 'missing').map(f => ({ path: f.relative, local: f.local })) }, null, 2)) + task.state = 'committing'; save(task) + for (const file of task.files.filter(f => f.action !== 'skip')) { + if (backupFileVersion(file.local) !== file.version) throw new Error('提交期间本地文件已变化:' + file.relative) + fs.mkdirSync(path.dirname(file.local), { recursive: true }) + const temp = file.local + '.' + crypto.randomUUID() + '.oltmp' + const staged = path.join(directory(task.id), 'stage', file.relative) + const hash = hashBackupFile(staged) + try { fs.copyFileSync(staged, temp); fs.renameSync(temp, file.local) } finally { fs.rmSync(temp, { force: true }) } + task.installed[file.relative] = hash; applied = true; save(task) + } + task.state = 'completed'; save(task) + if (!task.keepCopies) fs.rmSync(path.join(directory(task.id), 'files'), { recursive: true, force: true }) + return { taskId: task.id, restored: Object.keys(task.installed).length, backupDir: task.keepCopies ? directory(task.id) : '', errors: [] as string[] } + } catch (error: any) { + task.errors.push(error?.message || String(error)) + if (applied) { + try { rollback(task) } catch (e: any) { task.errors.push('自动回退失败:' + e.message); task.state = 'recovery-required'; save(task); return { taskId: task.id, restored: Object.keys(task.installed).length, backupDir: directory(task.id), errors: task.errors } } + } + task.state = controller.signal.aborted ? 'cancelled' : 'failed'; save(task) + return { taskId: task.id, restored: 0, backupDir: directory(task.id), errors: task.errors } + } finally { controllers.delete(task.id) } + } + + return { hasInterruptedRestore, readRestoreTask, listRestoreTasks, cancelRestoreTask, createPendingRestoreTask, failRestoreTask, rollbackRestoreTask, deleteRestoreTask, executeRestoreTransaction } +} diff --git a/src/server/backup/webdavRestoreTasks.ts b/src/server/backup/webdavRestoreTasks.ts new file mode 100644 index 00000000..06f8c49b --- /dev/null +++ b/src/server/backup/webdavRestoreTasks.ts @@ -0,0 +1,3 @@ +import { createRestoreTaskManager } from './restoreTasks' +export type { RestoreFile } from './restoreTasks' +export const { hasInterruptedRestore, listRestoreTasks, rollbackRestoreTask, deleteRestoreTask, executeRestoreTransaction } = createRestoreTaskManager('webdav-restore-tasks') diff --git a/src/server/index.ts b/src/server/index.ts index c5d1024d..c5462125 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,5 +1,6 @@ import { startServer, + reloadServerData, // stopServer, getStatus, // generateCode, @@ -8,6 +9,7 @@ import { export { startServer, + reloadServerData, // stopServer, getStatus, // generateCode, diff --git a/src/server/integrations/subsonic.ts b/src/server/integrations/subsonic.ts index 2fa0aad3..90c3f12f 100644 --- a/src/server/integrations/subsonic.ts +++ b/src/server/integrations/subsonic.ts @@ -1,12 +1,16 @@ +import { withRatingIdentityLock } from '../rating/ratingIdentityLock' +import { writeRating, writeRatingUnlocked, recordRatingBackfillFailure, flushRatingSnapshot, readRatings, resolveStoredRatingId } from '../rating/ratingService' +import { lookupLocalLyrics } from '@/server/music/localLyrics' import http from 'http' import { Readable } from 'stream' import { pipeline } from 'stream/promises' import crypto from 'crypto' import { URL } from 'url' import { getUserSpace, getUserDirname, getUserConfig } from '@/user' +import { createRatingSnapshot, withMetaLock } from '../rating/ratingSnapshot' import { callUserApiGetMusicUrl } from '@/server/sources/userApi' -import { downloadAndCache, checkCache, serveCacheFile, getFileName } from '@/server/music/fileCache' -import { resolveOpenlistCacheEntry } from '@/server/openlistBackup' +import { downloadAndCache, checkCache, serveCacheFile, getFileName, getCacheDir } from '@/server/music/fileCache' +import { resolveOpenlistCacheEntry } from '@/server/openlist/openlistBackup' import { getSingerPic, getSingerDetail, getSingerMid, nameSimilarity, resolveSingerSources, getOrderedSingerSources, normalizeName as normalizeSingerName, @@ -22,7 +26,7 @@ import { proxyCoverImage } from '@/server/media/coverProxy' import { subsonicLog } from '@/utils/log4js' import { fetchGenres, fetchRadios, fetchPlaylistsByGenre, fetchRadioSongs, fetchPlaylistSongs, fetchSongsByGenre } from '@/server/utils/discovery' import { listRadioStations, getRadioStation, addRadioStation, updateRadioStation, removeRadioStation } from '@/server/music/radioStations' -import { getLocalTracks, findLocalTrack, localMusic, localSong, localAlbums, localArtists, localFile, localCover, localLyrics, serveLocalTrack, isLocalSong, isLocalEntity, getLocalScanStatus, readLocalState, editLocalState, mergeLocalPlaylist } from '@/server/music/subsonicLocal' +import { getLocalTracks, localRatingId, findLocalTrack, localMusic, localSong, localAlbums, localArtists, localFile, localCover, localLyrics, serveLocalTrack, isLocalSong, isLocalEntity, getLocalScanStatus, readLocalState, editLocalState, mergeLocalPlaylist } from '@/server/music/subsonicLocal' import { getCustomMusicDir } from '@/server/music/customMusicManager' import { checkLyricCache } from '@/server/music/fileCache' import { parseLyrics } from '@/utils/lrcTool' @@ -123,6 +127,36 @@ function withTimeout(p: Promise, ms: number): Promise { }) } +/** 手动联动同步:每批解析的歌曲数(全量分批,不截断),避免一次发起大量在线回源 */ +const LINK_SYNC_BATCH_SIZE = 20 +const LINK_SYNC_ITEM_TIMEOUT_MS = 8000 + +export interface LinkSyncSummary { + direction: 'ratingToDislike' | 'dislikeToRating' + user: string + /** 本次扫描的候选条目数 */ + scanned: number + /** 实际新增的 dislike 规则 / 低星评分数 */ + added: number + /** 已存在、无需补写的条目数 */ + skipped: number + /** 解析失败或超时的条目数 */ + failed: number + /** 生效的 dislike 阈值 */ + threshold: number + /** 实际处理的批次数(0 表示无待处理条目) */ + batches: number + candidates?: Array<{ id: string, name?: string, source?: string }> + updated?: number + warning?: string + existing?: any[] + duplicates?: any[] + failures?: any[] +} + +const countDislikeRules = (rules: string): number => + String(rules || '').split('\n').filter(l => l.trim()).length + /** 多源结果按源轮询交错取数,避免"响应最快的源"占满全部名额 */ function interleaveBySource(groups: T[][], max: number): T[] { const out: T[] = [] @@ -433,6 +467,7 @@ export interface SubsonicMeta { starredAlbums: string[] starredArtists: string[] starredArtistNames: string[] + ratingOrigins?: Record ratings: Record } @@ -445,22 +480,9 @@ export function getSubsonicMetaFilePath(username: string): string { } export function readSubsonicMeta(username: string): SubsonicMeta { - try { - const filePath = getSubsonicMetaFilePath(username) - if (fs.existsSync(filePath)) { - const data = JSON.parse(fs.readFileSync(filePath, 'utf8')) - return { - starredAlbums: Array.isArray(data.starredAlbums) ? data.starredAlbums : [], - starredArtists: Array.isArray(data.starredArtists) ? data.starredArtists : [], - // 兼容旧版元数据:starredArtistNames 可能为 undefined - starredArtistNames: Array.isArray(data.starredArtistNames) ? data.starredArtistNames : [], - ratings: data.ratings && typeof data.ratings === 'object' ? data.ratings : {}, - } - } - } catch (e) { - subsonicLog.error('[Subsonic] Failed to read subsonic-meta.json:', e) - } - return { starredAlbums: [], starredArtists: [], starredArtistNames: [], ratings: {} } + const data = readRatings(username) + return { ...data, starredAlbums: Array.isArray(data.starredAlbums) ? data.starredAlbums : [], starredArtists: Array.isArray(data.starredArtists) ? data.starredArtists : [], starredArtistNames: Array.isArray(data.starredArtistNames) ? data.starredArtistNames : [] } + } export function writeSubsonicMeta(username: string, meta: SubsonicMeta) { @@ -468,9 +490,11 @@ export function writeSubsonicMeta(username: string, meta: SubsonicMeta) { const filePath = getSubsonicMetaFilePath(username) const dirPath = path.dirname(filePath) if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true }) - fs.writeFileSync(filePath, JSON.stringify(meta), 'utf8') + fs.writeFileSync(filePath + '.meta.tmp', JSON.stringify(meta), 'utf8') + fs.renameSync(filePath + '.meta.tmp', filePath) } catch (e) { subsonicLog.error('[Subsonic] Failed to write subsonic-meta.json:', e) + throw e } } @@ -708,51 +732,18 @@ export function syncNativeLibraryToSubsonic( * 后写入者会整体覆盖前者的修改,导致用户刚设置的星标或评分丢失。 * 这里按 username 串成 Promise 链,保证同一用户的读-改-写严格顺序执行。 */ -const metaLocks = new Map>() - -/** - * 同一用户 subsonic-meta.json 的「读-改-写」串行化锁。 - * 避免 handleSetRating / handleStar / syncDislikeToRating 并发写同一文件互相覆盖。 - */ -function withMetaLock(username: string, task: () => Promise): Promise { - const prev = metaLocks.get(username) ?? Promise.resolve() - const run = (): Promise => task() - // 前一个任务无论成功还是失败都要继续,避免一次异常卡死整条队列 - const next = prev.then(run, run) - const tail = next.then(() => undefined, () => undefined) - metaLocks.set(username, tail) - tail.then(() => { - if (metaLocks.get(username) === tail) metaLocks.delete(username) - }) - return next -} - /** * 双向联动:网页端「不喜欢」(dislike) <-> Subsonic 评分(rating)。 * 网页给某首歌点「不喜欢」时,回写该用户 subsonic-meta.json 的 ratings 映射, * 使 Subsonic 客户端能看到这颗低星(与正向联动 handleSetRating 共用同一份存储): - * rating > 0 -> ratings[id] = rating(低星,落入 dislike 区间) - * rating <= 0 -> 删除 ratings[id](恢复未评) + * rating > 0 -> 仅在没有评分时补充低星 + * rating <= 0 -> 不更改评分,保留用户手工数据 * id 即 Subsonic 歌曲 id(source_songId)。模块级函数,供 server.ts 的 /api/music/dislike 直接调用。 */ -export async function syncDislikeToRating(username: string, id: string, rating: number): Promise { - if (!id) return - try { - await withMetaLock(username, async () => { - const meta = readSubsonicMeta(username) - const r = Math.max(0, Math.min(5, Math.floor(rating))) - if (r <= 0) { - if (meta.ratings[id] === undefined) return - delete meta.ratings[id] - } else { - meta.ratings[id] = r - } - writeSubsonicMeta(username, meta) - subsonicLog.debug(`[Subsonic] dislike<->rating 同步: ${id} -> ${r} (user=${username})`) - }) - } catch (e) { - subsonicLog.error('[Subsonic] dislike<->rating 同步失败:', e) - } +export async function syncDislikeToRating(username: string, id: string, rating: number, song?: any): Promise { + if (!id || rating <= 0) return + const saved = await writeRating(username, id, rating, 'web-dislike-link', song, { onlyMissing: true }) + if (saved.snapshotWarning) throw new Error(saved.snapshotWarning) } /** @@ -1514,7 +1505,7 @@ class SubsonicHandler { if (isLocalSong(id) && ['stream', 'download', 'getCoverArt', 'getLyrics', 'getLyricsBySongId', 'getSong'].includes(method)) { const track = await findLocalTrack(user, id) if (!track || !localFile(track)) return error('Local song not found in selected storage') - if (method === 'getSong') return send({ song: { ...localSong(track, user), userRating: readSubsonicMeta(user).ratings[id] || 0 } }) + if (method === 'getSong') return send({ song: { ...localSong(track, user), userRating: readSubsonicMeta(user).ratings[localRatingId(track)] || 0 } }) if (method === 'getCoverArt') { const cover = localCover(track) if (cover) { res.setHeader('Content-Type', cover.mime); res.end(cover.data) } @@ -1619,7 +1610,7 @@ class SubsonicHandler { const songInfo: any = { id: track.platformId || track.id, source: match?.[1] || 'local', songmid: match?.[2] || '', name: track.title, singer: track.artist } // Reuse the website's indexed lyric lookup, including name/artist fallback. try { - const cached = checkLyricCache(songInfo, user) + const cached = lookupLocalLyrics(songInfo, user) if (cached.exists && (cached.content?.lyric || cached.content?.lrc)) return { ...cached.content, lyric: cached.content.lyric || cached.content.lrc } } catch { /* Missing cache must not prevent an online fallback. */ } if (!match || !musicSdk[match[1]]?.getLyric) return { lyric: '' } @@ -1719,7 +1710,7 @@ class SubsonicHandler { */ private musicToSongFlat(music: LX.Music.MusicInfo, parentId: string, artistIdOverride?: string, username?: string) { const meta = (music as any).meta || {} - if (meta.localTrack && username) return { ...localSong(meta.localTrack, username), parent: parentId, userRating: this.userRatingsCache.get(username)?.[music.id] || 0 } + if (meta.localTrack && username) return { ...localSong(meta.localTrack, username), parent: parentId, userRating: this.userRatingsCache.get(username)?.[localRatingId(meta.localTrack)] || 0 } const id = music.id const singer = music.singer || 'Unknown Artist' @@ -1973,13 +1964,22 @@ class SubsonicHandler { }, format) } - private async getLocalLibraryPlaylist(username: string, customOnly = false) { + private async getLocalLibraryPlaylist(username: string, customOnly = false, includeSongs = true) { const customDir = getCustomMusicDir(username) + const musicDir = path.resolve(getCacheDir(username, true)) + const cacheDir = path.resolve(getCacheDir(username, false)) + const customRoot = customDir ? path.resolve(customDir) : null + const includeCache = global.lx.config['subsonic.cachePlaylist'] === true const id = customOnly ? 'custom-library' : 'local-library' const tracks = (await getLocalTracks(username)).filter(t => { - const isCustom = !!customDir && path.resolve(t.root) === path.resolve(customDir) - return customOnly ? isCustom : !isCustom + const root = path.resolve(t.root) + // Built-in storage takes precedence when a custom directory points at it. + const isMusic = root === musicDir + const isCache = root === cacheDir + const isCustom = root === customRoot && !isMusic && !isCache + return customOnly ? isCustom : isMusic || (includeCache && isCache) }).sort((a, b) => a.title.localeCompare(b.title, 'zh-Hans-CN') || a.id.localeCompare(b.id)) + const state = includeSongs && tracks.length ? readLocalState(username) : undefined return { meta: { id, name: `${customOnly ? '自定义目录' : '本地歌曲'}(${username})`, owner: username, public: false, @@ -1990,7 +1990,10 @@ class SubsonicHandler { changed: new Date(tracks.reduce((n, t) => Math.max(n, t.mtime), 0)).toISOString(), readonly: true, }, - songs: tracks.map(t => this.musicToSongFlat(localMusic(t), id, undefined, username)), + songs: includeSongs ? tracks.map(t => ({ + ...localSong(t, username, state), parent: id, + userRating: this.userRatingsCache.get(username)?.[localRatingId(t)] || 0, + })) : [], } } @@ -2002,6 +2005,83 @@ class SubsonicHandler { : songs[0]?.id || 'logo' } + /** 评星歌单 id 前缀(虚拟只读,不落盘) */ + private static readonly RATING_PLAYLIST_PREFIX = 'rating_' + + /** + * 聚合 1~5 星评星歌单的歌曲。 + * - 数据来源:subsonic-meta.json 的 ratings(key 为 Subsonic 歌曲 id),反查回 MusicInfo; + * - rating_1 额外并入「我不喜欢」规则命中的歌曲,使只能用评星操作的 Subsonic 客户端 + * 可通过 1 星歌单查找并处理不喜欢(打 1 星 -> 写 dislike,提高/清除评分 -> 移除 dislike); + * - 不写入任何实体歌单,LX 客户端「我的歌单」不受影响。 + */ + private async getRatingPlaylistSongs(username: string): Promise> { + const groups = new Map() + for (let star = 1; star <= 5; star++) groups.set(star, []) + + const meta = await this.getUserSubsonicMeta(username) + const ratings = meta.ratings || {} + + // 所有可能被评星的歌曲:用户歌单 + 已缓存的在线歌曲(含本地音乐转换而来的对象) + const listData = await this.getSubsonicListData(username) + const all = new Map() + for (const m of [...listData.loveList, ...listData.defaultList, ...listData.userList.flatMap(l => l.list || [])]) all.set(m.id, m) + this.loadOnlineSongCache() + for (const [id, m] of this.onlineSongCache) if (!all.has(id)) all.set(id, m) + // 补充未收藏的本地歌曲:cacheOnlineSong 会跳过本地歌曲,仅靠歌单会漏掉已评分的 NAS 曲目; + // 缓存淘汰的已评分歌曲会在下方尝试按 ID 重新解析。 + for (const t of await getLocalTracks(username)) if (!all.has(t.id)) all.set(t.id, localMusic(t)) + + // Web独立评分资料可用于展示未加入歌单、也未进入在线缓存的歌曲。 + try { + const file = path.join(global.lx.userPath, getUserDirname(username), 'rating', 'songs.json') + const catalog = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {} + for (const [id, stored] of Object.entries(catalog)) { + if (all.has(id) || id.startsWith('local_')) continue + const item = stored as any + all.set(id, { ...item, meta: { songId: id.slice(String(item.source).length + 1), albumName: item.albumName } }) + } + } catch { /* 资料缺失时继续现有解析路径 */ } + + for (const [id, music] of all) { + const star = ratings[(music.meta as any)?.localTrack ? localRatingId((music.meta as any).localTrack) : id] + // ratings 来自可手工编辑的 subsonic-meta.json;只接受 1~5 的整数, + // 否则 groups.get(1.5) 为 undefined,push 会让整个 getPlaylists 请求 500。 + if (typeof star === 'number' && Number.isInteger(star) && star >= 1 && star <= 5) { + groups.get(star)!.push(music) + } + } + + // 缓存淘汰后重新解析已评分歌曲;解析失败保留原评分,后续可重试。 + for (const [id, star] of Object.entries(ratings)) { + if (all.has(id) || /^(alb_|art_|artist_)/.test(id) || !Number.isInteger(star) || star < 1 || star > 5) continue + try { + const hit = await withTimeout(this.resolveSongMeta(username, id), LINK_SYNC_ITEM_TIMEOUT_MS) + if (hit?.music) { + all.set(id, hit.music) + groups.get(star)!.push(hit.music) + } + } catch (e) { subsonicLog.warn(`[Subsonic] 无法解析已评分歌曲 ${id}`, e) } + } + + // 1 星歌单 = 1 星歌曲 ∪ dislike 规则命中的歌曲 + if (global.lx.config['subsonic.ratingPlaylists'] !== false) { + const candidates = [...all.values()] + const kept = await this.filterDislikedSongs(username, candidates) + const keptIds = new Set(kept.map(m => m.id)) + const oneStar = groups.get(1)! + const oneStarIds = new Set(oneStar.map(m => m.id)) + for (const m of candidates) { + if (!keptIds.has(m.id) && !oneStarIds.has(m.id)) oneStar.push(m) + } + } + return groups + } + + private isRatingPlaylistEnabled(): boolean { + return global.lx.config['subsonic.ratingPlaylists'] !== false + } + private async handleGetPlaylists(res: http.ServerResponse, username: string, format: string) { const userSpace = getUserSpace(username) const listData = await this.getSubsonicListData(username) @@ -2022,9 +2102,9 @@ class SubsonicHandler { const playlists: any[] = [] if (global.lx.config['subsonic.localPlaylist'] !== false) { - playlists.push((await this.getLocalLibraryPlaylist(username)).meta) + playlists.push((await this.getLocalLibraryPlaylist(username, false, false)).meta) if (global.lx.config['subsonic.customPlaylist'] !== false && getCustomMusicDir(username)) { - playlists.push((await this.getLocalLibraryPlaylist(username, true)).meta) + playlists.push((await this.getLocalLibraryPlaylist(username, true, false)).meta) } } @@ -2076,6 +2156,25 @@ class SubsonicHandler { } } + // [新增] 评星歌单:1~5 星各一个虚拟只读播放列表,1 星承载「我不喜欢」 + if (this.isRatingPlaylistEnabled()) { + try { + const groups = await this.getRatingPlaylistSongs(username) + for (let star = 1; star <= 5; star++) { + const musics = groups.get(star) || [] + // 与 default 歌单一致:不暴露 0 首的虚拟歌单,避免客户端歌单列表里出现空星级。 + // 直接打开指定 rating_N 仍返回空列表(handleGetPlaylist / getMusicDirectory 不受影响)。 + if (!musics.length) continue + const name = star === 1 + ? '★☆☆☆☆ 1星 · 我不喜欢' + : `${'★'.repeat(star)}${'☆'.repeat(5 - star)} ${star}星` + playlists.push(buildPlaylist(`${SubsonicHandler.RATING_PLAYLIST_PREFIX}${star}`, name, musics)) + } + } catch (err) { + subsonicLog.error('[Subsonic] 生成评星歌单失败:', err) + } + } + if (format === 'json') { return this.sendResponse(res, { playlists: { playlist: playlists } }, format) } @@ -2104,6 +2203,40 @@ class SubsonicHandler { return this.handleGetSharedPlaylist(res, username, id, format) } + // [新增] 评星歌单(虚拟只读):1 星承载「我不喜欢」。 + // 必须在此提前返回:下方 hideDisliked 会剔除 dislike 命中的歌曲,导致 1 星歌单被清空。 + if (id.startsWith(SubsonicHandler.RATING_PLAYLIST_PREFIX)) { + if (!this.isRatingPlaylistEnabled()) return this.sendError(res, 70, 'Rating playlists are disabled', format) + const star = parseInt(id.slice(SubsonicHandler.RATING_PLAYLIST_PREFIX.length), 10) + if (!(star >= 1 && star <= 5)) return this.sendError(res, 70, 'Rating playlist not found', format) + const groups = await this.getRatingPlaylistSongs(username) + const musics = groups.get(star) || [] + const actualRatings = (await this.getUserSubsonicMeta(username)).ratings + // 优先展示真实评分,没有评分的屏蔽项以 1 星展示。 + const entry = musics.map((m: LX.Music.MusicInfo) => ({ + ...this.musicToSongFlat(m, id, undefined, username), + userRating: actualRatings[(m.meta as any)?.localTrack ? localRatingId((m.meta as any).localTrack) : m.id] || star, + })) + const name = star === 1 + ? '★☆☆☆☆ 1星 · 我不喜欢' + : `${'★'.repeat(star)}${'☆'.repeat(5 - star)} ${star}星` + return this.sendLocalResponse(res, { + playlist: { + id, + name, + comment: star === 1 ? '包含 1 星及不喜欢命中歌曲;保留真实评分,联动仅补充数据' : '', + owner: username, + public: false, + songCount: musics.length, + duration: musics.reduce((sum: number, m: any) => sum + this.parseDuration(m.interval), 0), + created: new Date().toISOString(), + changed: new Date().toISOString(), + coverArt: this.playlistCoverId(id, musics), + entry, + }, + }, format) + } + const userSpace = getUserSpace(username) const listData = await this.getSubsonicListData(username) @@ -2159,6 +2292,7 @@ class SubsonicHandler { if (!playlistId) return this.sendError(res, 10, 'Required parameter is missing: playlistId', format) if (playlistId.startsWith('lb_')) return this.sendError(res, 0, '排行榜为只读播放列表,不支持修改', format) if (playlistId.startsWith('pl_')) return this.sendError(res, 0, '共享歌单为只读播放列表,不支持修改', format) + if (playlistId.startsWith(SubsonicHandler.RATING_PLAYLIST_PREFIX)) return this.sendError(res, 0, '评星歌单为只读播放列表,请通过给歌曲评星来修改', format) try { const userSpace = getUserSpace(username) @@ -2265,6 +2399,7 @@ class SubsonicHandler { if (!id) return this.sendError(res, 10, 'Required parameter is missing: id', format) if (id.startsWith('lb_')) return this.sendError(res, 0, '排行榜为只读播放列表,不支持删除', format) if (id.startsWith('pl_')) return this.sendError(res, 0, '共享歌单为只读播放列表,不支持删除', format) + if (id.startsWith(SubsonicHandler.RATING_PLAYLIST_PREFIX)) return this.sendError(res, 0, '评星歌单为只读播放列表,不支持删除', format) if (id === 'default' || id === 'love') { return this.sendError(res, 0, 'Built-in playlists cannot be deleted', format) } @@ -3015,8 +3150,18 @@ class SubsonicHandler { let musics: LX.Music.MusicInfo[] = [] let dirName = 'Unknown' - - if (id.startsWith('radio_tx_')) { + let ratingStar = 0 // 评星歌单星级;0 表示非评星歌单 + + // [新增] 评星歌单也可通过 getMusicDirectory 浏览(部分客户端用此端点打开歌单) + if (id.startsWith(SubsonicHandler.RATING_PLAYLIST_PREFIX)) { + if (!this.isRatingPlaylistEnabled()) return this.sendError(res, 70, 'Rating playlists are disabled', format) + const star = parseInt(id.slice(SubsonicHandler.RATING_PLAYLIST_PREFIX.length), 10) + if (!(star >= 1 && star <= 5)) return this.sendError(res, 70, 'Rating playlist not found', format) + const groups = await this.getRatingPlaylistSongs(username) + musics = groups.get(star) || [] + ratingStar = star + dirName = star === 1 ? '★☆☆☆☆ 1星 · 我不喜欢' : `${'★'.repeat(star)}${'☆'.repeat(5 - star)} ${star}星` + } else if (id.startsWith('radio_tx_')) { // [新增] 返回具体电台内的歌曲 // const radioId = id.replace('radio_tx_', '') try { @@ -3054,17 +3199,23 @@ class SubsonicHandler { } } - // [dislike] 过滤不喜欢的歌曲 - if (global.lx.config['subsonic.hideDisliked']) { + // [dislike] 过滤不喜欢的歌曲;评星歌单必须跳过,否则 1 星歌单会被全部剔除 + if (global.lx.config['subsonic.hideDisliked'] && !ratingStar) { musics = await this.filterDislikedSongs(username, musics) } + const actualRatings = ratingStar ? (await this.getUserSubsonicMeta(username)).ratings : {} + // 保留真实评分,没有评分的屏蔽项以 1 星展示。 + const toSong = (m: LX.Music.MusicInfo) => { + const flat = this.musicToSongFlat(m, id, undefined, username) + return ratingStar ? { ...flat, userRating: actualRatings[(m.meta as any)?.localTrack ? localRatingId((m.meta as any).localTrack) : m.id] || ratingStar } : flat + } if (format === 'json') { return this.sendResponse(res, { directory: { id, name: dirName, - child: musics.map((m: LX.Music.MusicInfo) => this.musicToSongFlat(m, id, undefined, username)), + child: musics.map((m: LX.Music.MusicInfo) => toSong(m)), }, }, format) } @@ -3072,7 +3223,7 @@ class SubsonicHandler { directory: { attrs: { id, name: dirName }, children: { - child: musics.map((m: LX.Music.MusicInfo) => this.musicToSongXml(m, id, undefined, username)), + child: musics.map((m: LX.Music.MusicInfo) => ({ attrs: toSong(m) })), }, }, }, format) @@ -5034,6 +5185,7 @@ class SubsonicHandler { const merge = (base: string[], add: Set, del: Set): string[] => Array.from(new Set([...(base || []).filter((x: string) => !del.has(x)), ...add])) await this.saveUserSubsonicMeta(username, { + ...fresh, starredAlbums: merge(fresh.starredAlbums, addedAlbums, removedAlbums), starredArtists: merge(fresh.starredArtists, addedArtists, removedArtists), starredArtistNames: merge(fresh.starredArtistNames, addedArtistNames, removedArtistNames), @@ -5099,15 +5251,15 @@ class SubsonicHandler { return valid.length } catch (e) { subsonicLog.error('[Subsonic] 写入 dislike 规则失败:', e) - return 0 + throw e } } /** 写入专辑维度的 dislike 规则(!<专辑名>@<歌手>,按歌手拆多条) */ - private async addDislikeAlbum(username: string, album: { albumName: string, singers: string[] }): Promise { + private async addDislikeAlbum(username: string, album: { albumName: string, singers: string[], source?: string }, ratingId?: string, channel = 'subsonic-rating'): Promise { try { const userSpace = getUserSpace(username) as any - const infos = (album.singers || []).map(singer => ({ albumName: album.albumName, singer })) + const infos = (album.singers || []).map(singer => ({ albumName: album.albumName, singer, source: album.source, provenance: { channel, at: Date.now(), ratingId } })) if (infos.length === 0) return 0 await userSpace.dislikeManage.dislikeDataManage.addDislikeAlbums(infos) await userSpace.dislikeManage.createSnapshot() @@ -5115,7 +5267,7 @@ class SubsonicHandler { return infos.length } catch (e) { subsonicLog.error('[Subsonic] 写入专辑 dislike 失败:', e) - return 0 + throw e } } @@ -5186,7 +5338,7 @@ class SubsonicHandler { */ private async resolveDislikeTarget(username: string, id: string): Promise<{ infos: Array<{ name: string, singer: string }> - album?: { albumName: string, singers: string[] } + album?: { albumName: string, singers: string[], source?: string } singer?: { name: string, source: string, singerId: string } keys: Set }> { @@ -5206,7 +5358,7 @@ class SubsonicHandler { } const identity = await this.resolveSingerIdentity(username, name, preset) - const infos = identity.name ? [{ name: '', singer: identity.name }] : [] + const infos = identity.name ? [{ name: '', singer: identity.name, source: identity.source, provenance: { channel: 'subsonic-rating', at: Date.now() } }] : [] return { infos, singer: identity, @@ -5237,7 +5389,7 @@ class SubsonicHandler { if (!albumName) return { infos: [], keys: new Set() } const singers = [...singerSet] const keys = new Set(singers.map(s => encodeAlbumRule(albumName, s))) - const album = { albumName, singers } + const album = { albumName, singers, source } return { infos: [], album, keys } } @@ -5251,16 +5403,14 @@ class SubsonicHandler { singer: hit.music.singer || '', source: hit.music.source, interval: hit.music.interval, + provenance: { channel: 'subsonic-rating', at: Date.now() }, pic: (hit.music as any).img || (hit.music as any).meta?.picUrl || undefined, }] : [] let singerIdentity: { name: string, source: string, singerId: string } | undefined if (hit?.music) { const rawSingerId = (hit.music as any).singerId - singerIdentity = await this.resolveSingerIdentity( - username, - (hit.music.singer || '').split('、')[0] || '', - rawSingerId ? { source: hit.music.source, id: String(rawSingerId) } : undefined, - ) + // 歌曲规则已有歌曲身份,不为附带歌手资料在线回源,避免补同步被无关超时丢弃。 + singerIdentity = { name: (hit.music.singer || '').split('、')[0] || '', source: hit.music.source, singerId: String(rawSingerId || '') } } return { infos, @@ -5281,7 +5431,7 @@ class SubsonicHandler { return filterDisliked(musics, ruleSet, (m) => ({ name: m.name, singer: m.singer, - source: m.source, + source: (m.meta as any)?.localTrack?.platformId?.split('_')[0] || m.source, albumId: (m as any).meta?.albumId ?? (m as any).albumId ?? (m as any).album?.id, albumName: (m as any).meta?.albumName, }), { crossSource, duetMode, normalizeName, requireSinger }) @@ -5296,60 +5446,207 @@ class SubsonicHandler { return entries.filter(e => keptIds.has(e.music.id)) } + /** + * 手动补数据(评星 → 不喜欢):对「我的评分」里落在 dislike 区间的低星歌曲补写原生 dislike 规则。 + * 语义等价于重放一次 setRating 的正向联动,但只补不删;与快照导入/回滚完全解耦, + * 仅由管理员在 Subsonic 配置页点击「同步」时触发。 + */ + async syncRatingsToDislike(username: string, preview = false): Promise { + const threshold = Number(global.lx.config['subsonic.dislikeRating'] ?? 1) + const summary: LinkSyncSummary = { + direction: 'ratingToDislike', user: username, + scanned: 0, added: 0, skipped: 0, failed: 0, threshold, batches: 0, + } + if (threshold <= 0) return summary + + const meta = readSubsonicMeta(username) + const ratings = meta.ratings || {} + const lowRatedIds = Object.keys(ratings).filter(id => + typeof ratings[id] === 'number' && ratings[id] > 0 && ratings[id] <= threshold) + for (let i = lowRatedIds.length - 1; i >= 0; i--) { if (await resolveStoredRatingId(username, lowRatedIds[i]) !== lowRatedIds[i]) lowRatedIds.splice(i, 1) } + summary.scanned = lowRatedIds.length + summary.candidates = lowRatedIds.map(id => ({ id, source: id.replace(/^(alb_|art_)/, '').split('_')[0] })) + if (preview) return summary + if (lowRatedIds.length === 0) return summary + + const userSpace = getUserSpace(username) as any + // 全量分批:每批解析后批量写入,直到所有低星歌曲都处理完;绝不因条数上限截断 + try { + for (let start = 0; start < lowRatedIds.length; start += LINK_SYNC_BATCH_SIZE) { + const batch = lowRatedIds.slice(start, start + LINK_SYNC_BATCH_SIZE) + summary.batches++ + const infos: any[] = [] + const albumInfos: any[] = [] + // 单条解析可能在线回源,逐条设超时;失败只记数,不中断后续批次 + for (const id of batch) { + try { + const target = await withTimeout(this.resolveDislikeTarget(username, id), LINK_SYNC_ITEM_TIMEOUT_MS) + if (!target.infos.length && (!target.album || !target.album.singers.length)) { summary.failed++; continue } + for (const info of target.infos) infos.push({ ...info, provenance: { channel: 'manual-rating-to-dislike', at: Date.now(), ratingId: id } }) + if (target.album) { + for (const singer of target.album.singers) albumInfos.push({ albumName: target.album.albumName, source: target.album.source, singer, provenance: { channel: 'manual-rating-to-dislike', at: Date.now(), ratingId: id } }) + } + } catch (e) { + summary.failed++ + subsonicLog.warn(`[Subsonic] 联动同步:解析评分歌曲 ${id} 失败`, e) + } + } + if (infos.length === 0 && albumInfos.length === 0) continue + + try { + // addDislikeInfo / addDislikeAlbums 自带按规则行去重,只补不删 + const before = userSpace.dislikeManage.dislikeDataManage.getDislikeRulesString() as string + const beforeData = JSON.stringify(userSpace.dislikeManage.dislikeDataManage.dislikeRules) + await userSpace.dislikeManage.dislikeDataManage.addDislikeInfo(infos) + if (albumInfos.length > 0) await userSpace.dislikeManage.dislikeDataManage.addDislikeAlbums(albumInfos) + const after = userSpace.dislikeManage.dislikeDataManage.getDislikeRulesString() as string + const beforeItems = new Map((JSON.parse(beforeData || '{}').dislikeList || []).map((x: any) => [`${x.source || '*'}::${x.dislikeRule}`, JSON.stringify(x)])) + const changedExisting = (userSpace.dislikeManage.dislikeDataManage.dislikeRules?.dislikeList || []).filter((x: any) => { const previous = beforeItems.get(`${x.source || '*'}::${x.dislikeRule}`); return previous && previous !== JSON.stringify(x) }).length + summary.updated = (summary.updated || 0) + changedExisting + const added = Math.max(0, countDislikeRules(after) - countDislikeRules(before)) + summary.added += added + summary.skipped += Math.max(0, infos.length + albumInfos.length - added) + } catch (e) { + // 单批写入失败也不能中断后续批次,否则会变成「只处理部分」 + summary.failed += infos.length + albumInfos.length + subsonicLog.warn(`[Subsonic] 联动同步:第 ${summary.batches} 批写入 dislike 失败`, e) + } + } + } finally { + // 全部分批结束后统一落盘 + 失效缓存,只生成一份快照,避免按批堆积 + if (summary.added > 0 || summary.updated) { + await userSpace.dislikeManage.createSnapshot() + invalidateDislikeCache(username) + } + } + return summary + } + + /** + * 手动补数据(不喜欢 → 评星):对当前 dislike 规则命中的歌曲补写低星评分。 + * 语义等价于重放一次网页端「不喜欢」的反向联动(1 星),只补不覆盖已有评分。 + */ + async syncDislikesToRating(username: string, preview = false): Promise { + return withRatingIdentityLock(username, () => this.syncDislikesToRatingUnlocked(username, preview)) + } + private async syncDislikesToRatingUnlocked(username: string, preview = false): Promise { + const threshold = Number(global.lx.config['subsonic.dislikeRating'] ?? 1) + const summary: LinkSyncSummary = { + direction: 'dislikeToRating', user: username, + scanned: 0, added: 0, skipped: 0, failed: 0, threshold, batches: 0, + } + + // 与评星歌单同源:用户歌单 + 已缓存的在线歌曲,纯内存规则匹配,不在线回源 + const listData = await this.getSubsonicListData(username) + const all = new Map() + for (const m of [ + ...listData.loveList, + ...listData.defaultList, + ...listData.userList.flatMap(l => (l.list || []) as LX.Music.MusicInfo[]), + ]) all.set(m.id, m) + this.loadOnlineSongCache() + for (const [id, m] of this.onlineSongCache) if (!all.has(id)) all.set(id, m) + // 同样补充未收藏的本地歌曲,避免手动同步漏掉本地曲目的 dislike 规则 + for (const t of await getLocalTracks(username)) if (!all.has(t.id)) all.set(t.id, localMusic(t)) + const candidates = Array.from(all.values()) + summary.scanned = candidates.length + if (candidates.length === 0) return summary + summary.batches = 1 + + const kept = await this.filterDislikedSongs(username, candidates) + const keptIds = new Set(kept.map(m => m.id)) + const hits = candidates.filter(m => !keptIds.has(m.id)) + const meta = readSubsonicMeta(username) + const unique = new Map() + summary.existing = []; summary.duplicates = []; summary.failures = [] + for (const music of hits) { + try { + const id = await resolveStoredRatingId(username, music.id) + const row = { id, name: music.name, source: id.startsWith('local_') ? 'local' : id.split('_')[0] } + if (unique.has(id)) { summary.duplicates.push(row); continue } + unique.set(id, music) + if (meta.ratings[id] > 0) summary.existing.push(row) + } catch { summary.failures.push({ id: music.id, name: music.name, stage: 'identity' }); summary.failed++ } + } + summary.candidates = [...unique].filter(([id]) => !meta.ratings[id]).map(([id, music]) => ({ id, name: music.name, source: id.startsWith('local_') ? 'local' : id.split('_')[0] })) + summary.skipped = summary.existing.length + summary.duplicates.length + if (preview) return summary + for (const candidate of summary.candidates) { + try { + const saved = await writeRatingUnlocked(username, candidate.id, 1, 'manual-dislike-to-rating', unique.get(candidate.id), { onlyMissing: true, deferSnapshot: true }) + if (saved.unchanged) summary.skipped++; else summary.added++ + if (saved.snapshotWarning) summary.warning = saved.snapshotWarning + this.userRatingsCache.set(username, saved.meta.ratings) + } catch { recordRatingBackfillFailure(username,candidate.id,'manual-dislike-to-rating'); summary.failed++; summary.failures.push({ ...candidate, stage: 'write' }) } + } + if (summary.added) summary.warning = await flushRatingSnapshot(username) || summary.warning + + return summary + } + /** * 处理 Subsonic setRating:写回评分,并按配置把低分(<=阈值)联动为「不喜欢」。 */ + async reconcileRatingDislikes(username: string, preview = true) { + return withRatingIdentityLock(username, () => this.reconcileRatingDislikesUnlocked(username, preview)) + } + private async reconcileRatingDislikesUnlocked(username: string, preview = true) { + const dm = (getUserSpace(username) as any).dislikeManage + const ratings = readSubsonicMeta(username).ratings + const threshold = Number(global.lx.config['subsonic.dislikeRating'] ?? 1) + const valid = new Set() + for (const [id, value] of Object.entries(ratings) as [string, number][]) { + if (value > 0 && value <= threshold && await resolveStoredRatingId(username, id).catch(() => id) === id) valid.add(id) + } + const candidates = await this.syncRatingsToDislike(username, true) + if (preview) return { ...candidates, ...dm.dislikeDataManage.reconcileRatingReasons(valid, true) } + await dm.createSnapshot() // 先保留可回滚的当前规则 + const removed = dm.dislikeDataManage.reconcileRatingReasons(valid, false) + await dm.createSnapshot() + invalidateDislikeCache(username) + const added = await this.syncRatingsToDislike(username, false) + return { ...added, ...removed } + } + + async applyRatingLink(username: string, id: string, rating: number, song?: any, channel = 'web-rating-link') { + const threshold = global.lx.config['subsonic.dislikeRating'] ?? 1 + if (!global.lx.config['subsonic.linkRatingToDislike'] || threshold <= 0 || rating <= 0 || rating > threshold) return + const target = await this.resolveDislikeTarget(username, id) + if (!target.infos.length && !target.album && song && !song.isLocal && !song._localFilename && !id.startsWith('local_')) { + if (!song.name || !song.singer) throw new Error('缺少歌曲资料') + const info = { id, name: song.name, singer: song.singer, source: song.source, interval: song.interval, provenance: { channel, at: Date.now(), ratingId: id } } + await this.addDislikeRules(username, [info]) + return + } + if (!target.infos.length && !target.album) throw new Error('无法解析不喜欢目标') + await this.addDislikeRules(username, target.infos.map(x => ({ ...x, provenance: { channel, at: Date.now(), ratingId: id } }))) + if (target.album) await this.addDislikeAlbum(username, target.album, id, channel) + } + + async getRatingMetadata(username: string, id: string): Promise { + if (/^(alb_|art_|artist_|lalb_|lart_)/.test(id)) { + const target = await withTimeout(this.resolveDislikeTarget(username,id),LINK_SYNC_ITEM_TIMEOUT_MS) + return target.album ? {name:target.album.albumName,singer:target.album.singers.join('、'),source:target.album.source} : target.infos[0] ? {name:target.infos[0].singer,source:(target.infos[0] as any).source} : undefined + } + return (await withTimeout(this.resolveSongMeta(username,id),LINK_SYNC_ITEM_TIMEOUT_MS))?.music + } + private async handleSetRating(res: http.ServerResponse, username: string, params: URLSearchParams, format: string) { const id = params.get('id') if (!id) return this.sendError(res, 10, 'Required parameter is missing: id', format) - const rating = parseInt(params.get('rating') || '0', 10) - if (isNaN(rating) || rating < 0 || rating > 5) { + const rating = Number(params.get('rating') || '0') + if (!Number.isInteger(rating) || rating < 0 || rating > 5) { return this.sendError(res, 0, 'Rating must be between 0 and 5', format) } - // 读-改-写整体串行化,避免与并发的 star / dislike 联动互相覆盖 - await withMetaLock(username, async () => { - const meta = await this.getUserSubsonicMeta(username) - if (rating === 0) { - delete meta.ratings[id] - } else { - meta.ratings[id] = rating - } - await this.saveUserSubsonicMeta(username, meta) - // [修复] 立即刷新内存评星缓存,保证本次请求之后的歌曲序列化能带上最新 userRating - this.userRatingsCache.set(username, meta.ratings || {}) - }) - subsonicLog.debug(`[Subsonic] setRating ${id} -> ${rating} (user=${username})`) - - // [dislike 联动] 0 < rating <= dislikeRating 视为「不喜欢」,写回 lx-music 原生 dislike 规则; - // 提高评分或清除评分则移除此前写入的规则。需 linkRatingToDislike 开启才联动,否则仅记录评分。 - const threshold = global.lx.config['subsonic.dislikeRating'] ?? 1 - if (global.lx.config['subsonic.linkRatingToDislike'] && threshold > 0) { - try { - const target = await this.resolveDislikeTarget(username, id) - // [调试] 打印解析到的维度与身份信息,便于确认各入口能拿到什么 - if (global.lx.config['subsonic.enableDebug']) { - subsonicLog.debug(`[Subsonic] dislike 解析 id=${id}`, JSON.stringify({ - dimension: target.album ? 'album' : (id.startsWith('art_') || id.startsWith('artist_') ? 'singer' : 'song'), - song: target.infos, - singer: target.singer || null, - album: target.album || null, - keys: Array.from(target.keys), - })) - } - if (rating > 0 && rating <= threshold) { - let added = await this.addDislikeRules(username, target.infos) - if (target.album) added += await this.addDislikeAlbum(username, target.album) - subsonicLog.debug(`[Subsonic] setRating 联动 dislike:写入 ${added} 条规则 (id=${id}, user=${username})`) - } else { - const removed = await this.removeDislikeRules(username, target.keys) - if (removed > 0) { - subsonicLog.debug(`[Subsonic] setRating 联动 dislike:移除 ${removed} 条规则 (id=${id}, user=${username})`) - } - } - } catch (e) { - subsonicLog.error('[Subsonic] setRating 联动 dislike 失败:', e) - } + const resolved = rating > 0 ? await this.getRatingMetadata(username,id).catch(() => undefined) : undefined + const saved = await writeRating(username, id, rating, 'subsonic', resolved) + this.userRatingsCache.set(username, saved.meta.ratings) + try { await this.applyRatingLink(username, saved.id, rating, undefined, 'subsonic-rating') } + catch (e) { + subsonicLog.error('[Subsonic] setRating 联动 dislike 失败:', e) + return this.sendError(res, 0, '评分已保存,但不喜欢同步失败;请重试或手动补同步', format) } + if (saved.snapshotWarning) return this.sendError(res, 0, saved.snapshotWarning, format) return this.sendResponse(res, {}, format) } @@ -5514,13 +5811,25 @@ class SubsonicHandler { return this.renderRandomSongs(res, picked, format, rootKey, username) } + private async filterRecommendationEntries(username: string, entries: { music: LX.Music.MusicInfo, listId: string }[]) { + if (!global.lx.config['subsonic.dislikeNoRecommend']) return entries + const visible = await this.filterDislikedEntries(username, entries) + const meta = await this.getUserSubsonicMeta(username) + const threshold = Number(global.lx.config['subsonic.dislikeRating'] ?? 1) + return visible.filter(({ music }) => { + const track = (music.meta as any)?.localTrack + const rating = meta.ratings[track ? localRatingId(track) : music.id] || 0 + return !(threshold > 0 && rating > 0 && rating <= threshold) + }) + } + private async renderRandomSongs(res: http.ServerResponse, picked: { music: LX.Music.MusicInfo, listId: string }[], format: string, rootKey: string = 'randomSongs', username?: string) { // [dislike] 随机歌曲 / 流派歌曲 / 每日推荐等入口共用此渲染。 // 这里受 dislikeNoRecommend 控制(与列表剔除 hideDisliked 相互独立): // 即使不隐藏歌曲,也可以让它们不再出现在推荐里。 let list = picked if (username && global.lx.config['subsonic.dislikeNoRecommend']) { - list = await this.filterDislikedEntries(username, picked) + list = await this.filterRecommendationEntries(username, picked) } if (format === 'json') { @@ -5553,16 +5862,9 @@ class SubsonicHandler { ) { const size = Math.min(parseInt(params.get('size') || '20'), 100) try { - // 取当天全部候选(最多 100),再按「不喜欢」阈值排除:评分落在 (0, threshold] 视为不喜欢。 - // 阈值取自 subsonic.dislikeRating(可配置),避免此处硬编码导致管理员改阈值后语义不一致。 const all = await fetchRecommendedSongs(100) - const meta = await this.getUserSubsonicMeta(username) - const threshold = global.lx.config['subsonic.dislikeRating'] ?? 1 - const songs = all.filter((s: any) => { - const r = meta.ratings[s.id] || 0 - return !(threshold > 0 && r > 0 && r <= threshold) - }).slice(0, size) - const picked = songs.map((s: any) => ({ music: s, listId: 'recommended' })) + const candidates = all.map((music: any) => ({ music, listId: 'recommended' })) + const picked = (await this.filterRecommendationEntries(username, candidates)).slice(0, size) return this.renderRandomSongs(res, picked, format, 'recommendedSongs', username) } catch (e) { subsonicLog.error('[Subsonic] getRecommendedSongs 出错:', e) @@ -5635,7 +5937,7 @@ class SubsonicHandler { // [dislike] 相似歌曲属于推荐性质,受 dislikeNoRecommend 控制 if (global.lx.config['subsonic.dislikeNoRecommend']) { - picked = await this.filterDislikedEntries(username, picked) + picked = await this.filterRecommendationEntries(username, picked) } return picked } @@ -6222,10 +6524,10 @@ class SubsonicHandler { // 命中缓存但客户端音频格式与缓存容器不一致 → 走服务端转码(需 subsonic.transcode.enabled 开启,否则发原始缓存文件) if (this.shouldTranscodeFormat(params, c.filename) && global.lx.config['subsonic.transcode.enabled']) { const tcBitrate = maxBitrate === 0 ? 320 : maxBitrate - await this.transcodeStream(req, res, c.filename, tcBitrate, format, timeOffsetSec, this.getRequestedAudioFormat(params)) + await this.transcodeStream(req, res, c.path || c.filename, tcBitrate, format, timeOffsetSec, this.getRequestedAudioFormat(params)) return } - return serveCacheFile(req, res, c.filename, username) + return serveCacheFile(req, res, c.filename, username, c.folder === 'music' ? 'music' : 'cache') } } } @@ -6247,8 +6549,9 @@ class SubsonicHandler { // OpenList 命中后交付:本地缓存未命中但 OpenList 已有该文件时, // 按「交付方式」配置决定:direct=302 直链(客户端直连,省服务器出向流量);proxy=服务端代理转发(兼容不支持跳转的客户端)。 // 仅在「缓存优先 + 优先使用 OpenList」同时开启时生效。 - if (method === 'stream' || method === 'download') { + if ((method === 'stream' || method === 'download') && global.lx.config['subsonic.playCacheFirst'] !== false && global.lx.config['subsonic.playCacheFirstUseOpenlist'] === true) { let openlistHit: Awaited> = null + const playbackSignal = AbortSignal.timeout(4000) const cloudQualities = this.getCacheProbeOrder(quality, maxBitrate).qualities if (global.lx.config['subsonic.transcode.enabled']) { for (const q of this.getCacheProbeOrder('flac', 0).qualities) if (!cloudQualities.includes(q)) cloudQualities.push(q) @@ -6256,7 +6559,7 @@ class SubsonicHandler { for (const q of cloudQualities) { for (const folder of ['cache', 'music'] as const) { const openlistBase = getFileName(musicInfo, q, folder === 'music', username) - openlistHit = await resolveOpenlistCacheEntry(username, folder, openlistBase, { id: musicInfo.id || id, source, quality: q }) + openlistHit = await resolveOpenlistCacheEntry(username, folder, openlistBase, { id: musicInfo.id || id, source, quality: q }, playbackSignal) if (openlistHit) break } if (openlistHit) break @@ -7436,7 +7739,7 @@ class SubsonicHandler { // [dislike] 热门歌曲同样过滤 if (global.lx.config['subsonic.hideDisliked']) { - picked = await this.filterDislikedEntries(username, picked) + picked = await this.filterRecommendationEntries(username, picked) } // [修复] 尊重客户端的 count:SDK 分支此前会把抓到的全部返回(最多 500 首) picked = picked.slice(0, count) @@ -7626,8 +7929,9 @@ class SubsonicHandler { singer: params.get('artist') || '' } as any + const localLyric = lookupLocalLyrics(musicMeta, username) let hash = (musicMeta as any).hash || (musicMeta as any).meta?.hash || '' - if (source === 'kg' && !hash) { + if (!localLyric.exists && source === 'kg' && !hash) { try { const title = musicMeta.name || params.get('title') || params.get('name') || songmid const searchRes = await musicSdk.kg.musicSearch.search(title, 1, 5) @@ -7652,8 +7956,7 @@ class SubsonicHandler { lrcUrl: (musicMeta as any).lrcUrl || (musicMeta as any).meta?.lrcUrl || '', } - const requestObj = musicSdk[source].getLyric(songInfo) - const lyricInfo = await requestObj.promise + const lyricInfo = localLyric.exists ? localLyric.content : await musicSdk[source].getLyric(songInfo).promise const rawLrc = lyricInfo.lyric || '' const transLrc = lyricInfo.tlyric || '' @@ -7764,4 +8067,21 @@ class SubsonicHandler { } } +/** 歌单/列表类内存缓存统一失效:回滚等修改权威歌单数据后调用,避免 Subsonic API 在 TTL 内返回陈旧数据 */ +export function invalidateSubsonicPlaylistCache(): void { + playlistDetailCache.clear() + playlistRadioCache = null + sharedPlaylistCoverCache.clear() + leaderboardCoverCache.clear() +} + export const subsonicHandler = new SubsonicHandler() + +/** 管理端「联动数据同步」按钮入口:仅手动触发,不参与快照导入/回滚链路 */ +export function syncRatingsToDislike(username: string, preview = false): Promise { + return subsonicHandler.syncRatingsToDislike(username, preview) +} + +export function syncDislikesToRating(username: string, preview = false): Promise { + return subsonicHandler.syncDislikesToRating(username, preview) +} diff --git a/src/server/music/audioProbe.ts b/src/server/music/audioProbe.ts new file mode 100644 index 00000000..d01d36ff --- /dev/null +++ b/src/server/music/audioProbe.ts @@ -0,0 +1,11 @@ +/** Reject error pages and tiny placeholder responses when advertising a quality. */ +export function getVerifiedAudioSize(status: number, headers: Record): number | null { + if (status !== 200 && status !== 206) return null + const header = (name: string) => String(headers[name] || '').toLowerCase() + const type = header('content-type').split(';')[0].trim() + if (!type.startsWith('audio/') && !['application/octet-stream', 'binary/octet-stream', 'application/ogg'].includes(type)) return null + const range = header('content-range').match(/^bytes \d+-\d+\/(\d+)$/) + if (status === 206 && !range) return null + const size = Number(range ? range[1] : header('content-length')) + return Number.isSafeInteger(size) && size >= 1024 ? size : null +} diff --git a/src/server/music/cacheCleanup.ts b/src/server/music/cacheCleanup.ts new file mode 100644 index 00000000..2ceeece5 --- /dev/null +++ b/src/server/music/cacheCleanup.ts @@ -0,0 +1,111 @@ +import fs from 'fs' +import path from 'path' + +export const CACHE_MEDIA_EXTENSIONS = new Set(['.mp3', '.flac', '.m4a', '.ogg', '.wav', '.ape', '.aac', '.opus', '.wma', '.alac', '.aiff', '.aif', '.lrc']) + +// Async filesystem traversal keeps large cache trees off the server's synchronous hot path. +export const collectCacheMediaAsync = async (directory: string) => { + const files: Array<{ path: string, size: number, used: number, mtime: number, ino: number }> = [] + const walk = async (dir: string): Promise => { + try { + if ((await fs.promises.lstat(dir)).isSymbolicLink()) return + for (const entry of await fs.promises.readdir(dir, { withFileTypes: true })) { + const fullPath = path.join(dir, entry.name) + if (entry.isDirectory()) await walk(fullPath) + else if (entry.isFile() && CACHE_MEDIA_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) { + try { + const stat = await fs.promises.lstat(fullPath) + if (stat.isFile()) files.push({ path: fullPath, size: stat.size, used: Math.max(stat.atimeMs, stat.mtimeMs), mtime: stat.mtimeMs, ino: stat.ino }) + } catch (error: any) { if (!['ENOENT', 'ENOTDIR'].includes(error.code)) throw error } + } + } + } catch (error: any) { if (!['ENOENT', 'ENOTDIR'].includes(error.code)) throw error } + } + await walk(directory) + return files +} + +// A transfer owns its audio/lyric group until completion, followed by a short handoff window. +export class CacheUsageGuard { + private targets = new Map() + constructor(private now = Date.now) {} + acquire(file: string) { + const key = path.resolve(file).slice(0, -path.extname(file).length) + const state = this.targets.get(key) || { active: 0, until: 0 } + state.active++ + this.targets.set(key, state) + let released = false + return () => { + if (released) return + released = true + state.active-- + state.until = this.now() + 30000 + setTimeout(() => { + if (this.targets.get(key) === state && !state.active && state.until <= this.now()) this.targets.delete(key) + }, 31000).unref() + } + } + protects(file: string) { + const resolved = path.resolve(file).slice(0, -path.extname(file).length) + let protectedFile = false + for (const [target, state] of this.targets) { + if (!state.active && state.until <= this.now()) { this.targets.delete(target); continue } + if (resolved === target) protectedFile = true + } + return protectedFile + } +} + +export const cleanupCacheDirectory = async (directory: string, options: { + limitBytes?: number + retentionMs?: number + protectedPaths: Set + refreshProtectedPaths?: () => Set + isProtected?: (file: string) => boolean + shouldStop?: () => boolean + onDeleted: (file: string) => void + now?: number +}) => { + const files = await collectCacheMediaAsync(directory) + const groups = new Map() + for (const file of files) { + const key = file.path.slice(0, -path.extname(file.path).length) + const group = groups.get(key) || { files: [], used: 0 } + group.files.push(file) + group.used = Math.max(group.used, file.used) + groups.set(key, group) + } + let totalSize = files.reduce((sum, file) => sum + file.size, 0) + const overLimit = options.limitBytes !== undefined && totalSize > options.limitBytes + const target = overLimit ? options.limitBytes! * 0.95 : Infinity + const now = options.now ?? Date.now() + let deletedCount = 0 + let batch = 0 + let protectedPaths = options.protectedPaths + for (const { files: group, used } of [...groups.values()].sort((a, b) => a.used - b.used)) { + if (batch++ % 32 === 0) { + await new Promise(resolve => setImmediate(resolve)) + protectedPaths = options.refreshProtectedPaths?.() ?? protectedPaths + } + if (options.shouldStop?.()) break + const expired = !!options.retentionMs && now - used > options.retentionMs + if (!expired && !(overLimit && totalSize > target)) continue + if (group.some(file => protectedPaths.has(path.resolve(file.path)) || options.isProtected?.(file.path))) continue + // Each small deletion group is synchronous: no backup/download can start between protection and unlink. + const current = group.map(file => { + try { return fs.lstatSync(file.path) } catch { return undefined } + }) + if (group.some((file, i) => { + const stat = current[i] + return !stat?.isFile() || stat.ino !== file.ino || stat.size !== file.size || stat.mtimeMs !== file.mtime || + (!(overLimit && totalSize > target) && now - Math.max(stat.atimeMs, stat.mtimeMs) <= options.retentionMs!) + })) continue + for (const file of group.sort((a, b) => Number(path.extname(a.path).toLowerCase() === '.lrc') - Number(path.extname(b.path).toLowerCase() === '.lrc'))) { + try { fs.unlinkSync(file.path) } catch { break } + totalSize -= file.size + deletedCount++ + options.onDeleted(file.path) + } + } + return { totalSize, deletedCount } +} diff --git a/src/server/music/customMusicManager.ts b/src/server/music/customMusicManager.ts index 69e94d1a..be7d2311 100644 --- a/src/server/music/customMusicManager.ts +++ b/src/server/music/customMusicManager.ts @@ -1,3 +1,4 @@ +import { withRatingIdentityLock } from '../rating/ratingIdentityLock' import fs from 'fs' import path from 'path' import http from 'http' @@ -6,7 +7,7 @@ import crypto from 'crypto' const { MusicTagger, MetaPicture } = require('music-tag-native') import { getUserConfig } from '@/user' import { formatPlayTime } from '@/utils/common' -import { buildLyrics } from '@/utils/lrcTool' +import { buildLyrics, parseLyrics } from '@/utils/lrcTool' import { getLyricFetcher, embedLyricsIntoFile, getAudioMetadataUnsupportedStatus } from './fileCache' import * as fileCache from './fileCache' import { remasterStaging } from '../task/remasterStaging' @@ -104,6 +105,19 @@ const detectQualityFromBitrate = (bitrate: number | undefined, ext: string, tagg class CustomIndexManager { private indexes: Map> = new Map() // username -> (compositeKey -> CustomCacheItem) + private loadedVersions = new Map() + + getVersion(username: string): string { + const file = this.getIndexFilePath(username) + let stamp = 'missing' + if (file) { + try { + const stat = fs.statSync(file, { bigint: true }) + stamp = `${stat.mtimeNs}:${stat.ctimeNs}:${stat.size}:${stat.ino}` + } catch (error: any) { if (error.code !== 'ENOENT') throw error } + } + return crypto.createHash('sha256').update(JSON.stringify([file, stamp])).digest('hex') + } private getIndexFilePath(username: string): string | null { const customDir = getCustomMusicDir(username) @@ -113,46 +127,61 @@ class CustomIndexManager { load(username: string): Map { const file = this.getIndexFilePath(username) + const version = this.getVersion(username) + const map = this.indexes.get(username) || new Map() + if (this.loadedVersions.get(username) === version) return map if (!file || !fs.existsSync(file)) { - const empty = new Map() - this.indexes.set(username, empty) - return empty - } - try { + map.clear() + } else { + // Parse before changing the live Map; failed reads must not erase valid data. const data = JSON.parse(fs.readFileSync(file, 'utf-8')) - const map = new Map(Object.entries(data)) - this.indexes.set(username, map) - return map - } catch (e) { - const empty = new Map() - this.indexes.set(username, empty) - return empty + const entries = Object.entries(data) as [string, CustomCacheItem][] + map.clear() + for (const [key, item] of entries) map.set(key, item) } + this.indexes.set(username, map) + this.loadedVersions.set(username, version) + return map } save(username: string) { const file = this.getIndexFilePath(username) const index = this.indexes.get(username) if (!file || !index) return + const temporary = `${file}.${crypto.randomUUID()}.tmp` try { const obj = Object.fromEntries(index) - fs.writeFileSync(file, JSON.stringify(obj, null, 2), 'utf-8') + fs.writeFileSync(temporary, JSON.stringify(obj), { encoding: 'utf-8', flag: 'wx' }) + fs.renameSync(temporary, file) + this.loadedVersions.set(username, this.getVersion(username)) } catch (e) { + try { fs.unlinkSync(temporary) } catch { /* already committed or not created */ } console.error(`[自定义音乐] 为用户 ${username} 保存自定义索引失败:`, e) + throw e } } getAll(username: string): CustomCacheItem[] { - const index = this.indexes.get(username) || this.load(username) + const index = this.load(username) return Array.from(index.values()) } + getItems(username: string, filenames: string[]): CustomCacheItem[] { + const index = this.load(username) + const items: CustomCacheItem[] = [] + for (const filename of new Set(filenames)) { + const item = index.get(filename) + if (item) items.push(item) + } + return items + } + set(username: string, key: string, item: CustomCacheItem) { const index = this.indexes.get(username) || this.load(username) index.set(key, item) } - remove(username: string, filenameOrKey: string): boolean { + remove(username: string, filenameOrKey: string, persist = true): boolean { const index = this.indexes.get(username) || this.load(username) let deleted = false for (const [k, item] of Array.from(index.entries())) { @@ -161,26 +190,31 @@ class CustomIndexManager { deleted = true } } - if (deleted) this.save(username) + if (deleted && persist) this.save(username) return deleted } } export const customIndexManager = new CustomIndexManager() +export const getCustomMusicVersion = (username: string) => customIndexManager.getVersion(username) // 同步自定义目录下的歌曲并生成/更新 custom_index.json // 递归扫描目录下的所有音频文件(支持任意深层子目录) -const scanAllFilesRecursively = async (baseDir: string, currentDir: string = baseDir): Promise<{ relativePath: string; fullPath: string }[]> => { +const scanAllFilesRecursively = async (baseDir: string) => { const extensions = ['.mp3', '.flac', '.m4a', '.ogg', '.wav', '.ape'] - let results: { relativePath: string; fullPath: string }[] = [] - try { + const results: { relativePath: string; fullPath: string }[] = [] + const indexFiles: string[] = [] + const visit = async (currentDir: string) => { const entries = await fs.promises.readdir(currentDir, { withFileTypes: true }) + // Parent indexes precede child indexes, preserving deeper association priority. + for (const name of ['music_index.json', 'cache_index.json', 'custom_index.json']) { + if (entries.some(entry => entry.isFile() && entry.name === name)) indexFiles.push(path.join(currentDir, name)) + } for (const entry of entries) { if (entry.name.startsWith('.') || entry.name === 'node_modules') continue const fullPath = path.join(currentDir, entry.name) if (entry.isDirectory()) { - const subResults = await scanAllFilesRecursively(baseDir, fullPath) - results = results.concat(subResults) + await visit(fullPath) } else if (entry.isFile()) { const ext = path.extname(entry.name).toLowerCase() if (extensions.includes(ext)) { @@ -189,10 +223,10 @@ const scanAllFilesRecursively = async (baseDir: string, currentDir: string = bas } } } - } catch (e) { - console.error(`[自定义音乐] 读取目录 ${currentDir} 出错:`, e) } - return results + // A failed directory read must not turn a partial listing into index deletions. + await visit(baseDir) + return { files: results, indexFiles } } // 安全解析并检验文件路径,防止路径遍历攻击 @@ -209,68 +243,61 @@ const resolveSafePath = (baseDir: string, relativePath: string): string | null = // 将各索引文件中的 filename 字段重映射为相对于 rootDir 的路径,构建统一的「相对路径 → 关联信息」查找表 // 这样当父目录用户(B)的 customDir 包含子目录用户(A)的 customDir 时, // A 手动关联写入 custom_index.json 的信息也能被 B 正确读取并路径对齐 -const buildLinkedInfoMap = (rootDir: string): Map => { +const buildLinkedInfoMap = async (rootDir: string, indexFiles: string[]): Promise> => { const map = new Map() - const scanDir = (dir: string) => { + for (const fp of indexFiles) { + const dir = path.dirname(fp) // dir 相对于 rootDir 的前缀,用于路径重映射 const prefix = path.relative(rootDir, dir).replace(/\\/g, '/') const addPrefix = (filename: string) => prefix && prefix !== '.' ? `${prefix}/${filename}` : filename - // 读取当前目录下的索引文件 - const indexFiles = ['music_index.json', 'cache_index.json', 'custom_index.json'] - for (const fname of indexFiles) { - const fp = path.join(dir, fname) - if (!fs.existsSync(fp)) continue - try { - const data: Record = JSON.parse(fs.readFileSync(fp, 'utf-8')) - for (const item of Object.values(data)) { - if (!item || !item.filename) continue - // 仅收录有真实来源(非 custom)或已手动关联(有 songmid 且 songmid !== id)的条目 - const isLinked = item.source && item.source !== 'custom' && item.id - const isManualLinked = item.id && item.songmid && item.songmid !== item.id - if (!isLinked && !isManualLinked) continue - // 将条目的 filename 重映射为相对于 rootDir 的路径 - const remappedFilename = addPrefix(item.filename) - // 深层(更靠近文件本身)的索引优先级更高,后扫描的子目录条目覆盖父目录条目 - map.set(remappedFilename, { ...item, filename: remappedFilename }) - } - } catch (e) { - console.warn(`[自定义音乐] 读取 ${fname} 失败(${dir}):`, e) - } - } - - // 递归处理子目录 try { - const entries = fs.readdirSync(dir, { withFileTypes: true }) - for (const entry of entries) { - if (!entry.isDirectory()) continue - if (entry.name.startsWith('.') || entry.name === 'node_modules') continue - scanDir(path.join(dir, entry.name)) + const data: Record = JSON.parse(await fs.promises.readFile(fp, 'utf-8')) + for (const item of Object.values(data)) { + if (!item || !item.filename) continue + // 仅收录有真实来源或已手动关联的条目。 + const isLinked = item.source && item.source !== 'custom' && item.id + const isManualLinked = item.id && item.songmid && item.songmid !== item.id + if (!isLinked && !isManualLinked) continue + const remappedFilename = addPrefix(item.filename) + // 子目录索引覆盖父目录索引。 + map.set(remappedFilename, { ...item, filename: remappedFilename }) } - } catch (e) { /* 无权限或不可读时跳过 */ } + } catch (e) { + console.warn(`[自定义音乐] 读取 ${fp} 失败:`, e) + } } - - scanDir(rootDir) return map } // 同步扫描并更新自定义目录索引 -export const syncCustomIndex = async (username: string) => { +const pendingCustomScans = new Map>() +export const syncCustomIndex = (username: string): Promise => { + const key = JSON.stringify([username, getCustomMusicDir(username)]) + const pending = pendingCustomScans.get(key) + if (pending) return pending + const task = Promise.resolve().then(() => scanCustomIndex(username)).finally(() => { + if (pendingCustomScans.get(key) === task) pendingCustomScans.delete(key) + }) + pendingCustomScans.set(key, task) + return task +} + +const scanCustomIndex = async (username: string) => { const customDir = getCustomMusicDir(username) if (!customDir || !fs.existsSync(customDir)) { throw new Error('用户未开启或未配置自定义音乐目录') } // 尝试从目录内已有的索引文件中借用关联信息 - const linkedInfoMap = buildLinkedInfoMap(customDir) + const { files: scannedFiles, indexFiles } = await scanAllFilesRecursively(customDir) + const linkedInfoMap = await buildLinkedInfoMap(customDir, indexFiles) + if (getCustomMusicDir(username) !== customDir) throw new Error('自定义目录配置已变更,请重新同步') const index = customIndexManager.load(username) - const extensions = ['.mp3', '.flac', '.m4a', '.ogg', '.wav', '.ape'] - - // 递归深层扫描所有子目录中的音频文件 - const scannedFiles = await scanAllFilesRecursively(customDir) + const originalEntries = Array.from(index.entries()) const foundRelPaths = new Set() let updated = false @@ -281,7 +308,13 @@ export const syncCustomIndex = async (username: string) => { foundRelPaths.add(relPath) const ext = path.extname(filePath).toLowerCase() - const stats = await fs.promises.stat(filePath) + let stats: fs.Stats + try { stats = await fs.promises.stat(filePath) } catch (error: any) { + if (error.code === 'ENOENT') continue + throw error + } + if (getCustomMusicDir(username) !== customDir) throw new Error('自定义目录配置已变更,请重新同步') + if (!fs.existsSync(filePath)) continue const subDir = path.dirname(relPath).replace(/\\/g, '/') const subPath = subDir === '.' ? '' : subDir @@ -393,7 +426,9 @@ export const syncCustomIndex = async (username: string) => { } // 清理已从磁盘删除的文件索引及旧格式键 - for (const [k, item] of Array.from(index.entries())) { + for (const [k, item] of originalEntries) { + // A file operation may have replaced this record while the scan awaited I/O. + if (index.get(k) !== item) continue if (!foundRelPaths.has(item.filename) || k !== item.filename) { index.delete(k) updated = true @@ -408,7 +443,7 @@ export const syncCustomIndex = async (username: string) => { } // 获取自定义音乐列表(带 songInfo 转换) -export const getCustomMusicList = async (username: string) => { +export const getCustomMusicList = async (username: string, filenames?: string[]) => { const customDir = getCustomMusicDir(username) if (!customDir) return [] @@ -417,7 +452,7 @@ export const getCustomMusicList = async (username: string) => { await syncCustomIndex(username) } - const items = customIndexManager.getAll(username) + const items = filenames ? customIndexManager.getItems(username, filenames) : customIndexManager.getAll(username) return items.map(item => ({ ...item, songInfo: { @@ -518,7 +553,7 @@ export const serveCustomFile = (req: http.IncomingMessage, res: http.ServerRespo } // 删除自定义目录下的文件 -export const removeCustomFile = (filename: string, username: string): boolean => { +export const removeCustomFile = (filename: string, username: string, persist = true): boolean => { const customDir = getCustomMusicDir(username) if (!customDir) return false @@ -532,7 +567,7 @@ export const removeCustomFile = (filename: string, username: string): boolean => if (fs.existsSync(lrcPath)) { try { fs.unlinkSync(lrcPath) } catch (e) { } } - customIndexManager.remove(username, filename) + customIndexManager.remove(username, filename, persist) // 安全清理变空的父级子目录 try { @@ -572,7 +607,8 @@ export const removeCustomFile = (filename: string, username: string): boolean => } // 手动关联自定义目录歌曲(更新 ID3 标签及 custom_index.json) -export const linkCustomSong = async (filename: string, songInfo: any, username: string) => { +export const linkCustomSong = (filename: string, songInfo: any, username: string) => withRatingIdentityLock(username, () => linkCustomSongUnlocked(filename, songInfo, username)) +const linkCustomSongUnlocked = async (filename: string, songInfo: any, username: string) => { const customDir = getCustomMusicDir(username) if (!customDir) throw new Error('用户未启用或未配置自定义目录') @@ -623,6 +659,8 @@ export const linkCustomSong = async (filename: string, songInfo: any, username: customIndexManager.set(username, filename, updatedItem) customIndexManager.save(username) + require('./subsonicLocal').invalidateLocalCatalog(username) + await require('./subsonicLocal').getLocalTracks(username, false, undefined, true) return { success: true, message: '关联成功', data: updatedItem } } @@ -663,10 +701,14 @@ export const batchUpdateMetadata = async (filenames: string[], username: string) ires.on('error', rejectI) }) req.on('error', rejectI) - setTimeout(() => { req.destroy(); rejectI(new Error('Timeout')) }, 8000) + const timeout = setTimeout(() => { req.destroy(); rejectI(new Error('Timeout')) }, 8000) + req.once('close', () => clearTimeout(timeout)) }).catch(() => undefined) } + if (getCustomMusicDir(username) !== customDir || customIndexManager.load(username).get(filename) !== item) { + throw new Error('歌曲索引已变更,请重试补全元信息') + } let tagger: any try { tagger = new MusicTagger() @@ -680,19 +722,21 @@ export const batchUpdateMetadata = async (filenames: string[], username: string) tagger.save() } catch (e) { console.warn(`[自定义音乐] 批量写入标签失败: ${filename}`, e) + throw e } finally { try { if (tagger) tagger.dispose() } catch (e) { } } if (item) { + const updatedItem = { ...item } if (imageBuffer && imageBuffer.length > 0) { - item.hasCover = true - item.coverType = 'embedded' + updatedItem.hasCover = true + updatedItem.coverType = 'embedded' } const stats = fs.statSync(filePath) - item.mtime = stats.mtimeMs - item.size = stats.size - customIndexManager.set(username, filename, item) + updatedItem.mtime = stats.mtimeMs + updatedItem.size = stats.size + customIndexManager.set(username, filename, updatedItem) } successCount++ } catch (e) { @@ -700,7 +744,7 @@ export const batchUpdateMetadata = async (filenames: string[], username: string) } } - customIndexManager.save(username) + if (successCount) customIndexManager.save(username) return { successCount, failCount } } @@ -757,7 +801,7 @@ export const batchEmbedLyric = async (filenames: string[], username: string) => } else if (item && item.source && item.source !== 'unknown' && item.source !== 'local' && item.source !== 'custom') { const lyricFetcherFn = getLyricFetcher() if (lyricFetcherFn) { - lyricText = await lyricFetcherFn(item) + lyricText = await lyricFetcherFn(item, username) } } @@ -767,6 +811,9 @@ export const batchEmbedLyric = async (filenames: string[], username: string) => continue } + if (getCustomMusicDir(username) !== customDir || customIndexManager.load(username).get(filename) !== item) { + throw new Error('歌曲索引已变更,请重试嵌入歌词') + } const embedResult = embedLyricsIntoFile(filePath, lyricText) if (!embedResult.success) { details.push({ filename, status: 'fail', reason: embedResult.error || '歌词写入失败' }) @@ -775,8 +822,8 @@ export const batchEmbedLyric = async (filenames: string[], username: string) => } if (item) { - item.hasEmbedLyric = true - customIndexManager.set(username, filename, item) + const stats = fs.statSync(filePath) + customIndexManager.set(username, filename, { ...item, hasEmbedLyric: true, mtime: stats.mtimeMs, size: stats.size }) } details.push({ filename, status: 'success' }) successCount++ @@ -786,11 +833,40 @@ export const batchEmbedLyric = async (filenames: string[], username: string) => } } - customIndexManager.save(username) + if (successCount) customIndexManager.save(username) return { successCount, skippedCount, failCount, details } } // 为自定义目录保存歌词缓存 (.lrc 文件并同步索引) +export const checkCustomLyricCache = (song: any, username: string): { exists: boolean; content?: any } => { + try { + const root = getCustomMusicDir(username) + if (!root) return { exists: false } + const items = Array.from(customIndexManager.load(username).values()) + const item = items.find(item => (song.id && item.id === song.id && (!song.strictIdentity || item.source === song.source)) || (song.source && item.source === song.source && song.songmid && String(item.songmid) === String(song.songmid))) + || (!song.strictIdentity && items.find(item => song.name && song.singer && item.name === song.name && item.singer === song.singer)) + if (!item) return { exists: false } + const file = resolveSafePath(root, item.filename.replace(/\.[^.]+$/, '.lrc')) + if (!file || !fs.existsSync(file)) { + if (song.includeEmbedded) { + const audio = resolveSafePath(root, item.filename) + let tagger: any + try { + if (!audio) return { exists: false } + tagger = new MusicTagger() + tagger.loadPath(audio) + if (tagger.lyrics?.trim()) return { exists: true, content: parseLyrics(tagger.lyrics) } + } finally { try { tagger?.dispose() } catch {} } + } + return { exists: false } + } + const real = fs.realpathSync(file), base = fs.realpathSync(root) + if (!real.startsWith(base + path.sep)) return { exists: false } + const content = parseLyrics(fs.readFileSync(real, 'utf8').replace(/^\uFEFF/, '')) + return content.lyric?.trim() ? { exists: true, content } : { exists: false } + } catch { return { exists: false } } +} + export const saveCustomLyricCache = (songInfo: any, lyricsObj: any, username: string): boolean => { try { const customDir = getCustomMusicDir(username) @@ -825,9 +901,11 @@ export const saveCustomLyricCache = (songInfo: any, lyricsObj: any, username: st if (!formattedLrc) return false fs.writeFileSync(lrcPath, formattedLrc, { encoding: 'utf-8' }) - matchedItem.hasLyric = true - matchedItem.lyricFilename = path.relative(customDir, lrcPath).replace(/\\/g, '/') - customIndexManager.set(username, matchedItem.filename, matchedItem) + customIndexManager.set(username, matchedItem.filename, { + ...matchedItem, + hasLyric: true, + lyricFilename: path.relative(customDir, lrcPath).replace(/\\/g, '/') + }) customIndexManager.save(username) return true } catch (e) { @@ -1018,7 +1096,7 @@ export const replaceCustomMusicItem = async ( // 如果文件名改变,移除旧键并设置新键 if (targetAudioFilename !== currentItem.filename) { - customIndexManager.remove(username, currentItem.filename) + customIndexManager.remove(username, currentItem.filename, false) } customIndexManager.set(username, targetAudioFilename, replacementItem) customIndexManager.save(username) diff --git a/src/server/music/downloadLyrics.ts b/src/server/music/downloadLyrics.ts new file mode 100644 index 00000000..13ee4b19 --- /dev/null +++ b/src/server/music/downloadLyrics.ts @@ -0,0 +1,45 @@ +import { buildLyrics } from '../../utils/lrcTool' + +export function normalizeDownloadLyricSong(song: any) { + const meta = song.meta || {} + const source = String(song.source || meta.source || '') + let songmid = String(song.songmid || song.songId || meta.songmid || meta.songId || song.id || '') + if (source && songmid.startsWith(`${source}_`)) songmid = songmid.slice(source.length + 1) + const normalized = { ...meta, ...song, source, songmid, id: songmid } + for (const key of ['hash', 'interval', 'copyrightId', 'albumId', 'lrcUrl', 'mrcUrl', 'trcUrl']) { + normalized[key] = song[key] || meta[key] || '' + } + return normalized +} + +/** Use the authenticated owner's existing lyrics before requesting the source. */ +export async function resolveSongLyricData(song: any, owner: string, lookup: (song: any, owner: string) => any, sdk: any, preferLocal = true, strictIdentity = true): Promise { + const normalized = normalizeDownloadLyricSong(song) + if (!normalized.source || !normalized.songmid) throw new Error('缺少歌曲平台或 ID') + const local = (includeEmbedded = false) => { + if (!preferLocal) return null + try { + const found = lookup({ ...normalized, strictIdentity, includeEmbedded }, owner) + return found.exists && (found.content?.lyric || found.content?.lrc)?.trim() ? { ...found.content, _fromLocalCache: true } : null + } catch { return null } + } + const cached = local() + if (cached) return cached + try { + if (!sdk[normalized.source]?.getLyric) throw new Error('音源不支持歌词') + const request = sdk[normalized.source].getLyric(normalized) + const result = await (request.promise || request) + if (!(result?.lyric || result?.lrc)?.trim()) throw new Error('音源未返回歌词') + return result + } catch (error) { + const fallback = local(true) + if (fallback) return fallback + throw error + } +} + +export async function resolveDownloadLyrics(song: any, owner: string, lookup: (song: any, owner: string) => any, sdk: any): Promise { + const result = await resolveSongLyricData(song, owner, lookup, sdk) + // Standard timed text remains readable by third-party players; omit the private awlrc payload. + return buildLyrics(result, false) +} diff --git a/src/server/music/fileCache.ts b/src/server/music/fileCache.ts index 80ae6318..237c8aab 100644 --- a/src/server/music/fileCache.ts +++ b/src/server/music/fileCache.ts @@ -1,3 +1,7 @@ +import { AsyncLocalStorage } from 'node:async_hooks' +import { collectCacheMediaAsync, cleanupCacheDirectory, CacheUsageGuard } from './cacheCleanup' +import { withRatingIdentityLock } from '../rating/ratingIdentityLock' +import { beginCloudBackup } from '@/utils/backupPolicy' import { resolveContainedPath } from '../auth/security' import { serveFile } from '../http/serveFile' import { getCacheBrowseLocation } from './cacheBrowseLocation' @@ -152,6 +156,13 @@ export const setUserCacheLocation = (username: string | undefined, location: unk export const cacheProgress: Map = new Map() export const getProgressKey = (songKey: string, username?: string) => JSON.stringify([username && username !== 'default' ? username : '_open', songKey]) +const cacheUsageGuard = new CacheUsageGuard() +const cacheWriteScope = new AsyncLocalStorage void>>() +const protectCachePath = (file: string) => { + const scope = cacheWriteScope.getStore() + const resolved = path.resolve(file) + if (scope && !scope.has(resolved)) scope.set(resolved, cacheUsageGuard.acquire(file)) +} const downloadJobs = new Map; signature: string }>() const expireProgress = (key: string) => { const value = cacheProgress.get(key) @@ -164,7 +175,7 @@ export const activeTasks: Map Promise +type LyricFetcher = (songInfo: any, username?: string) => Promise let _lyricFetcher: LyricFetcher | null = null export const setLyricFetcher = (fn: LyricFetcher) => { _lyricFetcher = fn } @@ -271,6 +282,8 @@ class CacheIndexManager { load(username: string, folder: 'cache' | 'music', location?: string) { const loc = location || getUserCacheLocation(username) const key = this.getKey(username, folder, loc) + const cached = this.indexes.get(key) + if (cached) return cached if (isRemasterStage(username)) { const index = new Map(Object.entries(remasterStaging.readIndex(username, folder))) this.indexes.set(key, index) @@ -291,6 +304,7 @@ class CacheIndexManager { } save(username: string, folder: 'cache' | 'music', location?: string) { + if (global.lx?.restoreMaintenance) return const loc = location || getUserCacheLocation(username) const key = this.getKey(username, folder, loc) const index = this.indexes.get(key) @@ -859,7 +873,11 @@ export const syncCacheIndex = async (username?: string, roots: Array<'cache' | ' if (!extensions.includes(ext)) continue const filePath = path.join(dir, file) - const stats = await fs.promises.stat(filePath) + let stats: Stats + try { stats = await fs.promises.stat(filePath) } catch (error: any) { + if (['ENOENT', 'ENOTDIR'].includes(error.code)) continue + throw error + } // Try to find if this file is already known in index by its filename let existingEntry = filenameToItemMap.get(file) @@ -1082,6 +1100,8 @@ export const syncCacheIndex = async (username?: string, roots: Array<'cache' | ' updated = true } + // Cleanup may have removed this file while metadata/lyrics were being inspected. + if (!fs.existsSync(filePath)) continue const compositeKey = `${normalizedId}_${finalQuality || 'unknown'}` foundKeysOnDisk.add(compositeKey) @@ -1365,7 +1385,8 @@ export const batchUpdateMetadata = async (filenames: string[], username: string /** * Link an unindexed local file to a specific online song identity */ -export const linkLocalFile = async (oldFilename: string, songInfo: any, username: string | undefined, targetFolder?: string) => { +export const linkLocalFile = (oldFilename: string, songInfo: any, username: string | undefined, targetFolder?: string) => withRatingIdentityLock(username || '_open', () => linkLocalFileUnlocked(oldFilename, songInfo, username, targetFolder)) +const linkLocalFileUnlocked = async (oldFilename: string, songInfo: any, username: string | undefined, targetFolder?: string) => { const normalizedUsername = (username && username !== '_open' && username !== 'default') ? username : '_open' // Find the item in all possible folders @@ -1433,8 +1454,11 @@ export const linkLocalFile = async (oldFilename: string, songInfo: any, username indexManager.update(normalizedUsername, item, folder) // 3. Post-link processing: Update ID3 tags and cover + require('./subsonicLocal').updateLocalTrackLink(normalizedUsername, dir, oldFilename, newFilename, newId) await batchUpdateMetadata([newFilename], normalizedUsername) + require('./subsonicLocal').invalidateLocalCatalog(normalizedUsername) + await require('./subsonicLocal').getLocalTracks(normalizedUsername) return { success: true, filename: newFilename, @@ -1819,6 +1843,25 @@ export const checkCache = (songInfo: any, username?: string, isLyricCheck: boole return { exists: false } } +export const readCachedEmbeddedLyrics = (songInfo: any, username?: string) => { + const owner = username && username !== 'default' ? username : '_open' + const id = normalizeSongId(songInfo) + for (const folder of ['cache', 'music'] as const) { + const item = indexManager.get(owner, id, folder, songInfo.quality) + if (!item) continue + let tagger: any + try { + const file = resolveContainedPath(getCacheDir(owner, folder === 'music'), item.filename) + tagger = new MusicTagger() + tagger.loadPath(file) + const lyric = tagger.lyrics + if (lyric?.trim()) return { exists: true, content: parseLyrics(lyric) } + } catch { /* Try the next exact-ID file. */ } + finally { try { tagger?.dispose() } catch {} } + } + return { exists: false } +} + export const checkLyricCache = (songInfo: any, username?: string) => { const id = normalizeSongId(songInfo) const normalizedUsername = (username && username !== '_open' && username !== 'default') ? username : '_open' @@ -1843,7 +1886,7 @@ export const checkLyricCache = (songInfo: any, username?: string) => { // [Fix] Index-based name+singer fallback for the simple naming pattern // When the lrc filename does not contain a song ID, match by name + singer from the index - if (songInfo.name && songInfo.singer) { + if (!songInfo.strictIdentity && songInfo.name && songInfo.singer) { const targetName = String(songInfo.name).toLowerCase() const targetSinger = String(songInfo.singer).toLowerCase() for (const folder of folderTypes) { @@ -1898,6 +1941,7 @@ export const checkLyricCache = (songInfo: any, username?: string) => { const fileNameWithoutExt = file.substring(0, file.lastIndexOf('.')) const segments = fileNameWithoutExt.split('_-_') if (segments.length >= 2) { + if (songInfo.strictIdentity && segments[segments.length - 3] !== songInfo.source) continue const fileId = segments[segments.length - 2] const fileCleanId = cleanId(fileId) if (fileId === id || fileCleanId === id || fileId === targetCleanId || fileCleanId === targetCleanId) { @@ -2132,7 +2176,7 @@ const ensureCachedLyrics = async ( } try { - const lyricText = await _lyricFetcher({ ...songInfo, quality: resolvedQuality }) + const lyricText = await _lyricFetcher({ ...songInfo, quality: resolvedQuality }, username) if (!lyricText) return if (shouldCacheLyric && !hasCachedLyric) { @@ -2211,7 +2255,17 @@ export const downloadAndCache = (...args: Parameters { + let releaseWrite: () => void + try { releaseWrite = beginCloudBackup() } catch (error) { return Promise.reject(error) } + const protectedFiles = new Map void>() + const cleanupLocation = getUserCacheLocation(username) + const job = cacheWriteScope.run(protectedFiles, () => downloadAndCacheImpl(...args)).finally(() => { + for (const release of protectedFiles.values()) release() + // Let promise consumers open the file before it becomes eligible for eviction. + if (!onlyDownload && !provenance.customTargetDir && !isRemasterStage(username)) { + setTimeout(() => void cleanupUserCache(username && username !== 'default' ? username : '_open', cleanupLocation, false).catch(error => console.error('[cache] deferred cleanup failed:', error)), 31000).unref() + } + releaseWrite() if (downloadJobs.get(key)?.promise === job) downloadJobs.delete(key) }) // 文件成功落盘后,发一个与 openlist 无关的通用事件,供各模块(如 openlist 备份)订阅 @@ -2239,6 +2293,7 @@ const downloadAndCacheImpl = async (songInfo: any, url: string, quality?: string const sourceName = provenance.sourceName || songInfo.sourceName const result = checkCache({ ...songInfo, quality, exactQuality: true }, username, false) + if (result.path) protectCachePath(result.path) if (result.exists && !result.isCollision) { const targetFolder: 'cache' | 'music' = isOnlyDownload ? 'music' : 'cache' if (result.folder === targetFolder && result.path) { @@ -2259,6 +2314,7 @@ const downloadAndCacheImpl = async (songInfo: any, url: string, quality?: string const ext = inspection.extension || sourceExt const finalBaseName = getFileName(songInfo, actualQuality, isOnlyDownload, username) const finalPath = path.join(dir, finalBaseName + ext) + protectCachePath(finalPath) if (!fs.existsSync(finalPath)) { fs.copyFileSync(result.path, finalPath) } @@ -2356,10 +2412,11 @@ const downloadAndCacheImpl = async (songInfo: any, url: string, quality?: string // 该路径对「原生客户端 / Web / Subsonic」等走 lx-server 的请求统一生效—— // 因为 OpenList 上传备份本身也是全局触发(见 downloadAndCacheImpl 的 fileCachedUploadListeners), // 故「优先使用 OpenList 取回」不限定 Subsonic。是否启用由 openlist 自身开关(enable + 用户备份配置)决定, - // 并受 subsonic.playCacheFirst / subsonic.playCacheFirstUseOpenlist 控制(见 isOpenlistCacheFirstEnabled)。 + // 由 openlist.playback.enable 总开关控制;Subsonic 的入口开关不影响 Web 下载器。 if (username) { const targetFolder: 'cache' | 'music' = isOnlyDownload ? 'music' : 'cache' const restoreResolver = (globalThis as any).lx?.cacheRestoreResolver + protectCachePath(path.join(dir, baseName + '.mp3')) const restored = restoreResolver ? await restoreResolver(username, targetFolder, dir, baseName, quality || 'unknown', signal) : null if (restored) { cacheProgress.set(songKey, { progress: 100, status: 'finished', total: restored.size, received: restored.size }) @@ -2551,6 +2608,7 @@ const downloadAndCacheImpl = async (songInfo: any, url: string, quality?: string const finalBaseName = getFileName(songInfo, actualQuality, isOnlyDownload, username) const finalPath = path.join(dir, finalBaseName + ext) + protectCachePath(finalPath) fs.rename(tempPath, finalPath, async (err) => { if (err) { safeUnlink(fileStream, tempPath) @@ -2988,25 +3046,17 @@ export const serveCacheFile = (req: http.IncomingMessage, res: http.ServerRespon res.writeHead(404); res.end('Not Found') } -export const getCacheStats = (username?: string) => { +export const getCacheStats = async (username?: string) => { const roots = ['cache', 'music'] const result: any = { cache: { totalSize: 0, fileCount: 0 }, music: { totalSize: 0, fileCount: 0 }, totalSize: 0, fileCount: 0 } const normalizedUsername = (username && username !== '_open' && username !== 'default') ? username : '_open' for (const folder of roots) { const dir = getCacheDir(normalizedUsername, folder === 'music') if (!fs.existsSync(dir)) continue - const files = fs.readdirSync(dir) - const extensions = ['.mp3', '.flac', '.m4a', '.ogg', '.wav', '.lrc'] - for (const file of files) { - const ext = path.extname(file).toLowerCase() - if (extensions.includes(ext)) { - try { - const stats = fs.statSync(path.join(dir, file)) - result[folder].totalSize += stats.size - result.totalSize += stats.size - if (ext !== '.lrc') { result[folder].fileCount++; result.fileCount++ } - } catch (e) { } - } + for (const file of await collectCacheMediaAsync(dir)) { + result[folder].totalSize += file.size + result.totalSize += file.size + if (path.extname(file.path).toLowerCase() !== '.lrc') { result[folder].fileCount++; result.fileCount++ } } } return result @@ -3168,50 +3218,95 @@ export const cleanupStaleTempFiles = async (username?: string, maxAgeMs: number return cleanedCount } -export const checkAndCleanupCache = async (username?: string) => { - if (isRemasterStage(username)) return // The replacement transaction owns all staging cleanup. - void cleanupStaleTempFiles(username) - const config = (global as any).lx.config - if (!config || !config['user.enableCacheSizeLimit']) return - const { totalSize } = getCacheStats(username) - const limitBytes = (config['user.cacheSizeLimit'] || 2000) * 1024 * 1024 - if (totalSize <= limitBytes) return - const roots: Array<'cache' | 'music'> = ['cache', 'music'] - const allFiles: Array<{ path: string, size: number, mtime: number }> = [] - const normalizedUsername = (username && username !== '_open' && username !== 'default') ? username : '_open' - for (const folder of roots) { - const dir = getCacheDir(normalizedUsername, folder === 'music') - if (!fs.existsSync(dir)) continue - const files = fs.readdirSync(dir) - for (const file of files) { - try { - const filePath = path.join(dir, file) - const stats = fs.statSync(filePath) - allFiles.push({ path: filePath, size: stats.size, mtime: stats.mtime.getTime() }) - } catch (e) { } +const cacheCleanupJobs = new Map }>() +const cleanupUserCache = (username: string, location: string, expire: boolean): Promise => { + const directory = getCacheDir(username, false, location) + const key = path.resolve(directory) + const pending = cacheCleanupJobs.get(key) + if (pending) { pending.expire ||= expire; return pending.promise } + const state = { expire, promise: Promise.resolve() } + state.promise = Promise.resolve().then(async () => { + do { + const runExpiration = state.expire + state.expire = false + await runUserCacheCleanup(username, location, directory, runExpiration) + } while (state.expire) + }).finally(() => { cacheCleanupJobs.delete(key) }) + cacheCleanupJobs.set(key, state) + return state.promise +} + +const runUserCacheCleanup = async (username: string, location: string, directory: string, expire: boolean) => { + if (global.lx?.restoreMaintenance) return + const config = global.lx.config + const limit = Number(config['user.cacheSizeLimit']) + const days = Number(config['cache.maxAgeDays'] ?? 14) + const limitBytes = config['user.enableCacheSizeLimit'] && Number.isFinite(limit) && limit > 0 ? limit * 1024 * 1024 : undefined + const retentionMs = expire && Number.isFinite(days) && days > 0 ? days * 86400000 : undefined + if (limitBytes === undefined && retentionMs === undefined) return + const lx = (globalThis as any).lx + const getProtectedPaths = () => { + const activePaths: Set = lx.getActiveBackupLocalPaths?.() || new Set() + return new Set(Array.from(activePaths, file => path.resolve(file))) + } + const deleted = new Set() + try { + const result = await cleanupCacheDirectory(directory, { + limitBytes, retentionMs, protectedPaths: getProtectedPaths(), refreshProtectedPaths: getProtectedPaths, + isProtected: file => cacheUsageGuard.protects(file), + shouldStop: () => !!global.lx?.restoreMaintenance, + onDeleted(file) { + deleted.add(path.relative(directory, file).replace(/\\/g, '/')) + try { lx.softDeleteLocalFileByPath?.(file) } catch (error) { + console.error('[cache] backup deletion notification failed:', error) + } + }, + }) + if (result.deletedCount) console.log(`[cache] ${location}/${username}: removed ${result.deletedCount} cache files`) + } finally { + // Process the shared, current index once, including entries added during asynchronous traversal. + if (deleted.size) { + const index = indexManager.load(username, 'cache', location) + let changed = false + for (const [key, item] of index) { + if (deleted.has(item.filename) && !fs.existsSync(path.join(directory, item.filename))) { + index.delete(key); changed = true + } else if (item.lyricFilename && deleted.has(item.lyricFilename) && !fs.existsSync(path.join(directory, item.lyricFilename))) { + item.lyricFilename = undefined + item.hasLyric = false + changed = true + } + } + if (changed) indexManager.save(username, 'cache', location) } } - allFiles.sort((a, b) => a.mtime - b.mtime) - // 跳过「仍有活跃备份记录」的文件,避免本地缓存被清理后 OpenList 上传因源文件不存在而失败 - let activeBackupPaths: Set | null = null - try { activeBackupPaths = (globalThis as any).lx?.getActiveBackupLocalPaths?.() ?? null } catch { activeBackupPaths = null } - let currentSize = totalSize - const targetSize = limitBytes * 0.95 - let deletedCount = 0 - for (const file of allFiles) { - if (currentSize <= targetSize) break - if (activeBackupPaths && activeBackupPaths.has(file.path)) { - if ((global as any).lx?.config?.['debug.enabled']) console.log(`[文件缓存] 跳过清理(仍有活跃备份记录): ${file.path}`) - continue - } +} + +export const checkAndCleanupCache = async (username?: string) => { + if (isRemasterStage(username)) return + const normalized = username && username !== 'default' ? username : '_open' + try { await cleanupUserCache(normalized, getUserCacheLocation(normalized), false) } catch (error) { + console.error('[文件缓存] 空间清理失败:', error) + } +} + +// Sweep both locations and inactive users. Each user/location has an independent quota. +export const cleanupAllCaches = async () => { + const seen = new Set() + for (const location of [CACHE_ROOTS.ROOT, CACHE_ROOTS.DATA]) { + const root = path.resolve(location === CACHE_ROOTS.DATA ? global.lx.dataPath : process.cwd(), 'cache') + if (seen.has(root)) continue + seen.add(root) try { - fs.unlinkSync(file.path) - ;(globalThis as any).lx?.softDeleteLocalFileByPath?.(file.path) - currentSize -= file.size - deletedCount++ - } catch (e) { } + if (!fs.existsSync(root) || fs.lstatSync(root).isSymbolicLink()) continue + for (const entry of fs.readdirSync(root, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name === 'default') continue + try { await cleanupUserCache(entry.name, location, true) } catch (error) { + console.error('[文件缓存] 自动清理失败:', error) + } + } + } catch (error) { console.error('[文件缓存] 扫描缓存目录失败:', error) } } - console.log(`[文件缓存] 用户 ${normalizedUsername} 缓存空间清理完成,已删除 ${deletedCount} 个过期文件`) } // 启动 5 秒后自动扫描并清理系统遗留的过期 .tmp 文件 diff --git a/src/server/music/localLyrics.ts b/src/server/music/localLyrics.ts new file mode 100644 index 00000000..766d5290 --- /dev/null +++ b/src/server/music/localLyrics.ts @@ -0,0 +1,29 @@ +import fs from 'node:fs' +import path from 'node:path' +import { resolveContainedPath } from '../auth/security' +import { parseLyrics } from '@/utils/lrcTool' +import { checkLyricCache, getCacheDir, readCachedEmbeddedLyrics } from './fileCache' +import { getCustomMusicDir, checkCustomLyricCache } from './customMusicManager' + +/** All roots are resolved from the authenticated user, never from a supplied absolute path. */ +export function lookupLocalLyrics(song: any, user: string): { exists: boolean; content?: any } { + if (song.filename) { + if (!['cache', 'music', 'custom'].includes(song.folder || 'cache')) throw new Error('歌词目录无效') + const root = song.folder === 'custom' ? getCustomMusicDir(user) : getCacheDir(user, song.folder === 'music') + if (!root) return { exists: false } + const audio = resolveContainedPath(root, song.filename) + if (!fs.existsSync(audio)) return { exists: false } + const relative = song.filename.slice(0, song.filename.length - path.extname(song.filename).length) + '.lrc' + const lyric = resolveContainedPath(root, relative) + if (fs.existsSync(lyric)) { + const content = parseLyrics(fs.readFileSync(lyric, 'utf8').replace(/^\uFEFF/, '')) + if (content.lyric?.trim()) return { exists: true, content } + } + return { exists: false } + } + const cached = checkLyricCache(song, user) + if (cached.exists && (cached.content?.lyric || cached.content?.lrc)?.trim()) return cached + const custom = checkCustomLyricCache(song, user) + if (custom.exists) return custom + return song.includeEmbedded ? readCachedEmbeddedLyrics(song, user) : { exists: false } +} diff --git a/src/server/music/parallelDownload.ts b/src/server/music/parallelDownload.ts new file mode 100644 index 00000000..757e6fea --- /dev/null +++ b/src/server/music/parallelDownload.ts @@ -0,0 +1,126 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import http from 'node:http' +import https from 'node:https' +import { resolvePublicUrl } from '../auth/security' + +const MIN_SIZE = 8 * 1024 * 1024 +// Across all browser downloads, not four unbounded connections per task. +let active = 0 +const waiting: Array<() => void> = [] +async function slot() { + if (active >= 8) await new Promise(resolve => waiting.push(resolve)) + else active++ + return () => { const next = waiting.shift(); if (next) next(); else active-- } +} + +type Options = { + signal: AbortSignal + progress: (received: number, total: number) => void + resolve?: typeof resolvePublicUrl +} + +/** Null means the upstream does not safely support parallel ranges. */ +export async function downloadInRanges(input: string, options: Options): Promise<{ filePath: string; total: number; cleanup: () => void } | null> { + const controller = new AbortController() + const abort = () => controller.abort(options.signal.reason) + options.signal.addEventListener('abort', abort, { once: true }) + if (options.signal.aborted) abort() + let file: fs.promises.FileHandle | undefined + let dir: string | undefined + const cleanup = () => { if (dir) fs.rmSync(dir, { recursive: true, force: true }) } + const request = async (target: string, method: string, range?: string, redirects = 0): Promise => { + controller.signal.throwIfAborted() + if (redirects > 5) throw new Error('Too many redirects') + const { url, address, family } = await (options.resolve || resolvePublicUrl)(target) + return new Promise((resolve, reject) => { + const req = (url.protocol === 'https:' ? https : http).request(url, { + method, agent: false, signal: controller.signal, + lookup: (_host: string, opts: any, callback: any) => opts.all ? callback(null, [{ address, family }]) : callback(null, address, family), + headers: { 'User-Agent': 'Mozilla/5.0', Referer: url.origin, 'Accept-Encoding': 'identity', ...(range ? { Range: range } : {}) }, + }, res => { + if ([301, 302, 303, 307, 308].includes(res.statusCode || 0) && res.headers.location) { + res.on('error', () => {}) + res.resume() + request(new URL(res.headers.location, url).href, method, range, redirects + 1).then(resolve, reject) + } else resolve(res) + }) + req.setTimeout(30000, () => req.destroy(new Error('Upstream timeout'))) + req.on('error', reject) + req.end() + }) + } + try { + const releaseProbe = await slot() + let head: http.IncomingMessage + try { head = await request(input, 'HEAD') } finally { releaseProbe() } + const total = Number(head.headers['content-length']) + const etag = head.headers.etag + const contentType = String(head.headers['content-type'] || '') + const supported = head.statusCode === 200 && Number.isSafeInteger(total) && total >= MIN_SIZE && + head.headers['accept-ranges'] === 'bytes' && !head.headers['content-encoding'] && + (contentType.startsWith('audio/') || contentType.startsWith('application/octet-stream')) + head.on('error', () => {}) + head.resume() + if (!supported) return null + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-ranges-')) + const filePath = path.join(dir, 'audio') + file = await fs.promises.open(filePath, 'w+') + await file.truncate(total) + const received = [0, 0, 0, 0] + const chunkSize = Math.ceil(total / 4) + const jobs = Array.from({ length: 4 }, async (_, index) => { + const release = await slot() + try { + const start = index * chunkSize, end = Math.min(total - 1, start + chunkSize - 1) + for (let attempt = 0; attempt < 3; attempt++) { + let response: http.IncomingMessage | undefined + try { + received[index] = 0 + response = await request(input, 'GET', `bytes=${start}-${end}`) + if (response.statusCode !== 206 || response.headers['content-range'] !== `bytes ${start}-${end}/${total}` || + (etag && response.headers.etag !== etag) || response.headers['content-encoding']) { + throw new Error('Invalid upstream range response') + } + let offset = start + for await (const value of response) { + const chunk = Buffer.from(value) + if (offset + chunk.length > end + 1) throw new Error('Oversized upstream range') + let written = 0 + while (written < chunk.length) { + const result = await file!.write(chunk, written, chunk.length - written, offset + written) + if (!result.bytesWritten) throw new Error('Unable to write download segment') + written += result.bytesWritten + } + offset += chunk.length + received[index] += chunk.length + options.progress(received.reduce((a, b) => a + b, 0), total) + } + if (offset !== end + 1) throw new Error('Truncated upstream range') + return + } catch (error) { + response?.destroy() + controller.signal.throwIfAborted() + if (attempt === 2) throw error + } + } + } finally { release() } + }) + // Cancel siblings before cleanup; never close the file while writes are pending. + const guarded = jobs.map(job => job.catch(error => { controller.abort(error); throw error })) + const results = await Promise.allSettled(guarded) + const failed = results.find(result => result.status === 'rejected') + if (failed?.status === 'rejected') throw failed.reason + await file.close() + file = undefined + return { filePath, total, cleanup } + } catch (error) { + controller.abort(error) + if (file) await file.close() + cleanup() + if (options.signal.aborted) throw error + // Range ignored, mismatched or unstable: retry using the existing single stream. + return null + } finally { options.signal.removeEventListener('abort', abort) } +} diff --git a/src/server/music/subsonicLocal.ts b/src/server/music/subsonicLocal.ts index 0ab41c30..92469b83 100644 --- a/src/server/music/subsonicLocal.ts +++ b/src/server/music/subsonicLocal.ts @@ -4,7 +4,7 @@ import crypto from 'node:crypto' import type http from 'node:http' import { getUserDirname } from '@/user' import { getCacheList, getCacheDir, getUserCacheLocation, syncCacheIndex } from './fileCache' -import { getCustomMusicDir, syncCustomIndex } from './customMusicManager' +import { getCustomMusicDir, getCustomMusicList, syncCustomIndex } from './customMusicManager' import { withCacheBrowseLocation } from './cacheBrowseLocation' const { MusicTagger } = require('music-tag-native') @@ -41,6 +41,7 @@ interface Registry { tracks: Record } export interface LocalPlaylist { name: string; ids: string[]; created: string; changed: string; standalone: boolean } interface UserState { playlists: Record; starred: Record } const locks = new Map>() +const catalogRevisions = new Map() const catalogs = new Map() const scans = new Map() const pending = new Map>() @@ -91,11 +92,12 @@ const duration = (value: unknown) => { } /** Main roots follow persistent user settings; browsing overrides never affect this catalog. */ -export function getLocalTracks(user: string, force = false): Promise { +const customScanTimes = new Map() +export function getLocalTracks(user: string, force = false, browseLocation?: string, customIndexOnly = false): Promise { return withCacheBrowseLocation('/rest/', () => { - const location = getUserCacheLocation(user) + const location = browseLocation || getUserCacheLocation(user) const custom = getCustomMusicDir(user) - const key = JSON.stringify([location, custom, getCacheDir(user, false, location), getCacheDir(user, true, location)]) + const key = JSON.stringify([catalogRevisions.get(user) || 0, location, custom, getCacheDir(user, false, location), getCacheDir(user, true, location)]) const cached = catalogs.get(user) if (!force && cached?.key === key && Date.now() - cached.at < 30_000) return Promise.resolve(cached.tracks) const pendingKey = `${user}:${key}` @@ -109,7 +111,13 @@ export function getLocalTracks(user: string, force = false): Promise= 5 * 60_000) + const customItems = shouldScan ? await syncCustomIndex(user) : await getCustomMusicList(user) + if (shouldScan) customScanTimes.set(user, { root: custom, at: Date.now() }) + for (const item of customItems) items.push({ item, root: custom }) if (getCustomMusicDir(user) !== custom) throw new Error('Custom music directory changed during scan; retry the request') } const file = userFile(user, 'subsonic-local-registry.json') @@ -122,7 +130,8 @@ export function getLocalTracks(user: string, force = false): Promise !seen.has(id))] } + +/** 关联后评分以在线身份为准;未关联时保留本地身份。 */ +export const localRatingId = (track: Pick): string => track.platformId || track.id +export function invalidateLocalCatalog(user: string): void { catalogs.delete(user); catalogRevisions.set(user, (catalogRevisions.get(user) || 0) + 1) } + +/** 缓存关联可能改名;保留原本地ID和旧定位别名,读取评分时仍以最新在线ID为准。 */ +export function updateLocalTrackLink(user: string, root: string, oldFilename: string, filename: string, platformId: string): void { + const file = userFile(user, 'subsonic-local-registry.json') + const registry = read(file, { tracks: {} }) + const actualRoot = fs.realpathSync(root) + const oldPath = path.resolve(actualRoot, oldFilename) + const newPath = localFile({ root, filename }) + if (!newPath || !oldPath.startsWith(actualRoot + path.sep)) throw new Error('Invalid local rating relocation') + const previous = registry.tracks[oldPath] + if (previous) { + const track = { ...previous, root: actualRoot, filename, platformId } + for (const [key, item] of Object.entries(registry.tracks)) if (item.id === track.id) registry.tracks[key] = track + registry.tracks[newPath] = track + write(file, registry) + } + invalidateLocalCatalog(user) +} diff --git a/src/server/openlistBackup.ts b/src/server/openlist/openlistBackup.ts similarity index 51% rename from src/server/openlistBackup.ts rename to src/server/openlist/openlistBackup.ts index 9737964c..e2cc9b99 100644 --- a/src/server/openlistBackup.ts +++ b/src/server/openlist/openlistBackup.ts @@ -11,16 +11,143 @@ * 状态机:pending -> uploading -> completed -> verified(失败记 failed,删除记 deleted)。 * 纯路径(不含域名/URL)存库,供后续定位与下载使用。 */ +import { openlistError } from './openlistErrors' import path from 'node:path' import fs from 'node:fs' import crypto from 'node:crypto' +import { hashBackupFile, backupFileVersion } from '@/utils/backupFiles' +import { prepareIntegrity, startIntegrity, integrityStatus as getIntegrityStatus, cancelIntegrity, type IntegrityFile } from './openlistIntegrity' +export { cancelIntegrity } +export function integrityStatus() { + const context = integrityContext() + return getIntegrityStatus(context.files, context.source) +} +import { metadataObservations, recordRemoteObservation, metadataStatus, startMetadata, cancelMetadata } from './openlistMetadata' +export { cancelMetadata } +function metadataSource() { const cfg = getConfig(); return crypto.createHash('sha256').update(JSON.stringify([cfg.endpoint, cfg.root.replace(/\\/g, '/').replace(/\/+$/, '') || '/', cfg.username, cfg.token || cfg.password])).digest('hex') } +let connectionSource = '' +let connection = { state: 'unknown', checkedAt: 0, lastSuccessAt: 0, nextProbeAt: 0, failures: 0, message: '尚未探测连接' } +let connectionProbe: Promise> | null = null +export function getOpenlistConnection() { + const source = metadataSource() + if (connectionSource !== source) { + connectionSource = source + connection = { state: 'unknown', checkedAt: 0, lastSuccessAt: 0, nextProbeAt: 0, failures: 0, message: '尚未探测连接' } + } + return { ...connection, enabled: getConfig().enable, checking: verifyRunning } +} +export async function probeOpenlistConnection() { + getOpenlistConnection() + if (connectionProbe) return connectionProbe + const source = connectionSource, cl = getClient() + if (!cl) return { ...getOpenlistConnection(), state: 'disabled', message: 'OpenList 未启用或连接未配置' } + connectionProbe = (async () => { + try { + await cl.testConnection(10000) + if (source === metadataSource()) connection = { state: 'online', checkedAt: Date.now(), lastSuccessAt: Date.now(), nextProbeAt: 0, failures: 0, message: '连接正常,可访问配置的备份目录' } + } catch (error: any) { + if (source === metadataSource()) { + const detail = String(error?.message || error) + const auth = ['auth', 'permission'].includes(openlistError(error).code) + const failures = connection.failures + 1 + connection = { ...connection, state: auth ? 'auth_error' : 'unavailable', checkedAt: Date.now(), failures, + nextProbeAt: Date.now() + Math.min(300000, 30000 * 2 ** Math.min(failures - 1, 4)), + message: auth ? '认证或访问权限异常,请检查账号、令牌和目录权限' : '暂时无法访问 OpenList,请检查服务、网络或备份目录' } + } + } + return getOpenlistConnection() + })() + try { return await connectionProbe } finally { connectionProbe = null } +} +async function connectionReady(cl: OpenListClient): Promise { + if (cl !== getClient() || !getConfig().enable) return false + const health = getOpenlistConnection() + if (health.nextProbeAt > Date.now()) return false + if (health.state === 'online' && Date.now() - health.checkedAt < 15000) return true + return (await probeOpenlistConnection()).state === 'online' +} +async function pauseConnection(error: any, cl: OpenListClient): Promise { + if (!openlistError(error).connection) return false + await probeOpenlistConnection() + return cl !== getClient() || getOpenlistConnection().state !== 'online' +} +function observeRemote(row: any, info?: { size?: number; modified?: string }, error?: any) { recordRemoteObservation(metadataSource(), row, info, error) } +export function openlistMetadataStatus() { const root = getConfig().root.replace(/\/+$/, '') + '/'; return { ...metadataStatus(metadataSource()), total: listAllBackups().filter(row => !row.deleted_at && row.remote_path.startsWith(root)).length } } +export async function startOpenlistMetadata(options: { target?: string; ids?: number[] }) { + if (!options || !['local', 'remote', 'both'].includes(options.target || '')) throw new Error('检查位置无效') + if (options.ids !== undefined && (!Array.isArray(options.ids) || !options.ids.length || options.ids.some(id => !Number.isSafeInteger(id) || id <= 0))) throw new Error('记录范围无效') + if (options.target !== 'local' && (await probeOpenlistConnection()).state !== 'online') throw new Error(getOpenlistConnection().message) + const ids = options.ids ? new Set(options.ids) : null + const cl = getClient(), root = getConfig().root.replace(/\/+$/, '') + '/' + const rows = listAllBackups().filter(row => !row.deleted_at && (!ids || ids.has(row.id)) && row.remote_path.startsWith(root)) + const source = metadataSource() + return startMetadata(source, rows, options.target!, cl, async () => { + if (source !== metadataSource() || !getConfig().enable) throw new Error('连接配置已变化,请重新开始检查') + const status = getOpenlistConnection() + return status.nextProbeAt > Date.now() ? false : (await probeOpenlistConnection()).state === 'online' + }) +} +function integrityContext() { + const cl = getClient() + if (!cl) throw new Error('OpenList 未启用') + const source = JSON.stringify([getConfig().endpoint, cl.resolveRemotePath('')]) + const observations = metadataObservations(metadataSource()) + const files = new Map() + for (const row of listAllBackups()) { + if (row.deleted_at || !['completed', 'verified'].includes(row.status) || !row.remote_path.startsWith(cl.resolveRemotePath(''))) continue + if (row.folder_type === 'data' && (row.name || '').split('/').some((segment: string) => segment.toLowerCase() === 'test-evidence')) continue + const observation = observations(row.remote_path) + const changedAt = observation?.baseline === JSON.stringify([row.size, row.local_hash, row.remote_mtime]) ? observation.changedAt : undefined + const type = row.folder_type === 'data' ? backupType(row.name || '') || 'system' : row.folder_type + files.set(row.remote_path, { key: String(row.id), name: row.name || path.basename(row.remote_path), remote: row.remote_path, user: displayBackupUser(row), type, size: row.size || 0, hash: row.local_hash, version: String(row.remote_mtime), changedAt }) + for (const att of listAttachments(row.id)) { + if (att.deleted_at || !['completed', 'verified'].includes(att.status) || !att.remote_path.startsWith(cl.resolveRemotePath(''))) continue + files.set(att.remote_path, { key: 'a:' + att.id, name: path.posix.basename(att.remote_path), remote: att.remote_path, user: att.user === '_system' ? displayBackupUser(row) : att.user, type, size: att.size || 0, hash: att.local_hash, version: String(att.updated_at) }) + } + } + return { cl, source, files: [...files.values()].sort((a, b) => a.remote.localeCompare(b.remote)) } +} +export function prepareOpenlistIntegrity(options: { type?: string; user?: string; scope?: string }) { + const context = integrityContext() + return prepareIntegrity(context.files, context.source, options) +} +export function startOpenlistIntegrity(token: string) { + if (syncJob || verifyRunning || manualTransfers || activeUploads || uploadQueue.length) throw new Error('OpenList 正在同步或检查,请稍后校验') + const context = integrityContext(), release = beginCloudRestore() + manualTransfers++ + const done = () => { manualTransfers--; release() } + try { return startIntegrity(context.cl, context.files, context.source, token, done) } catch (e) { done(); throw e } +} +import { executeRestoreTransaction, listRestoreTasks, readRestoreTask, cancelRestoreTask, rollbackRestoreTask as rollbackTask, deleteRestoreTask, createPendingRestoreTask, failRestoreTask, type RestoreFile } from './openlistRestoreTasks' +import { markRestoreDependencies, mergeRestoreDependency } from '../backup/restoreDependencies' +import { publishRecoveryManifest, readRecoveryManifest, listRecoveryVersions, type RecoveryCache } from './openlistRecoveryManifest' +export { listRestoreTasks, cancelRestoreTask, deleteRestoreTask } +export function rollbackRestoreTask(id: string) { + if (syncJob || verifyRunning || manualTransfers || activeUploads || isCloudBackupRunning()) throw new Error('云端正在同步或校验,请稍后回退') + return rollbackTask(id) +} +export function startOpenlistRestore(options: OpenlistRestoreOptions, resumeId?: string): string { + if (global.lx.restoreMaintenance) throw new Error('请先重启服务退出恢复维护状态') + const id = resumeId || createPendingRestoreTask(options) + const actual = resumeId ? { ...readRestoreTask(id).options, planToken: undefined } : options + void restoreOpenlistData({ ...actual, preview: false, resumeTaskId: id }).catch(error => failRestoreTask(id, error)) + return id +} +export async function getOpenlistRecoveryHistory() { + const cl = getClient() + try { + return { versions: cl ? await listRecoveryVersions(cl) : [], tasks: listRestoreTasks(), warning: cl ? '' : 'OpenList 未启用,仅显示本地任务' } + } catch (error: any) { + return { versions: [], tasks: listRestoreTasks(), warning: '云端历史暂不可用:' + error.message } + } +} import { OpenListClient, OpenListNotFoundError } from './openlistClient' import { registerOpenListCacheFile, unregisterOpenListCacheFile, getCacheDir, getIndexItemByFilename } from '@/server/music/fileCache' import { callUserApiGetMusicUrl } from '@/server/sources/userApi' import { getUserDirname } from '@/user/data' import { getUserSpace } from '@/user' import { File } from '@/constants' -import { backupType, backupLocalPath, getBackupTypes, scanBackupFiles, beginCloudRestore, isCloudRestoreRunning } from '@/utils/backupPolicy' +import { backupType, backupLocalPath, getBackupTypes, normalizeBackupTypes, scanBackupFiles, beginCloudBackup, isCloudBackupRunning, beginCloudRestore, isCloudRestoreRunning, type BackupType } from '@/utils/backupPolicy' import { ensureBackupTables, upsertBackupFile, @@ -86,7 +213,6 @@ interface BackupConfig { password: string token: string root: string - keepLocalAfterUpload: boolean verifyInterval: number includeNonAudio: boolean syncInterval: number @@ -107,7 +233,6 @@ function getConfig(): BackupConfig { password: c['openlist.password'] || '', token: c['openlist.token'] || '', root: c['openlist.root'] || '/lx_music', - keepLocalAfterUpload: c['openlist.keepLocalAfterUpload'] !== false, verifyInterval: Number(c['openlist.verifyInterval'] || 5), includeNonAudio: c['openlist.includeNonAudio'] === true, syncInterval: Number(c['openlist.syncInterval'] || 60), @@ -130,19 +255,33 @@ function parseUserBackups(raw: any): UserBackupCfg[] { /** 去掉结尾斜杠(兼容用户误填的 / 或 \),用于规范化 externalUrl */ const normalizeTrailingSlash = (u: string) => u.replace(/[\\/]+$/, '') -/** 解析条目的有效模式:显式 mode 优先;否则沿用全局 keepLocalAfterUpload 的语义 */ -function effectiveMode(entry: UserBackupCfg, cfg: BackupConfig): SyncMode { - if (entry.mode) return entry.mode - return cfg.keepLocalAfterUpload === false ? 'upload-delete' : 'upload' +// 这些 folder_type 属于“应用运行规则/用户数据”(系统配置、歌单与不喜欢快照、统一模型用户数据)。 +// 它们的本地副本必须保留:OpenList 的“不保留本地(upload-delete)”对它们不生效, +// 否则删除本地会改变应用运行规则(配置丢失、歌单/快照无法恢复)。见业务约束。 +const PROTECTED_LOCAL_TYPES = new Set(['system', 'data', 'list']) + +/** 解析条目的有效模式:唯一来源为条目显式 mode;未配置时默认 upload(保留本地)。 + * 「上传后删除本地」只能由显式 mode === 'upload-delete' 表达,不再有全局兜底开关。 */ +function effectiveMode(entry: UserBackupCfg, _cfg?: BackupConfig): SyncMode { + return entry.mode || 'upload' } -/** 从已有备份行推断其模式(用于验证/删除环节) */ +/** 从已有备份行推断其模式(用于验证/删除环节)。 + * 受保护类型(系统配置/歌单与不喜欢快照/统一模型用户数据)禁止 upload-delete: + * 即便条目显式设为 upload-delete 或历史行残留该值,也回退为 upload,确保本地配置与快照永不被删除。 + * 其余类型(cache/music)维持原语义,仅显式 upload-delete 会删本地。 */ function effectiveModeFromRow(row: any, cfg: BackupConfig): SyncMode { - if (row.folder_type === 'data') return 'upload' - const entry = cfg.userBackups.find(u => u.user === row.user && u.enabled) - if (entry) return effectiveMode(entry, cfg) - if (row.mode) return row.mode as SyncMode - return cfg.keepLocalAfterUpload === false ? 'upload-delete' : 'upload' + const derive = (): SyncMode => { + const entry = cfg.userBackups.find(u => u.user === row.user && u.enabled) + if (entry) return effectiveMode(entry, cfg) + if (row.mode) return row.mode as SyncMode + return 'upload' + } + if (PROTECTED_LOCAL_TYPES.has(row.folder_type)) { + const m = derive() + return m === 'upload-delete' ? 'upload' : m + } + return derive() } function normalizeUser(u?: string): string { @@ -178,7 +317,12 @@ let syncJob: Promise | null = null let manualTransfers = 0 function hashFile(localPath: string): string { - return crypto.createHash('sha256').update(fs.readFileSync(localPath)).digest('hex') + return hashBackupFile(localPath) +} +async function remoteMatchesBackup(cl: OpenListClient, row: any, remotePath: string): Promise { + if (!row.local_hash) return false + try { return (await cl.getFileInfo(remotePath)).size === row.size } + catch (e) { if (e instanceof OpenListNotFoundError) return false; throw e } } function folderEnabled(user: string, folder: string, cfg = getConfig()): boolean { @@ -193,11 +337,26 @@ function rowEnabled(row: any, cfg = getConfig()): boolean { const type = relative ? backupType(relative) : null return cfg.enable && row.user === '_system' && !!type && getBackupTypes('openlist', global.lx.config as any).includes(type) } + if (row.folder_type === 'list') { + return cfg.enable && ((global.lx.config as any)['backup.openlist.types'] === undefined || getBackupTypes('openlist', global.lx.config as any).includes('snapshots')) + } // 启用统一数据备份后,不再执行旧音乐/快照记录的上传即删策略。 if ((global.lx.config as any)['backup.openlist.types'] !== undefined && row.folder_type !== 'cache') return false return cfg.enable && folderEnabled(row.user, row.folder_type, cfg) } +// 数据记录的相对路径稳定;切换挂载目录后,上传重试必须使用当前目标。 +// 下载记录仍指向原副本,不能擅自改变恢复来源。 +function currentUploadRow(row: any, cl: OpenListClient): any { + if (row.folder_type !== 'data' || row.direction === 'download') return row + const relative = row.name || row.remote_path.split('/_data/')[1] + if (!relative || !backupType(relative)) return row + const remotePath = cl.resolveRemotePath('_data/' + relative) + if (remotePath === row.remote_path) return row + setBackupStatus(row.id, 'pending', { remotePath, error: null, retryCount: 0 }) + return getBackupById(row.id)! +} + function getClient(): OpenListClient | null { const cfg = getConfig() if (!cfg.enable || !cfg.endpoint) { client = null; clientSig = ''; return null } @@ -254,6 +413,97 @@ function resolveUserDir(entry: UserBackupCfg, folderType: 'cache' | 'music' | 'l } } +/** 所有真实用户(含系统用户 _open),用于快照类备份对所有用户生效,不受 userBackups 勾选限制。 */ +function getAllUserNames(): string[] { + const names: string[] = [] + const users = (global.lx.config as any).users + if (Array.isArray(users)) for (const u of users) if (u && u.name) names.push(u.name) + if (!names.includes('_open')) names.push('_open') + return names +} + +/** Resolve display ownership without rewriting the internal _system database identity. */ +function resolveUsernameFromPath(value?: string | null): string | null { + if (!value) return null + const normalized = String(value).replace(/\\/g, '/') + const dataRoot = path.resolve(global.lx.dataPath).replace(/\\/g, '/') + '/' + const inDataRoot = process.platform === 'win32' ? normalized.toLowerCase().startsWith(dataRoot.toLowerCase()) : normalized.startsWith(dataRoot) + const relative = inDataRoot ? normalized.slice(dataRoot.length) + : normalized.includes('/_data/') ? normalized.slice(normalized.lastIndexOf('/_data/') + 7) + : normalized + // Source files have an extra namespace segment; match it before generic users/. + const match = /^(?:users\/source|music|_snapshots|users)\/([^/]+)\//.exec(relative) + if (!match) return null + const directory = match[1] + if (directory === '_open') return '_open' + // Build from current accounts so additions, removals and renames do not use stale mappings. + const names = getAllUserNames() + if (names.includes(directory)) return directory + for (const name of names) { + try { if (getUserDirname(name) === directory) return name } catch { /* Unknown account. */ } + } + return null +} +function displayBackupUser(row: { user: string; folder_type?: string; name?: string | null; local_path?: string | null; remote_path?: string | null }): string { + if (row.user !== '_system') return row.user + return resolveUsernameFromPath(row.folder_type === 'data' ? row.name : null) + || resolveUsernameFromPath(row.local_path) || resolveUsernameFromPath(row.remote_path) || '_system' +} + +/** 解析某用户 dislike 快照目录(与歌单快照同一 snapshot.backupPath 体系,但使用独立的 dislike/snapshot 子目录,避免互相覆盖)。 */ +function resolveDislikeSnapshotDir(user: string): string { + const backupPathConf = ((global.lx.config as any)['snapshot.backupPath'] || '').trim() + let dir: string + if (backupPathConf) { + const base = path.isAbsolute(backupPathConf) ? backupPathConf : path.join(global.lx.dataPath, backupPathConf) + dir = path.join(base, user, File.dislikeDir, File.dislikeSnapshotDir) + } else { + const userDir = path.join(global.lx.userPath, getUserDirname(user)) + dir = path.join(userDir, File.dislikeDir, File.dislikeSnapshotDir) + } + try { fs.mkdirSync(dir, { recursive: true }) } catch { /* ignore */ } + return dir +} + +/** 上传单个用户某类快照目录(list 或 dislike)到 OpenList,folder_type 仍记 'list'(复用旧表字段,避免 schema 变更)。 */ +async function backupUserSnapshotKind(cl: OpenListClient, cfg: BackupConfig, summary: SyncSummary, user: string, kind: 'list' | 'dislike'): Promise { + const dir = kind === 'list' ? resolveSnapshotDir(user) : resolveDislikeSnapshotDir(user) + if (!dir || !fs.existsSync(dir)) return + for (const lp of walkLocal(dir, 'list')) { + const rel = path.relative(dir, lp).replace(/\\/g, '/') + // 传统布局下 dislike 快照置于 user/list/dislike/ 子目录,与歌单快照区分;统一数据模式(selectedData)不走此路。 + const remoteSub = kind === 'dislike' ? 'dislike/' : '' + const remotePath = cl.resolveRemotePath(`${user}/list/${remoteSub}${rel}`) + const existing = getBackupByLocalPath(user, 'list', lp) + if ((existing?.deleted_at || existing?.status === 'deleted') && !localChanged(existing)) continue + if (existing && enqueuedIds.has(`f:${existing.id}`)) continue + if (existing?.status === 'completed' && existing.remote_path === remotePath && !localChanged(existing) && await remoteMatchesBackup(cl, existing, remotePath)) continue + if (existing?.status === 'failed' && existing.retry_count >= MAX_AUTO_RETRIES && existing.remote_path === remotePath) continue + if (existing?.status === 'verified' && existing.remote_path === remotePath && !localChanged(existing)) { + try { + const remote = await cl.getFileInfo(remotePath) + const modified = parseMod(remote.modified) + if (remote.size === existing.size && (modified == null || existing.remote_mtime == null || modified === existing.remote_mtime) && await remoteMatchesBackup(cl, existing, remotePath)) continue + } catch (e: any) { + if (!(e instanceof OpenListNotFoundError)) { summary.errors.push(rel + ': ' + (e?.message || e)); continue } + } + } + const row = upsertBackupFile({ user, folderType: 'list', localPath: lp, remotePath, name: rel, mode: 'upload', direction: 'upload', status: 'pending' }) + enqueueUpload(row, cl, 'upload') + summary.uploaded++ + } +} + +/** 对所有真实用户备份歌单与不喜欢快照(传统模式下脱离 userBackups 用户勾选;统一数据模式下仅在手动 folder='list' 时补充)。 */ +async function backupAllSnapshots(cl: OpenListClient, cfg: BackupConfig, summary: SyncSummary, opts: { user?: string; folder?: 'cache' | 'music' | 'list' }): Promise { + if (opts.folder && opts.folder !== 'list') return + for (const user of getAllUserNames()) { + if (opts.user && user !== opts.user) continue + await backupUserSnapshotKind(cl, cfg, summary, user, 'list') + await backupUserSnapshotKind(cl, cfg, summary, user, 'dislike') + } +} + function isSubPath(child: string, parent: string): boolean { const np = path.normalize(parent) const nc = path.normalize(child) @@ -353,6 +603,8 @@ async function refetchLocalFile(row: any): Promise { } async function uploadOne(row: any, cl: OpenListClient, mode: SyncMode, retries = UPLOAD_RETRY_TIMES): Promise { + row = currentUploadRow(row, cl) + if (!await connectionReady(cl)) { setBackupStatus(row.id, 'pending'); return false } if (!rowEnabled(row) || getBackupById(row.id)?.deleted_at) return false // 本地源文件缺失时尝试按源重新拉取(缓存被清理 / 容器重建导致丢失 -> ENOENT 自愈) if (!row.local_path || !fs.existsSync(row.local_path)) { @@ -367,7 +619,7 @@ async function uploadOne(row: any, cl: OpenListClient, mode: SyncMode, retries = } for (let attempt = 1; attempt <= retries; attempt++) { try { - setBackupStatus(row.id, 'uploading') + setBackupStatus(row.id, 'uploading', { error: null }) const before = fs.statSync(row.local_path) const localHash = hashFile(row.local_path) await cl.mkdir(path.posix.dirname(row.remote_path)) @@ -385,9 +637,11 @@ async function uploadOne(row: any, cl: OpenListClient, mode: SyncMode, retries = localHash, remoteMtime: parseMod(info.modified), error: null, + retryCount: 0, }) return true } catch (e: any) { + if (await pauseConnection(e, cl)) { setBackupStatus(row.id, 'pending', { error: null }); return false } if (attempt >= retries) { setBackupStatus(row.id, 'failed', { error: String(e?.message || e), retryCount: (row.retry_count || 0) + 1 }) return false @@ -402,12 +656,18 @@ async function uploadOne(row: any, cl: OpenListClient, mode: SyncMode, retries = * 单文件从远端下载到本地(download / sync 模式),并补登记缓存索引。 * 与 uploadOne 对称,失败记为 failed(带重试计数)。 */ -async function downloadOne(row: any, cl: OpenListClient, mode: SyncMode): Promise { +async function downloadOne(row: any, cl: OpenListClient, mode: SyncMode, metadata?: RecoveryCache): Promise { + if (!await connectionReady(cl)) { setBackupStatus(row.id, 'pending'); return false } if (!rowEnabled(row) || getBackupById(row.id)?.deleted_at) return false try { setBackupStatus(row.id, 'uploading') + if (!metadata && row.direction === 'download') { + const manifest = await readRecoveryManifest(cl) + const prefix = cl.resolveRemotePath(row.user + '/' + row.folder_type + '/') + metadata = manifest?.cache.find(item => item.user === row.user && item.folder === row.folder_type && row.remote_path === prefix + item.relative) + } fs.mkdirSync(path.dirname(row.local_path), { recursive: true }) - await cl.download(row.remote_path, row.local_path) + await cl.download(row.remote_path, row.local_path, undefined, undefined, metadata?.hash) const info = await cl.getFileInfo(row.remote_path) upsertLocalFile({ user: row.user, source: row.source, folderType: row.folder_type, localPath: row.local_path }) const isAud = isAudio(row.local_path) @@ -416,7 +676,7 @@ async function downloadOne(row: any, cl: OpenListClient, mode: SyncMode): Promis if (isAud) { const id = registerDownloadedCache(row.user, row.folder_type, row.local_path) setBackupStatus(row.id, 'verified', { size, remotePath: row.remote_path }) - await downloadAttachmentsForFile(row, cl) + await downloadAttachmentsForFile(row, cl, metadata) } else if (row.folder_type === 'list') { setBackupStatus(row.id, 'verified', { size, remotePath: row.remote_path }) try { getUserSpace(row.user).listManage.snapshotDataManage.updateSnapshotDir() } catch { /* ignore */ } @@ -426,6 +686,7 @@ async function downloadOne(row: any, cl: OpenListClient, mode: SyncMode): Promis setBackupStatus(row.id, 'verified', { size, remoteMtime: parseMod(info.modified), localMtime: fs.statSync(row.local_path).mtimeMs, localHash: hashFile(row.local_path), error: null, retryCount: 0 }) return true } catch (e: any) { + if (await pauseConnection(e, cl)) { setBackupStatus(row.id, 'pending', { error: null }); return false } setBackupStatus(row.id, 'failed', { error: String(e?.message || e), retryCount: (row.retry_count || 0) + 1 }) return false } @@ -436,7 +697,7 @@ async function downloadOne(row: any, cl: OpenListClient, mode: SyncMode): Promis * 仅在附件记录存在时生效(上传侧需 openlist.includeNonAudio 才会登记附件),默认无副作用。 * 已存在的本地附件直接登记、跳过下载,保证幂等。 */ -async function downloadAttachmentsForFile(row: any, cl: OpenListClient): Promise { +async function downloadAttachmentsForFile(row: any, cl: OpenListClient, metadata?: RecoveryCache): Promise { let atts: any[] = [] try { atts = listAttachments(row.id) } catch { return } if (!atts.length) return @@ -447,25 +708,26 @@ async function downloadAttachmentsForFile(row: any, cl: OpenListClient): Promise ? att.local_path : path.join(audioDir, path.basename(att.remote_path)) try { - if (!fs.existsSync(localPath)) { + const expected = metadata?.attachments.find(a => att.remote_path.endsWith('/' + a.relative))?.hash + if (!fs.existsSync(localPath) || (expected && hashFile(localPath) !== expected)) { fs.mkdirSync(path.dirname(localPath), { recursive: true }) - await cl.download(att.remote_path, localPath) + await cl.download(att.remote_path, localPath, undefined, undefined, expected) } upsertLocalFile({ user: row.user, source: row.source, folderType: row.folder_type, localPath }) - setAttachmentStatus(att.id, 'verified') + setAttachmentStatus(att.id, 'verified', expected ? { localHash: expected } : {}) } catch (e: any) { // 附件失败不应阻断主音频恢复 console.warn('[openlist-backup] 附件恢复失败:', att.remote_path, e?.message || e) + if (metadata) throw e } } } -/** 是否「缓存优先 + 优先使用 OpenList」:两者同时开启才走 OpenList 直链/取回 */ +/** 跨 Web/Subsonic 的 OpenList 播放来源总开关;旧配置只作迁移兼容。 */ export function isOpenlistCacheFirstEnabled(): boolean { const c: any = (globalThis as any).lx?.config if (!c) return false - if (c['subsonic.playCacheFirst'] === false) return false - return c['subsonic.playCacheFirstUseOpenlist'] === true + return c['openlist.playback.enable'] === true || (c['openlist.playback.enable'] === undefined && c['subsonic.playCacheFirst'] !== false && c['subsonic.playCacheFirstUseOpenlist'] === true) } /** 在 OpenList 备份中按「确定性缓存文件名 base」匹配 verified/completed 记录 */ @@ -506,7 +768,7 @@ export async function tryRestoreFromOpenlist( // 自定义目标目录不应复用另一目录的索引或附件绝对路径。 if (path.resolve(finalPath) !== path.resolve(row.local_path)) return null fs.mkdirSync(path.dirname(finalPath), { recursive: true }) - await cl.download(row.remote_path, finalPath, signal) + await cl.download(row.remote_path, finalPath, signal, undefined, row.local_hash || undefined) if (signal?.aborted) return null upsertLocalFile({ user, source: row.source, folderType, localPath: finalPath }) registerDownloadedCache(user, folderType, finalPath) @@ -526,13 +788,26 @@ export async function tryRestoreFromOpenlist( * - 否则用外网 url 拼接 /d/(外网 url 缺省等于内网 endpoint) * 未匹配/未开启/失败返回 null(调用方继续走原始回源)。 */ +const playbackLinks = new Map }>() +function waitPlaybackLink(pending: Promise, signal?: AbortSignal): Promise { + if (!signal) return pending + if (signal.aborted) return Promise.resolve(null) + return new Promise(resolve => { + const done = (value: any) => { signal.removeEventListener('abort', stop); resolve(value) } + const stop = () => done(null) + signal.addEventListener('abort', stop, { once: true }) + pending.then(done, () => done(null)) + }) +} export async function resolveOpenlistCacheEntry( user: string, folderType: 'cache' | 'music', baseName: string, song?: { id: string; source: string; quality: string }, + signal?: AbortSignal, ): Promise<{ url: string; filename: string; quality: string } | null> { const cfg = getConfig() + if (signal?.aborted) return null if (!cfg.enable) return null if (!folderEnabled(user, folderType, cfg)) return null if (!isOpenlistCacheFirstEnabled()) return null @@ -543,13 +818,24 @@ export async function resolveOpenlistCacheEntry( try { const cl = getClient() if (!cl) return null - const info = await cl.getFileInfo(row.remote_path) - const filename = path.basename(row.remote_path) - if (info?.rawUrl) return { url: info.rawUrl, filename, quality: row.quality || song?.quality || 'unknown' } - const base = normalizeTrailingSlash(cfg.externalUrl || cfg.endpoint || '') - if (!base) return null - const p = row.remote_path.replace(/^\/+/, '') - return { url: `${base}/d/${p.split('/').map((s: string) => encodeURIComponent(s)).join('/')}`, filename, quality: row.quality || song?.quality || 'unknown' } + if (!row.remote_path.startsWith(cl.resolveRemotePath(''))) return null + const key = crypto.createHash('sha256').update(JSON.stringify([cfg.endpoint, cfg.token, cfg.root, user, row.remote_path, row.local_hash, row.updated_at])).digest('hex') + const old = playbackLinks.get(key) + if (old && old.until > Date.now()) return await waitPlaybackLink(old.pending, signal) + for (const [key, item] of playbackLinks) if (item.until < Date.now()) playbackLinks.delete(key) + if (playbackLinks.size > 1000) playbackLinks.delete(playbackLinks.keys().next().value!) + const pending = (async () => { + const info = await cl.getFileInfo(row.remote_path, AbortSignal.timeout(4000)) + if (info.size != null && row.size != null && info.size !== row.size) return null + const filename = path.basename(row.remote_path) + if (info?.rawUrl) return { url: info.rawUrl, filename, quality: row.quality || song?.quality || 'unknown' } + const base = normalizeTrailingSlash(cfg.externalUrl || cfg.endpoint || '') + if (!base) return null + const p = row.remote_path.replace(/^\/+/, '') + return { url: `${base}/d/${p.split('/').map((s: string) => encodeURIComponent(s)).join('/')}`, filename, quality: row.quality || song?.quality || 'unknown' } + })().catch(() => null) + playbackLinks.set(key, { until: Date.now() + 30000, pending }) + return await waitPlaybackLink(pending, signal) } catch { return null } @@ -560,17 +846,20 @@ export async function resolveOpenlistCacheDirectUrl(user: string, folder: 'cache } async function uploadAttachment(att: any, cl: OpenListClient): Promise { + if (!await connectionReady(cl)) { setAttachmentStatus(att.id, 'pending'); return } if (!folderEnabled(att.user, att.folder_type)) return try { setAttachmentStatus(att.id, 'uploading') if (att.local_path && fs.existsSync(att.local_path)) { await cl.mkdir(path.posix.dirname(att.remote_path)) + const hash = hashFile(att.local_path) const info = await cl.upload(att.local_path, att.remote_path) - setAttachmentStatus(att.id, 'completed', { size: info.size }) + setAttachmentStatus(att.id, 'completed', { size: info.size, localHash: hash }) } const v = await cl.getFileInfo(att.remote_path) if (v && v.size != null) setAttachmentStatus(att.id, 'verified', { size: v.size }) } catch (e: any) { + if (await pauseConnection(e, cl)) { setAttachmentStatus(att.id, 'pending'); return } setAttachmentStatus(att.id, 'failed', { error: String(e?.message || e) }) } } @@ -600,8 +889,10 @@ function enqueueAttachment(att: any, cl: OpenListClient): void { } function pumpUploads(): void { + if (isCloudRestoreRunning()) return while (activeUploads < MAX_CONCURRENT_UPLOADS && uploadQueue.length) { const job = uploadQueue.shift()! + const releaseBackup = beginCloudBackup() activeUploads++ void (async () => { try { @@ -612,6 +903,7 @@ function pumpUploads(): void { } finally { enqueuedIds.delete(job.kind === 'file' ? `f:${job.id}` : `a:${job.id}`) activeUploads-- + releaseBackup() pumpUploads() } })() @@ -724,6 +1016,53 @@ export function onFileCached( lazyStartScheduler() } +/** + * 保存配置、某用户模式变为 upload-delete 时,对本地 cache/music 存量文件触发一次性迁移: + * 统一经过上传与内容校验流程,不能凭旧的 verified/completed 状态直接删除本地。 + * 仅 cache/music 生效;system/data/list 受 PROTECTED_LOCAL_TYPES 保护,永不删本地。 + */ +export async function triggerBackfillUploadDelete(user: string): Promise { + const cfg = getConfig() + if (!cfg.enable) return + const entry = cfg.userBackups.find(u => u.user === user && u.enabled) + if (!entry || effectiveMode(entry, cfg) !== 'upload-delete' || isCloudRestoreRunning()) return + const cl = getClient() + if (!cl) return + ensureBackupTables() + + const folders: Array<'cache' | 'music'> = ['cache', 'music'] + for (const folderType of folders) { + if (folderType === 'music' && (global.lx.config as any)['backup.openlist.types'] !== undefined) continue + if (!folderEnabled(user, folderType, cfg)) continue + const dir = resolveUserDir(entry, folderType, cfg) + if (!dir || !fs.existsSync(dir)) continue + const locals = walkLocal(dir, folderType) + for (const localPath of locals) { + const existing = getBackupByLocalPath(user, folderType, localPath) + if (existing && enqueuedIds.has(`f:${existing.id}`)) continue + const relative = path.relative(dir, localPath).replace(/\\/g, '/') + const remotePath = cl.resolveRemotePath(`${user}/${folderType}/${relative}`) + const item = getIndexItemByFilename(path.basename(localPath), user) + const row = upsertBackupFile({ + user, + folderType, + localPath, + remotePath, + source: existing?.source || null, + songmid: existing?.songmid || null, + quality: existing?.quality || null, + mode: 'upload-delete', + status: 'pending', + direction: 'upload', + cacheMetadata: item ? JSON.stringify(item) : existing?.cache_metadata, + }) + void enqueueUpload(row, cl, 'upload-delete') + if (cfg.includeNonAudio) queueAttachmentsForFile(user, folderType, localPath, row, dir, cl, cfg) + } + } + lazyStartScheduler() +} + /** * 订阅通用「文件落盘」事件(由 fileCache 在音频写入后发出,事件本身与 openlist 无关), * 落盘即排程一次防抖全量同步。这样 fileCache 不直接依赖 openlist 实现,仅发中性事件。 @@ -756,19 +1095,26 @@ export function registerFileCacheSyncHook(): void { export async function verifyPending(): Promise { if (verifyRunning || syncJob || manualTransfers || isCloudRestoreRunning()) return + const releaseBackup = beginCloudBackup() verifyRunning = true try { const cl = getClient() if (!cl) return const cfg = getConfig() - const rows = [...listBackupByStatus('completed'), ...listBackupByStatus('uploading'), ...listBackupByStatus('verified')] + const health = getOpenlistConnection() + if (health.nextProbeAt > Date.now()) return + if ((await probeOpenlistConnection()).state !== 'online') return + const rows = [...listBackupByStatus('completed'), ...listBackupByStatus('verified')] for (const row of rows) { if (!rowEnabled(row, cfg) || enqueuedIds.has(`f:${row.id}`)) continue try { const info = await cl.getFileInfo(row.remote_path) + observeRemote(row, info) if (row.status === 'verified' && !(effectiveModeFromRow(row, cfg) === 'upload-delete' && fs.existsSync(row.local_path))) { // 保留同步基线;远端变化应由同步引擎处理,不能在此覆盖基线。 - setBackupStatus(row.id, 'verified', { error: info.size === row.size ? null : '远端文件大小发生变化,等待同步确认' }) + const valid = info.size === row.size + if (!valid) setBackupStatus(row.id, 'failed', { error: '远端内容校验失败,等待重新上传', retryCount: (row.retry_count || 0) + 1 }) + else if (row.error) setBackupStatus(row.id, 'verified', { error: null }) continue } if (info && info.size != null) { @@ -782,36 +1128,34 @@ export async function verifyPending(): Promise { continue } const mode = effectiveModeFromRow(row, cfg) - if (mode === 'upload-delete' && row.local_path && fs.existsSync(row.local_path)) { - if (!row.local_hash || !await cl.verifyFile(row.remote_path, row.local_hash) || hashFile(row.local_path) !== row.local_hash) { + if (mode === 'upload-delete' && !PROTECTED_LOCAL_TYPES.has(row.folder_type) && fs.existsSync(row.local_path)) { + if (!row.local_hash || !await cl.verifyFile(row.remote_path, row.local_hash) || (fs.existsSync(row.local_path) && hashFile(row.local_path) !== row.local_hash)) { setBackupStatus(row.id, 'failed', { error: '远端内容校验失败,保留本地文件', retryCount: (row.retry_count || 0) + 1 }) continue } } if (!rowEnabled(row) || getBackupById(row.id)?.deleted_at) continue setBackupStatus(row.id, 'verified', { size: info.size, remotePath: row.remote_path, remoteMtime: parseMod(info.modified), error: null, retryCount: 0 }) - if (mode === 'upload-delete' && row.local_path && fs.existsSync(row.local_path)) { + if (mode === 'upload-delete' && !PROTECTED_LOCAL_TYPES.has(row.folder_type) && row.local_path && fs.existsSync(row.local_path)) { try { fs.unlinkSync(row.local_path) removeLocalCache(row) softDeleteLocalFile(row.user, row.local_path) - // 级联删除本地附件:仅当该附件自身已 verified 且未被其它备份引用时(避免附件仍在上传中被误删) - for (const a of listAttachments(row.id)) { - if (a.status === 'verified' && a.local_path && fs.existsSync(a.local_path) && countLocalPathRefs(a.local_path, row.id, a.id) === 0) { - try { fs.unlinkSync(a.local_path); softDeleteLocalFile(row.user, a.local_path) } catch { /* ignore */ } - } - } + // 附件在下方独立校验实际内容后清理。 } catch { /* ignore */ } } } } catch (e: any) { + if (!(e instanceof OpenListNotFoundError) && (await probeOpenlistConnection()).state !== 'online') return + observeRemote(row, undefined, e) const missingDataBackup = row.folder_type === 'data' && e instanceof OpenListNotFoundError && fs.existsSync(row.local_path) setBackupStatus(row.id, missingDataBackup ? 'failed' : row.status, { error: String(e?.message || e) }) } } // 失败自动重投(限次,超过 MAX_AUTO_RETRIES 后保持 failed,由用户手动重试) - for (const row of listBackupByStatus('failed')) { + for (let row of listBackupByStatus('failed')) { if (!rowEnabled(row, cfg)) continue + row = currentUploadRow(row, cl) if ((row.retry_count || 0) >= MAX_AUTO_RETRIES) continue const mode = effectiveModeFromRow(row, cfg) try { @@ -823,7 +1167,7 @@ export async function verifyPending(): Promise { } } catch { /* ignore */ } } - // 卡死回收:重启/并发打挂导致的 pending 或长时间 uploading 孤儿记录,重新入队重试 + // 无活动队列项的 uploading 是进程中断留下的孤儿,立即恢复,不能用轮询刷新的时间判断。 const STALE_MS = 10 * 60 * 1000 const now = Date.now() for (const row of listBackupByStatus('pending')) { @@ -833,10 +1177,8 @@ export async function verifyPending(): Promise { } for (const row of listBackupByStatus('uploading')) { if (!rowEnabled(row, cfg) || enqueuedIds.has(`f:${row.id}`)) continue - if (now - (row.updated_at || 0) > STALE_MS) { - if (row.direction === 'download') await downloadOne(row, cl, effectiveModeFromRow(row, cfg)) - else enqueueUpload(row, cl, effectiveModeFromRow(row, cfg)) - } + if (row.direction === 'download') await downloadOne(row, cl, effectiveModeFromRow(row, cfg)) + else enqueueUpload(row, cl, effectiveModeFromRow(row, cfg)) } for (const att of listAttachmentsByStatus('pending')) if (folderEnabled(att.user, att.folder_type, cfg)) enqueueAttachment(att, cl) for (const att of listAttachmentsByStatus('uploading')) { @@ -868,6 +1210,7 @@ export async function verifyPending(): Promise { console.warn('[openlist-backup] 验证轮询出错:', e) } finally { verifyRunning = false + releaseBackup() } } @@ -875,6 +1218,26 @@ function lazyStartScheduler(): void { startVerifyScheduler() } +let connectionTimer: ReturnType | null = null +let nextScheduledSync = 0 +let nextScheduledCheck = 0 +let lastSyncResult: { at: number; uploaded: number; downloaded: number; deleted: number; errors: string[] } | null = null +export function getOpenlistRuntimeStatus() { + const scheduled = getConfig().enable && !isCloudRestoreRunning() + const rows = listBackups() + const counts: Record = { pending: 0, uploading: 0, completed: 0, failed: 0 } + for (const row of rows) { + const status = row.status === 'verified' ? 'completed' : row.status + if (status in counts) counts[status]++ + } + return { running: !!syncJob || !!activeUploads || !!manualTransfers, checking: verifyRunning, + nextSyncAt: scheduled && syncTimer ? nextScheduledSync : 0, + nextCheckAt: scheduled && verifyTimer ? nextScheduledCheck : 0, + counts, lastRun: lastSyncResult, + lastRecordAt: rows.reduce((max, row) => Math.max(max, Number(row.updated_at) || 0), 0), + lastError: rows.find(row => row.status === 'failed')?.error || null } +} + export function startVerifyScheduler(): void { const cfg = getConfig() const signature = JSON.stringify([cfg.enable, cfg.verifyInterval, cfg.syncInterval]) @@ -883,14 +1246,28 @@ export function startVerifyScheduler(): void { schedulerSignature = signature if (!cfg.enable) return const delay = (minutes: number, fallback: number) => Number.isFinite(minutes) && minutes > 0 ? Math.min(2147483647, Math.max(60000, minutes * 60000)) : fallback * 60000 - verifyTimer = setInterval(() => { void verifyPending() }, delay(cfg.verifyInterval, 5)) - syncTimer = setInterval(() => { void runSyncNow().catch(e => console.warn('[OpenList] 定时同步失败:', e)) }, delay(cfg.syncInterval, 60)) + const checkDelay = delay(cfg.verifyInterval, 5), syncDelay = delay(cfg.syncInterval, 60) + nextScheduledCheck = Date.now() + checkDelay + nextScheduledSync = Date.now() + syncDelay + verifyTimer = setInterval(() => { nextScheduledCheck = Date.now() + checkDelay; void verifyPending() }, checkDelay) + syncTimer = setInterval(() => { nextScheduledSync = Date.now() + syncDelay; void runSyncNow().catch(e => console.warn('[OpenList] 定时同步失败:', e)) }, syncDelay) verifyTimer.unref() syncTimer.unref() + connectionTimer = setInterval(() => { + const status = getOpenlistConnection() + if (status.nextProbeAt && Date.now() >= status.nextProbeAt) void probeOpenlistConnection().then(result => { + if (result.state === 'online') void verifyPending() + }).catch(error => console.warn('[OpenList] 连接探测失败:', error)) + }, 10000) + connectionTimer.unref() void verifyPending() } export function stopVerifyScheduler(): void { + if (connectionTimer) clearInterval(connectionTimer) + connectionTimer = null + nextScheduledCheck = 0 + nextScheduledSync = 0 if (verifyTimer) { clearInterval(verifyTimer) verifyTimer = null @@ -930,7 +1307,15 @@ function walkLocalAll(dir: string, map = new Map(), root = dir): function localChanged(row: any): boolean { if (!fs.existsSync(row.local_path)) return false const st = fs.statSync(row.local_path) - return row.local_hash ? hashFile(row.local_path) !== row.local_hash : row.size !== st.size || row.local_mtime == null || st.mtimeMs !== row.local_mtime + if (!row.local_hash) return row.size !== st.size || row.local_mtime == null || st.mtimeMs !== row.local_mtime + const changed = hashFile(row.local_path) !== row.local_hash + const after = fs.statSync(row.local_path) + if (after.size !== st.size || after.mtimeMs !== st.mtimeMs) return true + // 后台已确认内容相同,更新观察基线,避免仅 touch 后永久显示待上传。 + if (!changed && row.id && (row.size !== st.size || row.local_mtime !== st.mtimeMs)) { + setBackupStatus(row.id, row.status, { size: st.size, localMtime: st.mtimeMs }) + } + return changed } async function downloadDirection(entry: UserBackupCfg, cl: OpenListClient, cfg: BackupConfig, folderType: 'cache' | 'music' | 'list', summary: SyncSummary): Promise { @@ -942,6 +1327,7 @@ async function downloadDirection(entry: UserBackupCfg, cl: OpenListClient, cfg: catch (e) { if (e instanceof OpenListNotFoundError) return; throw e } const localMap = walkLocalAll(dir) const mode = effectiveMode(entry, cfg) + const recovery = await readRecoveryManifest(cl) for (const rf of remoteFiles) { if (rf.isDir) continue if (!rf.path.startsWith(remoteBase + '/')) throw new Error('远端文件不在配置的同步目录内') @@ -958,6 +1344,7 @@ async function downloadDirection(entry: UserBackupCfg, cl: OpenListClient, cfg: const isListFile = folderType === 'list' && rel.endsWith('.json') if (!isAud && !isListFile) continue // 附件由所属音频负责恢复 const existing = getBackupByLocalPath(entry.user, folderType, target) + const metadata = recovery?.cache.find(item => item.user === entry.user && item.folder === folderType && item.relative === rel) if (existing?.deleted_at || existing?.status === 'deleted' || enqueuedIds.has('f:' + existing?.id)) continue if (localMap.has(rel)) { const rm = parseMod(rf.modified) @@ -970,11 +1357,16 @@ async function downloadDirection(entry: UserBackupCfg, cl: OpenListClient, cfg: } const row = upsertBackupFile({ user: entry.user, folderType, localPath: target, remotePath: rf.path, mode, - size: existing?.size, source: existing?.source, songmid: existing?.songmid, - quality: existing?.quality, cacheMetadata: existing?.cache_metadata, + size: existing?.size, source: metadata?.source || existing?.source, songmid: metadata?.songmid || existing?.songmid, + quality: metadata?.quality || existing?.quality, cacheMetadata: metadata?.metadata || existing?.cache_metadata, direction: 'download', status: 'pending', }) - if (await downloadOne(row, cl, mode)) summary.downloaded++ + if (metadata) for (const att of metadata.attachments) { + const local = path.resolve(dir, att.relative) + if (!isSubPath(local, path.resolve(dir))) throw new Error('缓存附件路径越界') + upsertAttachment({ backupFileId: row.id, user: entry.user, folderType, localPath: local, remotePath: remoteBase + '/' + att.relative, category: att.category as any, size: att.size, status: 'pending' }) + } + if (await downloadOne(row, cl, mode, metadata)) summary.downloaded++ else summary.errors.push(entry.user + '/' + rel + ': 下载失败') } } @@ -982,15 +1374,17 @@ async function downloadDirection(entry: UserBackupCfg, cl: OpenListClient, cfg: async function uploadDirection(entry: UserBackupCfg, cl: OpenListClient, cfg: BackupConfig, folderType: 'cache' | 'music' | 'list', summary: SyncSummary): Promise { const dir = resolveUserDir(entry, folderType, cfg) if (!dir) return - const mode = effectiveMode(entry, cfg) + // list 是歌单/不喜欢快照,属受保护类型:upload-delete 对其不生效,入库模式固定为 upload + const mode = folderType === 'list' ? 'upload' : effectiveMode(entry, cfg) for (const lp of walkLocal(dir, folderType)) { const rel = path.relative(dir, lp).replace(/\\/g, '/') const remotePath = cl.resolveRemotePath(entry.user + '/' + folderType + '/' + rel) const existing = getBackupByLocalPath(entry.user, folderType, lp) if ((existing?.deleted_at || existing?.status === 'deleted') && !localChanged(existing)) continue - if (existing && (enqueuedIds.has('f:' + existing.id) || existing.status === 'uploading' || existing.status === 'completed')) continue - if (existing?.status === 'failed' && existing.retry_count >= MAX_AUTO_RETRIES) continue - const unchanged = existing?.status === 'verified' && !localChanged(existing) && existing.remote_path === remotePath + if (existing && enqueuedIds.has('f:' + existing.id)) continue + if (existing?.status === 'completed' && existing.remote_path === remotePath && !localChanged(existing) && await remoteMatchesBackup(cl, existing, remotePath)) continue + if (existing?.status === 'failed' && existing.retry_count >= MAX_AUTO_RETRIES && existing.remote_path === remotePath) continue + const unchanged = existing?.status === 'verified' && !localChanged(existing) && existing.remote_path === remotePath && await remoteMatchesBackup(cl, existing, remotePath) const item = getIndexItemByFilename(path.basename(lp), entry.user) let row = existing if (!unchanged) { @@ -1007,16 +1401,40 @@ async function uploadDirection(entry: UserBackupCfg, cl: OpenListClient, cfg: Ba } } +async function preserveDeletedBackup(row: any, cl: OpenListClient, remoteExists: boolean) { + const root = path.resolve(global.lx.dataPath, 'backups', 'openlist-recycle') + const dir = path.resolve(root, crypto.randomUUID()) + if (!isSubPath(dir, root) || dir === root) throw new Error('回收目录无效,已取消删除') + fs.mkdirSync(dir, { recursive: true }) + fs.writeFileSync(path.join(dir, 'manifest.json'), JSON.stringify({ at: Date.now(), source: metadataSource(), row, attachments: listAttachments(row.id) }), { mode: 0o600 }) + if (fs.existsSync(row.local_path)) { + fs.copyFileSync(row.local_path, path.join(dir, 'file')) + if (row.local_hash && hashFile(path.join(dir, 'file')) !== row.local_hash) throw new Error('保存回收副本时发现本地文件变化,已取消删除') + } + else if (remoteExists) await cl.download(row.remote_path, path.join(dir, 'file'), undefined, undefined, row.local_hash || undefined) + for (const att of listAttachments(row.id)) { + if (att.local_path && fs.existsSync(att.local_path)) fs.copyFileSync(att.local_path, path.join(dir, 'attachment-' + att.id)) + else if (att.remote_path) await cl.download(att.remote_path, path.join(dir, 'attachment-' + att.id)) + } +} async function deletionDetection(entry: UserBackupCfg, cl: OpenListClient, cfg: BackupConfig, folderType: 'cache' | 'music' | 'list', summary: SyncSummary): Promise { - for (const row of listBackupsByUserFolder(entry.user, folderType)) { + const rows = listBackupsByUserFolder(entry.user, folderType) + const observations = new Map> | null>() + let deletions = 0 + for (const row of rows) { + if (!['verified', 'completed'].includes(row.status) || enqueuedIds.has('f:' + row.id)) continue + let info = null + try { info = await cl.getFileInfo(row.remote_path) } catch (error) { if (!(error instanceof OpenListNotFoundError)) throw error } + observations.set(row.id, info) + if ((!info && fs.existsSync(row.local_path) && !localChanged(row)) || + (info && !fs.existsSync(row.local_path) && folderType !== 'cache' && getLocalFile(row.user, row.local_path) && row.local_hash)) deletions++ + } + if (deletions > 20) throw new Error('检测到超过 20 项删除,已暂停本目录同步;请核对本地目录和云端挂载后再处理') + for (const row of rows) { if (!['verified', 'completed'].includes(row.status) || enqueuedIds.has('f:' + row.id)) continue if (listAttachments(row.id).some(a => enqueuedIds.has(`a:${a.id}`))) continue const localExists = fs.existsSync(row.local_path) - let info: Awaited> | null = null - try { info = await cl.getFileInfo(row.remote_path) } - catch (e) { - if (!(e instanceof OpenListNotFoundError)) throw e // 网络/权限错误绝不能解释为删除 - } + const info = observations.get(row.id) || null if (!localExists && info) { // cache 是可淘汰的副本,缺失不代表用户希望删除云端。 // 显式清理登记也保留远端;只有音乐/快照的已同步文件缺失才传播删除。 @@ -1024,10 +1442,15 @@ async function deletionDetection(entry: UserBackupCfg, cl: OpenListClient, cfg: if (folderType === 'cache' || !registered || !row.local_hash) continue const rm = parseMod(info.modified) if (info.size !== row.size || (rm != null && row.remote_mtime != null && rm !== row.remote_mtime)) continue + await preserveDeletedBackup(row, cl, true) + const latest = await cl.getFileInfo(row.remote_path) + if (latest.size !== info.size || latest.modified !== info.modified || fs.existsSync(row.local_path)) continue await cl.remove(row.remote_path) } else if (!info && localExists) { // 本地有尚未同步的新修改时保留它,让上传方向重建远端。 if (localChanged(row)) continue + await preserveDeletedBackup(row, cl, false) + if (localChanged(row)) continue fs.unlinkSync(row.local_path) } else if (info || localExists) continue softDeleteLocalFile(row.user, row.local_path) @@ -1066,6 +1489,47 @@ async function backupSystemConfigFile(cl: OpenListClient, _cfg: BackupConfig, su summary.uploaded++ } +/** + * 额外保留带时间戳的系统配置「时间点副本」,用于回溯历史配置。 + * - 主镜像(_data/config.js 或 _system/config.js)文件名保持不变,恢复逻辑只扫 _data, + * 因此历史副本放在独立的 _system/config-history/ 下,不参与恢复、也不会被 backupType 识别。 + * - 文件名含内容指纹(md5 前 8 位):配置未变化时不会重复产生副本。 + * - 仅当系统配置确实被云端备份时才触发(统一模型的 system 类型 / 旧模型 backupSystemConfig), + * 并按 configBackup.retentionDays 清理过期的历史副本。 + */ +async function backupSystemConfigHistory(cl: OpenListClient, cfg: BackupConfig, summary: SyncSummary): Promise { + const lx: any = (globalThis as any).lx + const configPath: string = lx?.configPath || process.env.CONFIG_PATH || path.join(lx?.dataPath || '.', 'config.js') + if (!configPath || !fs.existsSync(configPath)) return + const selectedData = lx?.config?.['backup.openlist.types'] !== undefined + const shouldBackupSystem = selectedData + ? getBackupTypes('openlist', lx?.config as any).includes('system') + : cfg.backupSystemConfig + if (!shouldBackupSystem) return + + const fingerprint = crypto.createHash('md5').update(fs.readFileSync(configPath)).digest('hex').slice(0, 8) + const historyDir = cl.resolveRemotePath('_system/config-history') + await cl.mkdir(historyDir) + let files: Awaited> = [] + try { files = await cl.listFilesRecursive(historyDir) } catch (e) { if (!(e instanceof OpenListNotFoundError)) throw e } + const historyNameRe = /^config-\d{4}-\d{2}-\d{2}_\d{6}-[0-9a-f]{8}\.js$/ + const days = Number(lx?.config?.['configBackup.retentionDays']) + const cutoff = Date.now() - (Number.isFinite(days) && days > 0 ? days : 7) * 24 * 60 * 60 * 1000 + for (const f of files) { + if (f.isDir || !historyNameRe.test(f.name)) continue + const modified = parseMod(f.modified) + if (modified == null || modified >= cutoff) continue + await cl.remove(f.path) + } + // 进程重启后内存指纹丢失,用远端已有的同名指纹文件兜底去重 + if (files.some(f => !f.isDir && f.name.endsWith(`-${fingerprint}.js`) && historyNameRe.test(f.name) && (parseMod(f.modified) ?? 0) >= cutoff)) return + const d = new Date() + const pad = (n: number) => String(n).padStart(2, '0') + const stamp = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}` + await cl.upload(configPath, `${historyDir}/config-${stamp}-${fingerprint}.js`) + summary.uploaded++ +} + function dataBackupContext() { return { dataPath: global.lx.dataPath, configPath: global.lx.configPath, config: global.lx.config as any } } @@ -1077,14 +1541,15 @@ async function backupSelectedData(cl: OpenListClient, summary: SyncSummary): Pro for (const [relative, local] of scanBackupFiles(context, types)) { const existing = getBackupByLocalPath('_system', 'data', local) if ((existing?.deleted_at || existing?.status === 'deleted') && !localChanged(existing)) continue - if (existing && (enqueuedIds.has(`f:${existing.id}`) || existing.status === 'completed' || existing.status === 'uploading')) continue - if (existing?.status === 'failed' && existing.retry_count >= MAX_AUTO_RETRIES) continue const remotePath = cl.resolveRemotePath('_data/' + relative) + if (existing && enqueuedIds.has(`f:${existing.id}`)) continue + if (existing?.status === 'completed' && existing.remote_path === remotePath && !localChanged(existing) && await remoteMatchesBackup(cl, existing, remotePath)) continue + if (existing?.status === 'failed' && existing.retry_count >= MAX_AUTO_RETRIES && existing.remote_path === remotePath) continue if (existing?.status === 'verified' && existing.remote_path === remotePath && !localChanged(existing)) { try { const remote = await cl.getFileInfo(remotePath) const modified = parseMod(remote.modified) - if (remote.size === existing.size && (modified == null || existing.remote_mtime == null || modified === existing.remote_mtime)) continue + if (remote.size === existing.size && (modified == null || existing.remote_mtime == null || modified === existing.remote_mtime) && await remoteMatchesBackup(cl, existing, remotePath)) continue } catch (e: any) { if (!(e instanceof OpenListNotFoundError)) { summary.errors.push(relative + ': ' + (e?.message || e)); continue } } @@ -1095,36 +1560,79 @@ async function backupSelectedData(cl: OpenListClient, summary: SyncSummary): Pro } } -export async function restoreOpenlistData(): Promise<{ restored: number; errors: string[] }> { +export interface OpenlistRestoreOptions { + types?: BackupType[] + mode?: 'missing' | 'overwrite' + backupBeforeRestore?: boolean + preview?: boolean + planToken?: string + resumeTaskId?: string + snapshotId?: string +} + +export async function restoreOpenlistData(options: OpenlistRestoreOptions = {}) { + if (!options || typeof options !== 'object' || Array.isArray(options)) throw new Error('恢复参数无效') + const mode = options.mode ?? 'overwrite' + if (mode !== 'missing' && mode !== 'overwrite') throw new Error('恢复方式无效') + for (const key of ['preview', 'backupBeforeRestore'] as const) { + if (options[key] !== undefined && typeof options[key] !== 'boolean') throw new Error('恢复开关必须为布尔值') + } + if (options.planToken !== undefined && typeof options.planToken !== 'string') throw new Error('预览凭据无效') if (syncJob || verifyRunning || manualTransfers || activeUploads) throw new Error('OpenList 正在同步或校验,请稍后恢复') const cl = getClient() if (!cl) throw new Error('OpenList 未启用或未配置') const releaseRestore = beginCloudRestore() manualTransfers++ - const result = { restored: 0, errors: [] as string[] } + const result = { preview: options.preview === true, mode, restored: 0, scanned: 0, added: 0, overwritten: 0, merged: 0, skipped: 0, taskId: '', backupDir: '', planToken: '', warnings: [] as string[], files: [] as Array<{ path: string; action: RestoreFile['action'] }>, errors: [] as string[] } try { - const context = dataBackupContext() - const types = getBackupTypes('openlist', context.config) + const context = { ...dataBackupContext(), config: { ...global.lx.config } } + const types = options.types === undefined ? getBackupTypes('openlist', context.config) : normalizeBackupTypes(options.types) + if (!types.length) throw new Error('请至少选择一种恢复内容') + const localVersion = backupFileVersion const base = cl.resolveRemotePath('_data') - const files = await cl.listFilesRecursive(base) + const recovery = await readRecoveryManifest(cl, options.snapshotId) + if (!recovery) result.warnings.push('这是没有 SHA-256 清单的旧备份,无法核对历史内容指纹。建议先重新完成一次备份。') + const files: Array<{ path: string; size: number; modified: string; isDir: boolean; relative?: string; hash?: string }> = recovery + ? recovery.files.map(f => ({ path: cl.resolveRemotePath(f.object), size: f.size, modified: new Date(recovery.createdAt).toISOString(), isDir: false, relative: f.relative, hash: f.hash })) + : await cl.listFilesRecursive(base) // 先验证完整清单,避免先写一部分后才发现越界路径。 const selected = files.filter(file => !file.isDir).map(file => { - if (!file.path.startsWith(base + '/')) throw new Error('OpenList 备份文件超出数据目录') - const relative = file.path.slice(base.length + 1) + if (!recovery && !file.path.startsWith(base + '/')) throw new Error('OpenList 备份文件超出数据目录') + const relative = file.relative || file.path.slice(base.length + 1) const type = backupType(relative) if (!type || !types.includes(type)) return null - return { file, local: backupLocalPath(relative, context), relative } + const local = backupLocalPath(relative, context) + const version = localVersion(local) + const exists = version !== 'missing' + const action: RestoreFile['action'] = exists ? (mode === 'missing' ? 'skip' : 'overwrite') : 'add' + return { file, local, relative, version, action: action as RestoreFile['action'] } }).filter((item): item is NonNullable => !!item) // config.js 最后恢复,整个操作使用恢复开始时的类型和目标目录。 - selected.sort((a, b) => Number(a.relative === 'config.js') - Number(b.relative === 'config.js')) - for (const { file, local, relative } of selected) { - try { - fs.mkdirSync(path.dirname(local), { recursive: true }) - await cl.download(file.path, local) - const row = upsertBackupFile({ user: '_system', folderType: 'data', localPath: local, remotePath: file.path, name: relative, mode: 'upload', status: 'verified', direction: 'upload' }) - setBackupStatus(row.id, 'verified', { size: file.size, localMtime: fs.statSync(local).mtimeMs, localHash: hashFile(local), remoteMtime: parseMod(file.modified), error: null }) - result.restored++ - } catch (e: any) { result.errors.push(relative + ': ' + (e?.message || e)) } + selected.sort((a, b) => Number(a.relative === 'config.js') - Number(b.relative === 'config.js') || a.relative.localeCompare(b.relative)) + const transactionFiles: RestoreFile[] = selected.map(item => ({ relative: item.relative, local: item.local, remote: item.file.path, size: item.file.size, hash: item.file.hash, version: item.version, action: item.action })) + if (mode === 'missing') markRestoreDependencies(transactionFiles) + for (const file of transactionFiles) selected.find(item => item.relative === file.relative)!.action = file.action + result.files = selected.map(item => ({ path: item.relative, action: item.action })) + result.scanned = selected.length + result.added = selected.filter(item => item.action === 'add').length + result.overwritten = selected.filter(item => item.action === 'overwrite').length + result.merged = selected.filter(item => item.action === 'merge').length + result.skipped = selected.filter(item => item.action === 'skip').length + result.planToken = crypto.createHash('sha256').update(JSON.stringify({ endpoint: getConfig().endpoint, base, types, mode, backupBeforeRestore: options.backupBeforeRestore !== false, selected })).digest('hex') + if (options.preview) return result + if (options.planToken !== undefined && options.planToken !== result.planToken) throw new Error('云端文件、本地文件或恢复设置已变化,请重新预览') + if (options.backupBeforeRestore !== false && transactionFiles.some(f => ['overwrite', 'merge'].includes(f.action))) fs.mkdirSync(global.lx.getConfigBackupDir?.() || path.join(context.dataPath, 'backups'), { recursive: true }) + const transaction = await executeRestoreTransaction({ + files: transactionFiles, source: getConfig().endpoint + '|' + base, + options: { types, mode, backupBeforeRestore: options.backupBeforeRestore !== false, snapshotId: options.snapshotId }, + keepCopies: options.backupBeforeRestore !== false, resumeId: options.resumeTaskId, + download: (file, target, signal) => cl.download(file.remote, target, signal, undefined, file.hash), + merge: (file, staged) => mergeRestoreDependency(file, staged, transactionFiles), + }) + Object.assign(result, transaction) + if (!transaction.errors.length) for (const item of selected.filter(item => transactionFiles.find(f => f.relative === item.relative)?.action !== 'skip')) { + const row = upsertBackupFile({ user: '_system', folderType: 'data', localPath: item.local, remotePath: cl.resolveRemotePath('_data/' + item.relative), name: item.relative, mode: 'upload', status: 'verified', direction: 'upload' }) + setBackupStatus(row.id, 'verified', { size: item.file.size, localMtime: fs.statSync(item.local).mtimeMs, localHash: item.file.hash || hashFile(item.local), remoteMtime: null, error: null }) } return result } finally { manualTransfers--; releaseRestore() } @@ -1136,9 +1644,17 @@ export async function restoreOpenlistData(): Promise<{ restored: number; errors: */ export async function runSyncNow(opts: { user?: string; folder?: 'cache' | 'music' | 'list' } = {}): Promise { while (syncJob) await syncJob.catch(() => {}) + const releaseBackup = beginCloudBackup() const job = runSyncImpl(opts) syncJob = job - try { return await job } finally { if (syncJob === job) syncJob = null } + try { + const result = await job + lastSyncResult = { at: Date.now(), ...result } + return result + } catch (error: any) { + lastSyncResult = { at: Date.now(), uploaded: 0, downloaded: 0, deleted: 0, errors: [error.message] } + throw error + } finally { if (syncJob === job) syncJob = null; releaseBackup() } } async function runSyncImpl(opts: { user?: string; folder?: 'cache' | 'music' | 'list' }): Promise { @@ -1149,12 +1665,22 @@ async function runSyncImpl(opts: { user?: string; folder?: 'cache' | 'music' | ' const cl = getClient() if (!cl) throw new Error('OpenList 客户端不可用') const summary: SyncSummary = { uploaded: 0, downloaded: 0, deleted: 0, errors: [] } + // 路径/挂载错误在登记数十个任务之前报告,避免看起来连接正常、上传却全部失败。 + try { + try { await cl.testConnection() } catch (e: any) { + // 挂载下的新备份子目录可由首次备份创建;挂载本身缺失/无权限则直接报告。 + if (!(e instanceof OpenListNotFoundError) && !/no such file|object not found/i.test(e?.message || '')) throw e + await cl.mkdir(cl.resolveRemotePath('')) + await cl.testConnection() + } + await probeOpenlistConnection() + } catch (e: any) { await probeOpenlistConnection(); summary.errors.push(openlistError(e).message); return summary } const selectedData = (global.lx.config as any)['backup.openlist.types'] !== undefined for (const entry of cfg.userBackups) { if (!entry.enabled) continue if (opts.user && entry.user !== opts.user) continue const mode = effectiveMode(entry, cfg) - const folders: Array<'cache' | 'music' | 'list'> = opts.folder ? [opts.folder] : (selectedData ? ['cache'] : ['cache', 'music', 'list']) + const folders: Array<'cache' | 'music'> = opts.folder && opts.folder !== 'list' ? [opts.folder] : (selectedData ? ['cache'] : ['cache', 'music']) for (const ft of folders) { if (selectedData && ft !== 'cache') continue const dir = resolveUserDir(entry, ft, cfg) @@ -1168,11 +1694,16 @@ async function runSyncImpl(opts: { user?: string; folder?: 'cache' | 'music' | ' await uploadDirection(entry, cl, cfg, ft, summary) } } catch (e: any) { - summary.errors.push(`${entry.user}/${ft}: ${e?.message || e}`) + summary.errors.push(`${entry.user}/${ft}: ${openlistError(e).message}`) + if (await pauseConnection(e, cl)) return summary } } } if (selectedData && !opts.user && !opts.folder) await backupSelectedData(cl, summary) + // 歌单与不喜欢快照:对所有真实用户备份,不受 userBackups 用户勾选限制(传统模式默认走此路;统一数据模式仅手动 folder='list' 时补充) + if (!selectedData || opts.folder === 'list') { + await backupAllSnapshots(cl, cfg, summary, opts) + } while (activeUploads || uploadQueue.length) await sleep(20) for (const row of listBackupByStatus('failed')) { if (rowEnabled(row, cfg) && (!opts.user || row.user === opts.user) && (!opts.folder || row.folder_type === opts.folder)) summary.errors.push(row.user + '/' + row.name + ': ' + row.error) @@ -1184,6 +1715,17 @@ async function runSyncImpl(opts: { user?: string; folder?: 'cache' | 'music' | ' summary.errors.push('system-config: ' + (e?.message || e)) } } + // 全量同步时额外留一份带时间戳的系统配置时间点副本(主镜像文件名不变) + if (!opts.user && !opts.folder) { + try { + await backupSystemConfigHistory(cl, cfg, summary) + } catch (e: any) { + summary.errors.push('system-config-history: ' + (e?.message || e)) + } + } + if (!summary.errors.length && !opts.user && !opts.folder) { + try { await publishRecoveryManifest(cl) } catch (e: any) { summary.errors.push('recovery-manifest: ' + (e?.message || e)) } + } return summary } @@ -1192,18 +1734,58 @@ async function runSyncImpl(opts: { user?: string; folder?: 'cache' | 'music' | ' export function listBackups(): any[] { ensureBackupTables() const db = getBackupDb() - // 本地是否存在:由 openlist_local_files 表(排除软删除)通过 (user, local_path) 关联 JOIN 得出, - // 不再依赖 fs.existsSync,也无需前端二次请求。 + const observations = metadataObservations(metadataSource()) + // 登记表仅表示曾经落盘;手动删除和其他删除入口不一定更新登记。 const files = db.prepare(` - SELECT f.*, - (SELECT 1 FROM openlist_local_files l - WHERE l.user = f.user AND l.local_path = f.local_path AND l.deleted_at IS NULL - LIMIT 1) AS local_exists + SELECT f.* FROM openlist_backup_files f WHERE f.deleted_at IS NULL ORDER BY f.updated_at DESC `).all() as any[] - return files.map((f) => ({ ...f, local_exists: !!f.local_exists, attachments: listAttachments(f.id) })) + // Only persisted content checks for the current source and exact backup fingerprint count. + const checks = new Map() + if (getClient()) { + const context = integrityContext() + for (const result of getIntegrityStatus(context.files, context.source).results) checks.set(result.key, result) + } + return files.map((f) => { + // 数据备份内部统一登记为 _system;展示时按 music、音源和用户快照路径还原所属账号。 + // 仅覆盖展示层 user 字段,不改写 DB,避免破坏 openlist_local_files 的 user 一致性与唯一约束。 + const user = displayBackupUser(f) + const relative = f.folder_type === 'data' ? (f.name || f.remote_path.split('/_data/')[1]) : '' + const check = checks.get(String(f.id)) + let localExists: boolean | null = false + let localState = 'missing' + try { + const stat = f.local_path ? fs.statSync(f.local_path) : null + localExists = !!stat?.isFile() + localState = !localExists ? 'missing' : f.local_mtime == null ? 'untracked' + : stat!.size !== f.size || stat!.mtimeMs !== f.local_mtime ? 'changed' : 'same' + } + catch (error: any) { + // 权限或 I/O 错误不能当作文件不存在。 + localExists = ['ENOENT', 'ENOTDIR'].includes(error?.code) ? false : null + localState = localExists === null ? 'error' : 'missing' + } + const observed = observations(f.remote_path) + const observation = observed?.baseline === JSON.stringify([f.size, f.local_hash, f.remote_mtime]) ? observed : undefined + const localObservation = observations(f.remote_path, 'local') + const unavailable = ['unavailable', 'auth_error'].includes(getOpenlistConnection().state) + const remoteState = unavailable ? 'unavailable' : observation?.state || 'unchecked' + const cfg = getConfig(), mode = effectiveModeFromRow(f, cfg) + const enabled = rowEnabled(f, cfg) + let taskState = 'completed' + if (!enabled) taskState = 'paused' + else if (f.status === 'uploading') taskState = 'uploading' + else if (localState === 'error' || ['error', 'unknown'].includes(remoteState)) taskState = 'attention' + else if (f.status === 'failed') taskState = 'failed' + else if (['changed', 'missing'].includes(remoteState)) { + taskState = mode === 'download' ? 'download' : mode === 'sync' ? 'attention' : localExists ? 'pending' : 'attention' + } else if (localState === 'changed') taskState = mode === 'download' ? 'attention' : 'pending' + else if (f.status === 'pending') taskState = mode === 'download' ? 'download' : 'pending' + const stale = check?.result === 'stale' + return { ...f, error_raw: f.error || observation?.error || null, error: f.error || observation?.error ? openlistError(f.error || observation?.error).message : null, task_state: taskState, sync_enabled: enabled, sync_mode: mode, local_state: localState, local_checked_at: Date.now(), last_manual_local_check: localObservation?.baseline === JSON.stringify([f.size, f.local_hash, f.remote_mtime]) ? localObservation.checkedAt : null, remote_state: remoteState, remote_error: observation?.error || null, remote_checked_at: observation?.checkedAt || null, verification_stale: stale, user, content_verification: check?.result || null, content_verified_at: check?.result === 'matched' ? check.checkedAt : null, backup_type: relative ? backupType(relative) : null, local_exists: localExists, attachments: listAttachments(f.id) } + }) } /** @@ -1215,23 +1797,26 @@ export async function retryBackup(id: number): Promise<{ ok: boolean; message?: const row = getBackupById(id) if (!row || row.deleted_at || row.status === 'deleted') return { ok: false, message: '备份记录不存在或已删除' } if (!rowEnabled(row, cfg)) return { ok: false, message: '该用户或目录未启用 OpenList 同步' } - if (syncJob || verifyRunning || enqueuedIds.has('f:' + id)) return { ok: false, message: '文件正在同步或校验,请稍后重试' } + if (syncJob || verifyRunning || manualTransfers || isCloudRestoreRunning() || enqueuedIds.has('f:' + id)) return { ok: false, message: '文件正在同步或校验,请稍后重试' } const cl = getClient() if (!cl) return { ok: false, message: 'OpenList 客户端不可用' } + if (!await connectionReady(cl)) return { ok: false, message: getOpenlistConnection().message } const mode = effectiveModeFromRow(row, cfg) const download = row.direction === 'download' || mode === 'download' // 不以“路径存在”判断成功。上传必须重新传输并确认大小/本地指纹;下载必须完整落盘。 + const releaseBackup = beginCloudBackup() enqueuedIds.add('f:' + id) manualTransfers++ try { const ok = download ? await downloadOne(row, cl, mode) : await uploadOne(row, cl, mode) if (!ok) return { ok: false, message: getBackupById(id)?.error || '重试失败' } - return { ok: true, message: download ? '已重新下载' : '已重新上传,等待校验' } + return { ok: true, message: download ? '已重新下载' : '已重新上传并检查大小' } } catch (e: any) { return { ok: false, message: String(e?.message || e) } } finally { enqueuedIds.delete('f:' + id) manualTransfers-- + releaseBackup() } } @@ -1247,66 +1832,64 @@ export async function deleteOpenlistBackups(ids: number[], skipMainRemote = fals remoteFailed: string[] }> { if (!skipMainRemote && (syncJob || verifyRunning || manualTransfers)) throw new Error('OpenList 正在同步或校验,请稍后删除') - const db = getBackupDb() - const cl = getClient() - const ts = Date.now() - const remoteFailed: string[] = [] - let deleted = 0 - let remoteRemoved = 0 - for (const id of ids) { - const row = getBackupById(id) - if (!row || row.deleted_at) continue - if (enqueuedIds.has(`f:${id}`)) { remoteFailed.push(`${row.remote_path} (文件正在上传,请稍后删除)`); continue } - const atts = listAttachments(id) - if (atts.some(a => enqueuedIds.has(`a:${a.id}`))) { remoteFailed.push(`${row.remote_path} (附件正在上传,请稍后删除)`); continue } - // 先软删除附件记录(避免后续远程引用计数把同属本记录的附件误判为「被其它活跃记录引用」) - // 附件:删远端(仅当无其它活跃记录引用同一远端路径)+ 删本地 - for (const a of atts) { - if (unlinkAttachment(id, a.id) > 0) continue - db.prepare('UPDATE openlist_backup_attachments SET deleted_at = ?, status = ? WHERE id = ?').run(ts, 'deleted', a.id) - if (a.remote_path) { - if (cl && countRemotePathRefs(a.remote_path, id, a.id) === 0) { - try { - await cl.remove(a.remote_path) - remoteRemoved++ - } catch (e: any) { - remoteFailed.push(`${a.remote_path} (${e?.message || e})`) - } - } - } - if (a.local_path && fs.existsSync(a.local_path) && countLocalPathRefs(a.local_path, id, a.id) === 0) { - try { fs.unlinkSync(a.local_path); softDeleteLocalFile(row.user, a.local_path) } catch { /* ignore */ } - } - } - // 软删除主记录 - db.prepare( - 'UPDATE openlist_backup_files SET deleted_at = ?, status = ?, error = NULL WHERE id = ?', - ).run(ts, 'deleted', id) - // 主文件远端:删远端(仅当无其它活跃记录引用同一远端路径) - if (row.remote_path && !skipMainRemote) { - if (cl && countRemotePathRefs(row.remote_path, id, -1) === 0) { + const release = skipMainRemote ? () => {} : beginCloudRestore() + try { + const db = getBackupDb() + const cl = getClient() + const ts = Date.now() + const remoteFailed: string[] = [] + let deleted = 0 + let remoteRemoved = 0 + for (const id of ids) { + const row = getBackupById(id) + if (!row || row.deleted_at) continue + if (enqueuedIds.has(`f:${id}`)) { remoteFailed.push(`${row.remote_path} (文件正在上传,请稍后删除)`); continue } + const atts = listAttachments(id) + if (atts.some(a => enqueuedIds.has(`a:${a.id}`))) { remoteFailed.push(`${row.remote_path} (附件正在上传,请稍后删除)`); continue } + // 远端删除成功后才更改记录与本地文件;失败保留关联,允许原 ID 重试。 + const paths = new Set(atts.filter(a => countRemotePathRefs(a.remote_path, id, a.id) === 0).map(a => a.remote_path)) + if (!skipMainRemote && countRemotePathRefs(row.remote_path, id, -1) === 0) paths.add(row.remote_path) + let failed = false + for (const remote of paths) { + if (!remote) continue try { - await cl.remove(row.remote_path) + if (!cl) throw new Error('OpenList 未连接') + await cl.remove(remote) remoteRemoved++ } catch (e: any) { - remoteFailed.push(`${row.remote_path} (${e?.message || e})`) + if (e instanceof OpenListNotFoundError) continue + remoteFailed.push(`${remote} (${e?.message || e})`) + failed = true + break + } + } + if (failed) continue + // 先软删除附件记录(避免后续远程引用计数把同属本记录的附件误判为「被其它活跃记录引用」) + // 附件:删远端(仅当无其它活跃记录引用同一远端路径)+ 删本地 + for (const a of atts) { + if (unlinkAttachment(id, a.id) > 0) continue + db.prepare('UPDATE openlist_backup_attachments SET deleted_at = ?, status = ? WHERE id = ?').run(ts, 'deleted', a.id) + if (a.local_path && fs.existsSync(a.local_path) && countLocalPathRefs(a.local_path, id, a.id) === 0) { + try { fs.unlinkSync(a.local_path); softDeleteLocalFile(row.user, a.local_path) } catch { /* ignore */ } } - } else if (!cl) { - remoteFailed.push(`${row.remote_path} (OpenList 未连接,跳过远端删除)`) } + // 软删除主记录 + db.prepare( + 'UPDATE openlist_backup_files SET deleted_at = ?, status = ?, error = NULL WHERE id = ?', + ).run(ts, 'deleted', id) + // 主文件本地:仅 cache 类型物理删除本地临时副本(music 为用户实际文件,仅软删记录) + if (row.folder_type === 'cache' && row.local_path && fs.existsSync(row.local_path) && countLocalPathRefs(row.local_path, id, -1) === 0) { + try { fs.unlinkSync(row.local_path); softDeleteLocalFile(row.user, row.local_path) } catch { /* ignore */ } + } + removeLocalCache(row) + deleted++ } - // 主文件本地:仅 cache 类型物理删除本地临时副本(music 为用户实际文件,仅软删记录) - if (row.folder_type === 'cache' && row.local_path && fs.existsSync(row.local_path) && countLocalPathRefs(row.local_path, id, -1) === 0) { - try { fs.unlinkSync(row.local_path); softDeleteLocalFile(row.user, row.local_path) } catch { /* ignore */ } + if (remoteFailed.length) { + console.warn( + '[openlist] 删除备份记录时以下远端文件未能从网盘删除(失败记录及本地文件已保留,可重试):\n' + + remoteFailed.join('\n'), + ) } - removeLocalCache(row) - deleted++ - } - if (remoteFailed.length) { - console.warn( - '[openlist] 删除备份记录时以下远端文件未能从网盘删除(记录已软删,但网盘文件残留):\n' + - remoteFailed.join('\n'), - ) - } - return { deleted, remoteRemoved, remoteFailed } + return { deleted, remoteRemoved, remoteFailed } + } finally { release(); pumpUploads() } } diff --git a/src/server/openlistBackupDb.ts b/src/server/openlist/openlistBackupDb.ts similarity index 97% rename from src/server/openlistBackupDb.ts rename to src/server/openlist/openlistBackupDb.ts index 71e525f7..097cf934 100644 --- a/src/server/openlistBackupDb.ts +++ b/src/server/openlist/openlistBackupDb.ts @@ -14,6 +14,7 @@ export type AttachmentCategory = 'image' | 'lyric' | 'other' export type AttachmentStatus = 'pending' | 'uploading' | 'completed' | 'verified' | 'failed' | 'deleted' export interface AttachmentRow { + local_hash: string | null id: number backup_file_id: number user: string @@ -94,6 +95,9 @@ export function getBackupDb(): DatabaseSync { const db = new DatabaseSync(dbPath) db.exec('PRAGMA journal_mode = WAL') db.exec('PRAGMA synchronous = NORMAL') + // WAL 允许多读单写:不设置 busy_timeout 时第二个写事务会立刻抛 SQLITE_BUSY(database is locked), + // 多进程共用同一数据目录(多开 dev 实例、备份进程与页面同时写)时会频繁撞锁,这里改为等待重试。 + db.exec('PRAGMA busy_timeout = 8000') db.exec(`CREATE TABLE IF NOT EXISTS openlist_backup_files ( id INTEGER PRIMARY KEY AUTOINCREMENT, user TEXT NOT NULL, @@ -147,6 +151,7 @@ export function getBackupDb(): DatabaseSync { deleted_at INTEGER, UNIQUE(user, folder_type, local_path) )`) + try { db.exec('ALTER TABLE openlist_backup_attachments ADD COLUMN local_hash TEXT') } catch { /* 已存在 */ } // 老库兼容:补齐附件唯一约束(已存在重复行则忽略,手动 upsert 仍能去重) try { db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_ol_att_uniq ON openlist_backup_attachments(user, folder_type, local_path)') @@ -331,13 +336,14 @@ export function upsertAttachment(input: UpsertAttachmentInput): AttachmentRow { export function setAttachmentStatus( id: number, status: AttachmentStatus, - opts: { error?: string | null; size?: number | null } = {}, + opts: { error?: string | null; size?: number | null; localHash?: string | null } = {}, ): void { const db = getBackupDb() const sets: string[] = ['status = ?', 'updated_at = ?'] const params: any[] = [status, Date.now()] if (opts.error !== undefined) { sets.push('error = ?'); params.push(opts.error) } if (opts.size !== undefined) { sets.push('size = ?'); params.push(opts.size) } + if (opts.localHash !== undefined) { sets.push('local_hash = ?'); params.push(opts.localHash) } params.push(id) db.prepare(`UPDATE openlist_backup_attachments SET ${sets.join(', ')} WHERE id = ?`).run(...params) } diff --git a/src/server/openlistClient.ts b/src/server/openlist/openlistClient.ts similarity index 70% rename from src/server/openlistClient.ts rename to src/server/openlist/openlistClient.ts index 10c7771c..e3b0dd28 100644 --- a/src/server/openlistClient.ts +++ b/src/server/openlist/openlistClient.ts @@ -2,8 +2,8 @@ * OpenList / alist 存储客户端(音频备份专用)。 * 契约已对真实实例(天翼云盘 tyCloud 挂载)实测确认: * - 登录 POST /api/auth/login -> data.token - * - 上传 PUT /api/fs/put(file-path 头 + 原始字节体),部分构建写完文件仍返 500, - * 因此成功与否以 getInfo 轮询确认文件存在为准 + * - 上传 PUT /api/fs/put(file-path 头 + 原始字节体),成功响应后用元信息确认大小。 + * 明确报错不凭旧同名文件认定成功;普通上传不回读正文。 * - 校验/直链 POST /api/fs/get -> { raw_url, size, name } * - 建目录 POST /api/fs/mkdir;删除 POST /api/fs/remove */ @@ -12,6 +12,7 @@ import path from 'node:path' import { randomUUID, createHash } from 'node:crypto' import { Readable } from 'node:stream' import { pipeline } from 'node:stream/promises' +import { hashBackupFile } from '@/utils/backupFiles' export interface OpenListClientConfig { endpoint: string @@ -27,6 +28,7 @@ export interface OpenListFileInfo { openlistUrl?: string name?: string modified?: string + sha256?: string } export class OpenListNotFoundError extends Error {} @@ -75,7 +77,7 @@ export class OpenListClient { }) const data: any = await resp.json().catch(() => ({})) if (data?.code !== 200 || !data?.data?.token) { - throw new Error('OpenList 登录失败:' + (data?.message || resp.status)) + throw new Error('OpenList 登录失败(' + (data?.code || resp.status) + '):' + (data?.message || resp.status)) } this.token = data.data.token return this.token! @@ -85,9 +87,9 @@ export class OpenListClient { return { Authorization: this.token || this.directToken || '' } } - private async request(route: string, init: RequestInit): Promise { + private async request(route: string, init: RequestInit, bodyFactory?: () => any): Promise { await this.ensureToken(init.signal || undefined) - const send = () => fetch(`${this.endpoint}${route}`, { ...init, headers: { ...init.headers, ...this.authHeader() } }) + const send = () => fetch(`${this.endpoint}${route}`, { ...init, ...(bodyFactory ? { body: bodyFactory() } : {}), headers: { ...init.headers, ...this.authHeader() } }) const response = await send() const data: any = await response.clone().json().catch(() => ({})) if (!this.directToken && (response.status === 401 || data.code === 401)) { @@ -121,8 +123,8 @@ export class OpenListClient { signal: signal ?? AbortSignal.timeout(META_TIMEOUT_MS), }) const data: any = await resp.json().catch(() => ({})) - if (resp.status === 404 || data?.code === 404) throw new OpenListNotFoundError('OpenList 目录不存在:' + p) - if (data?.code !== 200) throw new Error('OpenList 列目录失败:' + (data?.message || resp.status)) + if (resp.status === 404 || data?.code === 404 || (data?.code !== 200 && /object not found|no such file/i.test(data?.message || ''))) throw new OpenListNotFoundError('OpenList 目录不存在:' + p) + if (data?.code !== 200) throw new Error('OpenList 列目录失败(' + (data?.code || resp.status) + '):' + (data?.message || resp.status)) const d = data.data || {} const items: any[] = d.content || [] all.push(...items) @@ -163,18 +165,20 @@ export class OpenListClient { /** * 上传本地文件到完整远端路径(remotePath)。 - * 采用 OpenList PUT + file-path 头 + 原始字节体;以 getInfo 确认落盘,规避服务端 500-on-success。 + * 采用 OpenList PUT + file-path 头 + 原始字节体;成功响应后以 getInfo 确认大小。 */ - async upload(localPath: string, remotePath: string, signal?: AbortSignal): Promise { + async upload(localPath: string, remotePath: string, signal?: AbortSignal, baselineHash?: string): Promise { await this.ensureToken() - const buf = await fs.promises.readFile(localPath) - const expectedSize = buf.length + const expectedSize = (await fs.promises.stat(localPath)).size + const expectedHash = hashBackupFile(localPath) + if (baselineHash && baselineHash !== expectedHash) throw new Error('本地文件与待备份版本不一致,请重新备份') const headers: Record = { ...this.authHeader(), 'Content-Type': 'application/octet-stream', - // HTTP 头值必须是 Latin-1(ByteString),含中文等非 ASCII 路径需先按 UTF-8 字节映射为 Latin-1, - // 服务端再以 UTF-8 还原,否则 fetch 会抛 "Cannot convert argument to a ByteString" 导致上传失败。 - 'file-path': Buffer.from(remotePath, 'utf8').toString('latin1'), + 'Content-Length': String(expectedSize), + // OpenList 对 File-Path 做 URL 解码。逐段编码同时兼容中文和文件名中的字面百分号, + // 避免 %E... 被解码成另一个文件名,上传成功却永远轮询不到原路径。 + 'file-path': encodePath(remotePath), overwrite: 'true', password: '', 'last-modified': goTime(), @@ -182,31 +186,34 @@ export class OpenListClient { const resp = await this.request('/api/fs/put', { method: 'PUT', headers, - body: buf, + duplex: 'half', signal: signal ?? AbortSignal.timeout(TRANSFER_TIMEOUT_MS), - }) + } as RequestInit, () => fs.createReadStream(localPath)) const text = await resp.text() let data: any = {} try { data = JSON.parse(text) } catch { /* 非 JSON 响应 */ } const code = Number(data?.code || resp.status) - if (code !== 200 && code !== 500) throw new Error('OpenList 上传被拒绝:' + (data?.message || code)) + if (code !== 200) throw new Error('OpenList 上传被拒绝:' + (data?.message || code)) const info = await this.pollFileInfo(remotePath, TRANSFER_TIMEOUT_MS, expectedSize) if (info && info.size != null) { + if (hashBackupFile(localPath) !== expectedHash) { + throw new Error('OpenList 上传期间本地文件发生变化') + } return { ...info, name: info.name || path.basename(remotePath) } } throw new Error('OpenList 上传失败:' + (data?.message || resp.status) + ' | body=' + text.slice(0, 300)) } - async getFileInfo(remotePath: string): Promise { - await this.ensureToken() + async getFileInfo(remotePath: string, signal?: AbortSignal): Promise { + await this.ensureToken(signal) const resp = await this.request('/api/fs/get', { method: 'POST', headers: { 'Content-Type': 'application/json', ...this.authHeader() }, body: JSON.stringify({ path: remotePath, password: '' }), - signal: AbortSignal.timeout(META_TIMEOUT_MS), + signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(META_TIMEOUT_MS)]) : AbortSignal.timeout(META_TIMEOUT_MS), }) const data: any = await resp.json().catch(() => ({})) - if (resp.status === 404 || data?.code === 404) throw new OpenListNotFoundError('OpenList 文件不存在:' + remotePath) + if (resp.status === 404 || data?.code === 404 || (data?.code !== 200 && /object not found|no such file/i.test(data?.message || ''))) throw new OpenListNotFoundError('OpenList 文件不存在:' + remotePath) if (data?.code !== 200) throw new Error('OpenList 获取文件信息失败:' + (data?.message || resp.status)) const d = data.data || {} return { @@ -215,6 +222,7 @@ export class OpenListClient { openlistUrl: `${this.endpoint}/d/${encodePath(remotePath.replace(/^\/+/, ''))}`, name: d.name, modified: d.modified || undefined, + sha256: /^[a-f0-9]{64}$/i.test(d.hash_info?.sha256 || d.hash_info?.SHA256 || '') ? String(d.hash_info.sha256 || d.hash_info.SHA256).toLowerCase() : undefined, } } @@ -229,18 +237,38 @@ export class OpenListClient { signal: AbortSignal.timeout(META_TIMEOUT_MS), }) const data: any = await resp.json().catch(() => ({})) - if (data?.code !== 200) throw new Error('OpenList 删除失败:' + (data?.message || resp.status)) + if (resp.status === 404 || data?.code === 404 || (data?.code !== 200 && /object not found|no such file/i.test(data?.message || ''))) throw new OpenListNotFoundError('OpenList 文件不存在:' + remotePath) + if (!resp.ok || data?.code !== 200) throw new Error('OpenList 删除失败:' + (data?.message || data?.code || resp.status)) } /** 删除唯一的本地副本前,校验远端实际字节,不能只相信同名文件的大小。 */ - async verifyFile(remotePath: string, expectedHash: string): Promise { - const info = await this.getFileInfo(remotePath) - if (!info.rawUrl) return false - const resp = await fetch(info.rawUrl, { signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS) }) - if (!resp.ok || !resp.body) { await resp.body?.cancel(); return false } + async verifyFile(remotePath: string, expectedHash: string, signal?: AbortSignal): Promise { + return await this.hashRemoteFile(remotePath, signal) === expectedHash + } + + async hashRemoteFile(remotePath: string, signal?: AbortSignal, onBytes?: (bytes: number) => void): Promise { + const info = await this.getFileInfo(remotePath, signal) + if (info.sha256) return info.sha256 + if (!info.rawUrl) throw new Error('OpenList 文件没有下载地址') + const resp = await fetch(info.rawUrl, { signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(TRANSFER_TIMEOUT_MS)]) : AbortSignal.timeout(TRANSFER_TIMEOUT_MS) }) + if (!resp.ok || !resp.body) { await resp.body?.cancel(); throw new Error('OpenList 内容校验下载失败:' + resp.status) } const hash = createHash('sha256') - for await (const chunk of Readable.fromWeb(resp.body as any)) hash.update(chunk) - return hash.digest('hex') === expectedHash + for await (const chunk of Readable.fromWeb(resp.body as any)) { hash.update(chunk); onBytes?.(chunk.length) } + return hash.digest('hex') + } + + async readJSON(remotePath: string): Promise { + const info = await this.getFileInfo(remotePath) + if (!info.rawUrl || info.size == null || info.size > 16 * 1024 * 1024) throw new Error('OpenList 恢复清单大小无效') + const response = await fetch(info.rawUrl, { signal: AbortSignal.timeout(TRANSFER_TIMEOUT_MS) }) + if (!response.ok || !response.body) throw new Error('OpenList 读取恢复清单失败') + const parts: Buffer[] = []; let size = 0 + for await (const chunk of Readable.fromWeb(response.body as any)) { + size += chunk.length + if (size > 16 * 1024 * 1024) throw new Error('OpenList 恢复清单超过大小限制') + parts.push(Buffer.from(chunk)) + } + return JSON.parse(Buffer.concat(parts).toString('utf8')) } /** @@ -281,7 +309,7 @@ export class OpenListClient { /** * 下载远端文件到本地路径(经由 getInfo 拿到的直链 raw_url 流式写入)。 */ - async download(remotePath: string, localPath: string, signal?: AbortSignal): Promise { + async download(remotePath: string, localPath: string, signal?: AbortSignal, beforeReplace?: () => void, expectedHash?: string): Promise { await this.ensureToken() const info = await this.getFileInfo(remotePath) const url = info.rawUrl @@ -295,6 +323,8 @@ export class OpenListClient { if (info.size == null || (await fs.promises.stat(tmpPath)).size !== info.size) { throw new Error('OpenList 下载文件大小与远端不一致') } + if (expectedHash && hashBackupFile(tmpPath) !== expectedHash) throw new Error('OpenList 下载内容 SHA-256 校验失败') + beforeReplace?.() await fs.promises.rename(tmpPath, localPath) } catch (e) { await fs.promises.rm(tmpPath, { force: true }).catch(() => {}) diff --git a/src/server/openlist/openlistErrors.ts b/src/server/openlist/openlistErrors.ts new file mode 100644 index 00000000..7df836d8 --- /dev/null +++ b/src/server/openlist/openlistErrors.ts @@ -0,0 +1,16 @@ +/** Stable, Chinese-facing errors; raw diagnostics remain available separately. */ +export function openlistError(error: any) { + const raw = String(error?.message || error || '') + const code = String(error?.code || error?.cause?.code || '') + const text = code + ' ' + raw + if (/401|unauthorized|password|密码/i.test(text)) return { code: 'auth', message: '登录失效,请检查账号或令牌', connection: true } + if (/403|forbidden/i.test(text)) return { code: 'permission', message: '无访问权限,请检查账号和备份目录权限', connection: true } + if (/timeout|timed out|abort|超时/i.test(text)) return { code: 'timeout', message: '请求超时,等待连接恢复后自动重试', connection: true } + if (/502|503|504/.test(text)) return { code: 'upstream', message: 'OpenList 或上游存储暂时不可用,等待恢复后自动重试', connection: true } + if (/fetch failed|ECONN|ENOTFOUND|EAI_AGAIN|socket|network|certificate|CERT_/i.test(text)) return { code: 'network', message: '无法连接 OpenList,请检查服务、网络或证书', connection: true } + if (/ENOENT|ENOTDIR/i.test(text)) return { code: 'local_missing', message: '本地源文件不存在,请检查文件路径', connection: false } + if (/EACCES|EPERM/i.test(text)) return { code: 'local_permission', message: '无法访问本地文件,请检查文件权限', connection: false } + if (/404|不存在|not found/i.test(text)) return { code: 'missing', message: '云端文件或目录不存在,请重新检查', connection: false } + if (/ENOSPC/i.test(text)) return { code: 'space', message: '本地磁盘空间不足,请清理后重试', connection: false } + return { code: 'operation', message: /[一-鿿]/.test(raw) ? raw : '操作失败,请查看详情中的原始错误', connection: false } +} diff --git a/src/server/openlist/openlistIntegrity.ts b/src/server/openlist/openlistIntegrity.ts new file mode 100644 index 00000000..c1f8ac20 --- /dev/null +++ b/src/server/openlist/openlistIntegrity.ts @@ -0,0 +1,94 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { OpenListClient, OpenListNotFoundError } from './openlistClient' + +export interface IntegrityFile { + key: string; name: string; remote: string; user: string; type: string + size: number; hash: string | null; version: string; changedAt?: number +} +type Result = IntegrityFile & { result: string; checkedAt: number; error?: string; remoteModified?: string; actualHash?: string } +interface Job { id: string; sourceId: string; state: string; total: number; done: number; bytes: number; estimatedBytes: number; current: string; createdAt: number; results: Result[] } +interface Store { results: Record; job?: Job } +let controller: AbortController | undefined +let activePath = '' +const plans = new Map() +const storePath = () => path.join(global.lx.dataPath, 'runtime', 'openlist-integrity.json') +function load(): Store { + try { + const store = JSON.parse(fs.readFileSync(storePath(), 'utf8')) as Store + if (store.job?.state === 'running' && (!controller || activePath !== storePath())) store.job.state = 'interrupted' + return store + } catch (e: any) { if (e.code === 'ENOENT') return { results: {} }; throw e } +} +function save(store: Store) { + const file = storePath(); fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file + '.tmp', JSON.stringify(store), { mode: 0o600 }); fs.renameSync(file + '.tmp', file) +} +const fingerprint = (file: IntegrityFile, source: string) => crypto.createHash('sha256').update(JSON.stringify([source, file.remote, file.size, file.hash, file.version])).digest('hex') +const sourceId = (source: string) => crypto.createHash('sha256').update(source).digest('hex') +export function integrityStatus(files: IntegrityFile[], source: string) { + const store = load(), current = new Map(files.map(f => [fingerprint(f, source), f])) + return { job: store.job?.sourceId === sourceId(source) ? store.job : null, results: Object.entries(store.results).filter(([key]) => current.has(key)).map(([key, result]) => { const file = current.get(key); return file?.changedAt && file.changedAt > result.checkedAt ? { ...result, result: 'stale' } : result }).sort((a, b) => b.checkedAt - a.checkedAt) } +} +export function prepareIntegrity(files: IntegrityFile[], source: string, options: { type?: string; user?: string; scope?: string }) { + if (controller) throw new Error('已有内容校验任务正在执行') + if (options.scope && !['changed', 'all', 'problems'].includes(options.scope)) throw new Error('校验范围无效') + const store = load() + const selected = files.filter(f => (!options.type || f.type === options.type) && (!options.user || f.user === options.user)).filter(f => { + const old = store.results[fingerprint(f, source)] + return options.scope === 'all' || (options.scope === 'problems' ? old && (old.result !== 'matched' || !!f.changedAt && f.changedAt > old.checkedAt) : !old || old.result !== 'matched' || !!f.changedAt && f.changedAt > old.checkedAt) + }) + const token = crypto.randomUUID() + const signature = JSON.stringify(files) + for (const [key, plan] of plans) if (plan.expires < Date.now()) plans.delete(key) + plans.set(token, { expires: Date.now() + 15 * 60_000, signature, files: selected, source }) + return { token, total: selected.length, bytes: selected.filter(f => f.hash).reduce((n, f) => n + f.size, 0), unknown: selected.filter(f => !f.hash).length } +} +export function startIntegrity(cl: OpenListClient, files: IntegrityFile[], source: string, token: string, release: () => void) { + if (controller) throw new Error('已有内容校验任务正在执行') + const plan = plans.get(token) + if (!plan || plan.expires < Date.now() || plan.source !== source || plan.signature !== JSON.stringify(files)) throw new Error('备份记录已变化或预览已过期,请重新预览') + if (!plan.files.length) throw new Error('没有需要校验的文件') + plans.delete(token) + const store = load() + const job: Job = { id: crypto.randomUUID(), sourceId: sourceId(source), state: 'running', total: plan.files.length, done: 0, bytes: 0, estimatedBytes: plan.files.filter(f => f.hash).reduce((n, f) => n + f.size, 0), current: '', createdAt: Date.now(), results: [] } + store.job = job; save(store) + const abort = new AbortController(); controller = abort; activePath = storePath() + void (async () => { + try { + for (const file of plan.files) { + abort.signal.throwIfAborted(); job.current = file.name; save(store) + const result: Result = { ...file, result: 'unavailable', checkedAt: Date.now() } + try { + if (!file.hash) result.error = '缺少该次备份的 SHA-256,不能用当前本地文件冒充上传基线;请重新备份以建立基线' + else { + const before = await cl.getFileInfo(file.remote, abort.signal) + if (before.size !== file.size) { result.result = 'mismatch'; result.error = '云端大小与备份记录不一致' } + else { + let lastSaved = Date.now() + const actual = await cl.hashRemoteFile(file.remote, abort.signal, bytes => { + job.bytes += bytes + if (Date.now() - lastSaved > 500) { save(store); lastSaved = Date.now() } + }) + const after = await cl.getFileInfo(file.remote, abort.signal) + if (before.size !== after.size || before.modified !== after.modified) throw new Error('校验期间云端文件发生变化,请重试') + result.remoteModified = after.modified; result.actualHash = actual + result.result = actual === file.hash ? 'matched' : 'mismatch' + if (result.result === 'mismatch') result.error = '云端 SHA-256 与该次备份记录不一致' + } + } + } catch (e: any) { + if (abort.signal.aborted) throw e + result.result = e instanceof OpenListNotFoundError ? 'missing' : 'error'; result.error = e.message + } + result.checkedAt = Date.now(); job.results.push(result); job.done++ + store.results[fingerprint(file, source)] = result; save(store) + } + job.state = 'completed' + } catch (e: any) { job.state = abort.signal.aborted ? 'cancelled' : 'failed'; job.current = e.message } + finally { if (job.state !== 'failed') job.current = ''; try { save(store) } finally { controller = undefined; activePath = ''; release() } } + })().catch(error => console.error('[OpenList] 校验任务记录写入失败:', error)) + return { jobId: job.id } +} +export function cancelIntegrity() { if (!controller) throw new Error('没有正在运行的内容校验'); controller.abort(new Error('用户停止校验')) } diff --git a/src/server/openlist/openlistMetadata.ts b/src/server/openlist/openlistMetadata.ts new file mode 100644 index 00000000..2d0d8369 --- /dev/null +++ b/src/server/openlist/openlistMetadata.ts @@ -0,0 +1,121 @@ +import { setTimeout as delay } from 'node:timers/promises' +import { openlistError } from './openlistErrors' +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { OpenListClient, OpenListNotFoundError } from './openlistClient' + +type Observation = { state: string; checkedAt: number; changedAt?: number; baseline: string; error?: string } +type Row = { id: number; local_path: string; remote_path: string; size: number | null; local_hash: string | null; local_mtime: number | null; remote_mtime: number | null } +type Job = { id: string; source: string; target: string; state: string; total: number; done: number; failed: number; startedAt: number; finishedAt?: number } +type Store = { observations: Record; job?: Job } +let controller: AbortController | undefined +let cachedFile = '', cachedStore: Store | undefined +let lastWrite = 0 +let saveTimer: ReturnType | undefined +const location = () => path.join(global.lx.dataPath, 'runtime', 'openlist-metadata.json') +const key = (source: string, remote: string, side: string) => crypto.createHash('sha256').update(JSON.stringify([source, remote, side])).digest('hex') +export const metadataBaseline = (row: Row) => JSON.stringify([row.size, row.local_hash, row.remote_mtime]) +function read(): Store { + const file = location() + if (cachedFile === file && cachedStore) return cachedStore + if (saveTimer) { clearTimeout(saveTimer); saveTimer = undefined } + cachedFile = file; cachedStore = undefined; lastWrite = 0 + try { cachedStore = JSON.parse(fs.readFileSync(file, 'utf8')) } + catch (error: any) { if (error.code === 'ENOENT') cachedStore = { observations: {} }; else throw error } + return cachedStore! +} +function write(store: Store, force = false) { + cachedStore = store + if (!force && Date.now() - lastWrite < 500) { + if (!saveTimer) saveTimer = setTimeout(() => { saveTimer = undefined; if (cachedFile !== location()) return; try { write(store, true) } catch (error) { console.error('[OpenList] 保存检查结果失败', error) } }, 500) + saveTimer?.unref() + return + } + if (saveTimer) { clearTimeout(saveTimer); saveTimer = undefined } + const file = location(); fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file + '.tmp', JSON.stringify(store), { mode: 0o600 }); fs.renameSync(file + '.tmp', file) + lastWrite = Date.now() +} +export function metadataObservations(source: string) { + const store = read() + return (remote: string, side = 'remote') => store.observations[key(source, remote, side)] +} +export function recordRemoteObservation(source: string, row: Row, info?: { size?: number; modified?: string }, error?: any) { + const store = read(), id = key(source, row.remote_path, 'remote'), baseline = metadataBaseline(row) + const modified = info?.modified ? new Date(info.modified).getTime() : NaN + const state = error ? error instanceof OpenListNotFoundError ? 'missing' : 'error' + : info?.size == null ? 'unknown' : info.size !== row.size || Number.isFinite(modified) && row.remote_mtime != null && modified !== row.remote_mtime ? 'changed' : 'same' + const previous = store.observations[id], checkedAt = Date.now() + const changedAt = previous?.baseline === baseline && previous.changedAt ? previous.changedAt + : ['changed', 'missing'].includes(state) ? checkedAt : undefined + store.observations[id] = { state, checkedAt, changedAt, baseline, error: error ? String(error.message || error) : undefined } + write(store) +} +export function metadataStatus(source: string) { + const job = read().job + return { job: job?.source === source ? { ...job, state: ['running', 'paused'].includes(job.state) && !controller ? 'interrupted' : job.state } : null } +} +export function cancelMetadata() { if (!controller) throw new Error('没有正在执行的文件检查'); controller.abort() } +export function startMetadata(source: string, rows: Row[], target: string, client: OpenListClient | null, ready?: () => Promise) { + if (controller) throw new Error('已有文件检查正在执行') + if (!['local', 'remote', 'both'].includes(target)) throw new Error('检查位置无效') + if (target !== 'local' && !client) throw new Error('请先启用并保存 OpenList 连接') + if (!rows.length) throw new Error('没有需要检查的记录') + const abort = controller = new AbortController() + const job: Job = { id: crypto.randomUUID(), source, target, state: 'running', total: rows.length, done: 0, failed: 0, startedAt: Date.now() } + try { const initial = read(); initial.job = job; write(initial, true) } catch (error) { controller = undefined; throw error } + void (async () => { + try { + for (const row of rows) { + if (target !== 'local' && ready) { + while (!abort.signal.aborted && !await ready()) { + job.state = 'paused'; const store = read(); store.job = job; write(store) + try { await delay(1000, undefined, { signal: abort.signal }) } catch { break } + } + job.state = 'running' + } + if (abort.signal.aborted) break + let failed = false + if (target !== 'remote') { + let state = 'missing', error: string | undefined + try { + const stat = await fs.promises.stat(row.local_path) + state = !stat.isFile() ? 'missing' : row.local_mtime == null ? 'untracked' : stat.size !== row.size || stat.mtimeMs !== row.local_mtime ? 'changed' : 'same' + } catch (e: any) { if (!['ENOENT', 'ENOTDIR'].includes(e.code)) { state = 'error'; error = e.message; failed = true } } + if (state === 'missing') failed = true + const store = read(); store.observations[key(source, row.remote_path, 'local')] = { state, error, checkedAt: Date.now(), baseline: metadataBaseline(row) }; write(store) + } + if (target !== 'local' && !abort.signal.aborted) { + while (!abort.signal.aborted) { + try { + const info = await client!.getFileInfo(row.remote_path, AbortSignal.any([abort.signal, AbortSignal.timeout(10000)])) + recordRemoteObservation(source, row, info) + break + } catch (error) { + if (abort.signal.aborted) break + if (!ready || !openlistError(error).connection) { + failed = true; recordRemoteObservation(source, row, undefined, error); break + } + // Retry the same row after recovery. Connection outages do not become file errors. + job.state = 'paused' + const store = read(); store.job = job; write(store) + let recovered = false + do { + recovered = await ready() + if (!recovered) try { await delay(1000, undefined, { signal: abort.signal }) } catch { break } + } while (!abort.signal.aborted && !recovered) + job.state = 'running' + } + } + if (abort.signal.aborted) break + } + job.done++; if (failed) job.failed++ + const store = read(); store.job = job; write(store) + } + job.state = abort.signal.aborted ? 'cancelled' : 'completed' + } catch { job.state = 'failed' } + finally { job.finishedAt = Date.now(); try { const store = read(); store.job = job; write(store, true) } finally { controller = undefined } } + })().catch(error => console.error('[OpenList] 文件检查状态保存失败', error)) + return { job } +} diff --git a/src/server/openlist/openlistRecoveryManifest.ts b/src/server/openlist/openlistRecoveryManifest.ts new file mode 100644 index 00000000..28b4a1dd --- /dev/null +++ b/src/server/openlist/openlistRecoveryManifest.ts @@ -0,0 +1,119 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { OpenListClient, OpenListNotFoundError } from './openlistClient' +import { hashBackupFile } from '@/utils/backupFiles' +import { listAllBackups, listAttachments } from './openlistBackupDb' +import { backupType, getBackupTypes } from '@/utils/backupPolicy' + +export interface RecoveryFile { relative: string; size: number; hash: string; object: string } +export interface RecoveryCache { + user: string; folder: string; relative: string; size: number; hash: string + source: string | null; songmid: string | null; quality: string | null; metadata: string | null + attachments: Array<{ relative: string; hash?: string; size: number; category: string }> +} +export interface RecoveryManifest { schema: 1; fingerprint: string; createdAt: number; files: RecoveryFile[]; cache: RecoveryCache[] } +const validId = (id: string) => /^[0-9a-f-]{36}\.json$/.test(id) +const checkedRelative = (relative: string) => typeof relative === 'string' && !!relative && !relative.startsWith('/') && !relative.includes('\\') && relative.split('/').every(p => p && p !== '.' && p !== '..' && !p.includes(':')) +export async function readRecoveryManifest(cl: OpenListClient, id?: string): Promise { + if (id && !validId(id)) throw new Error('历史备份 ID 无效') + let manifest: RecoveryManifest + try { manifest = await cl.readJSON(cl.resolveRemotePath(id ? '_system/recovery/history/' + id : '_system/recovery/latest.json')) } + catch (e) { if (!id && e instanceof OpenListNotFoundError) return null; throw e } + if (manifest?.schema !== 1 || !Array.isArray(manifest.files) || !Array.isArray(manifest.cache)) throw new Error('恢复清单格式无效') + if (!Number.isFinite(manifest.createdAt) || manifest.fingerprint !== crypto.createHash('sha256').update(JSON.stringify({ files: manifest.files, cache: manifest.cache })).digest('hex')) throw new Error('恢复清单指纹无效') + const seen = new Set() + for (const file of manifest.files) { + if (!backupType(file.relative) || !/^[a-f0-9]{64}$/.test(file.hash) || file.object !== `_system/recovery/objects/${file.hash.slice(0, 2)}/${file.hash}` || !Number.isSafeInteger(file.size) || file.size < 0 || seen.has(file.relative)) throw new Error('恢复清单包含无效或重复文件') + seen.add(file.relative) + } + for (const item of manifest.cache) { + if (typeof item.user !== 'string' || !checkedRelative(item.user) || item.user.includes('/') || !['cache', 'music'].includes(item.folder) || !checkedRelative(item.relative) || !/^[a-f0-9]{64}$/.test(item.hash) || !Number.isSafeInteger(item.size) || item.size < 0 || !Array.isArray(item.attachments)) throw new Error('缓存恢复清单无效') + for (const att of item.attachments) if (!checkedRelative(att.relative) || (att.hash !== undefined && !/^[a-f0-9]{64}$/.test(att.hash)) || !Number.isSafeInteger(att.size) || att.size < 0) throw new Error('缓存附件清单无效') + } + return manifest +} +export async function listRecoveryVersions(cl: OpenListClient) { + try { + const rows = await cl.listFilesRecursive(cl.resolveRemotePath('_system/recovery/history')) + return rows.filter(f => !f.isDir && validId(f.name)).map(f => ({ id: f.name, modified: f.modified, size: f.size })).sort((a, b) => b.modified.localeCompare(a.modified)) + } catch (e) { if (e instanceof OpenListNotFoundError) return []; throw e } +} +async function pruneRecoveryHistory(cl: OpenListClient, current: RecoveryManifest) { + const days = Math.min(3650, Math.max(1, Number(global.lx.config['openlist.historyRetentionDays']) || 7)) + const cutoff = Date.now() - days * 86_400_000 + const versions = await listRecoveryVersions(cl) + const referenced = new Set(current.files.map(f => f.object)) + for (const version of versions) { + const modified = Date.parse(version.modified) + if (Number.isFinite(modified) && modified < cutoff) { + await cl.remove(cl.resolveRemotePath('_system/recovery/history/' + version.id)) + } else { + const manifest = await readRecoveryManifest(cl, version.id) + for (const file of manifest!.files) referenced.add(file.object) + } + } + const prefix = cl.resolveRemotePath('_system/recovery/objects/') + let objects: Awaited> = [] + try { objects = await cl.listFilesRecursive(prefix.replace(/\/$/, '')) } catch (e) { if (!(e instanceof OpenListNotFoundError)) throw e } + for (const object of objects) { + if (object.isDir || !object.path.startsWith(prefix)) continue + const relative = object.path.slice(cl.resolveRemotePath('').length) + if (!/^_system\/recovery\/objects\/[0-9a-f]{2}\/[0-9a-f]{64}$/.test(relative)) continue + if (!referenced.has(relative) && Date.parse(object.modified) < cutoff) await cl.remove(object.path) + } +} +async function uploadJSON(cl: OpenListClient, relative: string, value: any) { + const dir = path.join(global.lx.dataPath, 'backups', 'openlist-manifest-tmp') + fs.mkdirSync(dir, { recursive: true }) + const temp = path.join(dir, crypto.randomUUID() + '.json') + try { + fs.writeFileSync(temp, JSON.stringify(value), { mode: 0o600 }) + const remote = cl.resolveRemotePath(relative) + await cl.mkdir(path.posix.dirname(remote)); await cl.upload(temp, remote) + } finally { fs.rmSync(temp, { force: true }) } +} + +/** Cloud metadata is independent of local SQLite; immutable content objects make history restorable. */ +export async function publishRecoveryManifest(cl: OpenListClient): Promise { + const files: RecoveryFile[] = [], cache: RecoveryCache[] = [] + const previous = await readRecoveryManifest(cl) + const dataPrefix = cl.resolveRemotePath('_data/') + const rows = listAllBackups().filter(r => ['completed', 'verified'].includes(r.status) && r.local_hash && !r.deleted_at) + const types = getBackupTypes('openlist', global.lx.config as any) + for (const row of rows) { + if (row.folder_type === 'data' && row.remote_path.startsWith(dataPrefix)) { + const relative = row.remote_path.slice(dataPrefix.length) + if (relative.split('/').some(segment => segment.toLowerCase() === 'test-evidence')) continue + if (!backupType(relative) || !types.includes(backupType(relative)!)) continue + const object = `_system/recovery/objects/${row.local_hash!.slice(0, 2)}/${row.local_hash}` + const target = cl.resolveRemotePath(object) + let exists = false + try { exists = (await cl.getFileInfo(target)).size === row.size } catch (e) { if (!(e instanceof OpenListNotFoundError)) throw e } + if (!exists) { + await cl.mkdir(path.posix.dirname(target)) + if (fs.existsSync(row.local_path) && hashBackupFile(row.local_path) === row.local_hash) await cl.upload(row.local_path, target, undefined, row.local_hash!) + else throw new Error('历史内容对象缺失且本地版本不可用,未回读云端文件:' + relative) + } + files.push({ relative, size: row.size!, hash: row.local_hash!, object }) + } else if (['cache', 'music'].includes(row.folder_type)) { + const prefix = cl.resolveRemotePath(row.user + '/' + row.folder_type + '/') + if (!row.remote_path.startsWith(prefix)) continue + const attachments: RecoveryCache['attachments'] = [] + for (const att of listAttachments(row.id).filter(a => ['completed', 'verified'].includes(a.status) && !a.deleted_at)) { + if (!att.remote_path.startsWith(prefix)) continue + const relative = att.remote_path.slice(prefix.length) + const prior = previous?.cache.find(c => c.user === row.user && c.folder === row.folder_type && c.relative === row.remote_path.slice(prefix.length))?.attachments.find(a => a.relative === relative && a.size === att.size) + attachments.push({ relative, hash: att.local_hash || prior?.hash || undefined, size: att.size!, category: att.category }) + } + cache.push({ user: row.user, folder: row.folder_type, relative: row.remote_path.slice(prefix.length), size: row.size!, hash: row.local_hash!, source: row.source, songmid: row.songmid, quality: row.quality, metadata: row.cache_metadata, attachments }) + } + } + files.sort((a, b) => a.relative.localeCompare(b.relative)); cache.sort((a, b) => (a.user + a.folder + a.relative).localeCompare(b.user + b.folder + b.relative)) + const fingerprint = crypto.createHash('sha256').update(JSON.stringify({ files, cache })).digest('hex') + if (previous?.fingerprint === fingerprint) { await pruneRecoveryHistory(cl, previous); return } + const manifest: RecoveryManifest = { schema: 1, fingerprint, createdAt: Date.now(), files, cache } + await uploadJSON(cl, '_system/recovery/history/' + crypto.randomUUID() + '.json', manifest) + await uploadJSON(cl, '_system/recovery/latest.json', manifest) + await pruneRecoveryHistory(cl, manifest) +} diff --git a/src/server/openlist/openlistRestoreTasks.ts b/src/server/openlist/openlistRestoreTasks.ts new file mode 100644 index 00000000..368a7613 --- /dev/null +++ b/src/server/openlist/openlistRestoreTasks.ts @@ -0,0 +1,3 @@ +import { createRestoreTaskManager } from '../backup/restoreTasks' +export type { RestoreFile, RestoreTask } from '../backup/restoreTasks' +export const { hasInterruptedRestore, readRestoreTask, listRestoreTasks, cancelRestoreTask, createPendingRestoreTask, failRestoreTask, rollbackRestoreTask, deleteRestoreTask, executeRestoreTransaction } = createRestoreTaskManager('openlist-restore-tasks') diff --git a/src/server/rating/README.md b/src/server/rating/README.md new file mode 100644 index 00000000..2f8b25b7 --- /dev/null +++ b/src/server/rating/README.md @@ -0,0 +1,16 @@ +# 评分模块 + +Web 与 Subsonic 共用这组服务。运行数据仍保存于用户目录,源码归类不改变数据位置。 + +| 文件 | 职责 | +| --- | --- | +| ratingService.ts | 评分读写、歌曲身份解析、描述资料、失败任务重试 | +| ratingIdentityLock.ts | 按用户串行化本地关联与评分写入 | +| ratingMigration.ts | 本地转在线的身份合并、不喜欢原因迁移及事务日志恢复 | +| ratingPreferences.ts | 用户独立的不喜欢自动补分偏好 | +| ratingSnapshot.ts | 评分导入、导出、快照与恢复、元数据锁 | +| ratingValidation.ts | 存储结构与评分历史校验 | + +锁顺序为身份锁 → 元数据锁;持有身份锁的调用方使用 writeRatingUnlocked,避免重复取得同一锁。 +迁移提交同步完成,不跨 await。事务失败会回滚;未完成日志在评分访问或不喜欢管理器初始化时恢复。 +不支持多个服务进程同时写入同一用户数据目录。 diff --git a/src/server/rating/ratingIdentityLock.ts b/src/server/rating/ratingIdentityLock.ts new file mode 100644 index 00000000..b18b2b9d --- /dev/null +++ b/src/server/rating/ratingIdentityLock.ts @@ -0,0 +1,10 @@ +/** 串行化同一用户的关联变更与评分写入;独立于元数据锁,固定先身份锁后元数据锁。 */ +const pending = new Map>() +export function withRatingIdentityLock(user: string, task: () => Promise): Promise { + const previous = pending.get(user) || Promise.resolve() + const result = previous.then(task, task) + const tail = result.then(() => undefined, () => undefined) + pending.set(user, tail) + void tail.then(() => { if (pending.get(user) === tail) pending.delete(user) }) + return result +} diff --git a/src/server/rating/ratingMigration.ts b/src/server/rating/ratingMigration.ts new file mode 100644 index 00000000..cea2c66c --- /dev/null +++ b/src/server/rating/ratingMigration.ts @@ -0,0 +1,92 @@ +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' + +const live = new Map>() +export function registerDislikeMigration(dir: string, manager: any) { live.set(path.resolve(dir), new WeakRef(manager)); recoverRatingTransaction(dir) } +const replace = (file: string, value: string | null) => { + if (value === null) { if (fs.existsSync(file)) fs.unlinkSync(file); return } + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file + '.transaction.tmp', value, { encoding: 'utf8', flush: true }) + fs.renameSync(file + '.transaction.tmp', file) +} +/** A durable redo journal finishes interrupted commits before another rating read. */ +export function recoverRatingTransaction(dir: string) { + const journal = path.join(dir, 'rating', 'transaction.json') + if (!fs.existsSync(journal)) return + const entries = JSON.parse(fs.readFileSync(journal, 'utf8')) + if (!Array.isArray(entries)) throw new Error('Invalid rating transaction') + for (const entry of entries) { + if (!entry || typeof entry.file !== 'string' || typeof entry.after !== 'string') throw new Error('Invalid rating transaction') + const target = path.resolve(dir, entry.file) + const relative = path.relative(dir, target).replaceAll('\\', '/') + if (!target.startsWith(path.resolve(dir) + path.sep) || !/^(subsonic-meta\.json|rating\/songs\.json|dislike\/snapshotInfo\.json|dislike\/snapshot\/snapshot_[a-f0-9]{32})$/.test(relative)) throw new Error('Invalid rating transaction path') + } + for (const entry of entries) replace(path.resolve(dir, entry.file), entry.after) + fs.unlinkSync(journal) + if (entries.some(entry => entry.file.replaceAll('\\', '/') === 'dislike/snapshotInfo.json')) refreshLive(dir) +} +function refreshLive(dir: string) { + const manager = live.get(path.resolve(dir))?.deref() + if (!manager) return + const info = path.join(dir, 'dislike', 'snapshotInfo.json') + if (!fs.existsSync(info)) return + manager.snapshotDataManage.snapshotInfo = JSON.parse(fs.readFileSync(info, 'utf8')) + const latest = manager.snapshotDataManage.snapshotInfo.latest + if (latest) manager.dislikeRules = JSON.parse(fs.readFileSync(path.join(dir, 'dislike', 'snapshot', 'snapshot_' + latest), 'utf8')) +} +export function commitRatingFiles(dir: string, changes: Map) { + recoverRatingTransaction(dir) + const entries = [...changes].map(([file, after]) => ({ file: path.relative(dir, file), after, before: fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : null })) + if (!entries.length) return + const journal = path.join(dir, 'rating', 'transaction.json') + replace(journal, JSON.stringify(entries)) + try { + for (const entry of entries) replace(path.join(dir, entry.file), entry.after) + } catch (error) { + // Keep the journal if rollback itself fails; the next read completes the commit. + for (const entry of entries) replace(path.join(dir, entry.file), entry.before) + fs.unlinkSync(journal) + throw error + } + fs.unlinkSync(journal) + if (entries.some(entry => entry.file.replaceAll('\\', '/') === 'dislike/snapshotInfo.json')) refreshLive(dir) +} +/** Pure identity normalization, shared by reads, linking and snapshot restore. */ +export function planRatingMigration(dir: string, data: any, catalog: any, tracks?: any[]) { + data.ratings ||= {} + const registry = path.join(dir, 'subsonic-local-registry.json') + const linked: any[] = tracks || (fs.existsSync(registry) ? Object.values(JSON.parse(fs.readFileSync(registry, 'utf8')).tracks || {}) : []) + const aliases = new Map() + for (const t of linked) if (t.id?.startsWith('local_') && /^(wy|tx|kw|kg|mg|bd)_/.test(t.platformId || '')) aliases.set(t.id, t.platformId) + for (const [old, target] of aliases) { + if (!data.ratings[target] && data.ratings[old]) { + data.ratings[target] = data.ratings[old] + if (data.ratingOrigins?.[old]) { data.ratingOrigins ||= {}; data.ratingOrigins[target] = data.ratingOrigins[old] } + if (data.ratingHistory?.[old]) { data.ratingHistory ||= {}; data.ratingHistory[target] = data.ratingHistory[old] } + catalog[target] = { ...(catalog[old] || {}), ...(catalog[target] || {}), id: target, source: target.split('_')[0] } + } + delete data.ratings[old] + if (data.ratingOrigins) delete data.ratingOrigins[old] + if (data.ratingHistory) delete data.ratingHistory[old] + delete catalog[old] + } + const changes = new Map() + const manager = live.get(path.resolve(dir))?.deref() + const infoFile = path.join(dir, 'dislike', 'snapshotInfo.json') + const info = manager?.snapshotDataManage.snapshotInfo || (fs.existsSync(infoFile) ? JSON.parse(fs.readFileSync(infoFile, 'utf8')) : null) + if (info?.latest) { + const source = manager?.dislikeRules || JSON.parse(fs.readFileSync(path.join(dir, 'dislike', 'snapshot', 'snapshot_' + info.latest), 'utf8')) + const rules = JSON.parse(JSON.stringify(source)) + let changed = false + for (const item of rules.dislikeList || []) for (const reason of [item.provenance, item.enrichedBy, ...(item.reasons || [])]) { + if (reason && aliases.has(reason.ratingId)) { reason.ratingId = aliases.get(reason.ratingId); changed = true } + } + if (changed) { + const content = JSON.stringify(rules), id = createHash('md5').update(content).digest('hex') + changes.set(path.join(dir, 'dislike', 'snapshot', 'snapshot_' + id), content) + changes.set(infoFile, JSON.stringify({ ...info, latest: id, time: Date.now(), list: [...new Set([info.latest, ...(info.list || [])])].filter(x => x !== id && !(info.imported || []).includes(x)) })) + } + } + return changes +} diff --git a/src/server/rating/ratingPreferences.ts b/src/server/rating/ratingPreferences.ts new file mode 100644 index 00000000..181ac94d --- /dev/null +++ b/src/server/rating/ratingPreferences.ts @@ -0,0 +1,19 @@ +import fs from 'node:fs' +import path from 'node:path' +import { getUserDirname } from '@/user' +export interface RatingPreferences { dislikeAutoRating: boolean } +const preferenceFile = (user: string) => path.join(global.lx.userPath, getUserDirname(user), 'rating', 'preferences.json') +export function readRatingPreferences(user: string): RatingPreferences { + const file = preferenceFile(user) + if (!fs.existsSync(file)) return { dislikeAutoRating: false } + try { const data = JSON.parse(fs.readFileSync(file, 'utf8')); return { dislikeAutoRating: data.dislikeAutoRating === true } } + catch { console.warn('[评分设置] 用户设置无法读取,自动补分暂按关闭处理:', user); return { dislikeAutoRating: false } } +} +export function saveRatingPreferences(user: string, value: unknown): RatingPreferences { + if (typeof value !== 'boolean') throw new Error('dislikeAutoRating must be boolean') + const data = { dislikeAutoRating: value } + const file = preferenceFile(user) + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file + '.tmp', JSON.stringify(data), 'utf8'); fs.renameSync(file + '.tmp', file) + return data +} diff --git a/src/server/rating/ratingService.ts b/src/server/rating/ratingService.ts new file mode 100644 index 00000000..a3ec8dba --- /dev/null +++ b/src/server/rating/ratingService.ts @@ -0,0 +1,262 @@ +import { commitRatingFiles, recoverRatingTransaction, planRatingMigration } from './ratingMigration' +import { normalizeRatingHistory, validateRatingStorage } from './ratingValidation' +import { createHash } from 'node:crypto' +import { withRatingIdentityLock } from './ratingIdentityLock' +import fs from 'node:fs' +import path from 'node:path' +import { getUserDirname } from '@/user' +import { createRatingSnapshot, withMetaLock } from './ratingSnapshot' +import { getLocalTracks, localFile, localRatingId } from '../music/subsonicLocal' +import { getCacheDir } from '../music/fileCache' +import { getCustomMusicDir } from '../music/customMusicManager' + +const metaFile = (user: string) => path.join(global.lx.userPath, getUserDirname(user), 'subsonic-meta.json') +export const readRatings = (user: string): any => { + const file = metaFile(user) + recoverRatingTransaction(path.dirname(file)) + if (!fs.existsSync(file)) return { starredAlbums: [], starredArtists: [], starredArtistNames: [], ratings: {} } + const data = validateRatingStorage(JSON.parse(fs.readFileSync(file, 'utf8'))) + if (data.ratingHistory) data.ratingHistory = normalizeRatingHistory(data.ratingHistory) + migrateLinkedRatings(user, data) + return { ...data, ratings: data.ratings || {} } +} +/** 同步读改写不跨 await,关联扫描与评分写入共享同一进程的串行临界段。 */ +export function migrateLinkedRatings(user: string, supplied?: any, tracks?: any[]) { + const file = metaFile(user) + recoverRatingTransaction(path.dirname(file)) + if (!fs.existsSync(file)) return + const data = supplied || validateRatingStorage(JSON.parse(fs.readFileSync(file, 'utf8'))) + const dir = path.dirname(file) + const registryFile = path.join(dir, 'subsonic-local-registry.json') + const linked: any[] = tracks || (fs.existsSync(registryFile) ? Object.values(JSON.parse(fs.readFileSync(registryFile, 'utf8')).tracks || {}) : []) + const catalogFile = path.join(dir, 'rating', 'songs.json') + const catalog = fs.existsSync(catalogFile) ? JSON.parse(fs.readFileSync(catalogFile, 'utf8')) : {} + const before = JSON.stringify(data), beforeCatalog = JSON.stringify(catalog) + const changes = planRatingMigration(dir, data, catalog, linked) + if (JSON.stringify(catalog) !== beforeCatalog) changes.set(catalogFile, JSON.stringify(catalog)) + if (JSON.stringify(data) !== before) changes.set(file, JSON.stringify(data)) + commitRatingFiles(dir, changes) +} +/** 未关联使用本地ID,关联后读写在线ID;本地评分迁移到在线ID,在线已有评分优先。 */ +export async function resolveRatingId(user: string, song: any): Promise { + if (!song || typeof song !== 'object') throw new Error('Missing song') + if (song.isLocal || song._localFilename) { + if (song._localUsername && song._localUsername !== user) throw new Error('不能为其他用户的本地文件评分') + const filename = song._localFilename || song.filename + if (typeof filename !== 'string') throw new Error('Missing local filename') + const folder = song._isCustomDir ? 'custom' : song._localFolder || song.folder + if (!['music', 'cache', 'custom'].includes(folder)) throw new Error('Invalid local folder') + const location = song._localLocation + if (location && !['root', 'data'].includes(location)) throw new Error('Invalid storage location') + const root = folder === 'custom' ? getCustomMusicDir(user) : getCacheDir(user, folder === 'music', location) + if (!root) throw new Error('Local storage unavailable') + const file = localFile({ root, filename }) + const tracks = await getLocalTracks(user, false, folder === 'custom' ? undefined : location) + let track = file ? tracks.find(t => localFile(t) === file) : undefined + if (!file) { + // 仅接受由本地文件注册表记录的改名别名,不按歌名或在线ID猜测文件。 + const actualRoot = fs.realpathSync(root) + const key = path.resolve(actualRoot, filename) + if (!key.startsWith(actualRoot + path.sep)) throw new Error('Invalid local path') + const registryFile = path.join(path.dirname(metaFile(user)), 'subsonic-local-registry.json') + const registry = fs.existsSync(registryFile) ? JSON.parse(fs.readFileSync(registryFile, 'utf8')) : { tracks: {} } + const prior = registry.tracks?.[key] + if (prior) track = tracks.find(t => t.id === prior.id && localFile(t)) + } + if (!track) throw new Error('该文件不在当前用户本地曲库中,请刷新本地列表') + return localRatingId(track) + } + const source = String(song.source || '') + let id = String(source === 'tx' ? song.songmid || song.meta?.songId || song.id || '' : song.meta?.songId || song.songmid || song.id || '') + if (id.startsWith('local_')) { + return resolveStoredRatingId(user, id) + } + if (!['wy','tx','kw','kg','mg','bd'].includes(source) || !id || id.length > 256 || /[\s\\/]/.test(id)) throw new Error('Invalid song identity') + if (!id.startsWith(source + '_')) id = source + '_' + id + return id +} +export async function resolveStoredRatingId(user: string, id: string): Promise { + if (!id.startsWith('local_')) return id + const track = (await getLocalTracks(user)).find(t => t.id === id) + if (track) return localRatingId(track) + if (Object.hasOwn(readRatings(user).ratings, id)) return id + throw new Error('Unknown local song') +} + +export interface RatingWriteOptions { onlyMissing?: boolean, deferSnapshot?: boolean, repairOnly?: boolean } +export function writeRating(user: string, id: string, rating: number, channel: string, song?: any, options: RatingWriteOptions = {}) { + return withRatingIdentityLock(user, async () => { + try {return await writeRatingUnlocked(user, id, rating, channel, song, options)} + catch(error){if(options.onlyMissing)recordRatingBackfillFailure(user,id,channel);throw error} + }) +} +/** 调用方已经持有身份锁时使用;读改写仍统一经过元数据锁。 */ +export async function writeRatingUnlocked(user: string, id: string, rating: number, channel: string, song?: any, options: RatingWriteOptions = {}) { + if (!Number.isInteger(rating) || rating < 0 || rating > 5) throw new Error('Rating must be an integer between 0 and 5') + if (typeof id !== 'string' || !id || id.length > 512 || ['__proto__', 'constructor', 'prototype'].includes(id)) throw new Error('Invalid rating ID') + const localInput = id.startsWith('local_') || String(song?.id || '').startsWith('local_') || song?.isLocal || song?._localFilename + id = song && (song.isLocal || song._localFilename) ? await resolveRatingId(user, song) : await resolveStoredRatingId(user, id) + if (song && !id.startsWith('local_') && localInput) { + const linked = (await getLocalTracks(user)).find(track => track.platformId === id) + // 使用服务端关联资料,避免关联前的列表条目覆盖在线歌曲资料。 + song = linked ? { name: linked.title, singer: linked.artist, albumName: linked.album, interval: linked.duration } : undefined + } + const meta = await withMetaLock(user, async () => { + const data = readRatings(user) + data.ratingOrigins ||= {} + if (options.repairOnly) rating = data.ratings[id] || 0 + const changed = !(options.onlyMissing && data.ratings[id] > 0) && (data.ratings[id] || 0) !== rating + if (!changed) data._ratingUnchanged = true + if (changed) { + const event = { from: data.ratings[id] || 0, to: rating, channel, at: Date.now() } + data.ratingHistory ||= {} + data.ratingHistory[id] = [...(data.ratingHistory[id] || []), event].slice(-30) + if (rating === 0) { delete data.ratings[id]; delete data.ratingOrigins[id] } + else { data.ratings[id] = rating; data.ratingOrigins[id] = { channel, at: Date.now() } } + fs.mkdirSync(path.dirname(metaFile(user)), { recursive: true }) + const temporary = metaFile(user) + '.rating.tmp' + fs.writeFileSync(temporary, JSON.stringify(data), 'utf8'); fs.renameSync(temporary, metaFile(user)) + } + if (!song && rating > 0) song = knownRatingSong(user, id) + if (!song && rating > 0) setRatingTask(user, 'metadata:' + id, {id,kind:'metadata',message:'歌曲资料尚未解析',at:Date.now()}) + if (song && rating > 0) { + try { + // 独立评分资料目录,不写歌单;拒绝持久化带鉴权参数的播放/封面URL。 + const file = path.join(path.dirname(metaFile(user)), 'rating', 'songs.json') + fs.mkdirSync(path.dirname(file), { recursive: true }) + const catalog = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : {} + const incoming: any = { id, name: String(song.name || '').slice(0,500), singer: String(song.singer || '').slice(0,500), source: id.startsWith('local_') ? 'local' : id.replace(/^(alb_|art_|artist_)/, '').split('_')[0], albumName: String(song.meta?.albumName || song.albumName || '').slice(0,500), interval: song.interval, img: safeCover(song.img || song.meta?.picUrl || song.picUrl), albumId: String(song.meta?.albumId || song.albumId || '').slice(0,200) } + const previous = catalog[id] || {} + catalog[id] = { ...previous, ...Object.fromEntries(Object.entries(incoming).filter(([,value]) => value !== undefined && value !== null && value !== '')) } + const temp = file + '.tmp' + const next = JSON.stringify(catalog) + if (fs.existsSync(file) && fs.readFileSync(file, 'utf8') !== next || !fs.existsSync(file)) { + fs.writeFileSync(temp, next, 'utf8'); fs.renameSync(temp, file); data._ratingCatalogChanged = true + } + setRatingTask(user, 'catalog:' + id, null) + setRatingTask(user, 'metadata:' + id, null) + } catch { data._ratingCatalogWarning = true; setRatingTask(user, 'catalog:' + id, { id, kind: 'catalog', message: '歌曲资料保存失败', song: ratingDescriptor(id, song), at: Date.now() }) } + } + return data + }) + let snapshotWarning: string | undefined = meta._ratingCatalogWarning ? '评分已保存,但评分歌曲资料保存失败' : undefined + delete meta._ratingCatalogWarning + const unchanged = !!meta._ratingUnchanged; delete meta._ratingUnchanged + const catalogChanged = !!meta._ratingCatalogChanged; delete meta._ratingCatalogChanged + if (!options.deferSnapshot && (!unchanged || catalogChanged || readRatingTasks(user).some((task: any) => task.kind === 'snapshot'))) { + const result = await flushRatingSnapshot(user); snapshotWarning = result || snapshotWarning + } + if (options.onlyMissing) setRatingTask(user, 'backfill:' + id, null) + return { id, meta, snapshotWarning, unchanged } +} + +/** 仅保留不带查询串/凭据的公开封面地址,避免持久化鉴权参数。 */ +export const safeCover = (value: any): string => { + if (typeof value !== 'string' || value.length > 500 || !/^https?:\/\//i.test(value)) return '' + try { const url = new URL(value); return /[?#]/.test(value) || url.username || url.password ? '' : value } catch { return '' } +} + +export type RatingKind = 'song' | 'album' | 'artist' +export const ratingKind = (id: string): RatingKind => /^(alb_|lalb_)/.test(id) ? 'album' : /^(art_|artist_|lart_)/.test(id) ? 'artist' : 'song' + +/** 汇总该用户的全部评分(歌曲/专辑/歌手),补齐封面、平台、来源与时间。 */ +export function listRatingItems(user: string): any[] { + const meta = readRatings(user) + const dir = path.join(global.lx.userPath, getUserDirname(user)) + const readJson = (file: string, fallback: any) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')) } catch { return fallback } } + const catalog = readJson(path.join(dir, 'rating', 'songs.json'), {}) + const online = readJson(path.join(global.lx.dataPath, 'runtime', 'subsonic-online-cache.json'), []) + const localRegistry = readJson(path.join(dir, 'subsonic-local-registry.json'), { tracks: {} }) + const onlineMap = new Map((Array.isArray(online) ? online : []).map((x: any) => [x.id, x])) + const localMap = new Map(Object.values(localRegistry.tracks || {}).map((x: any) => [x.id, x])) + const library = new Map() + for (const [file, prefix] of [['library/albums.json', 'alb'], ['library/artists.json', 'art'], ['dislike/library/albums.json', 'alb'], ['dislike/library/artists.json', 'art']]) { + const rows = readJson(path.join(dir, file), []) + if (Array.isArray(rows)) for (const row of rows) if (row?.id && !library.has(`${prefix}_${row.source}_${row.id}`)) library.set(`${prefix}_${row.source}_${row.id}`, row) + } + return Object.entries(meta.ratings as Record).map(([id, rating]) => { + const kind = ratingKind(id) + const origin = meta.ratingOrigins?.[id] || null + const history = meta.ratingHistory?.[id] || [] + if (kind !== 'song') { + const row = { ...(catalog[id] || {}), ...(library.get(id) || {}) } + return { id, rating, kind, history, name: row.name || id, singer: row.artistName || '', source: row.source || id.split('_')[1] || '', albumName: '', img: safeCover(row.picUrl || row.meta?.picUrl), origin } + } + const local: any = localMap.get(id) + const effectiveId = local?.platformId || id + const item: any = { ...(local || {}), ...(onlineMap.get(id) || {}), ...(catalog[id] || {}) } + const source = id.startsWith('local_') ? 'local' : item.source || id.split('_')[0] + return { id, rating, kind, history, effectiveId, name: item.name || item.title || id, singer: item.singer || item.artist || '', source, albumName: item.albumName || item.album || item.meta?.albumName || '', interval: item.interval || item.duration, img: safeCover(item.img || item.pic || item.picUrl || item.meta?.picUrl), origin } + }) +} + +/** 解析接口返回明确归属;本地历史只作说明,不作为在线评分回退值。 */ +export async function ratingIdentityDetails(user: string, song: any, details = false) { + const id = await resolveRatingId(user, song) + const local = song.isLocal || song._localFilename || String(song.id || '').startsWith('local_') + const identity = local ? (id.startsWith('local_') ? 'local' : 'linked-online') : 'online' + if (!details) return { id, identity } + const tracks = await getLocalTracks(user) + const related = tracks.filter(t => localRatingId(t) === id) + const rows = listRatingItems(user) + return { id, identity, + target: rows.find(x => x.id === id) || { id }, + linkedCopies: related.length } +} + +function ratingDescriptor(id: string, song: any) { + return {id,source:id.replace(/^(alb_|art_|artist_)/,'').split('_')[0],name:String(song?.name||song?.title||'').slice(0,500),singer:String(song?.singer||song?.artist||'').slice(0,500),albumName:String(song?.albumName||song?.meta?.albumName||song?.album||'').slice(0,500),albumId:String(song?.albumId||song?.meta?.albumId||'').slice(0,200),img:safeCover(song?.img||song?.meta?.picUrl),interval: typeof song?.interval === 'string' || typeof song?.interval === 'number' ? song.interval : undefined} +} +function knownRatingSong(user: string, id: string): any { + const files = [path.join(path.dirname(metaFile(user)),'rating','songs.json'),path.join(global.lx.dataPath,'runtime','subsonic-online-cache.json'),path.join(path.dirname(metaFile(user)),'subsonic-local-registry.json')] + for (const file of files) { + if (!fs.existsSync(file)) continue + try { + const data=JSON.parse(fs.readFileSync(file,'utf8'));const rows=Array.isArray(data)?data:Object.values(data.tracks||data) + const song:any=rows.find((row:any)=>row.id===id||row.platformId===id) + if(song)return ratingDescriptor(id,song) + } catch { /* 缓存损坏不阻止分数写入;主评分文件严格校验。 */ } + } +} +const taskFile=(user:string)=>path.join(path.dirname(metaFile(user)),'rating','sync-status.json') +export function readRatingTasks(user:string):any[] { + const file=taskFile(user);if(!fs.existsSync(file))return [] + try { + const tasks=JSON.parse(fs.readFileSync(file,'utf8')) + if(!Array.isArray(tasks)||tasks.some(t=>!t||typeof t.key!=='string'||!['catalog','metadata','snapshot','backfill'].includes(t.kind)))throw Error('invalid status') + return tasks + } catch {return [{key:'status-error',kind:'status',message:'同步状态文件损坏,原文件已保留;请人工检查',at:0}]} +} + +function setRatingTask(user:string,key:string,task:any) { + try {const file=taskFile(user);const current=readRatingTasks(user);if(current.some(row=>row.kind==='status'))throw Error('damaged status');const rows=current.filter(row=>row.key!==key);if(task)rows.push({...task,key});fs.mkdirSync(path.dirname(file),{recursive:true});const next=JSON.stringify(rows);if(!fs.existsSync(file)||fs.readFileSync(file,'utf8')!==next){fs.writeFileSync(file+'.tmp',next,'utf8');fs.renameSync(file+'.tmp',file)}} + catch { /* 状态文件失败不掩盖评分主操作的结果。 */ } +} +export async function flushRatingSnapshot(user:string):Promise { + try {await createRatingSnapshot(user);setRatingTask(user,'snapshot',null)} + catch {setRatingTask(user,'snapshot',{kind:'snapshot',message:'评分快照生成失败',at:Date.now()});return '评分已保存,但评分快照生成失败,可重试失败项'} +} +export async function retryRatingTasks(user:string) { + const tasks=readRatingTasks(user) + for(const task of tasks){ + if(task.kind==='status')throw Error('同步状态文件损坏,已保留原文件,请人工检查后重试') + if(task.kind==='backfill') { + try {await writeRating(user,task.id,1,task.channel,undefined,{onlyMissing:true})}catch{} + } else if ((task.kind==='catalog'||task.kind==='metadata') && task.id) { + if (!(readRatings(user).ratings[task.id]>0)) {setRatingTask(user,task.key,null);continue} + const song = task.kind==='catalog' ? task.song : await require('../integrations/subsonic').subsonicHandler.getRatingMetadata(user,task.id).catch(()=>undefined) + if(song)await writeRating(user,task.id,readRatings(user).ratings[task.id]||0,'repair',song,{repairOnly:true}) + } else if(task.kind==='snapshot') await flushRatingSnapshot(user) + } + + return {pending:readRatingTasks(user),version:ratingVersion(user)} +} +export function ratingVersion(user:string):string { + const dir=path.dirname(metaFile(user));const files=['subsonic-meta.json','subsonic-local-registry.json','rating/songs.json','rating/sync-status.json'] + return createHash('sha256').update(files.map(name=>{try{const stat=fs.statSync(path.join(dir,name),{bigint:true});return `${name}:${stat.mtimeNs}:${stat.size}`}catch{return name+':missing'}}).join('|')).digest('hex').slice(0,24) +} + +export function recordRatingBackfillFailure(user:string,id:string,channel:string) { + if (!['web-dislike-link','manual-dislike-to-rating'].includes(channel) || typeof id!=='string' || !id || id.length>512 || ['__proto__','constructor','prototype'].includes(id)) return + setRatingTask(user,'backfill:'+id,{id,kind:'backfill',channel,message:'补分写入失败',at:Date.now()}) +} diff --git a/src/server/rating/ratingSnapshot.ts b/src/server/rating/ratingSnapshot.ts new file mode 100644 index 00000000..a082e5ce --- /dev/null +++ b/src/server/rating/ratingSnapshot.ts @@ -0,0 +1,284 @@ +import { commitRatingFiles, recoverRatingTransaction, planRatingMigration } from './ratingMigration' +import { normalizeRatingHistory, validateRatingStorage } from './ratingValidation' +import { withRatingIdentityLock } from './ratingIdentityLock' +import fs from 'node:fs' +import path from 'node:path' +import { createHash } from 'node:crypto' +import { getUserDirname } from '@/user' +import { getUserConfig } from '@/user/data' + +/** + * 「我的评分」快照(第三种备份类型)。 + * 评星数据独立于歌单/不喜欢,存放在 <用户目录>/subsonic-meta.json, + * 因此独立成一套快照目录 <用户目录>/rating/snapshot/,与另外两类并列展示。 + */ +export interface RatingMeta { + starredAlbums: string[] + starredArtists: string[] + starredArtistNames: string[] + ratingOrigins?: Record + ratings: Record + ratingHistory?: Record + ratingCatalog?: Record + localLinks?: { id: string, filename: string, platformId?: string }[] +} + +export interface RatingSnapshotItem { + id: string + time: number + size: number + origin: 'imported' | 'auto' +} + +interface RatingSnapshotInfo { + imported: string[] +} + +const metaLocks = new Map>() + +/** + * 同一用户 subsonic-meta.json 的「读-改-写」串行化锁。 + * 避免 handleSetRating / handleStar / syncDislikeToRating 并发写同一文件互相覆盖。 + */ +export function withMetaLock(username: string, task: () => Promise): Promise { + const prev = metaLocks.get(username) ?? Promise.resolve() + const run = (): Promise => task() + // 前一个任务无论成功还是失败都要继续,避免一次异常卡死整条队列 + const next = prev.then(run, run) + const tail = next.then(() => undefined, () => undefined) + metaLocks.set(username, tail) + tail.then(() => { + if (metaLocks.get(username) === tail) metaLocks.delete(username) + }) + return next +} + +const emptyMeta = (): RatingMeta => ({ starredAlbums: [], starredArtists: [], starredArtistNames: [], ratings: {} }) + +const userDir = (username: string) => path.join(global.lx.userPath, getUserDirname(username)) +const metaPath = (username: string) => path.join(userDir(username), 'subsonic-meta.json') +const ratingDir = (username: string) => path.join(userDir(username), 'rating') +const snapshotDir = (username: string) => path.join(ratingDir(username), 'snapshot') +const infoPath = (username: string) => path.join(ratingDir(username), 'snapshotInfo.json') + +/** + * 快照 id 直接来自外部输入(下载/删除/恢复接口的 id 参数、导入时的文件名), + * 这里统一拒绝路径分隔符与上跳片段,避免拼进 snapshotPath 后发生路径穿越。 + */ +export const isValidSnapshotId = (id: unknown): id is string => { + if (typeof id !== 'string') return false + if (!id || id.length > 200) return false + if (id === '.' || id === '..' || id.includes('..')) return false + return !/[\\/:*?"<>|]/.test(id) +} + +const snapshotPath = (username: string, id: string) => { + if (!isValidSnapshotId(id)) throw new Error('Invalid snapshot id') + return path.join(snapshotDir(username), `snapshot_${id}`) +} + +export function sanitizeRatingCatalog(value: any): Record { + const catalog: Record = Object.create(null) + if (!value || typeof value !== 'object' || Array.isArray(value)) return catalog + for (const [id, raw] of Object.entries(value)) { + if (!raw || typeof raw !== 'object' || ['__proto__', 'constructor', 'prototype'].includes(id)) continue + const row: any = { id } + for (const key of ['name', 'singer', 'source', 'albumName', 'albumId', 'interval']) { + const field = (raw as any)[key] + if (typeof field === 'string' || typeof field === 'number') row[key] = String(field).slice(0, 500) + } + try { const url = new URL((raw as any).img); if (/^https?:$/.test(url.protocol) && !url.username && !url.password && !url.search && !url.hash) row.img = url.href } catch {} + catalog[id] = row + } + return catalog +} + +/** 完整导出评分、描述及关联参照;不包含音频,不猜测或覆盖当前文件关联。 */ +export function exportRatingBackup(username: string): RatingMeta & { type: string, restorePolicy: string } { + recoverRatingTransaction(userDir(username)) + const meta = readMeta(username) + let catalog = {} + let tracks: any[] = [] + const catalogFile = path.join(ratingDir(username), 'songs.json') + if (fs.existsSync(catalogFile)) catalog = JSON.parse(fs.readFileSync(catalogFile, 'utf8')) + const registryFile = path.join(userDir(username), 'subsonic-local-registry.json') + if (fs.existsSync(registryFile)) tracks = Object.values(JSON.parse(fs.readFileSync(registryFile, 'utf8')).tracks || {}) + return { ...meta, type: 'lxRating_v1', restorePolicy: 'ratings-and-descriptions-current-links', ratingCatalog: sanitizeRatingCatalog(catalog), + localLinks: [...new Map(tracks.map(t => [t.id, { id: t.id, filename: t.filename, platformId: t.platformId }])).values()] } +} + +const normalizeMeta = (data: any): RatingMeta => ({ + ...(data?.ratingHistory ? { ratingHistory: normalizeRatingHistory(data.ratingHistory) } : {}), + ...(data?.ratingCatalog ? { ratingCatalog: sanitizeRatingCatalog(data.ratingCatalog) } : {}), + ...(Array.isArray(data?.localLinks) ? { localLinks: data.localLinks.filter((x: any) => x && typeof x.id === 'string' && typeof x.filename === 'string').map((x: any) => ({ id: x.id, filename: x.filename, platformId: x.platformId })) } : {}), + ...(data?.ratingOrigins ? { ratingOrigins: data.ratingOrigins } : {}), + starredAlbums: Array.isArray(data?.starredAlbums) ? data.starredAlbums : [], + starredArtists: Array.isArray(data?.starredArtists) ? data.starredArtists : [], + starredArtistNames: Array.isArray(data?.starredArtistNames) ? data.starredArtistNames : [], + ratings: Object.fromEntries(Object.entries(data?.ratings && typeof data.ratings === 'object' && !Array.isArray(data.ratings) ? data.ratings : {}).filter(([id, value]) => !['__proto__', 'constructor', 'prototype'].includes(id) && Number.isInteger(value) && Number(value) >= 1 && Number(value) <= 5).map(([id, value]) => [id, Number(value)])), +}) + +/** 供 HTTP 导入入口复用:外部备份(含 lxRating_v1 标记)统一归一化为评分元数据后再落盘 */ +export const normalizeRatingMeta = normalizeMeta + +const readMeta = (username: string): RatingMeta => { + recoverRatingTransaction(userDir(username)) + const file = metaPath(username) + if (!fs.existsSync(file)) return emptyMeta() + try { + const data = validateRatingStorage(JSON.parse(fs.readFileSync(file, 'utf8'))) + const catalogFile = path.join(ratingDir(username), 'songs.json') + const catalog = fs.existsSync(catalogFile) ? JSON.parse(fs.readFileSync(catalogFile, 'utf8')) : {} + const before = JSON.stringify(data), beforeCatalog = JSON.stringify(catalog) + const changes = planRatingMigration(userDir(username), data, catalog) + if (JSON.stringify(data) !== before) changes.set(file, JSON.stringify(data)) + if (JSON.stringify(catalog) !== beforeCatalog) changes.set(catalogFile, JSON.stringify(catalog)) + commitRatingFiles(userDir(username), changes) + return normalizeMeta(data) + } + catch { throw new Error('评分文件损坏,已停止操作并保留原文件,请从备份恢复') } +} + +const writeMeta = (username: string, meta: RatingMeta) => { + fs.mkdirSync(path.dirname(metaPath(username)), { recursive: true }) + const file = metaPath(username) + fs.writeFileSync(file + '.restore.tmp', JSON.stringify(meta), 'utf8') + fs.renameSync(file + '.restore.tmp', file) +} + +const readInfo = (username: string): RatingSnapshotInfo => { + try { + if (fs.existsSync(infoPath(username))) { + const data = JSON.parse(fs.readFileSync(infoPath(username), 'utf8')) + if (Array.isArray(data.imported)) return { imported: data.imported } + } + } catch { /* ignore */ } + return { imported: [] } +} + +const writeInfo = (username: string, info: RatingSnapshotInfo) => { + fs.mkdirSync(ratingDir(username), { recursive: true }) + fs.writeFileSync(infoPath(username), JSON.stringify(info), 'utf8') +} + +const ensureDirs = (username: string) => fs.mkdirSync(snapshotDir(username), { recursive: true }) + +const md5 = (text: string) => createHash('md5').update(text).digest('hex') + +/** 自动评分快照保留上限:导入快照永久保留,只清理超量的系统快照(保留最新 max 份) */ +const cleanupAutoSnapshots = (username: string) => { + try { + const info = readInfo(username) + const max = Math.max(1, Number(getUserConfig(username)?.maxSnapshotNum) || Number(global.lx.config.maxSnapshotNum) || 10) + const entries = fs.readdirSync(snapshotDir(username)) + .filter(file => file.startsWith('snapshot_')) + .map(file => ({ + id: file.slice('snapshot_'.length), + mtime: fs.statSync(path.join(snapshotDir(username), file)).mtimeMs, + })) + .filter(entry => !info.imported.includes(entry.id)) + .sort((a, b) => b.mtime - a.mtime) + for (const entry of entries.slice(max)) { + try { fs.unlinkSync(snapshotPath(username, entry.id)) } catch { /* ignore */ } + } + } catch { /* ignore */ } +} + +/** 按当前评分内容生成(或复用)一份系统快照,返回快照 id */ +export const createRatingSnapshot = async (username: string): Promise => { + const meta = exportRatingBackup(username) + const id = md5(JSON.stringify(meta)) + ensureDirs(username) + const file = snapshotPath(username, id) + if (!fs.existsSync(file)) fs.writeFileSync(file, JSON.stringify(meta), 'utf8') + else { + // 复用内容相同的旧快照时刷新 mtime:清理按 mtime 只保留最新 N 份,不刷新会把刚复用的快照当成最旧的删掉 + const now = new Date() + try { fs.utimesSync(file, now, now) } catch { /* ignore */ } + } + cleanupAutoSnapshots(username) + return id +} + +/** 保存用户导入的评分备份:按原文件名落盘、保留文件时间、显式登记来源 */ +export const saveRatingImport = async (username: string, name: string, meta: RatingMeta, time: number): Promise => { + ensureDirs(username) + const file = snapshotPath(username, name) + fs.writeFileSync(file, JSON.stringify(meta), 'utf8') + if (time) { + const date = new Date(time) + fs.utimesSync(file, date, date) + } + const info = readInfo(username) + if (!info.imported.includes(name)) { + info.imported.push(name) + writeInfo(username, info) + } +} + +export const listRatingSnapshots = async (username: string): Promise => { + ensureDirs(username) + const info = readInfo(username) + const list: RatingSnapshotItem[] = [] + for (const file of fs.readdirSync(snapshotDir(username))) { + if (!file.startsWith('snapshot_')) continue + const id = file.slice('snapshot_'.length) + try { + const stat = fs.statSync(snapshotPath(username, id)) + list.push({ + id, + time: stat.mtimeMs, + size: stat.size, + origin: info.imported.includes(id) ? 'imported' : 'auto', + }) + } catch { /* 文件已消失 */ } + } + return list.sort((a, b) => b.time - a.time) +} + +export const readRatingSnapshot = async (username: string, id: string): Promise => { + try { + const file = snapshotPath(username, id) + if (!fs.existsSync(file)) return null + return normalizeMeta(JSON.parse(fs.readFileSync(file, 'utf8'))) + } catch { return null } +} + +/** 回滚:把快照内容写回 subsonic-meta.json(Subsonic 每次请求会重新预读该文件,缓存自动刷新) */ +export const restoreRatingSnapshot = async (username: string, id: string): Promise => { + const meta = await readRatingSnapshot(username, id) + if (!meta) throw new Error('Rating snapshot not found') + // 与 Subsonic 的评分/收藏写入共用锁,旧快照的收藏字段不参与恢复。 + await withRatingIdentityLock(username, () => withMetaLock(username, async () => { + recoverRatingTransaction(userDir(username)) + const current = readMeta(username) + const dir = userDir(username) + recoverRatingTransaction(dir) + const catalogFile = path.join(ratingDir(username), 'songs.json') + const catalog = meta.ratingCatalog ? sanitizeRatingCatalog(meta.ratingCatalog) : (fs.existsSync(catalogFile) ? JSON.parse(fs.readFileSync(catalogFile, 'utf8')) : {}) + const history = { ...(current.ratingHistory || meta.ratingHistory || {}) } + for (const key of new Set([...Object.keys(current.ratings), ...Object.keys(meta.ratings)])) { + const from = current.ratings[key] || 0, to = meta.ratings[key] || 0 + if (from !== to) history[key] = [...(history[key] || []), { from, to, channel: 'snapshot-restore', at: Date.now(), snapshotId: id }].slice(-30) + } + const restored = { ...current, ratings: { ...meta.ratings }, ratingOrigins: { ...(meta.ratingOrigins || {}) }, ratingHistory: history } + const changes = planRatingMigration(dir, restored, catalog) + changes.set(catalogFile, JSON.stringify(catalog)) + changes.set(metaPath(username), JSON.stringify(restored)) + commitRatingFiles(dir, changes) + + })) +} + +export const deleteRatingSnapshot = async (username: string, id: string): Promise => { + const file = snapshotPath(username, id) + if (!fs.existsSync(file)) return false + fs.unlinkSync(file) + const info = readInfo(username) + const index = info.imported.indexOf(id) + if (index >= 0) { + info.imported.splice(index, 1) + writeInfo(username, info) + } + return true +} diff --git a/src/server/rating/ratingValidation.ts b/src/server/rating/ratingValidation.ts new file mode 100644 index 00000000..a6b981bd --- /dev/null +++ b/src/server/rating/ratingValidation.ts @@ -0,0 +1,16 @@ +const record = (value: any) => value && typeof value === 'object' && !Array.isArray(value) +const safeId = (id: string) => !!id && id.length <= 512 && !['__proto__','constructor','prototype'].includes(id) +export function normalizeRatingHistory(value: any): Record { + const result: Record = Object.create(null) + if (!record(value)) return result + for (const [id, events] of Object.entries(value)) { + if (!safeId(id) || !Array.isArray(events)) continue + result[id] = events.filter(e => record(e) && Number.isInteger(e.from) && e.from >= 0 && e.from <= 5 && Number.isInteger(e.to) && e.to >= 0 && e.to <= 5 && typeof e.at === 'number' && Number.isFinite(e.at) && e.at > 0 && typeof e.channel === 'string' && e.channel.length <= 100).slice(-30).map(e => ({ from:e.from,to:e.to,at:e.at,channel:e.channel,...(typeof e.snapshotId === 'string' && e.snapshotId.length<=200 ? {snapshotId:e.snapshotId}: {}) })) + } + return result +} +export function validateRatingStorage(data: any) { + if (!record(data) || (data.ratings !== undefined && !record(data.ratings))) throw new Error('评分文件结构损坏,请从备份恢复;原文件未修改') + if (data.ratings && Object.entries(data.ratings).some(([id,value]) => !safeId(id) || !Number.isInteger(value) || Number(value)<1 || Number(value)>5)) throw new Error('评分文件包含无效分值,已停止操作并保留原文件') + return data +} diff --git a/src/server/server.ts b/src/server/server.ts index d70574d9..a29bf460 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,3 +1,13 @@ +import { readRatingPreferences, saveRatingPreferences } from './rating/ratingPreferences' +import { ratingVersion, readRatingTasks, retryRatingTasks, ratingIdentityDetails, listRatingItems, readRatings, resolveRatingId, resolveStoredRatingId, writeRating } from './rating/ratingService' +import { getOpenlistRuntimeStatus, getOpenlistConnection, probeOpenlistConnection, verifyPending } from './openlist/openlistBackup' +import * as webdavRestoreTasks from './backup/webdavRestoreTasks' +import { lookupLocalLyrics } from './music/localLyrics' +import { resolveDownloadLyrics, resolveSongLyricData } from './music/downloadLyrics' +import { getVerifiedAudioSize } from './music/audioProbe' +import { downloadInRanges } from './music/parallelDownload' +import { buildLyrics } from '../utils/lrcTool' +import { resolveOpenlistCacheEntry } from './openlist/openlistBackup' import http, { type IncomingMessage } from 'node:http' import { resolveCollectionUser } from './auth/collectionAccess' import fs from 'node:fs' @@ -15,20 +25,47 @@ import { SYNC_CLOSE_CODE, } from '@/constants' import { getUserSpace, releaseUserSpace, getUserName, getServerId, getUserDirname, getUserConfig, migrateUserData, renameUserSpace, finishRenameUserSpace, updateAllUserSnapshotDirs } from '@/user' -import { parseDislikeRules, splitSingers } from '@/modules/dislike/match' +import { scopeDislikeRules, parseDislikeRules, splitSingers } from '@/modules/dislike/match' import { encodeAlbumRule } from '@/modules/dislike/utils' import { normalizeText } from '@/server/utils/songVersion' import { invalidateDislikeCache } from '@/server/utils/dislikeCache' +import { invalidateSubsonicPlaylistCache, syncRatingsToDislike, syncDislikesToRating, readSubsonicMeta } from '@/server/integrations/subsonic' +import { exportRatingBackup, createRatingSnapshot, saveRatingImport, listRatingSnapshots, readRatingSnapshot, restoreRatingSnapshot, deleteRatingSnapshot, normalizeRatingMeta, isValidSnapshotId } from './rating/ratingSnapshot' import { createMsg2call } from 'message2call' import { handleFileManager } from './integrations/fileManager' import formidable from 'formidable' // @ts-ignore import musicSdkRaw from '@/modules/utils/musicSdk/index.js' const musicSdk = musicSdkRaw as any -import { initUserApis, callUserApiGetMusicUrl, isSourceSupported, getLoadedApis, getLoadedApisCount } from './sources/userApi' -import { startVerifyScheduler, runSyncNow, listBackups, retryBackup, deleteOpenlistBackups, registerFileCacheSyncHook, restoreOpenlistData } from '@/server/openlistBackup' -import { BACKUP_TYPE_INFO, getBackupTypes, normalizeBackupTypes } from '@/utils/backupPolicy' -import { OpenListClient } from '@/server/openlistClient' +import { initUserApis, unloadUserApis, callUserApiGetMusicUrl, isSourceSupported, getLoadedApis, getLoadedApisCount } from './sources/userApi' +import { startVerifyScheduler, runSyncNow, listBackups, startOpenlistMetadata, openlistMetadataStatus, cancelMetadata, retryBackup, deleteOpenlistBackups, registerFileCacheSyncHook, restoreOpenlistData, triggerBackfillUploadDelete } from '@/server/openlist/openlistBackup' +import { getOpenlistRecoveryHistory, startOpenlistRestore, listRestoreTasks, cancelRestoreTask, rollbackRestoreTask, deleteRestoreTask } from '@/server/openlist/openlistBackup' +import { prepareOpenlistIntegrity, startOpenlistIntegrity, integrityStatus, cancelIntegrity } from '@/server/openlist/openlistBackup' +import { BACKUP_TYPE_INFO, getBackupTypes, normalizeBackupTypes, isCloudBackupRunning } from '@/utils/backupPolicy' +import { OpenListClient } from '@/server/openlist/openlistClient' + +/** 快照备份的三类目录;检测口径与 public/app.js 的导入识别保持一致 */ +type SnapshotKind = 'list' | 'dislike' | 'rating' +const isSnapshotObject = (value: unknown): value is Record => + !!value && typeof value === 'object' && !Array.isArray(value) + +const detectSnapshotKind = (parsed: unknown): SnapshotKind => { + if (!isSnapshotObject(parsed)) return 'list' + if (parsed.type === 'lxRating_v1') return 'rating' + if (Array.isArray(parsed.defaultList) || Array.isArray(parsed.userList)) return 'list' + if (Array.isArray(parsed.dislikeList)) return 'dislike' + if (isSnapshotObject(parsed.ratings)) return 'rating' + return 'list' +} + +/** 落雪音乐离线备份 playList_v2 转回服务端内部歌单快照结构 */ +const toInternalListSnapshot = (parsed: unknown): string | null => { + if (!isSnapshotObject(parsed) || parsed.type !== 'playList_v2' || !Array.isArray(parsed.data)) return null + const defaultList = parsed.data.find((l: any) => l?.id === 'default')?.list || [] + const loveList = parsed.data.find((l: any) => l?.id === 'love')?.list || [] + const userList = parsed.data.filter((l: any) => l?.id !== 'default' && l?.id !== 'love') + return JSON.stringify({ defaultList, loveList, userList }) +} async function validateOpenListConfig(newConfig: any): Promise { const c: any = global.lx.config @@ -76,6 +113,7 @@ function scheduleOpenlistSync(): void { }, 8000) } import * as customSourceHandlers from './sources/customSourceHandlers' +import { deleteUserSourceFiles } from './sources/userSourceCleanup' import * as fileCache from './music/fileCache' import { withCacheBrowseLocation } from './music/cacheBrowseLocation' import { remasterStaging } from './task/remasterStaging' @@ -144,12 +182,13 @@ const serializeDislikeRules = (rules: string, username?: string) => { const albumsArr = JSON.parse(fs.readFileSync(albumsFile, 'utf8')) if (Array.isArray(albumsArr)) { for (const x of albumsArr) { + const scoped = scopeDislikeRules(parsed, x.source) const albumName = normalizeText(String(x.name || '')) if (!albumName) continue - let sSet = parsed.albums.get(albumName) + let sSet = scoped.albums.get(albumName) if (!sSet) { sSet = new Set() - parsed.albums.set(albumName, sSet) + scoped.albums.set(albumName, sSet) } const singers = splitSingers(x.artistName) for (const s of singers) sSet.add(s) @@ -162,8 +201,9 @@ const serializeDislikeRules = (rules: string, username?: string) => { const artistsArr = JSON.parse(fs.readFileSync(artistsFile, 'utf8')) if (Array.isArray(artistsArr)) { for (const x of artistsArr) { + const scoped = scopeDislikeRules(parsed, x.source) const singerName = normalizeText(String(x.name || '')) - if (singerName) parsed.singerNames.add(singerName) + if (singerName) scoped.singerNames.add(singerName) } } } @@ -172,6 +212,7 @@ const serializeDislikeRules = (rules: string, username?: string) => { } } return { + scoped: parsed.scoped ? Object.fromEntries([...parsed.scoped].map(([source, rules]) => [source, { exact: [...rules.exact], musicNames: [...rules.musicNames], singerNames: [...rules.singerNames], albums: [...rules.albums].map(([albumName, singers]) => ({ albumName, singers: [...singers] })) }])) : {}, exact: Array.from(parsed.exact), musicNames: Array.from(parsed.musicNames), singerNames: Array.from(parsed.singerNames), @@ -798,7 +839,8 @@ const saveUsers = () => { } /** [新增] 服务器内部热重载数据 */ -const reloadServerData = async () => { +export const reloadServerData = async (strict = false) => { + if (strict) for (const user of global.lx.config.users || []) releaseUserSpace(user.name, true) startupLog.info('Hot-reloading server data (users and config)...') // 1. 重新加载配置文件 (必须先加载基础配置) @@ -818,11 +860,14 @@ const reloadServerData = async () => { // 如果有 WebDAV 同步实例,手动同步其配置 if (global.lx.webdavSync) { global.lx.webdavSync.updateConfig({ + enable: global.lx.config['webdav.enable'], url: global.lx.config['webdav.url'], username: global.lx.config['webdav.username'], password: global.lx.config['webdav.password'], syncPath: global.lx.config['webdav.syncPath'], backupPath: global.lx.config['webdav.backupPath'], + retentionCount: global.lx.config['webdav.retentionCount'], + retentionDays: global.lx.config['webdav.retentionDays'], interval: global.lx.config['sync.interval'], backupInterval: global.lx.config['sync.backupInterval'], types: getBackupTypes('webdav', global.lx.config as any), @@ -831,9 +876,11 @@ const reloadServerData = async () => { } // 刷新缓存位置映射,使 _open 等未自定义位置的用户回退到最新的全局 serverCacheLocation fileCache.invalidateUserCacheLocations() + global.lx.rescheduleConfigBackup?.() startupLog.info(`Config re-loaded and merged from ${configPath}.`) } catch (err: any) { startupLog.error('Failed to reload config file:', err.message) + if (strict) throw err } } @@ -859,6 +906,7 @@ const reloadServerData = async () => { } } catch (err: any) { startupLog.error('Failed to reload users.json:', err.message) + if (strict) throw err } } @@ -868,6 +916,7 @@ const reloadServerData = async () => { startupLog.info('User APIs re-initialized.') } catch (err: any) { startupLog.error('Failed to re-init user APIs:', err.message) + if (strict) throw err } startVerifyScheduler() @@ -902,26 +951,6 @@ const formatBytes = (bytes: number): string => { return `${(bytes / Math.pow(1024, index)).toFixed(2)} ${units[index]}` } -const getHeaderValue = (headers: Record, key: string): string | undefined => { - const value = headers[key] ?? headers[key.toLowerCase()] - if (Array.isArray(value)) return value[0] - return value == null ? undefined : String(value) -} - -const parseContentLength = (headers: Record): number | null => { - const range = getHeaderValue(headers, 'content-range') - const total = range?.match(/\/(\d+)$/)?.[1] - if (total) { - const parsed = Number(total) - if (Number.isFinite(parsed) && parsed > 0) return parsed - } - - const length = Number(getHeaderValue(headers, 'content-length')) - if (Number.isFinite(length) && length > 0) return length - - return null -} - const getAudioRemoteSize = async (audioUrl: string): Promise => { if (!/^https?:\/\//i.test(audioUrl)) return null @@ -941,7 +970,7 @@ const getAudioRemoteSize = async (audioUrl: string): Promise => { try { const resp = await needle('head', audioUrl, null, options) - const size = parseContentLength(resp.headers || {}) + const size = getVerifiedAudioSize(resp.statusCode || 0, resp.headers || {}) if (size) return size } catch (e: any) { console.warn(`[音质探测] HEAD 请求探测文件大小失败: ${e.message}`) @@ -955,7 +984,7 @@ const getAudioRemoteSize = async (audioUrl: string): Promise => { Range: 'bytes=0-0', }, }) - return parseContentLength(resp.headers || {}) + return getVerifiedAudioSize(resp.statusCode || 0, resp.headers || {}) } catch (e: any) { console.warn(`[音质探测] Range 分段探测文件大小失败: ${e.message}`) } @@ -1246,6 +1275,13 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi const urlObj = new URL(req.url ?? '', `http://${req.headers.host}`) accessLog.info(`${req.method} ${urlObj.pathname} from ${ip}`) const pathname = urlObj.pathname + if (global.lx.restoreMaintenance && !pathname.startsWith('/api/openlist/restore') && !pathname.startsWith('/api/webdav/restore') && !['/api/restart', '/api/admin/verify', '/api/config', '/api/status', '/api/backup/status'].includes(pathname) && (pathname.startsWith('/api/') || !['GET', 'HEAD'].includes(req.method || ''))) { + res.writeHead(503, { 'Content-Type': 'application/json', 'Retry-After': '5' }) + res.end(JSON.stringify({ error: '恢复已进入维护状态,请重启服务后继续操作' })); return + } + if (global.lx.restoreMaintenance && pathname === '/api/config' && req.method !== 'GET') { + res.writeHead(503); res.end('恢复维护期间不能保存配置,请先重启服务'); return + } // 读取路径配置(每次请求都重新读取,保存后立刻生效) const normalizePath = (p: string) => (p || '').replace(/\/+$/, '') @@ -1255,6 +1291,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi // 而本段判断先于下方的 Subsonic 路由执行,导致自定义 subsonic.path 会被当成播放器请求、后端接口整个失效。 const subsonicPath = normalizePath(global.lx.config['subsonic.path'] || '/rest') || '/rest' const isSubsonicRequest = pathname === subsonicPath || pathname.startsWith(subsonicPath + '/') + if (global.lx.restoreMaintenance && isSubsonicRequest) { res.writeHead(503); res.end('恢复维护中,请重启服务'); return } // [修复] 判断是否为 LX 同步客户端协议路由 (如 /hello, /id, /ah 以及 //hello, //ah 等) const isSyncProtocolRequest = /^\/([^/]+\/)?(hello|id|ah)$/.test(pathname) @@ -1496,6 +1533,18 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi // [新增] 获取服务器状态 + if (pathname === '/api/backup/status' && req.method === 'GET') { + if (req.headers['x-frontend-auth'] !== global.lx.config['admin.password']) { res.writeHead(401); res.end('Unauthorized'); return } + try { + const config = global.lx.config + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }) + res.end(JSON.stringify({ success: true, maintenance: global.lx.restoreMaintenance === true, + webdav: config['webdav.enable'] ? global.lx.webdavSync?.getRuntimeStatus() : null, + openlist: config['openlist.enable'] ? getOpenlistRuntimeStatus() : null })) + } catch (error: any) { res.writeHead(500); res.end(JSON.stringify({ success: false, message: error.message })) } + return + } + if (pathname === '/api/status' && req.method === 'GET') { const auth = req.headers['x-frontend-auth'] if (auth !== global.lx.config['admin.password']) { @@ -1553,6 +1602,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi cpuModel: os.cpus()[0]?.model || 'Unknown', cpuSpeed: os.cpus()[0]?.speed || 0, isWebDAVConfigured: !!(global.lx.config['webdav.url'] && global.lx.config['webdav.url'].trim() !== '' && global.lx.config['webdav.enable']), + restoreMaintenance: global.lx.restoreMaintenance === true, sourcesCount: getLoadedApisCount ? getLoadedApisCount() : 0, nodeVersion: process.version, platform: `${os.type()} ${os.arch()}`, @@ -1568,6 +1618,54 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi return } + if (pathname === '/api/dashboard/overview' && req.method === 'GET') { + if (req.headers['x-frontend-auth'] !== global.lx.config['admin.password']) { res.writeHead(401); res.end('Unauthorized'); return } + const cfg: any = global.lx.config + const now = Date.now() + const HOURS = 12 + const buckets = Array.from({ length: HOURS }, (_, k) => ({ t: now - (HOURS - 1 - k) * 3600000, info: 0, warn: 0, error: 0 })) + const totals = { info: 0, warn: 0, error: 0 } + const recent: Array<{ time: string, level: string, msg: string }> = [] + try { + const file = path.join(global.lx.logPath, 'app.log') + const fd = fs.openSync(file, 'r') + const size = fs.fstatSync(fd).size + const len = Math.min(size, 512 * 1024) + const buf = Buffer.alloc(len) + fs.readSync(fd, buf, 0, len, size - len) + fs.closeSync(fd) + for (const line of buf.toString('utf8').split(/\r?\n/)) { + const m = line.match(/^\[([^\]]+)\]\s+\[?(INFO|WARN|WARNING|ERROR)\]?\s+(.*)$/) + if (!m) continue + const lv = m[2] === 'ERROR' ? 'error' : m[2] === 'INFO' ? 'info' : 'warn' + const ts = new Date(m[1]).getTime() + if (Number.isNaN(ts) || now - ts > 24 * 3600000) continue + totals[lv as 'info']++ + const idx = HOURS - 1 - Math.floor((now - ts) / 3600000) + if (idx >= 0 && idx < HOURS) (buckets[idx] as any)[lv]++ + if (lv !== 'info') { recent.push({ time: m[1], level: lv, msg: m[3].slice(0, 200) }); if (recent.length > 8) recent.shift() } + } + } catch { /* log file may not exist */ } + let disk: any = null + try { + const s: any = (fs as any).statfsSync(global.lx.dataPath) + disk = { total: s.blocks * s.bsize, free: s.bavail * s.bsize } + } catch { /* statfs unsupported */ } + const mu = process.memoryUsage() + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }) + res.end(JSON.stringify({ + success: true, + logs: { totals, buckets: buckets.map(b => ({ t: b.t, info: b.info, warn: b.warn, error: b.error })), recent: recent.reverse() }, + disk, + heap: { used: mu.rss, total: os.totalmem(), external: mu.external }, + services: { + webdav: !!cfg['webdav.enable'], openlist: !!cfg['openlist.enable'], + publicFavorites: !!cfg['user.enablePublicFavorites'], maintenance: global.lx.restoreMaintenance === true, + }, + node: process.version, pid: process.pid, + })) + return + } if (pathname === '/api/users') { const auth = req.headers['x-frontend-auth'] if (auth !== global.lx.config['admin.password']) { @@ -1788,7 +1886,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi } let deletedCount = 0 - const deletedUsers: { name: string, dataPath: string }[] = [] + const deletedUsers: { name: string, dataPath?: string }[] = [] for (const targetName of targets) { const idx = global.lx.config.users.findIndex(u => u.name === targetName) @@ -1797,7 +1895,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi // 保存用户数据路径(如果需要删除) console.log(`[用户管理] 删除数据选项: ${deleteData}, 用户数据路径: ${user.dataPath}`) - if (deleteData && user.dataPath) { + if (deleteData) { deletedUsers.push({ name: targetName, dataPath: user.dataPath }) } else { console.log(`[用户管理] 跳过删除用户 ${targetName} 的数据目录 (deleteData=${deleteData}, 数据目录存在=${!!user.dataPath})`) @@ -1811,6 +1909,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi } revokeUserSessions(targetName) releaseUserSpace(targetName, true) + unloadUserApis(targetName) global.lx.config.users.splice(idx, 1) deletedCount++ } @@ -1824,6 +1923,12 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi console.log(`[用户管理] 正在清理 ${deletedUsers.length} 个用户的数据目录...`) for (const user of deletedUsers) { try { + deleteUserSourceFiles(user.name) + } catch (err) { + console.error(`[用户管理] 删除用户 ${user.name} 的音源目录失败:`, err) + } + try { + if (!user.dataPath) continue console.log(`[用户管理] 检查并清理目录: ${user.dataPath}`) if (fs.existsSync(user.dataPath)) { fs.rmSync(user.dataPath, { recursive: true, force: true }) @@ -1905,11 +2010,16 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi console.error(err) } + const dislikeList = (await userSpace.dislikeManage.getDislikeRules()).dislikeList + await require('./music/subsonicLocal').getLocalTracks(verifiedUser) + const ratingMeta = readSubsonicMeta(verifiedUser) + const ratingItems = listRatingItems(verifiedUser) + const activeRatings = Object.fromEntries(ratingItems.filter(x => !x.dormant).map(x => [x.id, x.rating])) res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache, no-store, must-revalidate' }) - res.end(JSON.stringify({ ...data, albums, artists, dislikeAlbums, dislikeArtists })) + res.end(JSON.stringify({ ...data, albums, artists, dislikeAlbums, dislikeArtists, dislikeList, userRatings: activeRatings, storedRatings: ratingMeta.ratings, ratingOrigins: ratingMeta.ratingOrigins || {}, ratingItems, ratingSyncPending: readRatingTasks(verifiedUser) })) }).catch(err => { res.writeHead(500) res.end(err.message) @@ -1945,7 +2055,14 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi const userSpace = getUserSpace(verifiedUser) try { - const list = await userSpace.listManage.getSnapshotList() + const listSnaps = await userSpace.listManage.getSnapshotList() + const dislikeSnaps = userSpace.dislikeManage ? await userSpace.dislikeManage.snapshotDataManage.getSnapshotListWithMeta() : [] + const ratingSnaps = await listRatingSnapshots(verifiedUser) + const list = [ + ...listSnaps.map(s => ({ ...s, kind: 'list' })), + ...dislikeSnaps.map(s => ({ ...s, kind: 'dislike' })), + ...ratingSnaps.map(s => ({ ...s, kind: 'rating' })), + ].sort((a, b) => (b.time || 0) - (a.time || 0)) res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache, no-store, must-revalidate' @@ -1992,7 +2109,18 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi return } try { - const data = await userSpace.listManage.getSnapshot(id) + const kind = urlObj.searchParams.get('kind') + if (kind && kind !== 'list' && kind !== 'dislike' && kind !== 'rating') throw new Error('Invalid snapshot kind') + let data: any + if (kind === 'rating') { + // 评分快照严格隔离读取,避免与歌单 / 不喜欢目录中的同名 id 串数据 + data = await readRatingSnapshot(verifiedUser, id) + } else { + data = kind === 'dislike' ? null : await userSpace.listManage.getSnapshot(id) + if (!data && kind !== 'list' && userSpace.dislikeManage) { + data = await userSpace.dislikeManage.snapshotDataManage.getSnapshot(id) + } + } if (!data) { res.writeHead(404) res.end('Not Found') @@ -2039,10 +2167,21 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi const userSpace = getUserSpace(verifiedUser) try { const body = await readBody(req) - const { id } = JSON.parse(body) + const { id, kind } = JSON.parse(body) if (!id) throw new Error('Missing id') - await userSpace.listManage.restoreSnapshot(id) + if (kind && kind !== 'list' && kind !== 'dislike' && kind !== 'rating') throw new Error('Invalid snapshot kind') + if (kind === 'rating') { + await restoreRatingSnapshot(verifiedUser, id) + // Subsonic 每次非轻量请求都会重新预读 subsonic-meta.json,评星缓存随之刷新 + invalidateSubsonicPlaylistCache() + } else if (kind === 'dislike' && userSpace.dislikeManage) { + await userSpace.dislikeManage.restoreSnapshot(id) + invalidateDislikeCache(verifiedUser) + } else { + await userSpace.listManage.restoreSnapshot(id) + invalidateSubsonicPlaylistCache() + } res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ success: true })) @@ -2236,11 +2375,22 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi const userSpace = getUserSpace(verifiedUser) try { const body = await readBody(req) - const { id } = JSON.parse(body) + const { id, kind } = JSON.parse(body) if (!id) throw new Error('Missing id') - // 调用刚刚在 ListManage 中添加的方法 - await userSpace.listManage.removeSnapshot(id) + if (kind && kind !== 'list' && kind !== 'dislike' && kind !== 'rating') throw new Error('Invalid snapshot kind') + if (kind === 'rating') { + if (!await deleteRatingSnapshot(verifiedUser, id)) throw new Error('快照不存在') + } else if (kind === 'dislike') { + if (!userSpace.dislikeManage || !await userSpace.dislikeManage.snapshotDataManage.getSnapshot(id)) throw new Error('快照不存在') + await userSpace.dislikeManage.snapshotDataManage.removeSnapshot(id) + } else if (await userSpace.listManage.getSnapshot(id)) { + await userSpace.listManage.removeSnapshot(id) + } else if (!kind && userSpace.dislikeManage && await userSpace.dislikeManage.snapshotDataManage.getSnapshot(id)) { + await userSpace.dislikeManage.snapshotDataManage.removeSnapshot(id) + } else { + throw new Error('快照不存在') + } res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ success: true })) @@ -2257,6 +2407,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi const userParam = urlObj.searchParams.get('user') const time = parseInt(urlObj.searchParams.get('time') || '0') const filename = urlObj.searchParams.get('filename') + const restore = urlObj.searchParams.get('restore') === '1' if (!userParam) { res.writeHead(400) @@ -2288,48 +2439,56 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi try { const body = await readBody(req) - let finalData = body + let parsed: unknown = null + try { parsed = JSON.parse(body) } catch { /* 非 JSON 按原生歌单快照导入 */ } - // [核心兼容性修复] 检测是否为落雪音乐离线备份格式 (playList_v2) - try { - const jsonData = JSON.parse(body) - if (jsonData && jsonData.type === 'playList_v2' && Array.isArray(jsonData.data)) { - startupLog.info(`[Snapshot] Detected LX Music backup format for user ${verifiedUser}, converting back to internal format...`) - - // 寻找默认列表 - const defaultList = jsonData.data.find((l: any) => l.id === 'default')?.list || [] - // 寻找收藏列表 - const loveList = jsonData.data.find((l: any) => l.id === 'love')?.list || [] - // 其他所有列表均作为用户列表 - const userList = jsonData.data.filter((l: any) => l.id !== 'default' && l.id !== 'love') - - // 拼装为服务器内部快照格式 - const internalFormat = { - defaultList, - loveList, - userList - } + // 三类备份分流到各自目录:歌单 / 不喜欢 / 我的评分,与前端导入识别口径一致 + const kind: SnapshotKind = detectSnapshotKind(parsed) + const ratingMeta = kind === 'rating' ? normalizeRatingMeta(parsed) : null + const convertedList = kind === 'list' ? toInternalListSnapshot(parsed) : null + const finalData = ratingMeta ? JSON.stringify(ratingMeta) : (convertedList || body) - // 压缩为单行 JSON 以节省磁盘空间并保持与原生快照一致的大小 - finalData = JSON.stringify(internalFormat) - startupLog.info(`[Snapshot] Conversion complete for user ${verifiedUser}.`) - } - } catch (parseErr) { - // 解析失败说明不是标准的 JSON 格式或已经是原始快照,保持原样即可 - } - - // 处理文件名:如果以 snapshot_ 开头,则去掉(因为 saveSnapshotWithTime 会自动加) - // 如果不以 snapshot_ 开头,则保持原样(saveSnapshotWithTime 会自动加 snapshot_ 前缀) + // 文件名会直接拼进快照目录,统一拒绝路径分隔符与上跳片段 let name = filename - if (name.startsWith('snapshot_')) { - name = name.substring(9) + if (name.startsWith('snapshot_')) name = name.substring(9) + if (!isValidSnapshotId(name)) throw new Error('Invalid snapshot id') + + // 「导入并恢复」:先把导入前状态存成系统快照,再落盘并立即恢复 + let safetySnapshot = false + if (restore) { + if (kind === 'rating') await createRatingSnapshot(verifiedUser) + else if (kind === 'dislike') { + if (!userSpace.dislikeManage) throw new Error('不喜欢管理未启用') + await userSpace.dislikeManage.createSnapshot() + } else await userSpace.listManage.createSnapshot() + safetySnapshot = true + } + + if (kind === 'rating') { + await saveRatingImport(verifiedUser, name, ratingMeta!, time) + } else if (kind === 'dislike') { + if (!userSpace.dislikeManage) throw new Error('不喜欢管理未启用') + await userSpace.dislikeManage.saveSnapshotWithTime(name, finalData, time) + } else { + await userSpace.listManage.saveSnapshotWithTime(name, finalData, time) } - // 调用 ListManage 中的 saveSnapshotWithTime 方法 - await userSpace.listManage.saveSnapshotWithTime(name, finalData, time) + if (restore) { + if (kind === 'rating') { + await restoreRatingSnapshot(verifiedUser, name) + invalidateSubsonicPlaylistCache() + } else if (kind === 'dislike') { + if (!userSpace.dislikeManage) throw new Error('不喜欢管理未启用') + await userSpace.dislikeManage.restoreSnapshot(name) + invalidateDislikeCache(verifiedUser) + } else { + await userSpace.listManage.restoreSnapshot(name) + invalidateSubsonicPlaylistCache() + } + } res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ success: true })) + res.end(JSON.stringify({ success: true, kind, restored: restore, safetySnapshot })) } catch (err: any) { res.writeHead(500) res.end(err.message) @@ -2337,6 +2496,35 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi return } + // [新增] Subsonic 评分 / 不喜欢 联动补数据(仅管理员从配置页手动触发) + if (pathname === '/api/subsonic/sync-link' && req.method === 'POST') { + if (req.headers['x-frontend-auth'] !== global.lx.config['admin.password']) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: 'Unauthorized' })) + return + } + try { + const { user, direction, preview, reconcile } = JSON.parse(await readBody(req)) + if (!user || typeof user !== 'string') throw new Error('Missing user') + if (direction !== 'ratingToDislike' && direction !== 'dislikeToRating') throw new Error('Invalid direction') + const enabled = direction === 'ratingToDislike' + ? global.lx.config['subsonic.linkRatingToDislike'] + : true + if (!enabled) throw new Error('联动开关未开启,请先在配置中开启并保存') + const known = user === '_open' || global.lx.config.users.some((u: any) => u.name === user) + if (!known) throw new Error('用户不存在') + const result = direction === 'ratingToDislike' + ? (reconcile === true ? await require('./integrations/subsonic').subsonicHandler.reconcileRatingDislikes(user, preview !== false) : await syncRatingsToDislike(user, preview === true)) + : await syncDislikesToRating(user, preview === true) + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(result)) + } catch (err: any) { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(err.message) + } + return + } + // [新增] User Login Verification if (pathname === '/api/user/verify' && req.method === 'POST') { void readBody(req).then(body => { @@ -3201,7 +3389,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi // [核心逻辑] 如果是受限的公开用户,仅允许保存特定的 3 项设置 if (resolvedUsername === '_open' && global.lx.config['user.enablePublicRestriction']) { const restrictedSettings: any = {} - const allowedKeys = ['enableServerCache', 'enableServerLyricCache', 'serverCacheLocation', 'serverCacheNamingPattern', 'downloadConcurrency', 'enableRemaster', 'preferredQuality', 'enableOnlyDownloadMode', 'enablePublicSources', 'embedLyricToFile', 'preferServerCache'] + const allowedKeys = ['preferLocalLyrics', 'enableServerCache', 'enableServerLyricCache', 'serverCacheLocation', 'serverCacheNamingPattern', 'downloadConcurrency', 'enableRemaster', 'preferredQuality', 'enableOnlyDownloadMode', 'enablePublicSources', 'embedLyricToFile', 'preferServerCache'] allowedKeys.forEach(key => { if (settings[key] !== undefined) restrictedSettings[key] = settings[key] }) @@ -4172,8 +4360,18 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi username = verified } - const result = fileCache.checkCache({ name, singer, source, songmid, songId, quality, exactQuality }, username) - if (result && result.exists && username !== '_open' && username !== 'default') { + let result: any = fileCache.checkCache({ name, singer, source, songmid, songId, quality, exactQuality }, username) + if (!result.exists && urlObj.searchParams.get('includeOpenlist') === '1') { + const id = fileCache.normalizeSongId({ source, songmid, id: songId }) + const song = { source, songmid, id, name, singer } + const q = quality || '128k' + const playbackSignal = AbortSignal.timeout(4000) + for (const folder of ['music', 'cache'] as const) { + const hit = await resolveOpenlistCacheEntry(username, folder, fileCache.getFileName(song, q, folder === 'music', username), { id, source, quality: q }, playbackSignal) + if (hit) { result = { exists: true, url: hit.url, filename: hit.filename, quality: hit.quality, folder, origin: 'openlist' }; break } + } + } + if (result && result.exists && result.origin !== 'openlist' && username !== '_open' && username !== 'default') { const token = req.headers['x-user-token'] if (token) { result.url += `&token=${encodeURIComponent(token as string)}` @@ -4482,7 +4680,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi username = verified } try { - const stats = fileCache.getCacheStats(username) + const stats = await fileCache.getCacheStats(username) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ success: true, data: stats })) } catch (e: any) { @@ -4608,7 +4806,11 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi // 7. Get Cache Progress if (pathname === '/api/music/cache/progress' && req.method === 'GET') { const ids = urlObj.searchParams.get('ids')?.split(',') || [] - const owner = getCacheRequestUsername(req) + // Native downloads also authenticate with the player session cookie. + // Resolve the same owner here as /api/music/download. + const cookieSession = playerSessions.get(parseCookies(req.headers.cookie)[SESSION_COOKIE_NAME]) + const cookieOwner = cookieSession && Date.now() - cookieSession.createdAt <= SESSION_TTL && global.lx.config.users.some(u => u.name === cookieSession.user) ? cookieSession.user : null + const owner = verifyUserAuth(req) || cookieOwner || getCacheRequestUsername(req) if (!owner) { res.writeHead(401); res.end('Unauthorized'); return } const progress: any = {} ids.forEach(id => { @@ -4786,7 +4988,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi // ===== [新增] Custom Music APIs (独立于 cache/music) ===== // A. 获取自定义目录歌曲列表 - if (pathname === '/api/music/custom/list' && req.method === 'GET') { + if (pathname === '/api/music/custom/list' && (req.method === 'GET' || req.method === 'POST')) { const verified = verifyUserAuth(req) if (!verified) { res.writeHead(401, { 'Content-Type': 'application/json' }) @@ -4794,14 +4996,24 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi return } const userCfg = getUserConfig(verified) - void customMusicManager.getCustomMusicList(verified).then(list => { + void (async () => { + // Capture before reading the list so a concurrent save can only cause an extra refresh. + const version = `${customMusicManager.getCustomMusicVersion(verified)}:${!!userCfg?.allowOperateCustomMusicDir}:${!!userCfg?.allowWriteCustomMusicDir}` + if (req.method === 'GET') return { + version, + data: urlObj.searchParams.get('versionOnly') === '1' ? undefined : await customMusicManager.getCustomMusicList(verified) + } + const { filenames } = JSON.parse(await readBody(req)) + if (!Array.isArray(filenames) || filenames.some(name => typeof name !== 'string')) throw new Error('Invalid filenames') + return { version, data: await customMusicManager.getCustomMusicList(verified, filenames) } + })().then(snapshot => { res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-cache, no-store, must-revalidate', }) res.end(JSON.stringify({ success: true, - data: list, + ...snapshot, allowOperateCustomMusicDir: !!userCfg?.allowOperateCustomMusicDir, allowWriteCustomMusicDir: !!userCfg?.allowWriteCustomMusicDir })) @@ -4923,11 +5135,15 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi : (payload.filenames ? (Array.isArray(payload.filenames) ? payload.filenames : [payload.filenames]) : []) let deletedCount = 0 - for (const item of rawItems) { - const filename = typeof item === 'string' ? item : item?.filename - if (filename && customMusicManager.removeCustomFile(filename, verified)) { - deletedCount++ + try { + for (const item of rawItems) { + const filename = typeof item === 'string' ? item : item?.filename + if (filename && customMusicManager.removeCustomFile(filename, verified, false)) { + deletedCount++ + } } + } finally { + if (deletedCount) customMusicManager.customIndexManager.save(verified) } res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ success: true, deletedCount })) @@ -5326,7 +5542,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi // 没有 .lrc 文件,尝试通过 SDK 获取 const lyricFetcherFn = fileCache.getLyricFetcher() if (lyricFetcherFn) { - lyricText = await lyricFetcherFn(songInfo) + lyricText = await lyricFetcherFn(songInfo, username) } if (lyricText) { console.log(`[嵌入歌词] 从音源获取到歌词: ${filename}`) @@ -5382,14 +5598,14 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi void readBody(req).then(async body => { try { - const { filename, songInfo } = JSON.parse(body) + const { filename, songInfo, folder } = JSON.parse(body) if (!filename || !songInfo) { res.writeHead(400) res.end('Missing params') return } - const result = await fileCache.linkLocalFile(filename, songInfo, verified) + const result = await fileCache.linkLocalFile(filename, songInfo, verified, folder === 'music' || folder === 'cache' ? folder : undefined) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(result)) } catch (e: any) { @@ -5452,95 +5668,17 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi // [New] Fetch Lyrics if (pathname === '/api/music/lyric' && req.method === 'GET') { - const source = urlObj.searchParams.get('source') - // [Optimization] Accept multiple ID param names for better client compatibility - let songmid = urlObj.searchParams.get('songmid') || urlObj.searchParams.get('songId') || urlObj.searchParams.get('id') - - if (!source || !songmid) { - res.writeHead(400) - res.end('Missing source or songmid') - return - } - - // [Fix] Normalize ID by stripping source prefix if present (e.g., "tx_001..." -> "001...") - const sourcePrefix = `${source}_` - if (songmid.startsWith(sourcePrefix)) { - songmid = songmid.slice(sourcePrefix.length) + const songInfo: any = Object.fromEntries(urlObj.searchParams) + if (!songInfo.source || !(songInfo.songmid || songInfo.songId || songInfo.id)) { + res.writeHead(400); res.end('Missing source or songmid'); return } - - // [优化] 先检查本地 .lrc 文件缓存,命中则直接返回,无需网络请求(断网也可用) - const reqUsername = (req.headers['x-user-name'] as string) || '' - const isPublic = !reqUsername || reqUsername === 'default' - let lyricUsername = '_open' - if (!isPublic) { - const verified = verifyUserAuth(req) - if (verified) lyricUsername = verified - } - const localLyricResult = fileCache.checkLyricCache({ - source, - songmid, - id: urlObj.searchParams.get('songId') || urlObj.searchParams.get('id') || songmid, - name: urlObj.searchParams.get('name') || '', - singer: urlObj.searchParams.get('singer') || '', - }, lyricUsername) - - if (localLyricResult.exists && localLyricResult.content) { - console.log(`[歌词服务] 命中本地 .lrc 缓存: ${source}_${songmid}`) - res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=86400' }) - res.end(JSON.stringify({ ...localLyricResult.content, _fromLocalCache: true })) - return - } - + const owner = verifyUserAuth(req) || '_open' try { - if (!musicSdk[source]) { - throw new Error('Source not supported') - } - - // console.log('[歌词服务] 正在从音源获取歌词:', source, songmid) - - // Construct complete songInfo object for SDK compatibility - // KuGou (kg) needs: name, hash, interval - // MiGu (mg) needs: copyrightId, lrcUrl, mrcUrl, trcUrl (优先,避免调用getMusicInfo API) - const songInfo = { - songmid, - name: urlObj.searchParams.get('name') || '', - singer: urlObj.searchParams.get('singer') || '', - hash: urlObj.searchParams.get('hash') || '', - interval: urlObj.searchParams.get('interval') || '', - copyrightId: urlObj.searchParams.get('copyrightId') || '', - albumId: urlObj.searchParams.get('albumId') || '', - lrcUrl: urlObj.searchParams.get('lrcUrl') || '', - mrcUrl: urlObj.searchParams.get('mrcUrl') || '', - trcUrl: urlObj.searchParams.get('trcUrl') || '' - } - - const requestObj = musicSdk[source].getLyric(songInfo) - const lyricInfo = await requestObj.promise - - res.writeHead(200, { - 'Content-Type': 'application/json', - 'Cache-Control': 'public, max-age=86400' // Cache lyrics for 1 day - }) - res.end(JSON.stringify(lyricInfo)) + const result = await resolveSongLyricData(songInfo, owner, lookupLocalLyrics, musicSdk, + urlObj.searchParams.get('preferLocal') !== '0', false) + res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'private, no-store' }) + res.end(JSON.stringify(result)) } catch (err: any) { - console.error('[歌词服务] 获取歌词失败:', source, songmid, err.message || err) - - // [Fallback] 网络请求失败时,再次尝试本地 .lrc 文件(防止 Step2 miss 但物理文件存在的情况) - const fallbackResult = fileCache.checkLyricCache({ - source, - songmid, - id: urlObj.searchParams.get('songId') || urlObj.searchParams.get('id') || songmid, - name: urlObj.searchParams.get('name') || '', - singer: urlObj.searchParams.get('singer') || '', - }, lyricUsername) - if (fallbackResult.exists && fallbackResult.content) { - console.log(`[歌词服务] 网络获取失败,回退到本地 .lrc: ${source}_${songmid}`) - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ ...fallbackResult.content, _fromLocalCache: true })) - return - } - - // Avoid circular structure error - only send message res.writeHead(500, { 'Content-Type': 'text/plain' }) res.end(err.message || 'Failed to fetch lyric') } @@ -5567,7 +5705,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi username = verified } - if (!source || (!songmid && !songId)) { + if ((!source || (!songmid && !songId)) && !urlObj.searchParams.get('filename')) { res.writeHead(400) res.end('Missing source or songmid') return @@ -5575,7 +5713,9 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi const name = urlObj.searchParams.get('name') || '' const singer = urlObj.searchParams.get('singer') || '' - const result = fileCache.checkLyricCache({ source, songmid, id: songId, name, singer }, username) + let result: { exists: boolean; content?: any } + try { result = lookupLocalLyrics({ source, songmid, id: songId, name, singer, filename: urlObj.searchParams.get('filename'), folder: urlObj.searchParams.get('folder') || 'cache' }, username) } + catch { res.writeHead(400); res.end(JSON.stringify({ success: false, message: '本地歌词路径无效' })); return } if (result.exists) { res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ success: true, data: result.content })) @@ -5658,12 +5798,33 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi const isTaggingMode = urlObj.searchParams.get('tag') === '1' const rawTaskId = urlObj.searchParams.get('taskId') const taskId = rawTaskId ? fileCache.getProgressKey(rawTaskId, proxyUser || '_open') : null + const downloadAbort = new AbortController() + res.once('close', () => downloadAbort.abort()) + let preparedDownload: Awaited> = null + let progressBytes = 0, progressTime = Date.now() + const updateDownloadProgress = (received: number, total: number) => { + if (!taskId) return + const now = Date.now() + const elapsed = (now - progressTime) / 1000 + const speed = elapsed > 0 ? Math.max(0, (received - progressBytes) / elapsed) : 0 + if (elapsed < 0.5 && received !== total) return + progressBytes = received; progressTime = now + fileCache.cacheProgress.set(taskId, { progress: Math.min(99, Math.round(received / total * 100)), status: 'downloading', total, received, speed, updatedAt: now }) + } console.log(`[${proxyLabel}] 正在获取${filename === 'cover.jpg' ? '封面图片' : '媒体流'} (写入标签: ${isTaggingMode}, 任务ID: ${taskId})`) // 使用原生 http/https 模块以获得最高的流媒体转发性能 const http = require('http') const https = require('https') + if (req.method === 'GET' && isTaggingMode && !isInline && (!req.headers.range || req.headers.range === 'bytes=0-')) { + if (taskId) fileCache.cacheProgress.set(taskId, { progress: 0, status: 'downloading', total: 0, received: 0, speed: 0, updatedAt: Date.now() }) + try { + preparedDownload = await downloadInRanges(urlStr, { signal: downloadAbort.signal, progress: updateDownloadProgress }) + } catch { return } + if (downloadAbort.signal.aborted) { preparedDownload?.cleanup(); return } + } + // Manual redirect handling for maximum control and stability const doFetch = async (targetUrl: string, attempt: number) => { if (attempt > 5) { @@ -5677,7 +5838,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi try { const { url: parsedUrl, address, family } = await resolvePublicUrl(targetUrl) - if (res.destroyed || res.writableEnded) return + if (res.destroyed || res.writableEnded) { preparedDownload?.cleanup(); return } const options: any = { method: req.method, lookup: (_host: string, opts: any, callback: any) => opts.all ? callback(null, [{ address, family }]) : callback(null, address, family), @@ -5696,13 +5857,19 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi const lib = parsedUrl.protocol === 'https:' ? https : http const failProxy = (err: Error) => { + if (taskId) { + const previous = fileCache.cacheProgress.get(taskId) + fileCache.cacheProgress.set(taskId, { ...previous, progress: previous?.progress || 0, status: 'error', speed: 0, errorMsg: `下载连接中断:${(err as any).code || err.message}`, updatedAt: Date.now() }) + setTimeout(() => fileCache.cacheProgress.delete(taskId), 30000) + } if (res.destroyed || res.writableEnded) return if (res.headersSent) res.destroy(err) else { res.writeHead(502); res.end('Request Error') } } - const proxyReq = lib.request(targetUrl, options, (proxyRes: any) => { + let proxyReq: any + const handleResponse = (proxyRes: any) => { // The timeout protects connection/headers, not paused or backpressured audio. - proxyReq.setTimeout(0) + proxyReq?.setTimeout(0) proxyRes.once('error', failProxy) proxyRes.once('aborted', () => failProxy(new Error('Upstream response aborted'))) // 处理重定向 @@ -5717,6 +5884,11 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi } // 处理最终响应 + if (isTaggingMode && ![200, 206].includes(proxyRes.statusCode)) { + proxyRes.resume() + failProxy(new Error(`上游返回 HTTP ${proxyRes.statusCode}`)) + return + } let contentType = (req as any).castContentType || proxyRes.headers['content-type'] || 'application/octet-stream' // Some audio CDNs label M4A bytes as form data. Safari needs a media MIME type. // Only repair generic labels on successful responses; preserve explicit error documents. @@ -5765,19 +5937,19 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi const lyricInterval = urlObj.searchParams.get('interval') || '' const chunks: any[] = [] - let received = 0 + let received = preparedDownload?.total || 0 const total = parseInt(proxyRes.headers['content-length'] as string || '0', 10) let lastSpeedAt = Date.now() let lastSpeedBytes = 0 let currentSpeed = 0 - if (taskId) { + if (taskId && !preparedDownload) { fileCache.cacheProgress.set(taskId, { progress: 0, status: 'downloading', total, received: 0, speed: 0, updatedAt: Date.now() }) } proxyRes.on('data', (c: any) => { - chunks.push(c) - if (taskId) { + if (!preparedDownload) chunks.push(c) + if (taskId && !preparedDownload) { received += c.length const now = Date.now() if (now - lastSpeedAt >= 1000) { @@ -5801,13 +5973,14 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi let tempPath = '' let tagger: any = null try { - const buffer = Buffer.concat(chunks) - if (buffer.length < 100) throw new Error('File too small, possibly invalid'); + const buffer = preparedDownload ? null : Buffer.concat(chunks) + if (buffer && buffer.length < 100) throw new Error('File too small, possibly invalid'); // Use filename extension for temp file so MusicTagger can identify container format const ext = path.extname(filename) || '.mp3' tempPath = path.join(os.tmpdir(), `lx_tag_${Date.now()}_${crypto.randomBytes(8).toString('hex')}${ext}`) - fs.writeFileSync(tempPath, new Uint8Array(buffer)) + if (preparedDownload) fs.copyFileSync(preparedDownload.filePath, tempPath) + else fs.writeFileSync(tempPath, new Uint8Array(buffer!)) tagger = new MusicTagger() tagger.loadPath(tempPath) @@ -5840,41 +6013,59 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi } } // [新增] 嵌入歌词 USLT 标签:SDK 返回 { promise, cancel },必须 await .promise - if (embedLyric && lyricSource && lyricSongmid && musicSdk[lyricSource]?.getLyric) { + let expectedLyrics = '' + if (embedLyric) { try { - const lyricReqObj = musicSdk[lyricSource].getLyric({ + expectedLyrics = await resolveDownloadLyrics({ + source: lyricSource, songmid: lyricSongmid, name: songName, singer: artist, hash: lyricHash, interval: lyricInterval, - }) - const lyricResult = await lyricReqObj.promise - const lyricText = lyricResult?.lyric || lyricResult?.lrc || '' - if (lyricText) tagger.lyrics = lyricText - } catch (e) { /* 歌词获取失败不影响下载 */ } + copyrightId: urlObj.searchParams.get('copyrightId') || '', + albumId: urlObj.searchParams.get('albumId') || '', + lrcUrl: urlObj.searchParams.get('lrcUrl') || '', + mrcUrl: urlObj.searchParams.get('mrcUrl') || '', + trcUrl: urlObj.searchParams.get('trcUrl') || '', + }, proxyUser || '_open', lookupLocalLyrics, musicSdk) + tagger.lyrics = expectedLyrics + } catch (e: any) { + console.warn('[下载代理] 歌词嵌入失败:', lyricSource, lyricSongmid, e.message || e) + } } tagger.save() console.log('[下载代理] 音频元数据标签保存成功:', songName) tagger.dispose() tagger = null + if (expectedLyrics) { + const verification = new MusicTagger() + try { + verification.loadPath(tempPath) + if (verification.lyrics !== expectedLyrics) throw new Error('歌词标签写入后校验失败') + } finally { verification.dispose() } + } + const tagged = fs.readFileSync(tempPath) headers['Content-Length'] = tagged.length.toString() - if (!res.headersSent) { + if (!res.destroyed && !res.headersSent) { res.writeHead(200, headers) res.end(tagged) } finishProgress() } catch (e: any) { - if (!res.headersSent) { + console.warn('[下载代理] 标签处理失败,返回原始音频:', e.message || e) + if (!res.destroyed && !res.headersSent) { res.writeHead(200, headers) - res.end(Buffer.concat(chunks)) + if (preparedDownload) res.end(fs.readFileSync(preparedDownload.filePath)) + else res.end(Buffer.concat(chunks)) } finishProgress() } finally { if (tagger) tagger.dispose() if (tempPath) fs.unlink(tempPath, () => { }) + preparedDownload?.cleanup() } }) return @@ -5884,7 +6075,22 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi res.writeHead(proxyRes.statusCode || 200, headers) proxyRes.pipe(res) } - }) + } + + if (preparedDownload) { + const stream: any = fs.createReadStream(preparedDownload.filePath) + stream.statusCode = 200 + stream.headers = { 'content-type': 'application/octet-stream', 'content-length': String(preparedDownload.total) } + stream.once('error', () => preparedDownload?.cleanup()) + res.once('close', () => { + stream.destroy() + if (stream.closed) preparedDownload?.cleanup() + else stream.once('close', () => preparedDownload?.cleanup()) + }) + handleResponse(stream) + return + } + proxyReq = lib.request(targetUrl, options, handleResponse) proxyReq.on('error', (err: any) => { console.error(`[${proxyLabel}] 请求异常:`, err) @@ -5902,6 +6108,11 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi proxyReq.end() } catch (err: any) { + preparedDownload?.cleanup() + if (taskId) { + fileCache.cacheProgress.set(taskId, { progress: 0, status: 'error', speed: 0, errorMsg: `下载失败:${err.code || err.message}`, updatedAt: Date.now() }) + setTimeout(() => fileCache.cacheProgress.delete(taskId), 30000) + } if (err.code === 'ERR_PRIVATE_UPSTREAM') { console.warn(`[${proxyLabel}] 已拒绝受限地址: ${err.hostname} -> ${err.addresses.join(', ')}`) } else console.error(`[${proxyLabel}] 代理请求捕获异常:`, err) @@ -6412,7 +6623,10 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi if (!musicSdk[source] || !musicSdk[source].tipSearch) { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('[]'); return } - const tips = await musicSdk[source].tipSearch.search(name) + // SDK modules keep a requestObj; isolate it so one user's typing does + // not cancel another user's in-flight suggestions for the same source. + const tipApi = { ...musicSdk[source].tipSearch, requestObj: null } + const tips = await tipApi.search(name) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(tips || [])) } catch (err: any) { @@ -6596,7 +6810,14 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi } } else { // isSourceSupported = false: 无任何自定义源支持此平台,立即通知前端 - void pushProgress({ name: '系统', status: 'fail', message: `未找到支持 ${source} 平台的自定义源,请在设置中添加或启用相关源` }) + const hasAvailableSource = getLoadedApis().some(api => + Object.keys(api.sources || {}).some(platform => isSourceSupported(platform, verifiedUsername))) + const err: any = new Error(hasAvailableSource + ? `未找到支持 ${source} 平台的自定义源,请在设置中添加或启用相关源` + : '暂无可用音源,请在设置中添加或启用自定义源') + err.errorCode = hasAvailableSource ? 'PLATFORM_SOURCE_UNAVAILABLE' : 'NO_AVAILABLE_SOURCE' + // 配置问题由请求响应统一提示,不再额外推送一条 SSE 错误。 + throw err } // 自定义源失败则直接报错(内置 SDK 无独立解析能力,回退无意义) @@ -6678,7 +6899,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi console.error('[歌曲播放] 音频地址解析失败:', err.message) // [Fix] Return 500 but with specific error JSON to let frontend show detailed toast res.writeHead(500, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ error: err.message, code: 500, attempts: err.attempts })) + res.end(JSON.stringify({ error: err.message, code: 500, errorCode: err.errorCode, attempts: err.attempts })) } }) return @@ -6708,6 +6929,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi } const result = await resolveServerSong(songInfo, quality, verifiedUsername, false) + if (result.quality !== quality) throw new Error('音源返回的音质与请求音质不一致') const bytes = await getAudioRemoteSize(result.url) if (!bytes) throw new Error('无法读取真实文件大小') @@ -6743,7 +6965,8 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi if (!musicSdk[source] || !musicSdk[source].getLyric) { throw new Error(`Source ${source} not supported`) } - const result = await musicSdk[source].getLyric(songInfo) + const request = musicSdk[source].getLyric(songInfo) + const result = await (request.promise || request) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(result)) } catch (err: any) { @@ -6990,6 +7213,68 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi // GET /api/music/dislike 返回已解析的规则集(歌曲 / 歌手 / 专辑) // POST /api/music/dislike/add body: { type, name?, singer?, source?, albumId? } // POST /api/music/dislike/remove body: 同上 + if (pathname === '/api/data/rating-sync' && (req.method === 'GET' || req.method === 'POST')) { + res.setHeader('Content-Type','application/json'); res.setHeader('Cache-Control','no-store') + if (req.headers['x-frontend-auth'] !== global.lx.config['admin.password']) {res.writeHead(401);res.end(JSON.stringify({error:'Unauthorized'}));return} + try { + const user = new URL(req.url || '/', 'http://localhost').searchParams.get('user') || '' + if (user !== '_open' && !global.lx.config.users.some(u=>u.name===user)) throw Error('用户不存在') + const result = req.method === 'POST' ? await retryRatingTasks(user) : {pending:readRatingTasks(user),version:ratingVersion(user)} + res.end(JSON.stringify(result)) + } catch(e:any){res.writeHead(400);res.end(JSON.stringify({error:e.message}))} + return + } + + if (pathname === '/api/music/rating-preferences' && (req.method === 'GET' || req.method === 'POST')) { + const user = verifyUserAuth(req) + res.setHeader('Content-Type', 'application/json'); res.setHeader('Cache-Control', 'no-store') + if (!user) { res.writeHead(401); res.end(JSON.stringify({ error: '请先登录本地账号' })); return } + try { + const preference = req.method === 'GET' ? readRatingPreferences(user) : saveRatingPreferences(user, JSON.parse(await readBody(req)).dislikeAutoRating) + res.end(JSON.stringify(preference)) + } catch (e: any) { res.writeHead(400); res.end(JSON.stringify({ error: e.message })) } + return + } + + if (pathname === '/api/music/ratings' && (req.method === 'GET' || req.method === 'POST')) { + const user = verifyUserAuth(req) + if (!user) { res.writeHead(401); res.end(JSON.stringify({ error: '请先登录本地账号' })); return } + try { + res.setHeader('Content-Type', 'application/json') + res.setHeader('Cache-Control', 'no-store') + if (req.method === 'GET') { + const meta = readRatings(user) + for (const id of Object.keys(meta.ratings)) { if (id.startsWith('local_') && await resolveStoredRatingId(user, id) !== id) { delete meta.ratings[id]; if (meta.ratingOrigins) delete meta.ratingOrigins[id] } } + const songs = listRatingItems(user).filter(item => Object.hasOwn(meta.ratings, item.id)) + res.end(JSON.stringify({ version: ratingVersion(user), pending: readRatingTasks(user), ratings: meta.ratings, origins: meta.ratingOrigins || {}, songs })); return + } + const body = JSON.parse(await readBody(req)) + if (body.operation === 'version') { res.end(JSON.stringify({ version: ratingVersion(user) })); return } + if (body.operation === 'retryFailed') { res.end(JSON.stringify(await retryRatingTasks(user))); return } + if (body.operation === 'exportBackup') { res.end(JSON.stringify(exportRatingBackup(user))); return } + if (body.operation === 'resolve') { + if (!Array.isArray(body.songs) || body.songs.length > 100) throw new Error('Too many songs') + const meta = readRatings(user) + const entries = await Promise.all(body.songs.map(async (song: any) => { + try { const identity = await ratingIdentityDetails(user, song, body.includeDetails === true); return { ...identity, rating: meta.ratings[identity.id] || 0 } } + catch (e: any) { return { id: null, rating: 0, error: e.message } } + })) + res.end(JSON.stringify({ entries, version: ratingVersion(user) })); return + } + const rating = body.rating + if (!Number.isInteger(rating) || rating < 0 || rating > 5) throw new Error('评分必须为0至5的整数') + const requestedId = await resolveRatingId(user, body.song) + const saved = await writeRating(user, requestedId, rating, 'web', body.song) + const id = saved.id + let linkWarning + try { await require('./integrations/subsonic').subsonicHandler.applyRatingLink(user, id, rating, body.song) } + catch { linkWarning = '评分已保存,但不喜欢联动失败,可重试或手动补齐' } + invalidateSubsonicPlaylistCache() + res.end(JSON.stringify({ id, rating, warning: linkWarning || saved.snapshotWarning })) + } catch (err: any) { res.writeHead(400); res.end(JSON.stringify({ error: err.message })) } + return + } + if (pathname === '/api/music/dislike' && req.method === 'GET') { const verified = verifyUserAuth(req) if (!verified) { @@ -7021,8 +7306,15 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi } try { const isAdd = pathname === '/api/music/dislike/add' - const payload = JSON.parse(body || '{}') + let payload = JSON.parse(body || '{}') const type = String(payload.type || 'song') + if (type === 'song' && (payload.isLocal || payload._localFilename)) { + const effectiveId = await resolveRatingId(verified, payload) + const local = require('./music/subsonicLocal') + const tracks = await local.getLocalTracks(verified, false, payload._localLocation) + const track = tracks.find((item: any) => local.localRatingId(item) === effectiveId) + payload = { ...payload, id: effectiveId, source: effectiveId.startsWith('local_') ? 'local' : effectiveId.split('_')[0], name: track?.title || payload.name, singer: track?.artist || payload.singer } + } const name = String(payload.name || '') const singer = String(payload.singer || '') const source = String(payload.source || '') @@ -7043,7 +7335,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi return } const singers = splitSingers(singer) - if (isAdd) await dm.dislikeDataManage.addDislikeAlbums(singers.map(s => ({ albumName, singer: s }))) + if (isAdd) await dm.dislikeDataManage.addDislikeAlbums(singers.map(s => ({ albumName, singer: s, source, provenance: { channel: 'web', at: Date.now() } }))) else for (const s of singers) { removeKeys.add(encodeAlbumRule(normalizeText(albumName), s)) removeKeys.add(normalizeText(encodeAlbumRule(albumName, s))) @@ -7054,7 +7346,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi res.end(JSON.stringify({ success: false, message: 'Missing singer' })) return } - if (isAdd) await dm.dislikeDataManage.addDislikeInfo([{ name: '', singer, dislikeRule: '' }]) + if (isAdd) await dm.dislikeDataManage.addDislikeInfo([{ name: '', singer, source, dislikeRule: '', provenance: { channel: 'web', at: Date.now() } }]) else removeKeys.add(`@${normalizeText(singer)}`) } else { if (!name) { @@ -7065,6 +7357,8 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi if (isAdd) { const { type, ...songData } = payload songData.dislikeRule = '' + songData.provenance = { channel: 'web', at: Date.now() } + songData.reasons = [songData.provenance] await dm.dislikeDataManage.addDislikeInfo([songData]) } else { if (singer) { @@ -7076,29 +7370,25 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi } } - const dislikeRatingThreshold = global.lx.config['subsonic.dislikeRating'] ?? 1 - if (global.lx.config['subsonic.linkDislikeToRating'] && dislikeRatingThreshold > 0 && id && source) { - const subId = id.startsWith(`${source}_`) ? id : `${source}_${id}` + let ratingWarning: string | undefined + let ratingResult: string | undefined + const dislikeAutoRating = readRatingPreferences(verified).dislikeAutoRating + if (type === 'song' && dislikeAutoRating && id && source) { try { + const subId = await resolveRatingId(verified, payload) + const hadRating = readRatings(verified).ratings[subId] > 0 const { syncDislikeToRating } = require('./integrations/subsonic') - await syncDislikeToRating(verified, subId, isAdd ? 1 : 0) + await syncDislikeToRating(verified, subId, isAdd ? 1 : 0, payload) + ratingResult = !isAdd ? 'unchanged' : hadRating ? 'existing' : 'supplemented' } catch (e) { console.error('[黑名单 API] 评分回写失败:', e) + ratingWarning = '不喜欢操作已保存,但补分或评分快照未完成,可重试或在后台补分' + ratingResult = 'failed' } } if (!isAdd && removeKeys.size > 0) { - const rulesString = dm.dislikeDataManage.getDislikeRulesString() - const lines = rulesString.split('\n').filter((l: string) => l.trim()) - const remain = lines.filter((l: string) => { - const trimmed = l.trim().toLowerCase() - if (removeKeys.has(trimmed)) return false - // Also check without spaces or with alias - return true - }) - if (remain.length !== lines.length) { - await dm.dislikeDataManage.overwirteDislikeInfo(remain.join('\n')) - } + for (const rule of removeKeys) await dm.dislikeDataManage.removeDislikeInfo(rule, source || undefined) } await dm.createSnapshot() @@ -7108,7 +7398,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi const data: any = serializeDislikeRules(finalRulesString, verified) data.dislikeList = dm.dislikeDataManage.dislikeRules.dislikeList || [] res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }) - res.end(JSON.stringify({ success: true, data, options: dislikeMatchOptions() })) + res.end(JSON.stringify({ success: true, data, options: dislikeMatchOptions(), warning: ratingWarning, ratingResult })) } catch (err: any) { console.error('[黑名单 API] 处理异常:', err?.message) res.writeHead(500, { 'Content-Type': 'application/json' }) @@ -7244,6 +7534,8 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi 'webdav.password': global.lx.config['webdav.password'] || '', 'webdav.syncPath': global.lx.config['webdav.syncPath'] || '/lx-sync', 'webdav.backupPath': global.lx.config['webdav.backupPath'] || '/lx-sync-backups', + 'webdav.retentionCount': global.lx.config['webdav.retentionCount'] ?? 5, + 'webdav.retentionDays': global.lx.config['webdav.retentionDays'] ?? 0, 'sync.interval': global.lx.config['sync.interval'] || 60, 'sync.backupInterval': global.lx.config['sync.backupInterval'] || 24, 'webdav.excludeCache': global.lx.config['webdav.excludeCache'] ?? false, @@ -7274,13 +7566,14 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi 'subsonic.onlineSearchSources': global.lx.config['subsonic.onlineSearchSources'] ?? 'wy,tx,kw,kg,mg', 'subsonic.localPlaylist': global.lx.config['subsonic.localPlaylist'] ?? true, 'subsonic.customPlaylist': global.lx.config['subsonic.customPlaylist'] ?? true, + 'subsonic.cachePlaylist': global.lx.config['subsonic.cachePlaylist'] ?? false, 'subsonic.publicLeaderboards': global.lx.config['subsonic.publicLeaderboards'] ?? false, 'subsonic.leaderboardSource': global.lx.config['subsonic.leaderboardSource'] ?? 'tx', 'subsonic.sharedListMode': global.lx.config['subsonic.sharedListMode'] ?? 'leaderboard', 'subsonic.sharedListSort': global.lx.config['subsonic.sharedListSort'] ?? 'hot', 'subsonic.dislikeRating': global.lx.config['subsonic.dislikeRating'] ?? 1, + 'subsonic.ratingPlaylists': global.lx.config['subsonic.ratingPlaylists'] ?? true, 'subsonic.linkRatingToDislike': global.lx.config['subsonic.linkRatingToDislike'] ?? false, - 'subsonic.linkDislikeToRating': global.lx.config['subsonic.linkDislikeToRating'] ?? false, 'subsonic.hideDisliked': global.lx.config['subsonic.hideDisliked'] ?? true, 'subsonic.dislikeCrossSource': global.lx.config['subsonic.dislikeCrossSource'] ?? false, 'subsonic.dislikeNoRecommend': global.lx.config['subsonic.dislikeNoRecommend'] ?? true, @@ -7302,22 +7595,27 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi 'system.allowUnsafeVM': global.lx.config['system.allowUnsafeVM'] || false, 'configBackup.enable': global.lx.config['configBackup.enable'] ?? true, 'configBackup.retentionDays': global.lx.config['configBackup.retentionDays'] ?? 7, + 'configBackup.intervalHours': global.lx.config['configBackup.intervalHours'] ?? 24, + 'configBackup.scheduleTime': global.lx.config['configBackup.scheduleTime'] ?? '00:00', 'configBackup.dir': global.lx.config['configBackup.dir'] ?? '', 'snapshot.backupPath': global.lx.config['snapshot.backupPath'] ?? '', + openlistRestoreVersion: 2, + 'openlist.playback.enable': global.lx.config['openlist.playback.enable'] === true, 'openlist.enable': global.lx.config['openlist.enable'] ?? false, 'openlist.endpoint': global.lx.config['openlist.endpoint'] || '', 'openlist.username': global.lx.config['openlist.username'] || '', 'openlist.password': global.lx.config['openlist.password'] || '', 'openlist.token': global.lx.config['openlist.token'] || '', 'openlist.root': global.lx.config['openlist.root'] || '/lx_music', - 'openlist.keepLocalAfterUpload': global.lx.config['openlist.keepLocalAfterUpload'] !== false, 'openlist.verifyInterval': global.lx.config['openlist.verifyInterval'] || 5, 'openlist.syncInterval': global.lx.config['openlist.syncInterval'] || 60, + 'openlist.historyRetentionDays': global.lx.config['openlist.historyRetentionDays'] || 7, 'openlist.userBackups': global.lx.config['openlist.userBackups'] || '[]', 'openlist.includeNonAudio': global.lx.config['openlist.includeNonAudio'] === true, 'openlist.externalUrl': global.lx.config['openlist.externalUrl'] || global.lx.config['openlist.endpoint'] || '', 'openlist.backupSystemConfig': global.lx.config['openlist.backupSystemConfig'] === true, subsonicPortConflict: global.lx.subsonicPortConflict || null, + restoreMaintenance: global.lx.restoreMaintenance === true, configFilePath: global.lx.configPath || process.env.CONFIG_PATH || path.join(global.lx.dataPath, 'config.js'), } res.writeHead(200, { @@ -7396,6 +7694,8 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi if (newConfig['webdav.password'] !== undefined) global.lx.config['webdav.password'] = newConfig['webdav.password'] if (newConfig['webdav.syncPath'] !== undefined) global.lx.config['webdav.syncPath'] = newConfig['webdav.syncPath'] if (newConfig['webdav.backupPath'] !== undefined) global.lx.config['webdav.backupPath'] = newConfig['webdav.backupPath'] + if (newConfig['webdav.retentionCount'] !== undefined) global.lx.config['webdav.retentionCount'] = Math.max(1, Math.min(1000, Math.floor(Number(newConfig['webdav.retentionCount']) || 5))) + if (newConfig['webdav.retentionDays'] !== undefined) global.lx.config['webdav.retentionDays'] = Math.max(0, Math.min(3650, Math.floor(Number(newConfig['webdav.retentionDays']) || 0))) if (newConfig['sync.interval'] !== undefined) global.lx.config['sync.interval'] = parseInt(newConfig['sync.interval']) if (newConfig['sync.backupInterval'] !== undefined) global.lx.config['sync.backupInterval'] = parseInt(newConfig['sync.backupInterval']) || 24 if (newConfig['webdav.excludeCache'] !== undefined) global.lx.config['webdav.excludeCache'] = !!newConfig['webdav.excludeCache'] @@ -7497,15 +7797,16 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi warning = (warning ? warning + ';' : '') + 'OpenList 配置校验未通过(已保存,但同步可能因不可达而失败):' + (ve?.message || ve) } } + if (newConfig['openlist.playback.enable'] !== undefined) global.lx.config['openlist.playback.enable'] = newConfig['openlist.playback.enable'] === true if (newConfig['openlist.enable'] !== undefined) global.lx.config['openlist.enable'] = newConfig['openlist.enable'] if (newConfig['openlist.endpoint'] !== undefined) global.lx.config['openlist.endpoint'] = newConfig['openlist.endpoint'] if (newConfig['openlist.username'] !== undefined) global.lx.config['openlist.username'] = newConfig['openlist.username'] if (newConfig['openlist.password'] !== undefined) global.lx.config['openlist.password'] = newConfig['openlist.password'] if (newConfig['openlist.token'] !== undefined) global.lx.config['openlist.token'] = newConfig['openlist.token'] if (newConfig['openlist.root'] !== undefined) global.lx.config['openlist.root'] = newConfig['openlist.root'] - if (newConfig['openlist.keepLocalAfterUpload'] !== undefined) global.lx.config['openlist.keepLocalAfterUpload'] = newConfig['openlist.keepLocalAfterUpload'] if (newConfig['openlist.verifyInterval'] !== undefined) global.lx.config['openlist.verifyInterval'] = parseInt(newConfig['openlist.verifyInterval']) || 5 if (newConfig['openlist.syncInterval'] !== undefined) global.lx.config['openlist.syncInterval'] = parseInt(newConfig['openlist.syncInterval']) || 60 + if (newConfig['openlist.historyRetentionDays'] !== undefined) global.lx.config['openlist.historyRetentionDays'] = Math.min(3650, Math.max(1, Math.floor(Number(newConfig['openlist.historyRetentionDays']) || 7))) if (newConfig['openlist.userBackups'] !== undefined) global.lx.config['openlist.userBackups'] = newConfig['openlist.userBackups'] if (newConfig['openlist.includeNonAudio'] !== undefined) global.lx.config['openlist.includeNonAudio'] = newConfig['openlist.includeNonAudio'] if (newConfig['openlist.externalUrl'] !== undefined) global.lx.config['openlist.externalUrl'] = String(newConfig['openlist.externalUrl']).replace(/[\\/]+$/, '') @@ -7533,16 +7834,30 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi } if (newConfig['subsonic.enableDebug'] !== undefined) global.lx.config['subsonic.enableDebug'] = newConfig['subsonic.enableDebug'] // [本地配置备份] configBackup 配置 - if (newConfig['configBackup.enable'] !== undefined) global.lx.config['configBackup.enable'] = !!newConfig['configBackup.enable'] + let configBackupChanged = false + if (newConfig['configBackup.enable'] !== undefined) { global.lx.config['configBackup.enable'] = !!newConfig['configBackup.enable']; configBackupChanged = true } if (newConfig['configBackup.retentionDays'] !== undefined) { const rd = Number(newConfig['configBackup.retentionDays']) global.lx.config['configBackup.retentionDays'] = Number.isFinite(rd) && rd > 0 ? Math.floor(rd) : 7 + configBackupChanged = true + } + if (newConfig['configBackup.intervalHours'] !== undefined) { + const ih = parseInt(newConfig['configBackup.intervalHours']) + global.lx.config['configBackup.intervalHours'] = Number.isFinite(ih) && ih >= 1 && ih <= 8760 ? Math.floor(ih) : 24 + configBackupChanged = true } + if (newConfig['configBackup.scheduleTime'] !== undefined) { + const st = String(newConfig['configBackup.scheduleTime']).trim() + global.lx.config['configBackup.scheduleTime'] = /^([01]?\d|2[0-3]):[0-5]\d$/.test(st) ? st : '' + configBackupChanged = true + } + if (configBackupChanged) global.lx.rescheduleConfigBackup?.() if (newConfig['subsonic.onlineSearch'] !== undefined) global.lx.config['subsonic.onlineSearch'] = newConfig['subsonic.onlineSearch'] if (newConfig['subsonic.onlineSearchMode'] !== undefined) global.lx.config['subsonic.onlineSearchMode'] = newConfig['subsonic.onlineSearchMode'] if (newConfig['subsonic.onlineSearchSources'] !== undefined) global.lx.config['subsonic.onlineSearchSources'] = newConfig['subsonic.onlineSearchSources'] if (typeof newConfig['subsonic.localPlaylist'] === 'boolean') global.lx.config['subsonic.localPlaylist'] = newConfig['subsonic.localPlaylist'] if (typeof newConfig['subsonic.customPlaylist'] === 'boolean') global.lx.config['subsonic.customPlaylist'] = newConfig['subsonic.customPlaylist'] + if (typeof newConfig['subsonic.cachePlaylist'] === 'boolean') global.lx.config['subsonic.cachePlaylist'] = newConfig['subsonic.cachePlaylist'] if (newConfig['subsonic.publicLeaderboards'] !== undefined) global.lx.config['subsonic.publicLeaderboards'] = newConfig['subsonic.publicLeaderboards'] if (newConfig['subsonic.leaderboardSource'] !== undefined) { const s = String(newConfig['subsonic.leaderboardSource']).trim().toLowerCase() @@ -7557,6 +7872,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi if (['hot', 'new'].includes(st)) global.lx.config['subsonic.sharedListSort'] = st as any } if (newConfig['subsonic.dislikeRating'] !== undefined) global.lx.config['subsonic.dislikeRating'] = Number(newConfig['subsonic.dislikeRating']) + if (typeof newConfig['subsonic.ratingPlaylists'] === 'boolean') global.lx.config['subsonic.ratingPlaylists'] = newConfig['subsonic.ratingPlaylists'] if (newConfig['subsonic.hideDisliked'] !== undefined) global.lx.config['subsonic.hideDisliked'] = !!newConfig['subsonic.hideDisliked'] if (newConfig['subsonic.dislikeCrossSource'] !== undefined) global.lx.config['subsonic.dislikeCrossSource'] = !!newConfig['subsonic.dislikeCrossSource'] if (newConfig['subsonic.dislikeNoRecommend'] !== undefined) global.lx.config['subsonic.dislikeNoRecommend'] = !!newConfig['subsonic.dislikeNoRecommend'] @@ -7567,7 +7883,6 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi if (newConfig['subsonic.dislikeNormalizeName'] !== undefined) global.lx.config['subsonic.dislikeNormalizeName'] = !!newConfig['subsonic.dislikeNormalizeName'] if (newConfig['subsonic.dislikeRequireSinger'] !== undefined) global.lx.config['subsonic.dislikeRequireSinger'] = !!newConfig['subsonic.dislikeRequireSinger'] if (newConfig['subsonic.linkRatingToDislike'] !== undefined) global.lx.config['subsonic.linkRatingToDislike'] = !!newConfig['subsonic.linkRatingToDislike'] - if (newConfig['subsonic.linkDislikeToRating'] !== undefined) global.lx.config['subsonic.linkDislikeToRating'] = !!newConfig['subsonic.linkDislikeToRating'] if (newConfig['subsonic.recommendPoolSize'] !== undefined) global.lx.config['subsonic.recommendPoolSize'] = Number(newConfig['subsonic.recommendPoolSize']) if (newConfig['subsonic.lyricTranslation'] !== undefined) global.lx.config['subsonic.lyricTranslation'] = newConfig['subsonic.lyricTranslation'] if (newConfig['subsonic.cacheOnPlay'] !== undefined) global.lx.config['subsonic.cacheOnPlay'] = newConfig['subsonic.cacheOnPlay'] @@ -7578,9 +7893,10 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi const raw = global.lx.config['openlist.userBackups'] let rules: any[] = [] try { rules = typeof raw === 'string' ? JSON.parse(raw) : raw || [] } catch { /* 校验警告已返回 */ } - if (Array.isArray(rules) && rules.some(r => r?.enabled && (global.lx.config['backup.openlist.types'] === undefined || r.backupCache === true) && (r.mode === 'upload-delete' || (!r.mode && global.lx.config['openlist.keepLocalAfterUpload'] === false)))) { - global.lx.config['subsonic.playCacheFirst'] = true - global.lx.config['subsonic.playCacheFirstUseOpenlist'] = true + const udRules = Array.isArray(rules) ? rules.filter(r => r?.enabled && (global.lx.config['backup.openlist.types'] === undefined || r.backupCache === true) && r.mode === 'upload-delete') : [] + if (udRules.length) { + // 保存为 upload-delete 时,对存量本地文件触发一次性迁移(异步,不阻塞保存响应) + for (const r of udRules) void triggerBackfillUploadDelete(r.user).catch(err => console.warn('[OpenList] 存量缓存迁移失败:', err?.message || err)) } } if (newConfig['subsonic.quality.enabled'] !== undefined) global.lx.config['subsonic.quality.enabled'] = !!newConfig['subsonic.quality.enabled'] @@ -7608,6 +7924,8 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi password: global.lx.config['webdav.password'], syncPath: global.lx.config['webdav.syncPath'], backupPath: global.lx.config['webdav.backupPath'], + retentionCount: global.lx.config['webdav.retentionCount'], + retentionDays: global.lx.config['webdav.retentionDays'], interval: global.lx.config['sync.interval'], backupInterval: global.lx.config['sync.backupInterval'], excludeCache: global.lx.config['webdav.excludeCache'], @@ -7621,6 +7939,9 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi if (typeof global.lx?.saveConfig === 'function') { global.lx.saveConfig() } + if (['user.enableCacheSizeLimit', 'user.cacheSizeLimit', 'cache.maxAgeDays'].some(key => newConfig[key] !== undefined)) { + setImmediate(() => void fileCache.cleanupAllCaches()) + } // 动态热迁移所有活跃用户空间的快照目录 if (newConfig['snapshot.backupPath'] !== undefined) { @@ -7673,15 +7994,20 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi // 优先从文件名解析精确时间戳 let timestamp = 0 - const manualMatch = file.match(/^config-manual-(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})(\d{2})\.js$/) - const autoMatch = file.match(/^config-(\d{4})-(\d{2})-(\d{2})\.js$/) + const manualMatch = file.match(/^config-manual-(\d{4})-(\d{2})-(\d{2})_(\d{2})(\d{2})(\d{2})(?:-\d+)?\.js$/) + const autoMatch = file.match(/^config-(\d{4})-(\d{2})-(\d{2})(?:-(\d{2})(\d{2}))?\.js$/) if (manualMatch) { const [, y, m, d, h, min, s] = manualMatch timestamp = new Date(Number(y), Number(m) - 1, Number(d), Number(h), Number(min), Number(s)).getTime() } else if (autoMatch) { - // 自动备份若与 mtime/birthtime 在同一天,优先使用文件修改时间以展示具体时刻;否则取日期当天 00:00 - timestamp = stat.mtimeMs || stat.birthtimeMs || 0 + // 带时分后缀的文件解析为精确时刻;否则使用文件修改时间以展示具体时刻 + if (autoMatch[4] !== undefined) { + const [, y, m, d, h, min] = autoMatch + timestamp = new Date(Number(y), Number(m) - 1, Number(d), Number(h), Number(min), 0).getTime() + } else { + timestamp = stat.mtimeMs || stat.birthtimeMs || 0 + } } if (!timestamp) { @@ -7710,6 +8036,11 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi backupDir, autoBackupEnabled: global.lx.config['configBackup.enable'] !== false, retentionDays: global.lx.config['configBackup.retentionDays'] || 7, + intervalHours: global.lx.config['configBackup.intervalHours'] || 24, + scheduleTime: global.lx.config['configBackup.scheduleTime'] ?? '00:00', + nextBackupAt: global.lx.nextConfigBackupAt || 0, + lastBackupAt: global.lx.lastConfigBackupAt || 0, + lastError: global.lx.lastConfigBackupError || null, list, })) } catch (err: any) { @@ -7783,14 +8114,43 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi return } + if (pathname === '/api/openlist/restore/history' || pathname === '/api/openlist/restore/jobs' || pathname.startsWith('/api/openlist/restore/jobs/')) { + if (req.headers['x-frontend-auth'] !== global.lx.config['admin.password']) { + res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return + } + void (async () => { + try { + let result: any + if (pathname.endsWith('/history') && req.method === 'GET') result = await getOpenlistRecoveryHistory() + else if (pathname.endsWith('/jobs') && req.method === 'GET') result = { tasks: listRestoreTasks(), maintenance: global.lx.restoreMaintenance === true } + else if (pathname.endsWith('/jobs/start') && req.method === 'POST') { + const options = JSON.parse(await readBody(req) || '{}') + if (!options.planToken) throw new Error('请先扫描预览') + result = { taskId: startOpenlistRestore(options) } + } else { + const match = /^\/api\/openlist\/restore\/jobs\/([0-9a-f-]{36})(?:\/(cancel|resume|rollback))?$/.exec(pathname) + if (!match) throw new Error('恢复任务路径无效') + const [, id, action] = match + if (req.method === 'DELETE' && !action) { deleteRestoreTask(id); result = {} } + else if (req.method === 'POST' && action === 'cancel') { cancelRestoreTask(id); result = {} } + else if (req.method === 'POST' && action === 'resume') result = { taskId: startOpenlistRestore({}, id) } + else if (req.method === 'POST' && action === 'rollback') result = rollbackRestoreTask(id) + else throw new Error('不支持的恢复任务操作') + } + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, ...result })) + } catch (e: any) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: e.message })) } + })() + return + } + // [OpenList 同步 API] 手动触发同步(upload/download/sync 全量) if (pathname === '/api/openlist/restore' && req.method === 'POST') { if (req.headers['x-frontend-auth'] !== global.lx.config['admin.password']) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: 'Unauthorized' })); return } - void restoreOpenlistData().then(result => { + void readBody(req).then(body => restoreOpenlistData(JSON.parse(body || '{}'))).then(result => { res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ success: result.errors.length === 0 && result.restored > 0, ...result, restartRequired: true })) + res.end(JSON.stringify({ success: result.errors.length === 0 && result.scanned > 0, ...result, restartRequired: !result.preview && (result.restored > 0 || global.lx.restoreMaintenance === true) })) }).catch((e: any) => { res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: false, error: e.message })) }) @@ -7819,6 +8179,54 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi } // [OpenList 同步 API] 备份/同步状态列表 + if (pathname === '/api/openlist/integrity' || pathname.startsWith('/api/openlist/integrity/')) { + if (req.headers['x-frontend-auth'] !== global.lx.config['admin.password']) { + res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return + } + void (async () => { + try { + let result: any + if (pathname === '/api/openlist/integrity' && req.method === 'GET') result = integrityStatus() + else if (pathname === '/api/openlist/integrity/preview' && req.method === 'POST') result = prepareOpenlistIntegrity(JSON.parse(await readBody(req) || '{}')) + else if (pathname === '/api/openlist/integrity/start' && req.method === 'POST') result = startOpenlistIntegrity(JSON.parse(await readBody(req) || '{}').token) + else if (pathname === '/api/openlist/integrity/cancel' && req.method === 'POST') { cancelIntegrity(); result = { stopped: true } } + else { res.writeHead(404); res.end(); return } + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, ...result })) + } catch (e: any) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })) } + })() + return + } + if (pathname === '/api/openlist/connection') { + if (req.headers['x-frontend-auth'] !== global.lx.config['admin.password']) { + res.writeHead(401); res.end(); return + } + if (!['GET', 'POST'].includes(req.method || '')) { res.writeHead(405); res.end(); return } + void (async () => { + try { + const connection = req.method === 'POST' ? await probeOpenlistConnection() : getOpenlistConnection() + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, connection })) + if (req.method === 'POST' && connection.state === 'online') void verifyPending() + } catch { res.writeHead(500); res.end(JSON.stringify({ error: '连接探测失败' })) } + })() + return + } + if (['/api/openlist/metadata', '/api/openlist/metadata/start', '/api/openlist/metadata/cancel'].includes(pathname)) { + if (req.headers['x-frontend-auth'] !== global.lx.config['admin.password']) { + res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return + } + void (async () => { + try { + let result: any + if (pathname === '/api/openlist/metadata' && req.method === 'GET') result = openlistMetadataStatus() + else if (pathname === '/api/openlist/metadata/start' && req.method === 'POST') result = await startOpenlistMetadata(JSON.parse(await readBody(req) || '{}')) + else if (pathname === '/api/openlist/metadata/cancel' && req.method === 'POST') { cancelMetadata(); result = { stopped: true } } + else { res.writeHead(405); res.end(); return } + res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, ...result })) + } catch (error: any) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: error.message })) } + })() + return + } if (pathname === '/api/openlist/backups' && req.method === 'GET') { const auth = req.headers['x-frontend-auth'] if (auth !== global.lx.config['admin.password']) { @@ -7829,7 +8237,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi try { const list = listBackups() res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ success: true, list })) + res.end(JSON.stringify({ success: true, list, connection: getOpenlistConnection() })) } catch (e: any) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ success: false, error: e?.message || String(e) })) @@ -7893,6 +8301,42 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi return } + // [OpenList 同步 API] 批量重试备份记录(对选中的失败/异常项逐一提交重试) + if (pathname === '/api/openlist/retry-batch' && req.method === 'POST') { + const auth = req.headers['x-frontend-auth'] + if (auth !== global.lx.config['admin.password']) { + res.writeHead(401, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: 'Unauthorized' })) + return + } + void readBody(req).then(async (body) => { + try { + const cfg = JSON.parse(body || '{}') + const ids = Array.isArray(cfg.ids) ? cfg.ids.map((x: any) => Number(x)).filter((x: number) => x > 0) : [] + if (!ids.length) { + res.writeHead(422, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: '缺少有效的 id 列表' })) + return + } + let ok = 0 + let fail = 0 + for (const id of ids) { + try { + const rb = await retryBackup(id) + if (rb && rb.ok) ok++ + else fail++ + } catch { fail++ } + } + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: true, retried: ok, failed: fail })) + } catch (e: any) { + res.writeHead(500, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, error: e?.message || String(e) })) + } + }) + return + } + if (pathname === '/api/config/backups/download' && req.method === 'GET') { const auth = req.headers['x-frontend-auth'] if (auth !== global.lx.config['admin.password']) { @@ -7997,7 +8441,8 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi // 先自动备份一份当前的 config.js 防止回滚失误 if (typeof global.lx.backupConfigNow === 'function') { - global.lx.backupConfigNow() + const beforeRestore = global.lx.backupConfigNow() + if (!beforeRestore.success) throw new Error(beforeRestore.error || '无法保存恢复前的配置,已取消恢复') } // 复制备份文件覆盖当前 config.js @@ -8310,6 +8755,21 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi return } + if (pathname.startsWith('/api/webdav/restore/tasks')) { + if (req.headers['x-frontend-auth'] !== global.lx.config['admin.password']) { res.writeHead(401); res.end('Unauthorized'); return } + try { + res.setHeader('Content-Type', 'application/json') + const match = pathname.match(/^\/api\/webdav\/restore\/tasks\/([0-9a-f-]{36})(\/rollback)?$/) + if (pathname === '/api/webdav/restore/tasks' && req.method === 'GET') res.end(JSON.stringify({ success: true, tasks: webdavRestoreTasks.listRestoreTasks() })) + else if (match && !global.lx.webdavSync?.isBusy && !isCloudBackupRunning()) { + if (match[2] && req.method === 'POST') res.end(JSON.stringify({ success: true, ...webdavRestoreTasks.rollbackRestoreTask(match[1]) })) + else if (!match[2] && req.method === 'DELETE') { webdavRestoreTasks.deleteRestoreTask(match[1]); res.end(JSON.stringify({ success: true })) } + else throw new Error('不支持的恢复操作') + } else throw new Error('请等待当前任务完成,或检查恢复记录地址') + } catch (error: any) { res.writeHead(400); res.end(JSON.stringify({ success: false, message: error.message })) } + return + } + // WebDAV Restore API if (pathname === '/api/webdav/restore' && req.method === 'POST') { const auth = req.headers['x-frontend-auth'] @@ -8326,22 +8786,23 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi return } - void readBody(req).then((body) => { - let payload: any = {} - try { - payload = JSON.parse(body || '{}') - } catch (e) { } - - const mode = payload.mode || 'auto' - const targetFilename = payload.targetFilename - - void webdavSync.restoreFromRemote({ mode, targetFilename }).then(async (success: boolean) => { - res.writeHead(200, { 'Content-Type': 'application/json' }) - res.end(JSON.stringify({ success, restartRequired: true, message: success ? '已恢复所选类型;请重启服务完整加载用户、歌单和音乐索引。' : '恢复失败或云端没有所选类型的文件' })) - }).catch((err: any) => { - res.writeHead(500) - res.end(JSON.stringify({ success: false, message: err.message })) - }) + void readBody(req).then(async (body) => { + const payload = JSON.parse(body || '{}') + res.setHeader('Content-Type', 'application/json') + if (payload.cancel === true && typeof payload.token === 'string') { + webdavSync.discardRestorePreview(payload.token) + res.end(JSON.stringify({ success: true })) + } else if (payload.preview === true) { + const preview = await webdavSync.previewRestore({ mode: payload.mode, targetFilename: payload.targetFilename, strategy: payload.strategy, keepCopies: payload.keepCopies }) + res.end(JSON.stringify({ success: true, ...preview })) + } else { + if (typeof payload.token !== 'string') throw new Error('请先预览恢复范围,再确认恢复') + const success = await webdavSync.restorePlanned(payload.token) + res.end(JSON.stringify({ success, ...webdavSync.lastRestoreResult, restartRequired: global.lx.restoreMaintenance === true, message: success ? '恢复完成,请重启服务加载数据' : webdavSync.lastRestoreResult?.errors?.join(';') || '恢复失败' })) + } + }).catch((error: any) => { + res.writeHead(400, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ success: false, message: error.message })) }) return } @@ -8780,6 +9241,10 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi noServer: true, perMessageDeflate: false, }) as unknown as LX.SocketServer + global.lx.pauseForRestore = () => { + scheduler.stopScheduler() + for (const client of wss?.clients || []) client.close(1013, '数据恢复维护中,请重启服务') + } // WebDAV Sync Progress & Log Broadcast if (global.lx.webdavSync) { @@ -8822,6 +9287,7 @@ const handleStartServer = async (port = 9527, ip = '0.0.0.0') => await new Promi } wss.on('connection', function (socket: any, request) { + if (global.lx.restoreMaintenance) { socket.close(1013, '恢复维护中,请重启服务'); return } socket.isReady = false socket.moduleReadys = { list: false, @@ -9000,6 +9466,7 @@ const startSubsonicStandaloneServer = () => { // 不绑定特定 IP:监听所有网卡,访问控制交由防火墙处理 const subBindIP = '0.0.0.0' const subServer = http.createServer(async (req, res) => { + if (global.lx.restoreMaintenance) { res.writeHead(503); res.end('恢复维护中,请重启服务'); return } // 与主端口一致的 CORS 头,兼容跨域 Subsonic 客户端 res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS') @@ -9113,35 +9580,12 @@ export const startServer = async (port: number, ip: string) => { // [新增] 注入歌词获取钩子:用于服务器缓存时自动嵌入 USLT 标签 // SDK 的 getLyric() 返回 { promise, cancel },必须 await .promise - fileCache.setLyricFetcher(async (songInfo: any) => { + fileCache.setLyricFetcher(async (songInfo: any, owner = '_open') => { try { - const source = songInfo.source - if (!source || !musicSdk[source] || !musicSdk[source].getLyric) { - console.log(`[歌词抓取] 跳过: 不支持的音源 "${source}"`) - return null - } - // [Fix] Strip source prefix from songmid (e.g. "tx_004bd0..." -> "004bd0...") - let songmid: string = songInfo.songmid || songInfo.id || '' - const sourcePrefix = `${source}_` - if (songmid.startsWith(sourcePrefix)) songmid = songmid.slice(sourcePrefix.length) - if (!songmid) { - console.log(`[歌词抓取] 跳过: 歌曲 songmid 为空`) - return null - } - console.log(`[歌词抓取] 正在获取歌词: ${source}_${songmid} (${songInfo.name})`) - const requestObj = musicSdk[source].getLyric({ - songmid, - name: songInfo.name || '', - singer: songInfo.singer || '', - hash: songInfo.hash || '', - interval: songInfo.interval || '', - }) - const result = await requestObj.promise - const lyricText = result?.lyric || result?.lrc || null - console.log(`[歌词抓取] 获取结果: ${lyricText ? lyricText.length + ' 字符' : '无歌词'}`) - return lyricText + const result = await resolveSongLyricData(songInfo, owner, lookupLocalLyrics, musicSdk) + return buildLyrics(result) } catch (e: any) { - console.warn(`[歌词抓取] 抓取失败 (${songInfo.name}):`, e.message || e) + console.warn('[Lyric] Download lyric lookup failed:', songInfo.source, songInfo.songmid || songInfo.id, e.message || e) return null } }) @@ -9149,6 +9593,12 @@ export const startServer = async (port: number, ip: string) => { // if (status.status) await handleStopServer() startupLog.info(`starting sync server in ${process.env.NODE_ENV == 'production' ? 'production' : 'development'}`) + let appVersion = 'unknown' + try { + const pkg = JSON.parse(fs.readFileSync(path.join(process.cwd(), 'package.json'), 'utf8')) + if (pkg && pkg.version) appVersion = String(pkg.version) + } catch { } + startupLog.info(`LX Music Server version: v${appVersion}`) const proxyEnabled = global.lx.config['proxy.all.enabled'] const proxyAddress = global.lx.config['proxy.all.address'] console.log(`[网络代理] 音乐 SDK 代理状态: ${proxyEnabled ? `已启用 (${proxyAddress})` : '未启用'}`) @@ -9228,7 +9678,7 @@ export const startServer = async (port: number, ip: string) => { // console.log('sync server started') status.status = true status.message = '' - scheduler.startScheduler() + if (!global.lx.restoreMaintenance) scheduler.startScheduler() status.address = ip == '0.0.0.0' ? getAddress() : [ip] // [Subsonic 独立端口] 主端口就绪后启动(独立端口失败不影响主服务) @@ -9273,69 +9723,6 @@ export const removeDevice = async (userName: string, clientId: string) => { await userSpace.removeDevice(clientId) } -// ===== 缓存生命周期自动过期清理任务 (支持 0 永久保留) ===== -const getCacheRetentionMs = () => { - const daysConfig = global.lx.config && typeof global.lx.config['cache.maxAgeDays'] !== 'undefined' - ? Number(global.lx.config['cache.maxAgeDays']) - : 14 - if (isNaN(daysConfig) || daysConfig <= 0) return 0 // 0 表示永久保留,不清理 - return daysConfig * 24 * 60 * 60 * 1000 -} - -const cleanExpiredCacheFiles = async () => { - try { - const retentionMs = getCacheRetentionMs() - if (retentionMs === 0) { - // 设置为 0,永久保留缓存 - return - } - - const cacheDirs = [ - path.join(process.cwd(), 'cache'), - path.join(global.lx.dataPath, 'cache'), - ] - const now = Date.now() - let totalCleaned = 0 - let totalBytesFreed = 0 - - const scanAndCleanAsync = async (dir: string) => { - try { - const exists = await fs.promises.access(dir).then(() => true).catch(() => false) - if (!exists) return - const entries = await fs.promises.readdir(dir, { withFileTypes: true }) - for (const entry of entries) { - const fullPath = path.join(dir, entry.name) - if (entry.isDirectory()) { - await scanAndCleanAsync(fullPath) - await new Promise(resolve => setImmediate(resolve)) - } else if (entry.isFile()) { - if (entry.name === 'cache_index.json' || entry.name.endsWith('.json')) continue - try { - const stats = await fs.promises.stat(fullPath) - const fileTime = Math.max(stats.atimeMs || 0, stats.mtimeMs || 0) - if (now - fileTime > retentionMs) { - await fs.promises.unlink(fullPath) - totalCleaned++ - totalBytesFreed += stats.size - } - } catch (e) { } - } - } - } catch (e) { } - } - - const uniqueDirs = Array.from(new Set(cacheDirs)) - for (const d of uniqueDirs) { - await scanAndCleanAsync(d) - } - if (totalCleaned > 0) { - const retentionDays = Math.round(retentionMs / (24 * 60 * 60 * 1000)) - console.log(`[文件缓存] [${retentionDays}天过期清理] 成功清理 ${totalCleaned} 个过期缓存音频/歌词文件 (释放 ${(totalBytesFreed / 1024 / 1024).toFixed(2)} MB)`) - } - } catch (err) { - console.error('[文件缓存] 自动清理异常:', err) - } -} - -setTimeout(() => void cleanExpiredCacheFiles(), 10000) -setInterval(() => void cleanExpiredCacheFiles(), 12 * 60 * 60 * 1000) +// Cache-only expiration and size sweeps share backup and index protection. +setTimeout(() => void fileCache.cleanupAllCaches(), 10000) +setInterval(() => void fileCache.cleanupAllCaches(), 12 * 60 * 60 * 1000) diff --git a/src/server/sources/userApi.ts b/src/server/sources/userApi.ts index 30ce91ca..0f0945ef 100644 --- a/src/server/sources/userApi.ts +++ b/src/server/sources/userApi.ts @@ -368,7 +368,7 @@ export async function loadUserApi(apiInfo: UserApiInfo): Promise { count: (...args: any[]) => { if (global.lx.config['debug.enabled']) (console as any).count?.(...args) }, countReset: (...args: any[]) => { if (global.lx.config['debug.enabled']) (console as any).countReset?.(...args) }, assert: (condition?: boolean, ...data: any[]) => { if (global.lx.config['debug.enabled']) console.assert(condition, ...data) }, - dir: (...args: any[]) => { if (global.lx.config['debug.enabled']) console.dir(...args) }, + dir: (...args: any[]) => { if (global.lx.config['debug.enabled']) (console as any).dir(...args) }, dirxml: (...args: any[]) => { if (global.lx.config['debug.enabled']) (console as any).dirxml?.(...args) }, error: (...args: any[]) => { console.error(...args) } } @@ -946,6 +946,16 @@ async function loadSourcesFromDir(dirPath: string, owner: string, stats: { loade let fsWatcher: fs.FSWatcher | null = null const lastReloadMap = new Map() // 记录每个用户的最后加载时间 +export function unloadUserApis(username: string): void { + if (username === 'open' || username === '_open') return + for (const [key, api] of loadedApis) { + if (api.info.owner !== username) continue + loadedApis.delete(key) + apiStatus.delete(key) + } + lastReloadMap.set(username, Date.now()) +} + // 启动文件监控 function startWatcher(sourceRoot: string) { if (fsWatcher) return diff --git a/src/server/sources/userSourceCleanup.ts b/src/server/sources/userSourceCleanup.ts new file mode 100644 index 00000000..7c4b2249 --- /dev/null +++ b/src/server/sources/userSourceCleanup.ts @@ -0,0 +1,13 @@ +import fs from 'node:fs' +import path from 'node:path' + +/** Only remove a private source directory; never the shared _open directory. */ +export function deleteUserSourceFiles(username: string): void { + if (!username || username === '_open' || username === 'open' || username === '.' || username === '..' || /[\\/:]/.test(username)) { + throw new Error('Invalid private source owner') + } + const root = path.resolve(process.env.DATA_PATH || path.join(process.cwd(), 'data'), 'users', 'source') + const target = path.resolve(root, username) + if (path.dirname(target) !== root) throw new Error('Invalid private source directory') + fs.rmSync(target, { recursive: true, force: true }) +} diff --git a/src/server/utils/dislikeCache.ts b/src/server/utils/dislikeCache.ts index c04bda39..3a9dcc27 100644 --- a/src/server/utils/dislikeCache.ts +++ b/src/server/utils/dislikeCache.ts @@ -1,5 +1,5 @@ import type { DislikeRuleSet } from '@/modules/dislike/match' -import { parseDislikeRules } from '@/modules/dislike/match' +import { scopeDislikeRules, parseDislikeRules } from '@/modules/dislike/match' /** * dislike 规则集解析缓存。 @@ -52,12 +52,13 @@ export async function getCachedDislikeRuleSet( if (Array.isArray(albumsArr)) { const { normalizeText, splitSingers } = require('@/modules/dislike/match') for (const x of albumsArr) { + const scoped = scopeDislikeRules(set, x.source) const albumName = normalizeText(String(x.name || '')) if (!albumName) continue - let sSet = set.albums.get(albumName) + let sSet = scoped.albums.get(albumName) if (!sSet) { sSet = new Set() - set.albums.set(albumName, sSet) + scoped.albums.set(albumName, sSet) } const singers = splitSingers(x.artistName) for (const s of singers) sSet.add(s) @@ -70,8 +71,9 @@ export async function getCachedDislikeRuleSet( if (Array.isArray(artistsArr)) { const { normalizeText } = require('@/modules/dislike/match') for (const x of artistsArr) { + const scoped = scopeDislikeRules(set, x.source) const singerName = normalizeText(String(x.name || '')) - if (singerName) set.singerNames.add(singerName) + if (singerName) scoped.singerNames.add(singerName) } } } diff --git a/src/types/app.d.ts b/src/types/app.d.ts index 8924f9d6..0ac7b663 100644 --- a/src/types/app.d.ts +++ b/src/types/app.d.ts @@ -13,6 +13,14 @@ declare global { saveConfig: () => void backupConfigNow?: () => { success: boolean, filename?: string, error?: string } getConfigBackupDir?: () => string + backupSnapshotToLocal?: (srcPath: string, user: string, name: string) => void + removeSnapshotLocalBackups?: (user: string, name: string) => void + nextConfigBackupAt?: number + lastConfigBackupAt?: number + lastConfigBackupError?: string + rescheduleConfigBackup?: () => void + restoreMaintenance?: boolean + pauseForRestore?: () => void lastCpuSample?: { idle: number, total: number } lastProcessSample?: { cpu: NodeJS.CpuUsage, time: number } subsonicPortConflict?: { diff --git a/src/types/config.d.ts b/src/types/config.d.ts index 5916f99c..7b8f397f 100644 --- a/src/types/config.d.ts +++ b/src/types/config.d.ts @@ -201,6 +201,8 @@ declare namespace LX { /** * WebDAV 全量备份远端路径(默认 /lx-sync-backups) */ + 'webdav.retentionCount'?: number + 'webdav.retentionDays'?: number 'webdav.backupPath'?: string /** @@ -238,6 +240,18 @@ declare namespace LX { */ 'configBackup.dir'?: string + /** + * 本地配置自动备份周期(小时),默认 24;留空或非法值回退为 24 + */ + 'configBackup.intervalHours'?: number + + /** + * 本地配置自动备份锚点时间(HH:MM),默认 00:00(即每天 0 点)。 + * 若设置,则在该时刻首次触发,之后每 intervalHours 小时循环; + * 留空(或清除)则从保存配置/启动时起每 intervalHours 小时触发 + */ + 'configBackup.scheduleTime'?: string + /** * 歌单快照额外备份路径(留空则存于用户数据目录 list/snapshot)。 * 相对路径基于 data 目录,绝对路径直接使用 @@ -353,6 +367,7 @@ declare namespace LX { */ 'subsonic.localPlaylist'?: boolean 'subsonic.customPlaylist'?: boolean + 'subsonic.cachePlaylist'?: boolean 'subsonic.publicLeaderboards'?: boolean /** @@ -380,12 +395,16 @@ declare namespace LX { */ 'subsonic.linkRatingToDislike'?: boolean + + /** - * 解耦开关(反向): 不喜欢 -> 评星 是否自动联动 (默认 false) - * true 时, 网页端 / Subsonic 给某歌点「不喜欢」会回写该用户 subsonic-meta.json 的 ratings 映射(低星), - * 使 Subsonic 客户端能看到这颗星; false 时, 「不喜欢」仅写入原生 dislike 规则, 不回写评星, 与评星解耦。 + * 是否向 Subsonic 客户端暴露 1~5 星评星歌单 (默认 true) + * 虚拟只读歌单(不落盘、不影响 LX 客户端「我的歌单」):id 为 rating_1 ~ rating_5。 + * rating_1 承载「我不喜欢」:除 1 星歌曲外并入 dislike 规则命中的歌曲,无真实评分时显示 1 星; + * 仅控制显示;自动联动分别受对应开关控制,仅补充缺失数据, + * 便于只能用评星操作的 Subsonic 客户端通过 1 星查找并处理不喜欢。 */ - 'subsonic.linkDislikeToRating'?: boolean + 'subsonic.ratingPlaylists'?: boolean /** * Subsonic 列表中是否隐藏 dislike 命中的歌曲 (默认 true) @@ -533,6 +552,7 @@ declare namespace LX { // ===== OpenList 备份/同步 ===== /** 是否启用 OpenList 备份/同步 */ + 'openlist.playback.enable'?: boolean 'openlist.enable'?: boolean /** OpenList 地址(如 http://127.0.0.1:5244) */ 'openlist.endpoint'?: string @@ -544,12 +564,11 @@ declare namespace LX { 'openlist.token'?: string /** OpenList 中 lx-music 备份根目录 */ 'openlist.root'?: string - /** 上传 OpenList 后是否保留本地文件 */ - 'openlist.keepLocalAfterUpload'?: boolean /** 远端可用性校验周期(分钟) */ 'openlist.verifyInterval'?: number /** 双向同步周期(分钟) */ 'openlist.syncInterval'?: number + 'openlist.historyRetentionDays'?: number /** 各用户备份目录配置(JSON 数组) */ 'openlist.userBackups'?: string /** 是否同步非音频文件(图片/封面等) */ diff --git a/src/types/dislike_list.d.ts b/src/types/dislike_list.d.ts index ca700104..06908a90 100644 --- a/src/types/dislike_list.d.ts +++ b/src/types/dislike_list.d.ts @@ -28,11 +28,17 @@ declare namespace LX { * 规则串中以 !<专辑名>@<歌手> 形式存储,按歌手拆成多条。 */ interface DislikeAlbumInfo { + source?: string + provenance?: { channel: string, at: number, ratingId?: string } + reasons?: { channel: string, at: number, ratingId?: string }[] albumName: string singer: string } interface DislikeSongInfo { + provenance?: { channel: string, at: number, ratingId?: string } + reasons?: { channel: string, at: number, ratingId?: string }[] + enrichedBy?: { channel: string, at: number } id?: string name: string singer: string diff --git a/src/user/index.ts b/src/user/index.ts index 32e3f4b5..5d1ccf95 100644 --- a/src/user/index.ts +++ b/src/user/index.ts @@ -93,6 +93,11 @@ export const updateAllUserSnapshotDirs = (newBackupPath?: string) => { } catch (e) { console.error(`[Snapshot] Failed to hot-update snapshot dir for user ${name}:`, e) } + try { + space.dislikeManage.snapshotDataManage.updateSnapshotDir(newBackupPath) + } catch (e) { + console.error(`[DislikeSnapshot] Failed to hot-update snapshot dir for user ${name}:`, e) + } } } diff --git a/src/utils/backupFiles.ts b/src/utils/backupFiles.ts new file mode 100644 index 00000000..fc99b116 --- /dev/null +++ b/src/utils/backupFiles.ts @@ -0,0 +1,21 @@ +import fs from 'node:fs' +import crypto from 'node:crypto' + +/** Fixed memory use even for multi-gigabyte audio files. */ +export function hashBackupFile(file: string): string { + const hash = crypto.createHash('sha256') + const fd = fs.openSync(file, 'r') + const buffer = Buffer.allocUnsafe(1024 * 1024) + try { + let count: number + while ((count = fs.readSync(fd, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, count)) + return hash.digest('hex') + } finally { fs.closeSync(fd) } +} + +export function backupFileVersion(file: string): string { + if (!fs.existsSync(file)) return 'missing' + const stat = fs.lstatSync(file) + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('恢复目标不是普通文件:' + file) + return `${stat.size}:${stat.mtimeMs}:${hashBackupFile(file)}` +} diff --git a/src/utils/backupPolicy.ts b/src/utils/backupPolicy.ts index 9797258c..a41cf45d 100644 --- a/src/utils/backupPolicy.ts +++ b/src/utils/backupPolicy.ts @@ -5,9 +5,18 @@ export const BACKUP_TYPES = ['music', 'userFiles', 'snapshots', 'userData', 'sys export type BackupType = typeof BACKUP_TYPES[number] export type BackupDestination = 'webdav' | 'openlist' let cloudRestoreRunning = false -export const isCloudRestoreRunning = () => cloudRestoreRunning +let activeCloudBackups = 0 +export const isCloudRestoreActive = () => cloudRestoreRunning +export const isCloudBackupRunning = () => activeCloudBackups > 0 +export function beginCloudBackup(): () => void { + if (isCloudRestoreRunning()) throw new Error('恢复维护中,不能执行云端备份') + activeCloudBackups++ + let released = false + return () => { if (!released) { activeCloudBackups--; released = true } } +} +export const isCloudRestoreRunning = () => cloudRestoreRunning || global.lx?.restoreMaintenance === true export function beginCloudRestore(): () => void { - if (cloudRestoreRunning) throw new Error('另一项云端恢复正在执行,请稍后重试') + if (isCloudRestoreRunning() || activeCloudBackups) throw new Error('云端正在备份、校验或恢复,请等待完成;恢复维护后需重启') cloudRestoreRunning = true return () => { cloudRestoreRunning = false } } @@ -18,9 +27,9 @@ export interface BackupContext { } export const BACKUP_TYPE_INFO = [ - { id: 'music', label: '歌曲与歌词', description: 'data/music 内的歌曲、歌词、封面和音乐索引;不包含 cache 或外部歌曲目录。' }, + { id: 'music', label: '歌曲与歌词', description: '当前数据目录的 music/(默认 data/music)内的歌曲、已有歌词、封面和音乐索引;不含各级 cache、数据目录外独立 music 或外部曲库。' }, { id: 'userFiles', label: '用户音源文件', description: 'users/source 内的音源脚本、顺序和状态文件,以及上传的用户文件。' }, - { id: 'snapshots', label: '歌单与不喜欢快照', description: '快照正文、snapshotInfo.json;包含没有扩展名的快照及已配置的外部歌单快照目录。' }, + { id: 'snapshots', label: '歌单、不喜欢与评分快照', description: '快照正文、snapshotInfo.json;包含没有扩展名的快照及已配置的外部歌单快照目录。' }, { id: 'userData', label: '用户数据', description: '用户设置、播放记录、收藏与其他 JSON 数据;不包含音源文件和快照。' }, { id: 'system', label: '系统与账户', description: 'config.js、users.json、serverInfo.json 等系统 JSON;包含账号和连接凭据。' }, ] as const @@ -46,7 +55,7 @@ export function backupType(relative: string): BackupType | null { if (/^users\/[^/]+\/music\//i.test(rel)) return null if (/\.(tmp|oltmp|part|log|lock|db|sqlite)(-(wal|shm))?$/i.test(rel) || /(^|\/)temp-|(^|\/)lx-sync-backup-/.test(lower)) return null if (lower.startsWith('music/')) return 'music' - if (lower.startsWith('_snapshots/') || /^users\/[^/]+\/(list|dislike)\/snapshot(\/|info\.json$)/i.test(rel)) return 'snapshots' + if (lower.startsWith('_snapshots/') || /^users\/[^/]+\/(list|dislike|rating)\/snapshot(\/|info\.json$)/i.test(rel)) return 'snapshots' if (/^(users\/source|source|uploads)\//i.test(rel)) return 'userFiles' if (/^users\/[^/]+\//i.test(rel)) return 'userData' if (lower === 'config.js' || (!rel.includes('/') && lower.endsWith('.json'))) return 'system' @@ -88,7 +97,7 @@ export function scanBackupFiles(context: BackupContext, types: readonly BackupTy const rel = prefix + entry.name const local = path.join(dir, entry.name) if (entry.isDirectory()) { - if (!entry.name.startsWith('.') && !/^(runtime|cache|logs|tmp|backups)$/i.test(entry.name)) walk(local, rel + '/') + if (!entry.name.startsWith('.') && !/^(runtime|cache|logs|tmp|backups|test-evidence)$/i.test(entry.name)) walk(local, rel + '/') } else if (entry.isFile()) { const type = backupType(rel) if (type && types.includes(type)) files.set(rel, local) @@ -100,9 +109,12 @@ export function scanBackupFiles(context: BackupContext, types: readonly BackupTy const snapshots = String(context.config?.['snapshot.backupPath'] || '') if (types.includes('snapshots') && snapshots) { const base = path.isAbsolute(snapshots) ? snapshots : path.join(context.dataPath, snapshots) - // 外部快照目录只映射每个用户的 snapshot 子目录。 + // 外部快照目录只映射每个用户的 snapshot 子目录(歌单与 dislike)。 if (fs.existsSync(base)) for (const entry of fs.readdirSync(base, { withFileTypes: true })) { - if (entry.isDirectory() && !entry.isSymbolicLink()) walk(path.join(base, entry.name, 'snapshot'), `_snapshots/${entry.name}/snapshot/`) + if (entry.isDirectory() && !entry.isSymbolicLink()) { + walk(path.join(base, entry.name, 'snapshot'), `_snapshots/${entry.name}/snapshot/`) + walk(path.join(base, entry.name, 'dislike', 'snapshot'), `_snapshots/${entry.name}/dislike/snapshot/`) + } } } return files diff --git a/src/utils/configBackupSchedule.ts b/src/utils/configBackupSchedule.ts new file mode 100644 index 00000000..17a65e4c --- /dev/null +++ b/src/utils/configBackupSchedule.ts @@ -0,0 +1,13 @@ +/** 下一次本地备份:首次按锚点对齐,后续沿上次计划递增,跨天周期不会被每日重新对齐。 */ +export function getNextConfigBackupTime(anchor: string, intervalMs: number, now: number, previous?: number): number { + if (previous !== undefined) return previous + (Math.floor(Math.max(0, now - previous) / intervalMs) + 1) * intervalMs + const match = /^([01]?\d|2[0-3]):([0-5]\d)$/.exec(anchor) + if (!match) return now + intervalMs + const base = new Date(now) + base.setHours(Number(match[1]), Number(match[2]), 0, 0) + const start = base.getTime() + return start > now ? start : start + (Math.floor((now - start) / intervalMs) + 1) * intervalMs +} + +/** Node 定时器最大约 24.8 天;较长周期需要分段等待。 */ +export const configBackupTimerDelay = (next: number, now: number) => Math.max(1, Math.min(next - now, 2_147_483_647)) diff --git a/src/utils/webdavRecovery.ts b/src/utils/webdavRecovery.ts new file mode 100644 index 00000000..1a893bae --- /dev/null +++ b/src/utils/webdavRecovery.ts @@ -0,0 +1,151 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { Open } from 'unzipper' +import { pipeline } from 'node:stream/promises' +import { backupLocalPath, backupType, type BackupContext, type BackupType } from './backupPolicy' +import { hashBackupFile, backupFileVersion } from './backupFiles' +import { downloadWebdavFile } from './webdavTransfer' +import { parseWebdavManifest, webdavManifestName, type WebdavFileVersion } from './webdavState' +import { executeRestoreTransaction, type RestoreFile } from '@/server/backup/webdavRestoreTasks' +import { markRestoreDependencies, mergeRestoreDependency } from '@/server/backup/restoreDependencies' + +export interface WebdavRestoreOptions { mode?: 'auto' | 'files' | 'zip'; targetFilename?: string; strategy?: 'missing' | 'overwrite'; keepCopies?: boolean } +export interface RecoveryContext extends BackupContext { client: any; types: BackupType[]; syncPath: string; backupPath: string; identity: string } +interface Plan { token: string; expires: number; identity: string; files: RestoreFile[]; etags: Record; zip?: string; options: WebdavRestoreOptions; warnings: string[] } +export function checkedBackupPath(base: string, input: string) { + const name = path.posix.basename(input) + if (!/^lx-sync-backup-[a-zA-Z0-9T.-]+\.zip$/.test(name) || (input !== name && input !== base + '/' + name)) throw new Error('备份文件必须位于配置的 ZIP 目录内') + return base + '/' + name +} +export class WebdavRecovery { + private plans = new Map() + discard(token: string) { + const plan = this.plans.get(token) + if (!plan) return + if (plan.zip) fs.rmSync(plan.zip, { force: true }) + this.plans.delete(token) + } + private directory(dataPath: string) { const dir = path.join(dataPath, 'runtime', 'webdav-preview'); fs.mkdirSync(dir, { recursive: true }); return dir } + private cleanup() { + for (const [id, plan] of this.plans) if (plan.expires < Date.now()) { if (plan.zip) fs.rmSync(plan.zip, { force: true }); this.plans.delete(id) } + } + private file(context: RecoveryContext, relative: string, remote: string, size: number, strategy: string, hash?: string): RestoreFile { + if (!Number.isSafeInteger(size) || size < 0) throw new Error('备份文件大小无效:' + relative) + const local = backupLocalPath(relative, context), version = backupFileVersion(local) + return { relative, local, remote, size, hash, version, action: version === 'missing' ? 'add' : strategy === 'missing' ? 'skip' : 'overwrite' } + } + async prepare(context: RecoveryContext, options: WebdavRestoreOptions = {}, localZip?: string) { + this.cleanup() + if (options.strategy && !['missing', 'overwrite'].includes(options.strategy)) throw new Error('恢复覆盖方式无效') + if (options.mode && !['auto', 'files', 'zip'].includes(options.mode)) throw new Error('恢复来源无效') + const token = crypto.randomUUID(), strategy = options.strategy || 'overwrite' + const plan: Plan = { token, expires: Date.now() + 15 * 60_000, identity: context.identity, files: [], etags: {}, options: { ...options, strategy }, warnings: [] } + let items: any[] = [], mode = localZip ? 'zip' : options.mode || 'auto' + if (mode !== 'zip') { + try { items = await context.client.getDirectoryContents(context.syncPath + '/', { deep: true, signal: AbortSignal.timeout(30_000) }) } + catch (error: any) { if (mode !== 'auto' || Number(error.status || error.response?.status) !== 404) throw error } + const prefix = context.syncPath + '/' + items = items.filter(item => item.type === 'file' && item.filename.startsWith(prefix)) + if (mode === 'auto') mode = items.some(item => { const type = backupType(item.filename.slice(prefix.length)); return type && context.types.includes(type) }) ? 'files' : 'zip' + } + try { + if (mode === 'files') { + const manifest = items.find(item => item.filename === context.syncPath + '/' + webdavManifestName) + let versions: Record = {} + if (manifest) { + if (manifest.size > 16 * 1024 * 1024) throw new Error('备份清单过大') + const temp = path.join(this.directory(context.dataPath), token + '.json') + try { await downloadWebdavFile(context.client, manifest.filename, temp, Number(manifest.size)); versions = parseWebdavManifest(JSON.parse(fs.readFileSync(temp, 'utf8'))) } + finally { fs.rmSync(temp, { force: true }) } + } else plan.warnings.push('旧增量备份没有 SHA-256 清单,仅核对下载大小。') + for (const item of items) { + const relative = item.filename.slice((context.syncPath + '/').length), type = backupType(relative) + if (!type || !context.types.includes(type)) continue + const version = versions[relative] + if (manifest && !version && !plan.warnings.includes('部分旧云端文件没有 SHA-256 记录,仅核对下载大小。')) plan.warnings.push('部分旧云端文件没有 SHA-256 记录,仅核对下载大小。') + if (version && version.size !== Number(item.size)) throw new Error('云端大小与备份清单不一致:' + relative) + plan.files.push(this.file(context, relative, item.filename, Number(item.size), strategy, version?.hash)) + if (item.etag) plan.etags[relative] = item.etag + } + if (manifest) this.checkCoverage(versions, plan.files, context.types) + } else { + let zip = localZip + if (!zip) { + let remote = options.targetFilename ? checkedBackupPath(context.backupPath, options.targetFilename) : '' + if (!remote) { + const backups = await context.client.getDirectoryContents(context.backupPath + '/', { signal: AbortSignal.timeout(30_000) }) + const candidates = backups.filter((item: any) => item.type === 'file' && /^lx-sync-backup-[a-zA-Z0-9T.-]+\.zip$/.test(item.basename)).sort((a: any, b: any) => Date.parse(b.lastmod || '') - Date.parse(a.lastmod || '') || b.basename.localeCompare(a.basename)) + if (!candidates.length) throw new Error('云端没有 ZIP 备份') + remote = checkedBackupPath(context.backupPath, candidates[0].filename) + } + const info = await context.client.stat(remote, { signal: AbortSignal.timeout(30_000) }) + zip = path.join(this.directory(context.dataPath), token + '.zip') + plan.zip = zip + await downloadWebdavFile(context.client, remote, zip, Number(info.size), undefined, info.etag) + plan.options.targetFilename = remote + } else { + // Own the uploaded ZIP for the lifetime of the plan, independently of the HTTP temp file. + const owned = path.join(this.directory(context.dataPath), token + '.zip'); fs.copyFileSync(zip, owned); zip = owned; plan.zip = owned + } + const archive = await Open.file(zip!) + const manifest = archive.files.find(entry => entry.path === webdavManifestName) + let versions: Record = {} + if (manifest) { + if (manifest.uncompressedSize > 16 * 1024 * 1024) throw new Error('ZIP 清单过大') + versions = parseWebdavManifest(JSON.parse((await manifest.buffer()).toString('utf8'))) + } else plan.warnings.push('旧 ZIP 没有 SHA-256 清单,将检查解压大小。') + const seen = new Set() + for (const entry of archive.files) { + if (entry.path.split(/[\\/]/).some(segment => segment === '..') || path.posix.isAbsolute(entry.path)) throw new Error('ZIP 路径越界') + if ((((entry as any).externalFileAttributes >>> 16) & 0xf000) === 0xa000) throw new Error('ZIP 不允许符号链接') + if (entry.type !== 'File') continue + const relative = entry.path.replace(/\\/g, '/'), type = backupType(relative) + if (!type || !context.types.includes(type)) continue + const file = this.file(context, relative, entry.path, entry.uncompressedSize, strategy, versions[relative]?.hash) + const canonical = process.platform === 'win32' ? file.local.toLowerCase() : file.local + if (seen.has(canonical)) throw new Error('ZIP 包含重复恢复目标:' + relative) + seen.add(canonical) + if (manifest && (!versions[relative] || versions[relative].size !== entry.uncompressedSize)) throw new Error('ZIP 文件与清单不一致:' + relative) + plan.files.push(file) + } + if (manifest) this.checkCoverage(versions, plan.files, context.types) + } + if (!plan.files.length) throw new Error('备份中没有当前所选类型的文件') + if (strategy === 'missing') markRestoreDependencies(plan.files) + this.plans.set(token, plan) + const timer = setTimeout(() => { try { this.discard(token) } catch { /* Cleanup can be retried by the next preview. */ } }, 15 * 60_000) + timer.unref() + return this.summary(plan, context.types) + } catch (error) { if (plan.zip) fs.rmSync(plan.zip, { force: true }); throw error } + } + private checkCoverage(versions: Record, files: RestoreFile[], types: BackupType[]) { + const present = new Set(files.map(file => file.relative)) + for (const relative of Object.keys(versions)) { + const type = backupType(relative) + if (type && types.includes(type) && !present.has(relative)) throw new Error('备份清单中的文件缺失:' + relative) + } + } + private summary(plan: Plan, types: BackupType[]) { + return { token: plan.token, types, source: plan.zip ? 'zip' : 'files', total: plan.files.length, added: plan.files.filter(f => f.action === 'add').length, overwritten: plan.files.filter(f => f.action === 'overwrite').length, merged: plan.files.filter(f => f.action === 'merge').length, skipped: plan.files.filter(f => f.action === 'skip').length, bytes: plan.files.filter(f => f.action !== 'skip').reduce((n, f) => n + f.size, 0), warnings: plan.warnings, files: plan.files.map(f => ({ path: f.relative, size: f.size, action: f.action })) } + } + async execute(context: RecoveryContext, token: string) { + const plan = this.plans.get(token) + if (!plan || plan.expires < Date.now() || plan.identity !== context.identity) throw new Error('预览过期或配置已变化,请重新预览') + for (const file of plan.files) if (backupFileVersion(file.local) !== file.version) throw new Error('预览后本地文件已变化,请重新预览:' + file.relative) + this.plans.delete(token) + try { + const archive = plan.zip ? await Open.file(plan.zip) : null + return await executeRestoreTransaction({ files: plan.files, source: 'webdav:' + context.identity, options: plan.options, keepCopies: plan.options.keepCopies !== false, + download: async (file, target, signal) => { + if (archive) { + const entry = archive.files.find(entry => entry.path === file.remote)! + await pipeline(entry.stream(), fs.createWriteStream(target), { signal }) + } else await downloadWebdavFile(context.client, file.remote, target, file.size, signal, plan.etags[file.relative]) + if (fs.statSync(target).size !== file.size) throw new Error('恢复文件大小不一致:' + file.relative) + if (file.hash && hashBackupFile(target) !== file.hash) throw new Error('恢复文件 SHA-256 不一致:' + file.relative) + }, merge: (file, staged) => mergeRestoreDependency(file, staged, plan.files), + }) + } finally { if (plan.zip) fs.rmSync(plan.zip, { force: true }) } + } +} diff --git a/src/utils/webdavState.ts b/src/utils/webdavState.ts new file mode 100644 index 00000000..afefe6a1 --- /dev/null +++ b/src/utils/webdavState.ts @@ -0,0 +1,36 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { backupType } from './backupPolicy' + +export interface WebdavFileVersion { size: number; hash: string } +export interface WebdavState { manifest?: string; files: Array<[string, string]>; zip: Array<[string, string]>; remote: Record } +export const webdavManifestName = '.lx-backup-manifest.json' +export function parseWebdavManifest(value: any): Record { + if (value?.schema !== 1 || !value.files || typeof value.files !== 'object' || Array.isArray(value.files)) throw new Error('WebDAV 备份清单格式无效') + for (const [relative, entry] of Object.entries(value.files)) { + const file = entry as WebdavFileVersion + if (!backupType(relative) || !Number.isSafeInteger(file?.size) || file.size < 0 || !/^[a-f0-9]{64}$/.test(file?.hash)) throw new Error('WebDAV 备份清单条目无效') + } + return value.files +} +export function webdavStatePath(dataPath: string, identity: string) { + return path.join(dataPath, 'runtime', 'webdav-baselines', crypto.createHash('sha256').update(identity).digest('hex') + '.json') +} +export function readWebdavState(file: string): WebdavState { + try { + const state = JSON.parse(fs.readFileSync(file, 'utf8')) + parseWebdavManifest({ schema: 1, files: state.remote }) + for (const entries of [state.files, state.zip]) { + if (!Array.isArray(entries) || entries.some(entry => !Array.isArray(entry) || entry.length !== 2 || !backupType(entry[0]) || typeof entry[1] !== 'string' || !/^[a-f0-9]{64}$/.test(entry[1]))) throw new Error('基线格式无效') + } + return state + } catch (error: any) { + if (error.code !== 'ENOENT') console.warn('[WebDAV] 本地增量基线不可用,将重新备份') + return { files: [], zip: [], remote: {} } + } +} +export function saveWebdavState(file: string, state: WebdavState) { + fs.mkdirSync(path.dirname(file), { recursive: true }) + fs.writeFileSync(file + '.tmp', JSON.stringify(state), { mode: 0o600 }); fs.renameSync(file + '.tmp', file) +} diff --git a/src/utils/webdavSync.ts b/src/utils/webdavSync.ts index 3d052100..fe53bccc 100644 --- a/src/utils/webdavSync.ts +++ b/src/utils/webdavSync.ts @@ -1,14 +1,16 @@ +import { hashBackupFile, backupFileVersion } from './backupFiles' +import { downloadWebdavFile, uploadWebdavFile } from './webdavTransfer' +import { webdavStatePath, readWebdavState, saveWebdavState, webdavManifestName, parseWebdavManifest, type WebdavFileVersion } from './webdavState' +import { WebdavRecovery, checkedBackupPath, type WebdavRestoreOptions, type RecoveryContext } from './webdavRecovery' import fs from 'fs' import path from 'path' import http from 'http' import https from 'https' import { ZipArchive } from 'archiver' -import { Open } from 'unzipper' import crypto from 'crypto' import { EventEmitter } from 'events' -import { PassThrough } from 'stream' import { pipeline } from 'stream/promises' -import { backupType, backupLocalPath, getBackupTypes, normalizeBackupTypes, scanBackupFiles, beginCloudRestore, isCloudRestoreRunning, type BackupType } from './backupPolicy' +import { backupType, backupLocalPath, getBackupTypes, normalizeBackupTypes, scanBackupFiles, beginCloudBackup, beginCloudRestore, isCloudRestoreRunning, type BackupType } from './backupPolicy' interface WebDAVConfig { enable?: boolean @@ -23,6 +25,8 @@ interface WebDAVConfig { excludeMusic?: boolean // 排除下载目录 (data/music) types?: BackupType[] mirrorDeletes?: boolean + retentionCount?: number + retentionDays?: number } interface SyncLog { @@ -51,6 +55,14 @@ class WebDAVSync extends EventEmitter { private excludeMusic: boolean // 是否排除下载目录 (data/music) private watchTimer: NodeJS.Timeout | null = null private backupTimer: NodeJS.Timeout | null = null + private nextSyncAt = 0 + private nextZipAt = 0 + private recovery = new WebdavRecovery() + private remoteVersions: Record = {} + private stateFile = '' + private publishedManifest = '' + private lastArchiveSnapshot = new Map() + public lastRestoreResult: any = null private filesHash: Map = new Map() private backupHash: Map = new Map() private syncing = false @@ -73,14 +85,28 @@ class WebDAVSync extends EventEmitter { backupPath: this.backupPath, types: config.types, mirrorDeletes: config.mirrorDeletes === true, + retentionCount: Math.max(1, Math.min(1000, Math.floor(config.retentionCount || 5))), + retentionDays: Math.max(0, Math.min(3650, Math.floor(config.retentionDays || 0))), } this.syncInterval = (config.interval || 60) * 60 * 1000 this.backupInterval = (config.backupInterval || 24) * 60 * 60 * 1000 this.excludeCache = config.excludeCache ?? false this.excludeMusic = config.excludeMusic ?? false this.dataPath = dataPath + this.loadState() } + getRuntimeStatus() { + const scheduled = this.isConfigured() && !isCloudRestoreRunning() + return { running: this.syncing, restoring: this.restoring, + nextSyncAt: scheduled && this.watchTimer ? this.nextSyncAt : 0, + nextZipAt: scheduled && this.backupTimer ? this.nextZipAt : 0, + lastActivity: this.syncLogs[0] || null, + lastError: this.syncLogs.find(log => log.status === 'error') || null } + } + + get isBusy() { return this.syncing || this.restoring } + async initClient(force = false): Promise { if (!this.isConfigured()) return false if (this.client && !force) return true @@ -132,15 +158,88 @@ class WebDAVSync extends EventEmitter { return this.syncLogs } - private getFileHash(filePath: string): string { + private getFileHash(filePath: string): string { return hashBackupFile(filePath) } + private identity(includeTypes = false) { + return JSON.stringify([this.config.url, this.config.username, this.syncPath, this.backupPath, this.context().configPath, this.context().config?.['snapshot.backupPath'], includeTypes ? this.selectedTypes() : null]) + } + private loadState() { + this.stateFile = webdavStatePath(this.dataPath, this.identity()) + const state = readWebdavState(this.stateFile) + this.filesHash = new Map(state.files); this.backupHash = new Map(state.zip); this.remoteVersions = state.remote + this.publishedManifest = (state as any).manifest || '' + } + private saveState() { saveWebdavState(this.stateFile, { files: [...this.filesHash], zip: [...this.backupHash], remote: this.remoteVersions, manifest: this.publishedManifest }) } + private async loadRemoteManifest() { + if (this.publishedManifest) return + const remote = this.syncPath + '/' + webdavManifestName + let info: any + try { info = await this.client.stat(remote, { signal: AbortSignal.timeout(30_000) }) } + catch (error: any) { if (Number(error.status || error.response?.status) === 404) return; throw error } + if (Number(info.size) > 16 * 1024 * 1024) throw new Error('WebDAV 云端清单过大') + const dir = path.join(this.dataPath, 'runtime', 'webdav-manifests'); fs.mkdirSync(dir, { recursive: true }) + const temp = path.join(dir, crypto.randomUUID() + '.json') try { - const buffer = fs.readFileSync(filePath) - const hash = crypto.createHash('md5') - hash.update(buffer as any) - return hash.digest('hex') - } catch { - return '' - } + await downloadWebdavFile(this.client, remote, temp, Number(info.size), undefined, info.etag) + const versions = parseWebdavManifest(JSON.parse(fs.readFileSync(temp, 'utf8'))) + this.remoteVersions = { ...versions, ...this.remoteVersions } + } finally { fs.rmSync(temp, { force: true }) } + } + private async publishManifest() { + const value = JSON.stringify({ schema: 1, files: this.remoteVersions }) + const hash = crypto.createHash('sha256').update(value).digest('hex') + const remote = this.syncPath + '/' + webdavManifestName + if (hash === this.publishedManifest) { + try { await this.client.stat(remote, { signal: AbortSignal.timeout(30_000) }); return } + catch (error: any) { if (Number(error.status || error.response?.status) !== 404) throw error } + } + if (Buffer.byteLength(value) > 16 * 1024 * 1024) throw new Error('WebDAV 备份清单超过大小限制') + const dir = path.join(this.dataPath, 'runtime', 'webdav-manifests'); fs.mkdirSync(dir, { recursive: true }) + const temp = path.join(dir, crypto.randomUUID() + '.json') + try { + fs.writeFileSync(temp, value, { mode: 0o600 }); await this.ensureRemoteDir(this.syncPath) + await uploadWebdavFile(this.client, temp, remote, fs.statSync(temp).size) + this.publishedManifest = hash; this.saveState() + } finally { fs.rmSync(temp, { force: true }) } + } + private recoveryContext(): RecoveryContext { return { ...this.context(), client: this.client, types: this.selectedTypes(), syncPath: this.syncPath, backupPath: this.backupPath, identity: this.identity(true) } } + async previewRestore(options: WebdavRestoreOptions = {}) { + if (this.syncing || this.restoring) throw new Error('WebDAV 正在备份或恢复') + this.restoring = true + try { + if (!this.client) await this.initClient() + if (!this.client) throw new Error('WebDAV 客户端未初始化') + return await this.recovery.prepare(this.recoveryContext(), options) + } finally { this.restoring = false } + } + discardRestorePreview(token: string) { this.recovery.discard(token) } + async restorePlanned(token: string, startup = false): Promise { return this.performRestore({}, undefined, token, startup) } + private async performRestore(options: WebdavRestoreOptions = {}, localZip?: string, token?: string, startup = false): Promise { + if (this.restoring || this.syncing) throw new Error('WebDAV 正在备份或恢复,请稍后重试') + this.restoring = true + this.lastRestoreResult = undefined + let release: (() => void) | undefined + try { + release = beginCloudRestore() + if (global.lx.pauseForRestore) global.lx.pauseForRestore() + if (global.lx.pauseForRestore || startup) global.lx.restoreMaintenance = true + if (!localZip && !this.client) await this.initClient() + if (!localZip && !this.client) throw new Error('WebDAV 客户端未初始化') + const context = this.recoveryContext() + if (!token) token = (await this.recovery.prepare(context, options, localZip)).token + this.emit('progress', { type: 'restore', status: 'downloading', message: '正在暂存并校验所选文件,完成前不会覆盖本地数据' }) + const result = await this.recovery.execute(context, token) + this.lastRestoreResult = result + if (result.errors.length) throw new Error(result.errors.join(';')) + this.filesHash.clear(); this.backupHash.clear(); this.publishedManifest = ''; this.saveState() + this.emit('progress', { type: 'restore', status: 'finish', message: '恢复完成,已保留恢复前副本;请重启服务完整加载数据' }) + this.addLog({ timestamp: Date.now(), type: 'restore', file: options.targetFilename || 'selected data', status: 'success' }) + return true + } catch (error: any) { + this.lastRestoreResult = { ...(this.lastRestoreResult || {}), errors: [error.message] } + this.emit('progress', { type: 'restore', status: 'error', message: error.message }) + this.addLog({ timestamp: Date.now(), type: 'restore', file: 'selected data', status: 'error', message: error.message }) + return false + } finally { this.restoring = false; release?.() } } private context() { @@ -287,272 +386,81 @@ class WebDAVSync extends EventEmitter { } async uploadFile(relativePath: string): Promise { - if (isCloudRestoreRunning()) return false - if (this.shouldIgnore(relativePath)) return false + if (isCloudRestoreRunning() || this.shouldIgnore(relativePath)) return false if (!this.client) await this.initClient() if (!this.client) return false - - const localPath = this.localPath(relativePath) - if (!fs.existsSync(localPath)) return false - - let stat: fs.Stats - try { - stat = fs.statSync(localPath) - } catch { - return false - } - - const remotePath = `${this.syncPath}/${relativePath.replace(/\\/g, '/')}` - const remoteDir = path.posix.dirname(remotePath) - - // 确保远程父目录存在 - await this.ensureRemoteDir(remoteDir) - - // 重试机制:最多 3 次尝试,针对 423 Locked、405、网络波动进行智能退避 - const maxRetries = 3 - let lastError: any = null - - for (let attempt = 1; attempt <= maxRetries; attempt++) { + const local = this.localPath(relativePath), remote = this.syncPath + '/' + relativePath.replace(/\\/g, '/') + for (let attempt = 1; attempt <= 3; attempt++) { try { - // 使用流式上传并监控进度 - const readStream = fs.createReadStream(localPath) - const passThrough = new PassThrough() - let uploadedBytes = 0 - - passThrough.on('data', (chunk) => { - uploadedBytes += chunk.length - this.emit('progress', { - type: 'file', - status: 'uploading', - file: relativePath, - current: uploadedBytes, - total: stat.size - }) - }) - - readStream.pipe(passThrough) - await this.client.putFileContents(remotePath, passThrough, { overwrite: true }) - - this.emit('progress', { - type: 'file', - status: 'success', - file: relativePath, - current: stat.size, - total: stat.size - }) - - this.addLog({ - timestamp: Date.now(), - type: 'upload', - file: relativePath, - status: 'success', - }) + const version = backupFileVersion(local), hash = this.getFileHash(local), size = fs.statSync(local).size + if (!await this.ensureRemoteDir(path.posix.dirname(remote))) throw new Error('创建 WebDAV 目录失败') + await uploadWebdavFile(this.client, local, remote, size, current => this.emit('progress', { type: 'file', status: 'uploading', file: relativePath, current, total: size }), hash) + if (backupFileVersion(local) !== version) throw new Error('上传期间本地文件发生变化,未记录成功基线') + this.filesHash.set(relativePath, hash); this.remoteVersions[relativePath] = { size, hash }; this.saveState() + this.addLog({ timestamp: Date.now(), type: 'upload', file: relativePath, status: 'success' }) return true - } catch (err: any) { - lastError = err - const errMsg = String(err?.message || '') - const status = err?.status || err?.response?.status - - // 如果是 423 Locked,等待锁释放 - const isLocked = status === 423 || errMsg.includes('423') || errMsg.includes('Locked') - const isMethodNotAllowed = status === 405 || errMsg.includes('405') - - if (attempt < maxRetries) { - const delay = isLocked ? attempt * 1200 : attempt * 600 - console.warn(`[WebDAV] Upload ${relativePath} attempt ${attempt} failed (${errMsg}), retrying in ${delay}ms...`) - if (isMethodNotAllowed) { - // 尝试重新确保父目录 - this.ensuredDirs.delete(remoteDir) - await this.ensureRemoteDir(remoteDir) - } - await new Promise(r => setTimeout(r, delay)) - } + } catch (error: any) { + if (attempt < 3) { this.ensuredDirs.delete(path.posix.dirname(remote)); await new Promise(resolve => setTimeout(resolve, attempt * 600)); continue } + this.addLog({ timestamp: Date.now(), type: 'upload', file: relativePath, status: 'error', message: error.message }) } } - - console.error(`[WebDAV] Failed to upload file ${relativePath} after ${maxRetries} attempts:`, lastError?.message) - this.emit('progress', { - type: 'file', - status: 'error', - file: relativePath, - error: lastError?.message || 'Upload failed' - }) - this.addLog({ - timestamp: Date.now(), - type: 'upload', - file: relativePath, - status: 'error', - message: `重试${maxRetries}次失败: ${lastError?.message || '未知错误'}` - }) - // 优雅跳过,返回 false,不阻断整个批量流程 return false } async downloadFile(relativePath: string, rawRemotePath?: string): Promise { if (this.shouldIgnore(relativePath)) return false - if (!this.client) await this.initClient() - if (!this.client) return false - - const defaultRemotePath = `${this.syncPath}/${relativePath.replace(/\\/g, '/')}` - const primaryRemotePath = rawRemotePath || defaultRemotePath - const localPath = this.localPath(relativePath) - - // 确保本地目录存在 - const localDir = path.dirname(localPath) - if (!fs.existsSync(localDir)) { - fs.mkdirSync(localDir, { recursive: true }) - } - - const maxRetries = 3 - let lastError: any = null - - for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + if (!this.client) await this.initClient() + if (!this.client) return false + const remote = rawRemotePath || this.syncPath + '/' + relativePath + if (this.getRelativeRemotePath(remote) !== relativePath) throw new Error('云端路径不匹配') + const info = await this.client.stat(remote, { signal: AbortSignal.timeout(30_000) }) + const local = this.localPath(relativePath), baseline = backupFileVersion(local), temporary = local + '.' + crypto.randomUUID() + '.tmp' try { - let content: any = null - try { - content = await this.client.getFileContents(primaryRemotePath) as any - } catch (firstErr: any) { - // 如果因为 URL 编码差异导致 404,尝试自动回退 (如原始路径 vs decodeURIComponent vs encodeURI) - const status = firstErr?.status || firstErr?.response?.status - if (status === 404 || String(firstErr?.message).includes('404')) { - const candidates = new Set() - candidates.add(defaultRemotePath) - try { candidates.add(decodeURIComponent(primaryRemotePath)) } catch { } - try { candidates.add(encodeURI(decodeURI(primaryRemotePath))) } catch { } - - let recovered = false - for (const altPath of candidates) { - if (altPath === primaryRemotePath) continue - try { - content = await this.client.getFileContents(altPath) as any - recovered = true - break - } catch { } - } - if (!recovered) throw firstErr - } else { - throw firstErr - } - } - - // 对比内容哈希,如果一致则跳过写入,避免触发文件系统监控 - if (fs.existsSync(localPath)) { - const localContent = fs.readFileSync(localPath) as any - const localHash = crypto.createHash('md5').update(localContent).digest('hex') - const remoteHash = crypto.createHash('md5').update(content).digest('hex') - - if (localHash === remoteHash) { - return true - } - } - - const temporary = localPath + '.' + crypto.randomUUID() + '.tmp' - try { fs.writeFileSync(temporary, content); fs.renameSync(temporary, localPath) } - finally { fs.rmSync(temporary, { force: true }) } - - if (relativePath === 'config.js') { - console.log('config.js restored from WebDAV, content updated in data directory.') - } - - this.addLog({ - timestamp: Date.now(), - type: 'download', - file: relativePath, - status: 'success', - }) - return true - } catch (err: any) { - lastError = err - if (attempt < maxRetries) { - await new Promise(r => setTimeout(r, attempt * 600)) - } - } - } - - console.error(`[WebDAV] Failed to download file ${relativePath} after ${maxRetries} attempts:`, lastError?.message) - this.addLog({ - timestamp: Date.now(), - type: 'download', - file: relativePath, - status: 'error', - message: `下载失败: ${lastError?.message || '未知错误'}`, - }) - return false + await downloadWebdavFile(this.client, remote, temporary, Number(info.size), undefined, info.etag) + if (backupFileVersion(local) !== baseline) throw new Error('下载期间本地文件已变化') + fs.renameSync(temporary, local) + } finally { fs.rmSync(temporary, { force: true }) } + return true + } catch (error: any) { this.addLog({ timestamp: Date.now(), type: 'download', file: relativePath, status: 'error', message: error.message }); return false } } async createBackup(): Promise { + if (isCloudRestoreRunning()) return null + const releaseBackup = beginCloudBackup() + const id = crypto.randomUUID(), zipName = 'lx-sync-backup-' + new Date().toISOString().replace(/[:.]/g, '-') + '-' + id.slice(0, 8) + '.zip' + const zipPath = path.join(this.dataPath, zipName), stage = path.join(this.dataPath, 'runtime', 'webdav-archives', id) try { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-') - const zipName = `lx-sync-backup-${timestamp}.zip` - const zipPath = path.join(this.dataPath, zipName) - - // 预扫描待备份的文件总数,以便提供精确的打包进度条 - const allFiles = await this.scanFiles() - if (!allFiles.size) { - this.emit('progress', { type: 'backup', status: 'finish', message: '所选类型没有可备份的文件' }) - return null + const files = await this.scanFiles() + if (!files.size) return null + const manifest: Record = {} + fs.mkdirSync(stage, { recursive: true }) + for (const [relative, hash] of files) { + const local = this.localPath(relative), version = backupFileVersion(local), target = path.join(stage, relative) + fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(local, target) + if (backupFileVersion(local) !== version || hashBackupFile(target) !== hash) throw new Error('创建快照期间文件已变化:' + relative) + manifest[relative] = { hash, size: fs.statSync(target).size } } - const totalFiles = Math.max(allFiles.size, 1) - - this.emit('progress', { - type: 'backup', - status: 'packing', - message: `正在准备打包,共 ${totalFiles} 个文件...`, - current: 0, - total: totalFiles, - percent: 2 - }) - - await new Promise((resolve, reject) => { - const output = fs.createWriteStream(zipPath) - // 压缩级别调优:对于音乐、封面及数据库等混合文件,level: 4 或 5 可在保留极佳压缩比的同时将打包装箱耗时提升 3~4 倍 - const archive = new ZipArchive({ zlib: { level: 4 } }) - - let fileCount = 0 - - // 监听文件添加事件 - archive.on('entry', (entry: any) => { - fileCount++ - const packingPercent = Math.min(48, Math.round(5 + (fileCount / totalFiles) * 43)) - this.emit('progress', { - type: 'backup', - status: 'packing', - message: `正在打包: ${entry.name}`, - current: fileCount, - total: totalFiles, - percent: packingPercent, - file: entry.name - }) - }) - - output.on('close', () => resolve()) - archive.on('error', (err: Error) => reject(err)) - - archive.pipe(output) - for (const relative of allFiles.keys()) archive.file(this.localPath(relative), { name: relative.replace(/\\/g, '/') }) - - archive.finalize() - }) - - this.emit('progress', { - type: 'backup', - status: 'packed', - message: 'ZIP 压缩包创建完成,准备上传云端...', - percent: 50 - }) - + const archive = new ZipArchive({ zlib: { level: 4 } }) + let current = 0 + archive.on('entry', (entry: any) => this.emit('progress', { type: 'backup', status: 'packing', current: ++current, total: files.size + 1, file: entry.name })) + const output = pipeline(archive, fs.createWriteStream(zipPath)) + for (const relative of files.keys()) archive.file(path.join(stage, relative), { name: relative.replace(/\\/g, '/') }) + archive.append(JSON.stringify({ schema: 1, files: manifest }), { name: webdavManifestName }) + await Promise.all([archive.finalize(), output]) + this.lastArchiveSnapshot = files return zipName - } catch (err) { - console.error('Failed to create backup:', err) - return null - } + } catch (error: any) { + fs.rmSync(zipPath, { force: true }); this.emit('progress', { type: 'backup', status: 'error', message: error.message }); return null + } finally { fs.rmSync(stage, { recursive: true, force: true }); releaseBackup() } } async uploadBackup(force = false): Promise { if (this.syncing || this.restoring || isCloudRestoreRunning()) return false + const releaseBackup = beginCloudBackup() this.syncing = true if (!this.client) await this.initClient() - if (!this.client) { this.syncing = false; return false } + if (!this.client) { this.syncing = false; releaseBackup(); return false } let zipPath: string | null = null try { @@ -560,7 +468,7 @@ class WebDAVSync extends EventEmitter { if (!force) { const current = await this.scanFiles() const unchanged = current.size === this.backupHash.size && [...current].every(([file, hash]) => this.backupHash.get(file) === hash) - if (unchanged) { + if (unchanged && (await this.getBackupList()).length > 0) { console.log('No changes detected, skipping backup') return true } @@ -568,7 +476,6 @@ class WebDAVSync extends EventEmitter { this.emit('progress', { type: 'backup', status: 'preparing', message: '正在创建备份...' }) - const backupSnapshot = await this.scanFiles() const zipName = await this.createBackup() if (!zipName) return false @@ -577,35 +484,8 @@ class WebDAVSync extends EventEmitter { const remotePath = `${this.backupPath}/${zipName}` // 高性能流式上传:增大缓冲区至 1MB (1024 * 1024),减少操作系统 I/O 切换与背压调度 - const readStream = fs.createReadStream(zipPath, { highWaterMark: 1024 * 1024 }) - const passThrough = new PassThrough({ highWaterMark: 1024 * 1024 }) - let uploadedBytes = 0 - - // 节流控制,避免发送过多 SSE 消息 - let lastProgressTime = 0 - - passThrough.on('data', (chunk) => { - uploadedBytes += chunk.length - const now = Date.now() - if (now - lastProgressTime > 100 || uploadedBytes === stat.size) { // 至少间隔100ms - this.emit('progress', { - type: 'backup', - status: 'uploading', - file: zipName, - total: stat.size, - current: uploadedBytes - }) - lastProgressTime = now - } - }) - - // 确保远程备份目录存在 - const remoteDir = path.posix.dirname(remotePath) - await this.ensureRemoteDir(remoteDir) - - readStream.pipe(passThrough) - - await this.client.putFileContents(remotePath, passThrough, { overwrite: true }) + await this.ensureRemoteDir(path.posix.dirname(remotePath)) + await uploadWebdavFile(this.client, zipPath, remotePath, stat.size, current => this.emit('progress', { type: 'backup', status: 'uploading', file: zipName, current, total: stat.size })) this.emit('progress', { type: 'backup', @@ -622,9 +502,10 @@ class WebDAVSync extends EventEmitter { status: 'success', }) - this.backupHash = backupSnapshot + this.backupHash = new Map(this.lastArchiveSnapshot) + this.saveState() - // 清理旧备份(保留最近5个) + // 成功上传后按配置清理历史 ZIP,始终保留最新一份 try { await this.cleanOldBackups() } catch (e) { @@ -644,6 +525,7 @@ class WebDAVSync extends EventEmitter { return false } finally { this.syncing = false + releaseBackup() // 确保无论上传成功还是失败,总是及时清理本地临时 zip 文件 if (zipPath && fs.existsSync(zipPath)) { try { @@ -657,10 +539,12 @@ class WebDAVSync extends EventEmitter { async syncAllFiles(): Promise { if (this.syncing || this.restoring || isCloudRestoreRunning()) return false + const releaseBackup = beginCloudBackup() this.syncing = true if (!this.client) await this.initClient() if (!this.client) { this.syncing = false + releaseBackup() this.emit('progress', { type: 'sync', status: 'error', message: 'WebDAV 客户端未初始化' }) return false } @@ -670,6 +554,7 @@ class WebDAVSync extends EventEmitter { let failCount = 0 try { + await this.loadRemoteManifest() const files = await this.scanFiles() const fileList = Array.from(files.keys()) let count = 0 @@ -686,7 +571,7 @@ class WebDAVSync extends EventEmitter { await this.runConcurrent(fileList, 4, async (file) => { const ok = await this.uploadFile(file) count++ - if (ok) { successCount++; this.filesHash.set(file, files.get(file)!) } + if (ok) successCount++ else failCount++ this.emit('progress', { @@ -698,6 +583,7 @@ class WebDAVSync extends EventEmitter { }) }) + await this.publishManifest() if (failCount > 0) { console.warn(`[WebDAV] Sync all files finished: ${successCount} succeeded, ${failCount} skipped/failed.`) } else { @@ -710,6 +596,7 @@ class WebDAVSync extends EventEmitter { return false } finally { this.syncing = false + releaseBackup() // 无论如何,在最后必须发射 finish 或 error,保证前端进度条绝对不悬挂! this.emit('progress', { type: 'sync', @@ -729,12 +616,14 @@ class WebDAVSync extends EventEmitter { } const match = item.basename?.match(/lx-sync-backup-(.+?)\.zip$/) if (match && match[1]) { - const parts = match[1].split('T') + const parts = match[1].replace(/-[a-f0-9]{8}$/, '').split('T') if (parts.length === 2) { const timeStr = parts[1].replace(/(\d{2})-(\d{2})-(\d{2})-(\d{3}Z)/, '$1:$2:$3.$4') const parsed = Date.parse(`${parts[0]}T${timeStr}`) if (!isNaN(parsed)) return parsed } + const parsed = Date.parse(match[1]) + if (Number.isFinite(parsed)) return parsed } return 0 } @@ -745,12 +634,12 @@ class WebDAVSync extends EventEmitter { try { const items = await this.client.getDirectoryContents(`${this.backupPath}/`) const backups = items - .filter((item: any) => item.basename.startsWith('lx-sync-backup-')) - .sort((a: any, b: any) => this.parseBackupTime(b) - this.parseBackupTime(a)) + .filter((item: any) => item.type === 'file' && /^lx-sync-backup-[a-zA-Z0-9T.-]+\.zip$/.test(item.basename)) + .sort((a: any, b: any) => this.parseBackupTime(b) - this.parseBackupTime(a) || b.basename.localeCompare(a.basename)) - // 删除第6个及以后的备份 - for (let i = 5; i < backups.length; i++) { - await this.client.deleteFile(backups[i].filename) + const count = this.config.retentionCount || 5, days = this.config.retentionDays || 0 + for (let i = 1; i < backups.length; i++) { + if (i >= count || (days > 0 && this.parseBackupTime(backups[i]) > 0 && this.parseBackupTime(backups[i]) < Date.now() - days * 86400000)) await this.client.deleteFile(checkedBackupPath(this.backupPath, backups[i].filename)) } } catch (err) { console.error('Failed to clean old backups:', err) @@ -764,8 +653,8 @@ class WebDAVSync extends EventEmitter { try { const items = await this.client.getDirectoryContents(`${this.backupPath}/`) const backups = items - .filter((item: any) => item.basename && item.basename.startsWith('lx-sync-backup-')) - .sort((a: any, b: any) => this.parseBackupTime(b) - this.parseBackupTime(a)) + .filter((item: any) => item.type === 'file' && /^lx-sync-backup-[a-zA-Z0-9T.-]+\.zip$/.test(item.basename)) + .sort((a: any, b: any) => this.parseBackupTime(b) - this.parseBackupTime(a) || b.basename.localeCompare(a.basename)) return backups.map((item: any) => { const time = this.parseBackupTime(item) @@ -785,8 +674,8 @@ class WebDAVSync extends EventEmitter { } }) } catch (err: any) { - console.error('[WebDAV] Failed to get backup list:', err.message) - return [] + if (Number(err.status || err.response?.status) === 404) return [] + throw err } } @@ -795,7 +684,8 @@ class WebDAVSync extends EventEmitter { if (!this.client) return false try { - const remotePath = targetFilename.startsWith('/') ? targetFilename : `${this.backupPath}/${targetFilename}` + if (this.syncing || this.restoring || isCloudRestoreRunning()) throw new Error('正在备份或恢复,请稍后删除') + const remotePath = checkedBackupPath(this.backupPath, targetFilename) await this.client.deleteFile(remotePath) const basename = path.basename(remotePath) this.addLog({ @@ -819,180 +709,38 @@ class WebDAVSync extends EventEmitter { } } - async restoreBackupFile(targetFilename?: string): Promise { - if (this.restoring || this.syncing) throw new Error('WebDAV 正在备份或恢复,请稍后重试') - this.restoring = true - if (!this.client) await this.initClient() - if (!this.client) { this.restoring = false; return false } - - let releaseRestore: (() => void) | undefined - try { - releaseRestore = beginCloudRestore() - this.emit('progress', { type: 'restore', status: 'start', message: '正在检索备份数据...' }) - - let targetItem: any = null - if (targetFilename) { - // 用户指定了恢复的文件名或路径 - const basename = path.basename(targetFilename) - targetItem = { - filename: targetFilename.startsWith('/') ? targetFilename : `${this.backupPath}/${targetFilename}`, - basename - } - } else { - const items = await this.client.getDirectoryContents(`${this.backupPath}/`) - const backups = items - .filter((item: any) => item.basename.startsWith('lx-sync-backup-')) - .sort((a: any, b: any) => this.parseBackupTime(b) - this.parseBackupTime(a)) - - if (backups.length === 0) { - this.emit('progress', { type: 'restore', status: 'error', message: '云端未找到任何全量备份文件' }) - return false - } - targetItem = backups[0] - } - - this.emit('progress', { - type: 'restore', - status: 'downloading', - message: `正在下载备份: ${targetItem.basename}` - }) - - const content = await this.client.getFileContents(targetItem.filename) - const zipPath = path.join(this.dataPath, 'temp-restore.zip') - - fs.writeFileSync(zipPath, content as any) - - this.emit('progress', { - type: 'restore', - status: 'extracting', - message: '正在解压备份文件...' - }) - - const restored = await this.extractZip(zipPath, this.dataPath) - if (!restored) throw new Error('ZIP 中没有当前所选类型的文件') - fs.unlinkSync(zipPath) - - this.emit('progress', { type: 'restore', status: 'finish', message: '全量备份已成功恢复并还原!' }) - - this.addLog({ - timestamp: Date.now(), - type: 'restore', - file: targetItem.basename, - status: 'success', - }) - - return true - } catch (err: any) { - this.emit('progress', { type: 'restore', status: 'error', message: `备份恢复失败: ${err.message}` }) - this.addLog({ - timestamp: Date.now(), - type: 'restore', - file: targetFilename || 'backup', - status: 'error', - message: err.message, - }) - return false - } finally { - this.restoring = false - releaseRestore?.() - fs.rmSync(path.join(this.dataPath, 'temp-restore.zip'), { force: true }) - } - } - - async downloadLatestBackup(): Promise { - return this.restoreBackupFile() - } - - async restoreScatteredFiles(): Promise { - if (this.restoring || this.syncing) throw new Error('WebDAV 正在备份或恢复,请稍后重试') - this.restoring = true - let releaseRestore: (() => void) | undefined - try { - releaseRestore = beginCloudRestore() - if (!this.client) await this.initClient() - if (!this.client) return false - const items = await this.client.getDirectoryContents(this.syncPath + '/', { deep: true }) - const files = items.filter((item: any) => item.type === 'file').map((file: any) => ({ file, relative: this.getRelativeRemotePath(file.filename) })).filter((item: any) => !this.shouldIgnore(item.relative)) - if (!files.length) return false - let count = 0 - for (const { file, relative } of files) { - if (!await this.downloadFile(relative, file.filename)) throw new Error('恢复失败:' + relative) - this.filesHash.set(relative, this.getFileHash(this.localPath(relative))) - this.emit('progress', { type: 'restore', status: 'processing', current: ++count, total: files.length, file: relative }) - } - this.emit('progress', { type: 'restore', status: 'finish', total: files.length, message: '已恢复所选类型;未删除本地其他文件,也未上传文件。重启服务后完整加载恢复的数据。' }) - return true - } catch (err: any) { - this.emit('progress', { type: 'restore', status: 'error', message: err.message }) - return false - } finally { this.restoring = false; releaseRestore?.() } - } - - public async restoreLocalBackup(zipPath: string): Promise { - if (this.restoring || this.syncing) throw new Error('WebDAV 正在备份或恢复,请稍后重试') - const releaseRestore = beginCloudRestore() - this.restoring = true - try { - const count = await this.extractZip(zipPath, this.dataPath) - if (!count) throw new Error('ZIP 中没有当前所选类型的文件') - return count - } finally { this.restoring = false; releaseRestore() } - } - + async restoreBackupFile(targetFilename?: string): Promise { return this.performRestore({ mode: 'zip', targetFilename }) } + async downloadLatestBackup(): Promise { return this.restoreBackupFile() } + async restoreScatteredFiles(): Promise { return this.performRestore({ mode: 'files' }) } + public async restoreLocalBackup(zipPath: string): Promise { return this.extractZip(zipPath, this.dataPath) } public async extractZip(zipPath: string, _targetPath: string): Promise { - const archive = await Open.file(zipPath) - // 解压前检查全部条目,拒绝越界路径和符号链接。 - const selected = archive.files.filter(entry => entry.type === 'File' && !this.shouldIgnore(entry.path)) - const targets = selected.map(entry => { - const mode = ((entry as any).externalFileAttributes >>> 16) & 0xf000 - if (mode === 0xa000) throw new Error('备份中不允许符号链接') - return { entry, target: this.localPath(entry.path) } - }) - for (const { entry, target } of targets) { - fs.mkdirSync(path.dirname(target), { recursive: true }) - const temporary = target + '.' + crypto.randomUUID() + '.tmp' - try { - await pipeline(entry.stream(), fs.createWriteStream(temporary)) - if (fs.statSync(temporary).size !== entry.uncompressedSize) throw new Error('备份解压文件大小不一致:' + entry.path) - fs.renameSync(temporary, target) - } finally { fs.rmSync(temporary, { force: true }) } - } - return targets.length + if (!await this.performRestore({ mode: 'zip' }, zipPath)) throw new Error(this.lastRestoreResult?.errors?.join(';') || '恢复失败') + return this.lastRestoreResult.restored } async syncChangedFiles(): Promise { if (this.syncing || this.restoring || isCloudRestoreRunning()) return + const releaseBackup = beginCloudBackup() this.syncing = true try { + if (!this.client) await this.initClient() + if (!this.client) throw new Error('WebDAV 客户端未初始化') + await this.loadRemoteManifest() const { changed, deleted } = await this.getChangedFiles() await this.runConcurrent(changed, 5, async file => { - const hash = this.getFileHash(this.localPath(file)) - if (await this.uploadFile(file)) this.filesHash.set(file, hash) + await this.uploadFile(file) }) for (const file of deleted) { if (this.shouldIgnore(file)) { this.filesHash.delete(file); continue } - if (!this.config.mirrorDeletes || await this.deleteRemoteFile(file)) this.filesHash.delete(file) + if (!this.config.mirrorDeletes || await this.deleteRemoteFile(file)) { this.filesHash.delete(file); if (this.config.mirrorDeletes) delete this.remoteVersions[file] } } - } finally { this.syncing = false } + this.saveState(); await this.publishManifest() + } finally { this.syncing = false; releaseBackup() } } - async restoreFromRemote(options?: { mode?: 'auto' | 'zip' | 'files'; targetFilename?: string }): Promise { - const mode = options?.mode || 'auto' - if (mode === 'zip') return this.restoreBackupFile(options?.targetFilename) - if (mode === 'files') return this.restoreScatteredFiles() - // 自动恢复只在散文件目录确实不存在/为空时使用 ZIP;权限和网络错误不能触发另一份旧备份覆盖。 - if (!this.client) await this.initClient() - if (!this.client) return false - try { - const items = await this.client.getDirectoryContents(this.syncPath + '/', { deep: true }) - if (items.some((item: any) => item.type === 'file' && !this.shouldIgnore(this.getRelativeRemotePath(item.filename)))) return this.restoreScatteredFiles() - } catch (err: any) { - if ((err.status || err.response?.status) !== 404) return false - } - return this.restoreBackupFile(options?.targetFilename) - } + async restoreFromRemote(options: WebdavRestoreOptions & { startup?: boolean } = {}): Promise { return this.performRestore(options, undefined, undefined, options.startup === true) } - async testConnection(): Promise<{ success: boolean; message: string }> { + async testConnection(): Promise<{ success: boolean; message: string; checks?: Array<{ name: string; path: string; success: boolean; message: string }> }> { // 检查配置是否完整 if (!this.config.enable) { return { @@ -1013,17 +761,42 @@ class WebDAVSync extends EventEmitter { return { success: false, message: 'WebDAV客户端初始化失败,请检查配置是否正确' }; } - // 增加 10 秒超时控制,避免请求无限挂起 - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('连接超时,请检查 WebDAV 地址及网络连接')), 10000) - ) - - await Promise.race([ - this.client.getDirectoryContents('/'), - timeoutPromise - ]) - - return { success: true, message: '连接成功!WebDAV配置正确' }; + // 根目录列举可能失败,而实际挂载的子目录仍可用。仅检查配置目录,不写入测试文件。 + const checks = await Promise.all([ + { name: '同步目录', path: this.syncPath || '/' }, + { name: '备份目录', path: this.backupPath || '/' }, + ].map(async target => { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 10000) + try { + await this.client.getDirectoryContents(target.path, { deep: false, signal: controller.signal }) + return { ...target, success: true, message: '目录可读取' } + } catch (err: any) { + const status = Number(err?.status || err?.response?.status) + const detail = String(err?.code || err?.cause?.code || '') + ' ' + String(err?.message || '') + let message = '目录读取失败,请检查 WebDAV 地址、服务端日志及网络连接' + if (status === 401) message = '认证失败,请检查已保存的用户名、密码或应用专用密码' + else if (status === 403) message = '没有列目录权限,请检查账号对该目录的访问权限' + else if (status === 404) message = '目录不存在或 URL 路径不正确;首次上传可能会自动创建目录' + else if (status === 405 || status === 501) message = '服务不支持列目录 (PROPFIND),请检查 WebDAV 地址及代理的方法限制' + else if ([502, 503, 504].includes(status)) message = '网关或上游服务异常,请检查反向代理和存储挂载;列目录失败不代表上传一定失败' + else if (/timeout|timed out|aborted/i.test(detail)) message = '请求超时(每个目录最多等待 10 秒),请检查网络、代理和存储响应速度' + else if (/ENOTFOUND|EAI_AGAIN/.test(detail)) message = '域名解析失败,请检查服务器地址及服务器的 DNS' + else if (/ECONNREFUSED|ECONNRESET/.test(detail)) message = '连接被拒绝或中断,请检查端口、防火墙及服务状态' + else if (/CERT|TLS|SSL|SELF_SIGNED/i.test(detail)) message = 'TLS 证书校验失败,请检查证书有效期、域名和证书链' + // 不回显可能包含凭据、URL 或上游响应正文的原始错误。 + return { ...target, success: false, message: `${Number.isFinite(status) && status > 0 ? `HTTP ${status}:` : ''}${message}` } + } finally { + clearTimeout(timer) + } + })) + const passed = checks.filter(check => check.success).length + const summary = passed === checks.length ? '配置目录均可读取。' : passed ? '部分目录检查未通过。' : '配置目录读取检查未通过。' + return { + success: passed === checks.length, + message: `${summary}本测试使用已保存配置,仅检查目录读取,不会创建或删除远端文件。上传、删除权限及完整恢复能力尚未验证;如已能正常备份,请结合各目录结果排查。`, + checks, + } } catch (err: any) { let errorMsg = '连接失败'; if (err.message) { @@ -1042,6 +815,7 @@ class WebDAVSync extends EventEmitter { } startAutoSync() { + if (isCloudRestoreRunning()) return if (!this.isConfigured()) return // 避免重复启动定时器 @@ -1052,17 +826,23 @@ class WebDAVSync extends EventEmitter { // 保留成功上传的基线;首次检查需要上传尚未备份的本地文件。 // 按配置的时间间隔检查增量文件变化 + this.nextSyncAt = Date.now() + this.syncInterval + this.nextZipAt = Date.now() + this.backupInterval this.watchTimer = setInterval(() => { - void this.syncChangedFiles() + this.nextSyncAt = Date.now() + this.syncInterval + void this.syncChangedFiles().catch(error => console.error('[WebDAV] 增量备份失败:', error.message)) }, this.syncInterval) // 每24小时创建备份(如果有变化) this.backupTimer = setInterval(() => { - void this.uploadBackup() + this.nextZipAt = Date.now() + this.backupInterval + void this.uploadBackup().catch(error => console.error('[WebDAV] ZIP 备份失败:', error.message)) }, this.backupInterval) } stopAutoSync() { + this.nextSyncAt = 0 + this.nextZipAt = 0 if (this.watchTimer) { clearInterval(this.watchTimer) this.watchTimer = null @@ -1075,7 +855,10 @@ class WebDAVSync extends EventEmitter { } updateConfig(config: Partial) { + if (this.syncing || this.restoring) throw new Error('WebDAV 正在备份或恢复,不能切换配置') let changed = false + if (config.retentionCount !== undefined) this.config.retentionCount = Math.max(1, Math.min(1000, Math.floor(config.retentionCount || 5))) + if (config.retentionDays !== undefined) this.config.retentionDays = Math.max(0, Math.min(3650, Math.floor(config.retentionDays || 0))) if (config.types !== undefined && JSON.stringify(config.types) !== JSON.stringify(this.config.types)) { this.config.types = normalizeBackupTypes(config.types) changed = true @@ -1131,8 +914,7 @@ class WebDAVSync extends EventEmitter { } if (changed) { - this.filesHash.clear() - this.backupHash.clear() + this.loadState() this.client = null this.ensuredDirs.clear() this.stopAutoSync() diff --git a/src/utils/webdavTransfer.ts b/src/utils/webdavTransfer.ts new file mode 100644 index 00000000..20562fc2 --- /dev/null +++ b/src/utils/webdavTransfer.ts @@ -0,0 +1,47 @@ +import fs from 'node:fs' +import path from 'node:path' +import crypto from 'node:crypto' +import { Transform } from 'node:stream' +import { pipeline } from 'node:stream/promises' +import { hashBackupFile } from './backupFiles' + +export const missingWebdavFile = (error: any) => Number(error?.status || error?.response?.status) === 404 +export async function downloadWebdavFile(client: any, remote: string, target: string, size: number, signal?: AbortSignal, etag?: string) { + if (!Number.isSafeInteger(size) || size < 0) throw new Error('云端文件大小无效:' + remote) + const timeout = AbortSignal.timeout(300_000) + const combined = signal ? AbortSignal.any([signal, timeout]) : timeout + let received = 0 + fs.mkdirSync(path.dirname(target), { recursive: true }) + const stream = client.createReadStream(remote, { signal: combined, headers: etag ? { 'If-Match': etag } : {} }) + try { + await pipeline(stream, new Transform({ transform(chunk, _encoding, next) { + received += chunk.length + if (received > size) next(new Error('云端文件超过声明大小:' + remote)) + else next(null, chunk) + } }), fs.createWriteStream(target, { mode: 0o600 }), { signal: combined }) + if (received !== size) throw new Error('云端文件下载不完整:' + remote) + } catch (error) { fs.rmSync(target, { force: true }); throw error } + finally { stream.destroy() } +} + +/** PUT a temporary sibling then MOVE, so a failed upload cannot replace a valid backup. */ +export async function uploadWebdavFile(client: any, local: string, remote: string, size: number, onBytes?: (n: number) => void, expectedHash = hashBackupFile(local)) { + const temporary = remote + '.' + crypto.randomUUID() + '.part' + const signal = AbortSignal.timeout(300_000) + const input = fs.createReadStream(local) + const hash = crypto.createHash('sha256'); let uploaded = 0 + const stream = new Transform({ transform(chunk, _encoding, next) { hash.update(chunk); uploaded += chunk.length; onBytes?.(uploaded); next(null, chunk) } }) + input.on('error', error => stream.destroy(error)) + input.pipe(stream) + try { + if (await client.putFileContents(temporary, stream, { overwrite: true, signal }) === false) throw new Error('WebDAV 上传被拒绝') + if (uploaded !== size || hash.digest('hex') !== expectedHash) throw new Error('上传期间文件变化,未替换云端原副本') + const info = await client.stat(temporary, { signal }) + if (Number(info.size) !== size) throw new Error('WebDAV 上传后大小不一致') + await client.moveFile(temporary, remote, { overwrite: true, signal }) + } finally { + stream.destroy() + input.destroy() + try { await client.deleteFile(temporary, { signal: AbortSignal.timeout(10_000) }) } catch { /* MOVE already removed it, or cleanup will be retried later. */ } + } +} diff --git a/test/admin-auth-refresh.test.cjs b/test/admin-auth-refresh.test.cjs new file mode 100644 index 00000000..4f26cce3 --- /dev/null +++ b/test/admin-auth-refresh.test.cjs @@ -0,0 +1,66 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const source = fs.readFileSync('public/app.js', 'utf8'); + +function harness() { + const nodes = new Map(); + function node() { + const classes = new Set(); + return { classList: { add: v => classes.add(v), remove: v => classes.delete(v), contains: v => classes.has(v) }, style: { removeProperty() {} }, querySelector: () => ({}), classes }; + } + for (const id of ['app','login-overlay','admin-login-card','login-btn','login-btn-text']) nodes.set(id,node()); + nodes.get('login-overlay').classList.add('hidden'); + const pending = [], cleared = [], storage = new Map([['lx_auth','expired']]); + const context = vm.createContext({ API_BASE: '', document: { getElementById: id => nodes.get(id) }, localStorage: { removeItem: key => storage.delete(key) }, clearInterval: id => cleared.push(id), fetch: () => new Promise(resolve => pending.push(resolve)), location: { reload() { throw Error('Unexpected page reload'); } } }); + const logout = source.slice(source.indexOf(' logout() {'), source.indexOf(' showApp() {')); + const request = source.slice(source.indexOf(' async request('), source.indexOf(' formatUptime(')); + vm.runInContext(`class Harness { ${logout} ${request} }; globalThis.app = new Harness()`,context); + const app = context.app; + app.password = 'expired'; + app.monitorTimer = 11; + app.logsRefreshTimer = 12; + app.initLoginScreenEffects = () => { app._loginEffectsInited = true; }; + let logouts = 0; + const logoutImpl = app.logout.bind(app); + app.logout = () => { logouts++; logoutImpl(); }; + return { app, pending, nodes, cleared, storage, logouts: () => logouts }; +} + +test('concurrent unauthorized requests return to login once without refreshing and stop polling', async () => { + const h = harness(); + const requests = [h.app.request('/api/status'),h.app.request('/api/config')]; + const settled = Promise.allSettled(requests); + h.pending.forEach(resolve => resolve({status:401})); + const results = await settled; + assert.ok(results.every(r => r.status === 'rejected')); + assert.equal(h.logouts(),1); + assert.equal(h.app.password,null); + assert.equal(h.storage.has('lx_auth'),false); + assert.ok(h.nodes.get('app').classes.has('hidden')); + assert.ok(!h.nodes.get('login-overlay').classes.has('hidden')); + assert.equal(h.nodes.get('login-btn').disabled,false); + assert.ok(h.cleared.includes(11) && h.cleared.includes(12)); + await assert.rejects(h.app.request('/api/status'),/Unauthorized/); + assert.equal(h.pending.length,2); +}); + +test('wrong login password reports failure without logging out or refreshing', async () => { + const h = harness(); + h.app.password = null; + const request = h.app.request('/api/login',{method:'POST'}); + h.pending[0]({status:401}); + assert.equal((await request).success,false); + assert.equal(h.logouts(),0); +}); + +test('late unauthorized response from an older session cannot sign out the new session', async () => { + const h = harness(); + const request = h.app.request('/api/status'); + h.app.password = 'new-session'; + h.pending[0]({status:401}); + await assert.rejects(request,/Unauthorized/); + assert.equal(h.logouts(),0); + assert.equal(h.app.password,'new-session'); +}); diff --git a/test/admin-dislike-view.test.cjs b/test/admin-dislike-view.test.cjs new file mode 100644 index 00000000..5fe0a2fe --- /dev/null +++ b/test/admin-dislike-view.test.cjs @@ -0,0 +1,61 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const source = fs.readFileSync('public/app.js','utf8'); +function harness(data) { + const start=source.indexOf(' dislikeOriginLabel('),end=source.indexOf(' getPlaylistsSectionHtml(',start); + assert.ok(start>=0&&end>start); + const context=vm.createContext({}); + vm.runInContext(`class Harness { escapeHtml(s) { return String(s).replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",''') } getSourceLabel(s) { return s } ${source.slice(start,end)} }; globalThis.instance=new Harness()`,context); + const app=context.instance;app.currentUserData={username:'test',data};return app; +} +const fixture={dislikeList:[{name:'',singer:'artist',source:'tx',id:'tx_1',songmid:'1',img:'javascript:alert(1)',dislikeRule:'track@artist',provenance:{channel:'web',at:1}},{name:'',singer:'',source:'wy',dislikeRule:'!album@artist',provenance:{channel:'subsonic-rating',at:2}}],dislikeArtists:[{name:'Artist',source:'tx',picUrl:'https://example.com/cover.jpg'}],userRatings:{tx_1:5},ratingOrigins:{tx_1:{channel:'subsonic',at:3}}}; +test('管理查看合并三类记录,准确区分添加来源与平台,保留原始字段',()=>{ + const app=harness(fixture);const rows=app.getDislikeEntries(fixture); + assert.deepEqual(Array.from(rows,x=>x.kind),['song','album','artist']);assert.equal(rows[0].cover,'');assert.equal(rows[1].title,'album');assert.equal(rows[2].channel,'unknown'); + const html=app.getDislikeSectionHtml(fixture);assert.match(html,/Web 添加/);assert.match(html,/Subsonic 低分联动/);assert.match(html,/来源未记录/);assert.match(html,/查看详情/);assert.ok([...app._adminDetails.values()].every(m=>m.raw&&m.sections.length));assert.ok(!html.includes('
    '));assert.match(html,/aria-label="5 星"/);assert.ok(!html.includes('',value:'tx',selected:true}],closest:()=>host};const wrapper=make();const trigger={getBoundingClientRect:()=>({left:120,top:150,bottom:190,width:140})};context.window.CustomSelectManager.open(select,wrapper,trigger);return{body,manager:context.window.CustomSelectManager,select};
    +}
    +test('统一下拉菜单挂载到所属dialog,坐标换算为弹窗内且文本不注入HTML',()=>{
    + const host={children:[],appendChild(x){this.children.push(x)},getBoundingClientRect:()=>({left:100,top:50,bottom:650}),scrollLeft:0,scrollTop:10,clientLeft:1,clientTop:1};const{body}=setup(host);assert.equal(body.children.length,0);assert.equal(host.children.length,1);const dropdown=host.children[0];assert.equal(dropdown.style.left,'19px');assert.equal(dropdown.style.top,'153px');assert.equal(dropdown.children[0].children[0].children[0].textContent,'');
    +});
    +test('非模态页面下拉菜单继续挂载body,不改变普通页面坐标',()=>{const{body}=setup(null);assert.equal(body.children.length,1);assert.equal(body.children[0].style.left,'120px');assert.equal(body.children[0].style.top,'194px')});
    +test('我的评分显式初始化统一组件、映射手动补分,并在关闭时清理菜单',()=>{const text=fs.readFileSync('public/music/js/library/rating_manager.js','utf8');assert.match(text,/CustomSelectManager\?\.init\(select\)/);assert.match(text,/'manual-dislike-to-rating': '后台补分'/);assert.match(text,/collectionDialog.addEventListener\('close'.*CustomSelectManager\?\.closeAll/);assert.match(text,/评分来源是什么意思/);assert.match(text,/terms.every/)});
    diff --git a/test/rating-feature-coverage.test.ts b/test/rating-feature-coverage.test.ts
    new file mode 100644
    index 00000000..6b0195ce
    --- /dev/null
    +++ b/test/rating-feature-coverage.test.ts
    @@ -0,0 +1,181 @@
    +import test from 'node:test'
    +import assert from 'node:assert/strict'
    +import fs from 'node:fs'
    +import vm from 'node:vm'
    +import { transformSync } from 'esbuild'
    +import { DislikeDataManage } from '../src/modules/dislike/dislikeDataManage'
    +import { parseDislikeRules, filterDisliked } from '../src/modules/dislike/match'
    +
    +const source = fs.readFileSync('src/server/integrations/subsonic.ts', 'utf8').replace(/\r/g, '')
    +function between(from: string, to: string) {
    +  const a = source.indexOf(from), b = source.indexOf(to, a + from.length)
    +  assert.ok(a >= 0 && b > a, from)
    +  return source.slice(a, b)
    +}
    +function dataManage() {
    +  return new DislikeDataManage({ snapshotInfo: {}, getSnapshotInfo: async () => ({ latest: null }) } as any)
    +}
    +function harness() {
    +  const dm = dataManage()
    +  const state = { snapshots: 0, ratings: {} as Record, invalidations: 0, aliases: {} as Record }
    +  const methods = between('    private async resolveDislikeTarget(', '    /** 按 dislike 规则过滤歌曲列表') +
    +    between('    async syncRatingsToDislike(', '    /**\n     * 手动补数据(不喜欢') +
    +    between('    async syncDislikesToRating(', '    /**\n     * 处理 Subsonic setRating')
    +  const context = vm.createContext({
    +    resolveStoredRatingId: async (_u: string, id: string) => state.aliases[id] || id,
    +    localRatingId: (track: any) => track.platformId || track.id,
    +    state, global: { lx: { config: { 'subsonic.dislikeRating': 1 } } },
    +    getUserSpace: () => ({ dislikeManage: { dislikeDataManage: dm, createSnapshot: async () => { state.snapshots++ } } }),
    +    withTimeout: (p: any) => p, LINK_SYNC_ITEM_TIMEOUT_MS: 8000, LINK_SYNC_BATCH_SIZE: 20,
    +    countDislikeRules: (r: string) => r.split('\n').filter(Boolean).length,
    +    invalidateDislikeCache: () => { state.invalidations++ },
    +    writeRatingUnlocked:async (_u:string,id:string,rating:number)=>{const unchanged=state.ratings[id]>0;if(!unchanged)state.ratings[id]=rating;return {id,meta:{ratings:state.ratings},unchanged}},
    +    recordRatingBackfillFailure:()=>{},
    +    flushRatingSnapshot:async()=> (state as any).snapshotFailure ? '快照失败' : undefined,
    +    readSubsonicMeta: () => ({ ratings: state.ratings }),
    +    writeSubsonicMeta: (_u: string, meta: any) => { state.ratings = meta.ratings },
    +    withRatingIdentityLock: async (_u: string, fn: () => any) => fn(),
    +    withMetaLock: async (_u: string, f: () => any) => f(), createRatingSnapshot: async () => {},
    +    getLocalTracks: async () => [], localMusic: (x: any) => x,
    +    splitSingers: (s: string) => s.split('、'), normalizeText: (s: string) => s,
    +    encodeAlbumRule: (a: string, s: string) => `!${a}@${s}`,
    +    musicSdk: { wy: { extendDetail: { getAlbumSongs: async () => ({ name: 'album', list: [{ singer: 'artist' }] }) } } },
    +    subsonicLog: { warn() {}, error() {} },
    +  })
    +  vm.runInContext(transformSync(`class Harness {
    +    onlineSongCache = new Map(); userRatingsCache = new Map(); songs = new Map();
    +    loadOnlineSongCache() {}
    +    async getSubsonicListData() { return { loveList: [], defaultList: [...this.songs.values()], userList: [] } }
    +    async filterDislikedSongs(_u, songs) { return songs.filter(s => s.name !== 'blocked') }
    +    async resolveSongMeta(_u, id) { if (id === 'fail') throw Error('offline'); const music = this.songs.get(id); return music ? { music } : null }
    +    async resolveSingerIdentity(_u, name, preset) { return { name, source: preset?.source || '', singerId: preset?.id || '' } }
    +    async resolveArtistKey() { return { name: 'artist' } }
    +    dislikeRuleKey(name, singer) { return name + '@' + singer }
    +    ${methods}
    +  }; globalThis.instance = new Harness()`, { loader: 'ts', format: 'cjs' }).code, context)
    +  return { instance: (context as any).instance, dm, state }
    +}
    +const song = (id: string) => ({ id, name: 'track-' + id, singer: 'artist', source: 'wy', songmid: id, img: 'cover', interval: '03:00', types: [{ type: '128k' }], _types: { '128k': {} } })
    +
    +test('Subsonic 歌曲转换保留 Web 所需的已知资料字段', async () => {
    +  const { instance, dm } = harness()
    +  instance.songs.set('wy_1', song('wy_1'))
    +  const target = await instance.resolveDislikeTarget('alice', 'wy_1')
    +  await dm.addDislikeInfo(target.infos)
    +  const item = dm.dislikeRules.dislikeList[0] as any
    +  for (const key of ['id', 'source', 'songmid', 'name', 'singer', 'img', 'interval', 'types', '_types']) assert.deepEqual(item[key], (song('wy_1') as any)[key])
    +  assert.equal(item.dislikeRule, 'track-wy_1@artist')
    +})
    +
    +test('已知限制:残缺字段不会凭空补齐,同规则不同来源不会合并资料', async () => {
    +  const { instance, dm } = harness()
    +  instance.songs.set('wy_1', { id: 'wy_1', name: 'track', singer: 'artist' })
    +  const target = await instance.resolveDislikeTarget('alice', 'wy_1')
    +  assert.equal(target.infos[0].img, undefined)
    +  await dm.addDislikeInfo([{ name: 'track', singer: 'artist', dislikeRule: '' }])
    +  await dm.addDislikeInfo([{ ...song('wy_1'), name: 'track', dislikeRule: '' }] as any)
    +  assert.equal(dm.dislikeRules.dislikeList.length, 2)
    +  assert.equal((dm.dislikeRules.dislikeList[0] as any).img, undefined)
    +})
    +
    +test('歌手转换为名称规则,专辑转换为专辑规则,不展开歌曲', async () => {
    +  const { instance } = harness()
    +  const artist = await instance.resolveDislikeTarget('alice', 'art_tx_123')
    +  assert.equal(artist.infos[0].name, '')
    +  assert.equal(artist.infos[0].singer, 'artist')
    +  const album = await instance.resolveDislikeTarget('alice', 'alb_wy_123')
    +  assert.equal(album.infos.length, 0)
    +  assert.equal(album.album.albumName, 'album')
    +  assert.deepEqual(Array.from(album.album.singers), ['artist'])
    +})
    +
    +test('手动正向同步全量分批、幂等、跳过高分、失败继续并生成一次快照', async () => {
    +  const { instance, state, dm } = harness()
    +  for (let n = 0; n < 43; n++) { const id = 'wy_' + n; instance.songs.set(id, song(id)); state.ratings[id] = 1 }
    +  state.ratings.fail = 1; state.ratings.high = 5
    +  const first = await instance.syncRatingsToDislike('alice')
    +  assert.equal(first.scanned, 44); assert.equal(first.batches, 3); assert.equal(first.failed, 1)
    +  assert.equal(first.added, 43); assert.equal(state.snapshots, 1); assert.equal(state.invalidations, 1)
    +  const second = await instance.syncRatingsToDislike('alice')
    +  assert.equal(second.added, 0); assert.equal(dm.dislikeRules.dislikeList.length, 43)
    +})
    +
    +test('手动反向同步只补命中且缺失的评分,保留已有高分', async () => {
    +  const { instance, state } = harness()
    +  instance.songs.set('a', { ...song('a'), name: 'blocked' })
    +  instance.songs.set('b', { ...song('b'), name: 'blocked' })
    +  instance.songs.set('c', song('c')); state.ratings.a = 5
    +  const result = await instance.syncDislikesToRating('alice')
    +  assert.equal(result.added, 1); assert.equal(result.skipped, 1)
    +  assert.deepEqual(state.ratings, { a: 5, b: 1 })
    +})
    +
    +for (const [name, rules, candidate, options, expected] of [
    +  ['版本归一化开', 'track@artist', { name: 'track (Live)', singer: 'artist' }, { normalizeName: true }, 0],
    +  ['版本归一化关', 'track@artist', { name: 'track (Live)', singer: 'artist' }, { normalizeName: false }, 1],
    +  ['纯歌名要求歌手', 'track', { name: 'track', singer: 'artist' }, { requireSinger: true }, 1],
    +  ['纯歌名不要求歌手', 'track', { name: 'track', singer: 'artist' }, { requireSinger: false }, 0],
    +  ['合唱任一歌手', '@artist', { name: 'track', singer: 'other、artist' }, { duetMode: 'any' }, 0],
    +  ['合唱全部歌手', '@artist', { name: 'track', singer: 'other、artist' }, { duetMode: 'all' }, 1],
    +  ['合唱仅主唱', '@artist', { name: 'track', singer: 'other、artist' }, { duetMode: 'primary' }, 1],
    +] as const) {
    +  test(`规则选项:${name}`, () => {
    +    const result = filterDisliked([candidate], parseDislikeRules(rules), (x: any) => x, options as any)
    +    assert.equal(result.length, expected)
    +  })
    +}
    +
    +test('推荐过滤独立于普通列表隐藏开关', async () => {
    +  const method = between('    private async filterRecommendationEntries(', '    private async handleGetSimilarSongs')
    +  for (const hide of [false, true]) for (const exclude of [false, true]) {
    +    const context = vm.createContext({ global: { lx: { config: { 'subsonic.hideDisliked': hide, 'subsonic.dislikeNoRecommend': exclude } } } })
    +    vm.runInContext(transformSync(`class Harness {
    +      async getUserSubsonicMeta(){return {ratings:{}}}
    +      async filterDislikedEntries(_u, list) { return list.filter(x => x.music.id !== 'blocked') }
    +      musicToSongFlat(m) { return m }
    +      sendResponse(_res, data) { return data }
    +      ${method}
    +    }; globalThis.instance = new Harness()`, { loader: 'ts', format: 'cjs' }).code, context)
    +    const result = await (context as any).instance.renderRandomSongs({}, [{ music: { id: 'blocked' }, listId: 'default' }], 'json', 'randomSongs', 'alice')
    +    assert.equal(result.randomSongs.song.length, exclude ? 0 : 1)
    +  }
    +})
    +
    +test('已知限制:同名歌曲规则不带来源,跨来源开关不能隔离歌曲规则', () => {
    +  const rules = parseDislikeRules('track@artist')
    +  for (const crossSource of [false, true]) {
    +    const result = filterDisliked([{ name: 'track', singer: 'artist', source: 'tx' }], rules, x => x, { crossSource })
    +    assert.equal(result.length, 0)
    +  }
    +})
    +
    +test('手动正向同步把空解析计入失败,预览不写规则', async () => {
    +  const { instance, state, dm } = harness()
    +  state.ratings.missing = 1
    +  const preview = await instance.syncRatingsToDislike('alice', true)
    +  assert.equal(preview.candidates.length, 1)
    +  assert.equal(dm.dislikeRules.dislikeList.length, 0)
    +  assert.equal(state.snapshots, 0)
    +  const actual = await instance.syncRatingsToDislike('alice')
    +  assert.equal(actual.failed, 1)
    +  assert.equal(actual.added, 0)
    +})
    +
    +test('手动正向补齐已有记录也必须保存快照并报告updated', async () => {
    +  const { instance, state, dm } = harness()
    +  const music = song('wy_1')
    +  await dm.addDislikeInfo([{ name: music.name, singer: music.singer, source: music.source, id: music.id }])
    +  instance.songs.set('wy_1', music); state.ratings.wy_1 = 1
    +  const actual = await instance.syncRatingsToDislike('alice')
    +  assert.equal(actual.added, 0)
    +  assert.equal(actual.updated, 1)
    +  assert.equal(state.snapshots, 1)
    +  assert.equal((dm.dislikeRules.dislikeList[0] as any).img, 'cover')
    +})
    +
    +
    +test('后台补分预览区分已有分、重复关联和真正待新增,快照失败返回警告',async()=>{
    + const {instance,state}=harness();instance.songs.set('a',{...song('a'),name:'blocked'});instance.songs.set('b',{...song('b'),name:'blocked'});instance.songs.set('c',{...song('c'),name:'blocked'});state.aliases.b='a';state.ratings.a=5;
    + const preview=await instance.syncDislikesToRating('alice',true);assert.equal(preview.candidates.length,1);assert.equal(preview.existing.length,1);assert.equal(preview.duplicates.length,1);assert.equal(state.ratings.c,undefined)
    + ;(state as any).snapshotFailure=true;const result=await instance.syncDislikesToRating('alice');assert.equal(result.added,1);assert.equal(result.warning,'快照失败');assert.equal(state.ratings.a,5)
    +})
    diff --git a/test/rating-link-policy.test.ts b/test/rating-link-policy.test.ts
    new file mode 100644
    index 00000000..145b5b80
    --- /dev/null
    +++ b/test/rating-link-policy.test.ts
    @@ -0,0 +1,121 @@
    +import test from 'node:test'
    +import assert from 'node:assert/strict'
    +import fs from 'node:fs'
    +import vm from 'node:vm'
    +import { transformSync } from 'esbuild'
    +
    +const source = fs.readFileSync('src/server/integrations/subsonic.ts', 'utf8')
    +const start = source.indexOf('    private async handleSetRating(')
    +const end = source.indexOf('    private async handleGetStarred(', start)
    +const reverseStart = source.indexOf('export async function syncDislikeToRating(')
    +const reverseEnd = source.indexOf('/**', reverseStart)
    +function build(config: Record = {}) {
    +  const state = { ratings: {} as Record, adds: 0, removes: 0, snapshots: 0, fail: false }
    +  const context = vm.createContext({
    +    global: { lx: { config } }, state,
    +    withTimeout: (promise:any) => promise, LINK_SYNC_ITEM_TIMEOUT_MS:8000,
    +    resolveStoredRatingId: async (_u: string, id: string) => id,
    +    withRatingIdentityLock: async (_u: string, fn: () => any) => fn(),
    +    withMetaLock: async (_u: string, fn: () => any) => fn(),
    +    createRatingSnapshot: async () => { state.snapshots++ },
    +    writeRating: async (_u: string, id: string, rating: number, channel:string, _song:any, options:any={}) => { if (state.fail && channel==='web-dislike-link') throw Error('disk failure');if(options.onlyMissing&&state.ratings[id]>0)return {id,meta:{ratings:state.ratings},unchanged:true};if (rating) state.ratings[id] = rating; else delete state.ratings[id]; state.snapshots++; return { id, meta: { ratings: state.ratings } } },
    +    readSubsonicMeta: () => ({ ratings: state.ratings }),
    +    writeSubsonicMeta: (_u: string, meta: any) => { if (state.fail) throw Error('disk failure'); state.ratings = meta.ratings },
    +    subsonicLog: { debug() {}, warn() {}, error() {} },
    +  })
    +  vm.runInContext(transformSync(`
    +class Harness {
    +  userRatingsCache = new Map()
    +  async getUserSubsonicMeta() { return { ratings: state.ratings } }
    +  async saveUserSubsonicMeta(_u, meta) { state.ratings = meta.ratings }
    +  async resolveSongMeta() {return null}
    +  async getRatingMetadata() {return null}
    +  async resolveDislikeTarget() { if (state.fail) throw Error('offline'); return { infos: [{ name: 'song' }], keys: new Set(['song']) } }
    +  async addDislikeRules() { state.adds++; return 1 }
    +  async removeDislikeRules() { state.removes++; return 1 }
    +  sendResponse() { return { ok: true } }
    +  sendError(_r, _code, message) { return { ok: false, message } }
    +${source.slice(source.indexOf('    async applyRatingLink('), start)}
    +${source.slice(start, end)}
    +}
    +${source.slice(reverseStart, reverseEnd).replace('export async', 'async')}
    +globalThis.instance = new Harness()
    +globalThis.reverse = syncDislikeToRating
    +`, { loader: 'ts', format: 'cjs' }).code, context)
    +  return { state, instance: (context as any).instance, reverse: (context as any).reverse }
    +}
    +
    +test('评分歌单开启也不能绕过关闭的正向联动', async () => {
    +  const { state, instance } = build({ 'subsonic.ratingPlaylists': true, 'subsonic.linkRatingToDislike': false })
    +  await instance.handleSetRating({}, 'alice', new URLSearchParams({ id: 'wy_1', rating: '1' }), 'json')
    +  assert.equal(state.ratings.wy_1, 1)
    +  assert.equal(state.snapshots, 1)
    +  assert.equal(state.adds, 0)
    +})
    +
    +test('联动仅补充低分规则,提高或清除评分不删除手工规则', async () => {
    +  const { state, instance } = build({ 'subsonic.linkRatingToDislike': true })
    +  for (const rating of ['1', '5', '0']) await instance.handleSetRating({}, 'alice', new URLSearchParams({ id: 'wy_1', rating }), 'json')
    +  assert.equal(state.adds, 1)
    +  assert.equal(state.removes, 0)
    +  assert.equal(state.ratings.wy_1, undefined)
    +})
    +
    +test('联动解析失败返回部分成功提示,评分保留供重试', async () => {
    +  const { state, instance } = build({ 'subsonic.linkRatingToDislike': true })
    +  state.fail = true
    +  const result = await instance.handleSetRating({}, 'alice', new URLSearchParams({ id: 'wy_1', rating: '1' }), 'json')
    +  assert.equal(result.ok, false)
    +  assert.match(result.message, /评分已保存/)
    +  assert.equal(state.ratings.wy_1, 1)
    +})
    +
    +test('反向联动保留高分,取消不喜欢保留评分,仅缺失评分补 1 星', async () => {
    +  const { state, reverse } = build()
    +  state.ratings.wy_1 = 5
    +  await reverse('alice', 'wy_1', 1)
    +  await reverse('alice', 'wy_1', 0)
    +  assert.equal(state.ratings.wy_1, 5)
    +  await reverse('alice', 'wy_2', 1)
    +  assert.equal(state.ratings.wy_2, 1)
    +})
    +
    +for (const threshold of [0, 1, 2]) {
    +  for (const enabled of [false, true]) {
    +    for (const visible of [false, true]) {
    +      test(`开关矩阵:阈值 ${threshold} 联动 ${enabled} 评分歌单 ${visible}`, async () => {
    +        const { state, instance } = build({ 'subsonic.dislikeRating': threshold, 'subsonic.linkRatingToDislike': enabled, 'subsonic.ratingPlaylists': visible })
    +        await instance.handleSetRating({}, 'alice', new URLSearchParams({ id: 'wy_1', rating: '2' }), 'json')
    +        assert.equal(state.ratings.wy_1, 2)
    +        assert.equal(state.adds, enabled && threshold >= 2 ? 1 : 0)
    +        assert.equal(state.snapshots, 1)
    +      })
    +    }
    +  }
    +}
    +
    +test('Web歌手/专辑不喜欢不能被误写成歌曲评分', async () => {
    +  const server = fs.readFileSync('src/server/server.ts', 'utf8')
    +  const start = server.indexOf('            const dislikeAutoRating =')
    +  const end = server.indexOf('            if (!isAdd && removeKeys.size', start)
    +  assert.ok(start >= 0 && end > start)
    +  for (const type of ['song', 'singer', 'album']) {
    +    const calls: any[] = []
    +    const context = vm.createContext({
    +      readRatingPreferences: () => ({ dislikeAutoRating: true }),
    +      readRatings: () => ({ ratings: {} }),
    +      type, id: 'real-id', source: 'tx', verified: 'test', isAdd: true,
    +      global: { lx: { config: { 'subsonic.linkDislikeToRating': true, 'subsonic.dislikeRating': 1 } } },
    +      payload: { id: 'real-id', source: 'tx' }, resolveRatingId: async () => 'tx_real-id',
    +      require: () => ({ syncDislikeToRating: async (...args: any[]) => calls.push(args) }), console,
    +    })
    +    await vm.runInContext(transformSync(`(async () => { ${server.slice(start, end)} })()`, { loader: 'ts', format: 'cjs' }).code, context)
    +    assert.equal(calls.length, type === 'song' ? 1 : 0)
    +    if (type === 'song') assert.deepEqual(calls[0].slice(0,3), ['test', 'tx_real-id', 1])
    +  }
    +})
    +
    +
    +test('自动补分写盘失败向调用方抛出,不伪报成功',async()=>{
    + const {state,reverse}=build();state.fail=true;await assert.rejects(()=>reverse('alice','wy_1',1),/disk failure/)
    +})
    diff --git a/test/rating-migration.test.ts b/test/rating-migration.test.ts
    new file mode 100644
    index 00000000..c59203ac
    --- /dev/null
    +++ b/test/rating-migration.test.ts
    @@ -0,0 +1,54 @@
    +import test from 'node:test'
    +import assert from 'node:assert/strict'
    +import fs from 'node:fs'
    +import os from 'node:os'
    +import path from 'node:path'
    +import { commitRatingFiles, recoverRatingTransaction, planRatingMigration, registerDislikeMigration } from '../src/server/rating/ratingMigration'
    +const fixture = (fn: (dir: string) => void) => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-rating-transaction-')); try { fn(dir) } finally { fs.rmSync(dir, { recursive: true, force: true }) } }
    +test('跨文件提交第二次写入失败完整回滚,删除事务日志',()=>fixture(dir=>{
    + const a=path.join(dir,'rating/songs.json'),b=path.join(dir,'subsonic-meta.json');fs.mkdirSync(path.dirname(a),{recursive:true});fs.writeFileSync(a,'old catalog');fs.writeFileSync(b,'old ratings')
    + const rename=fs.renameSync;let failed=false
    + try { fs.renameSync=((from:any,to:any)=>{if(to===b&&!failed){failed=true;throw Error('disk failure')}return rename(from,to)}) as any
    + assert.throws(()=>commitRatingFiles(dir,new Map([[a,'new catalog'],[b,'new ratings']])),/disk failure/)
    + }finally{fs.renameSync=rename}
    + assert.equal(fs.readFileSync(a,'utf8'),'old catalog');assert.equal(fs.readFileSync(b,'utf8'),'old ratings');assert.ok(!fs.existsSync(path.join(dir,'rating/transaction.json')))
    +}))
    +test('中断后由日志完成所有文件提交,重复恢复幂等,拒绝越界日志',()=>fixture(dir=>{
    + fs.mkdirSync(path.join(dir,'rating'));fs.writeFileSync(path.join(dir,'rating/transaction.json'),JSON.stringify([{file:'rating/songs.json',after:'catalog'},{file:'subsonic-meta.json',after:'ratings'}]))
    + recoverRatingTransaction(dir);recoverRatingTransaction(dir);assert.equal(fs.readFileSync(path.join(dir,'subsonic-meta.json'),'utf8'),'ratings')
    + fs.writeFileSync(path.join(dir,'rating/transaction.json'),JSON.stringify([{file:'../escape',after:'bad'}]));assert.throws(()=>recoverRatingTransaction(dir),/Invalid/)
    +}))
    +test('迁移全部原因ID,保留手动原因和旧快照,并同步运行时规则及快照索引',()=>fixture(dir=>{
    + const dislike=path.join(dir,'dislike');fs.mkdirSync(path.join(dislike,'snapshot'),{recursive:true})
    + const rules={dislikeList:[{name:'歌',provenance:{channel:'web-rating-link',ratingId:'local_a'},reasons:[{channel:'web-rating-link',ratingId:'local_a'},{channel:'web'}]}]}
    + const info={latest:'old',list:[],imported:[],clients:{}}
    + fs.writeFileSync(path.join(dislike,'snapshot/snapshot_old'),JSON.stringify(rules));fs.writeFileSync(path.join(dislike,'snapshotInfo.json'),JSON.stringify(info))
    + const manager={dislikeRules:rules,snapshotDataManage:{snapshotInfo:info}};registerDislikeMigration(dir,manager)
    + const data={ratings:{local_a:1},ratingOrigins:{local_a:{channel:'web',at:1}}},catalog={local_a:{name:'歌'}}
    + const changes=planRatingMigration(dir,data,catalog,[{id:'local_a',platformId:'wy_a'}]);changes.set(path.join(dir,'subsonic-meta.json'),JSON.stringify(data));commitRatingFiles(dir,changes)
    + assert.equal(manager.dislikeRules.dislikeList[0].provenance.ratingId,'wy_a');assert.equal(manager.dislikeRules.dislikeList[0].reasons[0].ratingId,'wy_a');assert.equal(manager.dislikeRules.dislikeList[0].reasons[1].channel,'web');assert.notEqual(manager.snapshotDataManage.snapshotInfo.latest,'old');assert.ok(fs.existsSync(path.join(dislike,'snapshot/snapshot_old')))
    + assert.equal(planRatingMigration(dir,data,catalog,[{id:'local_a',platformId:'wy_a'}]).size,0)
    +}))
    +
    +test('只有收藏没有ratings也能整理关联,不生成虚假分数',()=>fixture(dir=>{
    + const data:any={starredArtists:['keep']},catalog={}
    + const changes=planRatingMigration(dir,data,catalog,[{id:'local_a',platformId:'wy_a'}])
    + assert.deepEqual(data.ratings,{});assert.deepEqual(data.starredArtists,['keep']);assert.equal(changes.size,0)
    +}))
    +test('仅提交评分文件不能覆盖尚未保存的运行时不喜欢',()=>fixture(dir=>{
    + const dislike=path.join(dir,'dislike');fs.mkdirSync(path.join(dislike,'snapshot'),{recursive:true})
    + const info={latest:'old',list:[],imported:[],clients:{}}
    + fs.writeFileSync(path.join(dislike,'snapshotInfo.json'),JSON.stringify(info));fs.writeFileSync(path.join(dislike,'snapshot/snapshot_old'),JSON.stringify({dislikeList:[]}))
    + const manager={snapshotDataManage:{snapshotInfo:info},dislikeRules:{dislikeList:[{name:'尚未保存'}]}}
    + registerDislikeMigration(dir,manager)
    + commitRatingFiles(dir,new Map([[path.join(dir,'subsonic-meta.json'),JSON.stringify({ratings:{wy_a:3}})]]))
    + assert.equal(manager.dislikeRules.dislikeList[0].name,'尚未保存')
    +}))
    +test('初始化不喜欢管理器时先完成遗留事务,不必访问评分接口',()=>fixture(dir=>{
    + fs.mkdirSync(path.join(dir,'rating'));fs.mkdirSync(path.join(dir,'dislike/snapshot'),{recursive:true})
    + const id='a'.repeat(32),info={latest:id,list:[],clients:{},imported:[]}
    + fs.writeFileSync(path.join(dir,'rating/transaction.json'),JSON.stringify([{file:'dislike/snapshot/snapshot_'+id,after:JSON.stringify({dislikeList:[{name:'已恢复'}]})},{file:'dislike/snapshotInfo.json',after:JSON.stringify(info)}]))
    + const manager={snapshotDataManage:{snapshotInfo:{}},dislikeRules:{dislikeList:[] as any[]}}
    + registerDislikeMigration(dir,manager)
    + assert.equal(manager.dislikeRules.dislikeList[0].name,'已恢复');assert.ok(!fs.existsSync(path.join(dir,'rating/transaction.json')))
    +}))
    diff --git a/test/rating-preferences.test.ts b/test/rating-preferences.test.ts
    new file mode 100644
    index 00000000..eae38db4
    --- /dev/null
    +++ b/test/rating-preferences.test.ts
    @@ -0,0 +1,25 @@
    +import test from 'node:test'
    +import assert from 'node:assert/strict'
    +import fs from 'node:fs'
    +import os from 'node:os'
    +import path from 'node:path'
    +import vm from 'node:vm'
    +import { createRequire } from 'node:module'
    +import { transformSync } from 'esbuild'
    +const actualRequire=createRequire(import.meta.url)
    +test('自动补分偏好默认关闭、按用户落盘隔离、布尔校验且关闭保留其他用户',()=>{
    + const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-preferences-'))
    + try{
    +  const mod={exports:{} as any}
    +  const require=(id:string)=>id==='@/user'?{getUserDirname:(user:string)=>user}:actualRequire(id)
    +  vm.runInNewContext(transformSync(fs.readFileSync('src/server/rating/ratingPreferences.ts','utf8'),{loader:'ts',format:'cjs'}).code,{module:mod,exports:mod.exports,require,global:{lx:{userPath:root}},console:{warn(){}}})
    +  const api=mod.exports
    +  assert.equal(api.readRatingPreferences('a').dislikeAutoRating,false)
    +  api.saveRatingPreferences('a',true);assert.equal(api.readRatingPreferences('a').dislikeAutoRating,true);assert.equal(api.readRatingPreferences('b').dislikeAutoRating,false)
    +  for(const value of [1,'true',null,{},undefined])assert.throws(()=>api.saveRatingPreferences('a',value))
    +  assert.equal(api.readRatingPreferences('a').dislikeAutoRating,true)
    +  api.saveRatingPreferences('b',true);api.saveRatingPreferences('a',false);assert.equal(api.readRatingPreferences('a').dislikeAutoRating,false);assert.equal(api.readRatingPreferences('b').dislikeAutoRating,true)
    +  const file=path.join(root,'a/rating/preferences.json');assert.equal(JSON.parse(fs.readFileSync(file,'utf8')).dislikeAutoRating,false);assert.equal(fs.existsSync(file+'.tmp'),false)
    +  fs.writeFileSync(file,'broken');assert.equal(api.readRatingPreferences('a').dislikeAutoRating,false)
    + }finally{fs.rmSync(root,{recursive:true,force:true})}
    +})
    diff --git a/test/rating-recommendation-switch.test.ts b/test/rating-recommendation-switch.test.ts
    new file mode 100644
    index 00000000..02091555
    --- /dev/null
    +++ b/test/rating-recommendation-switch.test.ts
    @@ -0,0 +1,16 @@
    +import test from 'node:test'
    +import assert from 'node:assert/strict'
    +import fs from 'node:fs'
    +import vm from 'node:vm'
    +import { transformSync } from 'esbuild'
    +const source=fs.readFileSync('src/server/integrations/subsonic.ts','utf8')
    +const start=source.indexOf('    private async handleGetRecommendedSongs(')
    +const end=source.indexOf('    private async ',start+20)
    +const filterStart=source.indexOf('    private async filterRecommendationEntries(')
    +const filterEnd=source.indexOf('    private async renderRandomSongs(',filterStart)
    +for(const enabled of [false,true]) test(`每日推荐低分排除遵守开关 ${enabled}`,async()=>{
    + const context:any={global:{lx:{config:{'subsonic.dislikeNoRecommend':enabled,'subsonic.dislikeRating':1}}},localRatingId:(t:any)=>t.platformId||t.id,fetchRecommendedSongs:async()=>[{id:'wy_1'},{id:'wy_2'}],subsonicLog:{error(){}}}
    + vm.runInNewContext(transformSync(`class Harness { async filterDislikedEntries(_u,entries){return entries} async getUserSubsonicMeta(){ return {ratings:{wy_1:1,wy_2:5}} } renderRandomSongs(_res,rows){return rows} ${source.slice(filterStart,filterEnd)} ${source.slice(start,end)} }; globalThis.instance=new Harness()`,{loader:'ts',format:'cjs'}).code,context)
    + const rows=await context.instance.handleGetRecommendedSongs({},'a',new URLSearchParams(),'json')
    + assert.equal(rows.length,enabled?1:2)
    +})
    diff --git a/test/rating-snapshot.test.ts b/test/rating-snapshot.test.ts
    new file mode 100644
    index 00000000..e99ab34e
    --- /dev/null
    +++ b/test/rating-snapshot.test.ts
    @@ -0,0 +1,213 @@
    +import test from 'node:test'
    +import assert from 'node:assert/strict'
    +import fs from 'node:fs'
    +import os from 'node:os'
    +import path from 'node:path'
    +import vm from 'node:vm'
    +import { createRequire } from 'node:module'
    +import { transformSync } from 'esbuild'
    +
    +/** 用 vm 注入受控的 global.lx 与 @/ 别名依赖,隔离文件系统的真实用户目录 */
    +const nodeRequire = createRequire(import.meta.url)
    +type Api = Record
    +
    +const load = (userPath: string, maxSnapshotNum = 3): Api => {
    +  const source = fs.readFileSync(path.resolve('src/server/rating/ratingSnapshot.ts'), 'utf8')
    +  const code = transformSync(source, { loader: 'ts', format: 'cjs', target: 'node20' }).code
    +  const mod = { exports: {} as Api }
    +  const require = (id: string): any => {
    +    if (id === './ratingMigration' || id === '../../server/rating/ratingMigration') return nodeRequire(path.resolve('src/server/rating/ratingMigration.ts'))
    +    if (id === './ratingIdentityLock') return nodeRequire(path.resolve('src/server/rating/ratingIdentityLock.ts'))
    +    if (id === './ratingValidation') return nodeRequire(path.resolve('src/server/rating/ratingValidation.ts'))
    +  if (id === '@/user') return { getUserDirname: (u: string) => u }
    +    if (id === '@/user/data') return { getUserConfig: () => ({ maxSnapshotNum }) }
    +    return nodeRequire(id)
    +  }
    +  const context = vm.createContext({
    +    module: mod,
    +    exports: mod.exports,
    +    require,
    +    global: { lx: { userPath, config: { maxSnapshotNum } } },
    +    console,
    +    process,
    +    Buffer,
    +    URL,
    +    __dirname: path.resolve('src/server'),
    +    __filename: path.resolve('src/server/rating/ratingSnapshot.ts'),
    +  })
    +  vm.runInContext(code, context)
    +  return mod.exports
    +}
    +
    +const withUser = async (maxSnapshotNum: number, fn: (api: Api, userDir: string) => Promise | void) => {
    +  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-rating-snap-'))
    +  try {
    +    const userDir = path.join(root, 'alice')
    +    fs.mkdirSync(userDir, { recursive: true })
    +    await fn(load(root, maxSnapshotNum), userDir)
    +  } finally {
    +    fs.rmSync(root, { recursive: true, force: true })
    +  }
    +}
    +
    +const metaFile = (userDir: string) => path.join(userDir, 'subsonic-meta.json')
    +const snapshotDir = (userDir: string) => path.join(userDir, 'rating', 'snapshot')
    +const infoFile = (userDir: string) => path.join(userDir, 'rating', 'snapshotInfo.json')
    +const writeMeta = (userDir: string, ratings: Record) =>
    +  fs.writeFileSync(metaFile(userDir), JSON.stringify({ ratings, starredAlbums: [], starredArtists: [], starredArtistNames: [] }), 'utf8')
    +const plain = (value: T): T => JSON.parse(JSON.stringify(value))
    +
    +test('恢复评分快照保留当前收藏,并与并发写入串行', async () => {
    +  await withUser(3, async (api, userDir) => {
    +    writeMeta(userDir, { old: 1 })
    +    const id = await api.createRatingSnapshot('alice')
    +    const pending = api.withMetaLock('alice', async () => {
    +      await new Promise(resolve => setTimeout(resolve, 10))
    +      fs.writeFileSync(metaFile(userDir), JSON.stringify({ ratings: { newer: 5 }, starredAlbums: ['album'], starredArtists: ['artist'], starredArtistNames: ['name'] }))
    +    })
    +    await Promise.all([pending, api.restoreRatingSnapshot('alice', id)])
    +    const restored = JSON.parse(fs.readFileSync(metaFile(userDir), 'utf8'))
    +    assert.equal(restored.ratingHistory.old.at(-1).channel, 'snapshot-restore')
    +    delete restored.ratingHistory
    +    assert.deepEqual(restored, {
    +      ratings: { old: 1 }, ratingOrigins: {}, starredAlbums: ['album'], starredArtists: ['artist'], starredArtistNames: ['name'],
    +    })
    +  })
    +})
    +
    +test('S4 快照 id:路径穿越与非法字符全部拒绝', async () => {
    +  await withUser(3, (api) => {
    +    for (const id of ['abc', '2026-11-08', 'a'.repeat(32), '用户备份']) {
    +      assert.equal(api.isValidSnapshotId(id), true, `应接受 ${id}`)
    +    }
    +    for (const id of ['', '.', '..', '../evil', 'a..b', 'a/b', 'a\\b', 'a:b', 'a*b', 'a?b', 'a"b', 'ab', 'a|b', 'x'.repeat(201), null, 1, {}, []]) {
    +      assert.equal(api.isValidSnapshotId(id), false, `应拒绝 ${String(id)}`)
    +    }
    +  })
    +})
    +
    +test('S9 内容去重:同一评分只生成一份快照,复用时刷新 mtime', async () => {
    +  await withUser(3, async (api, userDir) => {
    +    writeMeta(userDir, { a: 3 })
    +    const id1 = await api.createRatingSnapshot('alice')
    +    const file = path.join(snapshotDir(userDir), `snapshot_${id1}`)
    +    assert.ok(fs.existsSync(file))
    +    const old = new Date(Date.now() - 10 * 86_400_000)
    +    fs.utimesSync(file, old, old)
    +    const id2 = await api.createRatingSnapshot('alice')
    +    assert.equal(id2, id1)
    +    assert.ok(fs.statSync(file).mtimeMs > old.getTime() + 5 * 86_400_000)
    +    writeMeta(userDir, { a: 5 })
    +    const id3 = await api.createRatingSnapshot('alice')
    +    assert.notEqual(id3, id1)
    +  })
    +})
    +
    +test('S9 配额清理:只清超量的 auto 快照,imported 永久保留', async () => {
    +  await withUser(2, async (api, userDir) => {
    +    writeMeta(userDir, { a: 1 })
    +    const first = await api.createRatingSnapshot('alice')
    +    // 错开 mtime,避免同毫秒导致清理顺序不稳定
    +    fs.utimesSync(path.join(snapshotDir(userDir), `snapshot_${first}`), new Date(1), new Date(1))
    +    for (const star of [2, 3, 4]) {
    +      writeMeta(userDir, { a: star })
    +      const id = await api.createRatingSnapshot('alice')
    +      fs.utimesSync(path.join(snapshotDir(userDir), `snapshot_${id}`), new Date(star * 1000), new Date(star * 1000))
    +    }
    +    const imported = 'my-import.json'
    +    await api.saveRatingImport('alice', imported, { ratings: { z: 5 }, starredAlbums: [], starredArtists: [], starredArtistNames: [] }, Date.now() - 86_400_000)
    +    const list = plain(await api.listRatingSnapshots('alice'))
    +    assert.ok(fs.existsSync(path.join(snapshotDir(userDir), `snapshot_${imported}`)))
    +    assert.equal(list.filter((x: any) => x.origin === 'auto').length, 2, 'auto 快照应只保留最新 2 份')
    +    assert.equal(list.find((x: any) => x.id === imported)?.origin, 'imported')
    +    assert.equal(JSON.parse(fs.readFileSync(infoFile(userDir), 'utf8')).imported.includes(imported), true)
    +  })
    +})
    +
    +test('S9 索引兼容:旧版 snapshotInfo 无 imported 字段时降级为空数组', async () => {
    +  await withUser(3, async (api, userDir) => {
    +    writeMeta(userDir, { a: 1 })
    +    fs.mkdirSync(snapshotDir(userDir), { recursive: true })
    +    fs.writeFileSync(infoFile(userDir), '{ not json', 'utf8')
    +    await api.createRatingSnapshot('alice')
    +    const list = plain(await api.listRatingSnapshots('alice'))
    +    assert.equal(list[0].origin, 'auto')
    +    fs.writeFileSync(infoFile(userDir), JSON.stringify({ unrelated: 1 }), 'utf8')
    +    assert.equal(plain(await api.listRatingSnapshots('alice'))[0].origin, 'auto')
    +  })
    +})
    +
    +test('S9 meta 损坏阻止导出并保留原文件;读写与删除、回滚幂等', async () => {
    +  await withUser(3, async (api, userDir) => {
    +    fs.writeFileSync(metaFile(userDir), '{ broken', 'utf8')
    +    await assert.rejects(()=>api.createRatingSnapshot('alice'),/损坏/)
    +    assert.equal(fs.readFileSync(metaFile(userDir),'utf8'),'{ broken')
    +    assert.equal(await api.readRatingSnapshot('alice', 'missing'), null)
    +    assert.equal(await api.readRatingSnapshot('alice', '../evil'), null)
    +    assert.equal(await api.deleteRatingSnapshot('alice', 'missing'), false)
    +    await assert.rejects(() => api.restoreRatingSnapshot('alice', 'missing'), /not found/)
    +
    +    writeMeta(userDir, { song: 4 })
    +    const target = 'user-backup'
    +    await api.saveRatingImport('alice', target, { ratings: { song: 1 }, starredAlbums: [], starredArtists: [], starredArtistNames: [] }, 0)
    +    await api.saveRatingImport('alice', target, { ratings: { song: 1 }, starredAlbums: [], starredArtists: [], starredArtistNames: [] }, 0)
    +    assert.equal(JSON.parse(fs.readFileSync(infoFile(userDir), 'utf8')).imported.length, 1, '重复导入不应重复登记')
    +
    +    await api.restoreRatingSnapshot('alice', target)
    +    assert.equal(JSON.parse(fs.readFileSync(metaFile(userDir), 'utf8')).ratings.song, 1)
    +    assert.equal(await api.deleteRatingSnapshot('alice', target), true)
    +    assert.equal(JSON.parse(fs.readFileSync(infoFile(userDir), 'utf8')).imported.length, 0)
    +    await assert.rejects(() => api.saveRatingImport('alice', '../evil', {}, 0), /Invalid snapshot id/)
    +  })
    +})
    +
    +
    +test('完整评分导出含描述及关联参照,回滚恢复描述而不覆盖收藏或文件关联',async()=>{
    + await withUser(3,async(api,dir)=>{
    +  writeMeta(dir,{wy_1:4});fs.mkdirSync(path.join(dir,'rating'),{recursive:true})
    +  fs.writeFileSync(path.join(dir,'rating/songs.json'),JSON.stringify({wy_1:{name:'晴天',img:'https://example.com/a.jpg'}}))
    +  const registry=JSON.stringify({tracks:{a:{id:'local_a',filename:'a.wav',platformId:'wy_1'}}})
    +  fs.writeFileSync(path.join(dir,'subsonic-local-registry.json'),registry)
    +  const exported=api.exportRatingBackup('alice');assert.equal(exported.ratingCatalog.wy_1.name,'晴天');assert.equal(exported.localLinks[0].platformId,'wy_1')
    +  const id=await api.createRatingSnapshot('alice')
    +  fs.writeFileSync(path.join(dir,'rating/songs.json'),'{}');writeMeta(dir,{wy_1:1})
    +  await api.restoreRatingSnapshot('alice',id)
    +  assert.equal(JSON.parse(fs.readFileSync(path.join(dir,'rating/songs.json'),'utf8')).wy_1.name,'晴天')
    +  assert.equal(fs.readFileSync(path.join(dir,'subsonic-local-registry.json'),'utf8'),registry)
    +  assert.deepEqual(plain(api.normalizeRatingMeta({ratings:{a:6,b:'3',c:0,d:2}}).ratings),{d:2})
    + })
    +})
    +
    +
    +test('恢复第二个文件失败时回滚歌曲描述与评分,不留下半恢复状态',async()=>{
    + await withUser(3,async(api,dir)=>{
    +  writeMeta(dir,{wy_1:1});fs.mkdirSync(path.join(dir,'rating'),{recursive:true});const catalog=path.join(dir,'rating/songs.json');fs.writeFileSync(catalog,JSON.stringify({wy_1:{name:'旧描述'}}));const id=await api.createRatingSnapshot('alice')
    +  writeMeta(dir,{wy_1:5});fs.writeFileSync(catalog,JSON.stringify({wy_1:{name:'当前描述'}}));const before=fs.readFileSync(metaFile(dir),'utf8')
    +  const rename=fs.renameSync;let failed=false
    +  try {
    +   fs.renameSync=((from:any,to:any)=>{if(!failed&&String(to)===metaFile(dir)&&String(from).endsWith('.transaction.tmp')){failed=true;throw Error('模拟写入失败')}return rename(from,to)}) as any
    +   await assert.rejects(()=>api.restoreRatingSnapshot('alice',id),/模拟写入失败/)
    +  }finally{fs.renameSync=rename}
    +  assert.equal(fs.readFileSync(metaFile(dir),'utf8'),before);assert.equal(JSON.parse(fs.readFileSync(catalog,'utf8')).wy_1.name,'当前描述')
    + })
    +})
    +
    +
    +test('评分历史导入只保留有效事件并限制30条,异常结构不会进入展示数据',async()=>{
    + await withUser(3,async(api)=>{
    +  const bad=api.normalizeRatingMeta({ratings:{wy_1:4},ratingHistory:{wy_1:{broken:true},wy_2:[null,{},'bad',{from:0,to:9,at:1,channel:'web'}],wy_3:Array.from({length:50},(_,n)=>({from:0,to:4,at:n+1,channel:'web',url:'secret'}))}})
    +  assert.equal(bad.ratingHistory.wy_1,undefined);assert.equal(bad.ratingHistory.wy_2.length,0);assert.equal(bad.ratingHistory.wy_3.length,30);assert.equal(bad.ratingHistory.wy_3[0].url,undefined)
    + })
    +})
    +
    +test('恢复旧本地快照返回前完成身份迁移',async()=>{
    + await withUser(3,async(api,dir)=>{
    +  writeMeta(dir,{local_old:2});const id=await api.createRatingSnapshot('alice')
    +  fs.writeFileSync(path.join(dir,'subsonic-local-registry.json'),JSON.stringify({tracks:{file:{id:'local_old',platformId:'wy_online'}}}))
    +  writeMeta(dir,{wy_online:5})
    +  await api.restoreRatingSnapshot('alice',id)
    +  const data=JSON.parse(fs.readFileSync(metaFile(dir),'utf8'))
    +  assert.equal(data.ratings.local_old,undefined);assert.equal(data.ratings.wy_online,2)
    +  assert.ok(!fs.existsSync(path.join(dir,'rating/transaction.json')))
    + })
    +})
    diff --git a/test/sdk-interface-migration.test.cjs b/test/sdk-interface-migration.test.cjs
    new file mode 100644
    index 00000000..361a9895
    --- /dev/null
    +++ b/test/sdk-interface-migration.test.cjs
    @@ -0,0 +1,176 @@
    +const { test } = require('node:test')
    +const assert = require('node:assert/strict')
    +const { build } = require('esbuild')
    +const fs = require('node:fs/promises')
    +const path = require('node:path')
    +const Module = require('node:module')
    +const { deflateSync } = require('node:zlib')
    +
    +async function load(name) {
    +  const result = await build({
    +    entryPoints: [path.resolve('src/modules/utils/musicSdk', name + '.js')],
    +    bundle: true, write: false, platform: 'node', format: 'cjs', packages: 'external',
    +    plugins: [{ name: 'mock-transport', setup(b) {
    +      b.onLoad({ filter: /[\\/]utils[\\/]request\.js$/ }, () => ({ contents: 'export const httpFetch = (...args) => globalThis.__sdkMigrationHttp(...args)', loader: 'js' }))
    +      b.onLoad({ filter: /[\\/](mg|wy)[\\/]lyric\.js$/ }, async ({ path: p }) => ({ contents: await fs.readFile(p, 'utf8') + (p.includes(`${path.sep}mg${path.sep}`) ? '\nexport { mrcTools }' : '\nexport { parseTools }'), loader: 'js' }))
    +    } }],
    +  })
    +  const m = new Module(path.resolve('test/sdk-migration-bundle.cjs'), module)
    +  m.filename = path.resolve('test/sdk-migration-bundle.cjs')
    +  m.paths = module.paths
    +  m._compile(result.outputFiles[0].text, m.filename)
    +  return m.exports
    +}
    +const response = body => ({ promise: Promise.resolve({ statusCode: 200, body }), cancelHttp() {} })
    +
    +test('Kugou keeps chain and collection ID, including redirect-only encoded IDs', async () => {
    +  const kg = (await load('kg/songList')).default
    +  const calls = []
    +  kg.getListDetailByGcidChain = async (...args) => { calls.push(args); return 'new detail' }
    +  assert.equal(await kg.getUserListDetail('https://m.kugou.com/share/?global_collection_id=abc_123&chain=xyz_456', 1), 'new detail')
    +  assert.deepEqual(calls[0], ['abc_123', 'xyz_456'])
    +  globalThis.__sdkMigrationHttp = () => ({ promise: Promise.resolve({ statusCode: 302, headers: { location: 'https://m.kugou.com/songlist/gcid_encoded123' } }), cancelHttp() {} })
    +  kg.decodeGcid = async id => { assert.equal(id, 'gcid_encoded123'); return 'decoded' }
    +  kg.getUserListDetail2 = async id => id
    +  assert.equal(await kg.getUserListDetail('https://t1.kugou.com/short', 1), 'decoded')
    +})
    +
    +test('Kugou new endpoint reads all 50-song pages and keeps metadata', async () => {
    +  const kg = (await load('kg/songList')).default
    +  const pages = []
    +  kg.createHttp = async url => {
    +    const u = new URL(url), page = Number(u.searchParams.get('page'))
    +    pages.push(page)
    +    assert.equal(u.pathname, '/v2/zlist/list')
    +    assert.equal(u.searchParams.get('chain'), 'chain123')
    +    assert.equal(u.searchParams.get('pagesize'), '50')
    +    return { info: [{ count: 101, name: 'test', pic: 'https://img/{size}' }], list: { info: Array.from({ length: page < 3 ? 50 : 1 }, (_, n) => ({ hash: `${page}-${n}` })) } }
    +  }
    +  kg.getMusicInfos = async items => items
    +  const detail = await kg.getListDetailByGcidChain('collection', 'chain123')
    +  assert.equal(detail.list.length, 101)
    +  assert.equal(detail.total, 101)
    +  assert.deepEqual(pages, [1, 2, 3])
    +  assert.equal(detail.info.img, 'https://img/1000')
    +})
    +
    +test('QQ singer uses plural mids, separate counts, and page 2 starts at limit', async () => {
    +  const singer = (await load('tx/singer')).default
    +  globalThis.__sdkMigrationHttp = (url, options) => {
    +    const req = options.body
    +    if (req.req_1) {
    +      assert.deepEqual(req.req_1.param.singer_mids, ['mid'])
    +      return response({ code: 0, req_1: { code: 0, data: { singer_list: [{ basic_info: { singer_mid: 'mid', name: 'artist' }, ex_info: { desc: '', genre: 1 }, pic: { pic: 'avatar' } }] } }, req_2: { code: 0, data: { total: 12 } }, req_3: { code: 0, data: { totalNum: 34 } } })
    +    }
    +    assert.equal(req.req.param.begin, 5)
    +    return response({ code: 0, req: { code: 0, data: { albumList: [], total: 12 } } })
    +  }
    +  assert.deepEqual((await singer.getInfo('mid')).count, { music: 34, album: 12 })
    +  assert.equal((await singer.getAlbumList('mid', 2, 5)).page, 2)
    +})
    +
    +test('QQ lyric cancellation aborts the active request, including before lookup', async () => {
    +  const lyric = (await load('tx/lyric')).default
    +  let requests = 0, aborted = 0, rejectRequest
    +  globalThis.__sdkMigrationHttp = () => {
    +    requests++
    +    return { promise: new Promise((_, reject) => { rejectRequest = reject }), cancelHttp() { aborted++; rejectRequest(new Error('cancelled')) } }
    +  }
    +  const first = lyric.getLyric({ songId: 1 })
    +  first.cancelHttp()
    +  await assert.rejects(first.promise, /Cancel Request/)
    +  assert.equal(requests, 0)
    +  const second = lyric.getLyric({ songId: 1 })
    +  await new Promise(resolve => setImmediate(resolve))
    +  second.cancelHttp()
    +  await assert.rejects(second.promise, /cancelled/)
    +  assert.equal(aborted, 1)
    +})
    +
    +test('QQ lyric retries reject at a bound instead of resolving to a request object', async () => {
    +  const lyric = (await load('tx/lyric')).default
    +  let requests = 0
    +  globalThis.__sdkMigrationHttp = () => { requests++; return response({ code: 0, req: { code: 500 } }) }
    +  await assert.rejects(lyric.getLyric({ songId: 1 }).promise, /Get lyric failed/)
    +  assert.equal(requests, 4)
    +})
    +
    +test('QQ concurrent song-ID lookups share a promise and failed lookups can retry', async () => {
    +  const lyric = (await load('tx/lyric')).default
    +  let requests = 0
    +  globalThis.__sdkMigrationHttp = () => {
    +    requests++
    +    return response({ code: 0, req: { code: 0, data: { track_info: { id: 12, mid: 'mid', title: 'song', singer: [], interval: 120, file: { media_mid: 'media' } } } } })
    +  }
    +  assert.deepEqual(await Promise.all([lyric.getSongId({ songmid: 'mid' }), lyric.getSongId({ songmid: 'mid' })]), [12, 12])
    +  assert.equal(requests, 1)
    +  globalThis.__sdkMigrationHttp = () => response({ code: 500 })
    +  await assert.rejects(lyric.getSongId({ songmid: 'retry-mid' }))
    +  globalThis.__sdkMigrationHttp = () => response({ code: 0, req: { code: 0, data: { track_info: { id: 13, singer: [], file: { media_mid: 'media' } } } } })
    +  assert.equal(await lyric.getSongId({ songmid: 'retry-mid' }), 13)
    +})
    +
    +test('Kuwo UTF-8 decoder handles ordinary and XOR word lyrics', async () => {
    +  const { decodeLyric } = await load('kw/util')
    +  const text = '[00:00.005]测试歌词'
    +  const header = Buffer.from('tp=content\r\n\r\n')
    +  assert.equal(await decodeLyric(Buffer.concat([header, deflateSync(Buffer.from(text))]), false), text)
    +  const key = Buffer.from('yeelion'), buf = Buffer.from(text)
    +  for (let i = 0; i < buf.length; i++) buf[i] ^= key[i % key.length]
    +  assert.equal(await decodeLyric(Buffer.concat([header, deflateSync(Buffer.from(buf.toString('base64')))]), true), text)
    +})
    +
    +test('Netease and Migu preserve five milliseconds as .005', async () => {
    +  const { parseTools } = await load('wy/lyric')
    +  const { mrcTools } = await load('mg/lyric')
    +  assert.equal(parseTools.msFormat(5), '[00:00.005]')
    +  assert.equal(parseTools.msFormat(50, false), '[00:00.05]')
    +  assert.equal(parseTools.msFormat(5, false), '[00:00.00]')
    +  assert.match(mrcTools.parseLyric('[5,100](5,100)字').lyric, /^\[00:00\.005]/)
    +  globalThis.__sdkMigrationHttp = () => response('[00:01.50]一\n[00:02.50]二')
    +  assert.equal((await mrcTools.getLrc('https://example.test/lrc')).lyric, '[00:01.50]一\n[00:02.50]二')
    +})
    +
    +test('Migu uses resource ID and returns a URL rather than a request object', async () => {
    +  const pic = (await load('mg/pic')).default
    +  globalThis.__sdkMigrationHttp = (url, options) => {
    +    assert.equal(options.form.resourceId, 'resource-id')
    +    return response({ code: '000000', resource: [{ songId: 'resource-id', copyrightId: 'copyright', songName: 'song', albumImgs: [{ img: 'https://image.test/cover.jpg' }] }] })
    +  }
    +  assert.equal(await pic.getPic({ songmid: 'resource-id', copyrightId: 'copyright' }), 'https://image.test/cover.jpg')
    +})
    +
    +test('Netease EAPI artist pagination and new song shape keep quality and duration', async () => {
    +  const singer = (await load('wy/singer')).default
    +  const songs = singer.filterSongList([{ id: 1, name: 'song', ar: [{ name: 'artist' }], al: { id: 2, name: 'album', picUrl: 'pic' }, dt: 123000, h: { size: 300 }, privilege: { maxbr: 320000 } }])
    +  assert.equal(songs[0].interval, '02:03')
    +  assert.equal(songs[0].albumName, 'album')
    +  assert.equal(songs[0].img, 'pic')
    +  assert.ok(songs[0]._types['320k'])
    +})
    +
    +test('Kuwo category feed excludes artists and keeps unlabeled playlists and cover sizing', async () => {
    +  const playlists = (await load('kw/songList')).default
    +  const result = playlists.filterList2([{ list: [
    +    { type: 'artist', id: '1', digest: 4 },
    +    { type: 'songlist', id: '2', digest: 8, img: 'https://img.test/star/albumcover/120/a.jpg' },
    +    { type: 'album', id: '3', digest: 13 },
    +  ] }])
    +  assert.deepEqual(result.map(v => v.id), ['digest-8__2', 'digest-13__3'])
    +  assert.match(result[0].img, /albumcover\/1000/)
    +})
    +
    +test('Netease catalogue business errors stop after three attempts', async () => {
    +  const playlists = (await load('wy/songList')).default
    +  let attempts = 0
    +  globalThis.__sdkMigrationHttp = () => { attempts++; return response({ code: 500 }) }
    +  await assert.rejects(playlists.getTag(), /try max num/)
    +  assert.equal(attempts, 3)
    +})
    +
    +test('Cross-source matching excludes a live version of a studio title', async () => {
    +  const sdk = (await load('index')).default
    +  sdk.searchMusic = async () => [{ list: [{ name: 'Song (Live)', singer: 'Artist', albumName: 'Album', interval: '03:00' }] }]
    +  const result = await sdk.findMusic({ name: 'Song', singer: 'Artist', albumName: 'Album', interval: '03:00', source: 'tx' })
    +  assert.deepEqual(result, [])
    +})
    diff --git a/test/search-suggestions.test.cjs b/test/search-suggestions.test.cjs
    new file mode 100644
    index 00000000..c313f77e
    --- /dev/null
    +++ b/test/search-suggestions.test.cjs
    @@ -0,0 +1,171 @@
    +const test = require('node:test');
    +const assert = require('node:assert/strict');
    +const fs = require('node:fs');
    +const vm = require('node:vm');
    +const source = fs.readFileSync('public/music/js/library/search_suggestions.js', 'utf8');
    +const tick = () => new Promise(resolve => setImmediate(resolve));
    +function api() {
    +    const context = vm.createContext({ window: {}, AbortController, setTimeout, clearTimeout });
    +    vm.runInContext(source, context);
    +    return context.window.MusicSuggestions;
    +}
    +
    +test('aggregation normalizes duplicate words, counts each source once and limits results', () => {
    +    const suggestions = api();
    +    const tips = suggestions.merge([['第三代', 'ABC', '第三代', '周杰伦-发如雪'], ['abc', '周杰伦 — 发如雪', '第三代'], ['第三代']], '第三');
    +    assert.equal(tips[0], '第三代');
    +    assert.equal(tips.length, 3);
    +    assert.equal(suggestions.merge([Array.from({ length: 40 }, (_, i) => `词${i}`)], '词').length, 10);
    +});
    +
    +test('fast sources publish before a slow source; a timeout does not clear successful tips', async () => {
    +    const updates = [];
    +    const engine = api().create({ sources: ['kw', 'tx'], timeout: 15, fetchTips: source => source === 'kw' ? Promise.resolve(['第三代']) : new Promise(() => {}) });
    +    const pending = engine.search('第三', 'all', tips => updates.push([...tips]));
    +    await tick();
    +    assert.deepEqual(updates.at(-1), ['第三代']);
    +    assert.deepEqual([...await pending], ['第三代']);
    +});
    +
    +test('replaced and cancelled searches cannot publish even if fetch ignores AbortSignal', async () => {
    +    const pending = [], updates = [];
    +    const engine = api().create({ sources: ['kw'], fetchTips: (_, query) => new Promise(resolve => pending.push({ query, resolve })) });
    +    const old = engine.search('旧', 'all', tips => updates.push(['old', ...tips]));
    +    await tick();
    +    const fresh = engine.search('新', 'kw', tips => updates.push(['new', ...tips]));
    +    await tick();
    +    pending[0].resolve(['旧结果']);
    +    pending[1].resolve(['新结果']);
    +    await Promise.all([old, fresh]);
    +    assert.equal(updates.some(row => row.includes('旧结果')), false);
    +    assert.deepEqual(updates.at(-1), ['new', '新结果']);
    +    const cancel = engine.search('取消', 'kw', tips => updates.push(['cancel', ...tips]));
    +    await tick(); engine.cancel(); pending[2].resolve(['不能显示']); await cancel;
    +    assert.equal(updates.some(row => row.includes('不能显示')), false);
    +});
    +
    +test('cache is shared between aggregate and single-source requests, expires and stays bounded', async () => {
    +    let calls = 0, time = 0;
    +    const engine = api().create({ sources: ['kw'], ttl: 10, capacity: 2, now: () => time, fetchTips: async (_, query) => { calls++; return [query]; } });
    +    await engine.search('a', 'all', () => {});
    +    await engine.search('a', 'kw', () => {});
    +    assert.equal(calls, 1);
    +    time = 11;
    +    await engine.search('a', 'kw', () => {});
    +    assert.equal(calls, 2);
    +    await engine.search('b', 'kw', () => {});
    +    await engine.search('c', 'kw', () => {});
    +    await engine.search('a', 'kw', () => {});
    +    assert.equal(calls, 5);
    +});
    +
    +test('an unavailable source is retried rather than caching an empty or malformed response', async () => {
    +    let calls = 0;
    +    const engine = api().create({ sources: ['kw'], fetchTips: async () => { calls++; return calls === 1 ? { error: 'failed' } : calls === 2 ? [] : ['第三代']; } });
    +    for (let i = 0; i < 3; i++) await engine.search('第三', 'kw', () => {});
    +    assert.equal(calls, 3);
    +});
    +
    +function fixture() {
    +    class Node {
    +        constructor(id = '') { this.id = id; this.value = ''; this.children = []; this.dataset = {}; this.events = {}; this.attributes = {}; this.classes = new Set();
    +            this.classList = { add: value => this.classes.add(value), remove: value => this.classes.delete(value), contains: value => this.classes.has(value), toggle: (value, active) => active ? this.classes.add(value) : this.classes.delete(value) };
    +        }
    +        addEventListener(name, fn) { this.events[name] = fn; }
    +        emit(name, event = {}) { return this.events[name]?.({ target: this, preventDefault() {}, stopPropagation() {}, ...event }); }
    +        setAttribute(name, value) { this.attributes[name] = value; }
    +        removeAttribute(name) { delete this.attributes[name]; }
    +        append(...nodes) { this.children.push(...nodes); }
    +        appendChild(node) { this.children.push(node); }
    +        replaceChildren() { this.children = []; }
    +        querySelectorAll() { return this.children; }
    +        contains(node) { return this === node || this.children.some(child => child.contains(node)); }
    +        scrollIntoView() {}
    +    }
    +    const nodes = Object.fromEntries(['search-input', 'search-source', 'search-type', 'search-suggestions', 'search-suggestions-list'].map(id => [id, new Node(id)]));
    +    nodes['search-source'].value = 'all'; nodes['search-type'].value = 'song'; nodes['search-suggestions'].classes.add('hidden');
    +    const events = {}, timers = new Map(), requests = [];
    +    let sequence = 0, searches = 0;
    +    const document = { readyState: 'loading', activeElement: nodes['search-input'], getElementById: id => nodes[id], createElement: () => new Node(), addEventListener: (name, fn) => { events[name] = fn; } };
    +    const context = vm.createContext({ window: { currentSearchScope: 'network', doSearch: () => searches++ }, document, AbortController,
    +        setTimeout: (fn, ms) => { const id = ++sequence; timers.set(id, { fn, ms }); return id; }, clearTimeout: id => timers.delete(id),
    +        fetch: (url, options) => new Promise(resolve => requests.push({ url, signal: options.signal, resolve: tips => resolve({ ok: true, json: async () => tips }) })),
    +    });
    +    vm.runInContext(source, context); events.DOMContentLoaded();
    +    const runDebounce = () => { for (const [id, { fn, ms }] of [...timers]) if (ms === 300) { timers.delete(id); fn(); } };
    +    return { nodes, context, requests, runDebounce, searches: () => searches };
    +}
    +
    +test('IME sends no request until composition ends, and selecting a word searches once in aggregate mode', async () => {
    +    const f = fixture(), field = f.nodes['search-input'];
    +    field.value = 'di san'; field.emit('compositionstart'); field.emit('input', { isComposing: true }); f.runDebounce();
    +    let prevented = 0;
    +    f.context.window.SearchSuggestions.submit({ preventDefault: () => prevented++ });
    +    await tick(); assert.equal(f.requests.length, 0); assert.equal(f.searches(), 0);
    +    field.value = '第三'; field.emit('compositionend'); field.emit('input'); f.runDebounce(); await tick();
    +    assert.equal(f.requests.length, 5);
    +    assert.ok(f.requests.every(request => !request.url.includes('source=all')));
    +    f.requests.forEach(request => request.resolve(['第三代', '第三人称'])); await tick();
    +    field.emit('keydown', { key: 'ArrowDown' });
    +    field.emit('keydown', { key: 'Enter', preventDefault: () => prevented++ });
    +    assert.equal(field.value, '第三代'); assert.equal(f.searches(), 1); assert.equal(prevented, 2);
    +    assert.equal(f.nodes['search-source'].value, 'all'); assert.equal(field.attributes['aria-expanded'], 'false');
    +});
    +
    +test('input changes abort immediately, not after debounce; Escape and formal search prevent reopening', async () => {
    +    const f = fixture(), field = f.nodes['search-input'];
    +    field.value = '旧'; field.emit('input'); f.runDebounce(); await tick();
    +    field.value = '新'; field.emit('input');
    +    assert.ok(f.requests.every(request => request.signal.aborted));
    +    f.requests.forEach(request => request.resolve(['旧词'])); await tick();
    +    assert.equal(f.nodes['search-suggestions'].classList.contains('hidden'), true);
    +    f.runDebounce(); await tick();
    +    f.requests[5].resolve(['新词']); await tick();
    +    assert.equal(field.attributes['aria-expanded'], 'true');
    +    field.emit('keydown', { key: 'Escape' });
    +    f.requests.slice(6).forEach(request => request.resolve(['不能重新弹出'])); await tick();
    +    assert.equal(field.attributes['aria-expanded'], 'false');
    +    f.context.window.SearchSuggestions.submit({ preventDefault() {} });
    +    assert.equal(f.searches(), 1);
    +});
    +
    +test('progressive reordering preserves the keyboard-selected word; platform text never becomes HTML', async () => {
    +    const f = fixture(), field = f.nodes['search-input'];
    +    field.value = '第'; field.emit('input'); f.runDebounce(); await tick();
    +    f.requests[0].resolve(['第二', '']); await tick();
    +    field.emit('keydown', { key: 'ArrowDown' });
    +    f.requests[1].resolve(['第一', '第一', '第二']); f.requests[2].resolve(['第一']); await tick();
    +    const list = f.nodes['search-suggestions-list'];
    +    const active = list.children.find(child => child.attributes['aria-selected'] === 'true');
    +    assert.equal(active.dataset.tip, '第二');
    +    const htmlText = list.children.find(child => child.dataset.tip.startsWith('');
    +    assert.equal(htmlText.children[1].innerHTML, undefined);
    +    f.context.window.SearchSuggestions.hide();
    +});
    +
    +test('changing source or leaving the input prevents a late response from rendering', async () => {
    +    const f = fixture(), field = f.nodes['search-input'];
    +    field.value = '第三'; field.emit('input'); f.runDebounce(); await tick();
    +    f.nodes['search-source'].value = 'tx'; f.nodes['search-source'].emit('change');
    +    f.requests.forEach(request => request.resolve(['第三代'])); await tick();
    +    assert.equal(field.attributes['aria-expanded'], 'false');
    +    field.emit('focus'); await tick();
    +    assert.equal(f.requests.length, 6);
    +    field.emit('blur'); f.context.document.activeElement = null;
    +    f.requests[5].resolve(['第三代']); await tick();
    +    assert.equal(field.attributes['aria-expanded'], 'false');
    +});
    +
    +test('backend isolates each request from SDK singleton cancellation state', async () => {
    +    const server = fs.readFileSync('src/server/server.ts', 'utf8');
    +    const start = server.indexOf("if (pathname === '/api/music/tipSearch'");
    +    const end = server.indexOf('// [新增] 获取歌手详情 API', start);
    +    const body = server.slice(start, end).replace('catch (err: any)', 'catch (err)');
    +    let cancelled = 0;
    +    const sdk = { kw: { tipSearch: { requestObj: { cancelHttp: () => cancelled++ }, async search(name) { this.requestObj?.cancelHttp(); this.requestObj = { cancelHttp: () => cancelled++ }; await tick(); return [name]; } } } };
    +    const invoke = new Function('musicSdk', 'urlObj', 'res', `return (async () => { const pathname='/api/music/tipSearch', req={method:'GET'}; ${body} })()`);
    +    const results = [];
    +    await Promise.all(['一', '二'].map(name => invoke(sdk, new URL(`https://example.test/api/music/tipSearch?source=kw&name=${name}`), { writeHead() {}, end(text) { results.push(JSON.parse(text)[0]); } })));
    +    assert.equal(cancelled, 0); assert.deepEqual(results.sort(), ['一', '二']);
    +});
    diff --git a/test/snapshot-imported.test.ts b/test/snapshot-imported.test.ts
    new file mode 100644
    index 00000000..403bd0c3
    --- /dev/null
    +++ b/test/snapshot-imported.test.ts
    @@ -0,0 +1,134 @@
    +import test from 'node:test'
    +import assert from 'node:assert/strict'
    +import fs from 'node:fs'
    +import os from 'node:os'
    +import path from 'node:path'
    +import vm from 'node:vm'
    +import { transformSync } from 'esbuild'
    +
    +/**
    + * 提取 SnapshotDataManage 中与 imported 相关的真实成员,放进最小 Harness 里执行。
    + * 歌单与不喜欢两份实现分别加载,用同一组断言验证行为对称。
    + */
    +const MEMBERS = [
    +  'private getImported =',
    +  'private persistSnapshotInfo =',
    +  'markImported =',
    +  'unmarkImported =',
    +  'isImported =',
    +  'getSnapshotListWithMeta =',
    +]
    +
    +const extractMember = (lines: string[], marker: string): string => {
    +  const start = lines.findIndex(line => line.includes(marker))
    +  assert.ok(start >= 0, `找不到成员 ${marker}`)
    +  let end = start + 1
    +  for (; end < lines.length; end++) {
    +    if (/^  [A-Za-z_]\w*\s*[=(]/.test(lines[end])) break
    +  }
    +  return lines.slice(start, end).join('\n')
    +}
    +
    +const build = (rel: string, dir: string) => {
    +  const src = fs.readFileSync(path.resolve(rel), 'utf8')
    +  const lines = src.split('\n')
    +  const members = MEMBERS.map(m => extractMember(lines, m)).join('\n')
    +  const infoPath = path.join(dir, 'snapshotInfo.json')
    +  const snapshotDir = path.join(dir, 'snapshot')
    +  fs.mkdirSync(snapshotDir, { recursive: true })
    +  const harness = `
    +class Harness {
    +  snapshotInfo = { latest: null, time: 0, list: [], clients: {} }
    +  snapshotInfoFilePath = ${JSON.stringify(infoPath)}
    +  snapshotDir = ${JSON.stringify(snapshotDir)}
    +${members}
    +}
    +globalThis.Harness = Harness
    +`
    +  const context = vm.createContext({
    +    fs,
    +    path,
    +    syncLog: { warn() {}, error() {} },
    +    console,
    +    global: { lx: {} },
    +  })
    +  vm.runInContext(transformSync(harness, { loader: 'ts', format: 'cjs', target: 'node20' }).code, context)
    +  return { instance: new (context as any).Harness(), infoPath, snapshotDir }
    +}
    +
    +const plain = (value: T): T => JSON.parse(JSON.stringify(value))
    +const md5 = 'a'.repeat(32)
    +
    +for (const rel of ['src/modules/list/snapshotDataManage.ts', 'src/modules/dislike/snapshotDataManage.ts']) {
    +  const label = rel.includes('/list/') ? '歌单' : '不喜欢'
    +
    +  test(`S6 ${label}:旧索引无 imported 字段时兼容降级`, () => {
    +    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-imported-'))
    +    try {
    +      const { instance } = build(rel, dir)
    +      assert.equal(instance.isImported(md5), false, '32 位 md5 应视为系统快照')
    +      assert.equal(instance.isImported('user-backup'), true, '非 md5 文件名应视为用户导入')
    +      assert.deepEqual(plain(instance.snapshotInfo.imported), [])
    +    } finally { fs.rmSync(dir, { recursive: true, force: true }) }
    +  })
    +
    +  test(`S6 ${label}:来源登记幂等且落盘持久化`, () => {
    +    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-imported-'))
    +    try {
    +      const { instance, infoPath } = build(rel, dir)
    +      instance.markImported('backup-1')
    +      instance.markImported('backup-1')
    +      instance.markImported('backup-2')
    +      assert.deepEqual(plain(instance.snapshotInfo.imported), ['backup-1', 'backup-2'])
    +      assert.deepEqual(JSON.parse(fs.readFileSync(infoPath, 'utf8')).imported, ['backup-1', 'backup-2'])
    +      instance.unmarkImported('not-there')
    +      assert.deepEqual(plain(instance.snapshotInfo.imported), ['backup-1', 'backup-2'])
    +      instance.unmarkImported('backup-1')
    +      assert.deepEqual(plain(instance.snapshotInfo.imported), ['backup-2'])
    +      instance.markImported(md5)
    +      assert.equal(instance.isImported(md5), true, '显式登记应优先于 md5 推断')
    +    } finally { fs.rmSync(dir, { recursive: true, force: true }) }
    +  })
    +
    +  test(`S6 ${label}:快照列表 origin 判定与按时间倒序`, async () => {
    +    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-imported-'))
    +    try {
    +      const { instance, snapshotDir } = build(rel, dir)
    +      const autoFile = path.join(snapshotDir, `snapshot_${md5}`)
    +      const importFile = path.join(snapshotDir, 'snapshot_user-backup')
    +      fs.writeFileSync(autoFile, '{}')
    +      fs.writeFileSync(importFile, '{}')
    +      fs.utimesSync(importFile, new Date(1000), new Date(1000))
    +      fs.utimesSync(autoFile, new Date(2000), new Date(2000))
    +      const list = plain(await instance.getSnapshotListWithMeta())
    +      assert.equal(list.length, 2)
    +      assert.equal(list[0].id, md5)
    +      assert.equal(list[0].origin, 'auto')
    +      assert.equal(list[1].id, 'user-backup')
    +      assert.equal(list[1].origin, 'imported')
    +    } finally { fs.rmSync(dir, { recursive: true, force: true }) }
    +  })
    +}
    +
    +test('S7 双实现对称性:歌单与不喜欢的 imported 语义一致', () => {
    +  const a = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-imported-a-'))
    +  const b = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-imported-b-'))
    +  try {
    +    const x = build('src/modules/list/snapshotDataManage.ts', a).instance
    +    const y = build('src/modules/dislike/snapshotDataManage.ts', b).instance
    +    for (const inst of [x, y]) {
    +      inst.markImported('same')
    +      inst.markImported('same')
    +      inst.markImported(md5)
    +    }
    +    assert.deepEqual(plain(x.snapshotInfo.imported), plain(y.snapshotInfo.imported))
    +    assert.equal(x.isImported('same'), y.isImported('same'))
    +    assert.equal(x.isImported(md5), y.isImported(md5))
    +    assert.equal(x.isImported('other'), y.isImported('other'))
    +    for (const inst of [x, y]) inst.unmarkImported('same')
    +    assert.deepEqual(plain(x.snapshotInfo.imported), plain(y.snapshotInfo.imported))
    +  } finally {
    +    fs.rmSync(a, { recursive: true, force: true })
    +    fs.rmSync(b, { recursive: true, force: true })
    +  }
    +})
    diff --git a/test/snapshot-rating-wiring.test.ts b/test/snapshot-rating-wiring.test.ts
    new file mode 100644
    index 00000000..1a4c068d
    --- /dev/null
    +++ b/test/snapshot-rating-wiring.test.ts
    @@ -0,0 +1,185 @@
    +import test from 'node:test'
    +import assert from 'node:assert/strict'
    +import fs from 'node:fs'
    +import path from 'node:path'
    +import vm from 'node:vm'
    +import { transformSync } from 'esbuild'
    +
    +/**
    + * 从真实源码抽出两段可独立执行的逻辑:
    + *  1. server.ts 顶层的备份类型判定 / playList_v2 转换(上传快照分流的核心)
    + *  2. /api/subsonic/sync-link 路由的业务校验与分流
    + * 另外用 public/app.js 的真实判定表达式做前后端口径一致性校验,防止两类目录写串。
    + */
    +const serverSource = fs.readFileSync(path.resolve('src/server/server.ts'), 'utf8')
    +const appSource = fs.readFileSync(path.resolve('public/app.js'), 'utf8')
    +
    +const slice = (from: string, to: string): string => {
    +  const start = serverSource.indexOf(from)
    +  const end = serverSource.indexOf(to, start + from.length)
    +  assert.ok(start >= 0 && end > start, `无法从 server.ts 定位 ${from} ~ ${to}`)
    +  return serverSource.slice(start, end)
    +}
    +
    +const runInVm = (code: string, context: Record, exportExpr: string): T => {
    +  const sandbox = vm.createContext({ console, ...context })
    +  vm.runInContext(transformSync(`${code}\nglobalThis.__export = ${exportExpr}`, {
    +    loader: 'ts', format: 'cjs', target: 'node20',
    +  }).code, sandbox)
    +  return (sandbox as any).__export as T
    +}
    +
    +const kindBlock = slice('type SnapshotKind = ', 'async function validateOpenListConfig')
    +const kindApi = runInVm<{
    +  detectSnapshotKind: (parsed: unknown) => 'list' | 'dislike' | 'rating'
    +  toInternalListSnapshot: (parsed: unknown) => string | null
    +}>(kindBlock, {}, '{ detectSnapshotKind, toInternalListSnapshot }')
    +
    +/** public/app.js 中真实的 isRatingBackup / kindLabel 判定,注入同一批样本比对 */
    +const frontStart = appSource.indexOf('        const isRatingBackup = ')
    +const frontEnd = appSource.indexOf('        // 目标用户由超管', frontStart)
    +assert.ok(frontStart >= 0 && frontEnd > frontStart, '无法从 public/app.js 定位导入类型判定')
    +const frontKind = new Function('jsonData', `${appSource.slice(frontStart, frontEnd)}\nreturn kindLabel`) as (data: unknown) => string
    +
    +const samples: Array<{ name: string, value: unknown, kind: 'list' | 'dislike' | 'rating' }> = [
    +  { name: '非 JSON / 空值', value: null, kind: 'list' },
    +  { name: '原生歌单快照', value: { defaultList: [{ id: 'a' }], loveList: [], userList: [] }, kind: 'list' },
    +  { name: 'playList_v2 离线备份', value: { type: 'playList_v2', data: [{ id: 'default', list: [] }, { id: 'love', list: [] }] }, kind: 'list' },
    +  { name: '原生不喜欢快照', value: { dislikeList: ['a@歌手@歌曲'] }, kind: 'dislike' },
    +  { name: 'lxRating_v1 评分快照', value: { type: 'lxRating_v1', ratings: { a: 5 }, starredAlbums: [] }, kind: 'rating' },
    +  { name: '旧版评分快照(无 type)', value: { ratings: { a: 3 }, starredAlbums: [], starredArtists: [], starredArtistNames: [] }, kind: 'rating' },
    +  { name: 'type 优先于 dislikeList', value: { type: 'lxRating_v1', ratings: {}, dislikeList: [] }, kind: 'rating' },
    +  { name: '歌单字段优先于 ratings', value: { ratings: { a: 1 }, defaultList: [] }, kind: 'list' },
    +  { name: '空对象', value: {}, kind: 'list' },
    +  { name: '数组', value: [], kind: 'list' },
    +  { name: '字符串', value: 'not an object', kind: 'list' },
    +]
    +
    +test('S19 备份类型判定:歌单 / 不喜欢 / 评分三类分流', () => {
    +  for (const s of samples) {
    +    assert.equal(kindApi.detectSnapshotKind(s.value), s.kind, `样本「${s.name}」应判为 ${s.kind}`)
    +  }
    +})
    +
    +test('S19 前后端导入类型判定口径一致(避免上传到错误目录)', () => {
    +  const label: Record = { list: '我的歌单', dislike: '我不喜欢', rating: '我的评分' }
    +  for (const s of samples) {
    +    assert.equal(frontKind(s.value), label[kindApi.detectSnapshotKind(s.value)], `样本「${s.name}」前后端判定不一致`)
    +  }
    +})
    +
    +test('S19 playList_v2 转回内部结构:default / love / 其余列表', () => {
    +  const converted = kindApi.toInternalListSnapshot({
    +    type: 'playList_v2',
    +    data: [
    +      { id: 'default', list: [{ id: 'd' }] },
    +      { id: 'love', list: [{ id: 'l' }] },
    +      { id: 'custom', list: [{ id: 'c' }] },
    +    ],
    +  })
    +  assert.ok(converted)
    +  assert.deepEqual(JSON.parse(converted), {
    +    defaultList: [{ id: 'd' }],
    +    loveList: [{ id: 'l' }],
    +    userList: [{ id: 'custom', list: [{ id: 'c' }] }],
    +  })
    +})
    +
    +test('S19 playList_v2 转换:非该格式返回 null,残缺 data 不抛异常', () => {
    +  assert.equal(kindApi.toInternalListSnapshot(null), null)
    +  assert.equal(kindApi.toInternalListSnapshot({ type: 'lxRating_v1', ratings: {} }), null)
    +  assert.equal(kindApi.toInternalListSnapshot({ type: 'playList_v2', data: 'bad' }), null)
    +  const partial = kindApi.toInternalListSnapshot({ type: 'playList_v2', data: [null, { id: 'default', list: [] }] })
    +  assert.ok(partial)
    +  assert.deepEqual(JSON.parse(partial).defaultList, [])
    +})
    +
    +const syncLinkBody = slice(
    +  '          const { user, direction, preview, reconcile } = JSON.parse(await readBody(req))',
    +  '        } catch (err: any) {',
    +)
    +
    +const runSyncLink = async (options: {
    +  body: unknown
    +  config?: Record
    +  users?: string[]
    +}): Promise<{ ok: boolean, payload?: any, error?: string }> => {
    +  const written: string[] = []
    +  const res = { writeHead: () => {}, end: (text: string) => written.push(text) }
    +  const config = {
    +    users: (options.users ?? ['alice']).map(name => ({ name })),
    +    ...options.config,
    +  }
    +  const context = {
    +    req: {},
    +    global: { lx: { config } },
    +    readBody: async () => JSON.stringify(options.body),
    +    syncRatingsToDislike: async (user: string) => ({ direction: 'ratingToDislike', user, scanned: 1, added: 1, skipped: 0, failed: 0, threshold: 1, batches: 1 }),
    +    syncDislikesToRating: async (user: string) => ({ direction: 'dislikeToRating', user, scanned: 2, added: 0, skipped: 2, failed: 0, threshold: 1, batches: 1 }),
    +    res,
    +  }
    +  const run = runInVm<(user?: string) => Promise>(
    +    `async function run() {\n${syncLinkBody}\n}`,
    +    context,
    +    'run',
    +  )
    +  try {
    +    await run()
    +    return { ok: true, payload: written.length ? JSON.parse(written[0]) : null }
    +  } catch (err: any) {
    +    return { ok: false, error: err.message }
    +  }
    +}
    +
    +test('S20 /api/subsonic/sync-link:非法 direction / 缺用户被拒绝', async () => {
    +  const badDirection = await runSyncLink({ body: { user: 'alice', direction: 'unknown' } })
    +  assert.equal(badDirection.ok, false)
    +  assert.match(badDirection.error || '', /Invalid direction/)
    +
    +  const noUser = await runSyncLink({ body: { direction: 'ratingToDislike' } })
    +  assert.equal(noUser.ok, false)
    +  assert.match(noUser.error || '', /Missing user/)
    +})
    +
    +test('S20 /api/subsonic/sync-link:开关未开启时不得执行同步', async () => {
    +  const result = await runSyncLink({ body: { user: 'alice', direction: 'ratingToDislike' } })
    +  assert.equal(result.ok, false, '服务端必须独立校验开关,不能只依赖前端')
    +  assert.match(result.error || '', /联动开关未开启/)
    +})
    +
    +test('S20 /api/subsonic/sync-link:未知用户不得创建用户空间', async () => {
    +  const result = await runSyncLink({
    +    body: { user: 'mallory', direction: 'ratingToDislike' },
    +    config: { 'subsonic.linkRatingToDislike': true },
    +  })
    +  assert.equal(result.ok, false)
    +  assert.match(result.error || '', /用户不存在/)
    +})
    +
    +test('S20 /api/subsonic/sync-link:两个方向分别调用对应同步实现', async () => {
    +  const forward = await runSyncLink({
    +    body: { user: 'alice', direction: 'ratingToDislike' },
    +    config: { 'subsonic.linkRatingToDislike': true },
    +  })
    +  assert.equal(forward.ok, true, forward.error)
    +  assert.equal(forward.payload.direction, 'ratingToDislike')
    +  assert.equal(forward.payload.added, 1)
    +
    +  const backward = await runSyncLink({
    +    body: { user: 'bob', direction: 'dislikeToRating' },
    +    config: { 'subsonic.linkDislikeToRating': true },
    +    users: ['alice', 'bob'],
    +  })
    +  assert.equal(backward.ok, true, backward.error)
    +  assert.equal(backward.payload.direction, 'dislikeToRating')
    +  assert.equal(backward.payload.user, 'bob')
    +})
    +
    +test('S20 /api/subsonic/sync-link:_open 公开用户同样允许同步', async () => {
    +  const result = await runSyncLink({
    +    body: { user: '_open', direction: 'ratingToDislike' },
    +    config: { 'subsonic.linkRatingToDislike': true },
    +  })
    +  assert.equal(result.ok, true, result.error)
    +  assert.equal(result.payload.user, '_open')
    +})
    diff --git a/test/subsonic-local-playlist.test.ts b/test/subsonic-local-playlist.test.ts
    new file mode 100644
    index 00000000..651ad11f
    --- /dev/null
    +++ b/test/subsonic-local-playlist.test.ts
    @@ -0,0 +1,95 @@
    +import test from 'node:test'
    +import assert from 'node:assert/strict'
    +import fs from 'node:fs'
    +import path from 'node:path'
    +import vm from 'node:vm'
    +import { transformSync } from 'esbuild'
    +
    +const source = fs.readFileSync('src/server/integrations/subsonic.ts', 'utf8')
    +const start = source.indexOf('    private async getLocalLibraryPlaylist(')
    +const end = source.indexOf('    private playlistCoverId(', start)
    +assert.ok(start > 0 && end > start)
    +const detailStart = source.indexOf('    private async handleGetPlaylist(')
    +const detailEnd = source.indexOf("        if (id.startsWith('lb_'))", detailStart)
    +assert.ok(detailStart > 0 && detailEnd > detailStart)
    +
    +for (const location of ['data', 'root']) {
    +  test(`local playlist cache selection follows ${location} storage and updates without rescanning`, async () => {
    +    const config: Record = {}
    +    let customDir = path.resolve(location, 'custom')
    +    let stateReads = 0
    +    let songConversions = 0
    +    const tracks = ['music', 'cache', 'custom'].map((folder, i) => ({
    +      id: folder, root: path.resolve(location, folder), title: folder, duration: i + 1, mtime: 1000,
    +    }))
    +    const context = vm.createContext({
    +      global: { lx: { config } }, path,
    +      getCacheDir: (user: string, music: boolean) => {
    +        assert.equal(user, 'alice')
    +        return path.resolve(location, music ? 'music' : 'cache')
    +      },
    +      getCustomMusicDir: () => customDir,
    +      getLocalTracks: async () => tracks,
    +      localMusic: (track: any) => track,
    +      readLocalState: () => { stateReads++; return { starred: { music: 'starred' } } },
    +      localSong: (track: any, _user: string, state: any) => {
    +        songConversions++
    +        return { ...track, starred: state.starred[track.id] }
    +      },
    +      localRatingId: (track: any) => track.id,
    +    })
    +    vm.runInContext(transformSync(`class Harness {
    +      userRatingsCache = new Map([['alice', { music: 5 }]])
    +      musicToSongFlat(t) { return t }
    +      ${source.slice(start, end)}
    +      ${source.slice(detailStart, detailEnd)}
    +      }
    +      sendError(_res, code) { return { error: code } }
    +      sendLocalResponse(_res, data) { return data }
    +    }; globalThis.harness = new Harness()`, { loader: 'ts' }).code, context)
    +    const harness = (context as any).harness
    +    config['subsonic.localPlaylist'] = false
    +    for (const id of ['local-library', 'custom-library']) {
    +      const denied = await harness.handleGetPlaylist({}, 'alice', new URLSearchParams({ id }), 'json')
    +      assert.equal(denied.error, 70)
    +    }
    +    assert.equal(stateReads, 0)
    +    config['subsonic.localPlaylist'] = true
    +    config['subsonic.customPlaylist'] = false
    +    assert.equal((await harness.handleGetPlaylist({}, 'alice', new URLSearchParams({ id: 'custom-library' }), 'json')).error, 70)
    +    const response = await harness.handleGetPlaylist({}, 'alice', new URLSearchParams({ id: 'local-library' }), 'json')
    +    assert.equal(response.playlist.songCount, 1)
    +    assert.equal(response.playlist.entry[0].id, 'music')
    +    const check = async (custom: boolean, ids: string[], duration: number) => {
    +      const result = await harness.getLocalLibraryPlaylist('alice', custom)
    +      assert.deepEqual(Array.from(result.songs, (t: any) => t.id).sort(), ids)
    +      assert.equal(result.meta.songCount, ids.length)
    +      assert.equal(result.meta.duration, duration)
    +    }
    +    await check(false, ['music'], 1)
    +    await check(true, ['custom'], 3)
    +    config['subsonic.cachePlaylist'] = true
    +    await check(false, ['cache', 'music'], 3)
    +    await check(true, ['custom'], 3)
    +    config['subsonic.cachePlaylist'] = false
    +    await check(false, ['music'], 1)
    +    const before = { stateReads, songConversions }
    +    const metadata = await harness.getLocalLibraryPlaylist('alice', false, false)
    +    assert.equal(metadata.meta.songCount, 1)
    +    assert.equal(metadata.songs.length, 0)
    +    assert.deepEqual({ stateReads, songConversions }, before)
    +    config['subsonic.cachePlaylist'] = true
    +    const detail = await harness.getLocalLibraryPlaylist('alice')
    +    assert.equal(stateReads, before.stateReads + 1)
    +    assert.equal(detail.songs.find((t: any) => t.id === 'music').starred, 'starred')
    +    assert.equal(detail.songs.find((t: any) => t.id === 'music').userRating, 5)
    +    for (const folder of ['music', 'cache']) {
    +      customDir = path.resolve(location, folder)
    +      await check(false, ['cache', 'music'], 3)
    +      await check(true, [], 0)
    +      config['subsonic.cachePlaylist'] = false
    +      await check(false, ['music'], 1)
    +      config['subsonic.cachePlaylist'] = true
    +    }
    +  })
    +}
    diff --git a/test/subsonic-rating-playlists.test.ts b/test/subsonic-rating-playlists.test.ts
    new file mode 100644
    index 00000000..ef34968d
    --- /dev/null
    +++ b/test/subsonic-rating-playlists.test.ts
    @@ -0,0 +1,174 @@
    +import test from 'node:test'
    +import assert from 'node:assert/strict'
    +import fs from 'node:fs'
    +import path from 'node:path'
    +import vm from 'node:vm'
    +import { transformSync } from 'esbuild'
    +
    +/** 从 subsonic.ts 抽出评分歌单的两个私有方法,隔离其余依赖后放进 vm 执行 */
    +const source = fs.readFileSync(path.resolve('src/server/integrations/subsonic.ts'), 'utf8')
    +const start = source.indexOf('    private async getRatingPlaylistSongs')
    +const end = source.indexOf('    private async handleGetPlaylists', start)
    +assert.ok(start > 0 && end > start, '无法从 subsonic.ts 中定位评分歌单方法')
    +const methods = source.slice(start, end)
    +
    +const harness = `
    +class Harness {
    +  onlineSongCache = new Map()
    +  meta = { ratings: {} }
    +  listData = { loveList: [], defaultList: [], userList: [] }
    +  disliked = new Set()
    +  loadOnlineSongCache() {}
    +  async getUserSubsonicMeta() { return this.meta }
    +  async getSubsonicListData() { return this.listData }
    +  async filterDislikedSongs(_u, arr) { return arr.filter(m => !this.disliked.has(m.id)) }
    +${methods}
    +}
    +globalThis.Harness = Harness
    +`
    +
    +const build = (ratingPlaylists: boolean | undefined = undefined) => {
    +  const config: Record = {}
    +  if (ratingPlaylists !== undefined) config['subsonic.ratingPlaylists'] = ratingPlaylists
    +  const context = vm.createContext({
    +    global: { lx: { config } },
    +    getLocalTracks: async () => [],
    +    localMusic: (x: any) => x,
    +    localRatingId: (track: any) => track.platformId || track.id,
    +    withTimeout: (p: any) => p,
    +    LINK_SYNC_ITEM_TIMEOUT_MS: 8000,
    +    subsonicLog: { warn() {} },
    +    console,
    +  })
    +  vm.runInContext(transformSync(harness, { loader: 'ts', format: 'cjs', target: 'node20' }).code, context)
    +  return { instance: new (context as any).Harness(), config }
    +}
    +
    +const song = (id: string) => ({ id, name: id })
    +
    +test('S5 ratings 值域:非整数 / 越界 / 字符串评分不得让 getPlaylists 抛错', async () => {
    +  const { instance } = build()
    +  instance.meta = { ratings: { a: 1.5, b: 2.1, c: 0, d: 6, e: -1, f: '3', g: null, h: NaN, good: 3 } }
    +  instance.listData = {
    +    loveList: [],
    +    defaultList: ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'good'].map(song),
    +    userList: [],
    +  }
    +  const groups = await instance.getRatingPlaylistSongs('alice')
    +  assert.equal(groups.get(3).length, 1, '只有整数 3 应进入 3 星歌单')
    +  for (const star of [1, 2, 4, 5]) assert.equal(groups.get(star).length, 0)
    +})
    +
    +test('高评分屏蔽歌曲同时进入原星级与不喜欢合集', async () => {
    +  const { instance } = build(true)
    +  instance.meta = { ratings: { a: 5 } }
    +  instance.listData = { loveList: [], defaultList: [song('a')], userList: [] }
    +  instance.disliked = new Set(['a'])
    +  const groups = await instance.getRatingPlaylistSongs('alice')
    +  assert.equal(groups.get(1)[0].id, 'a')
    +  assert.equal(groups.get(5)[0].id, 'a')
    +})
    +
    +test('缓存淘汰后解析已评分歌曲,排除专辑和歌手', async () => {
    +  const { instance } = build(true)
    +  instance.meta = { ratings: { lost: 5, alb_wy_1: 5, art_tx_2: 4 } }
    +  const calls: string[] = []
    +  instance.resolveSongMeta = async (_user: string, id: string) => { calls.push(id); return { music: song(id) } }
    +  const groups = await instance.getRatingPlaylistSongs('alice')
    +  assert.equal(groups.get(5)[0].id, 'lost')
    +  assert.deepEqual(calls, ['lost'])
    +})
    +
    +test('S8 开关:默认开启,显式 false 关闭', () => {
    +  assert.equal(build().instance.isRatingPlaylistEnabled(), true)
    +  assert.equal(build(true).instance.isRatingPlaylistEnabled(), true)
    +  assert.equal(build(false).instance.isRatingPlaylistEnabled(), false)
    +})
    +
    +test('S8 1 星歌单 = 1 星歌曲 ∪ dislike 命中;关闭开关后不再并入', async () => {
    +  const enabled = build(true)
    +  enabled.instance.meta = { ratings: { a: 1 } }
    +  enabled.instance.listData = { loveList: [], defaultList: [song('a'), song('b')], userList: [] }
    +  enabled.instance.disliked = new Set(['b'])
    +  const on = await enabled.instance.getRatingPlaylistSongs('alice')
    +  assert.deepEqual(Array.from(on.get(1)).map((m: any) => m.id).sort(), ['a', 'b'])
    +
    +  const disabled = build(false)
    +  disabled.instance.meta = { ratings: { a: 1 } }
    +  disabled.instance.listData = { loveList: [], defaultList: [song('a'), song('b')], userList: [] }
    +  disabled.instance.disliked = new Set(['b'])
    +  const off = await disabled.instance.getRatingPlaylistSongs('alice')
    +  assert.deepEqual(Array.from(off.get(1)).map((m: any) => m.id), ['a'])
    +})
    +
    +test('S8 只读语义与配置契约:虚拟歌单不得被写入或删除', () => {
    +  assert.match(source, /RATING_PLAYLIST_PREFIX = 'rating_'/)
    +  assert.match(source, /if \(this\.isRatingPlaylistEnabled\(\)\)/)
    +  assert.match(source, /评星歌单为只读播放列表,请通过给歌曲评星来修改/)
    +  assert.match(source, /评星歌单为只读播放列表,不支持删除/)
    +  assert.match(source, /Rating playlists are disabled/)
    +  assert.match(source, /if \(!global\.lx\.config\['subsonic\.linkRatingToDislike'\]/)
    +  const defaults = fs.readFileSync(path.resolve('src/defaultConfig.ts'), 'utf8')
    +  assert.match(defaults, /'subsonic\.ratingPlaylists': true/)
    +})
    +
    +/** 从 handleGetPlaylists 抽出「生成评星歌单」的真实代码块,验证空星级是否会被跳过 */
    +const blockStart = source.indexOf('        // [新增] 评星歌单:1~5 星')
    +const blockEnd = source.indexOf("        if (format === 'json')", blockStart)
    +assert.ok(blockStart > 0 && blockEnd > blockStart, '无法从 subsonic.ts 中定位评星歌单生成块')
    +const ratingPlaylistBlock = source.slice(blockStart, blockEnd)
    +
    +const playlistHarness = `
    +class PlaylistHarness {
    +  constructor(groups, enabled) {
    +    this.groups = groups
    +    this.enabled = enabled
    +    this.playlists = []
    +  }
    +  isRatingPlaylistEnabled() { return this.enabled }
    +  async getRatingPlaylistSongs() { return this.groups }
    +  async run() {
    +    const playlists = this.playlists
    +    const buildPlaylist = (id, name, musics) => ({ id, name, songCount: musics.length })
    +    const username = 'alice'
    +    const subsonicLog = { error() {} }
    +    const SubsonicHandler = { RATING_PLAYLIST_PREFIX: 'rating_' }
    +${ratingPlaylistBlock}
    +    return playlists
    +  }
    +}
    +globalThis.PlaylistHarness = PlaylistHarness
    +`
    +
    +const buildPlaylists = async (counts: Record, enabled = true): Promise> => {
    +  const groups = new Map()
    +  for (let star = 1; star <= 5; star++) {
    +    groups.set(star, Array.from({ length: counts[star] || 0 }, (_, i) => song(`${star}-${i}`)))
    +  }
    +  const context = vm.createContext({ console })
    +  vm.runInContext(transformSync(playlistHarness, { loader: 'ts', format: 'cjs', target: 'node20' }).code, context)
    +  const instance = new (context as any).PlaylistHarness(groups, enabled)
    +  // vm 里的数组来自另一个 realm,deepStrictEqual 会因原型不同而误判;先转成主 realm 的普通对象。
    +  return JSON.parse(JSON.stringify(await instance.run()))
    +}
    +
    +test('S18 空星级不生成歌单:只暴露有歌曲的星级', async () => {
    +  const list = await buildPlaylists({ 1: 1, 3: 2 })
    +  assert.deepEqual(list.map(p => p.id), ['rating_1', 'rating_3'])
    +  assert.deepEqual(list.map(p => p.songCount), [1, 2])
    +  assert.match(list[0].name, /我不喜欢/)
    +})
    +
    +test('S18 五个星级都为空时不生成任何评星歌单', async () => {
    +  assert.deepEqual(await buildPlaylists({}), [])
    +})
    +
    +test('S18 关闭开关时不生成评星歌单', async () => {
    +  assert.deepEqual(await buildPlaylists({ 5: 1 }, false), [])
    +})
    +
    +test('S18 五个星级都有歌曲时全部保留且顺序固定', async () => {
    +  const list = await buildPlaylists({ 1: 1, 2: 1, 3: 1, 4: 1, 5: 1 })
    +  assert.deepEqual(list.map(p => p.id), ['rating_1', 'rating_2', 'rating_3', 'rating_4', 'rating_5'])
    +  assert.ok(list.every(p => p.songCount === 1))
    +})
    diff --git a/test/user-source-cleanup.test.ts b/test/user-source-cleanup.test.ts
    new file mode 100644
    index 00000000..e306765e
    --- /dev/null
    +++ b/test/user-source-cleanup.test.ts
    @@ -0,0 +1,34 @@
    +import { test } from 'node:test'
    +import assert from 'node:assert/strict'
    +import fs from 'node:fs'
    +import os from 'node:os'
    +import path from 'node:path'
    +import { deleteUserSourceFiles } from '../src/server/sources/userSourceCleanup'
    +
    +test('delete only the selected private source files and reject shared or escaping paths', () => {
    +  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-source-cleanup-'))
    +  const previous = process.env.DATA_PATH
    +  process.env.DATA_PATH = root
    +  try {
    +    for (const owner of ['alice', 'bob', '_open']) {
    +      const dir = path.join(root, 'users', 'source', owner)
    +      fs.mkdirSync(dir, { recursive: true })
    +      for (const file of ['test.js', 'sources.json', 'order.json', 'states.json']) {
    +        fs.writeFileSync(path.join(dir, file), '{}')
    +      }
    +    }
    +    deleteUserSourceFiles('alice')
    +    deleteUserSourceFiles('alice') // Missing directories are harmless.
    +    assert.equal(fs.existsSync(path.join(root, 'users/source/alice')), false)
    +    for (const owner of ['bob', '_open']) {
    +      assert.equal(fs.readdirSync(path.join(root, 'users/source', owner)).length, 4)
    +    }
    +    for (const owner of ['', '.', '..', '../bob', '..\\bob', 'open', '_open', 'C:\\data']) {
    +      assert.throws(() => deleteUserSourceFiles(owner))
    +    }
    +  } finally {
    +    if (previous === undefined) delete process.env.DATA_PATH
    +    else process.env.DATA_PATH = previous
    +    fs.rmSync(root, { recursive: true, force: true })
    +  }
    +})
    diff --git a/test/vu-meter.test.cjs b/test/vu-meter.test.cjs
    new file mode 100644
    index 00000000..22e3211c
    --- /dev/null
    +++ b/test/vu-meter.test.cjs
    @@ -0,0 +1,200 @@
    +const { test } = require('node:test')
    +const assert = require('node:assert/strict')
    +const { readFileSync } = require('node:fs')
    +const vm = require('node:vm')
    +
    +function createVisualizerEnvironment() {
    +  const filePath = 'public/music/js/player/visualizer.js'
    +  const code = readFileSync(filePath, 'utf8')
    +  const listeners = {}
    +  let currentTime = 1000
    +  let audioPlayer = {
    +    paused: false,
    +    muted: false,
    +    volume: 1.0,
    +    currentTime: 10,
    +    readyState: 4,
    +    addEventListener: () => {},
    +    removeEventListener: () => {}
    +  }
    +  const context = {
    +    window: {
    +      addEventListener: (evt, fn) => { listeners[evt] = fn },
    +      removeEventListener: () => {}
    +    },
    +    document: {
    +      documentElement: { classList: { contains: () => false }, getAttribute: () => null },
    +      getElementById: (id) => id === 'audio-player' ? audioPlayer : null,
    +      addEventListener: () => {}
    +    },
    +    performance: { now: () => currentTime },
    +    Math,
    +    Uint8Array,
    +    console
    +  }
    +  context.window.window = context.window
    +  context.window.document = context.document
    +  vm.createContext(context)
    +  vm.runInContext(code, context)
    +  return {
    +    LXStudioVUMeterAnimation: context.window.LXStudioVUMeterAnimation,
    +    setSimulatedTime: (t) => { currentTime = t },
    +    window: context.window,
    +    audioPlayer
    +  }
    +}
    +
    +test('vuToNorm accurately maps dial scale points to dial tick norms', () => {
    +  const { LXStudioVUMeterAnimation } = createVisualizerEnvironment()
    +  assert.ok(LXStudioVUMeterAnimation, 'LXStudioVUMeterAnimation must be exported on window')
    +
    +  const vu = new LXStudioVUMeterAnimation()
    +
    +  // Test scale bounds and tick alignments matching canvas masterTicks
    +  assert.equal(vu.vuToNorm(-30), 0.0)
    +  assert.ok(Math.abs(vu.vuToNorm(-20) - 0.08) < 1e-3, '-20 VU must map to norm 0.08')
    +  assert.ok(Math.abs(vu.vuToNorm(-10) - 0.32) < 1e-3, '-10 VU must map to norm 0.32')
    +  assert.ok(Math.abs(vu.vuToNorm(-7) - 0.44) < 1e-3, '-7 VU must map to norm 0.44')
    +  assert.ok(Math.abs(vu.vuToNorm(-5) - 0.54) < 1e-3, '-5 VU must map to norm 0.54')
    +  assert.ok(Math.abs(vu.vuToNorm(-3) - 0.64) < 1e-3, '-3 VU must map to norm 0.64')
    +  assert.ok(Math.abs(vu.vuToNorm(-1) - 0.74) < 1e-3, '-1 VU must map to norm 0.74')
    +  assert.ok(Math.abs(vu.vuToNorm(0) - 0.80) < 1e-3, '0 VU must map to norm 0.80')
    +  assert.ok(Math.abs(vu.vuToNorm(1) - 0.8666667) < 1e-3, '+1 VU must map to norm 0.8667')
    +  assert.ok(Math.abs(vu.vuToNorm(2) - 0.9333333) < 1e-3, '+2 VU must map to norm 0.9333')
    +  assert.ok(Math.abs(vu.vuToNorm(3) - 1.00) < 1e-3, '+3 VU must map to norm 1.00')
    +})
    +
    +const STUDY_THEMES = ['study-nocturne', 'study-heritage', 'study-atelier', 'study-riviera', 'study-cobalt']
    +
    +function createMockCtx(width, height) {
    +  const base = {
    +    canvas: { width, height, clientWidth: width, clientHeight: height },
    +    createLinearGradient: () => ({ addColorStop: () => {} }),
    +    createRadialGradient: () => ({ addColorStop: () => {} }),
    +    measureText: text => ({ width: String(text).length * 6 }),
    +    fillTextCalls: []
    +  }
    +  base.fillText = (text, x, y) => base.fillTextCalls.push({ text, x, y })
    +  return new Proxy(base, {
    +    get: (target, key) => (key in target ? target[key] : () => {}),
    +    set: (target, key, value) => { target[key] = value; return true }
    +  })
    +}
    +
    +test('design-study dials fall back to Nocturne for unsupported themes', () => {
    +  const { LXStudioVUMeterAnimation } = createVisualizerEnvironment()
    +  const vu = new LXStudioVUMeterAnimation()
    +  assert.deepEqual(STUDY_THEMES.map(id => vu.getDesign(id).name), ['Nocturne', 'Heritage', 'Atelier', 'Riviera', 'Cobalt'])
    +  const legacy = { 'vintage-amber': 'study-nocturne', 'ocean-blue': 'study-nocturne', 'cyber-cyan': 'study-nocturne', 'champagne-gold': 'study-nocturne', 'studio-monochrome': 'study-nocturne', unknown: 'study-nocturne' }
    +  for (const [from, to] of Object.entries(legacy)) {
    +    assert.equal(LXStudioVUMeterAnimation.normalizeTheme(from), to)
    +  }
    +  for (const id of STUDY_THEMES) {
    +    assert.equal(LXStudioVUMeterAnimation.normalizeTheme(id), id)
    +  }
    +  for (const id of ['constructor', 'toString', '__proto__', null, undefined]) {
    +    assert.equal(LXStudioVUMeterAnimation.normalizeTheme(id), 'study-nocturne')
    +    assert.ok(vu.getDesign(id).geometry)
    +  }
    +})
    +
    +test('every dial prints a calibrated, monotonic scale inside its sweep', () => {
    +  const { LXStudioVUMeterAnimation } = createVisualizerEnvironment()
    +  const vu = new LXStudioVUMeterAnimation()
    +  for (const id of STUDY_THEMES) {
    +    const { geometry: g } = vu.getDesign(id)
    +    const marks = vu.getScaleMarks(id)
    +    const angles = marks.map(m => vu.scaleAngle(id, m.norm))
    +    for (let i = 1; i < angles.length; i++) assert.ok(angles[i] > angles[i - 1], `${id} ticks must increase`)
    +    assert.ok(angles[0] >= g.start - 1e-9 && angles.at(-1) <= g.start + g.sweep + 1e-9, `${id} ticks must stay in the sweep`)
    +    const zero = marks.find(m => m.label === '0')
    +    assert.ok(zero && Math.abs(zero.norm - vu.vuToNorm(0)) < 1e-9, `${id} must align 0 VU to the shared calibration`)
    +  }
    +  assert.ok(vu.getScaleMarks('study-cobalt').length > vu.getScaleMarks('study-riviera').length, 'cobalt should be denser than riviera')
    +})
    +
    +test('every dial renders at detail and footer sizes and hides on mobile', () => {
    +  const { LXStudioVUMeterAnimation, window } = createVisualizerEnvironment()
    +  for (const id of STUDY_THEMES) {
    +    window.settings = { vuMeterTheme: id }
    +    for (const [w, h, compact] of [[1200, 380, false], [456, 72, true]]) {
    +      window.innerWidth = 1440
    +      const ctx = createMockCtx(w, h)
    +      new LXStudioVUMeterAnimation({ isCompact: compact }).renderConsole(ctx, w, h, 0.6, 0.7, compact)
    +      assert.ok(ctx.fillTextCalls.some(c => c.text === '0'), `${id} ${w}x${h} should print its scale`)
    +      for (const c of ctx.fillTextCalls) assert.ok(c.x >= 0 && c.x <= w && c.y >= 0 && c.y <= h, `${id} label ${c.text} must stay on canvas`)
    +    }
    +  }
    +  window.innerWidth = 390
    +  const ctx = createMockCtx(390, 40)
    +  new LXStudioVUMeterAnimation({ isCompact: true }).renderConsole(ctx, 390, 40, 0.6, 0.7, true)
    +  assert.equal(ctx.fillTextCalls.length, 0, 'mobile viewports should not draw a VU meter')
    +})
    +
    +test('VU meter at 100% player volume oscillates and does not stay pinned at max level (1.0)', () => {
    +  const { LXStudioVUMeterAnimation, setSimulatedTime } = createVisualizerEnvironment()
    +
    +  let simulatedTime = 1000
    +  let renderedLevels = []
    +  const mockCtx = {
    +    canvas: { width: 400, height: 60 },
    +    save: () => {},
    +    restore: () => {},
    +    clearRect: () => {},
    +    beginPath: () => {},
    +    moveTo: () => {},
    +    lineTo: () => {},
    +    stroke: () => {},
    +    fill: () => {},
    +    arcTo: () => {},
    +    arc: () => {},
    +    clip: () => {},
    +    fillText: () => {},
    +    createLinearGradient: () => ({ addColorStop: () => {} }),
    +    createRadialGradient: () => ({ addColorStop: () => {} }),
    +    roundRect: () => {}
    +  }
    +
    +  const vu = new LXStudioVUMeterAnimation({ isCompact: true })
    +
    +  vu.renderConsole = (ctx, w, h, levelL, levelR) => {
    +    renderedLevels.push({ levelL, levelR })
    +  }
    +
    +  function makeTimeDomain(targetRms) {
    +    const buf = new Uint8Array(512)
    +    const amp = Math.min(127, Math.round(targetRms * Math.SQRT2 * 128))
    +    for (let i = 0; i < 512; i++) {
    +      buf[i] = Math.round(128 + amp * Math.sin((i / 512) * Math.PI * 8))
    +    }
    +    return buf
    +  }
    +
    +  const rmsValues = [
    +    0.30, 0.30, 0.28, 0.14, 0.12, 0.12, 0.13, 0.32, 0.30, 0.15, 0.12, 0.11
    +  ]
    +
    +  vu.options.analyserL = {
    +    fftSize: 512,
    +    getByteTimeDomainData: (buf) => {
    +      const curRms = rmsValues[Math.floor((simulatedTime - 1000) / 100) % rmsValues.length]
    +      buf.set(makeTimeDomain(curRms))
    +    }
    +  }
    +  vu.options.analyserR = vu.options.analyserL
    +
    +  for (let frame = 0; frame < 60; frame++) {
    +    simulatedTime += 33
    +    setSimulatedTime(simulatedTime)
    +    vu.draw(null, mockCtx)
    +  }
    +
    +  const levels = renderedLevels.map(r => r.levelL)
    +  const minLevel = Math.min(...levels)
    +  const maxLevel = Math.max(...levels)
    +
    +  assert.ok(maxLevel < 1.0, `Max needle level should not be pegged at 1.0 (got ${maxLevel})`)
    +  assert.ok(maxLevel > 0.60, `Needle should rise into active zone on loud beats (got ${maxLevel})`)
    +  assert.ok(minLevel < 0.60, `Needle should dip down between beats (got ${minLevel})`)
    +  assert.ok((maxLevel - minLevel) > 0.15, `Needle should oscillate with significant dynamic range (swing was ${maxLevel - minLevel})`)
    +})
    diff --git a/test/web-rating-controls.test.cjs b/test/web-rating-controls.test.cjs
    new file mode 100644
    index 00000000..49a36f29
    --- /dev/null
    +++ b/test/web-rating-controls.test.cjs
    @@ -0,0 +1,34 @@
    +const test=require('node:test');const assert=require('node:assert/strict');const fs=require('node:fs');const vm=require('node:vm');
    +function setup(fetch){const storage=new Map([['lx_sync_user','a'],['lx_user_token','token-a']]);const window={addEventListener(){}};const context={window,localStorage:{getItem:k=>storage.get(k)||null},document:{addEventListener(){},hidden:false},setInterval(){},queueMicrotask(){},fetch,ensureUserAuthToken:async()=>true,getUserAuthHeaders:()=>({'x-user-token':storage.get('lx_user_token')})};vm.runInNewContext(fs.readFileSync('public/music/js/library/rating_manager.js','utf8'),context);return{manager:window.RatingManager,storage,context}}
    +const button=()=>({isConnected:true,textContent:'',setAttribute(){}});
    +test('评分按100首分批查询,不上传播放URL或鉴权封面',async()=>{const bodies=[];const{manager}=setup(async(_u,options)=>{const body=JSON.parse(options.body);bodies.push(body);return{ok:true,json:async()=>({entries:body.songs.map(s=>({id:s.id,rating:4}))})}});const buttons=[];for(let n=0;n<101;n++){const b=button();buttons.push(b);manager.attach(b,{id:'wy_'+n,source:'wy',url:'secret',pic:'token-cover'})}await manager.refresh();assert.equal(bodies.length,2);assert.equal(bodies[0].songs.length,100);assert.ok(!JSON.stringify(bodies).includes('secret'));assert.ok(!JSON.stringify(bodies).includes('token-cover'));assert.ok(buttons.every(x=>x.textContent==='★'))});
    +test('账号切换与播放器换歌后,旧请求不能刷新新控件',async()=>{let release;const{manager,storage}=setup(()=>new Promise(r=>{release=r}));const b=button();manager.attach(b,{id:'wy_old',source:'wy'});const pending=manager.refresh();await new Promise(r=>setImmediate(r));manager.attach(b,{id:'wy_new',source:'wy'});release({ok:true,json:async()=>({entries:[{id:'wy_old',rating:5}]})});await pending;assert.equal(b.textContent,'☆');const pending2=manager.refresh();await new Promise(r=>setImmediate(r));storage.set('lx_sync_user','b');storage.set('lx_user_token','token-b');release({ok:true,json:async()=>({entries:[{id:'wy_new',rating:5}]})});await pending2;assert.equal(b.textContent,'☆')});
    +
    +
    +test('同账号重叠刷新只采用最后请求,旧响应不能覆盖新评分',async()=>{
    + const waiting=[];const {manager}=setup(()=>new Promise(resolve=>waiting.push(resolve)));const b=button();manager.attach(b,{id:'wy_1',source:'wy'});
    + const old=manager.refresh();await new Promise(r=>setImmediate(r));const latest=manager.refresh();await new Promise(r=>setImmediate(r));
    + waiting[1]({ok:true,json:async()=>({entries:[{id:'wy_1',rating:5}]})});await latest;
    + waiting[0]({ok:true,json:async()=>({entries:[{id:'wy_1',rating:1}]})});await old;assert.equal(b.textContent,'★');
    +});
    +
    +
    +test('分数tag仅在已评分时显示,清分和退出账号立即隐藏',async()=>{
    + let rating=4;const {manager,storage}=setup(async()=>({ok:true,json:async()=>({entries:[{id:'wy_1',rating}]})}));const tag=button();manager.attach(tag,{id:'wy_1',source:'wy'},true);assert.equal(tag.hidden,true);await manager.refresh();assert.equal(tag.hidden,false);assert.equal(tag.textContent,'★ 4');rating=0;await manager.refresh();assert.equal(tag.hidden,true);rating=5;await manager.refresh();assert.equal(tag.hidden,false);storage.delete('lx_sync_user');await manager.refresh();assert.equal(tag.hidden,true);
    +});
    +
    +
    +test('歌单操作区接入评分,合集刷新重新读服务端并保留滚动',()=>{
    + const list=fs.readFileSync('public/music/js/library/songlist_manager.js','utf8');assert.match(list,/data-songlist-rating/);assert.match(list,/RatingManager\?\.attach\(button, entry.item\)/);
    + const rating=fs.readFileSync('public/music/js/library/rating_manager.js','utf8');assert.match(rating,/data = latest; render\(\); list.scrollTop = scroll/);assert.match(rating,/collectionDialog\?\.open.*collectionReload/);
    +});
    +
    +
    +test('同账号认证更新Token后用户偏好仍能回显并解除禁用',async()=>{
    + const {manager,storage,context}=setup(async()=>({ok:true,json:async()=>({dislikeAutoRating:true})}));const toggle={disabled:false,checked:false};const status={};context.document.getElementById=id=>id==='setting-dislike-auto-rating'?toggle:status;context.ensureUserAuthToken=async()=>{storage.set('lx_user_token','renewed-token')};await manager.loadPreference();assert.equal(toggle.checked,true);assert.equal(toggle.disabled,false);
    +});
    +
    +
    +test('版本不变仅查询版本,版本变化再批量刷新分数',async()=>{
    + let version='v1',resolved=0,polled=0;const {manager}=setup(async(_u,opt)=>{const body=JSON.parse(opt.body);if(body.operation==='version'){polled++;return{ok:true,json:async()=>({version})}}resolved++;return{ok:true,json:async()=>({version,entries:[{id:'wy_1',rating:4}]})}});manager.attach(button(),{id:'wy_1',source:'wy'});await manager.refresh();await manager.refresh();assert.equal(resolved,1);assert.equal(polled,1);version='v2';await manager.refresh();assert.equal(resolved,2)
    +});
    diff --git a/test/web-rating-service.test.ts b/test/web-rating-service.test.ts
    new file mode 100644
    index 00000000..e87341ff
    --- /dev/null
    +++ b/test/web-rating-service.test.ts
    @@ -0,0 +1,172 @@
    +import test from 'node:test'
    +import assert from 'node:assert/strict'
    +import fs from 'node:fs'
    +import os from 'node:os'
    +import path from 'node:path'
    +import vm from 'node:vm'
    +import { createRequire } from 'node:module'
    +import { transformSync } from 'esbuild'
    +const actualRequire = createRequire(import.meta.url)
    +function load(root: string, snapshot:()=>Promise = async()=>{}) {
    + const tracks:any[]=[]
    + const locks=new Map>()
    + const mod={exports:{} as any}
    + const require=(id:string):any=>{
    +    if (id === './ratingMigration' || id === '../../server/rating/ratingMigration') return actualRequire(path.resolve('src/server/rating/ratingMigration.ts'))
    +  if(id==='./ratingIdentityLock')return actualRequire(path.resolve('src/server/rating/ratingIdentityLock.ts'))
    +  if (id === './ratingValidation') return actualRequire(path.resolve('src/server/rating/ratingValidation.ts'))
    +  if(id==='@/user')return {getUserDirname:(user:string)=>user}
    +  if(id==='./ratingSnapshot')return {withMetaLock:(user:string,fn:any)=>{const p=(locks.get(user)||Promise.resolve()).then(fn);locks.set(user,p.catch(()=>{}));return p},createRatingSnapshot:snapshot}
    +  if(id==='../music/subsonicLocal')return {localRatingId:(track:any)=>track.platformId||track.id,getLocalTracks:async()=>tracks,localFile:(track:any)=>{const resolved=path.resolve(track.root,track.filename);return resolved.startsWith(path.resolve(track.root)+path.sep)&&fs.existsSync(resolved)?resolved:null}}
    +  if(id==='../music/fileCache')return {getCacheDir:(user:string)=>path.join(root,user,'audio')}
    +  if(id==='../music/customMusicManager')return {getCustomMusicDir:(user:string)=>path.join(root,user,'audio')}
    +  return actualRequire(id)
    + }
    + vm.runInNewContext(transformSync(fs.readFileSync('src/server/rating/ratingService.ts','utf8'),{loader:'ts',format:'cjs'}).code,{module:mod,exports:mod.exports,require,URL,global:{lx:{userPath:root,dataPath:root}}})
    + return {api:mod.exports,tracks}
    +}
    +test('Web评分服务并发写入不丢数据、隔离用户、保留收藏且不改歌单',async()=>{
    + const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-web-rating-unit-'))
    + try{
    +  const {api}=load(root);fs.mkdirSync(path.join(root,'a'),{recursive:true});const file=path.join(root,'a','subsonic-meta.json');fs.writeFileSync(file,JSON.stringify({starredArtists:['artist'],ratings:{}}));const list=path.join(root,'a','list.json');fs.writeFileSync(list,'playlist-marker')
    +  await Promise.all(Array.from({length:20},(_,n)=>api.writeRating('a','wy_'+n,5,'web',{name:'歌'+n,singer:'人',source:'wy',url:'secret-url'})))
    +  const data=api.readRatings('a');assert.equal(Object.keys(data.ratings).length,20);assert.equal(data.starredArtists[0],'artist');assert.equal(data.ratingOrigins.wy_0.channel,'web');assert.equal(fs.readFileSync(list,'utf8'),'playlist-marker');assert.equal(Object.keys(api.readRatings('b').ratings).length,0)
    +  assert.ok(!fs.readFileSync(path.join(root,'a','rating','songs.json'),'utf8').includes('secret-url'))
    +  for(const r of [-1,6,1.5,'5'])await assert.rejects(()=>api.writeRating('a','wy_bad',r,'web'))
    +  await api.writeRating('a','wy_0',0,'web');assert.equal(api.readRatings('a').ratings.wy_0,undefined);assert.equal(api.readRatings('a').ratingOrigins.wy_0,undefined)
    + }finally{fs.rmSync(root,{recursive:true,force:true})}
    +})
    +test('本地未关联按本地ID,关联及重新关联后按对应在线ID,拒绝越权',async()=>{
    + const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-web-rating-local-'))
    + try{
    +  const {api,tracks}=load(root);const audio=path.join(root,'a','audio');fs.mkdirSync(audio,{recursive:true});fs.writeFileSync(path.join(audio,'song.wav'),'audio');tracks.push({id:'local_stable',root:audio,filename:'song.wav',platformId:undefined})
    +  const song={isLocal:true,_localFilename:'song.wav',_localFolder:'custom',_localUsername:'a',source:'tx',songmid:'old'}
    +  assert.equal(await api.resolveRatingId('a',song),'local_stable');tracks[0].platformId='wy_new';assert.equal(await api.resolveRatingId('a',{...song,source:'wy',songmid:'new'}),'wy_new')
    +  await api.writeRating('a','local_stable',2,'web')
    +  assert.equal(api.readRatings('a').ratings.wy_new,2)
    +  tracks[0].platformId='tx_other'
    +  assert.equal(await api.resolveStoredRatingId('a','local_stable'),'tx_other')
    +  assert.equal(api.readRatings('a').ratings.tx_other,undefined)
    +  await api.writeRating('a','local_stable',4,'subsonic')
    +  assert.equal(api.readRatings('a').ratings.tx_other,4)
    +  assert.equal(api.readRatings('a').ratings.wy_new,2)
    +  await assert.rejects(()=>api.resolveRatingId('b',song));await assert.rejects(()=>api.resolveRatingId('a',{...song,_localFilename:'../subsonic-meta.json'}))
    +  assert.equal(await api.resolveRatingId('a',{source:'tx',songmid:'abc',songId:123}),'tx_abc')
    +  assert.equal(await api.resolveRatingId('a',{source:'wy',id:'wy_123'}),'wy_123')
    + }finally{fs.rmSync(root,{recursive:true,force:true})}
    +})
    +
    +
    +test('资料合并保留封面和专辑ID,拒绝凭据及查询参数封面', async()=>{
    + const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-merge-'))
    + try {
    +  const {api}=load(root)
    +  await api.writeRating('a','wy_1',4,'web',{name:'原歌名',source:'wy',albumId:'album',img:'https://example.com/cover.jpg'})
    +  await api.writeRating('a','wy_1',3,'web',{name:'',source:'wy'})
    +  const row=JSON.parse(fs.readFileSync(path.join(root,'a/rating/songs.json'),'utf8')).wy_1
    +  assert.equal(row.name,'原歌名');assert.equal(row.albumId,'album');assert.equal(row.img,'https://example.com/cover.jpg')
    +  assert.equal(api.safeCover('https://user:secret@example.com/a'),'');assert.equal(api.safeCover('https://example.com/a?token=secret'),'')
    + } finally { fs.rmSync(root,{recursive:true,force:true}) }
    +})
    +
    +test('排队评分在取得身份锁后重新解析关联,并清除旧本地评分',async()=>{
    + const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-identity-'))
    + try {
    +  const {api,tracks}=load(root);const audio=path.join(root,'a/audio');fs.mkdirSync(audio,{recursive:true});fs.writeFileSync(path.join(audio,'a.wav'),'audio')
    +  const track={id:'local_a',root:audio,filename:'a.wav',platformId:undefined as string|undefined};tracks.push(track)
    +  await api.writeRating('a','local_a',5,'web')
    +  const lock=actualRequire(path.resolve('src/server/rating/ratingIdentityLock.ts')).withRatingIdentityLock
    +  let release!:()=>void
    +  const association=lock('a',async()=>{await new Promise(r=>{release=r});track.platformId='wy_linked'})
    +  await Promise.resolve()
    +  const write=api.writeRating('a','local_a',2,'web')
    +  release();await association;await write
    +  assert.equal(api.readRatings('a').ratings.wy_linked,2)
    +  fs.writeFileSync(path.join(root,'a/subsonic-local-registry.json'),JSON.stringify({tracks:{a:track}}))
    +  assert.ok(!api.listRatingItems('a').some((x:any)=>x.id==='local_a'));assert.equal(api.readRatings('a').ratings.wy_linked,2)
    + } finally {fs.rmSync(root,{recursive:true,force:true})}
    +})
    +
    +
    +test('重复同分不改来源或时间,清分记录历史;旧本地ID资料不覆盖关联在线描述',async()=>{
    + const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-idempotence-'))
    + try {
    +  const {api,tracks}=load(root)
    +  await api.writeRating('a','wy_1',4,'web',{name:'在线曲名',source:'wy'})
    +  const first=api.readRatings('a');const duplicate=await api.writeRating('a','wy_1',4,'subsonic')
    +  assert.equal(duplicate.unchanged,true);assert.deepEqual(JSON.parse(JSON.stringify(api.readRatings('a').ratingOrigins)),JSON.parse(JSON.stringify(first.ratingOrigins)))
    +  assert.equal(api.readRatings('a').ratingHistory.wy_1.length,1)
    +  tracks.push({id:'local_old',platformId:'wy_1',title:'在线曲名',artist:'歌手',album:'专辑'})
    +  await api.writeRating('a','local_old',2,'web',{id:'local_old',source:'local',name:'旧本地曲名'})
    +  const catalog=JSON.parse(fs.readFileSync(path.join(root,'a/rating/songs.json'),'utf8'));assert.equal(catalog.wy_1.name,'在线曲名')
    +  await api.writeRating('a','wy_1',0,'web');const events=api.readRatings('a').ratingHistory.wy_1
    +  assert.equal(events.length,3);assert.equal(events[2].from,2);assert.equal(events[2].to,0)
    + }finally{fs.rmSync(root,{recursive:true,force:true})}
    +})
    +
    +
    +test('同分补齐描述不改评分来源,资料失败可单独重试,版本能感知变化',async()=>{
    + const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-repair-'))
    + try {
    +  const {api}=load(root);await api.writeRating('a','wy_1',4,'subsonic')
    +  const before=api.readRatings('a');const version=api.ratingVersion('a')
    +  await api.writeRating('a','wy_1',4,'web',{name:'补齐曲名',source:'wy'})
    +  assert.equal(api.readRatings('a').ratingOrigins.wy_1.channel,'subsonic');assert.equal(api.readRatings('a').ratingHistory.wy_1.length,1)
    +  assert.equal(JSON.parse(fs.readFileSync(path.join(root,'a/rating/songs.json'),'utf8')).wy_1.name,'补齐曲名');assert.notEqual(api.ratingVersion('a'),version)
    +  const rename=fs.renameSync;let failed=false
    +  try{fs.renameSync=((from:any,to:any)=>{if(!failed&&String(to).endsWith('songs.json')){failed=true;throw Error('disk')}return rename(from,to)}) as any
    +   const saved=await api.writeRating('a','wy_1',4,'web',{name:'可重试曲名',source:'wy'});assert.ok(saved.snapshotWarning)
    +  }finally{fs.renameSync=rename}
    +  assert.ok(api.readRatingTasks('a').some((t:any)=>t.kind==='catalog'))
    +  await api.retryRatingTasks('a');assert.equal(api.readRatingTasks('a').length,0)
    +  assert.equal(JSON.parse(fs.readFileSync(path.join(root,'a/rating/songs.json'),'utf8')).wy_1.name,'可重试曲名');assert.equal(api.readRatings('a').ratingOrigins.wy_1.at,before.ratingOrigins.wy_1.at)
    +  const stable=api.ratingVersion('a');await api.writeRating('a','wy_1',4,'web',{name:'可重试曲名',source:'wy'});assert.equal(api.ratingVersion('a'),stable)
    + }finally{fs.rmSync(root,{recursive:true,force:true})}
    +})
    +
    +test('快照失败记录状态并可重试,修复资料不能覆写较新的评分',async()=>{
    + const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-snapshot-retry-'))
    + try{
    +  let fail=true;const {api}=load(root,async()=>{if(fail)throw Error('snapshot disk')})
    +  const result=await api.writeRating('a','wy_1',2,'web',{name:'歌曲',source:'wy'});assert.ok(result.snapshotWarning);assert.ok(api.readRatingTasks('a').some((x:any)=>x.kind==='snapshot'))
    +  fail=false;await api.retryRatingTasks('a');assert.equal(api.readRatingTasks('a').length,0)
    +  await api.writeRating('a','wy_1',5,'subsonic');await api.writeRating('a','wy_1',1,'repair',{name:'歌曲',source:'wy'},{repairOnly:true});assert.equal(api.readRatings('a').ratings.wy_1,5);assert.equal(api.readRatings('a').ratingOrigins.wy_1.channel,'subsonic')
    + }finally{fs.rmSync(root,{recursive:true,force:true})}
    +})
    +
    +
    +test('同步状态损坏不清空文件,主评分损坏拒绝写入与覆盖',async()=>{
    + const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-storage-errors-'))
    + try{
    +  const {api}=load(root);await api.writeRating('a','wy_1',4,'web',{name:'歌曲',source:'wy'});const status=path.join(root,'a/rating/sync-status.json');fs.writeFileSync(status,'broken')
    +  assert.equal(api.readRatingTasks('a')[0].kind,'status');await assert.rejects(()=>api.retryRatingTasks('a'),/损坏/);assert.equal(fs.readFileSync(status,'utf8'),'broken')
    +  const meta=path.join(root,'a/subsonic-meta.json');fs.writeFileSync(meta,'broken');await assert.rejects(()=>api.writeRating('a','wy_1',5,'web'));assert.equal(fs.readFileSync(meta,'utf8'),'broken')
    + }finally{fs.rmSync(root,{recursive:true,force:true})}
    +})
    +
    +
    +test('补分主文件写入失败登记重试任务,只重试未评分项且保留较新评分',async()=>{
    + const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-backfill-retry-'))
    + try {
    +  const {api}=load(root);const rename=fs.renameSync;let fail=true
    +  try{fs.renameSync=((from:any,to:any)=>{if(fail&&String(to).endsWith('subsonic-meta.json'))throw Error('meta disk');return rename(from,to)}) as any
    +   await assert.rejects(()=>api.writeRating('a','wy_1',1,'web-dislike-link',{name:'曲',source:'wy'},{onlyMissing:true}),/meta disk/)
    +  }finally{fs.renameSync=rename;fail=false}
    +  assert.ok(api.readRatingTasks('a').some((x:any)=>x.kind==='backfill'))
    +  await api.writeRating('a','wy_1',5,'web',{name:'曲',source:'wy'});await api.retryRatingTasks('a');assert.equal(api.readRatings('a').ratings.wy_1,5);assert.equal(api.readRatingTasks('a').length,0)
    + }finally{fs.rmSync(root,{recursive:true,force:true})}
    +})
    +
    +test('关联迁移保留来源与历史,在线评分优先且删除本地所有当前记录',async()=>{
    + const root=fs.mkdtempSync(path.join(os.tmpdir(),'lx-rating-migrate-'))
    + try {
    +  const {api}=load(root);const dir=path.join(root,'a');fs.mkdirSync(path.join(dir,'rating'),{recursive:true})
    +  const file=path.join(dir,'subsonic-meta.json')
    +  fs.writeFileSync(file,JSON.stringify({ratings:{local_a:5,local_b:2,wy_b:4},ratingOrigins:{local_a:{channel:'web',at:1},local_b:{channel:'web',at:2},wy_b:{channel:'subsonic',at:3}},ratingHistory:{local_a:[{from:0,to:5,channel:'web',at:1}],local_b:[]},starredArtists:['keep']}))
    +  fs.writeFileSync(path.join(dir,'rating/songs.json'),JSON.stringify({local_a:{name:'原歌名'},local_b:{name:'删除'}}))
    +  fs.writeFileSync(path.join(dir,'subsonic-local-registry.json'),JSON.stringify({tracks:{a:{id:'local_a',platformId:'wy_a'},b:{id:'local_b',platformId:'wy_b'}}}))
    +  const data=api.readRatings('a');assert.deepEqual(JSON.parse(JSON.stringify(data.ratings)),{wy_a:5,wy_b:4});assert.equal(data.ratingOrigins.wy_a.at,1);assert.equal(data.ratingOrigins.wy_b.channel,'subsonic');assert.equal(data.ratingHistory.wy_a.length,1);assert.equal(data.ratingHistory.local_a,undefined);assert.equal(data.starredArtists[0],'keep')
    +  const catalog=JSON.parse(fs.readFileSync(path.join(dir,'rating/songs.json'),'utf8'));assert.equal(catalog.local_a,undefined);assert.equal(catalog.local_b,undefined);assert.equal(catalog.wy_a.name,'原歌名')
    +  const before=fs.readFileSync(file,'utf8');api.readRatings('a');assert.equal(fs.readFileSync(file,'utf8'),before)
    + } finally {fs.rmSync(root,{recursive:true,force:true})}
    +})
    diff --git a/test/webdav-connection-ui.test.cjs b/test/webdav-connection-ui.test.cjs
    new file mode 100644
    index 00000000..ee2e5713
    --- /dev/null
    +++ b/test/webdav-connection-ui.test.cjs
    @@ -0,0 +1,39 @@
    +const test = require('node:test');
    +const assert = require('node:assert/strict');
    +const fs = require('node:fs');
    +const vm = require('node:vm');
    +
    +test('WebDAV report persists details as text and restores the button after success or failure', async () => {
    +    const source = fs.readFileSync('public/app.js', 'utf8');
    +    const start = source.indexOf('    async testWebDAV() {');
    +    const method = source.slice(start, source.indexOf('\n    // ', start));
    +    let report;
    +    const button = { disabled: false, setAttribute() {}, removeAttribute() {}, parentElement: { appendChild(el) { report = el; } } };
    +    const notices = [];
    +    const context = vm.createContext({
    +        document: {
    +            getElementById: id => id === 'test-webdav-btn' ? button : report,
    +            createElement: () => ({ style: {}, setAttribute() {} }),
    +        },
    +        showSuccess: text => notices.push(text), showError: text => notices.push(text),
    +    });
    +    const app = vm.runInContext(`({${method}})`, context);
    +    let finish;
    +    app.request = () => new Promise(resolve => { finish = resolve; });
    +    const pending = app.testWebDAV();
    +    assert.equal(button.disabled, true);
    +    await app.testWebDAV(); // No duplicate request while busy.
    +    finish({ success: false, message: '部分目录检查未通过', checks: [{ name: '备份目录', path: '/', success: false, message: 'HTTP 502:网关异常' }] });
    +    await pending;
    +    assert.equal(button.disabled, false);
    +    assert.match(report.textContent, /备份目录 \/:未通过,HTTP 502/);
    +    assert.equal(report.innerHTML, undefined);
    +    app.request = async () => ({ success: true, message: '目录均可读取' });
    +    await app.testWebDAV();
    +    assert.equal(report.textContent, '目录均可读取');
    +    app.request = async () => { throw new Error('network unavailable'); };
    +    await app.testWebDAV();
    +    assert.equal(button.disabled, false);
    +    assert.match(report.textContent, /network unavailable/);
    +    assert.equal(notices.length, 3);
    +});
    diff --git a/test/webdav-connection.test.ts b/test/webdav-connection.test.ts
    new file mode 100644
    index 00000000..2f7ff9d1
    --- /dev/null
    +++ b/test/webdav-connection.test.ts
    @@ -0,0 +1,102 @@
    +import test from 'node:test'
    +import assert from 'node:assert/strict'
    +import http from 'node:http'
    +import WebDAVSync from '../src/utils/webdavSync'
    +
    +const create = () => new WebDAVSync({ enable: true, url: 'http://test.invalid', username: '', password: '', syncPath: '/sync', backupPath: '/backups' }, '.')
    +
    +test('connection test inspects configured directories, never the failing root or writes', async () => {
    +    const sync = create()
    +    const calls: string[] = []
    +    ;(sync as any).client = {
    +        getDirectoryContents: async (remote: string, options: any) => {
    +            calls.push(remote)
    +            assert.ok(options.signal instanceof AbortSignal)
    +            assert.equal(options.deep, false)
    +            if (remote === '/') throw Object.assign(new Error('Bad Gateway'), { status: 502 })
    +            return []
    +        },
    +    }
    +    const result = await sync.testConnection()
    +    assert.equal(result.success, true)
    +    assert.deepEqual(calls.sort(), ['/backups', '/sync'])
    +    assert.match(result.message, /上传、删除权限及完整恢复能力尚未验证/)
    +})
    +
    +test('each directory retains its result and actionable HTTP diagnostics', async t => {
    +    for (const [status, expected] of [[401, '认证失败'], [403, '权限'], [404, '首次上传'], [405, 'PROPFIND'], [501, 'PROPFIND'], [502, '不代表上传一定失败'], [503, '网关'], [504, '网关']] as const) {
    +        await t.test(String(status), async () => {
    +            const sync = create()
    +            ;(sync as any).client = {
    +                getDirectoryContents: async (remote: string) => {
    +                    if (remote === '/backups') throw Object.assign(new Error('secret password upstream body'), { response: { status } })
    +                    return []
    +                },
    +            }
    +            const result = await sync.testConnection()
    +            assert.equal(result.success, false)
    +            assert.match(result.message, /部分目录/)
    +            assert.equal(result.checks![0].success, true)
    +            assert.equal(result.checks![1].success, false)
    +            assert.match(result.checks![1].message, new RegExp(expected))
    +            assert.match(result.checks![1].message, new RegExp(`HTTP ${status}`))
    +            assert.doesNotMatch(JSON.stringify(result), /secret password/)
    +        })
    +    }
    +})
    +
    +test('network, certificate and timeout failures give specific guidance', async () => {
    +    for (const [code, expected] of [['ENOTFOUND', '域名解析'], ['ECONNREFUSED', '连接被拒绝'], ['SELF_SIGNED_CERT_IN_CHAIN', '证书'], ['ECONNABORTED', '超时']]) {
    +        const sync = create()
    +        ;(sync as any).client = { getDirectoryContents: async () => { throw Object.assign(new Error('request failed'), { code }) } }
    +        const result = await sync.testConnection()
    +        assert.equal(result.success, false)
    +        assert.ok(result.checks!.every(check => check.message.includes(expected)))
    +    }
    +})
    +
    +test('real WebDAV HTTP requests use configured paths when root returns 502', async t => {
    +    const requests: string[] = []
    +    const server = http.createServer((req, res) => {
    +        requests.push(`${req.method} ${req.url}`)
    +        req.resume()
    +        req.on('end', () => {
    +            if (req.url === '/') { res.writeHead(502); res.end(); return }
    +            res.writeHead(207, { 'Content-Type': 'application/xml' })
    +            res.end('')
    +        })
    +    })
    +    await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
    +    t.after(async () => { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())) })
    +    const address = server.address() as { port: number }
    +    const sync = new WebDAVSync({ enable: true, url: `http://127.0.0.1:${address.port}`, username: '', password: '', syncPath: '/sync', backupPath: '/backups' }, '.')
    +    const result = await sync.testConnection()
    +    assert.equal(result.success, true, JSON.stringify(result))
    +    assert.deepEqual(requests.sort(), ['PROPFIND /backups/', 'PROPFIND /sync/'])
    +})
    +
    +test('disabled and missing configuration do not initialize a client', async () => {
    +    for (const config of [{ enable: false, url: 'http://test.invalid' }, { enable: true, url: '' }]) {
    +        const sync = new WebDAVSync({ ...config, username: '', password: '' }, '.')
    +        ;(sync as any).initClient = () => { throw new Error('must not initialize') }
    +        assert.equal((await sync.testConnection()).success, false)
    +    }
    +})
    +
    +test('a stalled directory request is aborted after ten seconds', async t => {
    +    t.mock.timers.enable({ apis: ['setTimeout'] })
    +    const sync = create()
    +    let aborted = 0
    +    ;(sync as any).client = {
    +        getDirectoryContents: async (_remote: string, { signal }: { signal: AbortSignal }) => new Promise((_resolve, reject) => {
    +            signal.addEventListener('abort', () => { aborted++; reject(new Error('request aborted')) })
    +        }),
    +    }
    +    const pending = sync.testConnection()
    +    await Promise.resolve()
    +    t.mock.timers.tick(10000)
    +    const result = await pending
    +    assert.equal(aborted, 2)
    +    assert.equal(result.success, false)
    +    assert.ok(result.checks!.every(check => check.message.includes('超时')))
    +})
    diff --git a/test/webdav-recovery.test.ts b/test/webdav-recovery.test.ts
    new file mode 100644
    index 00000000..22e10fc1
    --- /dev/null
    +++ b/test/webdav-recovery.test.ts
    @@ -0,0 +1,189 @@
    +import test from 'node:test'
    +import assert from 'node:assert/strict'
    +import fs from 'node:fs'
    +import os from 'node:os'
    +import path from 'node:path'
    +import crypto from 'node:crypto'
    +import http from 'node:http'
    +import { Readable } from 'node:stream'
    +import WebDAVSync from '../src/utils/webdavSync'
    +import { WebdavRecovery, checkedBackupPath } from '../src/utils/webdavRecovery'
    +import { webdavManifestName } from '../src/utils/webdavState'
    +import { uploadWebdavFile, downloadWebdavFile } from '../src/utils/webdavTransfer'
    +import { beginCloudBackup, beginCloudRestore } from '../src/utils/backupPolicy'
    +import { rollbackRestoreTask, listRestoreTasks } from '../src/server/backup/webdavRestoreTasks'
    +
    +test('WebDAV recovery, persistent baselines and streaming transfers', async t => {
    +  const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'lx-webdav-recovery-'))
    +  const previous = global.lx
    +  global.lx = { dataPath: temp, config: {}, configPath: path.join(temp, 'config.js') } as any
    +  t.after(() => { global.lx = previous; fs.rmSync(temp, { recursive: true, force: true }) })
    +  const local = (relative: string, text: string) => {
    +    const file = path.join(temp, relative)
    +    fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, text); return file
    +  }
    +  const remote = new Map()
    +  let failRead = '', failMove = false, reads = 0, moves = 0, puts = 0
    +  const client = {
    +    exists: async () => true, createDirectory: async () => {},
    +    getDirectoryContents: async (prefix: string) => [...remote].filter(([name]) => name.startsWith(prefix)).map(([filename, bytes]) => ({ filename, basename: path.posix.basename(filename), type: 'file', size: bytes.length })),
    +    createReadStream: (name: string) => { reads++; if (name === failRead) return Readable.from((async function* () { throw new Error('read failed') })()); return Readable.from([remote.get(name)!]) },
    +    stat: async (name: string) => { if (!remote.has(name)) throw Object.assign(new Error('missing'), { status: 404 }); return { size: remote.get(name)!.length } },
    +    putFileContents: async (name: string, stream: any) => { puts++; const chunks: Buffer[] = []; for await (const chunk of stream) chunks.push(chunk); remote.set(name, Buffer.concat(chunks)); return true },
    +    moveFile: async (from: string, to: string) => { if (failMove) throw new Error('MOVE failed'); moves++; remote.set(to, remote.get(from)!); remote.delete(from) },
    +    deleteFile: async (name: string) => { remote.delete(name) },
    +  }
    +  const context = () => ({ dataPath: temp, configPath: global.lx.configPath, config: {}, client, types: ['userData'] as any, syncPath: '/sync', backupPath: '/zip', identity: 'test' })
    +  const a = local('users/alice/a.json', 'local-a'), b = local('users/alice/b.json', 'local-b')
    +  const seed = () => {
    +    remote.clear(); failRead = ''; remote.set('/sync/users/alice/a.json', Buffer.from('cloud-a')); remote.set('/sync/users/alice/b.json', Buffer.from('cloud-b'))
    +    local('users/alice/a.json', 'local-a'); local('users/alice/b.json', 'local-b')
    +  }
    +  await t.test('a second download failure leaves every original intact', async () => {
    +    seed(); const recovery = new WebdavRecovery(), preview = await recovery.prepare(context())
    +    failRead = '/sync/users/alice/b.json'
    +    const result = await recovery.execute(context(), preview.token)
    +    assert.match(result.errors.join(), /read failed/)
    +    assert.equal(fs.readFileSync(a, 'utf8'), 'local-a'); assert.equal(fs.readFileSync(b, 'utf8'), 'local-b')
    +  })
    +  await t.test('size-only downloads reject both truncation and oversized responses', async () => {
    +    seed(); const target = path.join(temp, 'download.tmp')
    +    await assert.rejects(downloadWebdavFile(client, '/sync/users/alice/a.json', target, 100), /不完整/)
    +    assert.equal(fs.existsSync(target), false)
    +    await assert.rejects(downloadWebdavFile(client, '/sync/users/alice/a.json', target, 1), /超过/)
    +    assert.equal(fs.existsSync(target), false)
    +  })
    +  await t.test('new manifests detect same-size corruption before committing anything', async () => {
    +    seed()
    +    const files = Object.fromEntries([...remote].map(([name, data]) => [name.slice('/sync/'.length), { size: data.length, hash: crypto.createHash('sha256').update(data).digest('hex') }]))
    +    remote.set('/sync/' + webdavManifestName, Buffer.from(JSON.stringify({ schema: 1, files })))
    +    const recovery = new WebdavRecovery(), preview = await recovery.prepare(context())
    +    remote.set('/sync/users/alice/b.json', Buffer.from('broken!'))
    +    const result = await recovery.execute(context(), preview.token)
    +    assert.match(result.errors.join(), /SHA-256/); assert.equal(fs.readFileSync(a, 'utf8'), 'local-a')
    +    remote.delete('/sync/users/alice/b.json')
    +    await assert.rejects(recovery.prepare(context()), /清单中的文件缺失/)
    +  })
    +  await t.test('preview is read-only, bound to config and rejects changed local files', async () => {
    +    seed(); const recovery = new WebdavRecovery(), preview = await recovery.prepare(context())
    +    assert.equal(preview.overwritten, 2); assert.equal(fs.readFileSync(a, 'utf8'), 'local-a')
    +    await assert.rejects(recovery.execute({ ...context(), identity: 'different' }, preview.token), /配置已变化/)
    +    fs.writeFileSync(a, 'user edit')
    +    await assert.rejects(recovery.execute(context(), preview.token), /本地文件已变化/)
    +  })
    +  await t.test('missing-only adds files while retaining existing files and permits guarded rollback', async () => {
    +    seed(); fs.unlinkSync(b)
    +    const recovery = new WebdavRecovery(), preview = await recovery.prepare(context(), { strategy: 'missing', keepCopies: true })
    +    assert.equal(preview.skipped, 1); assert.equal(preview.added, 1)
    +    const result = await recovery.execute(context(), preview.token)
    +    assert.deepEqual(result.errors, []); assert.equal(result.restored, 1)
    +    assert.equal(fs.readFileSync(a, 'utf8'), 'local-a'); assert.equal(fs.readFileSync(b, 'utf8'), 'cloud-b')
    +    rollbackRestoreTask(result.taskId); assert.equal(fs.existsSync(b), false)
    +    assert.ok(listRestoreTasks().some(task => task?.id === result.taskId && task.state === 'rolled-back'))
    +  })
    +  const instance = (mirrorDeletes = false) => {
    +    const sync = new WebDAVSync({ enable: true, url: 'http://fixture.invalid', syncPath: '/sync', types: ['userData'], mirrorDeletes }, temp)
    +    ;(sync as any).client = client
    +    return sync
    +  }
    +  await t.test('runtime status reports actual scheduled checks and clears them when stopped', () => {
    +    const sync = instance(), before = Date.now()
    +    try {
    +      sync.startAutoSync()
    +      const status = sync.getRuntimeStatus()
    +      assert.ok(status.nextSyncAt >= before + 60 * 60_000)
    +      assert.ok(status.nextZipAt >= before + 24 * 3600_000)
    +      assert.equal(status.running, false)
    +      global.lx.restoreMaintenance = true
    +      assert.equal(sync.getRuntimeStatus().nextSyncAt, 0)
    +      global.lx.restoreMaintenance = false
    +    } finally { global.lx.restoreMaintenance = false; sync.stopAutoSync() }
    +    assert.equal(sync.getRuntimeStatus().nextSyncAt, 0)
    +    assert.equal(sync.getRuntimeStatus().nextZipAt, 0)
    +  })
    +  await t.test('successful backup has no body downloads and survives restart without re-uploading', async () => {
    +    seed(); const sync = instance(); const before = reads
    +    await sync.syncChangedFiles(); assert.equal(reads, before)
    +    const uploaded = puts
    +    await instance().syncChangedFiles(); assert.equal(puts, uploaded)
    +    assert.ok(remote.has('/sync/' + webdavManifestName))
    +  })
    +  await t.test('tracked deletion propagates after restart only when mirror deletion is enabled', async () => {
    +    fs.unlinkSync(a); await instance(true).syncChangedFiles()
    +    assert.equal(remote.has('/sync/users/alice/a.json'), false)
    +    fs.unlinkSync(b); await instance(false).syncChangedFiles()
    +    assert.equal(remote.has('/sync/users/alice/b.json'), true)
    +  })
    +  await t.test('a fresh baseline preserves cloud-only manifest entries and permits legacy files with an explicit warning', async () => {
    +    const legacy = '/sync/users/alice/legacy.json'
    +    remote.set(legacy, Buffer.from('legacy'))
    +    const newFile = local('users/alice/new.json', 'new content')
    +    const sync = instance()
    +    ;(sync as any).publishedManifest = ''; (sync as any).remoteVersions = {}; (sync as any).filesHash.clear()
    +    await sync.syncChangedFiles()
    +    const manifest = JSON.parse(remote.get('/sync/' + webdavManifestName)!.toString())
    +    assert.ok(manifest.files['users/alice/b.json'])
    +    assert.ok(manifest.files['users/alice/new.json'])
    +    assert.equal(manifest.files['users/alice/legacy.json'], undefined)
    +    const recovery = new WebdavRecovery(), preview = await recovery.prepare(context(), { strategy: 'missing' })
    +    assert.ok(preview.warnings.some(warning => /部分旧云端文件/.test(warning)))
    +    assert.equal(fs.readFileSync(newFile, 'utf8'), 'new content')
    +    recovery.discard(preview.token)
    +  })
    +  await t.test('failed atomic upload never replaces the previous object and cleans temporary files', async () => {
    +    const file = local('users/alice/upload.json', 'new value'); remote.set('/object', Buffer.from('old value'))
    +    failMove = true
    +    await assert.rejects(uploadWebdavFile(client, file, '/object', 9), /MOVE failed/)
    +    failMove = false
    +    assert.equal(remote.get('/object')!.toString(), 'old value')
    +    assert.equal([...remote.keys()].some(name => name.endsWith('.part')), false)
    +    const before = moves
    +    await assert.rejects(uploadWebdavFile(client, file, '/object', 9, undefined, '0'.repeat(64)), /上传期间文件变化/)
    +    assert.equal(moves, before)
    +  })
    +  await t.test('restore excludes active backups and new backups during a restore', () => {
    +    const done = beginCloudBackup(); assert.throws(beginCloudRestore, /正在备份/); done(); done()
    +    const restored = beginCloudRestore(); assert.throws(beginCloudBackup, /恢复维护/); restored()
    +  })
    +  await t.test('ZIP deletion cannot escape the configured archive directory', () => {
    +    for (const input of ['/else/lx-sync-backup-ok.zip', '../lx-sync-backup-ok.zip', '/zip/../lx-sync-backup-ok.zip', '/zip/config.js']) assert.throws(() => checkedBackupPath('/zip', input))
    +    assert.equal(checkedBackupPath('/zip', 'lx-sync-backup-ok.zip'), '/zip/lx-sync-backup-ok.zip')
    +  })
    +  await t.test('retention respects both limits and always keeps the newest archive', async () => {
    +    remote.clear(); const sync = instance()
    +    ;(sync as any).backupPath = '/zip'; (sync as any).config.retentionCount = 2
    +    for (const date of ['2026-01-01', '2026-01-02', '2026-01-03']) remote.set(`/zip/lx-sync-backup-${date}.zip`, Buffer.from('zip'))
    +    remote.set('/zip/unrelated.zip', Buffer.from('keep'))
    +    await sync.cleanOldBackups()
    +    assert.equal(remote.has('/zip/lx-sync-backup-2026-01-01.zip'), false)
    +    ;(sync as any).config.retentionDays = 1
    +    await sync.cleanOldBackups()
    +    assert.equal(remote.has('/zip/lx-sync-backup-2026-01-02.zip'), false)
    +    assert.equal(remote.has('/zip/lx-sync-backup-2026-01-03.zip'), true)
    +    assert.equal(remote.has('/zip/unrelated.zip'), true)
    +  })
    +  await t.test('real WebDAV SDK streams PUT/MOVE/PROPFIND/GET over isolated local HTTP', async () => {
    +    const store = new Map(), methods: string[] = []
    +    const server = http.createServer(async (req, res) => {
    +      const key = decodeURIComponent(req.url!); methods.push(req.method!)
    +      if (req.method === 'PUT') { const chunks: Buffer[] = []; for await (const chunk of req) chunks.push(chunk); store.set(key, Buffer.concat(chunks)); res.writeHead(201); res.end(); return }
    +      if (req.method === 'MOVE') { const dest = decodeURIComponent(new URL(req.headers.destination!).pathname); store.set(dest, store.get(key)!); store.delete(key); res.writeHead(201); res.end(); return }
    +      if (req.method === 'DELETE') { store.delete(key); res.writeHead(204); res.end(); return }
    +      if (!store.has(key)) { res.writeHead(404); res.end(); return }
    +      if (req.method === 'PROPFIND') { res.writeHead(207, { 'Content-Type': 'application/xml' }); res.end(`${key}${store.get(key)!.length}"fixture"HTTP/1.1 200 OK`); return }
    +      res.writeHead(200, { 'Content-Length': store.get(key)!.length }); res.end(store.get(key))
    +    })
    +    await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
    +    try {
    +      const { createClient } = await import('webdav')
    +      const sdk = createClient(`http://127.0.0.1:${(server.address() as any).port}`)
    +      const file = local('users/alice/stream.json', 'large '.repeat(50000)), size = fs.statSync(file).size
    +      await uploadWebdavFile(sdk, file, '/backup', size)
    +      assert.equal(methods.includes('GET'), false)
    +      const output = path.join(temp, 'sdk-downloaded.tmp')
    +      await downloadWebdavFile(sdk, '/backup', output, size)
    +      assert.deepEqual(fs.readFileSync(output), fs.readFileSync(file))
    +      assert.ok(methods.includes('MOVE') && methods.includes('PROPFIND') && methods.includes('GET'))
    +    } finally { server.closeAllConnections(); await new Promise(resolve => server.close(() => resolve())) }
    +  })
    +})
    diff --git a/test/webdav-restore-ui.test.cjs b/test/webdav-restore-ui.test.cjs
    new file mode 100644
    index 00000000..a92d96fc
    --- /dev/null
    +++ b/test/webdav-restore-ui.test.cjs
    @@ -0,0 +1,40 @@
    +const test = require('node:test');
    +const assert = require('node:assert/strict');
    +const fs = require('node:fs');
    +const vm = require('node:vm');
    +
    +function fixture(confirm) {
    +    const source = fs.readFileSync('public/app.js', 'utf8');
    +    const start = source.indexOf('    async executeWebdavRestore(options) {');
    +    const method = source.slice(start, source.indexOf('\n    async loadWebdavRestoreTasks()', start));
    +    const status = {}, calls = [], errors = [];
    +    const app = vm.runInNewContext(`({${method}})`, {
    +        document: { getElementById: id => id === 'sync-status-content' ? status : id === 'webdav-restore-strategy' ? { value: 'missing' } : { checked: true } },
    +        showSelect: confirm, showSuccess() {}, showError: message => errors.push(message),
    +    });
    +    app.closeRestoreModal = () => {};
    +    app.request = async (_, options) => {
    +        const payload = JSON.parse(options.body); calls.push(payload);
    +        return payload.preview ? { success: true, token: 'confirmed-plan', added: 1, overwritten: 0, merged: 0, skipped: 1, warnings: [], files: [{ path: 'users/alice/data.json', action: 'add' }] } : { success: true };
    +    };
    +    return { app, calls, status, errors };
    +}
    +
    +test('WebDAV restore previews missing-only scope and executes only the confirmed token', async () => {
    +    const { app, calls, status } = fixture(async (_, message) => { assert.match(message, /新增 1/); assert.match(message, /users\/alice\/data.json/); return true; });
    +    await app.executeWebdavRestore({ mode: 'files' });
    +    assert.equal(calls[0].preview, true); assert.equal(calls[0].strategy, 'missing'); assert.equal(calls[0].keepCopies, true);
    +    assert.deepEqual(calls[1], { token: 'confirmed-plan' });
    +    assert.match(status.textContent, /重启/); assert.equal(app._webdavRestoreBusy, false);
    +});
    +
    +test('cancelled WebDAV preview never starts a restore and suppresses duplicate clicks', async () => {
    +    let finish;
    +    const { app, calls } = fixture(() => new Promise(resolve => { finish = resolve; }));
    +    const pending = app.executeWebdavRestore({ mode: 'zip' });
    +    await new Promise(resolve => setImmediate(resolve));
    +    await app.executeWebdavRestore({ mode: 'zip' });
    +    assert.equal(calls.length, 1);
    +    finish(false); await pending;
    +    assert.equal(calls.length, 2); assert.deepEqual(calls[1], { cancel: true, token: 'confirmed-plan' }); assert.equal(app._webdavRestoreBusy, false);
    +});