Skip to content

feat(bilibili): 支持把网易云歌曲 / 专辑 / 歌单绑定到 B 站音源 - #631

Open
1507045 wants to merge 1 commit into
cwuom:masterfrom
1507045:feat/bili-collection-binding
Open

1507045 wants to merge 1 commit into
cwuom:masterfrom
1507045:feat/bili-collection-binding

Conversation

@1507045

@1507045 1507045 commented Oct 31, 2026 •

Copy link
Copy Markdown

描述 / Description

为网易云歌曲、专辑与歌单增加「绑定到哔哩哔哩多 P 视频」的音源能力:绑定关系保存在本地 Room 数据库,并随 GitHub / WebDAV 同步;播放时优先使用已绑定的分 P,正在播放页的来源角标也会随之显示为哔哩哔哩。

类型 / Type

  • Bug 修复 / Bug Fix
  • 新功能 / New Feature
  • 文档更新 / Documentation Update
  • 其他(请描述)/ Other (please describe):

修复或解决的问题 / Issues Fixed or Closed by This PR

  • 网易云无版权 / 仅试听片段时,除了自动换源之外缺少「人工指定哪首 B 站视频」的确定性方案
  • 手机端 B 站分享只给 b23.tv 短链、不含 BV 号,旧流程无法直接识别
  • 绑定改动后正在播放的歌曲不会立即生效,需要手动切歌重新解析

关键实现 / Implementation

数据与同步

  • 新增 bili_collection_binding 表(Room 21 → 23),targetKey 覆盖 netease:song:<id> / netease:album:<id> / netease:playlist:<id>(无 id 时退回规范化名称),每条绑定保存视频标识与分 P 列表(tracks_json)
  • BiliCollectionBindingTargetMigration 按 PRAGMA table_info 分四路处理:老库只有表、只有索引、什么都没有、以及被外部清理掉目标表的情况,避免升级时 no such table 崩溃
  • 绑定随 GitHub / WebDAV 同步落地,合并策略按 updatedAt 取新(SyncBiliCollectionBindingMergePolicy);本地保存或解绑后标记同步版本并排队一次延迟同步
  • 解绑写墓碑而不是硬删(表内以空 bvid 表示),墓碑只参与同步、不进播放与 UI;否则「设备 A 解绑」会在下次同步被设备 B 的旧记录复活。合并时沿用策略里已有的「时间戳相同则删除优先」

绑定面板

  • 单曲 / 专辑 / 歌单页的绑定入口统一走 BiliCollectionBindingDialog:打开即用目标名字搜索 B 站,也可以贴 BV 号 / av 号 / 分享文案,b23.tv 短链会跟随跳转后还原出视频标识
  • 单曲绑定:点中搜索结果即确定(自动挑最贴合的分 P 落库并关闭面板),不再要求二次确认
  • 专辑 / 歌单绑定:按曲名相似度与分 P 标题做贪心配对(完全一致 100 / 互相包含 92 / 词命中 / 字符二元组 Dice),时长只参与同分排序;配对结果可在面板里逐首手动改

播放

  • 播放解析优先取绑定命中(单曲 → 当前歌单 → 专辑),命中失效时回退到原流程
  • 绑定保存或删除后,如果改动牵涉当前播放歌曲,按当前进度重解析音源
  • 自动换源探测候选收敛为最多 3 个视频;「换源时优先最高音质」开关放在同一分组
  • 换源顺序保持原链路:网易云 → 本地(neteaseLocalSourceFallbackEnabled)→ B 站(neteaseAutoSourceSwitchEnabled);用户手动绑定的分 P 排在最前
  • 自动换源的 B 站候选增加硬性时长门槛(8 秒,或时长的 2%,封顶 15 秒;任一侧缺时长信息时放行),避免自动挑中现场版、剪辑片段;用户自己绑定或手动选中的分 P 不走这道门槛,只按名字匹配
  • 正在播放页封面右下角来源角标:命中绑定且原本是网易云时显示哔哩哔哩

风险与兼容性 / Risks & Compatibility

  • 数据库版本 21 → 23,附带 22 / 23 两份 schema 与迁移单测;老用户升级路径已覆盖删表与缺索引两种异常
  • 新增设置项默认值保持现有行为:biliAlbumBindingPriorityEnabled 默认开启,但没有绑定时不改变原有解析结果
  • 只新增表与同步域,不改动既有表的字段语义
  • 归档新增 kind 17 记录属于同一协议版本内的追加:旧版本客户端读到含绑定的归档会在 kind in 1..16 断言处失败(错误路径为同步失败,不会损坏数据)。没有在本次一并提升归档协议,是因为 SyncArchiveV4Format 的 readManifest 要求 protocol / schema 精确相等,提升后所有既有 v4 归档(含 v4-frozen 夹具)将无法读取,需要配套的格式迁移;这一点留给维护者按发版节奏决定

评审反馈 / Review feedback

已在最新提交中处理:

第一轮:

  • 归档元数据扫描器缺少 kind 17 描述符(Codex P1 / CodeRabbit Major):SyncArchiveMetadataWire.descriptors 补 SyncBiliCollectionBinding 描述符,并新增 SyncArchiveBiliCollectionBindingTest 做读写往返覆盖
  • 解绑不产生墓碑、会被同步复活(Codex P1 / CodeRabbit Major):解绑改为写墓碑(表内空 bvid),播放与 UI 过滤墓碑,同步快照携带墓碑
  • 同步落地忽略 expectedMutationVersion / 整表替换非原子(CodeRabbit Major):applyAlbumBindings 比较本地写入版本,不一致时跳过并返回 false;DAO 增加 @Transaction replaceAll
  • 同名不同 id 的歌借走别人的分 P(CodeRabbit Major):trackFor 在双方都有 id 时只按 id 匹配
  • 手动改配对时两个歌抢同一个分 P 会被静默丢弃(Codex P2):选中分 P 时自动从其它歌上摘掉该分 P

第二轮:

  • 写入版本标记在释放锁之后才发生(Codex P1):markMutation 移到仓储互斥锁内,等锁的 replaceAll 因此必然读到新版本而跳过
  • 绑定在网易云完整缓存 / URL 预取命中时不会生效(Codex P2):resolveSongUrl 在缓存快路之前先尝试绑定解析,成功即直接放 B 站分 P;解析失败再照旧走缓存与网络,不牺牲离线可用性
  • 来源角标在绑定优先级关闭或回退时误报哔哩哔哩(Codex P2):只有还没有实测音源(playbackAudioSource == null)时才用绑定兜底,一旦解析出具体来源就以它为准
  • 远端时钟领先时墓碑时间戳偏旧,解绑会被旧记录压回(Codex P2):墓碑取 max(当前时间, 被删记录时间 + 1)
  • 同一毫秒的两条绑定合并结果依赖参数顺序(Codex P2):时间戳相同时按内容生成稳定决胜键,保证两端合并可交换

测试方式 / Testing

本机(Windows)实际执行并通过:

  • ./gradlew :app:assembleDebug
  • ./gradlew :app:lintDebug(0 error)与 :app:verifyDomainDependencies -x :app:crapToolTests、python3 tools_pub/quality/module_boundaries.py
  • ./gradlew :app:testDebugUnitTest --tests 'moe.ouom.neriplayer.ui.screen.NowPlayingScreenTest' --tests 'moe.ouom.neriplayer.ui.screen.SettingsPageTest'
  • ./gradlew :app:testDebugUnitTest --tests 'moe.ouom.neriplayer.architecture.*'(行数预算、values / values-zh / values-en 键对齐)
  • ./gradlew :platform:testDebugUnitTest --tests 'moe.ouom.neriplayer.platform.bilibili.binding.*'
  • ./gradlew :model:testDebugUnitTest --tests 'moe.ouom.neriplayer.data.model.bilibili.binding.*' --tests 'moe.ouom.neriplayer.data.model.sync.SyncBiliCollectionBindingModelsTest'
  • ./gradlew :database:testDebugUnitTest --tests 'moe.ouom.neriplayer.data.local.database.migration.*'
  • ./gradlew :sync:testDebugUnitTest --tests 'moe.ouom.neriplayer.data.sync.policy.SyncBiliCollectionBindingMergePolicyTest' --tests 'moe.ouom.neriplayer.data.sync.archive.SyncArchiveBiliCollectionBindingTest'
  • 模拟器实机:人为构造「v22 + 目标表缺失」的库验证升级不再崩溃;安装后播放已绑定曲目,确认分 P 生效且来源角标切换为哔哩哔哩

Windows 环境限制 / Windows-only limits

以下任务在本机被既有问题阻断,失败点均落在本次未改动的文件上,请以 Linux CI 结果为准:

  • :sync 部分用例:GitHubSyncCheckpoint 用 File.renameTo 覆盖已存在文件,Windows 上返回 false 抛 IOException
  • :local 的 AutoSettingsGeneratedTest 中 DataStore 写入用例:settings.preferences_pb.tmp 重命名失败
  • :database 的 NeriUserDataDatabaseVersionTest:SQLiteCantOpenDatabaseException,连带 :app:verifyCrap 无法完成

清单 / Checklist

  • 我已阅读并遵循贡献指南 / I have read and followed the contribution guidelines
  • 我已在本地测试这些更改 / I have tested these changes locally
  • 我已更新相关文档或注释(如适用) / I have updated relevant documentation or comments (if applicable)
  • 我确认本次更改没有破坏任何原有逻辑 / I confirm this change does not break existing behavior
  • 我确认本次更改保持向下兼容(如适用) / I confirm this change remains backward compatible (if applicable)
  • 我已运行必要的构建、测试或静态检查 / I have run the required build, tests, or static checks
  • 我确认没有提交密钥、Token 或其他敏感信息 / I confirm no secrets, tokens, or other sensitive information were committed
  • 涉及 UI 变更时,我已补充截图或录屏(如适用) / I have added screenshots or recordings for UI changes (if applicable)

其他信息 / Additional Information

绑定面板为纯 Compose 实现,未引入新依赖;B 站请求复用现有 BiliClient。

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-11-01T05:24:43.658705Z
We couldn’t complete this review. Comment @codex review to try again.
e0d6dbe New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cf44272d08

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

private fun validateHeader(kind: Int) {
require(count < expectedRecords) { "Unexpected sync record" }
require(kind in 1..16) { "Unexpected sync record" }
require(kind in 1..17) { "Unexpected sync record" }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Register record 17 in the metadata scanner

When an archive contains a binding, this now admits kind 17 and payload() then calls SyncArchiveMetadataBudget.inspect, but SyncArchiveMetadataWire.descriptors still has only the 16 serializers for kinds 1–16 and indexes it with descriptors[kind - 1]. Consequently, the next read of any uploaded archive containing biliCollectionBindings throws an IndexOutOfBoundsException; add the binding descriptor (and its nested track descriptor) before accepting/writing this kind.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7a09ed: appended SyncBiliCollectionBinding.serializer().descriptor to SyncArchiveMetadataWire.descriptors (index 16 for kind 17). The nested track descriptor is picked up automatically by nested() since field 8 is a list of a class. Added SyncArchiveBiliCollectionBindingTest, which writes an archive containing an active binding plus a tombstone and reads it back through SyncArchiveRecords.read (that path runs the metadata scanner, so it reproduces the out-of-bounds failure).

data.biliVideoSkipRules.forEach { writer.record(14, it) }
SyncSongLyricMergePolicy.collectOverrides(data).forEach { writer.record(15, SyncSongLyricMergePolicy.normalize(it)) }
data.playlistUsageDeletions.forEach { writer.record(16, it) }
data.biliCollectionBindings.forEach { writer.record(17, it) }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Version the new archive record before publishing it

On a sync target shared with an older app build, writing kind 17 under the unchanged V4 protocol/schema is incompatible: the parent reader hard-codes kind in 1..16, while SyncArchiveV4Format.PROTOCOL/SCHEMA and CURRENT_PROTOCOL_VERSION remain 4/1/4. Once an upgraded device uploads a binding, every older device rejects the entire archive instead of ignoring the new section, so this record needs a protocol/schema bump and upgrade gate (or a backward-compatible encoding).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checked this one carefully and deliberately did not bump the archive version. SyncArchiveV4Format.readManifest requires value.protocol == PROTOCOL && value.schema == SCHEMA exactly, and the repo ships frozen v3/v4 fixtures (SyncArchiveVersionMigrationTest, /sync/archive/v4-frozen). Raising PROTOCOL/SCHEMA makes every existing v4 archive unreadable, so it needs a real format migration (accept N-1 manifests, or a negotiation field) plus the CURRENT_PROTOCOL_VERSION upgrade gate; that is a cross-cutting change I would rather not fold into this feature PR.

What the change does instead: the record is additive within V4, so failures stay on the error path — an older build hits require(kind in 1..16) and the archive read returns a failure (SyncBackup ... invalid message), it does not corrupt remote or local data. The limitation is now recorded in the PR's Risks & Compatibility section so the maintainer can decide whether a protocol bump ships with this release.

if (key.isEmpty()) return
mutex.withLock {
val current = cached ?: loadFromDatabase().also { cached = it }
dao.deleteByKey(key)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Persist a tombstone when removing a synced binding

When the binding has already been uploaded or exists on another device, this physical delete makes AndroidSyncSnapshotBuilder.albumBindings() emit no local candidate for the key; the union/latest-wins merge therefore retains the remote active row, and applyAlbumBindings() restores it via replaceAll on the next sync. The sync model and merge policy already support isDeleted records, so unbinding must retain or otherwise emit a timestamped tombstone rather than removing all evidence of the deletion.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7a09ed. remove() now upserts a timestamped tombstone instead of deleteByKey; tombstones are stored with an empty bvid (no schema change), loadFromDatabase/UI/get()/all() filter them out, while snapshot() — the only consumer is AndroidSyncSnapshotBuilder.albumBindings() — includes them so isDeleted = true reaches SyncBiliCollectionBindingMergePolicy. Tombstones coming back from a remote are persisted too, so they keep suppressing the stale row. Covered by BiliCollectionBindingRepositoryTest (unbind → tombstone only in snapshot; tombstone survives replaceAll; re-bind replaces it).

assignments = if (page == null) {
assignments - song.id
} else {
assignments + (song.id to page.cid)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce unique page assignments before saving

When manually correcting a multi-song match, every row offers the same pages and this update permits two different songs to select the same cid. Both rows then appear matched and save reports success, but BiliCollectionBinding.normalized() applies distinctBy { it.cid }, silently discarding one song's track; clear the previous owner or reject the duplicate selection (or preserve both mappings) before persisting.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7a09ed by making the selection exclusive: picking a page for one song now drops that same cid from any other song's assignment (assignments.filterValues { it != page.cid } + (song.id to page.cid)). The previous owner's row immediately shows as unmatched, so nothing is silently discarded at save time.

@coderabbitai

coderabbitai Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • Resume reviews
  • Trigger review
Walkthrough

新增 B 站单曲、专辑和歌单分集绑定功能,包含本地存储、视频匹配、绑定界面和播放解析。绑定数据接入同步归档、合并及本地应用流程。新增网易云自动切源音质探测和 B 站绑定优先播放设置,并调整播放来源标识解析。

Merge Risk: Low · previous review b7a09

A concurrent sync can rarely overwrite a newly saved binding, and the keyboard’s search and done keys do not perform their advertised actions. The changes are mergeable with owner awareness, though the binding commit should be made atomic.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 35.85% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documenta… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed 标题准确概括了本次变更的主要功能:将网易云歌曲、专辑和歌单绑定到 B 站音源。
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 35.85% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 53 functions across 54 files.

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

Other comments: 2
modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/url/PlayerManagerUrlExtensions.kt: 1

1613-1619: Minor · Functional Correctness · Quick win

已完整缓存的网易云歌曲不会使用绑定分 P

绑定检查位于 getNeteaseSongUrl 中,但 resolveSongUrl 会先执行以下检查:

  • checkLocalCache
  • inspectExoPlayerCache(cacheKey) 的完整缓存检查
  • consumeGenericUrlPrefetch

如果这首歌曾经以网易云音源播放过,并且已经完整缓存,resolveSongUrl 会直接返回 offline.cache 地址,不会进入这段绑定逻辑。

cacheKey 来自 computeCacheKey(song),它是网易云的缓存键,与绑定状态无关。因此,refreshCurrentSongAfterBiliBindingChange 触发刷新后也会再次命中同一份缓存。用户保存绑定后仍然听到网易云音频,这与 PR 描述的“绑定优先”行为不一致。

修复方式:在 resolveSongUrl 进入 Exo 缓存和预取缓存检查之前,先为非本地的网易云歌曲尝试 tryResolveNeteaseAlbumBindingSource。已下载的本地文件可以保持优先。另一种方式是把绑定状态纳入 computeCacheKey。

app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingPlaybackSourceResolution.kt: 1

46-50: Minor · Functional Correctness · Quick win

关闭绑定优先级时,保持网易云角标

当 biliAlbumBindingPriorityEnabled 为 false 时,绑定解析会返回 null,播放流程可以继续使用网易云音源。但 NowPlayingScreen 仍会仅根据绑定记录设置 hasBiliCollectionBinding,不检查该开关。此时 resolved == NETEASE 会被覆盖为 BILIBILI,导致角标与实际音源不一致。

绑定成功且取流成功时,buildBiliPlaybackAudioInfo 已将来源设置为 BILIBILI,该情况不需要覆盖。绑定只应在来源未知时兜底,并同步更新测试断言。

🐛 建议修复
-    if (hasBiliCollectionBinding) {
-        if (resolved == null || resolved == PlaybackSourceType.NETEASE) {
-            return PlaybackSourceType.BILIBILI
-        }
+    if (hasBiliCollectionBinding && resolved == null) {
+        return PlaybackSourceType.BILIBILI
     }

Review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 51f0100 and cf44272.

Files ignored due to path filters: 6
  • CONTRIBUTING.md is excluded by !**/*.md
  • CONTRIBUTING_EN.md is excluded by !**/*.md
  • README.md is excluded by !**/*.md
  • README_EN.md is excluded by !**/*.md
  • docs/implementation.md is excluded by !**/*.md
  • docs/implementation_EN.md is excluded by !**/*.md
Files selected for processing: 68
  • app/src/main/java/moe/ouom/neriplayer/core/di/AppContainer.kt
  • app/src/main/java/moe/ouom/neriplayer/core/di/player/AppPlayerBindings.kt
  • app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingPlaybackSourceResolution.kt
  • app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingScreen.kt
  • app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/NowPlayingSecondaryActions.kt
  • app/src/main/java/moe/ouom/neriplayer/ui/screen/nowplaying/actions/MoreOptionsMainContent.kt
  • app/src/main/java/moe/ouom/neriplayer/ui/screen/playlist/BiliCollectionBindingDialog.kt
  • app/src/main/java/moe/ouom/neriplayer/ui/screen/playlist/LocalPlaylistDetailModernContent.kt
  • app/src/main/java/moe/ouom/neriplayer/ui/screen/playlist/NeteaseCollectionDetailScreen.kt
  • app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/SettingsScreen.kt
  • app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/component/playback/NeteaseSourceQualitySettingsItems.kt
  • app/src/main/java/moe/ouom/neriplayer/ui/screen/tab/settings/page/SettingsSearchIndex.kt
  • app/src/test/java/moe/ouom/neriplayer/architecture/OwnedMainSourceLineBudgetTest.kt
  • app/src/test/java/moe/ouom/neriplayer/ui/screen/NowPlayingScreenTest.kt
  • app/src/test/java/moe/ouom/neriplayer/ui/screen/tab/settings/page/SettingsPageTest.kt
  • config/quality/domain-dependencies.json
  • modules/common/src/main/res/values-en/strings_auth.xml
  • modules/common/src/main/res/values-en/strings_bili_album_binding.xml
  • modules/common/src/main/res/values-zh/strings_auth.xml
  • modules/common/src/main/res/values-zh/strings_bili_album_binding.xml
  • modules/common/src/main/res/values/strings_auth.xml
  • modules/common/src/main/res/values/strings_bili_album_binding.xml
  • modules/database/schemas/moe.ouom.neriplayer.data.local.database.NeriUserDataDatabase/22.json
  • modules/database/schemas/moe.ouom.neriplayer.data.local.database.NeriUserDataDatabase/23.json
  • modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/NeriUserDataDatabase.kt
  • modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/dao/BiliCollectionBindingDao.kt
  • modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/entity/BiliCollectionBindingEntity.kt
  • modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/migration/platform/BiliCollectionBindingMigration.kt
  • modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/migration/platform/BiliCollectionBindingTargetMigration.kt
  • modules/database/src/test/java/moe/ouom/neriplayer/data/local/database/migration/BiliCollectionBindingMigrationTest.kt
  • modules/local/src/main/java/moe/ouom/neriplayer/data/local/platform/bilibili/BiliCollectionBindingRepositoryProvider.kt
  • modules/local/src/main/java/moe/ouom/neriplayer/data/local/playlist/LocalPlaylistRepository.kt
  • modules/local/src/main/java/moe/ouom/neriplayer/data/settings/SettingsRepository.kt
  • modules/local/src/main/java/moe/ouom/neriplayer/data/settings/playback/PlaybackSettingsSection.kt
  • modules/local/src/main/java/moe/ouom/neriplayer/data/sync/github/GitHubSyncManager.kt
  • modules/local/src/main/java/moe/ouom/neriplayer/data/sync/host/AndroidSyncLocalApplyHost.kt
  • modules/local/src/main/java/moe/ouom/neriplayer/data/sync/host/AndroidSyncLocalDataStore.kt
  • modules/local/src/main/java/moe/ouom/neriplayer/data/sync/host/AndroidSyncSnapshotBuilder.kt
  • modules/local/src/main/java/moe/ouom/neriplayer/data/sync/webdav/WebDavSyncManager.kt
  • modules/local/src/test/java/moe/ouom/neriplayer/data/playlist/usage/PlaylistUsagePersistenceTest.kt
  • modules/local/src/test/java/moe/ouom/neriplayer/data/settings/AutoSettingsGeneratedTest.kt
  • modules/local/src/test/java/moe/ouom/neriplayer/data/sync/host/AndroidSyncSnapshotDeviceIdTest.kt
  • modules/local/src/test/java/moe/ouom/neriplayer/data/sync/host/AndroidSyncSnapshotLegacyLyricsTest.kt
  • modules/local/src/test/java/moe/ouom/neriplayer/data/sync/host/SyncLocalHostTest.kt
  • modules/model/src/main/java/moe/ouom/neriplayer/data/model/bilibili/binding/BiliCollectionBinding.kt
  • modules/model/src/main/java/moe/ouom/neriplayer/data/model/sync/SyncBiliCollectionBindingModels.kt
  • modules/model/src/main/java/moe/ouom/neriplayer/data/model/sync/SyncDataModels.kt
  • modules/model/src/test/java/moe/ouom/neriplayer/data/model/bilibili/binding/BiliCollectionBindingAffectsSongTest.kt
  • modules/platform/src/main/java/moe/ouom/neriplayer/platform/bilibili/api/client/BiliClient.kt
  • modules/platform/src/main/java/moe/ouom/neriplayer/platform/bilibili/binding/BiliCollectionBindingRepository.kt
  • modules/platform/src/main/java/moe/ouom/neriplayer/platform/bilibili/binding/BiliCollectionTrackMatcher.kt
  • modules/platform/src/main/java/moe/ouom/neriplayer/platform/bilibili/playback/quality/BiliAudioSelector.kt
  • modules/platform/src/test/java/moe/ouom/neriplayer/platform/bilibili/binding/BiliCollectionTrackMatcherTest.kt
  • modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/PlayerManager.kt
  • modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/host/PlayerRepositoryDependencies.kt
  • modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/lifecycle/PlayerManagerBiliBindingSettingsObserver.kt
  • modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/lifecycle/PlayerManagerLifecycleExtensions.kt
  • modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/resolver/netease/PlayerManagerNeteaseAutoSourceSwitch.kt
  • modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/resolver/netease/PlayerManagerNeteaseCollectionBinding.kt
  • modules/playback/runtime/src/main/java/moe/ouom/neriplayer/core/player/url/PlayerManagerUrlExtensions.kt
  • modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/archive/SyncArchiveRecords.kt
  • modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/change/SyncDataChangeDetector.kt
  • modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/merge/engine/SyncDataMerger.kt
  • modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/policy/SyncBiliCollectionBindingMergePolicy.kt
  • modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/runtime/SyncLocalApplyHost.kt
  • modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/runtime/SyncLocalDataApplier.kt
  • modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/sanitize/SyncDataSanitizer.kt
  • modules/sync/src/test/java/moe/ouom/neriplayer/data/sync/policy/SyncBiliCollectionBindingMergePolicyTest.kt

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

Comment on lines +105 to +110
private suspend fun albumBindings(): List<moe.ouom.neriplayer.data.model.sync.SyncBiliCollectionBinding> {
val repo = biliCollectionBindingRepo ?: return emptyList()
return SyncBiliCollectionBindingMergePolicy.sanitize(
repo.snapshot().map { it.toSyncBiliCollectionBinding() }
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major · Functional Correctness · Heavy lift

解绑不会同步:快照不产生删除墓碑,已解绑的绑定会被合并恢复。

albumBindings() 只映射 repo.snapshot() 中的有效绑定。BiliCollectionBindingRepository.remove() 只执行 dao.deleteByKey,不保留墓碑。因此 SyncBiliCollectionBinding.isDeleted 在生产路径上永远不会为 true。

触发流程:

  1. 设备 A 绑定并同步,远端持有该绑定。
  2. 设备 A 解绑,本地快照中没有该 key。
  3. SyncBiliCollectionBindingMergePolicy.merge(local, remote) 取并集,远端的有效绑定保留下来。
  4. applyAlbumBindings 执行 replaceAll,把绑定写回本地。

结果:解绑会被同步撤销,即使只有一台设备也是如此。applyAlbumBindings 中的注释“没有关联的删除墓碑”与模型文档里的“解绑产生的墓碑”相矛盾。

请持久化解绑墓碑(key 加 updatedAt),并在这里输出 isDeleted = true 的记录。

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7a09ed — same fix as the Codex finding above. Unbind writes a tombstone (empty bvid, fresh updatedAt), snapshot() now emits isDeleted = true records for the sync payload, playback/UI reads filter tombstones, and remote tombstones are persisted on apply. I also removed the now-incorrect applyAlbumBindings comment that claimed there were no deletion tombstones.

Comment on lines +45 to +53
fun trackFor(song: SongItem): BiliCollectionBindingTrack? {
if (tracks.isEmpty()) return null
if (song.id > 0L) {
tracks.firstOrNull { it.songId > 0L && it.songId == song.id }?.let { return it }
}
val songName = normalizeBiliBindingText(song.originalName ?: song.name)
if (songName.isEmpty()) return null
return tracks.firstOrNull { normalizeBiliBindingText(it.songName) == songName }
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major · Functional Correctness · Quick win

trackFor 的歌名回退会把无关歌曲匹配到绑定分 P

当 song.id > 0 且没有按 id 命中时,代码继续按规范化歌名匹配。网易云歌曲都有有效 id,所以这个回退会作用于所有未绑定的歌曲。

BiliCollectionBindingRepository.hitIn 在没有歌单上下文时会扫描全部歌单绑定,并调用 trackFor。播放侧的 tryResolveNeteaseAlbumBindingSource 通过 forSong(song) 走这条路径,且从不传入歌单上下文。因此,只要某个歌单绑定里有同名曲目,其他歌手的同名歌曲(例如 "Intro" 或 "晴天" 翻唱)都会播放这条绑定的 B 站分 P。播放页角标也会显示为哔哩哔哩。affectsSong 也受影响,会对无关歌曲触发刷新。

修复方式:歌曲和曲目都有有效 id 时,只按 id 匹配。歌名回退只用于缺少 id 的情况。

🐛 建议修复
--- "a/modules/model/src/main/java/moe/ouom/neriplayer/data/model/bilibili/binding/BiliCollectionBinding.kt"
+++ "b/modules/model/src/main/java/moe/ouom/neriplayer/data/model/bilibili/binding/BiliCollectionBinding.kt"
@@ -42,15 +42,19 @@
     val tracks: List<BiliCollectionBindingTrack> = emptyList(),
     val updatedAt: Long = 0L
 ) {
     fun trackFor(song: SongItem): BiliCollectionBindingTrack? {
         if (tracks.isEmpty()) return null
         if (song.id > 0L) {
             tracks.firstOrNull { it.songId > 0L && it.songId == song.id }?.let { return it }
         }
         val songName = normalizeBiliBindingText(song.originalName ?: song.name)
         if (songName.isEmpty()) return null
-        return tracks.firstOrNull { normalizeBiliBindingText(it.songName) == songName }
+        // 双方都有 id 时 id 不同即视为不同歌曲,避免同名歌曲串播
+        return tracks.firstOrNull {
+            (song.id <= 0L || it.songId <= 0L) &&
+                normalizeBiliBindingText(it.songName) == songName
+        }
     }
 
     /**
      * 这次保存或删除的绑定是否牵涉这首歌:单曲键、所属专辑键或当前播放歌单键命中,
Committable suggestion

IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fun trackFor(song: SongItem): BiliCollectionBindingTrack? {
if (tracks.isEmpty()) return null
if (song.id > 0L) {
tracks.firstOrNull { it.songId > 0L && it.songId == song.id }?.let { return it }
}
val songName = normalizeBiliBindingText(song.originalName ?: song.name)
if (songName.isEmpty()) return null
return tracks.firstOrNull { normalizeBiliBindingText(it.songName) == songName }
}
fun trackFor(song: SongItem): BiliCollectionBindingTrack? {
if (tracks.isEmpty()) return null
if (song.id > 0L) {
tracks.firstOrNull { it.songId > 0L && it.songId == song.id }?.let { return it }
}
val songName = normalizeBiliBindingText(song.originalName ?: song.name)
if (songName.isEmpty()) return null
// 双方都有 id 时 id 不同即视为不同歌曲,避免同名歌曲串播
return tracks.firstOrNull {
(song.id <= 0L || it.songId <= 0L) &&
normalizeBiliBindingText(it.songName) == songName
}
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7a09ed, using the suggested shape: when both sides have a positive id, only the id match counts; the name fallback is limited to rows where one side lacks an id ((song.id <= 0L || track.songId <= 0L) && normalizedNameMatch). Added same name with a different id never borrows the binding to BiliCollectionBindingAffectsSongTest so a same-title cover cannot hijack the bound Bilibili page.

Comment on lines +134 to +146
suspend fun replaceAll(bindings: List<BiliCollectionBinding>) {
val valid = bindings.map { it.normalized() }
.filter { it.isValid() }
.distinctBy { it.targetKey }
mutex.withLock {
dao.deleteAll()
if (valid.isNotEmpty()) {
dao.upsertAll(valid.map { it.toEntity(json) })
}
cached = valid.associateBy { it.targetKey }
}
bumpRevision()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major · Data Integrity & Integration · Heavy lift

Supported by static analysis

Script executed:

#!/bin/bash
rg -n -C8 'applyAlbumBindings|expectedMutationVersion' --type=kotlin -g '*SyncLocalDataApplier*'

Repository: cwuom/NeriPlayer

Length of output: 6160


Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- repository ---'
nl -ba modules/platform/src/main/java/moe/ouom/neriplayer/platform/bilibili/binding/BiliCollectionBindingRepository.kt | sed -n '1,220p'
printf '%s\n' '--- DAO and entity references ---'
rg -n -C10 --type=kotlin 'interface BiliCollectionBindingDao|class BiliCollectionBindingDao|deleteAll\(|upsertAll\(|replaceAll\(' modules
printf '%s\n' '--- album binding host implementations and contracts ---'
rg -n -C14 --type=kotlin 'applyAlbumBindings|expectedMutationVersion|mutationVersion|markSyncMutation' modules

Repository: cwuom/NeriPlayer

Length of output: 44942


Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- DAO declaration ---'
rg -l --type=kotlin 'BiliCollectionBindingDao' modules | while read -r f; do
  echo "FILE: $f"
  rg -n -C12 --type=kotlin 'BiliCollectionBindingDao|deleteAll\(\)|upsertAll\(' "$f"
done
printf '%s\n' '--- concrete album binding host ---'
rg -l --type=kotlin 'applyAlbumBindings' modules | while read -r f; do
  echo "FILE: $f"
  rg -n -C18 --type=kotlin 'applyAlbumBindings' "$f"
done
printf '%s\n' '--- mutation version declarations and callers ---'
rg -n -C10 --type=kotlin 'expectedMutationVersion|mutationVersion|markSyncMutation' modules/platform modules/app modules/local modules/sync 2>/dev/null | head -n 500

Repository: cwuom/NeriPlayer

Length of output: 42046


Script executed:

#!/bin/bash
set -e
printf '%s\n' '--- AndroidSyncLocalApplyHost ---'
nl -ba modules/local/src/main/java/moe/ouom/neriplayer/data/sync/host/AndroidSyncLocalApplyHost.kt | sed -n '1,155p'
printf '%s\n' '--- guarded mutation-version implementation ---'
nl -ba modules/platform/src/main/java/moe/ouom/neriplayer/platform/bilibili/skip/BiliVideoSkipRepository.kt | sed -n '1,215p'
printf '%s\n' '--- mutation-version accessor definitions ---'
rg -n -C8 --type=kotlin 'fun readSyncMutationVersion|readSyncMutationVersion\(|class SecureTokenStorage|SecureTokenStorage\(' modules/local modules/platform modules/sync

Repository: cwuom/NeriPlayer

Length of output: 45478


阻止过期同步覆盖本地绑定,并以事务替换绑定表。

AndroidSyncLocalApplyHost.applyAlbumBindings 忽略 expectedMutationVersion,始终执行整表替换并返回 true。快照后发生的本地绑定写入可能因此被远端快照删除。

replaceAll 还分别执行 deleteAll() 和 upsertAll(...)。中途失败或进程终止时,绑定表可能保持为空。

请在 DAO 中用 @Transaction 包装替换操作,并在与本地写入版本更新一致的同步边界内检查 expectedMutationVersion。版本不一致时跳过替换并返回 false。

建议修复原子替换
 // BiliCollectionBindingDao
+@Transaction
+suspend fun replaceAll(bindings: List<BiliCollectionBindingEntity>) {
+    deleteAll()
+    if (bindings.isNotEmpty()) upsertAll(bindings)
+}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both parts fixed in b7a09ed:

  • applyAlbumBindings now passes expectedMutationVersion into replaceAll, which compares it against the local sync mutation version inside the repository mutex and returns false (skipping the replace and logging) when a local binding write happened after the snapshot — matching BiliVideoSkipRepository.replaceFromSyncIfUnchanged.
  • BiliCollectionBindingDao gained a @Transaction suspend fun replaceAll(...), so delete + insert can no longer leave an empty table.

Added a regression test (stale sync snapshot does not overwrite a newer local change).

private fun validateHeader(kind: Int) {
require(count < expectedRecords) { "Unexpected sync record" }
require(kind in 1..16) { "Unexpected sync record" }
require(kind in 1..17) { "Unexpected sync record" }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major · Stability & Availability · Quick win

Supported by static analysis

Script executed:

rg -n 'SyncArchiveMetadataWire' modules --glob '*.kt'
f=$(rg -l 'object SyncArchiveMetadataWire' modules --glob '*.kt' | head -1); echo "$f"; sed -n '1,200p' "$f"

Repository: cwuom/NeriPlayer

Length of output: 4129


Script executed:

#!/bin/bash
set -u
D=modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/archive
echo "== budget call sites"
rg -n -F -e 'budgetFor(' -e 'SyncArchiveMetadataBudget' -- modules/sync || test "$?" -eq 1
echo "== SyncArchiveMetadataBudget.kt"
sed -n '1,80p' "$D/budget/SyncArchiveMetadataBudget.kt"
echo "== SyncArchiveRecords.kt kind/budget/write references"
rg -n -e 'kind' -e 'budget' -e 'biliCollectionBindings' -e 'Unexpected sync record' -- "$D/SyncArchiveRecords.kt" || test "$?" -eq 1
echo "== SyncArchiveRecords.kt 190-260"
sed -n '190,260p' "$D/SyncArchiveRecords.kt"
echo "== descriptor list in wire file (lines 12-30)"
sed -n '12,30p' "$D/budget/SyncArchiveMetadataWire.kt"

Repository: cwuom/NeriPlayer

Length of output: 17442


SyncArchiveMetadataWire 没有 kind 17 的描述符,读取含绑定记录的归档时会越界失败。

validateHeader 接受 kind 17,但 SyncArchiveMetadataWire.descriptors 只有 16 项。读取 kind 17 时,root(kind) 会访问 descriptors[16],触发越界异常。该异常不是 IOException,所以不会走归档的 IO 错误处理路径。归档只要包含一条 kind 17 记录,读取就会在该记录处中断。

写入端会在存在绑定时写出 kind 17 记录,且不会经过 SyncArchiveMetadataWire.inspect。因此,导出的归档可以包含读取端无法解析的记录。

建议在描述符列表末尾追加 SyncBiliCollectionBinding 的描述符,使其索引为 16:

Suggested fix
         SyncSong.serializer().descriptor, SyncPlaylistUsageDeletion.serializer().descriptor
+        , SyncBiliCollectionBinding.serializer().descriptor

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in b7a09ed: descriptor appended so descriptors[16] is SyncBiliCollectionBinding.serializer().descriptor, plus a new SyncArchiveBiliCollectionBindingTest that round-trips kind 17 through the archive reader (the reader path runs SyncArchiveMetadataBudget.inspect, so the test exercises the scanner that used to blow up).

@1507045
1507045 force-pushed the feat/bili-collection-binding branch from cf44272 to b7a09ed Compare October 31, 2026 15:37

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b7a09edc7c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

dao.upsert(normalized.toEntity(json))
cached = current + (normalized.targetKey to normalized)
}
onChanged()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mark binding mutations before releasing the lock

With the production BiliCollectionBindingRepositoryProvider, onChanged() is the call that advances SecureTokenStorage's sync mutation version. Because this call happens after mutex.withLock returns, a replaceAll() already waiting on the mutex can acquire it in between, still observe the snapshot's old version, and overwrite the just-saved row; the subsequent callback then marks a mutation for data that has already been lost. Mark the mutation while the write is still protected (the same ordering is needed in remove).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4eddf0d. The repository now takes a separate markMutation callback and calls it inside mutex.withLock (right after the DB write and cache update) in both save and remove; onChanged only schedules the sync workers and still runs after the lock. A replaceAll that was waiting on the mutex therefore observes the bumped version and skips, so the just-saved row cannot be wiped. Regression test: local writes advance the sync mutation version before releasing the lock.

Comment on lines +1614 to +1616
if (allowAutoBiliFallback) {
tryResolveNeteaseAlbumBindingSource(song, sideEffects)?.let {
return@withContext it

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve bindings before reusing the logical-song cache

When a NetEase song already has a complete ExoPlayer cache entry (or a generic URL prefetch), resolveSongUrl returns that result before it ever calls getNeteaseSongUrl, so this new binding lookup is unreachable. Saving a binding or enabling the priority switch refreshes NetEase with forceRefresh=false, leaving the cached NetEase source playing despite the setting's promise that bound parts play directly. Resolve the binding before those logical-song cache fast paths or make the binding/source part of cache invalidation/keying.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4eddf0d, taking the "resolve the binding before the cache fast paths" option. resolveSongUrl now attempts tryResolveNeteaseAlbumBindingSource right after the direct-stream shortcut, i.e. before the complete-ExoPlayer-cache branch and before consumeGenericUrlPrefetch; a successful bound resolution returns immediately, so a cached NetEase copy can no longer win. When the binding is absent or its stream cannot be resolved (expired/private video, offline), the call returns null and the pre-existing cache/prefetch/network path runs unchanged, so offline playback of already-cached audio is not lost. Skipped when shouldDeferNeteaseAlternateSources is set, so the Listen Together authoritative source still takes precedence. Local downloaded files (checkLocalCache) still win, since that is an explicit offline choice.

Comment on lines +46 to +48
if (hasBiliCollectionBinding) {
if (resolved == null || resolved == PlaybackSourceType.NETEASE) {
return PlaybackSourceType.BILIBILI

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Report Bilibili only when that stream was selected

When binding priority is disabled, or when getAudioWithDecision fails for an expired/private video and the resolver falls back, playbackAudioSource is correctly NETEASE but this branch rewrites it to Bilibili merely because a row exists. The Now Playing badge then reports a source that is not being played; only override an unknown/stale value while a bound Bilibili resolution is actually active (or trust the BILIBILI audio info emitted by the resolver).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4eddf0d. The badge now only falls back to Bilibili while playbackAudioSource == null, i.e. when there is no measured source yet and the NetEase label would just be a guess from song metadata. Once a concrete source exists it wins: bound playback emits PlaybackAudioSource.BILIBILI from buildBiliPlaybackAudioInfo, so a genuinely bound song still shows Bilibili, while a NetEase fallback (priority off or dead video) truthfully stays NetEase. Updated bili collection binding keeps netease badge when netease audio is playing and kept also overrides tag only resolution for the unresolved case.

// 写墓碑而不是硬删:否则对面设备上的旧记录会在下次同步把绑定复活
val tombstone = (current[key] ?: BiliCollectionBinding(targetKey = key)).copy(
isDeleted = true,
updatedAt = System.currentTimeMillis()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Advance deletion tombstones past the previous version

When a binding was synced from a device whose clock is ahead of this device, its existing updatedAt can be greater than System.currentTimeMillis(). This tombstone is then older than the active row, so SyncBiliCollectionBindingMergePolicy selects the active row and the next sync resurrects the binding; stamp deletions with at least a safe successor of the prior row's timestamp.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4eddf0d: the tombstone timestamp is now maxOf(System.currentTimeMillis(), (existing?.updatedAt ?: 0L) + 1L), so a row stamped by a device whose clock runs ahead still loses to the deletion. Covered by tombstone outruns a record stamped by a device whose clock is ahead.

if (left.isDeleted != right.isDeleted) {
return if (left.isDeleted) left else right
}
return left

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Break equal-timestamp binding ties deterministically

When two devices save different active bindings for the same key with the same millisecond timestamp (or both records carry the default zero), merge(local, remote) passes the local record first and this fallback always returns left. Each device therefore chooses its own value and successive syncs can alternate the remote archive indefinitely. Add a stable content/device tie-break so merge is commutative.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4eddf0d. After the timestamp and isDeleted checks, ties are broken by a stable content key (bvid, target/video titles, then cids and parts in cid order), so both devices independently pick the same record and the archive stops flipping. Added equal timestamps break ties by content so both devices agree, which asserts merge(a, b) == merge(b, a).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

Other comments: 2
app/src/main/java/moe/ouom/neriplayer/ui/screen/playlist/BiliCollectionBindingDialog.kt: 1

354-354: Minor · Functional Correctness · Quick win

软键盘的"搜索"/"完成"按钮没有绑定动作。

两个 OutlinedTextField 设置了 ImeAction.Search 和 ImeAction.Done,但没有提供 keyboardActions。ImeAction.Search 没有默认行为,所以点击键盘上的搜索键不会触发 runSearch。用户必须再点击单独的"搜索"按钮。链接输入框的完成键同样不会触发 resolveLink。

请补充 keyboardActions,并在 busy 时跳过。

建议修复
-                    keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search)
+                    keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
+                    keyboardActions = KeyboardActions(onSearch = { if (!busy) runSearch() })
-                        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done)
+                        keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
+                        keyboardActions = KeyboardActions(onDone = { if (!busy) resolveLink() })

需要导入 androidx.compose.foundation.text.KeyboardActions。busy 在第 325 行定义,早于 AlertDialog 的内容 lambda,可直接引用。

Also applies to: 386-386

modules/platform/src/main/java/moe/ouom/neriplayer/platform/bilibili/binding/BiliCollectionBindingRepository.kt: 1

108-115: Minor · Data Integrity & Integration · Quick win

将绑定提交和变更版本标记放入同一不可取消临界区。

save 和 remove 在释放 mutex 后才调用 onChanged()。在这段窗口内,replaceAll 可以读到旧的 readSyncMutationVersion(),并用远端快照覆盖刚写入的本地绑定。该路径会造成绑定数据丢失,不只是显示问题。

当对话框因 onDismiss 离开组合时,rememberCoroutineScope 的协程也会取消。已提交的 Room 写入、缓存更新和 onChanged() 不是同一事务,不能保证取消后仍完成后续步骤。

建议修复
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.withContext
+
     suspend fun save(binding: BiliCollectionBinding): Boolean {
         val normalized = binding.normalized()
         if (!normalized.isValid()) return false
-        mutex.withLock {
-            // 缓存可能是冷的,先落一次全量,避免只把新记录当成全部
-            val current = cached ?: loadFromDatabase().also { cached = it }
-            dao.upsert(normalized.toEntity(json))
-            cached = current + (normalized.targetKey to normalized)
+        withContext(NonCancellable) {
+            mutex.withLock {
+                // 缓存可能是冷的,先落一次全量,避免只把新记录当成全部
+                val current = cached ?: loadFromDatabase().also { cached = it }
+                dao.upsert(normalized.toEntity(json))
+                cached = current + (normalized.targetKey to normalized)
+                onChanged()
+            }
+            bumpRevision()
         }
-        onChanged()
-        bumpRevision()
         return true
     }

对 remove 使用相同的结构。replaceAll 也应使用 withContext(NonCancellable) 包裹版本检查、Room 写入和缓存更新,使本地提交在检查与 onChanged() 之间不会被远端替换插入。


Review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between cf44272 and b7a09ed.

Files ignored due to path filters: 6
  • CONTRIBUTING.md is excluded by !**/*.md
  • CONTRIBUTING_EN.md is excluded by !**/*.md
  • README.md is excluded by !**/*.md
  • README_EN.md is excluded by !**/*.md
  • docs/implementation.md is excluded by !**/*.md
  • docs/implementation_EN.md is excluded by !**/*.md
Files selected for processing: 12
  • app/src/main/java/moe/ouom/neriplayer/ui/screen/playlist/BiliCollectionBindingDialog.kt
  • modules/database/src/main/java/moe/ouom/neriplayer/data/local/database/dao/BiliCollectionBindingDao.kt
  • modules/local/src/main/java/moe/ouom/neriplayer/data/local/platform/bilibili/BiliCollectionBindingRepositoryProvider.kt
  • modules/local/src/main/java/moe/ouom/neriplayer/data/sync/host/AndroidSyncLocalApplyHost.kt
  • modules/model/src/main/java/moe/ouom/neriplayer/data/model/bilibili/binding/BiliCollectionBinding.kt
  • modules/model/src/main/java/moe/ouom/neriplayer/data/model/sync/SyncBiliCollectionBindingModels.kt
  • modules/model/src/test/java/moe/ouom/neriplayer/data/model/bilibili/binding/BiliCollectionBindingAffectsSongTest.kt
  • modules/model/src/test/java/moe/ouom/neriplayer/data/model/sync/SyncBiliCollectionBindingModelsTest.kt
  • modules/platform/src/main/java/moe/ouom/neriplayer/platform/bilibili/binding/BiliCollectionBindingRepository.kt
  • modules/platform/src/test/java/moe/ouom/neriplayer/platform/bilibili/binding/BiliCollectionBindingRepositoryTest.kt
  • modules/sync/src/main/java/moe/ouom/neriplayer/data/sync/archive/budget/SyncArchiveMetadataWire.kt
  • modules/sync/src/test/java/moe/ouom/neriplayer/data/sync/archive/SyncArchiveBiliCollectionBindingTest.kt

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.

@1507045
1507045 force-pushed the feat/bili-collection-binding branch from b7a09ed to 4edf0dd Compare October 31, 2026 18:35

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4edf0dd92d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

mutex.withLock {
// 缓存可能是冷的,先落一次全量,避免只把新记录当成全部
val current = cached ?: loadFromDatabase().also { cached = it }
dao.upsert(normalized.toEntity(json))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Advance rebinding timestamps past the stored row

When the existing binding or tombstone was synced from a device whose clock is ahead, a later local edit/rebind arrives here with an updatedAt lower than current[normalized.targetKey].updatedAt; this unconditional upsert replaces the local row but leaves the remote higher-timestamp value able to win the next merge, silently reverting the user's change. Advance active saves past the cached row's timestamp, just as remove already does for tombstones.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@C:/Users/anda/AppData/Local/Temp/reply1.md

biliAlbumBindingPriorityEnabled = enabled
if (!previousEnabled && enabled) {
scheduleQualityRefresh(
source = PlaybackAudioSource.NETEASE,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh Bilibili fallback when enabling binding priority

When priority is enabled while a logical NetEase song is currently playing through the automatic Bilibili fallback, its measured audio source is BILIBILI; PlayerManagerPlaybackQualityPort.refreshCurrentSong rejects refresh jobs whose source does not match, so this NetEase-only job does nothing and the saved binding is not selected until the track is replayed or changed. Schedule a Bilibili refresh too, as the binding-change path already does.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@C:/Users/anda/AppData/Local/Temp/reply2.md

@1507045
1507045 force-pushed the feat/bili-collection-binding branch from 4edf0dd to 86b036c Compare November 1, 2026 00:51
- 新增 bili_collection_binding 表(Room 21 → 23)与四路安全迁移,覆盖旧库缺表、
  缺索引和外部重建结构的情况,避免升级时 no such table 崩溃
- 单曲 / 专辑 / 歌单统一使用 BiliCollectionBindingDialog:应用内搜索 B 站视频与
  合集,或粘贴 BV / av / 分享文案,b23.tv 短链会跟随跳转后还原视频标识
- 单曲绑定点中即落库,不再二次匹配;专辑与歌单按曲名与分 P 标题贪心配对,
  时长只参与同分排序
- 播放解析优先取绑定命中(单曲 → 当前歌单 → 专辑),自动换源探测候选收敛为最多 3 个
- 绑定变更影响当前曲目时按当前进度重解析音源,播放页来源角标命中绑定后显示哔哩哔哩
- 绑定关系随 GitHub / WebDAV 同步,合并策略按 updatedAt 取新
- 补齐中英文案与 values-zh 资源、设置搜索关键词、设置生成测试与行数门禁清单,
  同步 CONTRIBUTING、docs/implementation 与 README
@1507045
1507045 force-pushed the feat/bili-collection-binding branch from 86b036c to e0d6dbe Compare November 1, 2026 05:18
@cwuom

cwuom commented Nov 6, 2026

Copy link
Copy Markdown
Owner

补充一下截图和录屏

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants