SubsExchange fixes

This commit is contained in:
Kelvin K 2025-04-01 00:56:24 +02:00
parent 7f7ebafa46
commit c1993ffa03
13 changed files with 119 additions and 24 deletions

View file

@ -2,6 +2,7 @@ package com.futo.platformplayer.api.media.models.contents
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import kotlinx.serialization.Serializable
import java.time.OffsetDateTime
interface IPlatformContent {

View file

@ -14,6 +14,7 @@ import java.time.OffsetDateTime
@kotlinx.serialization.Serializable
open class SerializedPlatformVideo(
override val contentType: ContentType = ContentType.MEDIA,
override val id: PlatformID,
override val name: String,
override val thumbnails: Thumbnails,
@ -27,7 +28,6 @@ open class SerializedPlatformVideo(
override val viewCount: Long,
override val isShort: Boolean = false
) : IPlatformVideo, SerializedPlatformContent {
override val contentType: ContentType = ContentType.MEDIA;
override val isLive: Boolean = false;
@ -44,6 +44,7 @@ open class SerializedPlatformVideo(
companion object {
fun fromVideo(video: IPlatformVideo) : SerializedPlatformVideo {
return SerializedPlatformVideo(
ContentType.MEDIA,
video.id,
video.name,
video.thumbnails,

View file

@ -3,6 +3,7 @@ package com.futo.platformplayer.models
import com.futo.platformplayer.api.media.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import java.time.LocalDateTime
@ -46,6 +47,7 @@ class HistoryVideo {
val name = str.substring(indexNext + 3);
val video = resolve?.invoke(url) ?: SerializedPlatformVideo(
ContentType.MEDIA,
id = PlatformID.asUrlID(url),
name = name,
thumbnails = Thumbnails(),

View file

@ -39,4 +39,16 @@ class OffsetDateTimeSerializer : KSerializer<OffsetDateTime> {
return OffsetDateTime.MIN;
return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC);
}
}
class OffsetDateTimeStringSerializer : KSerializer<OffsetDateTime> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("OffsetDateTime", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: OffsetDateTime) {
encoder.encodeString(value.toString());
}
override fun deserialize(decoder: Decoder): OffsetDateTime {
val str = decoder.decodeString();
return OffsetDateTime.parse(str);
}
}

View file

@ -375,7 +375,16 @@ class StateSubscriptions {
}
fun getSubscriptionsFeedWithExceptions(allowFailure: Boolean = false, withCacheFallback: Boolean = false, cacheScope: CoroutineScope, onProgress: ((Int, Int)->Unit)? = null, onNewCacheHit: ((Subscription, IPlatformContent)->Unit)? = null, subGroup: SubscriptionGroup? = null): Pair<IPager<IPlatformContent>, List<Throwable>> {
val exchangeClient = if(Settings.instance.subscriptions.useSubscriptionExchange) getSubsExchangeClient() else null;
var exchangeClient: SubsExchangeClient? = null;
if(Settings.instance.subscriptions.useSubscriptionExchange) {
try {
exchangeClient = getSubsExchangeClient();
}
catch(ex: Throwable){
Logger.e(TAG, "Failed to get subs exchange client: ${ex.message}", ex);
}
}
val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool, exchangeClient);
if(onNewCacheHit != null)
algo.onNewCacheHit.subscribe(onNewCacheHit)

View file

@ -5,6 +5,9 @@ import com.futo.platformplayer.UIDialogs
import com.futo.platformplayer.activities.MainActivity
import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.IPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.platforms.js.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.DedupContentPager
@ -78,8 +81,10 @@ abstract class SubscriptionsTaskFetchAlgorithm(
val contractableTasks = tasks.filter { !it.fromPeek && !it.fromCache && (it.type == ResultCapabilities.TYPE_VIDEOS || it.type == ResultCapabilities.TYPE_MIXED) };
val contract = if(contractableTasks.size > 10) subsExchangeClient?.requestContract(*contractableTasks.map { ChannelRequest(it.url) }.toTypedArray()) else null;
if(contract?.provided?.isNotEmpty() == true)
Logger.i(TAG, "Received subscription exchange contract (Requires ${contract?.required?.size}, Provides ${contract?.provided?.size}), ID: ${contract?.id}");
var providedTasks: MutableList<SubscriptionTask>? = null;
if(contract != null && contract.provided.isNotEmpty()){
if(contract != null && contract.required.isNotEmpty()){
providedTasks = mutableListOf()
for(task in tasks.toList()){
if(!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) {
@ -127,16 +132,18 @@ abstract class SubscriptionsTaskFetchAlgorithm(
//Resolve Subscription Exchange
if(contract != null) {
try {
val resolves = taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) && contract.required.contains(it.task.url) }.map {
ChannelResolve(
it.task.url,
it.pager!!.getResults().filter { it is IPlatformVideo }.map { SerializedPlatformVideo.fromVideo(it as IPlatformVideo) }
)
}.toTypedArray()
val resolve = subsExchangeClient?.resolveContract(
contract,
*taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) }.map {
ChannelResolve(
it.task.url,
it.pager!!.getResults()
)
}.toTypedArray()
*resolves
);
if (resolve != null) {
UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})")
for(result in resolve){
val task = providedTasks?.find { it.url == result.channelUrl };
if(task != null) {
@ -153,6 +160,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
}
catch(ex: Throwable) {
//TODO: fetch remainder after all?
Logger.e(TAG, "Failed to resolve Subscription Exchange contract due to: " + ex.message, ex);
}
}

View file

@ -1,8 +1,10 @@
package com.futo.platformplayer.subsexchange
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
class ChannelRequest(
var url: String
@SerialName("ChannelUrl")
var channelUrl: String
);

View file

@ -2,12 +2,18 @@ package com.futo.platformplayer.subsexchange
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.OffsetDateTime
@Serializable
class ChannelResolve(
@SerialName("ChannelUrl")
var channelUrl: String,
var content: List<IPlatformContent>,
@SerialName("Content")
var content: List<SerializedPlatformContent>,
@SerialName("Channel")
var channel: IPlatformChannel? = null
)

View file

@ -2,16 +2,22 @@ package com.futo.platformplayer.subsexchange
import com.futo.platformplayer.api.media.models.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent
import com.futo.platformplayer.api.media.models.video.SerializedPlatformContent
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.time.OffsetDateTime
@Serializable
class ChannelResult(
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
@SerialName("DateTime")
var dateTime: OffsetDateTime,
@SerialName("ChannelUrl")
var channelUrl: String,
var content: List<IPlatformContent>,
@SerialName("Content")
var content: List<SerializedPlatformContent>,
@SerialName("Channel")
var channel: IPlatformChannel? = null
)

View file

@ -2,16 +2,27 @@ package com.futo.platformplayer.subsexchange
import com.futo.platformplayer.serializers.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import com.futo.platformplayer.serializers.OffsetDateTimeStringSerializer
import com.google.gson.annotations.SerializedName
import kotlinx.datetime.LocalDateTime
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.Serializer
import java.time.OffsetDateTime
@Serializable
class ExchangeContract(
@SerialName("ID")
var id: String,
@SerialName("Requests")
var requests: List<ChannelRequest>,
@SerialName("Provided")
var provided: List<String> = listOf(),
@SerialName("Required")
var required: List<String> = listOf(),
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
@SerialName("Expire")
@kotlinx.serialization.Serializable(with = OffsetDateTimeStringSerializer::class)
var expired: OffsetDateTime = OffsetDateTime.MIN,
@SerialName("ContractVersion")
var contractVersion: Int = 1
)

View file

@ -1,10 +1,14 @@
package com.futo.platformplayer.subsexchange
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ExchangeContractResolve(
@SerialName("PublicKey")
val publicKey: String,
@SerialName("Signature")
val signature: String,
@SerialName("Data")
val data: String
)

View file

@ -1,3 +1,5 @@
import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.logging.Logger
import com.futo.platformplayer.subsexchange.ChannelRequest
import com.futo.platformplayer.subsexchange.ChannelResolve
import com.futo.platformplayer.subsexchange.ChannelResult
@ -19,13 +21,19 @@ import java.util.Base64
import java.io.InputStreamReader
import java.io.OutputStream
import java.io.OutputStreamWriter
import java.math.BigInteger
import java.nio.charset.StandardCharsets
import java.security.KeyPairGenerator
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.RSAPublicKeySpec
class SubsExchangeClient(private val server: String, private val privateKey: String) {
private val json = Json {
ignoreUnknownKeys = true
}
private val publicKey: String = extractPublicKey(privateKey)
// Endpoints
@ -43,18 +51,18 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str
// Endpoint: Resolve
fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> {
val contractResolve = convertResolves(*resolves)
val result = post("/api/Channel/Resolve?contractId=${contract.id}", Json.encodeToString(contractResolve), "application/json")
return Json.decodeFromString(result)
val result = post("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json")
return Serializer.json.decodeFromString(result)
}
suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> {
val contractResolve = convertResolves(*resolves)
val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Json.encodeToString(contractResolve), "application/json")
return Json.decodeFromString(result)
val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json")
return Serializer.json.decodeFromString(result)
}
private fun convertResolves(vararg resolves: ChannelResolve): ExchangeContractResolve {
val data = Json.encodeToString(resolves)
val data = Serializer.json.encodeToString(resolves)
val signature = createSignature(data, privateKey)
return ExchangeContractResolve(
@ -66,15 +74,31 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str
// IO methods
private fun post(query: String, body: String, contentType: String): String {
val url = URL("$server$query")
val url = URL("${server.trim('/')}$query")
with(url.openConnection() as HttpURLConnection) {
requestMethod = "POST"
setRequestProperty("Content-Type", contentType)
doOutput = true
OutputStreamWriter(outputStream, StandardCharsets.UTF_8).use { it.write(body) }
OutputStreamWriter(outputStream, StandardCharsets.UTF_8).use { it.write(body); it.flush() }
val status = responseCode;
Logger.i("SubsExchangeClient", "POST [${url}]: ${status}");
if(status == 200)
InputStreamReader(inputStream, StandardCharsets.UTF_8).use {
return it.readText()
}
else {
var errorStr = "";
try {
errorStr = InputStreamReader(errorStream, StandardCharsets.UTF_8).use {
return@use it.readText()
}
}
catch(ex: Throwable){}
throw Exception("Exchange server resulted in code ${status}:\n" + errorStr);
InputStreamReader(inputStream, StandardCharsets.UTF_8).use {
return it.readText()
}
}
}
@ -98,8 +122,15 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str
val keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey))
val keyFactory = KeyFactory.getInstance("RSA")
val privateKeyObj = keyFactory.generatePrivate(keySpec) as RSAPrivateKey
val publicKeyObj: RSAPublicKey = keyFactory.generatePublic(keySpec) as RSAPublicKey;
return Base64.getEncoder().encodeToString(publicKeyObj.encoded)
val publicKeyObj: PublicKey? = keyFactory.generatePublic(RSAPublicKeySpec(privateKeyObj.modulus, BigInteger.valueOf(65537)));
var publicKeyBase64 = Base64.getEncoder().encodeToString(publicKeyObj?.encoded);
var pem = "-----BEGIN PUBLIC KEY-----"
while(publicKeyBase64.length > 0) {
val length = Math.min(publicKeyBase64.length, 64);
pem += "\n" + publicKeyBase64.substring(0, length);
publicKeyBase64 = publicKeyBase64.substring(length);
}
return pem + "\n-----END PUBLIC KEY-----";
}
fun createSignature(data: String, privateKey: String): String {

View file

@ -6,6 +6,7 @@ import com.futo.platformplayer.api.media.Serializer
import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnail
import com.futo.platformplayer.api.media.models.Thumbnails
import com.futo.platformplayer.api.media.models.contents.ContentType
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
@ -39,6 +40,7 @@ class RequireMigrationTests {
val viewCount = 1000L
return SerializedPlatformVideo(
ContentType.MEDIA,
platformId,
name,
thumbnails,