diff --git a/changelog.md b/changelog.md index 87d15cdd..b68e61b8 100644 --- a/changelog.md +++ b/changelog.md @@ -1,9 +1,19 @@ # Changelog -## v2.2.0 (2026-11-7) +## v2.2.0 (2026-11-10) ### 🌟 新增功能与体验升级 +- **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 逐字歌词,更新酷我歌词、网易歌手和咪咕封面接口,补齐酷狗分享歌单解析;保留被替换的旧实现为注释,修复歌词时间精度、分页与取消请求问题。 @@ -21,6 +31,12 @@ ### 🐛 缺陷修复与体验优化 +- **评分模块归类与迁移边界修复**:评分服务、偏好、快照、校验、身份锁及事务迁移集中到 `src/server/rating/`;修复无评分用户的关联整理、仅评分事务覆盖未保存不喜欢数据的问题,并在不喜欢数据初始化时恢复遗留事务。 + +- **评分迁移与恢复一致性修复**:本地关联在线歌曲时同步迁移不喜欢原因中的评分 ID;评分、歌曲资料与相关不喜欢快照通过事务日志提交,写入失败可回滚,中断后可恢复。旧评分快照恢复时直接整理关联身份,保留手动不喜欢原因与历史快照。 + +- 修复不喜欢规则括号空格重复、同身份资料补全及跨来源隔离;记录评分与不喜欢来源,手动同步增加候选预览、资料补全统计及未解析失败计数。 +- 修复 Subsonic 评分歌单显示与自动联动开关耦合,补齐评分歌单配置和评分快照备份分类;恢复评分时保留收藏,缓存淘汰后尝试重新解析评分歌曲,并提示评分已保存但联动失败的情况。 - **酷狗码导入修复 (#483)**:完善酷狗分享歌单的链接解析与歌曲列表获取,修复部分歌单导入失败的问题。 - **备份记录详情面板优化**:加宽桌面详情弹窗,分开展示标题与文件名,优化表格间距及长路径、SHA-256 和元数据换行,避免内容撑出面板或挤压关闭按钮,并适配移动端宽度。 - **列表长文本滚动优化**:Web 播放器及后台多类列表中,超出宽度的文件名、歌曲名、歌单名等文字在桌面端悬停时滚动,移开后恢复开头;移动端保持自动滚动,完整显示的文字保持静止。 @@ -228,7 +244,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 31d988e1..ceba9e40 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,13 +20,14 @@ services: # Subsonic 独立服务 # - SUBSONIC_STANDALONE=true # - SUBSONIC_PORT=4050 - # WebDAV 备份目标 + # WebDAV 备份目标(仅填地址与凭据不会启动同步,需显式开启 WEBDAV_ENABLE) + # - WEBDAV_ENABLE=true # - WEBDAV_URL= # - WEBDAV_USERNAME= # - WEBDAV_PASSWORD= # 网页播放器鉴权 # - ENABLE_WEBPLAYER_AUTH=true - # - WEBPLAYER_PASSWORD=123456 + # - WEBPLAYER_PASSWORD=<请设置强密码,勿使用默认值> # 其他常用项 # - SYNC_INTERVAL=60 # - CONFIG_PATH=/server/data/config.js diff --git a/docs/en/guide/backup-recovery.md b/docs/en/guide/backup-recovery.md index 304cdda7..fe1180ba 100644 --- a/docs/en/guide/backup-recovery.md +++ b/docs/en/guide/backup-recovery.md @@ -162,3 +162,17 @@ Content verification is a separate historical result, not a real-time guarantee. 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. 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. + +### Subsonic ratings and snapshots + +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. + +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. + +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. + +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. + +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. + +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. diff --git a/docs/en/guide/web-player.md b/docs/en/guide/web-player.md index 8def8635..f040bce6 100644 --- a/docs/en/guide/web-player.md +++ b/docs/en/guide/web-player.md @@ -159,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 2fd40cec..c238184a 100644 --- a/docs/guide/backup-recovery.md +++ b/docs/guide/backup-recovery.md @@ -162,3 +162,17 @@ ZIP 默认最多保留 5 份,保留天数默认 0(不限天数)。成功 在记录页点击“检查文件”,选择本地、云端或两者,以及已选记录、当前筛选结果或当前连接的全部记录。范围只包括记录主文件;绑定附件请使用内容校验入口,历史版本对象不在本次范围。检查存在性、大小、修改时间,不上传、不下载正文、不删除文件,不会生成 SHA-256 内容校验结果。云端逐项请求元信息,允许停止;已完成结果保留。 高级筛选显示当前匹配范围内“已检查 X/Y 项”和最近检查时间,悬停可看最早检查时间;这不是整轮全部成功的证明。每个文件详情有准确时间。本地时间随刷新记录重新检查,手动检查时间另外保留;云端结果按连接与备份版本保存,服务重启后仍可查看。后台检查不保证所有文件同时检查,进程中断的任务需重新启动。 + +### Subsonic 评分与快照 + +评分歌单开关仅控制虚拟歌单显示,不启用自动联动。两个联动开关独立控制方向,阈值为 0 时停止联动;联动只补缺失数据,提高或清除评分不删除不喜欢规则,取消不喜欢不删除评分,也不覆盖已有评分。1 星歌单并入不喜欢命中歌曲,保留已有真实评分。 + +评分快照恢复只替换评分,保留当前歌手和专辑收藏,兼容旧快照格式。评分快照及索引属于备份的「快照」范围;当前 subsonic-meta.json 属于「用户数据」,完整备份请同时选择两项。评分成功但不喜欢解析或写入失败会返回部分成功提示,可重试或手动补同步;两个存储不构成跨文件事务。缓存淘汰的已评分歌曲会尝试重新解析,源不可用时保留评分但暂时无法展示歌曲。 + +新不喜欢规则按来源去重和匹配;未指定来源时显式作用于全部来源。相同来源、歌曲身份不冲突的重复记录会补齐缺失字段,保留已有有效资料和原始显示名称。新不喜欢记录含 provenance,补全记录含 enrichedBy;评分来源存于 subsonic-meta.json 的 ratingOrigins。手动补同步先预览候选(包含在线缓存),执行时空解析计入失败,资料补全数单独报告。 + +配置变化不会重算历史数据。提高低分阈值后,手动补齐可增加符合条件的规则;降低阈值、设为0、关闭联动或清除评分不会撤销旧规则。匹配和显示选项仅改变后续过滤/展示结果。Web反向自动补1星仅限带来源和歌曲ID的单首歌曲,歌手和专辑不喜欢不会被误写成歌曲评分。 + +后台「数据查看 → 不喜欢」按所选用户合并不喜欢规则与不喜欢歌手/专辑库。可筛选类型、添加来源和音乐平台,预览已保存封面、查看规则及完整字段。没有来源记录时显示「来源未记录」,不会推断其来自Web或Subsonic;该页只读,不修改联动或评分。 + +评分身份迁移与恢复使用事务日志:写入失败回滚,进程中断后在下次评分访问时完成提交。恢复时按当前文件关联整理本地与在线评分,并同步不喜欢原因中的评分 ID,保留手动原因与旧快照。事务日志临时保存于用户 `rating/transaction.json`,完成后移除;不要让多个服务器进程同时写入同一数据目录。 diff --git a/docs/guide/web-player.md b/docs/guide/web-player.md index a485366e..c75871a8 100644 --- a/docs/guide/web-player.md +++ b/docs/guide/web-player.md @@ -174,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/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/public/app.js b/public/app.js index f7821344..fdca2f32 100644 --- a/public/app.js +++ b/public/app.js @@ -1635,6 +1635,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'); @@ -1650,6 +1651,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 }; // 统计数据 @@ -1710,6 +1713,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) { @@ -1737,6 +1746,9 @@ class App { }, 400); } catch (err) { + if (this._dataRequestId !== requestId) return; + statsContainer.classList.remove('content-loading'); + contentContainer.classList.remove('content-loading'); contentContainer.innerHTML = '

加载数据失败

'; } finally { applyMarqueeChecks(); @@ -1792,10 +1804,337 @@ 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 = ` @@ -3361,12 +3700,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']) { @@ -3392,6 +3731,9 @@ class App { this.initCustomSelects(); this.syncCustomSelects(); + this.bindLinkSyncToggles(); + this.updateLinkSyncUI(); + void this.loadLinkSyncUsers(); } catch (e) { console.error('Failed to load subsonic config:', e); } @@ -3430,8 +3772,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', @@ -3462,10 +3804,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( @@ -3494,6 +3838,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-primary)'; + target.style.outlineOffset = '4px'; + setTimeout(() => { target.style.outline = 'none'; }, 2000); + }, 350); + } + async loadLogs(isAuto = false) { const logType = document.getElementById('log-type-select')?.value || 'app'; @@ -6194,9 +6686,10 @@ class App { return; } - // 确保「全部 / 歌单快照 / 不喜欢」子页签存在 + // 确保「类型 / 来源」筛选条件存在 this.ensureSnapshotKindTabs(); const kind = this._snapshotKind || 'all'; + const origin = this._snapshotOrigin || 'all'; // 添加加载状态 container.classList.add('content-loading'); @@ -6204,19 +6697,33 @@ class App { try { // 添加 user 参数 const all = await this.request(`/api/data/snapshots?user=${encodeURIComponent(username)}`); - const list = kind === 'all' ? all : all.filter(i => (i.kind || 'list') === kind); + const list = all.filter(i => + (kind === 'all' || (i.kind || 'list') === kind) && + (origin === 'all' || (i.origin || 'auto') === origin)); if (!list.length) { - const emptyText = kind === 'dislike' ? '该用户暂无「不喜欢」快照' : '暂无快照'; + 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('zh-CN', { hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' })}
-
snapshot_${item.id}${item.kind === 'dislike' ? '我不喜欢' : '我的歌单'}
+
${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'); @@ -6264,19 +6771,20 @@ class App { } } - // 在「歌单快照」面板顶部注入「类型」下拉查询条件,便于按 我的歌单 / 我不喜欢 过滤 + // 在「歌单快照」面板顶部注入「类型 / 来源」两个下拉查询条件 ensureSnapshotKindTabs() { - if (document.getElementById('snapshot-kind-filter')) return; 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.innerHTML = ''; sel.value = cur; sel.addEventListener('change', () => { this._snapshotKind = sel.value; @@ -6295,24 +6803,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 @@ -6325,8 +6906,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); @@ -6373,7 +6962,11 @@ class App { const data = await this.request(`/api/data/snapshot?id=${encodeURIComponent(id)}&kind=${kind || 'list'}&user=${encodeURIComponent(username)}`); let backupData, fname; - if (kind === 'dislike') { + 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`; @@ -6496,7 +7089,7 @@ class App { 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; } @@ -6507,6 +7100,8 @@ class App { body: JSON.stringify({ id, kind }) }); showSuccess('回滚成功!请重启客户端或重新连接同步服务。'); + // 仅提示 + 提供手动操作入口,不在此处调用联动同步 + void this.maybeOfferLinkSync(username, kind === 'rating' ? '我的评分' : kind === 'dislike' ? '我不喜欢' : '我的歌单'); this.loadDashboard(); // 刷新数据概览 } catch (err) { showError('回滚失败: ' + err.message); diff --git a/public/index.html b/public/index.html index 234d147a..7c12cb76 100644 --- a/public/index.html +++ b/public/index.html @@ -690,6 +690,14 @@

快速操作

收藏歌手 0 + +
@@ -1493,11 +1501,11 @@

Subsonic 协议配置 + OpenList + 播放缓存设置 ↗ +
@@ -1640,14 +1653,14 @@

Subsonic 协议配置

-
不喜欢 / 评分联动 (Subsonic)
+
评分与不喜欢(Subsonic)
- + - - - + + +
- 在列表中隐藏 dislike 歌曲 + data-help="在 Subsonic 搜索、普通歌单和专辑列表中隐藏命中项。关闭后重新显示,不删除规则,也不修改评分;评分歌单不受此过滤影响。">?