Skip to content

feat(pikpak): 引入 PikPak 云端离线下载作为 BT 备选方案#2978

Merged
StageGuard merged 14 commits intoopen-ani:mainfrom
NihilDigit:feat/pikpak-cloud-offline
May 6, 2026
Merged

feat(pikpak): 引入 PikPak 云端离线下载作为 BT 备选方案#2978
StageGuard merged 14 commits intoopen-ani:mainfrom
NihilDigit:feat/pikpak-cloud-offline

Conversation

@NihilDigit
Copy link
Copy Markdown
Contributor

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 定稿的方案:

  • PikPak 的设置合并进 BitTorrent tab 的最下方,与 anitorrent 通过 group header 并列;
  • 顶部一个 SwitchItem 作为总开关,关闭时后续字段通过 AniAnimatedVisibility 整块隐藏;
  • 新增一个「测试连接」item,走现有 ConnectionTester 框架(与代理 / danmaku server 的测试一致)。

设置页

会话持久化与密码处理PikPakSessionStoreAdapter 将 SDK 的 SessionStore 桥接到 SettingsRepository

  • 仅持久化 refresh token;短寿的 access token 在每次启动时走一次 refresh 重算;
  • SDK 的 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 的矩阵测试。
  • Live smoke testPikPakLiveSmokeTest 端到端打通 auth → 写入验证码签名 → 提交 → 轮询 → 解析 URL;读取 PIKPAK_USERNAME / PIKPAK_PASSWORD 环境变量,未设置时自动跳过,CI 不会触发。
  • 手动验证:Android release(开启 R8 minify,使用 upstream 既有 proguard 配置,未引入新规则)与 Desktop 均已通过启用 → 填写凭据 → 测试连接成功 → 密码被正确擦除的全流程。

Type of change

Bug fix
New feature
Breaking change
Refactor
Performance
Style
Docs
Chore

@NihilDigit NihilDigit force-pushed the feat/pikpak-cloud-offline branch from 4750a2c to d1018fd Compare April 20, 2026 17:16
由 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
@NihilDigit NihilDigit force-pushed the feat/pikpak-cloud-offline branch from d1018fd to 8580fb6 Compare April 20, 2026 17:25
@NihilDigit NihilDigit marked this pull request as draft April 21, 2026 08:10
@NihilDigit
Copy link
Copy Markdown
Contributor Author

自测发现持久化相关行为似乎存在问题,正在排查,暂时转为 draft,修复后再标记 ready。

@NihilDigit
Copy link
Copy Markdown
Contributor Author

更新:此前描述不准确。自测装的是旧构建 APK,那里 PikPakAcceleratorGroup.ktonValueChangeCompleted 缺 diff-guard,Compose 重组时 TextField 伪触发会把 refreshToken="" 的旧闭包 config 覆盖写回。当前 HEAD 已有修复(if (newUsername != config.username) + 改账号/密码时清旧会话),用相同 R8 规则重编 release 已复现不到。抱歉误报。

@NihilDigit NihilDigit marked this pull request as ready for review April 21, 2026 08:55
原 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 永远传 "",仅在用户确认非空值时写回。
空字符串确认视作无操作,防止误触把已保存密码清零。
@NihilDigit
Copy link
Copy Markdown
Contributor Author

再次更新:进一步测试发现另有一个真实问题,跟前面那条 UI diff-guard 无关。onSessionSaved 会在拿到 refreshToken 后抹掉 password,一旦 refreshToken 被服务端 revoke,engine 无恢复路径,Test 静默失败需手动重输密码。commit 9deadc853 作为 interim 将三平台 onSessionSaved 改为 no-op 并遮蔽 UI 回显,TODO(pikpak-credential-keystore) 留给后续的 OS keystore 持久化方案。

NihilDigit and others added 2 commits April 22, 2026 13:05
- 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` 测试替身保持
既有语义,全部通过。
@StageGuard
Copy link
Copy Markdown
Member

Is the PR ready to review?

@NihilDigit
Copy link
Copy Markdown
Contributor Author

Is the PR ready to review?

Yes👍

Copy link
Copy Markdown
Member

@StageGuard StageGuard left a comment

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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:pikpak KMP module implementing OfflineDownloadEngine via PikPakOfflineDownloadEngine (slot cache, eviction policy, session store adapter, live/ABI canary tests).
  • Add OfflineDownloadMediaResolver and 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.

Comment thread app/android/src/main/kotlin/AndroidModules.kt
Comment thread app/desktop/src/main/kotlin/DesktopModules.kt
Comment thread app/shared/application/src/iosMain/kotlin/ios/AniIos.kt
Comment thread app/shared/ui-settings/src/commonMain/kotlin/ui/settings/SettingsViewModel.kt Outdated
claude and others added 4 commits April 26, 2026 07:44
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.
@NihilDigit
Copy link
Copy Markdown
Contributor Author

参考 rclone obscure 的做法,把 PikPakConfig 中的 passwordrefreshToken 混淆后落盘。4b6654d00utils/io 中新增 obscure / tryReveal 两个工具方法,采用 AES-CTR 加密,配合硬编码密钥与随机 IV,输出带 ob1: 前缀的 URL-safe base64 字符串;JVM 端基于 javax.crypto,Apple 平台基于 CCCryptorCreateWithMode,无需新增依赖。8e81f9e16 把这两个字段改用 ObscuredStringSerializer 序列化。

测试连接 (testPikPakLogin) 此前的 catch (Throwable) 会把协程取消抛出的 CancellationException 一同捕获并转为登录失败,使设置页关闭或切换时的探测被误报为登录失败,fad45a31d 已修复。

Desktop / Android / iOS 三处 Koin 模块以及 SettingsViewModel 中关于 "engine wipes password after signin" 的过期注释也已更正为当前实现。

:utils:io:app:shared:app-data:torrent:pikpak 的 desktopTest 与 :app:shared:ui-settings:compileKotlinDesktop 均通过。

@NihilDigit NihilDigit requested a review from StageGuard April 28, 2026 03:38
`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).
Comment thread app/shared/ui-settings/src/commonMain/kotlin/ui/settings/SettingsViewModel.kt Outdated
claude and others added 5 commits April 28, 2026 07:57
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
@StageGuard StageGuard enabled auto-merge (squash) May 4, 2026 06:32
@StageGuard StageGuard disabled auto-merge May 6, 2026 05:04
@StageGuard StageGuard merged commit 4e56af9 into open-ani:main May 6, 2026
9 of 11 checks passed
@NihilDigit NihilDigit deleted the feat/pikpak-cloud-offline branch May 7, 2026 11:00
@ZerOri
Copy link
Copy Markdown

ZerOri commented May 10, 2026

有同样的方法能否引入115和百度网盘?

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.

RFC: 通过 PikPak 引入云端离线下载能力

5 participants