Skip to content
Draft
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
672 changes: 0 additions & 672 deletions .editorconfig

This file was deleted.

1 change: 1 addition & 0 deletions .idea/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

@Suppress("DSL_SCOPE_VIOLATION")
plugins {
id(libs.plugins.kotlin.jvm.pluginId) apply false
id(libs.plugins.kotlin.jvm.pluginId)
}

allprojects {
Expand Down
8 changes: 6 additions & 2 deletions goplay/src/main/java/be/tapped/goplay/GoPlayApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,13 @@ public object GoPlayApi :
AllProgramsHtmlJsonExtractor(),
ProgramDetailHtmlJsonExtractor(),
contentRootRepo(httpClient, contentTreeJsonParser())
),
).withResilience(ResilientConfig().toResilience()),
EpgRepo by httpEpgRepo(httpClient),
StreamRepo by httpStreamRepo(httpClient, mpegDashStreamResolver(httpClient), hlsStreamResolver()),
ProfileRepo by HttpProfileRepo(ProfileUserAttributeParser()),
CategoryRepo by categoryRepo(contentRootRepo(httpClient, contentTreeJsonParser())),
MyListRepo by HttpMyListRepo(myFavoriteProgramRepo(httpClient), addFavoriteProgramRepo(httpClient), removeFavoriteRepo(httpClient))
MyListRepo by HttpMyListRepo(
myFavoriteProgramRepo(httpClient),
addFavoriteProgramRepo(httpClient),
removeFavoriteRepo(httpClient)
)
20 changes: 16 additions & 4 deletions goplay/src/main/java/be/tapped/goplay/content/ProgramRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import arrow.core.Either
import arrow.core.Either.Companion.catch
import arrow.core.Nel
import arrow.core.computations.either
import arrow.core.flatten
import arrow.fx.coroutines.parTraverse
import be.tapped.goplay.Failure
import be.tapped.goplay.AllPrograms
import be.tapped.goplay.Detail
import be.tapped.goplay.Resilience
import be.tapped.goplay.epg.GoPlayBrand
import be.tapped.goplay.safeDecodeFromJsonElement
import be.tapped.goplay.safeDecodeFromString
Expand Down Expand Up @@ -43,7 +45,8 @@ internal class HttpProgramRepo(
either {
val html = client.safeGet<HttpResponse>("$siteUrl/programmas").bind().safeReadText().bind()
val jsonPrograms = allProgramsHtmlJsonExtractor.parse(html).bind()
val programs = jsonPrograms.map { jsonSerializer.safeDecodeFromString<Program.Overview>(it).bind() }.toNel { Failure.Content.NoPrograms }.bind()
val programs = jsonPrograms.map { jsonSerializer.safeDecodeFromString<Program.Overview>(it).bind() }
.toNel { Failure.Content.NoPrograms }.bind()
AllPrograms(programs)
}
}
Expand All @@ -53,14 +56,22 @@ internal class HttpProgramRepo(
either {
val html = client.safeGet<HttpResponse>("$siteUrl${link.link}").bind().safeReadText().bind()
val jsonProgram = programDetailHtmlJsonExtractor.parse(html).bind()
val dataObj = catch { jsonSerializer.safeDecodeFromString<JsonObject>(jsonProgram).bind().getValue("data") }.mapLeft(Failure::JsonParsingException).bind()
val dataObj = catch {
jsonSerializer.safeDecodeFromString<JsonObject>(jsonProgram).bind().getValue("data")
}.mapLeft(Failure::JsonParsingException).bind()
val program = jsonSerializer.safeDecodeFromJsonElement<Program.Detail>(dataObj).bind()
Detail(program)
}
}

override suspend fun fetchProgramById(id: Program.Id): Either<Failure, Detail> =
withContext(Dispatchers.IO) { either { Detail(client.safeGet<Program.Detail>("$siteUrl/api/program/${id.id}").bind()) } }
withContext(Dispatchers.IO) {
either {
Detail(
client.safeGet<Program.Detail>("$siteUrl/api/program/${id.id}").bind()
)
}
}

override suspend fun fetchPopularPrograms(brand: GoPlayBrand?): Either<Failure, Nel<Detail>> {
fun GoPlayBrand?.toPathSegment() =
Expand Down Expand Up @@ -116,4 +127,5 @@ internal class ProgramDetailHtmlJsonExtractor {
// A poor man's HTML decoder
// Shameless port of http://www.java2s.com/example/java-utility-method/html-decode/htmldecode-string-strsrc-415f0.html
// TODO refactor or replace with a dedicated MPP lib?
private fun String.htmlDecode(): String = replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&#039;", "'").replace("&amp;", "&")
private fun String.htmlDecode(): String =
replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&#039;", "'").replace("&amp;", "&")
1 change: 1 addition & 0 deletions goplay/src/main/java/be/tapped/goplay/model.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import be.tapped.goplay.epg.EpgProgram
import be.tapped.goplay.profile.TokenWrapper
import be.tapped.goplay.stream.ResolvedStream
import kotlinx.datetime.LocalDate
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject

public sealed interface Failure {
Expand Down
89 changes: 89 additions & 0 deletions goplay/src/main/java/be/tapped/goplay/resillience.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package be.tapped.goplay

import arrow.core.Either
import arrow.core.Nel
import arrow.core.flatten
import arrow.fx.coroutines.Schedule.Companion.exponential
import arrow.fx.coroutines.Schedule.Companion.identity
import arrow.fx.coroutines.Schedule.Companion.recurs
import arrow.fx.coroutines.retry
import be.tapped.goplay.content.Category
import be.tapped.goplay.content.Program
import be.tapped.goplay.content.ProgramRepo
import be.tapped.goplay.epg.GoPlayBrand
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.ExperimentalTime
import kotlinx.coroutines.runBlocking
import arrow.fx.coroutines.CircuitBreaker as FxCircuitBreaker
import arrow.fx.coroutines.Schedule as FxSchedule

public interface Resilience {
public suspend fun <A> resilient(action: suspend () -> A): A

public suspend fun <A> resilientCatch(action: suspend () -> A): Either<Throwable, A> =
Either.catch { resilient(action) }
}

@OptIn(ExperimentalTime::class)
public data class ResilientConfig(
val circuitBreaker: CircuitBreaker = CircuitBreaker(),
val schedule: Schedule = Schedule(),
) {

public fun toResilience(): Resilience {
val breaker = runBlocking { toCircuitBreaker() }
return object : Resilience {
override suspend fun <A> resilient(action: suspend () -> A): A =
toSchedule<Throwable>().retry {
breaker.protectOrThrow(action)
}
}
}

private suspend fun toCircuitBreaker(): FxCircuitBreaker = FxCircuitBreaker.of(
maxFailures = circuitBreaker.failureRateThreshold,
resetTimeoutNanos = circuitBreaker.durationOfOpenState.inWholeNanoseconds.toDouble()
)

private fun <In> toSchedule(): FxSchedule<In, In> =
(recurs<In>(schedule.maxRetries) and exponential(
schedule.retryWaitDuration,
schedule.retryBackoffMultiplier
)) zipRight identity()

public data class CircuitBreaker(val failureRateThreshold: Int = 10, val durationOfOpenState: Duration = 4.seconds)

public data class Schedule(
val retryWaitDuration: Duration = 500.milliseconds,
val maxRetries: Int = 4,
val retryBackoffMultiplier: Double = 2.5,
)
}

internal fun ProgramRepo.withResilience(resilience: Resilience): ProgramRepo =
object : ProgramRepo {
override suspend fun fetchPrograms(): Either<Failure, AllPrograms> =
resilience.resilientCatch(this@withResilience::fetchPrograms).mapLeft(Failure::Network).flatten()

override suspend fun fetchProgramByLink(link: Program.Link): Either<Failure, Detail> =
resilience.resilientCatch {
this@withResilience.fetchProgramByLink(link)
}.mapLeft(Failure::Network).flatten()

override suspend fun fetchProgramById(id: Program.Id): Either<Failure, Detail> =
resilience.resilientCatch {
this@withResilience.fetchProgramById(id)
}.mapLeft(Failure::Network).flatten()

override suspend fun fetchPopularPrograms(brand: GoPlayBrand?): Either<Failure, Nel<Detail>> =
resilience.resilientCatch {
this@withResilience.fetchPopularPrograms(brand)
}.mapLeft(Failure::Network).flatten()

override suspend fun fetchProgramsByCategory(categoryId: Category.Id): Either<Failure, Nel<Detail>> =
resilience.resilientCatch {
this@withResilience.fetchProgramsByCategory(categoryId)
}.mapLeft(Failure::Network).flatten()
}
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ arrow = "1.0.1"
coroutines = "1.6.0"
kotest = "5.1.0"
ktor = "1.6.7"
spotless = "6.3.0"

[libraries]
kotlinx-serialization-json = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.1.0"
Expand Down Expand Up @@ -40,3 +41,4 @@ kotlin-gradle = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlinx-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
spotless = { id = "com.diffplug.spotless", version.ref = "spotless" }
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
1 change: 0 additions & 1 deletion settings.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
enableFeaturePreview("VERSION_CATALOGS")
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")

rootProject.name = "flemish-tv-aggregator"
Expand Down