Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions app/android/src/main/kotlin/AndroidModules.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import android.os.Environment
import android.widget.Toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.runBlocking
import kotlinx.io.files.Path
import me.him188.ani.android.navigation.AndroidBrowserNavigator
Expand All @@ -33,7 +36,13 @@ import me.him188.ani.app.domain.media.resolver.AndroidWebMediaResolver
import me.him188.ani.app.domain.media.resolver.HttpStreamingMediaResolver
import me.him188.ani.app.domain.media.resolver.LocalFileMediaResolver
import me.him188.ani.app.domain.media.resolver.MediaResolver
import me.him188.ani.app.domain.media.resolver.OfflineDownloadMediaResolver
import me.him188.ani.app.domain.media.resolver.TorrentMediaResolver
import me.him188.ani.torrent.offline.OfflineDownloadEngine
import me.him188.ani.app.data.models.preference.PikPakConfig
import me.him188.ani.torrent.pikpak.PikPakCredentials
import me.him188.ani.torrent.pikpak.PikPakOfflineDownloadEngine
import me.him188.ani.torrent.pikpak.PikPakSessionStoreAdapter
import me.him188.ani.app.domain.mediasource.web.AndroidWebCaptchaCoordinator
import me.him188.ani.app.domain.mediasource.web.WebCaptchaCoordinator
import me.him188.ani.app.domain.settings.ProxyProvider
Expand Down Expand Up @@ -166,10 +175,49 @@ fun getAndroidModules(
MediampPlayerFactoryLoader.first()
}

single<OfflineDownloadEngine> {
val settings = get<SettingsRepository>()
val configState = settings.pikpakConfig.flow
.stateIn(coroutineScope, SharingStarted.Eagerly, initialValue = PikPakConfig.Default)
val credentialsFlow = configState
.map { cfg ->
if (cfg.enabled && cfg.username.isNotEmpty() &&
(cfg.password.isNotEmpty() || cfg.refreshToken.isNotEmpty())
) {
PikPakCredentials(cfg.username, cfg.password)
} else null
}
.stateIn(coroutineScope, SharingStarted.Eagerly, initialValue = null)
val sessionStore = PikPakSessionStoreAdapter(
readRefreshToken = { configState.value.refreshToken },
writeRefreshToken = { rt ->
settings.pikpakConfig.update { copy(refreshToken = rt) }
},
// PikPakConfig.password stays on disk obscured (AES-CTR with a
// hardcoded key, the same approach as `rclone obscure`; see
// ObscuredStringSerializer). We need to keep it because a
// server-side revoke of the refresh token would otherwise leave
// the engine with no recovery path — Test and playback would
// silently fail until the user re-typed the password.
// PikPakAcceleratorGroup never echoes the stored value back to
// the password field, so the obscured copy is what the eyedrop
// attacker would see.
onSessionSaved = {},
)
PikPakOfflineDownloadEngine(
scopedHttpClient = get<HttpClientProvider>().get(ScopedHttpClientUserAgent.ANI),
credentials = credentialsFlow,
scope = coroutineScope,
sessionStore = sessionStore,
slotQueueLength = { configState.value.slotQueueLength },
)
Comment thread
NihilDigit marked this conversation as resolved.
}
factory<MediaResolver> {
val torrentResolvers = get<TorrentManager>().engines.map { TorrentMediaResolver(it, get()) }
val btFallback = MediaResolver.from(torrentResolvers)
MediaResolver.from(
get<TorrentManager>().engines
.map { TorrentMediaResolver(it, get()) }
listOf<MediaResolver>(OfflineDownloadMediaResolver(get(), fallback = btFallback))
.plus(torrentResolvers)
.plus(LocalFileMediaResolver())
.plus(HttpStreamingMediaResolver())
.plus(
Expand Down
59 changes: 57 additions & 2 deletions app/desktop/src/main/kotlin/DesktopModules.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
package me.him188.ani.app.desktop

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.runBlocking
import me.him188.ani.app.data.persistent.dataStores
import me.him188.ani.app.data.persistent.database.AniDatabase
Expand All @@ -30,7 +33,13 @@ import me.him188.ani.app.domain.media.resolver.DesktopWebMediaResolver
import me.him188.ani.app.domain.media.resolver.HttpStreamingMediaResolver
import me.him188.ani.app.domain.media.resolver.LocalFileMediaResolver
import me.him188.ani.app.domain.media.resolver.MediaResolver
import me.him188.ani.app.domain.media.resolver.OfflineDownloadMediaResolver
import me.him188.ani.app.domain.media.resolver.TorrentMediaResolver
import me.him188.ani.app.data.models.preference.PikPakConfig
import me.him188.ani.torrent.offline.OfflineDownloadEngine
import me.him188.ani.torrent.pikpak.PikPakCredentials
import me.him188.ani.torrent.pikpak.PikPakOfflineDownloadEngine
import me.him188.ani.torrent.pikpak.PikPakSessionStoreAdapter
import me.him188.ani.app.domain.mediasource.web.DesktopWebCaptchaCoordinator
import me.him188.ani.app.domain.mediasource.web.WebCaptchaCoordinator
import me.him188.ani.app.domain.torrent.DefaultTorrentManager
Expand Down Expand Up @@ -124,10 +133,56 @@ fun getDesktopModules(getContext: () -> DesktopContext, scope: CoroutineScope) =
}
single<BrowserNavigator> { DesktopBrowserNavigator() }
single<WebCaptchaCoordinator> { DesktopWebCaptchaCoordinator(AniDesktopCaptchaTopBar) }
single<OfflineDownloadEngine> {
val settings = get<SettingsRepository>()
val configState = settings.pikpakConfig.flow
.stateIn(scope, SharingStarted.Eagerly, initialValue = PikPakConfig.Default)
// Credentials are "usable" when we have a password to sign in with
// *or* a previously-persisted refresh token — either way the SDK
// has something to authenticate with.
val credentialsFlow = configState
.map { cfg ->
if (cfg.enabled && cfg.username.isNotEmpty() &&
(cfg.password.isNotEmpty() || cfg.refreshToken.isNotEmpty())
) {
PikPakCredentials(cfg.username, cfg.password)
} else null
}
.stateIn(scope, SharingStarted.Eagerly, initialValue = null)
val sessionStore = PikPakSessionStoreAdapter(
readRefreshToken = { configState.value.refreshToken },
writeRefreshToken = { rt ->
settings.pikpakConfig.update { copy(refreshToken = rt) }
},
// PikPakConfig.password stays on disk obscured (AES-CTR with a
// hardcoded key, the same approach as `rclone obscure`; see
// ObscuredStringSerializer). We need to keep it because a
// server-side revoke of the refresh token would otherwise leave
// the engine with no recovery path — Test and playback would
// silently fail until the user re-typed the password.
// PikPakAcceleratorGroup never echoes the stored value back to
// the password field, so the obscured copy is what the eyedrop
// attacker would see.
onSessionSaved = {},
)
PikPakOfflineDownloadEngine(
scopedHttpClient = get<HttpClientProvider>().get(ScopedHttpClientUserAgent.ANI),
credentials = credentialsFlow,
scope = scope,
sessionStore = sessionStore,
slotQueueLength = { configState.value.slotQueueLength },
)
}
Comment thread
NihilDigit marked this conversation as resolved.
factory<MediaResolver> {
val torrentResolvers = get<TorrentManager>().engines.map { TorrentMediaResolver(it, get()) }
// Hand PikPak the local-BT resolvers as its fallback so a failing
// PikPak (auth/network/limit) doesn't lock the user out of BT
// playback. The fallback is still listed in the chain below for the
// PikPak-disabled case.
val btFallback = MediaResolver.from(torrentResolvers)
MediaResolver.from(
get<TorrentManager>().engines
.map { TorrentMediaResolver(it, get()) }
listOf<MediaResolver>(OfflineDownloadMediaResolver(get(), fallback = btFallback))
.plus(torrentResolvers)
.plus(LocalFileMediaResolver())
.plus(HttpStreamingMediaResolver())
.plus(
Expand Down
1 change: 1 addition & 0 deletions app/shared/app-data/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ kotlin {

api(projects.torrent.torrentApi)
api(projects.torrent.anitorrent)
api(projects.torrent.pikpak)

api(libs.datastore.core) // Data Persistence
api(libs.datastore.preferences.core) // Preferences
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright (C) 2024-2026 OpenAni and contributors.
*
* 此源代码的使用受 GNU AFFERO GENERAL PUBLIC LICENSE version 3 许可证的约束, 可以在以下链接找到该许可证.
* Use of this source code is governed by the GNU AGPLv3 license, which can be found at the following link.
*
* https://github.com/open-ani/ani/blob/main/LICENSE
*/

package me.him188.ani.app.data.models.preference

import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import me.him188.ani.app.data.models.preference.PikPakConfig.Companion.SLOT_QUEUE_MAX_NUMERIC
import me.him188.ani.app.data.models.preference.PikPakConfig.Companion.SLOT_QUEUE_UNLIMITED
import me.him188.ani.utils.io.obscure
import me.him188.ani.utils.io.tryReveal

/**
* User-configurable settings for the PikPak offline-download backend.
*
* The engine runs a server-side slot cache: completed offline tasks stay in a
* well-known working folder on the user's PikPak drive, keyed by source
* bucket, so replays of the same magnet are served straight from the cache.
* Old buckets are evicted to honor [slotQueueLength] — they are *not* deleted
* immediately after each resolve. See `PikPakOfflineDownloadEngine` for the
* full eviction policy.
*
* [refreshToken] is written by the engine after a successful signin/refresh
* (not user-editable). It lets the next app launch skip the rate-limited
* `/v1/auth/signin` endpoint and go straight to a cheap refresh.
*
* [password] is accepted from the settings UI to bootstrap the first signin
* and stays on disk so the engine can silently re-signin if the stored
* refresh token gets revoked server-side (Test / playback would otherwise
* fail until the user re-typed the password). To keep credentials out of
* casual reads of the on-disk JSON, both [password] and [refreshToken] are
* persisted via [ObscuredStringSerializer] — AES-CTR with a hardcoded key,
* the same approach `rclone obscure` takes. This blocks eyedropping; it is
* not real encryption (the key is in the binary). The settings UI also
* never echoes [password] back.
*/
@Serializable
data class PikPakConfig(
val enabled: Boolean = false,
val username: String = "",
@Serializable(with = ObscuredStringSerializer::class)
val password: String = "",
@Serializable(with = ObscuredStringSerializer::class)
val refreshToken: String = "",
/**
* How many distinct source buckets the engine keeps cached in its
* working folder ("Animeko-Playing"). Real numeric values 1..13 are
* bucket caps; the UI also offers a final "unlimited" stop (stored as
* [SLOT_QUEUE_UNLIMITED]) that disables eviction entirely.
*/
val slotQueueLength: Int = 1,
) {
override fun toString(): String {
return "PikPakConfig(enabled=$enabled, username=$username, password.hash=${password.hashCode()}, " +
"refreshToken.hash=${refreshToken.let { if (it.isNotEmpty()) it.hashCode() else "" }}, " +
"slotQueueLength=$slotQueueLength)"
}

companion object {
val Default = PikPakConfig()

/** Last numeric step on the slider. */
const val SLOT_QUEUE_MAX_NUMERIC: Int = 13

/**
* One step past [SLOT_QUEUE_MAX_NUMERIC]: the dedicated "no eviction"
* stop. Any value ≥ this is treated as unlimited by the engine.
*/
const val SLOT_QUEUE_UNLIMITED: Int = SLOT_QUEUE_MAX_NUMERIC + 1
}
}

/**
* Encodes [String] values via [obscure] / [tryReveal] so the JSON written to
* DataStore contains an `ob1:`-tagged AES-CTR payload instead of plaintext.
*
* Decoding falls back to treating the raw value as plaintext when the magic
* prefix is absent — that path covers the migration from PR #2978's V2, which
* persisted [PikPakConfig.password] and [PikPakConfig.refreshToken] in the
* clear. The next [SerializablePreference.update] then writes the obscured
* form back automatically.
*/
internal object ObscuredStringSerializer : KSerializer<String> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("ObscuredString", PrimitiveKind.STRING)

override fun serialize(encoder: Encoder, value: String) {
encoder.encodeString(obscure(value))
}

override fun deserialize(decoder: Decoder): String {
val raw = decoder.decodeString()
return tryReveal(raw) ?: raw
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import me.him188.ani.app.data.models.preference.MediaCacheSettings
import me.him188.ani.app.data.models.preference.MediaPreference
import me.him188.ani.app.data.models.preference.MediaSelectorSettings
import me.him188.ani.app.data.models.preference.OneshotActionConfig
import me.him188.ani.app.data.models.preference.PikPakConfig
import me.him188.ani.app.data.models.preference.ProfileSettings
import me.him188.ani.app.data.models.preference.ProxySettings
import me.him188.ani.app.data.models.preference.ThemeSettings
Expand Down Expand Up @@ -75,6 +76,7 @@ interface SettingsRepository {

val videoResolverSettings: Settings<VideoResolverSettings>
val anitorrentConfig: Settings<AnitorrentConfig>
val pikpakConfig: Settings<PikPakConfig>
val torrentPeerConfig: Settings<TorrentPeerConfig>

val oneshotActionConfig: Settings<OneshotActionConfig>
Expand Down Expand Up @@ -224,6 +226,12 @@ class PreferencesRepositoryImpl(
default = { AnitorrentConfig.Default },
)

override val pikpakConfig: Settings<PikPakConfig> = SerializablePreference(
"pikpakConfig",
PikPakConfig.serializer(),
default = { PikPakConfig.Default },
)

override val torrentPeerConfig: Settings<TorrentPeerConfig> = SerializablePreference(
"torrentPeerConfig",
TorrentPeerConfig.serializer(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import me.him188.ani.app.domain.media.resolver.MediaResolver
import me.him188.ani.app.domain.media.resolver.MediaSourceOpenException
import me.him188.ani.app.domain.media.resolver.OpenFailures
import me.him188.ani.app.domain.media.resolver.ResolutionFailures
import me.him188.ani.app.domain.media.resolver.TorrentMediaDataProvider
import me.him188.ani.app.domain.media.resolver.TorrentBackedMediaDataProvider
import me.him188.ani.app.domain.media.resolver.UnsupportedMediaException
import me.him188.ani.app.domain.media.selector.MediaSelector
import me.him188.ani.app.domain.player.VideoLoadingState
Expand Down Expand Up @@ -90,7 +90,7 @@ class PlayerSession(
logger.info { "Set media data to player: $data" }
player.setMediaData(data)

_videoLoadingStateFlow.value = VideoLoadingState.Succeed(isBt = source is TorrentMediaDataProvider)
_videoLoadingStateFlow.value = VideoLoadingState.Succeed(isBt = source is TorrentBackedMediaDataProvider)
withContext(mainDispatcher) {
player.resume()
}
Expand Down
Loading
Loading