feat(pikpak): 引入 PikPak 云端离线下载作为 BT 备选方案#2978
Conversation
4750a2c to
d1018fd
Compare
由 PikPak 的海外服务器完成磁链下载, 以 HTTPS 直链交回本地播放器, 缓解部分网络下本地 BT 播放不稳的问题. - 新增 torrent/pikpak/ 模块, 提供后端无关的 OfflineDownloadEngine 抽象及 PikPakOfflineDownloadEngine 实现 - 新增 OfflineDownloadMediaResolver, 以 first-match 位次拦截磁链, 功能关闭时透明回退到 anitorrent - 设置合并进 BitTorrent tab, 与 anitorrent 通过 group header 并列; 顶部总开关 + 测试连接 item - PikPakSessionStoreAdapter 仅持久化 refresh token; 登录成功后 立即从 DataStore 擦除明文密码 - 依赖外部 SDK io.github.nihildigit:pikpak-kotlin:0.4.3 (Maven Central) - 新增 4 个单测 + 1 个需凭据的 live smoke test Closes open-ani#2976
d1018fd to
8580fb6
Compare
|
自测发现持久化相关行为似乎存在问题,正在排查,暂时转为 draft,修复后再标记 ready。 |
|
更新:此前描述不准确。自测装的是旧构建 APK,那里 |
原 onSessionSaved 会在拿到 refreshToken 后立即从 DataStore 抹掉 password, 以降低 DataStore 文件泄露时的凭据风险。但一旦 refreshToken 被服务端 revoke (常见触发:同账号在另一客户端登录),engine 没有恢复路径——pre-warm / Test 连接都会因 `signin: invalid_argument: the username and the password can't be empty` 静默失败,用户只能手动重输密码。 interim 修复:三个平台 Koin 模块 (Android / Desktop / iOS) 的 onSessionSaved 改为 no-op,password 留在 DataStore,SDK 的 refresh → signin 回退路径天然畅通。 留下 `TODO(pikpak-credential-keystore)` 注释指向后续的 OS keystore 加密持久化 方案 (Android KeyStore / Secret Service / iOS Keychain),该方案需要引入 commonMain 抽象 CredentialStore 及三套平台实现,工作量较大故分两步走。 为避免设置页面回显存储的密码 (shoulder-surfing / visibility toggle 泄露), PikPakAcceleratorGroup 的密码框 value 永远传 "",仅在用户确认非空值时写回。 空字符串确认视作无操作,防止误触把已保存密码清零。
|
再次更新:进一步测试发现另有一个真实问题,跟前面那条 UI diff-guard 无关。 |
- torrent/pikpak 删除 kotlin("test") 声明。ani-mpp-lib-targets 已通过
:utils:testing 注入 kotlin-test 所需依赖;umbrella artifact 让 JVM
variant inference 选到 kotlin-test-junit (JUnit 4),与工程通过
de.mannodermaus 插件引入的 kotlin-test-junit5 在
androidDeviceTestCompileClasspath 撞 capability,导致 macOS
Self-Hosted runner 挂。
- OfflineDownloadMediaResolver 的异常分类改成多 catch、subclass-first
顺序。原先的 `e is CancellationException && e !is TimeoutCancellationException`
在 common 类型系统里被编译器判为 "Check is always true"——latent bug:
timeout 本应进 fallback,但 common 层永远走不到那里。
- OfflineDownloadMediaResolverTest 的 unchecked cast 改用
assertIs<HttpStreamingMediaDataProvider>,与测试意图对齐。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`CacheOnBtPlayExtension` 原先按 `media.kind == BitTorrent` 决定是否拉起 anitorrent 自动缓存。这是个 pre-resolve 判据——在 upstream-only 世界里 等价(BT kind 的 media 最终一定 resolve 成 `TorrentMediaDataProvider`), 但 PikPak 加入后 BT kind 的 media 会被 `OfflineDownloadMediaResolver` 拦截并以 HTTP 形式播放,旧判据会让 PikPak 成功的那一次播放同时启动一份 重复的 anitorrent 后台下载。这违背本 PR 描述里 "对现有 BT 路径零侵入" 的承诺。 做法: - 新增 marker `interface TorrentBackedMediaDataProvider`,由 `TorrentMediaDataProvider` 实现,让调用方可以用类型判断区分 "本次 resolve 产出的 provider 是否由本地 BT 引擎驱动"。 - `PlayerSession` 的 `VideoLoadingState.Succeed(isBt = ...)` 改为按 此 marker 判断(语义等价,当前仅一处实现;marker 的存在是为了解耦 调用点与具体类型、以便测试替身)。 - `CacheOnBtPlayExtension` 改为订阅 `videoLoadingStateFlow`,仅在 `Succeed.isBt == true` 时建立缓存。判据从 "打算走哪条路" 挪到 "实际走了哪条路"。 此判据同时覆盖 PikPak 运行时失败、`OfflineDownloadMediaResolver` 内部 fallback 回落到 anitorrent 的路径:此时 `source is TorrentBackedMediaDataProvider` 为真,缓存仍会照常建立,透明 fallback 的行为保留不动。 测试:新增 `skipAutoCacheWhenResolvedToHttp` 覆盖 PikPak 成功返回 HTTP 的场景;原有 4 个 case 经由新增的 `ConfigurableResolver` 测试替身保持 既有语义,全部通过。
|
Is the PR ready to review? |
Yes👍 |
StageGuard
left a comment
There was a problem hiding this comment.
Reviewed the PR. I found one cancellation-handling issue that should be fixed before merge.
torrent/pikpak/src/commonMain/kotlin/me/him188/ani/torrent/pikpak/PikPakConnectionTest.kt:38-42
testPikPakLogin catches Throwable, so coroutine cancellation from the settings connection-test job, view-model scope, or Ktor/PikPak login path is converted into false. That makes a cancelled probe look like a normal failed login and prevents the caller's cancellation/error path from propagating correctly. Please catch and rethrow CancellationException before converting real auth/network failures to false.
Checked locally:
:torrent:pikpak:desktopTest --tests "me.him188.ani.torrent.pikpak.*":app:shared:app-data:desktopTest --tests "*OfflineDownloadMediaResolverTest" --tests "*CacheOnBtPlayExtensionTest":app:shared:ui-settings:compileKotlinDesktop
There was a problem hiding this comment.
Pull request overview
This PR introduces a cloud-offline-download path (PikPak-backed) as a BitTorrent magnet/.torrent alternative, integrating it into the existing media resolver chain with transparent fallback to local BT, plus settings UI and persistence.
Changes:
- Add new
:torrent:pikpakKMP module implementingOfflineDownloadEngineviaPikPakOfflineDownloadEngine(slot cache, eviction policy, session store adapter, live/ABI canary tests). - Add
OfflineDownloadMediaResolverand wire it as the first resolver with local-torrent fallback on Android/Desktop/iOS. - Add PikPak settings model + UI (BitTorrent tab), connection test plumbing, and update caching logic to key off “actually BT-backed playback”.
Reviewed changes
Copilot reviewed 33 out of 33 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| torrent/pikpak/src/desktopTest/kotlin/me/him188/ani/torrent/pikpak/PikPakLiveSmokeTest.kt | Live smoke tests against real PikPak service using env-provided credentials. |
| torrent/pikpak/src/desktopTest/kotlin/me/him188/ani/torrent/pikpak/PikPakKtorAbiCompatTest.kt | MockEngine canary to detect Ktor ABI drift between SDK and app. |
| torrent/pikpak/src/commonTest/kotlin/me/him188/ani/torrent/pikpak/SourceKeyForTest.kt | Tests for deterministic source key derivation (btih normalization / URL hash fallback). |
| torrent/pikpak/src/commonTest/kotlin/me/him188/ani/torrent/pikpak/SlotEvictionPolicyTest.kt | Tests eviction policy for slot cache queue length semantics. |
| torrent/pikpak/src/commonTest/kotlin/me/him188/ani/torrent/pikpak/PikPakSessionStoreAdapterTest.kt | Tests refresh-token persistence adapter and callback behavior. |
| torrent/pikpak/src/commonTest/kotlin/me/him188/ani/torrent/pikpak/BuildResolvedMediaTest.kt | Tests mapping from provider file metadata to playable ResolvedMedia. |
| torrent/pikpak/src/commonMain/kotlin/me/him188/ani/torrent/pikpak/PikPakSessionStoreAdapter.kt | Bridges SDK SessionStore to app persistence (refresh token only). |
| torrent/pikpak/src/commonMain/kotlin/me/him188/ani/torrent/pikpak/PikPakOfflineDownloadEngine.kt | Core PikPak offline orchestration: slot/bucket cache, polling, URL resolution, cleanup, eviction. |
| torrent/pikpak/src/commonMain/kotlin/me/him188/ani/torrent/pikpak/PikPakConnectionTest.kt | Adds login probe helper for settings “Test connection”. |
| torrent/pikpak/src/commonMain/kotlin/me/him188/ani/torrent/offline/OfflineDownloadEngine.kt | Introduces backend-agnostic offline-download engine API + result/exceptions. |
| torrent/pikpak/build.gradle.kts | Adds module deps + .env propagation for JVM test tasks + SDK dependency. |
| settings.gradle.kts | Includes new :torrent:pikpak module. |
| app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/PikPakAcceleratorGroup.kt | Adds PikPak settings group UI inside BT tab, including connection test and slot slider. |
| app/shared/ui-settings/src/commonMain/kotlin/ui/settings/framework/components/TextFieldItem.kt | Extends TextFieldItem to support masked input + show/hide toggle. |
| app/shared/ui-settings/src/commonMain/kotlin/ui/settings/SettingsViewModel.kt | Adds PikPakConfig state, connection tester, and backup/restore integration. |
| app/shared/ui-settings/src/commonMain/kotlin/ui/settings/SettingsScreen.kt | Wires PikPak settings group into the BT tab. |
| app/shared/application/src/iosMain/kotlin/ios/AniIos.kt | Registers PikPak engine and resolver chain head on iOS with BT fallback. |
| app/shared/app-lang/src/androidMain/res/values/strings.xml | Adds PikPak strings (EN) and retitles anitorrent group label. |
| app/shared/app-lang/src/androidMain/res/values-zh-rTW/strings.xml | Retitles anitorrent group label (zh-TW). |
| app/shared/app-lang/src/androidMain/res/values-zh-rHK/strings.xml | Retitles anitorrent group label (zh-HK). |
| app/shared/app-lang/src/androidMain/res/values-zh-rCN/strings.xml | Adds PikPak strings (zh-CN) and retitles anitorrent group label. |
| app/shared/app-data/src/commonTest/kotlin/domain/player/extension/CacheOnBtPlayExtensionTest.kt | Updates tests to use new torrent-backed marker and adds HTTP-intercept case. |
| app/shared/app-data/src/commonTest/kotlin/domain/media/resolver/OfflineDownloadMediaResolverTest.kt | Adds resolver fallback-matrix tests (timeouts, auth, IO, cancel propagation, etc.). |
| app/shared/app-data/src/commonTest/kotlin/domain/danmaku/DanmakuCacheTest.kt | Extends settings repo fake to include pikpakConfig. |
| app/shared/app-data/src/commonMain/kotlin/domain/player/extension/CacheOnBtPlayExtension.kt | Gates auto-cache based on post-resolve “BT-backed playback” rather than source kind. |
| app/shared/app-data/src/commonMain/kotlin/domain/media/resolver/TorrentMediaResolver.kt | Adds TorrentBackedMediaDataProvider marker and applies it to torrent provider. |
| app/shared/app-data/src/commonMain/kotlin/domain/media/resolver/OfflineDownloadMediaResolver.kt | New resolver that routes BT through offline engine with fallback to local torrent. |
| app/shared/app-data/src/commonMain/kotlin/domain/episode/PlayerSession.kt | Sets VideoLoadingState.Succeed.isBt based on torrent-backed provider marker. |
| app/shared/app-data/src/commonMain/kotlin/data/repository/user/SettingsRepository.kt | Adds pikpakConfig to settings repository interface/implementation. |
| app/shared/app-data/src/commonMain/kotlin/data/models/preference/PikPakConfig.kt | Introduces persisted PikPak settings model (enabled/credentials/slot queue). |
| app/shared/app-data/build.gradle.kts | Adds dependency on new :torrent:pikpak module. |
| app/desktop/src/main/kotlin/DesktopModules.kt | Registers PikPak engine and resolver chain head on Desktop with BT fallback. |
| app/android/src/main/kotlin/AndroidModules.kt | Registers PikPak engine and resolver chain head on Android with BT fallback. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The probe's catch (Throwable) was swallowing CancellationException, so coroutine cancellation from the settings connection-test job, view-model scope, or Ktor/PikPak login path was being converted into a `false` return — making cancelled probes look like normal failed logins and breaking structured cancellation up the call chain. Catch CancellationException explicitly and rethrow before the generic Throwable branch handles real auth/network failures. Reported in upstream PR review (StageGuard, Copilot).
Adds obscure / tryReveal — AES-CTR with a hardcoded 32-byte key plus a
random 16-byte IV, encoded as URL-safe base64 (no padding) and tagged
with an "ob1:" magic prefix. Same anti-eyedropping intent and structure
as `rclone obscure`; it is not real encryption (the key ships in the
binary). The magic prefix lets tryReveal cleanly distinguish the
obscured format from legacy plaintext, so callers can do a transparent
migration without false-positive "decryption" of arbitrary strings.
JVM uses javax.crypto.Cipher("AES/CTR/NoPadding") + SecureRandom.
Apple targets use CCCryptorCreateWithMode(kCCModeCTR, ...) and
SecRandomCopyBytes from CoreCrypto/Security, mirroring how Digest.kt
already wires its expect/actual via platform.CoreCrypto. No new
dependencies on either side.
Tests cover roundtrip on ASCII / Unicode / 2 KB inputs, IV freshness
across calls, the empty-string short-circuit, the legacy-plaintext
fallback in tryReveal, malformed-but-prefixed payload rejection, and a
sanity check on secureRandomBytes.
Wraps PikPakConfig.password and PikPakConfig.refreshToken in an
ObscuredStringSerializer so the on-disk JSON in DataStore no longer
contains plaintext credentials, addressing the eyedropping concern
StageGuard raised on the upstream PR review.
The serializer round-trips through the AES-CTR obscure / tryReveal
helpers in utils/io. tryReveal returns null when the magic prefix is
absent, so the next read after upgrading from V2's plaintext store
silently falls back to the raw value — and the next preference write
(e.g. on the first refresh-token rotation) re-persists it in the
obscured form. No data migration step is needed.
The platform Koin modules (Desktop / Android / iOS) and
SettingsViewModel previously claimed the engine wiped the password
after signin; that wipe never landed in V2. Update those comments to
describe the actual design — password stays on disk so a refresh-token
revoke server-side stays recoverable, just no longer in the clear.
This matches the direction StageGuard suggested in the dev group
("写死一个 key 加密") and the rclone obscure pattern WoL pointed to.
|
参考 测试连接 ( Desktop / Android / iOS 三处 Koin 模块以及
|
`outPin.addressOf(0)` failed K/Native metadata compilation across all upstream build matrix targets with `Unresolved reference 'addressOf'`. `addressOf` is an extension on `Pinned<ByteArray>` defined in `kotlinx.cinterop`, not a member, so it must be imported explicitly (matches the existing pattern in Digest.native.kt).
Per StageGuard's review on PR open-ani#2978, exporting `pikpakConfig` directly into the backup leaks the user's username/refresh-token (and AES-CTR-obscured password) to whoever receives the JSON. Write `PikPakConfig.Default` instead and skip the field on restore so older backups can't silently re-introduce credentials on a different device.
sync: merge upstream/main into feat/pikpak-cloud-offline
# Conflicts: # app/shared/app-data/src/commonMain/kotlin/domain/player/extension/CacheOnBtPlayExtension.kt
|
有同样的方法能否引入115和百度网盘? |
Description
对接 RFC open-ani/animeko#2976。引入 PikPak 作为 anitorrent 的云端下载替代:由 PikPak 的海外服务器完成磁链下载,再将生成的 HTTPS 直链交回本地播放器。BT 源的资源质量与 HTTPS 传输的稳定连通性因此可以同时获得,缓解部分网络环境下本地 BT 播放不稳的问题。
Closes #2976
Reasoning
抽象层。新增
torrent/pikpak/模块,其中OfflineDownloadEngine为后端无关的接口;PikPakOfflineDownloadEngine为目前唯一实现。日后如引入 115、迅雷等云盘后端,仅需新增实现类,resolver 与设置层不需要改动。Resolver 接入与回退。新增
OfflineDownloadMediaResolver在三个平台(Android / Desktop / iOS)的 Koin 模块中被放在MediaResolver.from(listOf(...))的首位,利用ChainedMediaResolver的 first-match 语义拦截磁链 /.torrent。功能关闭或尚未配置时,supports()返回false,链路透明回落到TorrentMediaResolver(anitorrent)——对现有 BT 路径零侵入。设置 UI 与 RFC 结论对齐。按 review 定稿的方案:
SwitchItem作为总开关,关闭时后续字段通过AniAnimatedVisibility整块隐藏;ConnectionTester框架(与代理 / danmaku server 的测试一致)。会话持久化与密码处理。
PikPakSessionStoreAdapter将 SDK 的SessionStore桥接到SettingsRepository:save()触发onSessionSaved回调,平台模块在此时把 DataStore 中的明文密码擦除。密码只在 bootstrap 出 refresh token 前短暂停留,之后不再落盘。依赖。PikPak 协议层由外部 SDK
io.github.nihildigit:pikpak-kotlin:0.4.3(Maven Central)承载。SDK 与 DTO 相关代码均不进入 animeko 仓库,本次 PR 仅保留协议无关的适配层。Testing
torrent/pikpak/模块内,./gradlew :torrent:pikpak:desktopTest):PikPakSessionStoreAdapterTest—— session 持久化与密码擦除回调;BuildResolvedMediaTest—— 从 PikPak 任务结构选出视频文件并构造ResolvedMedia的逻辑;SlotEvictionPolicyTest—— free 配额满时对远端任务的回收策略;OfflineDownloadMediaResolverTest—— resolver 链 fallback 的矩阵测试。PikPakLiveSmokeTest端到端打通 auth → 写入验证码签名 → 提交 → 轮询 → 解析 URL;读取PIKPAK_USERNAME/PIKPAK_PASSWORD环境变量,未设置时自动跳过,CI 不会触发。Type of change
❌ Bug fix
✅ New feature
❌ Breaking change
❌ Refactor
❌ Performance
❌ Style
❌ Docs
❌ Chore