diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt index 554a4723..edb1caa3 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/contents/IPlatformContent.kt @@ -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 { diff --git a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt index 49b6265b..68bb5cb9 100644 --- a/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt +++ b/app/src/main/java/com/futo/platformplayer/api/media/models/video/SerializedPlatformVideo.kt @@ -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, diff --git a/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt b/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt index b491f95f..80574968 100644 --- a/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt +++ b/app/src/main/java/com/futo/platformplayer/models/HistoryVideo.kt @@ -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(), diff --git a/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt b/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt index 31fbaadd..faee4e3b 100644 --- a/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt +++ b/app/src/main/java/com/futo/platformplayer/serializers/OffsetDateTimeSerializer.kt @@ -39,4 +39,16 @@ class OffsetDateTimeSerializer : KSerializer { return OffsetDateTime.MIN; return OffsetDateTime.of(LocalDateTime.ofEpochSecond(epochSecond, 0, ZoneOffset.UTC), ZoneOffset.UTC); } +} +class OffsetDateTimeStringSerializer : KSerializer { + 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); + } } \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt index f0a56b41..1d1acff6 100644 --- a/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt +++ b/app/src/main/java/com/futo/platformplayer/states/StateSubscriptions.kt @@ -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, List> { - 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) diff --git a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt index 630cef65..15235017 100644 --- a/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt +++ b/app/src/main/java/com/futo/platformplayer/subscription/SubscriptionsTaskFetchAlgorithm.kt @@ -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? = 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); } } diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt index cde158d5..a7939ae4 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelRequest.kt @@ -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 ); \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt index eaedc191..7bf5e022 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResolve.kt @@ -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, + @SerialName("Content") + var content: List, + @SerialName("Channel") var channel: IPlatformChannel? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt index f55f2451..c13f101c 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ChannelResult.kt @@ -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, + @SerialName("Content") + var content: List, + @SerialName("Channel") var channel: IPlatformChannel? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt index 6618c3cb..d357c8b7 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContract.kt @@ -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, + @SerialName("Provided") var provided: List = listOf(), + @SerialName("Required") var required: List = 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 ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt index 30550d51..8f42e0c3 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/ExchangeContractResolve.kt @@ -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 ) \ No newline at end of file diff --git a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt index 2fcfaf3f..a58e17b0 100644 --- a/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt +++ b/app/src/main/java/com/futo/platformplayer/subsexchange/SubsExchangeClient.kt @@ -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 { 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 { 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 { diff --git a/app/src/test/java/com/futo/platformplayer/RequireMigrationTests.kt b/app/src/test/java/com/futo/platformplayer/RequireMigrationTests.kt index 1b5eabc8..3118194b 100644 --- a/app/src/test/java/com/futo/platformplayer/RequireMigrationTests.kt +++ b/app/src/test/java/com/futo/platformplayer/RequireMigrationTests.kt @@ -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,