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.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import kotlinx.serialization.Serializable
import java.time.OffsetDateTime import java.time.OffsetDateTime
interface IPlatformContent { interface IPlatformContent {

View File

@ -14,6 +14,7 @@ import java.time.OffsetDateTime
@kotlinx.serialization.Serializable @kotlinx.serialization.Serializable
open class SerializedPlatformVideo( open class SerializedPlatformVideo(
override val contentType: ContentType = ContentType.MEDIA,
override val id: PlatformID, override val id: PlatformID,
override val name: String, override val name: String,
override val thumbnails: Thumbnails, override val thumbnails: Thumbnails,
@ -27,7 +28,6 @@ open class SerializedPlatformVideo(
override val viewCount: Long, override val viewCount: Long,
override val isShort: Boolean = false override val isShort: Boolean = false
) : IPlatformVideo, SerializedPlatformContent { ) : IPlatformVideo, SerializedPlatformContent {
override val contentType: ContentType = ContentType.MEDIA;
override val isLive: Boolean = false; override val isLive: Boolean = false;
@ -44,6 +44,7 @@ open class SerializedPlatformVideo(
companion object { companion object {
fun fromVideo(video: IPlatformVideo) : SerializedPlatformVideo { fun fromVideo(video: IPlatformVideo) : SerializedPlatformVideo {
return SerializedPlatformVideo( return SerializedPlatformVideo(
ContentType.MEDIA,
video.id, video.id,
video.name, video.name,
video.thumbnails, 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.PlatformID
import com.futo.platformplayer.api.media.models.PlatformAuthorLink import com.futo.platformplayer.api.media.models.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnails 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.SerializedPlatformVideo
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import java.time.LocalDateTime import java.time.LocalDateTime
@ -46,6 +47,7 @@ class HistoryVideo {
val name = str.substring(indexNext + 3); val name = str.substring(indexNext + 3);
val video = resolve?.invoke(url) ?: SerializedPlatformVideo( val video = resolve?.invoke(url) ?: SerializedPlatformVideo(
ContentType.MEDIA,
id = PlatformID.asUrlID(url), id = PlatformID.asUrlID(url),
name = name, name = name,
thumbnails = Thumbnails(), thumbnails = Thumbnails(),

View File

@ -40,3 +40,15 @@ class OffsetDateTimeSerializer : KSerializer<OffsetDateTime> {
return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC); 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>> { 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); val algo = SubscriptionFetchAlgorithm.getAlgorithm(_algorithmSubscriptions, cacheScope, allowFailure, withCacheFallback, _subscriptionsPool, exchangeClient);
if(onNewCacheHit != null) if(onNewCacheHit != null)
algo.onNewCacheHit.subscribe(onNewCacheHit) 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.activities.MainActivity
import com.futo.platformplayer.api.media.models.ResultCapabilities import com.futo.platformplayer.api.media.models.ResultCapabilities
import com.futo.platformplayer.api.media.models.contents.IPlatformContent 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.JSClient
import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig import com.futo.platformplayer.api.media.platforms.js.SourcePluginConfig
import com.futo.platformplayer.api.media.structures.DedupContentPager 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 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; 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; var providedTasks: MutableList<SubscriptionTask>? = null;
if(contract != null && contract.provided.isNotEmpty()){ if(contract != null && contract.required.isNotEmpty()){
providedTasks = mutableListOf() providedTasks = mutableListOf()
for(task in tasks.toList()){ for(task in tasks.toList()){
if(!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) { if(!task.fromCache && !task.fromPeek && contract.provided.contains(task.url)) {
@ -127,16 +132,18 @@ abstract class SubscriptionsTaskFetchAlgorithm(
//Resolve Subscription Exchange //Resolve Subscription Exchange
if(contract != null) { if(contract != null) {
try { 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( val resolve = subsExchangeClient?.resolveContract(
contract, contract,
*taskResults.filter { it.pager != null && (it.task.type == ResultCapabilities.TYPE_MIXED || it.task.type == ResultCapabilities.TYPE_VIDEOS) }.map { *resolves
ChannelResolve(
it.task.url,
it.pager!!.getResults()
)
}.toTypedArray()
); );
if (resolve != null) { if (resolve != null) {
UIDialogs.appToast("SubsExchange (Res: ${resolves.size}, Prov: ${resolve.size})")
for(result in resolve){ for(result in resolve){
val task = providedTasks?.find { it.url == result.channelUrl }; val task = providedTasks?.find { it.url == result.channelUrl };
if(task != null) { if(task != null) {
@ -153,6 +160,7 @@ abstract class SubscriptionsTaskFetchAlgorithm(
} }
catch(ex: Throwable) { catch(ex: Throwable) {
//TODO: fetch remainder after all? //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 package com.futo.platformplayer.subsexchange
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
class ChannelRequest( 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.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent 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 kotlinx.serialization.Serializable
import java.time.OffsetDateTime import java.time.OffsetDateTime
@Serializable @Serializable
class ChannelResolve( class ChannelResolve(
@SerialName("ChannelUrl")
var channelUrl: String, var channelUrl: String,
var content: List<IPlatformContent>, @SerialName("Content")
var content: List<SerializedPlatformContent>,
@SerialName("Channel")
var channel: IPlatformChannel? = null 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.channels.IPlatformChannel
import com.futo.platformplayer.api.media.models.contents.IPlatformContent 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.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer import com.futo.platformplayer.serializers.OffsetDateTimeSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import java.time.OffsetDateTime import java.time.OffsetDateTime
@Serializable @Serializable
class ChannelResult( class ChannelResult(
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) @kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class)
@SerialName("DateTime")
var dateTime: OffsetDateTime, var dateTime: OffsetDateTime,
@SerialName("ChannelUrl")
var channelUrl: String, var channelUrl: String,
var content: List<IPlatformContent>, @SerialName("Content")
var content: List<SerializedPlatformContent>,
@SerialName("Channel")
var channel: IPlatformChannel? = null 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.OffsetDateTimeNullableSerializer
import com.futo.platformplayer.serializers.OffsetDateTimeSerializer 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.Serializable
import kotlinx.serialization.Serializer
import java.time.OffsetDateTime import java.time.OffsetDateTime
@Serializable @Serializable
class ExchangeContract( class ExchangeContract(
@SerialName("ID")
var id: String, var id: String,
@SerialName("Requests")
var requests: List<ChannelRequest>, var requests: List<ChannelRequest>,
@SerialName("Provided")
var provided: List<String> = listOf(), var provided: List<String> = listOf(),
@SerialName("Required")
var required: List<String> = listOf(), var required: List<String> = listOf(),
@kotlinx.serialization.Serializable(with = OffsetDateTimeSerializer::class) @SerialName("Expire")
@kotlinx.serialization.Serializable(with = OffsetDateTimeStringSerializer::class)
var expired: OffsetDateTime = OffsetDateTime.MIN, var expired: OffsetDateTime = OffsetDateTime.MIN,
@SerialName("ContractVersion")
var contractVersion: Int = 1 var contractVersion: Int = 1
) )

View File

@ -1,10 +1,14 @@
package com.futo.platformplayer.subsexchange package com.futo.platformplayer.subsexchange
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class ExchangeContractResolve( data class ExchangeContractResolve(
@SerialName("PublicKey")
val publicKey: String, val publicKey: String,
@SerialName("Signature")
val signature: String, val signature: String,
@SerialName("Data")
val data: String 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.ChannelRequest
import com.futo.platformplayer.subsexchange.ChannelResolve import com.futo.platformplayer.subsexchange.ChannelResolve
import com.futo.platformplayer.subsexchange.ChannelResult import com.futo.platformplayer.subsexchange.ChannelResult
@ -19,13 +21,19 @@ import java.util.Base64
import java.io.InputStreamReader import java.io.InputStreamReader
import java.io.OutputStream import java.io.OutputStream
import java.io.OutputStreamWriter import java.io.OutputStreamWriter
import java.math.BigInteger
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.security.KeyPairGenerator import java.security.KeyPairGenerator
import java.security.spec.PKCS8EncodedKeySpec import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.RSAPublicKeySpec
class SubsExchangeClient(private val server: String, private val privateKey: String) { class SubsExchangeClient(private val server: String, private val privateKey: String) {
private val json = Json {
ignoreUnknownKeys = true
}
private val publicKey: String = extractPublicKey(privateKey) private val publicKey: String = extractPublicKey(privateKey)
// Endpoints // Endpoints
@ -43,18 +51,18 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str
// Endpoint: Resolve // Endpoint: Resolve
fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> { fun resolveContract(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> {
val contractResolve = convertResolves(*resolves) val contractResolve = convertResolves(*resolves)
val result = post("/api/Channel/Resolve?contractId=${contract.id}", Json.encodeToString(contractResolve), "application/json") val result = post("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json")
return Json.decodeFromString(result) return Serializer.json.decodeFromString(result)
} }
suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> { suspend fun resolveContractAsync(contract: ExchangeContract, vararg resolves: ChannelResolve): Array<ChannelResult> {
val contractResolve = convertResolves(*resolves) val contractResolve = convertResolves(*resolves)
val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Json.encodeToString(contractResolve), "application/json") val result = postAsync("/api/Channel/Resolve?contractId=${contract.id}", Serializer.json.encodeToString(contractResolve), "application/json")
return Json.decodeFromString(result) return Serializer.json.decodeFromString(result)
} }
private fun convertResolves(vararg resolves: ChannelResolve): ExchangeContractResolve { private fun convertResolves(vararg resolves: ChannelResolve): ExchangeContractResolve {
val data = Json.encodeToString(resolves) val data = Serializer.json.encodeToString(resolves)
val signature = createSignature(data, privateKey) val signature = createSignature(data, privateKey)
return ExchangeContractResolve( return ExchangeContractResolve(
@ -66,15 +74,31 @@ class SubsExchangeClient(private val server: String, private val privateKey: Str
// IO methods // IO methods
private fun post(query: String, body: String, contentType: String): String { 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) { with(url.openConnection() as HttpURLConnection) {
requestMethod = "POST" requestMethod = "POST"
setRequestProperty("Content-Type", contentType) setRequestProperty("Content-Type", contentType)
doOutput = true 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 keySpec = PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey))
val keyFactory = KeyFactory.getInstance("RSA") val keyFactory = KeyFactory.getInstance("RSA")
val privateKeyObj = keyFactory.generatePrivate(keySpec) as RSAPrivateKey val privateKeyObj = keyFactory.generatePrivate(keySpec) as RSAPrivateKey
val publicKeyObj: RSAPublicKey = keyFactory.generatePublic(keySpec) as RSAPublicKey; val publicKeyObj: PublicKey? = keyFactory.generatePublic(RSAPublicKeySpec(privateKeyObj.modulus, BigInteger.valueOf(65537)));
return Base64.getEncoder().encodeToString(publicKeyObj.encoded) 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 { 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.PlatformAuthorLink
import com.futo.platformplayer.api.media.models.Thumbnail import com.futo.platformplayer.api.media.models.Thumbnail
import com.futo.platformplayer.api.media.models.Thumbnails 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.SerializedPlatformVideo
import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails import com.futo.platformplayer.api.media.models.video.SerializedPlatformVideoDetails
import com.futo.platformplayer.serializers.FlexibleBooleanSerializer import com.futo.platformplayer.serializers.FlexibleBooleanSerializer
@ -39,6 +40,7 @@ class RequireMigrationTests {
val viewCount = 1000L val viewCount = 1000L
return SerializedPlatformVideo( return SerializedPlatformVideo(
ContentType.MEDIA,
platformId, platformId,
name, name,
thumbnails, thumbnails,