mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-07-26 20:02:14 +00:00
Move data/player/domain logic into commonMain KMP source sets
Sweeps addon repositories, watchlist/profile storage, Trakt/Nuvio sync, discovery models, and player policy classes out of Android-only packages into data/commonMain and player/commonMain, with thin androidMain wrappers where platform APIs are still required.
This commit is contained in:
parent
7085c5c7bf
commit
fc6bc620b3
152 changed files with 3227 additions and 2767 deletions
|
|
@ -301,7 +301,9 @@ tasks.matching { it.name == "preBuild" }.configureEach {
|
|||
|
||||
tasks.withType<Test>().configureEach {
|
||||
dependsOn(rootProject.tasks.named("buildFluxaCoreHost"))
|
||||
dependsOn(rootProject.tasks.named("buildFluxaStreamingEngineHost"))
|
||||
dependsOn(generateFluxaCoreUniFfiBindings)
|
||||
jvmArgs("-Djava.library.path=${rustCrateDir.resolve("target/debug").absolutePath}")
|
||||
systemProperty(
|
||||
"jna.library.path",
|
||||
rustCrateDir.resolve("target/debug").absolutePath
|
||||
|
|
|
|||
|
|
@ -0,0 +1,208 @@
|
|||
package com.fluxa.app.core.rust
|
||||
|
||||
import android.util.Log
|
||||
import com.fluxa.app.data.local.*
|
||||
import com.fluxa.app.data.repository.NuvioAccountImportCoordinator
|
||||
import com.fluxa.app.data.repository.StremioRepository
|
||||
import com.fluxa.app.data.repository.TraktIntegration
|
||||
import com.fluxa.app.data.repository.TraktRepository
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
internal class AndroidAuthEffectHandler(
|
||||
private val repository: StremioRepository,
|
||||
private val traktRepository: TraktRepository,
|
||||
private val watchlistManager: WatchlistManager,
|
||||
private val nuvioAccountImportCoordinator: NuvioAccountImportCoordinator,
|
||||
private val gson: Gson
|
||||
) {
|
||||
suspend fun execute(effect: NativeHeadlessEffect): HeadlessEffectCompletion = when (effect.type) {
|
||||
"runExternalSync" -> runExternalSync(effect)
|
||||
"runAuthFlow" -> runAuthFlow(effect)
|
||||
"exchangeAuthCode" -> exchangeAuthCode(effect)
|
||||
"refreshAuthToken" -> refreshAuthToken(effect)
|
||||
"syncExternalIntegration" -> syncExternalIntegration(effect)
|
||||
else -> failure(effect, "unsupported_auth_effect")
|
||||
}
|
||||
|
||||
private suspend fun runExternalSync(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val profile = effect.payload.parseProfile() ?: return success(effect, emptyMap<String, Any?>())
|
||||
if (effect.payload.string("provider") == "nuvio") {
|
||||
val updatedProfile = nuvioAccountImportCoordinator.sync(profile) {}
|
||||
return success(effect, mapOf("profile" to updatedProfile))
|
||||
}
|
||||
return success(
|
||||
effect,
|
||||
mapOf(
|
||||
"snapshot" to when (effect.payload.string("provider")) {
|
||||
"trakt" -> traktRepository.getSyncSnapshot(profile, effect.payload.string("language", profile.safeLanguage))
|
||||
else -> repository.getExternalContinueWatching(profile, effect.payload.string("language", profile.safeLanguage))
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun runAuthFlow(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
return when (effect.payload.string("provider")) {
|
||||
"trakt" -> when (effect.payload.string("mode")) {
|
||||
"deviceCode" -> success(effect, repository.createTraktDeviceCode())
|
||||
else -> failure(effect, "unsupported_auth_mode")
|
||||
}
|
||||
else -> failure(effect, "unsupported_auth_provider")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun exchangeAuthCode(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val payload = effect.payload
|
||||
val profile = payload.parseProfile() ?: return failure(effect, "missing_profile")
|
||||
val updated = when (payload.string("provider")) {
|
||||
"trakt" -> {
|
||||
val response = repository.exchangeTraktCode(payload.string("code"))
|
||||
profile.copy(
|
||||
traktAccessToken = response.accessToken,
|
||||
traktRefreshToken = response.refreshToken,
|
||||
traktTokenExpiresAt = TraktIntegration.tokenExpiresAt(response.createdAt, response.expiresIn)
|
||||
)
|
||||
}
|
||||
"traktDevice" -> {
|
||||
val response = repository.exchangeTraktDeviceCode(payload.string("code"))
|
||||
if (!response.isSuccessful) {
|
||||
val errorCode = response.errorBody()?.string()?.let(FluxaCoreNative::traktOAuthErrorCode)
|
||||
return success(
|
||||
effect,
|
||||
mapOf(
|
||||
"status" to "pending",
|
||||
"errorCode" to (errorCode ?: "http_${response.code()}"),
|
||||
"httpCode" to response.code(),
|
||||
"retryAfterSeconds" to response.headers()["Retry-After"]?.toLongOrNull()
|
||||
)
|
||||
)
|
||||
}
|
||||
val responseBody = response.body() ?: return failure(effect, "empty_device_token")
|
||||
profile.copy(
|
||||
traktAccessToken = responseBody.accessToken,
|
||||
traktRefreshToken = responseBody.refreshToken,
|
||||
traktTokenExpiresAt = TraktIntegration.tokenExpiresAt(responseBody.createdAt, responseBody.expiresIn)
|
||||
)
|
||||
}
|
||||
"mal" -> {
|
||||
val response = repository.exchangeMalCode(payload.string("code"), payload.string("codeVerifier"))
|
||||
profile.copy(
|
||||
malAccessToken = response.accessToken,
|
||||
malRefreshToken = response.refreshToken,
|
||||
malTokenExpiresAt = response.expiresIn?.let { System.currentTimeMillis() + it * 1000L }
|
||||
)
|
||||
}
|
||||
"simkl" -> profile.copy(simklAccessToken = repository.exchangeSimklCode(payload.string("code")).accessToken)
|
||||
"anilist" -> {
|
||||
val response = repository.exchangeAnilistCode(payload.string("code"))
|
||||
profile.copy(
|
||||
anilistAccessToken = response.accessToken,
|
||||
anilistRefreshToken = response.refreshToken,
|
||||
anilistTokenExpiresAt = response.expiresIn?.let { System.currentTimeMillis() + it * 1000L }
|
||||
)
|
||||
}
|
||||
else -> return failure(effect, "unsupported_auth_provider")
|
||||
}
|
||||
return success(effect, mapOf("profile" to updated))
|
||||
}
|
||||
|
||||
private suspend fun refreshAuthToken(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val profile = effect.payload.parseProfile() ?: return failure(effect, "missing_profile")
|
||||
val updated = when (effect.payload.string("provider")) {
|
||||
"trakt" -> refreshTraktTokenIfNeeded(profile)
|
||||
"mal" -> refreshMalTokenIfNeeded(profile)
|
||||
else -> return failure(effect, "unsupported_auth_provider")
|
||||
}
|
||||
return success(effect, mapOf("profile" to updated))
|
||||
}
|
||||
|
||||
private suspend fun refreshTraktTokenIfNeeded(profile: UserProfile): UserProfile {
|
||||
val refreshToken = profile.traktRefreshToken?.takeIf(String::isNotBlank) ?: return profile
|
||||
val refreshWindowMs = 24L * 60L * 60L * 1000L
|
||||
if (!profile.traktAccessToken.isNullOrBlank() && profile.safeTraktTokenExpiresAt > System.currentTimeMillis() + refreshWindowMs) return profile
|
||||
return runCatching {
|
||||
val response = traktRepository.refreshTraktToken(refreshToken)
|
||||
profile.copy(
|
||||
traktAccessToken = response.accessToken,
|
||||
traktRefreshToken = response.refreshToken,
|
||||
traktTokenExpiresAt = TraktIntegration.tokenExpiresAt(response.createdAt, response.expiresIn)
|
||||
)
|
||||
}.getOrElse { throwable ->
|
||||
Log.w("Trakt", "Token refresh failed", throwable)
|
||||
if ((throwable as? retrofit2.HttpException)?.code() in setOf(400, 401)) {
|
||||
profile.copy(traktAccessToken = null, traktRefreshToken = null, traktTokenExpiresAt = null)
|
||||
} else profile
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshMalTokenIfNeeded(profile: UserProfile): UserProfile {
|
||||
val refreshToken = profile.malRefreshToken?.takeIf(String::isNotBlank) ?: return profile
|
||||
val refreshWindowMs = 24L * 60L * 60L * 1000L
|
||||
if (!profile.malAccessToken.isNullOrBlank() && profile.safeMalTokenExpiresAt > System.currentTimeMillis() + refreshWindowMs) return profile
|
||||
return runCatching {
|
||||
val response = repository.refreshMalToken(refreshToken)
|
||||
profile.copy(
|
||||
malAccessToken = response.accessToken,
|
||||
malRefreshToken = response.refreshToken ?: refreshToken,
|
||||
malTokenExpiresAt = response.expiresIn?.let { System.currentTimeMillis() + it * 1000L }
|
||||
)
|
||||
}.getOrElse { throwable ->
|
||||
Log.w("Mal", "Token refresh failed", throwable)
|
||||
if ((throwable as? retrofit2.HttpException)?.code() in setOf(400, 401)) {
|
||||
profile.copy(malAccessToken = null, malRefreshToken = null, malTokenExpiresAt = null)
|
||||
} else profile
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncExternalIntegration(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val payload = effect.payload
|
||||
val profile = payload.parseProfile() ?: return failure(effect, "missing_profile")
|
||||
if (payload.string("provider") == "stremio") {
|
||||
if (profile.authKey.isBlank()) return failure(effect, "missing_stremio_token")
|
||||
val addons = repository.getUserAddons(profile.authKey, forceRefresh = true)
|
||||
val library = repository.getLibraryItems(profile.authKey)
|
||||
val updated = profile.copy(localAddons = addons.map { it.transportUrl }.distinct())
|
||||
return success(
|
||||
effect,
|
||||
mapOf(
|
||||
"profile" to updated,
|
||||
"snapshot" to mapOf("addons" to addons, "library" to library),
|
||||
"externalContinueWatching" to library
|
||||
)
|
||||
)
|
||||
}
|
||||
val traktToken = profile.traktAccessToken ?: return failure(effect, "missing_trakt_token")
|
||||
val language = payload.string("language", profile.safeLanguage)
|
||||
val snapshot = traktRepository.getTraktSyncSnapshot(profile, language)
|
||||
val watchedState = withTimeoutOrNull(8_000L) { traktRepository.getTraktWatchedState(traktToken) }
|
||||
if (watchedState != null) {
|
||||
watchlistManager.replaceExternalWatchedEpisodes("trakt", watchedState.episodeIdsBySeries)
|
||||
watchlistManager.replaceExternalWatchedContentDurations("trakt", watchedState.durationRecords)
|
||||
}
|
||||
val externalItems = repository.getExternalContinueWatching(profile, language)
|
||||
val updated = profile.copy(
|
||||
traktLastSyncAt = System.currentTimeMillis(),
|
||||
traktLastSyncedItems = snapshot.syncedItems,
|
||||
traktLastContinueWatchingCount = snapshot.continueWatchingCount,
|
||||
traktLastWatchlistCount = snapshot.watchlistCount
|
||||
)
|
||||
return success(
|
||||
effect,
|
||||
mapOf(
|
||||
"profile" to updated,
|
||||
"snapshot" to snapshot,
|
||||
"watchedState" to watchedState,
|
||||
"externalContinueWatching" to externalItems
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun Map<String, Any?>.parseProfile(): UserProfile? = parseProfile(gson)
|
||||
|
||||
private fun success(effect: NativeHeadlessEffect, value: Any?) =
|
||||
HeadlessEffectCompletion(effectId = effect.id, status = "ok", value = value)
|
||||
|
||||
private fun failure(effect: NativeHeadlessEffect, code: String) =
|
||||
HeadlessEffectCompletion(effectId = effect.id, status = "error", error = mapOf("code" to code))
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
package com.fluxa.app.core.rust
|
||||
|
||||
import android.content.Context
|
||||
import com.fluxa.app.common.ReleaseDateUtils
|
||||
import com.fluxa.app.data.local.*
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.fluxa.app.data.repository.StremioRepository
|
||||
import com.fluxa.app.ui.catalog.CalendarUpcomingItem
|
||||
import com.fluxa.app.ui.catalog.CalendarWidgetProvider
|
||||
import com.fluxa.app.ui.catalog.EpisodeCalendarLoader
|
||||
import com.fluxa.app.ui.catalog.EpisodeNotificationHelper
|
||||
import com.google.gson.Gson
|
||||
|
||||
internal class AndroidCalendarEffectHandler(
|
||||
private val context: Context,
|
||||
private val repository: StremioRepository,
|
||||
private val watchlistManager: WatchlistManager,
|
||||
private val gson: Gson
|
||||
) {
|
||||
suspend fun execute(effect: NativeHeadlessEffect): HeadlessEffectCompletion = when (effect.type) {
|
||||
"readCalendarMonth" -> readMonth(effect)
|
||||
"replaceExternalContinueWatching" -> replaceExternalContinueWatching(effect)
|
||||
"updateCalendarWidget" -> updateWidget(effect)
|
||||
"notifyReleasedEpisodes" -> notifyReleasedEpisodes(effect)
|
||||
else -> failure(effect, "unsupported_calendar_effect")
|
||||
}
|
||||
|
||||
private suspend fun readMonth(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val profile = effect.payload.parseProfile(gson)
|
||||
val year = effect.payload.number("year")?.toInt() ?: return failure(effect, "missing_year")
|
||||
val month = effect.payload.number("month")?.toInt() ?: return failure(effect, "missing_month")
|
||||
val plannedItems = effect.payload.list("plannedItems").mapNotNull { raw ->
|
||||
runCatching { gson.fromJson(gson.toJsonTree(raw), Meta::class.java) }.getOrNull()
|
||||
}
|
||||
val result = EpisodeCalendarLoader(repository, watchlistManager).loadMonth(profile, year, month, plannedItems)
|
||||
return success(
|
||||
effect,
|
||||
mapOf(
|
||||
"items" to result.items,
|
||||
"localItems" to result.localItems,
|
||||
"externalItems" to result.externalItems
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun replaceExternalContinueWatching(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val items = effect.payload.list("items").mapNotNull { raw ->
|
||||
runCatching { gson.fromJson(gson.toJsonTree(raw), Meta::class.java) }.getOrNull()
|
||||
}
|
||||
watchlistManager.replaceExternalContinueWatching(items)
|
||||
return success(effect, mapOf("count" to items.size))
|
||||
}
|
||||
|
||||
private fun updateWidget(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val profile = effect.payload.parseProfile(gson)
|
||||
val items = calendarItems(effect)
|
||||
CalendarWidgetProvider.updateCalendar(
|
||||
context = context,
|
||||
items = items,
|
||||
language = profile?.safeLanguage ?: "en",
|
||||
accentColorArgb = profile?.safeAccentColorArgb ?: 0xFFFFFFFF.toInt()
|
||||
)
|
||||
return success(effect, mapOf("count" to items.size))
|
||||
}
|
||||
|
||||
private suspend fun notifyReleasedEpisodes(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val profile = effect.payload.parseProfile(gson)
|
||||
val items = calendarItems(effect)
|
||||
EpisodeNotificationHelper.notifyReleasedEpisodes(
|
||||
context = context,
|
||||
profile = profile,
|
||||
items = items,
|
||||
todayIso = ReleaseDateUtils.todayIso()
|
||||
)
|
||||
return success(effect, mapOf("count" to items.size))
|
||||
}
|
||||
|
||||
private fun calendarItems(effect: NativeHeadlessEffect): List<CalendarUpcomingItem> =
|
||||
effect.payload.list("items").mapNotNull { raw ->
|
||||
runCatching { gson.fromJson(gson.toJsonTree(raw), CalendarUpcomingItem::class.java) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun success(effect: NativeHeadlessEffect, value: Any?) =
|
||||
HeadlessEffectCompletion(effectId = effect.id, status = "ok", value = value)
|
||||
|
||||
private fun failure(effect: NativeHeadlessEffect, code: String) =
|
||||
HeadlessEffectCompletion(effectId = effect.id, status = "error", error = mapOf("code" to code))
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
package com.fluxa.app.core.rust
|
||||
|
||||
import android.util.Log
|
||||
import com.fluxa.app.data.remote.CastMember
|
||||
import com.fluxa.app.data.remote.DetailTrailer
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.fluxa.app.data.remote.MetaDetail
|
||||
import com.fluxa.app.data.remote.MetaLink
|
||||
import com.fluxa.app.data.remote.MetaRating
|
||||
import com.fluxa.app.data.remote.Stream
|
||||
import com.fluxa.app.data.remote.SubtitleData
|
||||
import com.fluxa.app.data.remote.Video
|
||||
import com.fluxa.app.data.repository.CloudStreamCatalogClient
|
||||
import com.fluxa.app.data.repository.toStremioType
|
||||
import com.fluxa.app.plugins.PluginManager
|
||||
import com.fluxa.app.plugins.cloudstream.ExternalExtensionRunner
|
||||
import com.fluxa.app.plugins.cloudstream.ScraperActor
|
||||
import com.fluxa.app.plugins.cloudstream.ScraperLoadResult
|
||||
import com.fluxa.app.plugins.cloudstream.ScraperSearchResult
|
||||
import com.fluxa.app.plugins.cloudstream.ScraperSubtitle
|
||||
import com.fluxa.app.plugins.cloudstream.ScraperTrailer
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
internal class AndroidCloudStreamRuntime(private val pluginManager: PluginManager) {
|
||||
suspend fun loadMetaDetail(id: String): MetaDetail? {
|
||||
val (apiName, url) = CloudStreamCatalogClient.decodeCsId(id) ?: return null
|
||||
val api = pluginManager.loadedApis.value.firstOrNull { it.name == apiName } ?: run {
|
||||
Log.w("CS3Detail", "Plugin not found: $apiName")
|
||||
return null
|
||||
}
|
||||
val runner = ExternalExtensionRunner()
|
||||
val load = withTimeoutOrNull(20_000L) { runner.loadContent(api, url) } ?: run {
|
||||
Log.w("CS3Detail", "$apiName: loadContent timed out or returned null for url=$url")
|
||||
return null
|
||||
}
|
||||
val videos = load.episodes?.mapIndexed { index, episode ->
|
||||
Video(
|
||||
id = CloudStreamCatalogClient.encodeCsId(apiName, episode.data),
|
||||
name = episode.name ?: "Episode ${index + 1}",
|
||||
season = episode.season,
|
||||
number = episode.episode,
|
||||
released = episode.date?.toIsoDate(),
|
||||
thumbnail = episode.posterUrl,
|
||||
overview = episode.description,
|
||||
rating = episode.rating?.let(::ratingString),
|
||||
episodeRuntime = episode.runTime
|
||||
)
|
||||
}
|
||||
return MetaDetail(
|
||||
id = id,
|
||||
type = load.type.toStremioType(),
|
||||
name = load.title,
|
||||
genres = load.tags,
|
||||
poster = load.posterUrl,
|
||||
background = load.backgroundPosterUrl ?: load.posterUrl,
|
||||
logo = load.logoUrl,
|
||||
description = load.plot,
|
||||
releaseInfo = load.year?.toString(),
|
||||
released = load.year?.let { "$it-01-01" },
|
||||
runtime = load.duration?.let { "${it}m" },
|
||||
videos = videos,
|
||||
trailers = load.trailers?.mapIndexedNotNull { index, trailer -> trailer.toDetailTrailer(apiName, index) },
|
||||
imdbRating = load.rating?.let(::ratingString),
|
||||
ageRating = load.contentRating,
|
||||
ratings = load.rating?.let { listOf(MetaRating("Cloudstream", ratingString(it))) },
|
||||
cast = load.actors?.map { it.toCastMember() },
|
||||
links = load.toMetaLinks(),
|
||||
status = if (load.comingSoon) "Coming Soon" else load.status,
|
||||
originalName = load.synonyms?.firstOrNull { it != load.title },
|
||||
collectionParts = load.recommendations?.mapNotNull { it.toMeta(apiName) }
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun loadStreams(id: String, directTimeoutMs: Long): List<Stream> {
|
||||
val (apiName, data) = CloudStreamCatalogClient.decodeCsId(id) ?: return emptyList()
|
||||
val api = pluginManager.loadedApis.value.firstOrNull { it.name == apiName } ?: return emptyList()
|
||||
val runner = ExternalExtensionRunner()
|
||||
val direct = try {
|
||||
withTimeoutOrNull(directTimeoutMs) { runner.loadStreams(api, data) }
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
if (direct != null && direct.links.isNotEmpty()) return direct.links.toStreams(apiName, direct.subtitles)
|
||||
val streamData = try {
|
||||
withTimeoutOrNull(15_000L) { runner.loadContent(api, data)?.data }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
} ?: data
|
||||
val result = withTimeoutOrNull(30_000L) { runner.loadStreams(api, streamData) } ?: return emptyList()
|
||||
return result.links.toStreams(apiName, result.subtitles)
|
||||
}
|
||||
|
||||
fun qualityScore(quality: String): Int {
|
||||
val value = quality.lowercase()
|
||||
return when {
|
||||
value.contains("4k") || value.contains("2160") -> 2160
|
||||
value.contains("1440") -> 1440
|
||||
value.contains("1080") -> 1080
|
||||
value.contains("720") -> 720
|
||||
value.contains("480") -> 480
|
||||
value.contains("360") -> 360
|
||||
value.contains("240") -> 240
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<com.fluxa.app.plugins.cloudstream.ScraperStreamLink>.toStreams(
|
||||
apiName: String,
|
||||
subtitles: List<ScraperSubtitle>
|
||||
): List<Stream> = sortedByDescending { qualityScore(it.quality) }.map { link ->
|
||||
Stream(
|
||||
name = " $apiName\n${link.quality}",
|
||||
title = link.name,
|
||||
url = link.url,
|
||||
subtitles = subtitles.map { it.toSubtitleData() },
|
||||
behaviorHints = buildMap {
|
||||
put("proxyHeaders", buildMap { put("request", link.headers) })
|
||||
link.referer?.let { put("referer", it) }
|
||||
put("cs3Type", link.type)
|
||||
put("isM3u8", link.isM3u8)
|
||||
put("isDash", link.isDash)
|
||||
},
|
||||
addonName = " $apiName"
|
||||
)
|
||||
}
|
||||
|
||||
private fun Long.toIsoDate(): String {
|
||||
val millis = if (this > 10_000_000_000L) this else this * 1000L
|
||||
return java.time.Instant.ofEpochMilli(millis).atZone(java.time.ZoneOffset.UTC).toLocalDate().toString()
|
||||
}
|
||||
|
||||
private fun ratingString(rating: Int): String = "%.1f".format(java.util.Locale.US, rating.toFloat() / 10f)
|
||||
|
||||
private fun ScraperActor.toCastMember() = CastMember(name = name, character = role, profilePath = image)
|
||||
|
||||
private fun ScraperTrailer.toDetailTrailer(apiName: String, index: Int): DetailTrailer? {
|
||||
val targetUrl = url.takeIf { it.isNotBlank() } ?: return null
|
||||
return DetailTrailer("cs3:$apiName:trailer:$index", "Trailer ${index + 1}", if (raw) "Trailer" else "Extractor", targetUrl, null, apiName)
|
||||
}
|
||||
|
||||
private fun ScraperSearchResult.toMeta(apiName: String): Meta? {
|
||||
val name = title.takeIf { it.isNotBlank() } ?: return null
|
||||
return Meta(
|
||||
id = CloudStreamCatalogClient.encodeCsId(apiName, url),
|
||||
name = name,
|
||||
type = type?.toStremioType() ?: "movie",
|
||||
poster = posterUrl,
|
||||
releaseInfo = year?.toString(),
|
||||
imdbRating = quality,
|
||||
background = posterUrl
|
||||
)
|
||||
}
|
||||
|
||||
private fun ScraperLoadResult.toMetaLinks(): List<MetaLink>? {
|
||||
val links = mutableListOf<MetaLink>()
|
||||
uniqueUrl?.takeIf { it.isNotBlank() }?.let { links += MetaLink("Source", "Cloudstream", it) }
|
||||
url.takeIf { it.isNotBlank() && it != uniqueUrl }?.let { links += MetaLink("Page", "Cloudstream", it) }
|
||||
syncData.orEmpty().forEach { (key, value) -> if (key.isNotBlank() && value.isNotBlank()) links += MetaLink(key, "Cloudstream Sync", value) }
|
||||
synonyms.orEmpty().forEach { links += MetaLink(it, "Cloudstream Synonym", it) }
|
||||
nextAiringUnixTime?.let { unix ->
|
||||
val label = listOfNotNull(nextAiringSeason?.let { "S$it" }, nextAiringEpisode?.let { "E$it" }).joinToString("").ifBlank { "Next airing" }
|
||||
links += MetaLink(label, "Cloudstream Next Airing", unix.toString())
|
||||
}
|
||||
seasonNames.orEmpty().forEach { season ->
|
||||
val name = season.name?.takeIf { it.isNotBlank() } ?: "Season ${season.displaySeason ?: season.season}"
|
||||
links += MetaLink(name, "Cloudstream Season", season.season.toString())
|
||||
}
|
||||
return links.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
private fun ScraperSubtitle.toSubtitleData() = SubtitleData(url = url, lang = lang)
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
package com.fluxa.app.core.rust
|
||||
|
||||
import android.content.Context
|
||||
import com.fluxa.app.data.local.OfflineDownloadManager
|
||||
import com.fluxa.app.data.local.OfflineSubtitleOption
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.fluxa.app.data.remote.Stream
|
||||
import com.fluxa.app.data.remote.Video
|
||||
import com.google.gson.Gson
|
||||
|
||||
internal class AndroidOfflineEffectHandler(
|
||||
private val context: Context,
|
||||
private val gson: Gson
|
||||
) {
|
||||
suspend fun enqueue(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val payload = effect.payload
|
||||
val result = OfflineDownloadManager.getInstance(context).enqueue(
|
||||
profileId = payload.stringOrNull("profileId"),
|
||||
meta = gson.fromJson(gson.toJsonTree(payload["meta"]), Meta::class.java),
|
||||
video = payload.objectValue("video")?.let { gson.fromJson(gson.toJsonTree(it), Video::class.java) },
|
||||
videoId = payload.stringOrNull("videoId"),
|
||||
stream = gson.fromJson(gson.toJsonTree(payload["stream"]), Stream::class.java),
|
||||
subtitle = payload.objectValue("subtitle")?.let {
|
||||
gson.fromJson(gson.toJsonTree(it), OfflineSubtitleOption::class.java)
|
||||
},
|
||||
profileLanguage = payload.stringOrNull("language")
|
||||
)
|
||||
return result.fold(
|
||||
onSuccess = { HeadlessEffectCompletion(effect.id, "ok", value = it) },
|
||||
onFailure = {
|
||||
HeadlessEffectCompletion(
|
||||
effectId = effect.id,
|
||||
status = "error",
|
||||
error = mapOf("code" to (it.message ?: "offline_download_failed"))
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -113,6 +113,24 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
|
|||
private val _streamProgressFlow = MutableSharedFlow<StreamProgressUpdate>(replay = 0, extraBufferCapacity = 32)
|
||||
val streamProgressFlow: SharedFlow<StreamProgressUpdate> = _streamProgressFlow
|
||||
|
||||
private val authEffectHandler = AndroidAuthEffectHandler(
|
||||
repository = repository,
|
||||
traktRepository = traktRepository,
|
||||
watchlistManager = watchlistManager,
|
||||
nuvioAccountImportCoordinator = nuvioAccountImportCoordinator,
|
||||
gson = gson
|
||||
)
|
||||
|
||||
private val calendarEffectHandler = AndroidCalendarEffectHandler(
|
||||
context = context,
|
||||
repository = repository,
|
||||
watchlistManager = watchlistManager,
|
||||
gson = gson
|
||||
)
|
||||
|
||||
private val offlineEffectHandler = AndroidOfflineEffectHandler(context, gson)
|
||||
private val cloudStreamRuntime = AndroidCloudStreamRuntime(pluginManager)
|
||||
|
||||
override suspend fun execute(effect: NativeHeadlessEffect): HeadlessEffectCompletion = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
syncWatchlistProfile(effect)
|
||||
|
|
@ -148,17 +166,17 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
|
|||
"fetchDiscoverPage" -> fetchCatalogPage(effect)
|
||||
"fetchSeasonEpisodes" -> fetchSeasonEpisodes(effect)
|
||||
"fetchSubtitles" -> fetchSubtitles(effect)
|
||||
"runExternalSync" -> runExternalSync(effect)
|
||||
"runAuthFlow" -> runAuthFlow(effect)
|
||||
"exchangeAuthCode" -> exchangeAuthCode(effect)
|
||||
"refreshAuthToken" -> refreshAuthToken(effect)
|
||||
"syncExternalIntegration" -> syncExternalIntegration(effect)
|
||||
"runExternalSync",
|
||||
"runAuthFlow",
|
||||
"exchangeAuthCode",
|
||||
"refreshAuthToken",
|
||||
"syncExternalIntegration" -> authEffectHandler.execute(effect)
|
||||
"writeSettings" -> writeSettings(effect)
|
||||
"readCalendarMonth" -> readCalendarMonth(effect)
|
||||
"replaceExternalContinueWatching" -> replaceExternalContinueWatching(effect)
|
||||
"updateCalendarWidget" -> updateCalendarWidget(effect)
|
||||
"notifyReleasedEpisodes" -> notifyReleasedEpisodes(effect)
|
||||
"enqueueOfflineDownload" -> enqueueOfflineDownload(effect)
|
||||
"readCalendarMonth",
|
||||
"replaceExternalContinueWatching",
|
||||
"updateCalendarWidget",
|
||||
"notifyReleasedEpisodes" -> calendarEffectHandler.execute(effect)
|
||||
"enqueueOfflineDownload" -> offlineEffectHandler.enqueue(effect)
|
||||
else -> error(effect, "unsupported_effect")
|
||||
}
|
||||
}.getOrElse { throwable ->
|
||||
|
|
@ -1070,196 +1088,6 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
|
|||
return ok(effect, mapOf("subtitles" to stream?.subtitles.orEmpty()))
|
||||
}
|
||||
|
||||
private suspend fun runExternalSync(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val profile = effect.payload.profile() ?: return ok(effect, emptyMap<String, Any?>())
|
||||
if (effect.payload.string("provider") == "nuvio") {
|
||||
val updatedProfile = nuvioAccountImportCoordinator.sync(profile) {}
|
||||
return ok(effect, mapOf("profile" to updatedProfile))
|
||||
}
|
||||
return ok(
|
||||
effect,
|
||||
mapOf(
|
||||
"snapshot" to when (effect.payload.string("provider")) {
|
||||
"trakt" -> traktRepository.getSyncSnapshot(profile, effect.payload.string("language", profile.safeLanguage))
|
||||
else -> repository.getExternalContinueWatching(profile, effect.payload.string("language", profile.safeLanguage))
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun runAuthFlow(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
return when (effect.payload.string("provider")) {
|
||||
"trakt" -> when (effect.payload.string("mode")) {
|
||||
"deviceCode" -> ok(effect, repository.createTraktDeviceCode())
|
||||
else -> error(effect, "unsupported_auth_mode")
|
||||
}
|
||||
else -> error(effect, "unsupported_auth_provider")
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun exchangeAuthCode(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val payload = effect.payload
|
||||
val profile = payload.profile() ?: return error(effect, "missing_profile")
|
||||
val updated = when (payload.string("provider")) {
|
||||
"trakt" -> {
|
||||
val response = repository.exchangeTraktCode(payload.string("code"))
|
||||
profile.copy(
|
||||
traktAccessToken = response.accessToken,
|
||||
traktRefreshToken = response.refreshToken,
|
||||
traktTokenExpiresAt = TraktIntegration.tokenExpiresAt(response.createdAt, response.expiresIn)
|
||||
)
|
||||
}
|
||||
"traktDevice" -> {
|
||||
val response = repository.exchangeTraktDeviceCode(payload.string("code"))
|
||||
if (!response.isSuccessful) {
|
||||
val errorCode = response.errorBody()?.string()?.let(FluxaCoreNative::traktOAuthErrorCode)
|
||||
return ok(
|
||||
effect,
|
||||
mapOf(
|
||||
"status" to "pending",
|
||||
"errorCode" to (errorCode ?: "http_${response.code()}"),
|
||||
"httpCode" to response.code(),
|
||||
"retryAfterSeconds" to response.headers()["Retry-After"]?.toLongOrNull()
|
||||
)
|
||||
)
|
||||
}
|
||||
val tokenResponse = response.body() ?: return error(effect, "empty_device_token")
|
||||
profile.copy(
|
||||
traktAccessToken = tokenResponse.accessToken,
|
||||
traktRefreshToken = tokenResponse.refreshToken,
|
||||
traktTokenExpiresAt = TraktIntegration.tokenExpiresAt(tokenResponse.createdAt, tokenResponse.expiresIn)
|
||||
)
|
||||
}
|
||||
"mal" -> {
|
||||
val response = repository.exchangeMalCode(payload.string("code"), payload.string("codeVerifier"))
|
||||
profile.copy(
|
||||
malAccessToken = response.accessToken,
|
||||
malRefreshToken = response.refreshToken,
|
||||
malTokenExpiresAt = response.expiresIn?.let { System.currentTimeMillis() + it * 1000L }
|
||||
)
|
||||
}
|
||||
"simkl" -> {
|
||||
val response = repository.exchangeSimklCode(payload.string("code"))
|
||||
profile.copy(simklAccessToken = response.accessToken)
|
||||
}
|
||||
"anilist" -> {
|
||||
val response = repository.exchangeAnilistCode(payload.string("code"))
|
||||
profile.copy(
|
||||
anilistAccessToken = response.accessToken,
|
||||
anilistRefreshToken = response.refreshToken,
|
||||
anilistTokenExpiresAt = response.expiresIn?.let { System.currentTimeMillis() + it * 1000L }
|
||||
)
|
||||
}
|
||||
else -> return error(effect, "unsupported_auth_provider")
|
||||
}
|
||||
return ok(effect, mapOf("profile" to updated))
|
||||
}
|
||||
|
||||
private suspend fun refreshAuthToken(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val payload = effect.payload
|
||||
val profile = payload.profile() ?: return error(effect, "missing_profile")
|
||||
val updated = when (payload.string("provider")) {
|
||||
"trakt" -> refreshTraktTokenIfNeeded(profile)
|
||||
"mal" -> refreshMalTokenIfNeeded(profile)
|
||||
else -> return error(effect, "unsupported_auth_provider")
|
||||
}
|
||||
return ok(effect, mapOf("profile" to updated))
|
||||
}
|
||||
|
||||
private suspend fun refreshTraktTokenIfNeeded(profile: UserProfile): UserProfile {
|
||||
val refreshToken = profile.traktRefreshToken?.takeIf { it.isNotBlank() } ?: return profile
|
||||
val refreshWindowMs = 24L * 60L * 60L * 1000L
|
||||
if (!profile.traktAccessToken.isNullOrBlank() && profile.safeTraktTokenExpiresAt > System.currentTimeMillis() + refreshWindowMs) {
|
||||
return profile
|
||||
}
|
||||
return runCatching {
|
||||
val response = traktRepository.refreshTraktToken(refreshToken)
|
||||
profile.copy(
|
||||
traktAccessToken = response.accessToken,
|
||||
traktRefreshToken = response.refreshToken,
|
||||
traktTokenExpiresAt = TraktIntegration.tokenExpiresAt(response.createdAt, response.expiresIn)
|
||||
)
|
||||
}.getOrElse { throwable ->
|
||||
Log.w("Trakt", "Token refresh failed", throwable)
|
||||
val status = (throwable as? retrofit2.HttpException)?.code()
|
||||
if (status == 400 || status == 401) {
|
||||
profile.copy(traktAccessToken = null, traktRefreshToken = null, traktTokenExpiresAt = null)
|
||||
} else {
|
||||
profile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshMalTokenIfNeeded(profile: UserProfile): UserProfile {
|
||||
val refreshToken = profile.malRefreshToken?.takeIf { it.isNotBlank() } ?: return profile
|
||||
val refreshWindowMs = 24L * 60L * 60L * 1000L
|
||||
if (!profile.malAccessToken.isNullOrBlank() && profile.safeMalTokenExpiresAt > System.currentTimeMillis() + refreshWindowMs) {
|
||||
return profile
|
||||
}
|
||||
return runCatching {
|
||||
val response = repository.refreshMalToken(refreshToken)
|
||||
profile.copy(
|
||||
malAccessToken = response.accessToken,
|
||||
malRefreshToken = response.refreshToken ?: refreshToken,
|
||||
malTokenExpiresAt = response.expiresIn?.let { System.currentTimeMillis() + it * 1000L }
|
||||
)
|
||||
}.getOrElse { throwable ->
|
||||
Log.w("Mal", "Token refresh failed", throwable)
|
||||
val status = (throwable as? retrofit2.HttpException)?.code()
|
||||
if (status == 400 || status == 401) {
|
||||
profile.copy(malAccessToken = null, malRefreshToken = null, malTokenExpiresAt = null)
|
||||
} else {
|
||||
profile
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncExternalIntegration(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val payload = effect.payload
|
||||
val profile = payload.profile() ?: return error(effect, "missing_profile")
|
||||
if (payload.string("provider") == "stremio") {
|
||||
if (profile.authKey.isBlank()) return error(effect, "missing_stremio_token")
|
||||
val addons = repository.getUserAddons(profile.authKey, forceRefresh = true)
|
||||
val library = repository.getLibraryItems(profile.authKey)
|
||||
val updated = profile.copy(localAddons = addons.map { addon -> addon.transportUrl }.distinct())
|
||||
return ok(
|
||||
effect,
|
||||
mapOf(
|
||||
"profile" to updated,
|
||||
"snapshot" to mapOf("addons" to addons, "library" to library),
|
||||
"externalContinueWatching" to library
|
||||
)
|
||||
)
|
||||
}
|
||||
val traktToken = profile.traktAccessToken
|
||||
if (traktToken.isNullOrBlank()) return error(effect, "missing_trakt_token")
|
||||
val language = payload.string("language", profile.safeLanguage)
|
||||
val snapshot = traktRepository.getTraktSyncSnapshot(profile, language)
|
||||
val watchedState = withTimeoutOrNull(8_000L) {
|
||||
traktRepository.getTraktWatchedState(traktToken)
|
||||
}
|
||||
if (watchedState != null) {
|
||||
watchlistManager.replaceExternalWatchedEpisodes("trakt", watchedState.episodeIdsBySeries)
|
||||
watchlistManager.replaceExternalWatchedContentDurations("trakt", watchedState.durationRecords)
|
||||
}
|
||||
val externalItems = repository.getExternalContinueWatching(profile, language)
|
||||
val updated = profile.copy(
|
||||
traktLastSyncAt = System.currentTimeMillis(),
|
||||
traktLastSyncedItems = snapshot.syncedItems,
|
||||
traktLastContinueWatchingCount = snapshot.continueWatchingCount,
|
||||
traktLastWatchlistCount = snapshot.watchlistCount
|
||||
)
|
||||
return ok(
|
||||
effect,
|
||||
mapOf(
|
||||
"profile" to updated,
|
||||
"snapshot" to snapshot,
|
||||
"watchedState" to watchedState,
|
||||
"externalContinueWatching" to externalItems
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun writeSettings(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
return ok(
|
||||
effect,
|
||||
|
|
@ -1270,73 +1098,6 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private suspend fun readCalendarMonth(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val profile = effect.payload.profile()
|
||||
val year = effect.payload.number("year")?.toInt() ?: return error(effect, "missing_year")
|
||||
val month = effect.payload.number("month")?.toInt() ?: return error(effect, "missing_month")
|
||||
val plannedItems = effect.payload.list("plannedItems").mapNotNull { raw ->
|
||||
runCatching { gson.fromJson(gson.toJsonTree(raw), Meta::class.java) }.getOrNull()
|
||||
}
|
||||
val result = EpisodeCalendarLoader(repository, watchlistManager).loadMonth(profile, year, month, plannedItems)
|
||||
return ok(
|
||||
effect,
|
||||
mapOf(
|
||||
"items" to result.items,
|
||||
"localItems" to result.localItems,
|
||||
"externalItems" to result.externalItems
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun replaceExternalContinueWatching(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val items = effect.payload.list("items").mapNotNull { raw ->
|
||||
runCatching { gson.fromJson(gson.toJsonTree(raw), Meta::class.java) }.getOrNull()
|
||||
}
|
||||
watchlistManager.replaceExternalContinueWatching(items)
|
||||
return ok(effect, mapOf("count" to items.size))
|
||||
}
|
||||
|
||||
private fun updateCalendarWidget(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val profile = effect.payload.profile()
|
||||
val items = calendarItems(effect)
|
||||
CalendarWidgetProvider.updateCalendar(
|
||||
context = context,
|
||||
items = items,
|
||||
language = profile?.safeLanguage ?: "en",
|
||||
accentColorArgb = profile?.safeAccentColorArgb ?: 0xFFFFFFFF.toInt()
|
||||
)
|
||||
return ok(effect, mapOf("count" to items.size))
|
||||
}
|
||||
|
||||
private suspend fun notifyReleasedEpisodes(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val profile = effect.payload.profile()
|
||||
val items = calendarItems(effect)
|
||||
EpisodeNotificationHelper.notifyReleasedEpisodes(
|
||||
context = context,
|
||||
profile = profile,
|
||||
items = items,
|
||||
todayIso = ReleaseDateUtils.todayIso()
|
||||
)
|
||||
return ok(effect, mapOf("count" to items.size))
|
||||
}
|
||||
|
||||
private suspend fun enqueueOfflineDownload(effect: NativeHeadlessEffect): HeadlessEffectCompletion {
|
||||
val payload = effect.payload
|
||||
val result = OfflineDownloadManager.getInstance(context).enqueue(
|
||||
profileId = payload.stringOrNull("profileId"),
|
||||
meta = gson.fromJson(gson.toJsonTree(payload["meta"]), Meta::class.java),
|
||||
video = payload.objectValue("video")?.let { gson.fromJson(gson.toJsonTree(it), Video::class.java) },
|
||||
videoId = payload.stringOrNull("videoId"),
|
||||
stream = gson.fromJson(gson.toJsonTree(payload["stream"]), Stream::class.java),
|
||||
subtitle = payload.objectValue("subtitle")?.let { gson.fromJson(gson.toJsonTree(it), OfflineSubtitleOption::class.java) },
|
||||
profileLanguage = payload.stringOrNull("language")
|
||||
)
|
||||
return result.fold(
|
||||
onSuccess = { ok(effect, it) },
|
||||
onFailure = { error(effect, it.message ?: "offline_download_failed") }
|
||||
)
|
||||
}
|
||||
|
||||
internal fun ok(effect: NativeHeadlessEffect, value: Any?): HeadlessEffectCompletion =
|
||||
HeadlessEffectCompletion(effectId = effect.id, status = "ok", value = value)
|
||||
|
||||
|
|
@ -1363,207 +1124,15 @@ class FluxaAndroidHeadlessEnvironment @Inject constructor(
|
|||
private fun com.google.gson.JsonObject.getAsJsonArrayOrNull(key: String): JsonArray? =
|
||||
get(key)?.takeIf { it.isJsonArray }?.asJsonArray
|
||||
|
||||
internal fun calendarItems(effect: NativeHeadlessEffect): List<CalendarUpcomingItem> =
|
||||
effect.payload.list("items").mapNotNull { raw ->
|
||||
runCatching { gson.fromJson(gson.toJsonTree(raw), CalendarUpcomingItem::class.java) }.getOrNull()
|
||||
}
|
||||
|
||||
internal fun isTmdbContentId(id: String): Boolean =
|
||||
id.startsWith("tmdb:", ignoreCase = true) || id.toIntOrNull() != null
|
||||
|
||||
internal suspend fun loadCsNativeMetaDetail(id: String): MetaDetail? {
|
||||
val (apiName, url) = CloudStreamCatalogClient.decodeCsId(id) ?: return null
|
||||
val api = pluginManager.loadedApis.value.firstOrNull { it.name == apiName } ?: run {
|
||||
Log.w("CS3Detail", "Plugin not found: $apiName")
|
||||
return null
|
||||
}
|
||||
val runner = ExternalExtensionRunner()
|
||||
val load = withTimeoutOrNull(20_000L) { runner.loadContent(api, url) } ?: run {
|
||||
Log.w("CS3Detail", "$apiName: loadContent timed out or returned null for url=$url")
|
||||
return null
|
||||
}
|
||||
Log.d("CS3Detail", "$apiName: load type=${load.type}, episodeCount=${load.episodes?.size ?: "null"}")
|
||||
val stremioType = load.type.toStremioType()
|
||||
val videos = load.episodes?.mapIndexed { idx, ep ->
|
||||
Video(
|
||||
id = CloudStreamCatalogClient.encodeCsId(apiName, ep.data),
|
||||
name = ep.name ?: "Episode ${idx + 1}",
|
||||
season = ep.season,
|
||||
number = ep.episode,
|
||||
released = ep.date?.toCs3IsoDate(),
|
||||
thumbnail = ep.posterUrl,
|
||||
overview = ep.description,
|
||||
rating = ep.rating?.let(::cs3RatingString),
|
||||
episodeRuntime = ep.runTime
|
||||
)
|
||||
}
|
||||
Log.d("CS3Detail", "$apiName: built MetaDetail with ${videos?.size ?: "null"} videos")
|
||||
return MetaDetail(
|
||||
id = id,
|
||||
type = stremioType,
|
||||
name = load.title,
|
||||
genres = load.tags,
|
||||
poster = load.posterUrl,
|
||||
background = load.backgroundPosterUrl ?: load.posterUrl,
|
||||
logo = load.logoUrl,
|
||||
description = load.plot,
|
||||
releaseInfo = load.year?.toString(),
|
||||
released = load.year?.let { "$it-01-01" },
|
||||
runtime = load.duration?.let { "${it}m" },
|
||||
videos = videos,
|
||||
trailers = load.trailers?.mapIndexedNotNull { index, trailer -> trailer.toDetailTrailer(apiName, index) },
|
||||
imdbRating = load.rating?.let(::cs3RatingString),
|
||||
ageRating = load.contentRating,
|
||||
ratings = load.rating?.let { listOf(MetaRating("Cloudstream", cs3RatingString(it))) },
|
||||
cast = load.actors?.map { it.toCastMember() },
|
||||
links = load.toMetaLinks(),
|
||||
status = when {
|
||||
load.comingSoon -> "Coming Soon"
|
||||
else -> load.status
|
||||
},
|
||||
originalName = load.synonyms?.firstOrNull { it != load.title },
|
||||
collectionParts = load.recommendations?.mapNotNull { it.toCs3Meta(apiName) }
|
||||
)
|
||||
}
|
||||
internal suspend fun loadCsNativeMetaDetail(id: String): MetaDetail? = cloudStreamRuntime.loadMetaDetail(id)
|
||||
|
||||
private fun Long.toCs3IsoDate(): String {
|
||||
val millis = if (this > 10_000_000_000L) this else this * 1000L
|
||||
return java.time.Instant.ofEpochMilli(millis)
|
||||
.atZone(java.time.ZoneOffset.UTC)
|
||||
.toLocalDate()
|
||||
.toString()
|
||||
}
|
||||
internal suspend fun loadCsNativeStreams(id: String, directTimeoutMs: Long = 30_000L): List<Stream> =
|
||||
cloudStreamRuntime.loadStreams(id, directTimeoutMs)
|
||||
|
||||
private fun cs3RatingString(rating: Int): String =
|
||||
"%.1f".format(java.util.Locale.US, rating.toFloat() / 10f)
|
||||
|
||||
private fun ScraperActor.toCastMember(): CastMember =
|
||||
CastMember(name = name, character = role, profilePath = image)
|
||||
|
||||
private fun ScraperTrailer.toDetailTrailer(apiName: String, index: Int): DetailTrailer? {
|
||||
val targetUrl = url.takeIf { it.isNotBlank() } ?: return null
|
||||
return DetailTrailer(
|
||||
id = "cs3:$apiName:trailer:$index",
|
||||
title = "Trailer ${index + 1}",
|
||||
type = if (raw) "Trailer" else "Extractor",
|
||||
url = targetUrl,
|
||||
thumbnail = null,
|
||||
source = apiName
|
||||
)
|
||||
}
|
||||
|
||||
private fun ScraperSearchResult.toCs3Meta(apiName: String): Meta? {
|
||||
val title = title.takeIf { it.isNotBlank() } ?: return null
|
||||
return Meta(
|
||||
id = CloudStreamCatalogClient.encodeCsId(apiName, url),
|
||||
name = title,
|
||||
type = type?.toStremioType() ?: "movie",
|
||||
poster = posterUrl,
|
||||
releaseInfo = year?.toString(),
|
||||
imdbRating = quality,
|
||||
background = posterUrl
|
||||
)
|
||||
}
|
||||
|
||||
private fun ScraperLoadResult.toMetaLinks(): List<MetaLink>? {
|
||||
val links = mutableListOf<MetaLink>()
|
||||
uniqueUrl?.takeIf { it.isNotBlank() }?.let { links.add(MetaLink("Source", "Cloudstream", it)) }
|
||||
url.takeIf { it.isNotBlank() && it != uniqueUrl }?.let { links.add(MetaLink("Page", "Cloudstream", it)) }
|
||||
syncData.orEmpty().forEach { (key, value) ->
|
||||
if (key.isNotBlank() && value.isNotBlank()) links.add(MetaLink(key, "Cloudstream Sync", value))
|
||||
}
|
||||
synonyms.orEmpty().forEach { synonym ->
|
||||
links.add(MetaLink(synonym, "Cloudstream Synonym", synonym))
|
||||
}
|
||||
nextAiringUnixTime?.let { unix ->
|
||||
val label = listOfNotNull(
|
||||
nextAiringSeason?.let { "S$it" },
|
||||
nextAiringEpisode?.let { "E$it" }
|
||||
).joinToString("").ifBlank { "Next airing" }
|
||||
links.add(MetaLink(label, "Cloudstream Next Airing", unix.toString()))
|
||||
}
|
||||
seasonNames.orEmpty().forEach { season ->
|
||||
val name = season.name?.takeIf { it.isNotBlank() } ?: "Season ${season.displaySeason ?: season.season}"
|
||||
links.add(MetaLink(name, "Cloudstream Season", season.season.toString()))
|
||||
}
|
||||
return links.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
internal suspend fun loadCsNativeStreams(id: String, directTimeoutMs: Long = 30_000L): List<Stream> {
|
||||
val (apiName, data) = CloudStreamCatalogClient.decodeCsId(id) ?: return emptyList()
|
||||
val api = pluginManager.loadedApis.value.firstOrNull { it.name == apiName } ?: return emptyList()
|
||||
val runner = ExternalExtensionRunner()
|
||||
|
||||
val directResult = try {
|
||||
withTimeoutOrNull(directTimeoutMs) { runner.loadStreams(api, data) }
|
||||
} catch (_: Throwable) { null }
|
||||
if (directResult != null && directResult.links.isNotEmpty()) {
|
||||
return directResult.links
|
||||
.sortedByDescending { csQualityScore(it.quality) }
|
||||
.map { link ->
|
||||
Stream(
|
||||
name = " $apiName\n${link.quality}",
|
||||
title = link.name,
|
||||
url = link.url,
|
||||
subtitles = directResult.subtitles.map { it.toSubtitleData() },
|
||||
behaviorHints = buildMap {
|
||||
put("proxyHeaders", buildMap { put("request", link.headers) })
|
||||
link.referer?.let { put("referer", it) }
|
||||
put("cs3Type", link.type)
|
||||
put("isM3u8", link.isM3u8)
|
||||
put("isDash", link.isDash)
|
||||
},
|
||||
addonName = " $apiName"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val streamData = try {
|
||||
withTimeoutOrNull(15_000L) { runner.loadContent(api, data)?.data }
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
} ?: data
|
||||
val result = withTimeoutOrNull(30_000L) {
|
||||
runner.loadStreams(api, streamData)
|
||||
} ?: return emptyList()
|
||||
return result.links
|
||||
.sortedByDescending { csQualityScore(it.quality) }
|
||||
.map { link ->
|
||||
Stream(
|
||||
name = " $apiName\n${link.quality}",
|
||||
title = link.name,
|
||||
url = link.url,
|
||||
subtitles = result.subtitles.map { it.toSubtitleData() },
|
||||
behaviorHints = buildMap {
|
||||
put("proxyHeaders", buildMap { put("request", link.headers) })
|
||||
link.referer?.let { put("referer", it) }
|
||||
put("cs3Type", link.type)
|
||||
put("isM3u8", link.isM3u8)
|
||||
put("isDash", link.isDash)
|
||||
},
|
||||
addonName = " $apiName"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ScraperSubtitle.toSubtitleData() = SubtitleData(
|
||||
url = url,
|
||||
lang = lang
|
||||
)
|
||||
|
||||
internal fun csQualityScore(quality: String): Int {
|
||||
val q = quality.lowercase()
|
||||
return when {
|
||||
q.contains("4k") || q.contains("2160") -> 2160
|
||||
q.contains("1440") -> 1440
|
||||
q.contains("1080") -> 1080
|
||||
q.contains("720") -> 720
|
||||
q.contains("480") -> 480
|
||||
q.contains("360") -> 360
|
||||
q.contains("240") -> 240
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
internal fun csQualityScore(quality: String): Int = cloudStreamRuntime.qualityScore(quality)
|
||||
|
||||
internal suspend fun buildPlaybackStreamRequestIds(
|
||||
type: String,
|
||||
|
|
|
|||
|
|
@ -13,36 +13,6 @@ import com.fluxa.app.ui.catalog.horizontalCardWidth
|
|||
import com.fluxa.app.ui.catalog.posterCardHeight
|
||||
import com.fluxa.app.ui.catalog.posterCardWidth
|
||||
|
||||
const val CONTINUE_WATCHING_CATEGORY_ID = "continue_watching"
|
||||
|
||||
fun HomeCategory.isContinueWatchingCategory(): Boolean = id == CONTINUE_WATCHING_CATEGORY_ID
|
||||
|
||||
fun Meta.matchesFilter(filter: String): Boolean = when (filter) {
|
||||
"movie" -> type == "movie"
|
||||
"series" -> type == "series" || type == "tv" || type == "anime"
|
||||
else -> true
|
||||
}
|
||||
|
||||
fun orderHomeCategories(categories: List<HomeCategory>, filter: String = "all"): List<HomeCategory> {
|
||||
return categories
|
||||
.mapNotNull { category ->
|
||||
val items = when {
|
||||
category.isContinueWatchingCategory() || category.id == "library" -> {
|
||||
if (filter == "all") category.items else {
|
||||
category.items.filter { it.matchesFilter(filter) }.ifEmpty { category.items }
|
||||
}
|
||||
}
|
||||
filter == "all" -> category.items
|
||||
else -> category.items.filter { it.matchesFilter(filter) }
|
||||
}
|
||||
when {
|
||||
items.isEmpty() && category.type != "collection_folder" -> null
|
||||
items === category.items -> category
|
||||
else -> category.copy(items = items)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveHomeCardLayout(category: HomeCategory, profile: UserProfile?): String {
|
||||
return if (category.isContinueWatchingCategory()) {
|
||||
profile?.resolvedContinueWatchingLayout ?: "horizontal"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.fluxa.app.common.AppStrings
|
|||
import com.fluxa.app.data.local.LibraryUserCollection
|
||||
import com.fluxa.app.data.local.LibraryUserCollectionFolder
|
||||
import com.fluxa.app.data.local.OfflineDownloadManager
|
||||
import com.fluxa.app.data.local.isPlayable
|
||||
import com.fluxa.app.data.local.ProfileManager
|
||||
import com.fluxa.app.data.local.UserProfile
|
||||
import com.fluxa.app.data.local.WatchlistStore
|
||||
|
|
@ -73,7 +74,7 @@ class AndroidLibraryDataSource(
|
|||
}
|
||||
|
||||
val profileDownloads = downloads.filter { it.profileId == null || it.profileId == profile?.id }
|
||||
val downloadGroups = profileDownloads.toOfflineDownloadGroups().map { group ->
|
||||
val downloadGroups = profileDownloads.toOfflineDownloadGroups(String::toFileImageModel).map { group ->
|
||||
LibraryDownloadGroupUiModel(
|
||||
key = group.key,
|
||||
title = group.title,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.core.rust.FluxaCoreNative
|
||||
import com.fluxa.app.data.remote.Stream
|
||||
import com.fluxa.app.player.StreamSelectionRequest
|
||||
import com.fluxa.app.player.StreamSourceSelectionPolicy
|
||||
|
||||
internal object AndroidStreamSourceSelectionPolicy : StreamSourceSelectionPolicy {
|
||||
override fun select(request: StreamSelectionRequest): Int = FluxaCoreNative.selectStreamIndex(
|
||||
streams = request.streams,
|
||||
currentVideoId = request.currentVideoId,
|
||||
initialStreamIndex = request.initialStreamIndex,
|
||||
savedUrl = request.savedUrl,
|
||||
savedTitle = request.savedTitle,
|
||||
sourceSelectionMode = request.sourceSelectionMode,
|
||||
regexPattern = request.regexPattern,
|
||||
preferredBingeGroup = request.preferredBingeGroup
|
||||
)
|
||||
}
|
||||
|
||||
internal fun selectStreamIndex(
|
||||
streams: List<Stream>,
|
||||
currentVideoId: String?,
|
||||
initialStreamIndex: Int,
|
||||
savedUrl: String?,
|
||||
savedTitle: String?,
|
||||
sourceSelectionMode: String,
|
||||
regexPattern: String?,
|
||||
preferredBingeGroup: String?
|
||||
): Int {
|
||||
return AndroidStreamSourceSelectionPolicy.select(
|
||||
StreamSelectionRequest(
|
||||
streams,
|
||||
currentVideoId,
|
||||
initialStreamIndex,
|
||||
savedUrl,
|
||||
savedTitle,
|
||||
sourceSelectionMode,
|
||||
regexPattern,
|
||||
preferredBingeGroup
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.common.AppStrings
|
||||
import java.util.Locale
|
||||
|
||||
internal data class ChoiceOption(val value: String, val label: String)
|
||||
|
||||
internal fun languageDisplayName(language: String, lang: String): String = when (language.lowercase()) {
|
||||
"none", "", "__off__" -> AppStrings.t(lang, "settings.none")
|
||||
"forced" -> AppStrings.t(lang, "settings.forced")
|
||||
"original" -> AppStrings.t(lang, "settings.original")
|
||||
"device_language" -> AppStrings.t(lang, "settings.device_language")
|
||||
"tr", "tr-tr" -> Locale.forLanguageTag("tr").getDisplayLanguage(Locale.forLanguageTag("tr"))
|
||||
"en", "en-us" -> "English"
|
||||
else -> Locale.forLanguageTag(language).getDisplayLanguage(Locale.forLanguageTag(language)).takeIf { it.isNotBlank() } ?: language
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.player.STREAM_SOURCE_MODE_FIRST
|
||||
import com.fluxa.app.player.STREAM_SOURCE_MODE_MANUAL
|
||||
|
||||
import com.fluxa.app.common.AppStrings
|
||||
import com.fluxa.app.data.local.*
|
||||
import com.fluxa.app.data.remote.*
|
||||
|
|
|
|||
|
|
@ -1,47 +1,69 @@
|
|||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import android.util.Log
|
||||
import com.fluxa.app.core.rust.FluxaCoreNative
|
||||
import com.fluxa.app.core.rust.NativeHeadlessEngineResult
|
||||
import com.fluxa.app.data.local.UserProfile
|
||||
import com.fluxa.app.data.remote.TraktDeviceCodeResponse
|
||||
import com.fluxa.app.data.repository.StremioRepository
|
||||
import com.fluxa.app.data.repository.TraktIntegration
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal class HomeAuthCoordinator(
|
||||
private val repository: StremioRepository,
|
||||
private val scope: CoroutineScope,
|
||||
private val gson: Gson,
|
||||
private val dispatch: suspend (Any) -> NativeHeadlessEngineResult,
|
||||
private val activeProfile: () -> UserProfile?,
|
||||
private val updateActiveProfile: (UserProfile) -> Unit,
|
||||
private val invalidateHome: () -> Unit
|
||||
) {
|
||||
fun exchangeTraktCode(
|
||||
fun refreshTokenIfNeeded(
|
||||
provider: String,
|
||||
profile: UserProfile,
|
||||
onProfileUpdated: (UserProfile) -> Unit
|
||||
) {
|
||||
scope.launch {
|
||||
val result = dispatch(
|
||||
mapOf(
|
||||
"type" to "authRefreshRequested",
|
||||
"provider" to provider,
|
||||
"profile" to profile
|
||||
)
|
||||
)
|
||||
updatedProfile(result)?.takeIf { it != profile }?.let { updated ->
|
||||
accept(updated, onProfileUpdated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun exchangeCode(
|
||||
provider: String,
|
||||
code: String,
|
||||
codeVerifier: String?,
|
||||
onProfileUpdated: (UserProfile) -> Unit,
|
||||
onComplete: (Boolean) -> Unit
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val response = repository.exchangeTraktCode(code)
|
||||
activeProfile()?.let { profile ->
|
||||
val updated = profile.copy(
|
||||
traktAccessToken = response.accessToken,
|
||||
traktRefreshToken = response.refreshToken,
|
||||
traktTokenExpiresAt = TraktIntegration.tokenExpiresAt(response.createdAt, response.expiresIn)
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
onProfileUpdated(updated)
|
||||
invalidateHome()
|
||||
onComplete(true)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
withContext(Dispatchers.Main) { onComplete(false) }
|
||||
scope.launch {
|
||||
val profile = activeProfile()
|
||||
if (profile == null) {
|
||||
onComplete(false)
|
||||
return@launch
|
||||
}
|
||||
val result = dispatch(
|
||||
mapOf(
|
||||
"type" to "authExchangeRequested",
|
||||
"provider" to provider,
|
||||
"code" to code,
|
||||
"codeVerifier" to codeVerifier,
|
||||
"profile" to profile
|
||||
)
|
||||
)
|
||||
val updated = updatedProfile(result)
|
||||
if (updated == null) {
|
||||
onComplete(false)
|
||||
return@launch
|
||||
}
|
||||
accept(updated, onProfileUpdated)
|
||||
onComplete(true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -50,112 +72,86 @@ internal class HomeAuthCoordinator(
|
|||
onProfileUpdated: (UserProfile) -> Unit,
|
||||
onComplete: (Boolean, String?) -> Unit
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val codeResponse = repository.createTraktDeviceCode()
|
||||
withContext(Dispatchers.Main) { onCodeReady(codeResponse) }
|
||||
val startedAt = System.currentTimeMillis()
|
||||
val expiresAt = startedAt + codeResponse.expiresIn * 1000L
|
||||
var intervalMs = codeResponse.interval.coerceAtLeast(5) * 1000L
|
||||
var failureMessageKey: String? = "toast.trakt_connect_failed"
|
||||
while (System.currentTimeMillis() < expiresAt) {
|
||||
delay(intervalMs)
|
||||
val response = repository.exchangeTraktDeviceCode(codeResponse.deviceCode)
|
||||
if (response.isSuccessful) {
|
||||
val tokenResponse = response.body() ?: break
|
||||
activeProfile()?.let { profile ->
|
||||
val updated = profile.copy(
|
||||
traktAccessToken = tokenResponse.accessToken,
|
||||
traktRefreshToken = tokenResponse.refreshToken,
|
||||
traktTokenExpiresAt = TraktIntegration.tokenExpiresAt(tokenResponse.createdAt, tokenResponse.expiresIn)
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
onProfileUpdated(updated)
|
||||
invalidateHome()
|
||||
onComplete(true, null)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
scope.launch {
|
||||
val profile = activeProfile()
|
||||
if (profile == null) {
|
||||
onComplete(false, "toast.trakt_connect_failed")
|
||||
return@launch
|
||||
}
|
||||
val codeResult = dispatch(
|
||||
mapOf(
|
||||
"type" to "authFlowRequested",
|
||||
"provider" to "trakt",
|
||||
"mode" to "deviceCode"
|
||||
)
|
||||
)
|
||||
val codeResponse = decode<TraktDeviceCodeResponse>(
|
||||
(codeResult.state["auth"] as? Map<*, *>)?.get("result")
|
||||
)
|
||||
if (codeResponse == null) {
|
||||
onComplete(false, "toast.trakt_connect_failed")
|
||||
return@launch
|
||||
}
|
||||
onCodeReady(codeResponse)
|
||||
val expiresAt = System.currentTimeMillis() + codeResponse.expiresIn * 1000L
|
||||
var intervalMs = codeResponse.interval.coerceAtLeast(5) * 1000L
|
||||
var failureMessageKey = "toast.trakt_connect_failed"
|
||||
while (System.currentTimeMillis() < expiresAt) {
|
||||
delay(intervalMs)
|
||||
val tokenResult = dispatch(
|
||||
mapOf(
|
||||
"type" to "authExchangeRequested",
|
||||
"provider" to "traktDevice",
|
||||
"code" to codeResponse.deviceCode,
|
||||
"profile" to (activeProfile() ?: profile)
|
||||
)
|
||||
)
|
||||
val authResult = (tokenResult.state["auth"] as? Map<*, *>)?.get("result") as? Map<*, *>
|
||||
val updated = decode<UserProfile>(authResult?.get("profile"))
|
||||
if (updated != null) {
|
||||
accept(updated, onProfileUpdated)
|
||||
onComplete(true, null)
|
||||
return@launch
|
||||
}
|
||||
val errorCode = authResult?.get("errorCode") as? String
|
||||
val httpCode = (authResult?.get("httpCode") as? Number)?.toInt()
|
||||
when {
|
||||
errorCode == "slow_down" || httpCode == 429 -> {
|
||||
val retryAfterMs = (authResult.get("retryAfterSeconds") as? Number)?.toLong()?.times(1000L)
|
||||
intervalMs = (retryAfterMs ?: intervalMs + 5_000L).coerceAtMost(60_000L)
|
||||
}
|
||||
errorCode == "expired_token" || errorCode == "invalid_grant" -> {
|
||||
failureMessageKey = "toast.trakt_device_code_expired"
|
||||
break
|
||||
}
|
||||
val errorCode = response.errorBody()?.string()?.let(FluxaCoreNative::traktOAuthErrorCode)
|
||||
when {
|
||||
errorCode == "slow_down" || response.code() == 429 -> {
|
||||
val retryAfterMs = response.headers()["Retry-After"]?.toLongOrNull()?.let { it * 1000L }
|
||||
intervalMs = (retryAfterMs ?: (intervalMs + 5_000L)).coerceAtMost(60_000L)
|
||||
continue
|
||||
}
|
||||
errorCode == "expired_token" || errorCode == "invalid_grant" -> {
|
||||
Log.w("Trakt", "Device auth expired: $errorCode")
|
||||
failureMessageKey = "toast.trakt_device_code_expired"
|
||||
break
|
||||
}
|
||||
errorCode == "authorization_pending" || response.code() in setOf(400, 404, 409, 428) -> {
|
||||
continue
|
||||
}
|
||||
else -> {
|
||||
Log.w("Trakt", "Device auth failed: HTTP ${response.code()} ${errorCode.orEmpty()}")
|
||||
break
|
||||
}
|
||||
}
|
||||
errorCode == "authorization_pending" || httpCode in setOf(400, 404, 409, 428) -> Unit
|
||||
else -> break
|
||||
}
|
||||
if (System.currentTimeMillis() >= expiresAt) {
|
||||
failureMessageKey = "toast.trakt_device_code_expired"
|
||||
}
|
||||
withContext(Dispatchers.Main) { onComplete(false, failureMessageKey) }
|
||||
} catch (e: Exception) {
|
||||
Log.w("Trakt", "Device authorization failed", e)
|
||||
withContext(Dispatchers.Main) { onComplete(false, "toast.trakt_connect_failed") }
|
||||
}
|
||||
if (System.currentTimeMillis() >= expiresAt) {
|
||||
failureMessageKey = "toast.trakt_device_code_expired"
|
||||
}
|
||||
onComplete(false, failureMessageKey)
|
||||
}
|
||||
}
|
||||
|
||||
fun exchangeMalCode(
|
||||
code: String,
|
||||
codeVerifier: String,
|
||||
onProfileUpdated: (UserProfile) -> Unit,
|
||||
onComplete: (Boolean) -> Unit
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val response = repository.exchangeMalCode(code, codeVerifier)
|
||||
activeProfile()?.let { profile ->
|
||||
val updated = profile.copy(
|
||||
malAccessToken = response.accessToken,
|
||||
malRefreshToken = response.refreshToken
|
||||
)
|
||||
withContext(Dispatchers.Main) {
|
||||
onProfileUpdated(updated)
|
||||
onComplete(true)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
withContext(Dispatchers.Main) { onComplete(false) }
|
||||
}
|
||||
}
|
||||
private fun accept(profile: UserProfile, onProfileUpdated: (UserProfile) -> Unit) {
|
||||
updateActiveProfile(profile)
|
||||
onProfileUpdated(profile)
|
||||
invalidateHome()
|
||||
}
|
||||
|
||||
fun exchangeSimklCode(
|
||||
code: String,
|
||||
onProfileUpdated: (UserProfile) -> Unit,
|
||||
onComplete: (Boolean) -> Unit
|
||||
) {
|
||||
scope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val response = repository.exchangeSimklCode(code)
|
||||
activeProfile()?.let { profile ->
|
||||
val updated = profile.copy(simklAccessToken = response.accessToken)
|
||||
withContext(Dispatchers.Main) {
|
||||
onProfileUpdated(updated)
|
||||
onComplete(true)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
withContext(Dispatchers.Main) { onComplete(false) }
|
||||
}
|
||||
}
|
||||
private fun updatedProfile(result: NativeHeadlessEngineResult): UserProfile? {
|
||||
val auth = result.state["auth"] as? Map<*, *>
|
||||
val authResult = auth?.get("result")
|
||||
val value = (authResult as? Map<*, *>)?.get("profile")
|
||||
?: authResult
|
||||
?: (result.state["profile"] as? Map<*, *>)?.get("active")
|
||||
return decode(value)
|
||||
}
|
||||
|
||||
private inline fun <reified T> decode(value: Any?): T? {
|
||||
if (value == null) return null
|
||||
return runCatching { gson.fromJson(gson.toJsonTree(value), T::class.java) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.player.TrailerResolveResult
|
||||
|
||||
import android.util.LruCache
|
||||
import com.fluxa.app.data.local.UserProfile
|
||||
import com.fluxa.app.data.local.WatchlistManager
|
||||
|
|
|
|||
|
|
@ -1,95 +0,0 @@
|
|||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import android.content.Context
|
||||
import com.fluxa.app.common.ReleaseDateUtils
|
||||
import com.fluxa.app.core.rust.FluxaCoreStateHandle
|
||||
import com.fluxa.app.data.local.*
|
||||
import com.fluxa.app.data.local.UserProfile
|
||||
import com.fluxa.app.data.local.WatchlistManager
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal data class HomeCalendarSnapshot(
|
||||
val localItems: List<Meta>,
|
||||
val externalItems: List<Meta>
|
||||
)
|
||||
|
||||
internal class HomeCalendarController(
|
||||
private val context: Context,
|
||||
private val episodeCalendarLoader: EpisodeCalendarLoader,
|
||||
private val watchlistManager: WatchlistManager,
|
||||
private val scope: CoroutineScope,
|
||||
private val activeProfile: () -> UserProfile?,
|
||||
private val setActiveProfile: (UserProfile?) -> Unit,
|
||||
private val onSnapshotLoaded: (HomeCalendarSnapshot) -> Unit,
|
||||
private val coreState: FluxaCoreStateHandle
|
||||
) {
|
||||
private val gson = Gson()
|
||||
private val _items = MutableStateFlow<List<CalendarUpcomingItem>>(emptyList())
|
||||
val items: StateFlow<List<CalendarUpcomingItem>> = _items.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
fun loadMonth(profile: UserProfile?, year: Int, month: Int) {
|
||||
setActiveProfile(profile ?: activeProfile())
|
||||
scope.launch(Dispatchers.IO) {
|
||||
dispatchCalendar("setCalendarLoading", true)
|
||||
try {
|
||||
val calendarProfile = profile ?: activeProfile()
|
||||
val result = episodeCalendarLoader.loadMonth(calendarProfile, year, month)
|
||||
if (result.externalItems.isNotEmpty()) {
|
||||
watchlistManager.replaceExternalContinueWatching(result.externalItems)
|
||||
}
|
||||
onSnapshotLoaded(
|
||||
HomeCalendarSnapshot(
|
||||
localItems = result.localItems,
|
||||
externalItems = result.externalItems
|
||||
)
|
||||
)
|
||||
dispatchCalendar("setCalendarItems", result.items)
|
||||
CalendarWidgetProvider.updateCalendar(
|
||||
context = context,
|
||||
items = result.items,
|
||||
language = calendarProfile?.safeLanguage ?: "en",
|
||||
accentColorArgb = calendarProfile?.safeAccentColorArgb ?: 0xFFFFFFFF.toInt()
|
||||
)
|
||||
EpisodeNotificationHelper.notifyReleasedEpisodes(
|
||||
context = context,
|
||||
profile = profile,
|
||||
items = result.items,
|
||||
todayIso = ReleaseDateUtils.todayIso()
|
||||
)
|
||||
} finally {
|
||||
dispatchCalendar("setCalendarLoading", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchCalendar(type: String, value: Any?) {
|
||||
val snapshotJson = coreState.dispatch(CoreAction(type = type, value = value))
|
||||
val calendar = gson.fromJson(snapshotJson, CoreStateSnapshot::class.java)?.calendar ?: return
|
||||
_items.value = calendar.items
|
||||
_isLoading.value = calendar.isLoading
|
||||
}
|
||||
|
||||
private data class CoreAction(
|
||||
val type: String,
|
||||
val value: Any?
|
||||
)
|
||||
|
||||
private data class CoreStateSnapshot(
|
||||
val calendar: CoreCalendarSnapshot = CoreCalendarSnapshot()
|
||||
)
|
||||
|
||||
private data class CoreCalendarSnapshot(
|
||||
val items: List<CalendarUpcomingItem> = emptyList(),
|
||||
val isLoading: Boolean = false
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.core.rust.NativeHeadlessEngineResult
|
||||
import com.fluxa.app.data.local.*
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
|
||||
internal class HomeCatalogPagingCoordinator(
|
||||
private val scope: CoroutineScope,
|
||||
private val platformContentGateway: HomePlatformContentGateway,
|
||||
private val activeProfile: () -> UserProfile?,
|
||||
private val categories: () -> List<HomeCategory>,
|
||||
private val setCategories: (List<HomeCategory>) -> Unit,
|
||||
private val folderCategories: () -> Map<String, HomeCategory>,
|
||||
private val setFolderCategories: (Map<String, HomeCategory>) -> Unit,
|
||||
private val normalizeItems: suspend (List<Meta>, String, String, String?) -> List<Meta>,
|
||||
private val dispatch: suspend (Any) -> NativeHeadlessEngineResult,
|
||||
private val decodeItems: suspend (Any?) -> List<Meta>
|
||||
) {
|
||||
private val inFlight = mutableSetOf<String>()
|
||||
|
||||
fun loadMore(categoryId: String) {
|
||||
val category = categories().firstOrNull { it.id == categoryId } ?: folderCategories()[categoryId] ?: return
|
||||
if (!category.canLoadMore || !inFlight.add(categoryId)) return
|
||||
scope.launch {
|
||||
try {
|
||||
val catalogSources = category.catalogSources.orEmpty()
|
||||
val remoteSources = category.remoteSources.orEmpty()
|
||||
if (catalogSources.isNotEmpty() || remoteSources.isNotEmpty()) {
|
||||
loadCollectionPage(category, catalogSources, remoteSources)
|
||||
} else {
|
||||
loadCatalogPage(category)
|
||||
}
|
||||
} finally {
|
||||
if (category.catalogSources.orEmpty().isNotEmpty() || category.remoteSources.orEmpty().isNotEmpty()) {
|
||||
update(categoryId) { it.copy(folderSourcesLoading = false) }
|
||||
}
|
||||
inFlight.remove(categoryId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadCollectionPage(
|
||||
category: HomeCategory,
|
||||
catalogSources: List<HomeCatalogSource>,
|
||||
remoteSources: List<LibraryRemoteSource>
|
||||
) {
|
||||
update(category.id) { it.copy(folderSourcesLoading = true) }
|
||||
val nextSkip = if (category.items.isEmpty()) 0 else category.skip + 20
|
||||
val language = activeProfile()?.safeLanguage ?: "en"
|
||||
val sourceResults = Channel<Pair<HomeCatalogSource, List<Meta>>>(catalogSources.size)
|
||||
coroutineScope {
|
||||
val semaphore = Semaphore(4)
|
||||
catalogSources.forEach { source ->
|
||||
launch(Dispatchers.IO) {
|
||||
semaphore.withPermit {
|
||||
val items = platformContentGateway.addonCatalog(
|
||||
source.transportUrl,
|
||||
source.type,
|
||||
source.catalogId,
|
||||
nextSkip,
|
||||
source.genre
|
||||
)
|
||||
sourceResults.send(source to normalizeItems(items, source.catalogId, language, source.genre))
|
||||
}
|
||||
}
|
||||
}
|
||||
repeat(catalogSources.size) {
|
||||
val (source, items) = sourceResults.receive()
|
||||
val sourceMap = items.flatMap { listOf("${it.type}:${it.id}" to source, it.id to source) }.toMap()
|
||||
update(category.id) { existing ->
|
||||
existing.copy(
|
||||
items = (existing.items + items).distinctBy { "${it.type}:${it.id}" },
|
||||
resultSources = existing.resultSources + sourceMap
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val remoteItems = if (remoteSources.isEmpty()) emptyList() else fetchPage(
|
||||
category,
|
||||
skip = nextSkip,
|
||||
transportUrl = null,
|
||||
remoteSources = remoteSources
|
||||
)
|
||||
update(category.id) { existing ->
|
||||
existing.copy(
|
||||
items = if (remoteItems.isEmpty()) existing.items else (existing.items + remoteItems).distinctBy { "${it.type}:${it.id}" },
|
||||
skip = nextSkip,
|
||||
canLoadMore = existing.items.isNotEmpty() || remoteItems.isNotEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun loadCatalogPage(category: HomeCategory) {
|
||||
val items = fetchPage(
|
||||
category,
|
||||
skip = category.skip + category.items.size,
|
||||
transportUrl = category.addonTransportUrl,
|
||||
remoteSources = category.remoteSources.orEmpty()
|
||||
)
|
||||
update(category.id) { existing ->
|
||||
existing.copy(
|
||||
items = if (items.isEmpty()) existing.items else (existing.items + items).distinctBy { "${it.type}:${it.id}" },
|
||||
canLoadMore = items.isNotEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchPage(
|
||||
category: HomeCategory,
|
||||
skip: Int,
|
||||
transportUrl: String?,
|
||||
remoteSources: List<LibraryRemoteSource>
|
||||
): List<Meta> {
|
||||
val result = dispatch(
|
||||
mapOf(
|
||||
"type" to "catalogPageRequested",
|
||||
"categoryId" to category.id,
|
||||
"transportUrl" to transportUrl,
|
||||
"contentType" to category.type,
|
||||
"catalogId" to category.catalogId,
|
||||
"skip" to skip,
|
||||
"genre" to category.addonGenre,
|
||||
"search" to null,
|
||||
"remoteSource" to remoteSources,
|
||||
"profile" to activeProfile()
|
||||
)
|
||||
)
|
||||
val home = result.state["home"] as? Map<*, *> ?: return emptyList()
|
||||
val paging = home["paging"] as? Map<*, *> ?: return emptyList()
|
||||
return decodeItems(paging["items"])
|
||||
}
|
||||
|
||||
private fun update(categoryId: String, transform: (HomeCategory) -> HomeCategory) {
|
||||
val hidden = folderCategories()[categoryId]
|
||||
if (hidden != null) {
|
||||
setFolderCategories(folderCategories() + (categoryId to transform(hidden)))
|
||||
} else {
|
||||
setCategories(categories().map { if (it.id == categoryId) transform(it) else it })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import android.util.Log
|
||||
import com.fluxa.app.data.local.*
|
||||
import com.fluxa.app.domain.discovery.buildCs3MetadataFeedOptions
|
||||
import com.fluxa.app.domain.discovery.effectiveHomeMetadataFeedSelection
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal class HomeCloudStreamCoordinator(
|
||||
private val scope: CoroutineScope,
|
||||
private val gateway: HomePlatformContentGateway,
|
||||
private val hasLoadedHome: StateFlow<Boolean>,
|
||||
private val activeProfile: () -> UserProfile?,
|
||||
private val categories: () -> List<HomeCategory>,
|
||||
private val setCategories: (List<HomeCategory>) -> Unit,
|
||||
private val billboardIsEmpty: () -> Boolean,
|
||||
private val refreshBillboard: suspend (UserProfile?) -> Unit
|
||||
) {
|
||||
private var refreshJob: Job? = null
|
||||
|
||||
fun bind() {
|
||||
scope.launch {
|
||||
gateway.loadedApis
|
||||
.map { apis -> apis.toCs3CatalogFeedDescriptors().map { "${it.pluginName}:${it.catalogIndex}:${it.catalogName}" }.toSet() }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
refresh()
|
||||
if (billboardIsEmpty()) scope.launch { refreshBillboard(activeProfile()) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
refreshJob?.cancel()
|
||||
refreshJob = scope.launch {
|
||||
try {
|
||||
hasLoadedHome.first { it }
|
||||
val profile = activeProfile()
|
||||
val toggles = profile?.homeFeedToggles
|
||||
val apis = gateway.loadedApis.value.filter { it.hasMainPage }
|
||||
val feedOptions = buildCs3MetadataFeedOptions(apis.toCs3CatalogFeedDescriptors())
|
||||
val enabledKeys = when {
|
||||
toggles == null || profile.cs3FeedsConfigured != true -> null
|
||||
toggles.any { it.startsWith("cs3_catalog_") } -> effectiveHomeMetadataFeedSelection(toggles, feedOptions.map { it.key })?.toSet()
|
||||
toggles.any { it.startsWith("cs3_plugin_") } -> toggles.toSet()
|
||||
else -> null
|
||||
}
|
||||
if (apis.isEmpty()) {
|
||||
val current = categories()
|
||||
val retained = current.filterNot { it.id.startsWith("cs3_") }
|
||||
if (retained.size != current.size) setCategories(retained)
|
||||
return@launch
|
||||
}
|
||||
val icons = gateway.installedPlugins.value
|
||||
.mapNotNull { plugin -> plugin.iconUrl?.takeIf { it.isNotBlank() }?.let { plugin.name to it } }
|
||||
.toMap()
|
||||
val rows = gateway.cloudHomeCategories(apis, icons, enabledKeys)
|
||||
if (!isActive) return@launch
|
||||
val rowsById = rows.associateBy { it.id }
|
||||
val current = categories()
|
||||
val existingIds = current.filter { it.id.startsWith("cs3_") }.mapTo(mutableSetOf()) { it.id }
|
||||
val merged = current.mapNotNull { category -> if (category.id.startsWith("cs3_")) rowsById[category.id] else category }
|
||||
setCategories(merged + rows.filter { it.id !in existingIds })
|
||||
} catch (error: Throwable) {
|
||||
Log.w("HomeCloudStream", "Refresh failed", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,129 +0,0 @@
|
|||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.common.AppStrings
|
||||
import com.fluxa.app.core.rust.FluxaCoreStateHandle
|
||||
import com.fluxa.app.data.local.*
|
||||
import com.fluxa.app.data.local.UserProfile
|
||||
import com.fluxa.app.data.remote.AddonDescriptor
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.fluxa.app.data.repository.AddonRepository
|
||||
import com.fluxa.app.domain.discovery.DiscoverCatalogContentLoader
|
||||
import com.fluxa.app.domain.discovery.DiscoverCatalogOption
|
||||
import com.fluxa.app.domain.discovery.DiscoverRequest
|
||||
import com.fluxa.app.domain.discovery.buildDiscoverCatalogOptions
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.Locale
|
||||
|
||||
internal class HomeDiscoverController(
|
||||
private val addonRepository: AddonRepository,
|
||||
private val discoverCatalogContentLoader: DiscoverCatalogContentLoader,
|
||||
private val scope: CoroutineScope,
|
||||
private val activeProfile: () -> UserProfile?,
|
||||
private val userAddons: () -> List<AddonDescriptor>,
|
||||
private val setUserAddons: (List<AddonDescriptor>) -> Unit,
|
||||
private val normalizeCatalogItems: suspend (List<Meta>, String, String, String?) -> List<Meta>,
|
||||
private val coreState: FluxaCoreStateHandle
|
||||
) {
|
||||
private val gson = Gson()
|
||||
private val _results = MutableStateFlow<List<Meta>>(emptyList())
|
||||
val results: StateFlow<List<Meta>> = _results.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _genres = MutableStateFlow<List<DiscoverGenreOption>>(emptyList())
|
||||
val genres: StateFlow<List<DiscoverGenreOption>> = _genres.asStateFlow()
|
||||
|
||||
private val _catalogs = MutableStateFlow<List<DiscoverCatalogOption>>(emptyList())
|
||||
val catalogs: StateFlow<List<DiscoverCatalogOption>> = _catalogs.asStateFlow()
|
||||
|
||||
fun discover(type: String, catalogKey: String?, genre: String?, year: String?, rating: Float?, provider: String?, region: String?) {
|
||||
scope.launch {
|
||||
dispatchDiscover("setDiscoverLoading", true)
|
||||
try {
|
||||
val language = activeProfile()?.safeLanguage ?: "en"
|
||||
val results = discoverCatalogContentLoader.discover(
|
||||
request = DiscoverRequest(
|
||||
type = type,
|
||||
catalogKey = catalogKey,
|
||||
genre = genre,
|
||||
year = year,
|
||||
rating = rating,
|
||||
provider = provider,
|
||||
region = region
|
||||
),
|
||||
catalogOptions = _catalogs.value
|
||||
) { items, _, catalogId, selectedGenre ->
|
||||
normalizeCatalogItems(items, catalogId, language, selectedGenre)
|
||||
}
|
||||
dispatchDiscover("setDiscoverResults", results)
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
} finally {
|
||||
dispatchDiscover("setDiscoverLoading", false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearGenres() {
|
||||
scope.launch {
|
||||
dispatchDiscover("setDiscoverGenres", emptyList<DiscoverGenreOption>())
|
||||
}
|
||||
}
|
||||
|
||||
fun loadCatalogFilters(type: String, selectedCatalogKey: String?) {
|
||||
scope.launch {
|
||||
val profile = activeProfile()
|
||||
val language = profile?.safeLanguage ?: "en"
|
||||
val allLabel = AppStrings.t(language, "auto.all")
|
||||
val addons = userAddons().ifEmpty {
|
||||
addonRepository.getUserAddons(profile?.authKey.orEmpty(), profile?.safeLocalAddons)
|
||||
.also(setUserAddons)
|
||||
}
|
||||
val catalogOptions = buildDiscoverCatalogOptions(addons, type)
|
||||
dispatchDiscover("setDiscoverCatalogs", catalogOptions)
|
||||
val selectedCatalog = catalogOptions.firstOrNull { it.key == selectedCatalogKey }
|
||||
val selectedGenres = selectedCatalog?.genres.orEmpty()
|
||||
.distinct()
|
||||
.sortedBy { it.lowercase(Locale.ROOT) }
|
||||
.map { DiscoverGenreOption(it, it) }
|
||||
val genres = if (selectedCatalog == null || selectedGenres.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
val includeAll = !selectedCatalog.requiresGenre
|
||||
if (includeAll) listOf(DiscoverGenreOption(null, allLabel)) + selectedGenres else selectedGenres
|
||||
}
|
||||
dispatchDiscover("setDiscoverGenres", genres)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchDiscover(type: String, value: Any?) {
|
||||
val snapshotJson = coreState.dispatch(CoreAction(type = type, value = value))
|
||||
val snapshot = gson.fromJson(snapshotJson, CoreStateSnapshot::class.java)?.discover ?: return
|
||||
_results.value = snapshot.results
|
||||
_isLoading.value = snapshot.isLoading
|
||||
_genres.value = snapshot.genres
|
||||
_catalogs.value = snapshot.catalogs
|
||||
}
|
||||
|
||||
private data class CoreAction(
|
||||
val type: String,
|
||||
val value: Any?
|
||||
)
|
||||
|
||||
private data class CoreStateSnapshot(
|
||||
val discover: CoreDiscoverSnapshot = CoreDiscoverSnapshot()
|
||||
)
|
||||
|
||||
private data class CoreDiscoverSnapshot(
|
||||
val results: List<Meta> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val genres: List<DiscoverGenreOption> = emptyList(),
|
||||
val catalogs: List<DiscoverCatalogOption> = emptyList()
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.core.rust.NativeHeadlessEngineResult
|
||||
import com.fluxa.app.data.local.*
|
||||
import com.fluxa.app.data.remote.AddonDescriptor
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.fluxa.app.domain.discovery.DiscoverCatalogOption
|
||||
import com.fluxa.app.domain.discovery.buildDiscoverCatalogOptions
|
||||
import com.fluxa.app.domain.discovery.buildDiscoverContentTypes
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal class HomeHeadlessBrowseCoordinator(
|
||||
private val scope: CoroutineScope,
|
||||
private val gson: Gson,
|
||||
private val dispatch: suspend (Any) -> NativeHeadlessEngineResult,
|
||||
private val activeProfile: () -> UserProfile?,
|
||||
private val userAddons: () -> List<AddonDescriptor>
|
||||
) {
|
||||
private val metaListType = object : TypeToken<List<Meta>>() {}.type
|
||||
private val calendarItemListType = object : TypeToken<List<CalendarUpcomingItem>>() {}.type
|
||||
private val catalogListType = object : TypeToken<List<DiscoverCatalogOption>>() {}.type
|
||||
private val genreListType = object : TypeToken<List<DiscoverGenreOption>>() {}.type
|
||||
private val contentTypeListType = object : TypeToken<List<String>>() {}.type
|
||||
private val sourceMapType = object : TypeToken<Map<String, HomeCatalogSource>>() {}.type
|
||||
private val results = MutableStateFlow<List<Meta>>(emptyList())
|
||||
private val resultSources = MutableStateFlow<Map<String, HomeCatalogSource>>(emptyMap())
|
||||
private val loading = MutableStateFlow(false)
|
||||
private val genres = MutableStateFlow<List<DiscoverGenreOption>>(emptyList())
|
||||
private val catalogs = MutableStateFlow<List<DiscoverCatalogOption>>(emptyList())
|
||||
private val contentTypes = MutableStateFlow<List<String>>(emptyList())
|
||||
private val calendarItems = MutableStateFlow<List<CalendarUpcomingItem>>(emptyList())
|
||||
private val calendarLoading = MutableStateFlow(false)
|
||||
private var discoverJob: Job? = null
|
||||
|
||||
val discoverUiState: StateFlow<DiscoverUiState> = combine(
|
||||
combine(results, resultSources, loading) { items, sources, pending -> Triple(items, sources, pending) },
|
||||
genres,
|
||||
catalogs,
|
||||
contentTypes
|
||||
) { (items, sources, pending), genreOptions, catalogOptions, types ->
|
||||
DiscoverUiState(items, pending, genreOptions, catalogOptions, types, sources)
|
||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), DiscoverUiState())
|
||||
|
||||
val discoverGenres: StateFlow<List<DiscoverGenreOption>> = genres.asStateFlow()
|
||||
|
||||
val calendarUiState: StateFlow<CalendarUiState> = combine(calendarItems, calendarLoading) { items, pending ->
|
||||
CalendarUiState(items, pending)
|
||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), CalendarUiState())
|
||||
|
||||
fun clearResults() {
|
||||
results.value = emptyList()
|
||||
}
|
||||
|
||||
fun catalogOptions(type: String): List<DiscoverCatalogOption> = buildDiscoverCatalogOptions(userAddons(), type)
|
||||
|
||||
fun availableContentTypes(): List<String> = buildDiscoverContentTypes(userAddons())
|
||||
|
||||
fun setLoading(value: Boolean) {
|
||||
loading.value = value
|
||||
}
|
||||
|
||||
fun discover(type: String, catalogKey: String?, genre: String?, year: String?, rating: Float?, provider: String?, region: String?) {
|
||||
discoverJob?.cancel()
|
||||
discoverJob = scope.launch {
|
||||
loading.value = true
|
||||
try {
|
||||
val profile = activeProfile()
|
||||
val result = dispatch(
|
||||
mapOf(
|
||||
"type" to "discoverRequested",
|
||||
"contentType" to type,
|
||||
"filters" to mapOf(
|
||||
"catalogKey" to catalogKey,
|
||||
"genre" to genre,
|
||||
"year" to year,
|
||||
"rating" to rating,
|
||||
"provider" to provider,
|
||||
"region" to region
|
||||
),
|
||||
"profile" to profile,
|
||||
"language" to (profile?.safeLanguage ?: "en")
|
||||
)
|
||||
)
|
||||
val state = result.state["discover"] as? Map<*, *>
|
||||
results.value = decodeList(state?.get("results"), metaListType)
|
||||
resultSources.value = decodeObject(state?.get("resultSources"), sourceMapType) ?: emptyMap()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMore(transportUrl: String, contentType: String, catalogId: String, genre: String?) {
|
||||
if (loading.value) return
|
||||
scope.launch {
|
||||
loading.value = true
|
||||
try {
|
||||
val result = dispatch(
|
||||
mapOf(
|
||||
"type" to "discoverPageRequested",
|
||||
"transportUrl" to transportUrl,
|
||||
"contentType" to contentType,
|
||||
"catalogId" to catalogId,
|
||||
"skip" to results.value.size,
|
||||
"genre" to genre
|
||||
)
|
||||
)
|
||||
val state = result.state["discover"] as? Map<*, *>
|
||||
val updated = decodeList<Meta>(state?.get("results"), metaListType)
|
||||
val source = HomeCatalogSource(transportUrl, catalogId, contentType, genre)
|
||||
val sources = resultSources.value.toMutableMap()
|
||||
updated.forEach { item ->
|
||||
sources.putIfAbsent("${item.type}:${item.id}", source)
|
||||
sources.putIfAbsent(item.id, source)
|
||||
}
|
||||
results.value = updated
|
||||
resultSources.value = sources
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadCalendar(profile: UserProfile?, year: Int, month: Int, plannedItems: List<Meta>) {
|
||||
scope.launch {
|
||||
calendarLoading.value = true
|
||||
try {
|
||||
val result = dispatch(
|
||||
mapOf(
|
||||
"type" to "calendarMonthRequested",
|
||||
"profile" to profile,
|
||||
"year" to year,
|
||||
"month" to month,
|
||||
"plannedItems" to plannedItems
|
||||
)
|
||||
)
|
||||
val state = result.state["calendar"] as? Map<*, *>
|
||||
calendarItems.value = decodeList(state?.get("items"), calendarItemListType)
|
||||
} finally {
|
||||
calendarLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clearGenres() {
|
||||
genres.value = emptyList()
|
||||
}
|
||||
|
||||
fun loadFilters(type: String, selectedCatalogKey: String?, onLoaded: ((List<DiscoverCatalogOption>) -> Unit)?) {
|
||||
scope.launch {
|
||||
val profile = activeProfile()
|
||||
val result = dispatch(
|
||||
mapOf(
|
||||
"type" to "discoverCatalogFiltersRequested",
|
||||
"contentType" to type,
|
||||
"selectedCatalogKey" to selectedCatalogKey,
|
||||
"profile" to profile,
|
||||
"language" to (profile?.safeLanguage ?: "en")
|
||||
)
|
||||
)
|
||||
val state = result.state["discover"] as? Map<*, *> ?: return@launch
|
||||
val updatedCatalogs = decodeList<DiscoverCatalogOption>(state["catalogs"], catalogListType)
|
||||
catalogs.value = updatedCatalogs
|
||||
genres.value = decodeList(state["genres"], genreListType)
|
||||
contentTypes.value = decodeList(state["contentTypes"], contentTypeListType)
|
||||
onLoaded?.invoke(updatedCatalogs)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> decodeList(value: Any?, type: java.lang.reflect.Type): List<T> = withContext(Dispatchers.Default) {
|
||||
if (value == null) emptyList() else runCatching { gson.fromJson<List<T>>(gson.toJson(value), type) }.getOrDefault(emptyList())
|
||||
}
|
||||
|
||||
private suspend fun <T> decodeObject(value: Any?, type: java.lang.reflect.Type): T? = withContext(Dispatchers.Default) {
|
||||
if (value == null) null else runCatching { gson.fromJson<T>(gson.toJson(value), type) }.getOrNull()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.core.rust.NativeHeadlessEngineResult
|
||||
import com.fluxa.app.data.local.*
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal class HomeHeadlessSyncCoordinator(
|
||||
private val scope: CoroutineScope,
|
||||
private val gson: Gson,
|
||||
private val dispatch: suspend (Any) -> NativeHeadlessEngineResult,
|
||||
private val setActiveProfile: (UserProfile) -> Unit,
|
||||
private val setWatchlist: (List<Meta>) -> Unit,
|
||||
private val setContinueWatching: (List<Meta>) -> Unit,
|
||||
private val setExternalContinueWatching: (List<Meta>) -> Unit,
|
||||
private val setLiked: (List<Meta>) -> Unit,
|
||||
private val refreshDynamicRows: () -> Unit
|
||||
) {
|
||||
private val metaListType = object : TypeToken<List<Meta>>() {}.type
|
||||
|
||||
fun loadLibrary(profile: UserProfile?) {
|
||||
scope.launch {
|
||||
val result = dispatch(mapOf("type" to "libraryHydrateRequested", "profileId" to profile?.id))
|
||||
val library = result.state["library"] as? Map<*, *> ?: return@launch
|
||||
setWatchlist(decodeList(library["watchlist"]))
|
||||
setContinueWatching(decodeList(library["continueWatching"]))
|
||||
setLiked(decodeList(library["liked"]))
|
||||
refreshDynamicRows()
|
||||
}
|
||||
}
|
||||
|
||||
fun syncTrakt(profile: UserProfile, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) {
|
||||
syncIntegration("trakt", profile, onProfileUpdated, onComplete)
|
||||
}
|
||||
|
||||
fun syncStremio(profile: UserProfile, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) {
|
||||
syncIntegration("stremio", profile, onProfileUpdated, onComplete)
|
||||
}
|
||||
|
||||
fun syncNuvio(
|
||||
profile: UserProfile,
|
||||
onProfileUpdated: (UserProfile) -> Unit,
|
||||
onComplete: (Boolean) -> Unit,
|
||||
onSynced: (UserProfile) -> Unit
|
||||
) {
|
||||
scope.launch {
|
||||
val result = dispatch(
|
||||
mapOf("type" to "externalSyncRequested", "provider" to "nuvio", "profile" to profile, "language" to profile.safeLanguage)
|
||||
)
|
||||
val sync = result.state["sync"] as? Map<*, *>
|
||||
val updated = decodeProfile(
|
||||
sync?.get("profile")
|
||||
?: (sync?.get("snapshot") as? Map<*, *>)?.get("profile")
|
||||
?: (result.state["profile"] as? Map<*, *>)?.get("active")
|
||||
)
|
||||
if (updated != null) {
|
||||
setActiveProfile(updated)
|
||||
onProfileUpdated(updated)
|
||||
onSynced(updated)
|
||||
}
|
||||
onComplete(sync?.get("error") == null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun syncIntegration(
|
||||
provider: String,
|
||||
profile: UserProfile,
|
||||
onProfileUpdated: (UserProfile) -> Unit,
|
||||
onComplete: (Boolean) -> Unit
|
||||
) {
|
||||
scope.launch {
|
||||
val result = dispatch(
|
||||
mapOf(
|
||||
"type" to "externalIntegrationSyncRequested",
|
||||
"provider" to provider,
|
||||
"profile" to profile,
|
||||
"language" to profile.safeLanguage
|
||||
)
|
||||
)
|
||||
val sync = result.state["sync"] as? Map<*, *>
|
||||
val snapshot = sync?.get("snapshot") as? Map<*, *>
|
||||
val updated = decodeProfile(snapshot?.get("profile") ?: (result.state["profile"] as? Map<*, *>)?.get("active"))
|
||||
if (updated != null) {
|
||||
setActiveProfile(updated)
|
||||
setExternalContinueWatching(decodeList((result.state["home"] as? Map<*, *>)?.get("externalContinueWatching")))
|
||||
onProfileUpdated(updated)
|
||||
refreshDynamicRows()
|
||||
}
|
||||
onComplete(sync?.get("error") == null && updated != null)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun decodeProfile(value: Any?): UserProfile? = withContext(Dispatchers.Default) {
|
||||
if (value == null) null else runCatching { gson.fromJson(gson.toJsonTree(value), UserProfile::class.java) }.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun decodeList(value: Any?): List<Meta> = withContext(Dispatchers.Default) {
|
||||
if (value == null) emptyList() else runCatching { gson.fromJson<List<Meta>>(gson.toJsonTree(value), metaListType) }.getOrDefault(emptyList())
|
||||
}
|
||||
}
|
||||
|
|
@ -46,8 +46,7 @@ object HomeRowRankingPolicy {
|
|||
val json = FluxaCoreNative.optimizeHomeRowsJson(gson.toJson(request))
|
||||
val optimized = gson.fromJson<List<HomeCategory>>(json, homeCategoryListType) ?: emptyList()
|
||||
return optimized.map { category ->
|
||||
val original = originalItemsById[category.id]
|
||||
if (original != null) category.copy(items = original.items) else category
|
||||
originalItemsById[category.id] ?: category
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,8 +72,7 @@ object HomeRowRankingPolicy {
|
|||
)
|
||||
val result = gson.fromJson<List<HomeCategory>>(json, homeCategoryListType) ?: emptyList()
|
||||
return result.map { category ->
|
||||
val original = originalItemsById[category.id]
|
||||
if (original != null) category.copy(items = original.items) else category
|
||||
originalItemsById[category.id] ?: category
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,42 +0,0 @@
|
|||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.data.local.*
|
||||
import com.fluxa.app.data.local.UserProfile
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.fluxa.app.data.repository.AddonRepository
|
||||
|
||||
internal data class HomeSearchResults(
|
||||
val flatItems: List<Meta>,
|
||||
val rows: List<SearchResultRow>
|
||||
)
|
||||
|
||||
internal class HomeSearchCoordinator(
|
||||
private val addonRepository: AddonRepository,
|
||||
private val historyStore: SearchHistoryStore
|
||||
) {
|
||||
suspend fun search(query: String, profile: UserProfile?, filter: (List<Meta>) -> List<Meta>): HomeSearchResults {
|
||||
val normalizedQuery = query.trim()
|
||||
val rows = addonRepository.searchRows(
|
||||
query = normalizedQuery,
|
||||
language = profile?.safeLanguage ?: "en",
|
||||
authKey = profile?.authKey.orEmpty(),
|
||||
localAddons = profile?.safeLocalAddons.orEmpty()
|
||||
)
|
||||
val results = rows.flatMap { it.items }.distinctBy { it.id }.take(80)
|
||||
return HomeSearchResults(
|
||||
flatItems = filter(results),
|
||||
rows = rows.mapNotNull { row ->
|
||||
val filteredItems = filter(row.items)
|
||||
if (filteredItems.isEmpty()) null else row.copy(items = filteredItems)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun addToHistory(meta: Meta, current: List<Meta>, profile: UserProfile?): List<Meta> {
|
||||
val updated = current.toMutableList()
|
||||
val existingIndex = updated.indexOfFirst { it.id == meta.id || it.name.equals(meta.name, ignoreCase = true) }
|
||||
if (existingIndex != -1) updated.removeAt(existingIndex)
|
||||
updated.add(0, meta.copy(description = null, cast = null, ratings = null, awards = null))
|
||||
return updated.take(10).also { historyStore.save(it, profile) }
|
||||
}
|
||||
}
|
||||
|
|
@ -9,16 +9,12 @@ import com.fluxa.app.core.rust.FluxaCoreNative
|
|||
import com.fluxa.app.core.rust.FluxaCoreStateHandle
|
||||
import com.fluxa.app.core.rust.FluxaHeadlessRuntimeFactory
|
||||
import com.fluxa.app.domain.discovery.DiscoverCatalogOption
|
||||
import com.fluxa.app.domain.discovery.Cs3CatalogFeedDescriptor
|
||||
import com.fluxa.app.domain.discovery.MetadataFeedOption
|
||||
import com.fluxa.app.domain.discovery.buildDiscoverCatalogOptions
|
||||
import com.fluxa.app.domain.discovery.buildDiscoverContentTypes
|
||||
import com.fluxa.app.domain.discovery.buildCs3MetadataFeedOptions
|
||||
import com.fluxa.app.domain.discovery.effectiveHomeMetadataFeedSelection
|
||||
import com.fluxa.app.domain.discovery.isMetadataFeedEnabled
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -40,13 +36,8 @@ import java.util.Locale
|
|||
|
||||
import com.fluxa.app.plugins.PluginManager
|
||||
import com.fluxa.app.data.repository.CloudStreamCatalogClient
|
||||
import com.lagradost.cloudstream3.MainAPI
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
@ -74,13 +65,9 @@ class HomeViewModel @Inject constructor(
|
|||
private val streamListType = object : TypeToken<List<Stream>>() {}.type
|
||||
private val trailerListType = object : TypeToken<List<DetailTrailer>>() {}.type
|
||||
private val categoryListType = object : TypeToken<List<HomeCategory>>() {}.type
|
||||
private val calendarItemListType = object : TypeToken<List<CalendarUpcomingItem>>() {}.type
|
||||
private val videoListType = object : TypeToken<List<Video>>() {}.type
|
||||
private val subtitleListType = object : TypeToken<List<SubtitleData>>() {}.type
|
||||
private val introTimestampsListType = object : TypeToken<List<IntroTimestamps>>() {}.type
|
||||
private val discoverCatalogListType = object : TypeToken<List<DiscoverCatalogOption>>() {}.type
|
||||
private val discoverGenreListType = object : TypeToken<List<DiscoverGenreOption>>() {}.type
|
||||
private val discoverContentTypeListType = object : TypeToken<List<String>>() {}.type
|
||||
private val addonListType = object : TypeToken<List<AddonDescriptor>>() {}.type
|
||||
private val headlessRuntime = FluxaHeadlessRuntimeFactory.createUniFfi(headlessEnvironment)
|
||||
private val initialSearchHistory = searchHistoryStore.load(null)
|
||||
|
|
@ -109,10 +96,9 @@ class HomeViewModel @Inject constructor(
|
|||
"library" to mapOf("uiState" to LibraryUiState())
|
||||
)
|
||||
)
|
||||
private val _categories = MutableStateFlow<List<HomeCategory>>(emptyList())
|
||||
val categories: StateFlow<List<HomeCategory>> = _categories
|
||||
private val _collectionFolderCategories = MutableStateFlow<Map<String, HomeCategory>>(emptyMap())
|
||||
val collectionFolderCategories: StateFlow<Map<String, HomeCategory>> = _collectionFolderCategories.asStateFlow()
|
||||
private val categoryState = HomeCategoryStateStore()
|
||||
val categories: StateFlow<List<HomeCategory>> = categoryState.categories
|
||||
val collectionFolderCategories: StateFlow<Map<String, HomeCategory>> = categoryState.folderCategories
|
||||
|
||||
var savedHomeScrollIndex: Int = 0
|
||||
var savedHomeScrollOffset: Int = 0
|
||||
|
|
@ -120,7 +106,6 @@ class HomeViewModel @Inject constructor(
|
|||
var savedTvHomeScrollOffset: Int = 0
|
||||
var savedTvFocusedRowIndex: Int = -1
|
||||
val savedCategoryScrollPositions: HashMap<String, Pair<Int, Int>> = HashMap()
|
||||
private var categoriesRenderSignature: Int? = null
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading
|
||||
|
|
@ -140,38 +125,32 @@ class HomeViewModel @Inject constructor(
|
|||
val searchResults: StateFlow<List<Meta>> = searchFocusState.searchResults
|
||||
val searchRows: StateFlow<List<SearchResultRow>> = searchFocusState.searchRows
|
||||
|
||||
private val _headlessDiscoverResults = MutableStateFlow<List<Meta>>(emptyList())
|
||||
private val _headlessDiscoverResultSources = MutableStateFlow<Map<String, HomeCatalogSource>>(emptyMap())
|
||||
private val _headlessDiscoverLoading = MutableStateFlow(false)
|
||||
private val _headlessDiscoverGenres = MutableStateFlow<List<DiscoverGenreOption>>(emptyList())
|
||||
private val _headlessDiscoverCatalogs = MutableStateFlow<List<DiscoverCatalogOption>>(emptyList())
|
||||
private val _headlessDiscoverContentTypes = MutableStateFlow<List<String>>(emptyList())
|
||||
private val discoverResultSourceMapType = object : TypeToken<Map<String, HomeCatalogSource>>() {}.type
|
||||
private var discoverJob: Job? = null
|
||||
private val browseCoordinator by lazy {
|
||||
HomeHeadlessBrowseCoordinator(
|
||||
scope = viewModelScope,
|
||||
gson = gson,
|
||||
dispatch = ::dispatchHeadless,
|
||||
activeProfile = { currentActiveProfile },
|
||||
userAddons = { _userAddons.value }
|
||||
)
|
||||
}
|
||||
val discoverUiState: StateFlow<DiscoverUiState> get() = browseCoordinator.discoverUiState
|
||||
val discoverGenres: StateFlow<List<DiscoverGenreOption>> get() = browseCoordinator.discoverGenres
|
||||
val calendarUiState: StateFlow<CalendarUiState> get() = browseCoordinator.calendarUiState
|
||||
|
||||
val discoverUiState: StateFlow<DiscoverUiState> = combine(
|
||||
combine(
|
||||
_headlessDiscoverResults,
|
||||
_headlessDiscoverResultSources,
|
||||
_headlessDiscoverLoading
|
||||
) { results, resultSources, loading -> Triple(results, resultSources, loading) },
|
||||
_headlessDiscoverGenres,
|
||||
_headlessDiscoverCatalogs,
|
||||
_headlessDiscoverContentTypes
|
||||
) { (results, resultSources, loading), genres, catalogs, contentTypes ->
|
||||
DiscoverUiState(results, loading, genres, catalogs, contentTypes, resultSources)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DiscoverUiState())
|
||||
|
||||
val discoverGenres: StateFlow<List<DiscoverGenreOption>> get() = _headlessDiscoverGenres.asStateFlow()
|
||||
|
||||
private val _headlessCalendarItems = MutableStateFlow<List<CalendarUpcomingItem>>(emptyList())
|
||||
private val _headlessCalendarLoading = MutableStateFlow(false)
|
||||
|
||||
val calendarUiState: StateFlow<CalendarUiState> = combine(
|
||||
_headlessCalendarItems,
|
||||
_headlessCalendarLoading
|
||||
) { items, loading -> CalendarUiState(items, loading) }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), CalendarUiState())
|
||||
private val syncCoordinator by lazy {
|
||||
HomeHeadlessSyncCoordinator(
|
||||
scope = viewModelScope,
|
||||
gson = gson,
|
||||
dispatch = ::dispatchHeadless,
|
||||
setActiveProfile = { setActiveProfileState(it) },
|
||||
setWatchlist = ::setWatchlistState,
|
||||
setContinueWatching = ::setCurrentWatchlistState,
|
||||
setExternalContinueWatching = ::setExternalContinueWatchingState,
|
||||
setLiked = ::setLikedItemsState,
|
||||
refreshDynamicRows = ::refreshDynamicRows
|
||||
)
|
||||
}
|
||||
|
||||
private val billboardState = HomeBillboardStateHolder()
|
||||
val billboardError: StateFlow<String?> = billboardState.error
|
||||
|
|
@ -264,7 +243,20 @@ class HomeViewModel @Inject constructor(
|
|||
private var searchJob: Job? = null
|
||||
private val _isSearchLoading = MutableStateFlow(false)
|
||||
val isSearchLoading: StateFlow<Boolean> = _isSearchLoading.asStateFlow()
|
||||
private val loadMoreInFlight = mutableSetOf<String>()
|
||||
private val pagingCoordinator by lazy {
|
||||
HomeCatalogPagingCoordinator(
|
||||
scope = viewModelScope,
|
||||
platformContentGateway = platformContentGateway,
|
||||
activeProfile = { currentActiveProfile },
|
||||
categories = categoryState::currentCategories,
|
||||
setCategories = ::setCategoriesState,
|
||||
folderCategories = categoryState::currentFolderCategories,
|
||||
setFolderCategories = categoryState::replaceFolderCategories,
|
||||
normalizeItems = ::normalizeCatalogItems,
|
||||
dispatch = ::dispatchHeadless,
|
||||
decodeItems = { fromStateList(it, metaListType) }
|
||||
)
|
||||
}
|
||||
private val playbackController by lazy {
|
||||
coordinatorFactory.playback(
|
||||
context = appContext,
|
||||
|
|
@ -311,7 +303,7 @@ class HomeViewModel @Inject constructor(
|
|||
setPool = { billboardState.poolValue = it },
|
||||
index = { billboardState.indexValue },
|
||||
setIndex = { billboardState.indexValue = it },
|
||||
categories = { _categories.value },
|
||||
categories = categoryState::currentCategories,
|
||||
language = { currentActiveProfile?.safeLanguage ?: "en" },
|
||||
setMovie = { billboardState.movieValue = it },
|
||||
setLogo = { billboardState.logoValue = it },
|
||||
|
|
@ -361,7 +353,7 @@ class HomeViewModel @Inject constructor(
|
|||
continueWatchingItems = ::buildContinueWatchingItems,
|
||||
normalizeCatalogItems = ::normalizeCatalogItems,
|
||||
setCategories = ::setCategoriesState,
|
||||
currentCategories = { _categories.value }
|
||||
currentCategories = categoryState::currentCategories
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -380,7 +372,7 @@ class HomeViewModel @Inject constructor(
|
|||
private val dynamicRowsCoordinator by lazy {
|
||||
HomeDynamicRowsCoordinator(
|
||||
scope = viewModelScope,
|
||||
categories = { _categories.value },
|
||||
categories = categoryState::currentCategories,
|
||||
setCategories = ::setCategoriesAndCache,
|
||||
activeProfile = { currentActiveProfile },
|
||||
buildUserCollectionHomeCategories = ::buildUserCollectionHomeCategories,
|
||||
|
|
@ -389,6 +381,17 @@ class HomeViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private val authCoordinator by lazy {
|
||||
HomeAuthCoordinator(
|
||||
scope = viewModelScope,
|
||||
gson = gson,
|
||||
dispatch = ::dispatchHeadless,
|
||||
activeProfile = { currentActiveProfile },
|
||||
updateActiveProfile = ::setActiveProfileState,
|
||||
invalidateHome = { setCategoriesState(emptyList()) }
|
||||
)
|
||||
}
|
||||
|
||||
private val watchlistFlowBinder by lazy {
|
||||
HomeWatchlistFlowBinder(
|
||||
watchlistStore = watchlistStore,
|
||||
|
|
@ -402,77 +405,26 @@ class HomeViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
private val cloudStreamCoordinator by lazy {
|
||||
HomeCloudStreamCoordinator(
|
||||
scope = viewModelScope,
|
||||
gateway = platformContentGateway,
|
||||
hasLoadedHome = _hasLoadedHome,
|
||||
activeProfile = { currentActiveProfile },
|
||||
categories = categoryState::currentCategories,
|
||||
setCategories = ::setCategoriesAndCache,
|
||||
billboardIsEmpty = { billboardState.poolValue.isEmpty() },
|
||||
refreshBillboard = billboardLoader::load
|
||||
)
|
||||
}
|
||||
|
||||
init {
|
||||
watchlistFlowBinder.bind()
|
||||
observeCloudStreamPlugins()
|
||||
cloudStreamCoordinator.bind()
|
||||
}
|
||||
|
||||
private var cs3FetchJob: Job? = null
|
||||
|
||||
private fun scheduleCs3Refresh() {
|
||||
cs3FetchJob?.cancel()
|
||||
cs3FetchJob = viewModelScope.launch {
|
||||
try {
|
||||
_hasLoadedHome.first { it }
|
||||
val profile = currentActiveProfile
|
||||
val homeFeedToggles = profile?.homeFeedToggles
|
||||
val cs3FeedsConfigured = profile?.cs3FeedsConfigured == true
|
||||
val apis = platformContentGateway.loadedApis.value
|
||||
.filter { it.hasMainPage }
|
||||
val cs3FeedOptions = buildCs3MetadataFeedOptions(apis.toCs3CatalogFeedDescriptors())
|
||||
val enabledCs3FeedKeys = if (homeFeedToggles == null || !cs3FeedsConfigured) {
|
||||
null
|
||||
} else if (homeFeedToggles.any { it.startsWith("cs3_catalog_") }) {
|
||||
effectiveHomeMetadataFeedSelection(homeFeedToggles, cs3FeedOptions.map { it.key })?.toSet()
|
||||
} else if (homeFeedToggles.any { it.startsWith("cs3_plugin_") }) {
|
||||
homeFeedToggles.toSet()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
android.util.Log.d("HomeViewModel", "CS3 refresh: ${apis.size} APIs: ${apis.map { it.name }}")
|
||||
if (apis.isEmpty()) {
|
||||
val withoutCs3 = _categories.value.filter { !it.id.startsWith("cs3_") }
|
||||
if (withoutCs3.size != _categories.value.size) setCategoriesAndCache(withoutCs3)
|
||||
return@launch
|
||||
}
|
||||
val iconsByApiName = platformContentGateway.installedPlugins.value
|
||||
.filter { !it.iconUrl.isNullOrBlank() }
|
||||
.associate { it.name to it.iconUrl!! }
|
||||
val cs3Rows = platformContentGateway.cloudHomeCategories(
|
||||
apis = apis,
|
||||
iconsByApiName = iconsByApiName,
|
||||
enabledFeedKeys = enabledCs3FeedKeys
|
||||
)
|
||||
android.util.Log.d("HomeViewModel", "CS3 refresh: got ${cs3Rows.size} rows from ${apis.size} APIs")
|
||||
if (!isActive) return@launch
|
||||
val cs3RowsById = cs3Rows.associateBy { it.id }
|
||||
val currentCategories = _categories.value
|
||||
val existingCs3Ids = currentCategories.filter { it.id.startsWith("cs3_") }.map { it.id }.toSet()
|
||||
val merged = currentCategories.mapNotNull { cat ->
|
||||
if (cat.id.startsWith("cs3_")) cs3RowsById[cat.id] else cat
|
||||
}
|
||||
val newCs3Rows = cs3Rows.filter { it.id !in existingCs3Ids }
|
||||
setCategoriesAndCache(merged + newCs3Rows)
|
||||
} catch (t: Throwable) {
|
||||
android.util.Log.w("HomeViewModel", "CS3 refresh failed", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeCloudStreamPlugins() {
|
||||
viewModelScope.launch {
|
||||
platformContentGateway.loadedApis
|
||||
.map { apis -> apis.toCs3CatalogFeedDescriptors().map { "${it.pluginName}:${it.catalogIndex}:${it.catalogName}" }.toSet() }
|
||||
.distinctUntilChanged()
|
||||
.collect {
|
||||
scheduleCs3Refresh()
|
||||
if (billboardState.poolValue.isEmpty()) {
|
||||
viewModelScope.launch {
|
||||
billboardLoader.load(currentActiveProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cloudStreamCoordinator.refresh()
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
|
|
@ -710,7 +662,7 @@ class HomeViewModel @Inject constructor(
|
|||
"initialStreamIndex" to initialStreamIndex,
|
||||
"savedUrl" to savedUrl,
|
||||
"savedTitle" to savedTitle,
|
||||
"sourceSelectionMode" to (profile?.safeStreamSourceSelectionMode ?: STREAM_SOURCE_MODE_MANUAL),
|
||||
"sourceSelectionMode" to (profile?.safeStreamSourceSelectionMode ?: com.fluxa.app.player.STREAM_SOURCE_MODE_MANUAL),
|
||||
"regexPattern" to profile?.safeStreamSourceRegexPattern,
|
||||
"preferredBingeGroup" to preferredBingeGroup,
|
||||
"title" to meta.name,
|
||||
|
|
@ -928,230 +880,36 @@ class HomeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun loadMore(categoryId: String) {
|
||||
val category = _categories.value.firstOrNull { it.id == categoryId }
|
||||
?: _collectionFolderCategories.value[categoryId]
|
||||
?: return
|
||||
if (!category.canLoadMore || !loadMoreInFlight.add(categoryId)) return
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val catalogSources = category.catalogSources.orEmpty()
|
||||
val remoteSources = category.remoteSources.orEmpty()
|
||||
if (catalogSources.isNotEmpty() || remoteSources.isNotEmpty()) {
|
||||
updateHomeCategory(categoryId) { existing -> existing.copy(folderSourcesLoading = true) }
|
||||
val nextSkip = if (category.items.isEmpty()) 0 else category.skip + 20
|
||||
val lang = currentActiveProfile?.safeLanguage ?: "en"
|
||||
val semaphore = Semaphore(permits = 4)
|
||||
val sourceResults = Channel<Pair<HomeCatalogSource, List<Meta>>>(catalogSources.size)
|
||||
coroutineScope {
|
||||
catalogSources.forEach { source ->
|
||||
launch(Dispatchers.IO) {
|
||||
semaphore.withPermit {
|
||||
sourceResults.send(
|
||||
source to normalizeCatalogItems(
|
||||
platformContentGateway.addonCatalog(
|
||||
source.transportUrl,
|
||||
source.type,
|
||||
source.catalogId,
|
||||
skip = nextSkip,
|
||||
genre = source.genre
|
||||
),
|
||||
source.catalogId,
|
||||
lang,
|
||||
source.genre
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
repeat(catalogSources.size) {
|
||||
val (source, sourceItems) = sourceResults.receive()
|
||||
val sourceMap = sourceItems.flatMap { item ->
|
||||
listOf("${item.type}:${item.id}" to source, item.id to source)
|
||||
}.toMap()
|
||||
updateHomeCategory(categoryId) { existing ->
|
||||
existing.copy(
|
||||
items = (existing.items + sourceItems).distinctBy { "${it.type}:${it.id}" },
|
||||
resultSources = existing.resultSources + sourceMap
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val remoteItems = if (remoteSources.isEmpty()) {
|
||||
emptyList()
|
||||
} else {
|
||||
val result = dispatchHeadless(
|
||||
mapOf(
|
||||
"type" to "catalogPageRequested",
|
||||
"categoryId" to categoryId,
|
||||
"transportUrl" to null,
|
||||
"contentType" to category.type,
|
||||
"catalogId" to category.catalogId,
|
||||
"skip" to nextSkip,
|
||||
"genre" to null,
|
||||
"search" to null,
|
||||
"remoteSource" to remoteSources,
|
||||
"profile" to currentActiveProfile
|
||||
)
|
||||
)
|
||||
val home = result.state["home"] as? Map<*, *> ?: emptyMap<Any, Any>()
|
||||
val paging = home["paging"] as? Map<*, *> ?: emptyMap<Any, Any>()
|
||||
fromStateList<Meta>(paging["items"], metaListType)
|
||||
}
|
||||
updateHomeCategory(categoryId) { existing ->
|
||||
existing.copy(
|
||||
items = if (remoteItems.isEmpty()) existing.items else (existing.items + remoteItems).distinctBy { "${it.type}:${it.id}" },
|
||||
skip = nextSkip,
|
||||
canLoadMore = existing.items.isNotEmpty() || remoteItems.isNotEmpty()
|
||||
)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
val result = dispatchHeadless(
|
||||
mapOf(
|
||||
"type" to "catalogPageRequested",
|
||||
"categoryId" to categoryId,
|
||||
"transportUrl" to category.addonTransportUrl,
|
||||
"contentType" to category.type,
|
||||
"catalogId" to category.catalogId,
|
||||
"skip" to (category.skip + category.items.size),
|
||||
"genre" to category.addonGenre,
|
||||
"search" to null,
|
||||
"remoteSource" to remoteSources,
|
||||
"profile" to currentActiveProfile
|
||||
)
|
||||
)
|
||||
val home = result.state["home"] as? Map<*, *> ?: return@launch
|
||||
val paging = home["paging"] as? Map<*, *> ?: return@launch
|
||||
val newItems = fromStateList<Meta>(paging["items"], metaListType)
|
||||
updateHomeCategory(categoryId) { existing ->
|
||||
existing.copy(
|
||||
items = if (newItems.isEmpty()) existing.items else (existing.items + newItems).distinctBy { "${it.type}:${it.id}" },
|
||||
canLoadMore = newItems.isNotEmpty()
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (category.catalogSources.orEmpty().isNotEmpty() || category.remoteSources.orEmpty().isNotEmpty()) {
|
||||
updateHomeCategory(categoryId) { existing -> existing.copy(folderSourcesLoading = false) }
|
||||
}
|
||||
loadMoreInFlight.remove(categoryId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateHomeCategory(categoryId: String, update: (HomeCategory) -> HomeCategory) {
|
||||
val hiddenCategory = _collectionFolderCategories.value[categoryId]
|
||||
if (hiddenCategory != null) {
|
||||
_collectionFolderCategories.value = _collectionFolderCategories.value + (categoryId to update(hiddenCategory))
|
||||
return
|
||||
}
|
||||
setCategoriesState(
|
||||
_categories.value.map { existing ->
|
||||
if (existing.id == categoryId) {
|
||||
update(existing)
|
||||
} else {
|
||||
existing
|
||||
}
|
||||
}
|
||||
)
|
||||
pagingCoordinator.loadMore(categoryId)
|
||||
}
|
||||
|
||||
fun clearDiscoverResults() {
|
||||
_headlessDiscoverResults.value = emptyList()
|
||||
browseCoordinator.clearResults()
|
||||
}
|
||||
|
||||
fun discoverCatalogOptions(type: String): List<DiscoverCatalogOption> =
|
||||
buildDiscoverCatalogOptions(_userAddons.value, type)
|
||||
browseCoordinator.catalogOptions(type)
|
||||
|
||||
fun discoverContentTypes(): List<String> = buildDiscoverContentTypes(_userAddons.value)
|
||||
fun discoverContentTypes(): List<String> = browseCoordinator.availableContentTypes()
|
||||
|
||||
fun setDiscoverLoading(isLoading: Boolean) {
|
||||
_headlessDiscoverLoading.value = isLoading
|
||||
browseCoordinator.setLoading(isLoading)
|
||||
}
|
||||
|
||||
fun discover(type: String, catalogKey: String?, genre: String?, year: String?, rating: Float?, provider: String?, region: String?) {
|
||||
discoverJob?.cancel()
|
||||
discoverJob = viewModelScope.launch {
|
||||
_headlessDiscoverLoading.value = true
|
||||
try {
|
||||
val result = dispatchHeadless(
|
||||
mapOf(
|
||||
"type" to "discoverRequested",
|
||||
"contentType" to type,
|
||||
"filters" to mapOf(
|
||||
"catalogKey" to catalogKey,
|
||||
"genre" to genre,
|
||||
"year" to year,
|
||||
"rating" to rating,
|
||||
"provider" to provider,
|
||||
"region" to region
|
||||
),
|
||||
"profile" to currentActiveProfile,
|
||||
"language" to (currentActiveProfile?.safeLanguage ?: "en")
|
||||
)
|
||||
)
|
||||
val discover = result.state["discover"] as? Map<*, *>
|
||||
_headlessDiscoverResults.value = fromStateList(discover?.get("results"), metaListType)
|
||||
_headlessDiscoverResultSources.value = fromStateSourceMap(discover?.get("resultSources"))
|
||||
} finally {
|
||||
_headlessDiscoverLoading.value = false
|
||||
}
|
||||
}
|
||||
browseCoordinator.discover(type, catalogKey, genre, year, rating, provider, region)
|
||||
}
|
||||
|
||||
fun loadMoreDiscoverResults(transportUrl: String, contentType: String, catalogId: String, genre: String?) {
|
||||
if (_headlessDiscoverLoading.value) return
|
||||
viewModelScope.launch {
|
||||
_headlessDiscoverLoading.value = true
|
||||
try {
|
||||
val result = dispatchHeadless(
|
||||
mapOf(
|
||||
"type" to "discoverPageRequested",
|
||||
"transportUrl" to transportUrl,
|
||||
"contentType" to contentType,
|
||||
"catalogId" to catalogId,
|
||||
"skip" to _headlessDiscoverResults.value.size,
|
||||
"genre" to genre
|
||||
)
|
||||
)
|
||||
val discover = result.state["discover"] as? Map<*, *>
|
||||
val updatedResults = fromStateList<Meta>(discover?.get("results"), metaListType)
|
||||
val source = HomeCatalogSource(transportUrl, catalogId, contentType, genre)
|
||||
val updatedSources = _headlessDiscoverResultSources.value.toMutableMap()
|
||||
updatedResults.forEach { item ->
|
||||
updatedSources.putIfAbsent("${item.type}:${item.id}", source)
|
||||
updatedSources.putIfAbsent(item.id, source)
|
||||
}
|
||||
_headlessDiscoverResults.value = updatedResults
|
||||
_headlessDiscoverResultSources.value = updatedSources
|
||||
} finally {
|
||||
_headlessDiscoverLoading.value = false
|
||||
}
|
||||
}
|
||||
browseCoordinator.loadMore(transportUrl, contentType, catalogId, genre)
|
||||
}
|
||||
|
||||
fun loadCalendarMonth(activeProfile: UserProfile?, year: Int, month: Int, plannedItems: List<Meta> = emptyList()) {
|
||||
viewModelScope.launch {
|
||||
_headlessCalendarLoading.value = true
|
||||
try {
|
||||
val result = dispatchHeadless(
|
||||
mapOf(
|
||||
"type" to "calendarMonthRequested",
|
||||
"profile" to activeProfile,
|
||||
"year" to year,
|
||||
"month" to month,
|
||||
"plannedItems" to plannedItems
|
||||
)
|
||||
)
|
||||
val calendar = result.state["calendar"] as? Map<*, *>
|
||||
_headlessCalendarItems.value = fromStateList(calendar?.get("items"), calendarItemListType)
|
||||
} finally {
|
||||
_headlessCalendarLoading.value = false
|
||||
}
|
||||
}
|
||||
browseCoordinator.loadCalendar(activeProfile, year, month, plannedItems)
|
||||
}
|
||||
|
||||
fun loadDiscoverGenres(type: String) {
|
||||
_headlessDiscoverGenres.value = emptyList()
|
||||
browseCoordinator.clearGenres()
|
||||
}
|
||||
|
||||
fun loadDiscoverCatalogFilters(
|
||||
|
|
@ -1159,23 +917,7 @@ class HomeViewModel @Inject constructor(
|
|||
selectedCatalogKey: String?,
|
||||
onLoaded: ((List<DiscoverCatalogOption>) -> Unit)? = null
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val result = dispatchHeadless(
|
||||
mapOf(
|
||||
"type" to "discoverCatalogFiltersRequested",
|
||||
"contentType" to type,
|
||||
"selectedCatalogKey" to selectedCatalogKey,
|
||||
"profile" to currentActiveProfile,
|
||||
"language" to (currentActiveProfile?.safeLanguage ?: "en")
|
||||
)
|
||||
)
|
||||
val discover = result.state["discover"] as? Map<*, *> ?: return@launch
|
||||
val catalogs = fromStateList<DiscoverCatalogOption>(discover["catalogs"], discoverCatalogListType)
|
||||
_headlessDiscoverCatalogs.value = catalogs
|
||||
_headlessDiscoverGenres.value = fromStateList(discover["genres"], discoverGenreListType)
|
||||
_headlessDiscoverContentTypes.value = fromStateList(discover["contentTypes"], discoverContentTypeListType)
|
||||
onLoaded?.invoke(catalogs)
|
||||
}
|
||||
browseCoordinator.loadFilters(type, selectedCatalogKey, onLoaded)
|
||||
}
|
||||
|
||||
fun setUserAddons(addons: List<AddonDescriptor>) {
|
||||
|
|
@ -1217,7 +959,7 @@ class HomeViewModel @Inject constructor(
|
|||
savedTvFocusedRowIndex = -1
|
||||
savedCategoryScrollPositions.clear()
|
||||
}
|
||||
if (_categories.value.isEmpty()) {
|
||||
if (categoryState.currentCategories().isEmpty()) {
|
||||
val cached = homeCategoryCache.load(activeProfile)
|
||||
if (cached.isNotEmpty()) {
|
||||
setCategoriesState(cached)
|
||||
|
|
@ -1261,29 +1003,11 @@ class HomeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun refreshTraktTokenIfNeeded(profile: UserProfile, onProfileUpdated: (UserProfile) -> Unit) {
|
||||
refreshAuthTokenIfNeeded("trakt", profile, onProfileUpdated)
|
||||
authCoordinator.refreshTokenIfNeeded("trakt", profile, onProfileUpdated)
|
||||
}
|
||||
|
||||
fun refreshMalTokenIfNeeded(profile: UserProfile, onProfileUpdated: (UserProfile) -> Unit) {
|
||||
refreshAuthTokenIfNeeded("mal", profile, onProfileUpdated)
|
||||
}
|
||||
|
||||
private fun refreshAuthTokenIfNeeded(provider: String, profile: UserProfile, onProfileUpdated: (UserProfile) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val result = dispatchHeadless(
|
||||
mapOf(
|
||||
"type" to "authRefreshRequested",
|
||||
"provider" to provider,
|
||||
"profile" to profile
|
||||
)
|
||||
)
|
||||
val auth = result.state["auth"] as? Map<*, *>
|
||||
val updated = fromStateObject(auth?.get("result")?.let { (it as? Map<*, *>)?.get("profile") } ?: auth?.get("result"), UserProfile::class.java)
|
||||
if (updated != null && updated != profile) {
|
||||
setActiveProfileState(updated)
|
||||
onProfileUpdated(updated)
|
||||
}
|
||||
}
|
||||
authCoordinator.refreshTokenIfNeeded("mal", profile, onProfileUpdated)
|
||||
}
|
||||
|
||||
fun refreshExternalContinueWatching() {
|
||||
|
|
@ -1291,19 +1015,7 @@ class HomeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
fun loadLibraryData(activeProfile: UserProfile?) {
|
||||
viewModelScope.launch {
|
||||
val result = dispatchHeadless(
|
||||
mapOf(
|
||||
"type" to "libraryHydrateRequested",
|
||||
"profileId" to activeProfile?.id
|
||||
)
|
||||
)
|
||||
val library = result.state["library"] as? Map<*, *> ?: return@launch
|
||||
setWatchlistState(fromStateList(library["watchlist"], metaListType))
|
||||
setCurrentWatchlistState(fromStateList(library["continueWatching"], metaListType))
|
||||
setLikedItemsState(fromStateList(library["liked"], metaListType))
|
||||
refreshDynamicRows()
|
||||
}
|
||||
syncCoordinator.loadLibrary(activeProfile)
|
||||
}
|
||||
|
||||
fun syncTraktIntegration(
|
||||
|
|
@ -1311,26 +1023,7 @@ class HomeViewModel @Inject constructor(
|
|||
onProfileUpdated: (UserProfile) -> Unit,
|
||||
onComplete: (Boolean) -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val result = dispatchHeadless(
|
||||
mapOf(
|
||||
"type" to "externalIntegrationSyncRequested",
|
||||
"provider" to "trakt",
|
||||
"profile" to profile,
|
||||
"language" to profile.safeLanguage
|
||||
)
|
||||
)
|
||||
val sync = result.state["sync"] as? Map<*, *>
|
||||
val error = sync?.get("error")
|
||||
val updated = fromStateObject((sync?.get("snapshot") as? Map<*, *>)?.get("profile") ?: (result.state["profile"] as? Map<*, *>)?.get("active"), UserProfile::class.java)
|
||||
if (updated != null) {
|
||||
setActiveProfileState(updated)
|
||||
onProfileUpdated(updated)
|
||||
setExternalContinueWatchingState(fromStateList((result.state["home"] as? Map<*, *>)?.get("externalContinueWatching"), metaListType))
|
||||
refreshDynamicRows()
|
||||
}
|
||||
onComplete(error == null && updated != null)
|
||||
}
|
||||
syncCoordinator.syncTrakt(profile, onProfileUpdated, onComplete)
|
||||
}
|
||||
|
||||
fun syncNuvioIntegration(
|
||||
|
|
@ -1338,29 +1031,7 @@ class HomeViewModel @Inject constructor(
|
|||
onProfileUpdated: (UserProfile) -> Unit,
|
||||
onComplete: (Boolean) -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val result = dispatchHeadless(
|
||||
mapOf(
|
||||
"type" to "externalSyncRequested",
|
||||
"provider" to "nuvio",
|
||||
"profile" to profile,
|
||||
"language" to profile.safeLanguage
|
||||
)
|
||||
)
|
||||
val sync = result.state["sync"] as? Map<*, *>
|
||||
val updated = fromStateObject(
|
||||
sync?.get("profile")
|
||||
?: (sync?.get("snapshot") as? Map<*, *>)?.get("profile")
|
||||
?: (result.state["profile"] as? Map<*, *>)?.get("active"),
|
||||
UserProfile::class.java
|
||||
)
|
||||
if (updated != null) {
|
||||
setActiveProfileState(updated)
|
||||
onProfileUpdated(updated)
|
||||
loadLibraryData(updated)
|
||||
}
|
||||
onComplete(sync?.get("error") == null)
|
||||
}
|
||||
syncCoordinator.syncNuvio(profile, onProfileUpdated, onComplete, ::loadLibraryData)
|
||||
}
|
||||
|
||||
suspend fun isNuvioHealthy(): Boolean = nuvioSyncCoordinator.isHealthy()
|
||||
|
|
@ -1376,26 +1047,7 @@ class HomeViewModel @Inject constructor(
|
|||
onProfileUpdated: (UserProfile) -> Unit,
|
||||
onComplete: (Boolean) -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val result = dispatchHeadless(
|
||||
mapOf(
|
||||
"type" to "externalIntegrationSyncRequested",
|
||||
"provider" to "stremio",
|
||||
"profile" to profile,
|
||||
"language" to profile.safeLanguage
|
||||
)
|
||||
)
|
||||
val sync = result.state["sync"] as? Map<*, *>
|
||||
val snapshot = sync?.get("snapshot") as? Map<*, *>
|
||||
val updated = fromStateObject(snapshot?.get("profile") ?: (result.state["profile"] as? Map<*, *>)?.get("active"), UserProfile::class.java)
|
||||
if (updated != null) {
|
||||
setActiveProfileState(updated)
|
||||
setExternalContinueWatchingState(fromStateList((result.state["home"] as? Map<*, *>)?.get("externalContinueWatching"), metaListType))
|
||||
onProfileUpdated(updated)
|
||||
refreshDynamicRows()
|
||||
}
|
||||
onComplete(sync?.get("error") == null && updated != null)
|
||||
}
|
||||
syncCoordinator.syncStremio(profile, onProfileUpdated, onComplete)
|
||||
}
|
||||
|
||||
private fun buildUserCollectionHomeCategories(profile: UserProfile?, showAboveContinueWatching: Boolean? = null): List<HomeCategory> {
|
||||
|
|
@ -1439,32 +1091,7 @@ class HomeViewModel @Inject constructor(
|
|||
}
|
||||
|
||||
private fun setCategoriesState(categories: List<HomeCategory>) {
|
||||
val hiddenCollectionFolders = categories.filter { it.isCollectionFolderCategory() }
|
||||
val visibleCategories = categories.filterNot { it.isCollectionFolderCategory() || it.type == "collection" }
|
||||
when {
|
||||
categories.isEmpty() -> _collectionFolderCategories.value = emptyMap()
|
||||
hiddenCollectionFolders.isNotEmpty() || categories.any { it.type == "collection" } -> {
|
||||
val existingFolders = _collectionFolderCategories.value
|
||||
_collectionFolderCategories.value = hiddenCollectionFolders.associate { incoming ->
|
||||
val existing = existingFolders[incoming.id]
|
||||
incoming.id to if (existing == null || (existing.items.isEmpty() && !existing.folderSourcesLoading)) {
|
||||
incoming
|
||||
} else {
|
||||
incoming.copy(
|
||||
items = existing.items,
|
||||
skip = existing.skip,
|
||||
canLoadMore = existing.canLoadMore,
|
||||
resultSources = existing.resultSources,
|
||||
folderSourcesLoading = existing.folderSourcesLoading
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val signature = visibleCategories.renderSignatureForHome()
|
||||
if (categoriesRenderSignature == signature) return
|
||||
categoriesRenderSignature = signature
|
||||
_categories.value = visibleCategories
|
||||
categoryState.setCategories(categories)
|
||||
}
|
||||
|
||||
private fun setLoadingState(isLoading: Boolean) {
|
||||
|
|
@ -1555,195 +1182,19 @@ class HomeViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun fromStateSourceMap(value: Any?): Map<String, HomeCatalogSource> {
|
||||
return withContext(Dispatchers.Default) {
|
||||
runCatching {
|
||||
gson.fromJson<Map<String, HomeCatalogSource>>(gson.toJsonTree(value), discoverResultSourceMapType)
|
||||
}.getOrNull() ?: emptyMap()
|
||||
}
|
||||
}
|
||||
fun exchangeTraktCode(code: String, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) =
|
||||
authCoordinator.exchangeCode("trakt", code, null, onProfileUpdated, onComplete)
|
||||
|
||||
fun exchangeTraktCode(code: String, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) {
|
||||
exchangeAuthCode("trakt", code, null, onProfileUpdated, onComplete)
|
||||
}
|
||||
fun startTraktDeviceAuthorization(onCodeReady: (TraktDeviceCodeResponse) -> Unit, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean, String?) -> Unit) =
|
||||
authCoordinator.startTraktDeviceAuthorization(onCodeReady, onProfileUpdated, onComplete)
|
||||
|
||||
fun startTraktDeviceAuthorization(
|
||||
onCodeReady: (TraktDeviceCodeResponse) -> Unit,
|
||||
onProfileUpdated: (UserProfile) -> Unit,
|
||||
onComplete: (Boolean, String?) -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val profile = currentActiveProfile
|
||||
if (profile == null) {
|
||||
onComplete(false, "toast.trakt_connect_failed")
|
||||
return@launch
|
||||
}
|
||||
val codeResult = dispatchHeadless(
|
||||
mapOf(
|
||||
"type" to "authFlowRequested",
|
||||
"provider" to "trakt",
|
||||
"mode" to "deviceCode"
|
||||
)
|
||||
)
|
||||
val codeResponse = fromStateObject((codeResult.state["auth"] as? Map<*, *>)?.get("result"), TraktDeviceCodeResponse::class.java)
|
||||
if (codeResponse == null) {
|
||||
onComplete(false, "toast.trakt_connect_failed")
|
||||
return@launch
|
||||
}
|
||||
onCodeReady(codeResponse)
|
||||
val startedAt = System.currentTimeMillis()
|
||||
val expiresAt = startedAt + codeResponse.expiresIn * 1000L
|
||||
var intervalMs = codeResponse.interval.coerceAtLeast(5) * 1000L
|
||||
var failureMessageKey: String? = "toast.trakt_connect_failed"
|
||||
while (System.currentTimeMillis() < expiresAt) {
|
||||
delay(intervalMs)
|
||||
val tokenResult = dispatchHeadless(
|
||||
mapOf(
|
||||
"type" to "authExchangeRequested",
|
||||
"provider" to "traktDevice",
|
||||
"code" to codeResponse.deviceCode,
|
||||
"profile" to (currentActiveProfile ?: profile)
|
||||
)
|
||||
)
|
||||
val auth = tokenResult.state["auth"] as? Map<*, *>
|
||||
val result = auth?.get("result") as? Map<*, *>
|
||||
val updated = fromStateObject(result?.get("profile"), UserProfile::class.java)
|
||||
if (updated != null) {
|
||||
setActiveProfileState(updated)
|
||||
onProfileUpdated(updated)
|
||||
setCategoriesState(emptyList())
|
||||
onComplete(true, null)
|
||||
return@launch
|
||||
}
|
||||
val errorCode = result?.get("errorCode") as? String
|
||||
val httpCode = (result?.get("httpCode") as? Number)?.toInt()
|
||||
when {
|
||||
errorCode == "slow_down" || httpCode == 429 -> {
|
||||
val retryAfterMs = (result.get("retryAfterSeconds") as? Number)?.toLong()?.let { it * 1000L }
|
||||
intervalMs = (retryAfterMs ?: (intervalMs + 5_000L)).coerceAtMost(60_000L)
|
||||
}
|
||||
errorCode == "expired_token" || errorCode == "invalid_grant" -> {
|
||||
failureMessageKey = "toast.trakt_device_code_expired"
|
||||
break
|
||||
}
|
||||
errorCode == "authorization_pending" || httpCode in setOf(400, 404, 409, 428) -> {
|
||||
continue
|
||||
}
|
||||
else -> break
|
||||
}
|
||||
}
|
||||
if (System.currentTimeMillis() >= expiresAt) {
|
||||
failureMessageKey = "toast.trakt_device_code_expired"
|
||||
}
|
||||
onComplete(false, failureMessageKey)
|
||||
}
|
||||
}
|
||||
fun exchangeMalCode(code: String, codeVerifier: String, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) =
|
||||
authCoordinator.exchangeCode("mal", code, codeVerifier, onProfileUpdated, onComplete)
|
||||
|
||||
fun exchangeMalCode(code: String, codeVerifier: String, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) {
|
||||
exchangeAuthCode("mal", code, codeVerifier, onProfileUpdated, onComplete)
|
||||
}
|
||||
fun exchangeSimklCode(code: String, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) =
|
||||
authCoordinator.exchangeCode("simkl", code, null, onProfileUpdated, onComplete)
|
||||
|
||||
fun exchangeSimklCode(code: String, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) {
|
||||
exchangeAuthCode("simkl", code, null, onProfileUpdated, onComplete)
|
||||
}
|
||||
|
||||
fun exchangeAnilistCode(code: String, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) {
|
||||
exchangeAuthCode("anilist", code, null, onProfileUpdated, onComplete)
|
||||
}
|
||||
|
||||
private fun exchangeAuthCode(
|
||||
provider: String,
|
||||
code: String,
|
||||
codeVerifier: String?,
|
||||
onProfileUpdated: (UserProfile) -> Unit,
|
||||
onComplete: (Boolean) -> Unit
|
||||
) {
|
||||
viewModelScope.launch {
|
||||
val profile = currentActiveProfile
|
||||
if (profile == null) {
|
||||
onComplete(false)
|
||||
return@launch
|
||||
}
|
||||
val result = dispatchHeadless(
|
||||
mapOf(
|
||||
"type" to "authExchangeRequested",
|
||||
"provider" to provider,
|
||||
"code" to code,
|
||||
"codeVerifier" to codeVerifier,
|
||||
"profile" to profile
|
||||
)
|
||||
)
|
||||
val updated = fromStateObject((result.state["profile"] as? Map<*, *>)?.get("active"), UserProfile::class.java)
|
||||
if (updated != null) {
|
||||
setActiveProfileState(updated)
|
||||
onProfileUpdated(updated)
|
||||
setCategoriesState(emptyList())
|
||||
onComplete(true)
|
||||
} else {
|
||||
onComplete(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
fun exchangeAnilistCode(code: String, onProfileUpdated: (UserProfile) -> Unit, onComplete: (Boolean) -> Unit) =
|
||||
authCoordinator.exchangeCode("anilist", code, null, onProfileUpdated, onComplete)
|
||||
|
||||
}
|
||||
|
||||
private fun List<MainAPI>.toCs3CatalogFeedDescriptors(): List<Cs3CatalogFeedDescriptor> {
|
||||
return filter { it.hasMainPage }.flatMap { api ->
|
||||
api.mainPage.mapIndexed { index, page ->
|
||||
Cs3CatalogFeedDescriptor(
|
||||
pluginName = api.name,
|
||||
catalogName = page.name.takeIf { it.isNotBlank() } ?: api.name,
|
||||
catalogIndex = index
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun List<HomeCategory>.renderSignatureForHome(): Int {
|
||||
var result = size
|
||||
for (category in this) {
|
||||
result = 31 * result + category.id.hashCode()
|
||||
result = 31 * result + category.name.hashCode()
|
||||
result = 31 * result + category.type.hashCode()
|
||||
result = 31 * result + (category.addonIconUrl?.hashCode() ?: 0)
|
||||
result = 31 * result + category.items.size
|
||||
result = 31 * result + category.skip
|
||||
result = 31 * result + category.canLoadMore.hashCode()
|
||||
if (category.type == "collection_folder") {
|
||||
continue
|
||||
}
|
||||
val visibleCount = minOf(category.items.size, 24)
|
||||
for (index in 0 until visibleCount) {
|
||||
val item = category.items[index]
|
||||
result = 31 * result + item.id.hashCode()
|
||||
result = 31 * result + item.type.hashCode()
|
||||
result = 31 * result + item.name.hashCode()
|
||||
result = 31 * result + (item.poster?.hashCode() ?: 0)
|
||||
result = 31 * result + (item.background?.hashCode() ?: 0)
|
||||
result = 31 * result + (item.logo?.hashCode() ?: 0)
|
||||
result = 31 * result + (item.releaseInfo?.hashCode() ?: 0)
|
||||
result = 31 * result + (item.reason?.hashCode() ?: 0)
|
||||
result = 31 * result + (item.timeOffset?.hashCode() ?: 0)
|
||||
result = 31 * result + (item.duration?.hashCode() ?: 0)
|
||||
result = 31 * result + (item.lastVideoId?.hashCode() ?: 0)
|
||||
result = 31 * result + (item.lastEpisodeName?.hashCode() ?: 0)
|
||||
result = 31 * result + (item.continueWatchingPoster?.hashCode() ?: 0)
|
||||
result = 31 * result + (item.continueWatchingBackground?.hashCode() ?: 0)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun HomeCategory.isCollectionFolderCategory(): Boolean {
|
||||
return type == "collection_folder"
|
||||
}
|
||||
|
||||
private fun Video.continueWatchingTitleForHome(): String {
|
||||
val seasonEpisode = if (season != null && number != null) "S$season, E$number" else null
|
||||
val title = name?.trim()?.takeIf { it.isNotBlank() }
|
||||
return when {
|
||||
seasonEpisode != null && title != null -> "$seasonEpisode: $title"
|
||||
seasonEpisode != null -> seasonEpisode
|
||||
else -> title.orEmpty()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,25 +6,15 @@ import com.fluxa.app.data.local.UserProfile
|
|||
import com.fluxa.app.data.local.WatchlistManager
|
||||
import com.fluxa.app.data.remote.AddonDescriptor
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.fluxa.app.data.repository.AddonRepository
|
||||
import com.fluxa.app.data.repository.StremioRepository
|
||||
import com.fluxa.app.data.repository.TraktRepository
|
||||
import com.fluxa.app.data.repository.TraktWatchedState
|
||||
import com.fluxa.app.domain.discovery.DiscoverCatalogContentLoader
|
||||
import com.fluxa.app.domain.discovery.StreamDiscoveryUseCase
|
||||
import com.google.gson.Gson
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import javax.inject.Inject
|
||||
|
||||
class HomeViewModelCoordinatorFactory @Inject constructor() {
|
||||
internal fun search(addonRepository: AddonRepository, searchHistoryStore: SearchHistoryStore): HomeSearchCoordinator {
|
||||
return HomeSearchCoordinator(addonRepository, searchHistoryStore)
|
||||
}
|
||||
|
||||
internal fun episodeCalendar(repository: StremioRepository, watchlistManager: WatchlistManager): EpisodeCalendarLoader {
|
||||
return EpisodeCalendarLoader(repository, watchlistManager)
|
||||
}
|
||||
|
||||
internal fun library(
|
||||
repository: StremioRepository,
|
||||
traktRepository: TraktRepository,
|
||||
|
|
@ -35,59 +25,6 @@ class HomeViewModelCoordinatorFactory @Inject constructor() {
|
|||
return HomeLibraryCoordinator(repository, traktRepository, scope, coreState, gson)
|
||||
}
|
||||
|
||||
internal fun auth(
|
||||
repository: StremioRepository,
|
||||
scope: CoroutineScope,
|
||||
activeProfile: () -> UserProfile?,
|
||||
invalidateHome: () -> Unit
|
||||
): HomeAuthCoordinator {
|
||||
return HomeAuthCoordinator(repository, scope, activeProfile, invalidateHome)
|
||||
}
|
||||
|
||||
internal fun discover(
|
||||
addonRepository: AddonRepository,
|
||||
discoverCatalogContentLoader: DiscoverCatalogContentLoader,
|
||||
scope: CoroutineScope,
|
||||
activeProfile: () -> UserProfile?,
|
||||
userAddons: () -> List<AddonDescriptor>,
|
||||
setUserAddons: (List<AddonDescriptor>) -> Unit,
|
||||
normalizeCatalogItems: suspend (List<Meta>, String, String, String?) -> List<Meta>,
|
||||
coreState: FluxaCoreStateHandle
|
||||
): HomeDiscoverController {
|
||||
return HomeDiscoverController(
|
||||
addonRepository = addonRepository,
|
||||
discoverCatalogContentLoader = discoverCatalogContentLoader,
|
||||
scope = scope,
|
||||
activeProfile = activeProfile,
|
||||
userAddons = userAddons,
|
||||
setUserAddons = setUserAddons,
|
||||
normalizeCatalogItems = normalizeCatalogItems,
|
||||
coreState = coreState
|
||||
)
|
||||
}
|
||||
|
||||
internal fun calendar(
|
||||
context: Context,
|
||||
episodeCalendarLoader: EpisodeCalendarLoader,
|
||||
watchlistManager: WatchlistManager,
|
||||
scope: CoroutineScope,
|
||||
activeProfile: () -> UserProfile?,
|
||||
setActiveProfile: (UserProfile?) -> Unit,
|
||||
onSnapshotLoaded: (HomeCalendarSnapshot) -> Unit,
|
||||
coreState: FluxaCoreStateHandle
|
||||
): HomeCalendarController {
|
||||
return HomeCalendarController(
|
||||
context = context,
|
||||
episodeCalendarLoader = episodeCalendarLoader,
|
||||
watchlistManager = watchlistManager,
|
||||
scope = scope,
|
||||
activeProfile = activeProfile,
|
||||
setActiveProfile = setActiveProfile,
|
||||
onSnapshotLoaded = onSnapshotLoaded,
|
||||
coreState = coreState
|
||||
)
|
||||
}
|
||||
|
||||
internal fun playback(
|
||||
context: Context,
|
||||
repository: StremioRepository,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import com.fluxa.app.common.AppStrings
|
|||
import com.fluxa.app.common.ReleaseDateUtils
|
||||
import com.fluxa.app.core.StremioId
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.fluxa.app.data.remote.Video
|
||||
import com.fluxa.app.domain.discovery.Cs3CatalogFeedDescriptor
|
||||
import com.lagradost.cloudstream3.MainAPI
|
||||
|
||||
internal fun preferredHomeRowLabels(lang: String): List<String> {
|
||||
return listOf(
|
||||
|
|
@ -53,3 +56,25 @@ internal fun isRecentlyReleased(dateStr: String?, windowDays: Int): Boolean {
|
|||
internal fun isUpcomingRelease(dateStr: String?): Boolean {
|
||||
return ReleaseDateUtils.isUpcoming(dateStr)
|
||||
}
|
||||
|
||||
internal fun List<MainAPI>.toCs3CatalogFeedDescriptors(): List<Cs3CatalogFeedDescriptor> {
|
||||
return filter { it.hasMainPage }.flatMap { api ->
|
||||
api.mainPage.mapIndexed { index, page ->
|
||||
Cs3CatalogFeedDescriptor(
|
||||
pluginName = api.name,
|
||||
catalogName = page.name.takeIf { it.isNotBlank() } ?: api.name,
|
||||
catalogIndex = index
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Video.continueWatchingTitleForHome(): String {
|
||||
val seasonEpisode = if (season != null && number != null) "S$season, E$number" else null
|
||||
val title = name?.trim()?.takeIf { it.isNotBlank() }
|
||||
return when {
|
||||
seasonEpisode != null && title != null -> "$seasonEpisode: $title"
|
||||
seasonEpisode != null -> seasonEpisode
|
||||
else -> title.orEmpty()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,8 @@
|
|||
@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class)
|
||||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.data.local.OfflineDownloadItem
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
internal fun List<OfflineDownloadItem>.toOfflineDownloadGroups(): List<OfflineDownloadGroup> {
|
||||
return groupBy { item -> item.metaId.ifBlank { item.title } }
|
||||
.values
|
||||
.map { episodes ->
|
||||
val sorted = episodes.sortedWith(compareBy<OfflineDownloadItem> { it.videoId.orEmpty() }.thenByDescending { it.createdAt })
|
||||
val first = sorted.first()
|
||||
OfflineDownloadGroup(
|
||||
key = first.metaId.ifBlank { first.title },
|
||||
title = first.title,
|
||||
poster = first.localPosterPath?.toFileImageModel() ?: first.poster ?: first.localBackgroundPath?.toFileImageModel() ?: first.background,
|
||||
episodes = sorted,
|
||||
totalBytes = sorted.sumOf { it.totalBytes.takeIf { bytes -> bytes > 0L } ?: it.downloadedBytes }
|
||||
)
|
||||
}
|
||||
.sortedBy { it.title.lowercase(Locale.ROOT) }
|
||||
}
|
||||
|
||||
internal fun OfflineDownloadItem.effectiveSizeLabel(): String? {
|
||||
val size = totalBytes.takeIf { it > 0L } ?: downloadedBytes.takeIf { it > 0L } ?: return null
|
||||
return size.formatDownloadBytes()
|
||||
}
|
||||
|
||||
internal fun String.toFileImageModel(): String {
|
||||
return File(this).takeIf { it.exists() }?.toURI()?.toString() ?: this
|
||||
}
|
||||
|
||||
internal fun Long.formatDownloadBytes(): String {
|
||||
val value = this.toDouble()
|
||||
return when {
|
||||
this >= 1024L * 1024L * 1024L -> String.format(Locale.US, "%.1f GB", value / (1024.0 * 1024.0 * 1024.0))
|
||||
this >= 1024L * 1024L -> String.format(Locale.US, "%.0f MB", value / (1024.0 * 1024.0))
|
||||
this >= 1024L -> String.format(Locale.US, "%.0f KB", value / 1024.0)
|
||||
else -> "$this B"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.shared.feature.player.PlayerSeekFeedback
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
|
|
@ -250,7 +251,7 @@ internal fun BoxScope.PlayerTransientOverlays(
|
|||
.zIndex(300f)
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
SeekFeedback(seekDirection, seekFeedbackMs.takeIf { it > 0L } ?: if (seekDirection > 0) seekForwardMs else seekBackwardMs)
|
||||
PlayerSeekFeedback(seekDirection, seekFeedbackMs.takeIf { it > 0L } ?: if (seekDirection > 0) seekForwardMs else seekBackwardMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,136 +1,17 @@
|
|||
@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class, androidx.compose.animation.ExperimentalAnimationApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.data.local.*
|
||||
import com.fluxa.app.data.remote.*
|
||||
import com.fluxa.app.data.repository.*
|
||||
import com.fluxa.app.domain.discovery.*
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.focusable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.*
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.StrokeCap
|
||||
import androidx.compose.ui.graphics.drawscope.clipRect
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import coil3.compose.AsyncImage
|
||||
import com.fluxa.app.player.MediaTrack
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import java.util.Locale
|
||||
|
||||
internal fun nativeLanguageName(code: String): String {
|
||||
val normalized = code.lowercase(Locale.ROOT)
|
||||
val locale = Locale.forLanguageTag(normalized)
|
||||
val native = locale.getDisplayLanguage(locale).trim()
|
||||
return native.takeIf { it.isNotBlank() }?.replaceFirstChar { ch -> ch.titlecase(locale) } ?: code
|
||||
return native.takeIf { it.isNotBlank() }?.replaceFirstChar { it.titlecase(locale) } ?: code
|
||||
}
|
||||
|
||||
internal fun Meta.withCurrentEpisodeArtwork(artwork: String?): Meta {
|
||||
val episodeArtwork = artwork?.takeIf { it.isNotBlank() } ?: return this
|
||||
if (type != "series") return this
|
||||
return copy(
|
||||
continueWatchingPoster = episodeArtwork,
|
||||
continueWatchingBackground = episodeArtwork
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SeekFeedback(direction: Int, seekMs: Long) {
|
||||
val seconds = (seekMs / 1000L).coerceAtLeast(1L)
|
||||
val transition = rememberInfiniteTransition(label = "seek")
|
||||
val chevronShift by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(FluxaDimensions.AnimDuration.sidebarSlide, easing = FastOutSlowInEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
), label = "chevronShift"
|
||||
)
|
||||
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Canvas(modifier = Modifier.size(120.dp, 82.dp)) {
|
||||
val sign = if (direction > 0) 1f else -1f
|
||||
val stroke = size.minDimension * 0.075f
|
||||
val centerY = size.height / 2f
|
||||
val baseX = size.width / 2f - sign * size.width * 0.14f
|
||||
repeat(3) { index ->
|
||||
val phase = ((chevronShift + index * 0.28f) % 1f)
|
||||
val alpha = 0.22f + phase * 0.58f
|
||||
val x = baseX + sign * (index * size.width * 0.12f + phase * size.width * 0.05f)
|
||||
drawLine(
|
||||
color = Color.White.copy(alpha = alpha),
|
||||
start = Offset(x - sign * size.width * 0.06f, centerY - size.height * 0.12f),
|
||||
end = Offset(x + sign * size.width * 0.06f, centerY),
|
||||
strokeWidth = stroke,
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
drawLine(
|
||||
color = Color.White.copy(alpha = alpha),
|
||||
start = Offset(x + sign * size.width * 0.06f, centerY),
|
||||
end = Offset(x - sign * size.width * 0.06f, centerY + size.height * 0.12f),
|
||||
strokeWidth = stroke,
|
||||
cap = StrokeCap.Round
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = if (direction > 0) "+$seconds" else "-$seconds",
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Black,
|
||||
fontSize = 22.sp,
|
||||
style = androidx.compose.ui.text.TextStyle(
|
||||
shadow = Shadow(color = Color.Black.copy(alpha = 0.55f), offset = Offset(2f, 4f), blurRadius = 8f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun formatTime(ms: Long): String {
|
||||
val totalSec = ms / 1000
|
||||
val hr = totalSec / 3600
|
||||
val min = (totalSec % 3600) / 60
|
||||
val sec = totalSec % 60
|
||||
return if (hr > 0) {
|
||||
String.format(java.util.Locale.US, "%02d:%02d:%02d", hr, min, sec)
|
||||
} else {
|
||||
String.format(java.util.Locale.US, "%02d:%02d", min, sec)
|
||||
}
|
||||
return copy(continueWatchingPoster = episodeArtwork, continueWatchingBackground = episodeArtwork)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +0,0 @@
|
|||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.core.rust.FluxaCoreNative
|
||||
import com.fluxa.app.data.remote.Stream
|
||||
|
||||
internal const val STREAM_SOURCE_MODE_MANUAL = "manual"
|
||||
internal const val STREAM_SOURCE_MODE_FIRST = "first"
|
||||
|
||||
internal fun selectStreamIndex(
|
||||
streams: List<Stream>,
|
||||
currentVideoId: String?,
|
||||
initialStreamIndex: Int,
|
||||
savedUrl: String?,
|
||||
savedTitle: String?,
|
||||
sourceSelectionMode: String,
|
||||
regexPattern: String?,
|
||||
preferredBingeGroup: String?
|
||||
): Int {
|
||||
return FluxaCoreNative.selectStreamIndex(
|
||||
streams = streams,
|
||||
currentVideoId = currentVideoId,
|
||||
initialStreamIndex = initialStreamIndex,
|
||||
savedUrl = savedUrl,
|
||||
savedTitle = savedTitle,
|
||||
sourceSelectionMode = sourceSelectionMode,
|
||||
regexPattern = regexPattern,
|
||||
preferredBingeGroup = preferredBingeGroup
|
||||
)
|
||||
}
|
||||
|
|
@ -8,17 +8,12 @@ import com.fluxa.app.domain.discovery.*
|
|||
import com.fluxa.app.core.rust.FluxaCoreNative
|
||||
import com.fluxa.app.core.rust.models.SubtitleTrackRef
|
||||
import com.fluxa.app.player.MediaTrack
|
||||
import com.fluxa.app.player.resolveAudioLanguagePreference
|
||||
import java.util.Locale
|
||||
|
||||
object TrackSelectionState {
|
||||
private fun resolveAudioLanguagePref(profile: UserProfile?, meta: Meta): String? {
|
||||
val isAnime = meta.genres?.any { it.lowercase().contains("anime") } == true
|
||||
if (isAnime && profile?.safeAnimePreferJapaneseAudio == true) return "ja"
|
||||
return when (val pref = profile?.preferredAudioLanguage) {
|
||||
"original" -> meta.originalLanguage?.takeIf { it.isNotBlank() }
|
||||
"device_language" -> Locale.getDefault().language.takeIf { it.isNotBlank() }
|
||||
else -> pref
|
||||
}
|
||||
return resolveAudioLanguagePreference(profile, meta, Locale.getDefault().language)
|
||||
}
|
||||
|
||||
fun resolvePreferredAudioLanguage(profile: UserProfile?, meta: Meta): String {
|
||||
|
|
|
|||
|
|
@ -17,27 +17,10 @@ import okhttp3.Dns
|
|||
import org.json.JSONObject
|
||||
import java.net.Inet4Address
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
internal data class SubtitleInfo(
|
||||
val languageTag: String,
|
||||
val label: String,
|
||||
val url: String,
|
||||
val mimeType: String,
|
||||
val isAuto: Boolean
|
||||
)
|
||||
|
||||
internal data class TrailerResult(
|
||||
val streamUrl: String,
|
||||
val audioUrl: String?,
|
||||
val subtitles: List<SubtitleInfo>,
|
||||
val streamMimeType: String?
|
||||
)
|
||||
|
||||
internal sealed interface TrailerResolveResult {
|
||||
data class Ok(val data: TrailerResult) : TrailerResolveResult
|
||||
data object GeoBlocked : TrailerResolveResult
|
||||
data object Failed : TrailerResolveResult
|
||||
}
|
||||
import com.fluxa.app.player.TrailerPolicy
|
||||
import com.fluxa.app.player.TrailerResolveResult
|
||||
import com.fluxa.app.player.TrailerResult
|
||||
import com.fluxa.app.player.TrailerSubtitle
|
||||
|
||||
internal object TrailerResolver {
|
||||
|
||||
|
|
@ -52,11 +35,8 @@ internal object TrailerResolver {
|
|||
|
||||
fun init(cacheDir: java.io.File) = Unit
|
||||
|
||||
private fun videoId(youtubeUrl: String): String? =
|
||||
Regex("(?:v=|youtu\\.be/|embed/)([A-Za-z0-9_-]{11})").find(youtubeUrl)?.groupValues?.get(1)
|
||||
|
||||
suspend fun resolve(youtubeUrl: String): TrailerResolveResult = withContext(Dispatchers.IO) {
|
||||
val id = videoId(youtubeUrl) ?: return@withContext TrailerResolveResult.Failed
|
||||
val id = TrailerPolicy.youtubeVideoId(youtubeUrl) ?: return@withContext TrailerResolveResult.Failed
|
||||
memCache[id]?.let { return@withContext it }
|
||||
|
||||
val result = try {
|
||||
|
|
@ -126,7 +106,7 @@ internal object TrailerResolver {
|
|||
(0 until tracks.length()).mapNotNull { index ->
|
||||
tracks.optJSONObject(index)?.let { track ->
|
||||
val url = track.optString("baseUrl")
|
||||
if (url.isBlank()) null else SubtitleInfo(
|
||||
if (url.isBlank()) null else TrailerSubtitle(
|
||||
languageTag = track.optString("languageCode", "und"),
|
||||
label = track.optJSONObject("name")?.optString("simpleText")?.ifBlank { track.optString("languageCode") } ?: "",
|
||||
url = url,
|
||||
|
|
@ -187,7 +167,8 @@ internal object TrailerResolver {
|
|||
val pixels = resolution?.let { it.groupValues[1].toLong() * it.groupValues[2].toLong() } ?: 0L
|
||||
val bandwidth = Regex("BANDWIDTH=(\\d+)").find(streamAttributes)?.groupValues?.get(1)?.toLongOrNull() ?: 0L
|
||||
val url = masterUrl.toHttpUrlOrNull()?.resolve(line)?.toString() ?: return@forEach
|
||||
if (best == null || pixels > best!!.first || (pixels == best!!.first && bandwidth > best!!.second)) {
|
||||
val currentBest = best
|
||||
if (currentBest == null || pixels > currentBest.first || (pixels == currentBest.first && bandwidth > currentBest.second)) {
|
||||
best = Triple(pixels, bandwidth, url)
|
||||
}
|
||||
}
|
||||
|
|
@ -203,7 +184,7 @@ internal object TrailerResolver {
|
|||
subtitles = response.optJSONArray("subtitles")?.let { tracks ->
|
||||
(0 until tracks.length()).map { i ->
|
||||
val track = tracks.getJSONObject(i)
|
||||
SubtitleInfo(
|
||||
TrailerSubtitle(
|
||||
languageTag = track.getString("languageTag"),
|
||||
label = track.getString("label"),
|
||||
url = track.getString("url"),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import com.fluxa.app.data.remote.*
|
|||
import com.fluxa.app.data.repository.*
|
||||
import com.fluxa.app.domain.discovery.*
|
||||
import com.fluxa.app.shared.feature.player.PlayerContentUiModel
|
||||
import com.fluxa.app.shared.feature.player.formatPlayerTime
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
|
|
@ -379,7 +380,7 @@ internal fun TvPlayerUIContent(
|
|||
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
text = formatTime(if (isScrubbing) scrubPosition else position),
|
||||
text = formatPlayerTime(if (isScrubbing) scrubPosition else position),
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
|
|
@ -397,7 +398,7 @@ internal fun TvPlayerUIContent(
|
|||
Text(
|
||||
text = when {
|
||||
isSwitchingAudioSource -> playerText(lang, "english_source")
|
||||
hasStartedPlaying && duration > 0 -> formatTime(duration)
|
||||
hasStartedPlaying && duration > 0 -> formatPlayerTime(duration)
|
||||
else -> playerStatusText(lang, detailedStatus)
|
||||
},
|
||||
color = Color.White,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
@file:OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class)
|
||||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.shared.feature.player.formatPlayerTime
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
|
|
@ -198,7 +199,7 @@ internal fun MobilePlayerSeekbar(
|
|||
)
|
||||
}
|
||||
Text(
|
||||
text = formatTime(sliderPosition.toLong()),
|
||||
text = formatPlayerTime(sliderPosition.toLong()),
|
||||
color = Color.White,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.common.AppStrings
|
||||
import com.fluxa.app.shared.feature.player.formatPlayerTime
|
||||
import com.fluxa.app.data.local.*
|
||||
import com.fluxa.app.data.remote.*
|
||||
import com.fluxa.app.data.repository.*
|
||||
|
|
@ -207,7 +208,7 @@ internal fun MobilePlayerUIContent(
|
|||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = formatTime(if (isScrubbing) scrubPosition else position),
|
||||
text = formatPlayerTime(if (isScrubbing) scrubPosition else position),
|
||||
color = Color.White,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
|
|
@ -230,7 +231,7 @@ internal fun MobilePlayerUIContent(
|
|||
)
|
||||
}
|
||||
Text(
|
||||
text = formatTime(duration),
|
||||
text = formatPlayerTime(duration),
|
||||
color = Color.White.copy(alpha = 0.84f),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
|
|
|
|||
|
|
@ -409,8 +409,8 @@ class FluxaCoreBenchmarkTest {
|
|||
assertTrue(nativeCacheStorageFunctions.isEmpty())
|
||||
assertTrue(nativeFunctionNames.any { it == "cacheEntryPolicyJsonNative" })
|
||||
assertTrue(nativeFunctionNames.any { it == "cacheTrimPolicyJsonNative" })
|
||||
assertTrue(nativeFunctionNames.any { it == "startLocalStreamServerNative" })
|
||||
assertTrue(nativeFunctionNames.any { it == "stopLocalStreamServerNative" })
|
||||
assertFalse(nativeFunctionNames.any { it == "startLocalStreamServerNative" })
|
||||
assertFalse(nativeFunctionNames.any { it == "stopLocalStreamServerNative" })
|
||||
assertTrue(nativeFunctionNames.any { it == "parseManifestJsonNative" })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ class FluxaCorePlatformContractTest {
|
|||
assertEquals(600L, prefs.playerForwardBufferSeconds)
|
||||
assertEquals(0L, prefs.playerBackBufferSeconds)
|
||||
assertEquals("modern", prefs.detailEpisodeViewMode)
|
||||
assertEquals("dv8", prefs.dolbyVisionFallbackMode)
|
||||
assertEquals("hdr10", prefs.dolbyVisionFallbackMode)
|
||||
assertEquals("manual", prefs.streamSourceSelectionMode)
|
||||
}
|
||||
|
||||
|
|
@ -327,9 +327,9 @@ class FluxaCorePlatformContractTest {
|
|||
@Test
|
||||
fun nativeCoreSurfaceDoesNotExposeAddonResultMutationApis() {
|
||||
val publicMethodNames = FluxaCoreNative::class.java.methods.map { it.name.lowercase() }
|
||||
val mutationWords = listOf("rank", "sort", "reorder", "filterstreams", "filtermeta")
|
||||
val forbiddenMethods = setOf("rankAddonResults", "reorderAddonResults", "filterAddonStreams", "filterAddonMeta")
|
||||
|
||||
assertFalse(publicMethodNames.any { method -> mutationWords.any(method::contains) })
|
||||
assertFalse(publicMethodNames.any { it in forbiddenMethods.map(String::lowercase) })
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -498,9 +498,9 @@ class FluxaCorePlatformContractTest {
|
|||
"force" to true
|
||||
)
|
||||
)
|
||||
assertEquals("readHomeBootstrap", home.effects.single().type)
|
||||
assertEquals("p1", home.effects.single().payload["profileId"])
|
||||
assertEquals("tr", home.effects.single().payload["language"])
|
||||
val homeBootstrap = home.effects.single { it.type == "readHomeBootstrap" }
|
||||
assertEquals("p1", homeBootstrap.payload["profileId"])
|
||||
assertEquals("tr", homeBootstrap.payload["language"])
|
||||
|
||||
val library = engine.dispatch(
|
||||
mapOf(
|
||||
|
|
@ -508,8 +508,8 @@ class FluxaCorePlatformContractTest {
|
|||
"item" to mapOf("id" to "tt1", "name" to "Movie", "type" to "movie")
|
||||
)
|
||||
)
|
||||
assertEquals("writeLibraryCommand", library.effects.single().type)
|
||||
assertEquals("p1", library.effects.single().payload["profileId"])
|
||||
val libraryCommand = library.effects.single { it.type == "writeLibraryCommand" }
|
||||
assertEquals("p1", libraryCommand.payload["profileId"])
|
||||
|
||||
val progress = engine.dispatch(
|
||||
mapOf(
|
||||
|
|
@ -642,8 +642,8 @@ class FluxaCorePlatformContractTest {
|
|||
assertTrue(android.storage)
|
||||
assertTrue(android.player)
|
||||
assertTrue(android.plugins)
|
||||
assertTrue(android.torrent)
|
||||
assertTrue(android.localStream)
|
||||
assertFalse(android.torrent)
|
||||
assertFalse(android.localStream)
|
||||
|
||||
assertTrue(portable.http)
|
||||
assertTrue(portable.storage)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
package com.fluxa.app.data.repository
|
||||
|
||||
import com.fluxa.app.data.remote.MetaDetailResponse
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.fluxa.app.data.remote.decodeMetaDetailPayload
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class StremioAddonResourceClientTest {
|
||||
|
||||
@Test
|
||||
fun aiometadataMetaDetailKeepsEpisodesTrailersAndNullableSeasonPosters() {
|
||||
val json = """
|
||||
|
|
@ -44,9 +43,7 @@ class StremioAddonResourceClientTest {
|
|||
}
|
||||
""".trimIndent()
|
||||
|
||||
val detail = GsonBuilder().create()
|
||||
.fromJson(json, MetaDetailResponse::class.java)
|
||||
.meta!!
|
||||
val detail = decodeMetaDetailPayload(json)!!
|
||||
|
||||
val episode = detail.videos!!.single()
|
||||
assertEquals("Step Into My Office", episode.name)
|
||||
|
|
@ -77,9 +74,7 @@ class StremioAddonResourceClientTest {
|
|||
}
|
||||
""".trimIndent()
|
||||
|
||||
val detail = GsonBuilder().create()
|
||||
.fromJson(json, MetaDetailResponse::class.java)
|
||||
.meta!!
|
||||
val detail = decodeMetaDetailPayload(json)!!
|
||||
|
||||
assertEquals(
|
||||
listOf("Nicolas Cage", "Pedro Pascal", "Bella Ramsey"),
|
||||
|
|
|
|||
|
|
@ -27,15 +27,15 @@ class DolbyVisionShaderMathTest {
|
|||
}
|
||||
|
||||
private val m2Inv = arrayOf(
|
||||
floatArrayOf(1f, 1f, 1f),
|
||||
floatArrayOf(0.0086053084f, -0.0086053084f, 0.5600319713f),
|
||||
floatArrayOf(0.1110296250f, -0.1110296250f, -0.3206271744f)
|
||||
floatArrayOf(1f, 0.0086053084f, 0.1110296250f),
|
||||
floatArrayOf(1f, -0.0086053084f, -0.1110296250f),
|
||||
floatArrayOf(1f, 0.5600319713f, -0.3206271744f)
|
||||
)
|
||||
|
||||
private val mLmsXyz = arrayOf(
|
||||
floatArrayOf(2.0701800566f, 0.3650292938f, -0.0495955500f),
|
||||
floatArrayOf(-1.3264812278f, 0.6804494857f, -0.0494211680f),
|
||||
floatArrayOf(0.2066101766f, -0.0454801996f, 1.1879355232f)
|
||||
floatArrayOf(2.0701800566f, -1.3264812278f, 0.2066101766f),
|
||||
floatArrayOf(0.3650292938f, 0.6804494857f, -0.0454801996f),
|
||||
floatArrayOf(-0.0495955500f, -0.0494211680f, 1.1879355232f)
|
||||
)
|
||||
|
||||
private val mXyzBt709 = arrayOf(
|
||||
|
|
@ -83,7 +83,11 @@ class DolbyVisionShaderMathTest {
|
|||
val lms = floatArrayOf(pqEotf(lmsPq[0]), pqEotf(lmsPq[1]), pqEotf(lmsPq[2]))
|
||||
val xyz = mat3Mul(mLmsXyz, lms)
|
||||
val rgb = mat3Mul(mXyzBt2020, xyz).map { max(it, 0f) }.toFloatArray()
|
||||
return floatArrayOf(pqOetf(rgb[0]), pqOetf(rgb[1]), pqOetf(rgb[2]))
|
||||
return floatArrayOf(
|
||||
pqOetf(rgb[0].coerceIn(0f, 1f)),
|
||||
pqOetf(rgb[1].coerceIn(0f, 1f)),
|
||||
pqOetf(rgb[2].coerceIn(0f, 1f))
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.player.STREAM_SOURCE_MODE_FIRST
|
||||
import com.fluxa.app.player.STREAM_SOURCE_MODE_MANUAL
|
||||
import com.fluxa.app.player.STREAM_SOURCE_MODE_REGEX
|
||||
|
||||
import com.fluxa.app.data.remote.Stream
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
|
|
|||
128
build.gradle.kts
128
build.gradle.kts
|
|
@ -24,14 +24,14 @@ val rustStreamingHostLibraryName = when {
|
|||
else -> "libfluxa_streaming_engine.so"
|
||||
}
|
||||
val rustCoreDelegateFiles = mapOf(
|
||||
"data/src/main/java/com/fluxa/app/domain/discovery/StremioAddonUrls.kt" to listOf(
|
||||
"data/src/androidMain/kotlin/com/fluxa/app/domain/discovery/StremioAddonUrls.kt" to listOf(
|
||||
"FluxaCoreNative.normalizeManifestUrl",
|
||||
"FluxaCoreNative.identity",
|
||||
"FluxaCoreNative.manifestCandidates",
|
||||
"FluxaCoreNative.baseUrl",
|
||||
"FluxaCoreNative.preferHttpsAssetUrl"
|
||||
),
|
||||
"data/src/main/java/com/fluxa/app/domain/discovery/StremioAddonProtocol.kt" to listOf(
|
||||
"data/src/androidMain/kotlin/com/fluxa/app/domain/discovery/StremioAddonProtocol.kt" to listOf(
|
||||
"FluxaCoreNative.supportsResource"
|
||||
),
|
||||
"app/src/main/java/com/fluxa/app/core/StremioId.kt" to listOf(
|
||||
|
|
@ -41,7 +41,7 @@ val rustCoreDelegateFiles = mapOf(
|
|||
"data/src/androidMain/kotlin/com/fluxa/app/data/remote/StreamPlaybackResolver.android.kt" to listOf(
|
||||
"FluxaCoreNative.streamPlaybackInfo"
|
||||
),
|
||||
"player/src/main/java/com/fluxa/app/player/TorrentStreamManager.kt" to listOf(
|
||||
"player/src/androidMain/kotlin/com/fluxa/app/player/TorrentStreamManager.kt" to listOf(
|
||||
"TorrentCorePolicy.plan",
|
||||
"TorrentCorePolicy.statusInfo"
|
||||
),
|
||||
|
|
@ -50,35 +50,35 @@ val rustCoreDelegateFiles = mapOf(
|
|||
"FluxaCoreNative.streamPlaybackInfo",
|
||||
"FluxaCoreNative.isTorrentPlaybackUrl"
|
||||
),
|
||||
"data/src/main/java/com/fluxa/app/data/repository/StremioAddonManifestClient.kt" to listOf(
|
||||
"data/src/androidMain/kotlin/com/fluxa/app/data/repository/StremioAddonManifestClient.kt" to listOf(
|
||||
"FluxaCoreNative.buildResourceUrl",
|
||||
"FluxaCoreNative.manifestFetchPlan",
|
||||
"FluxaCoreNative.parseManifestJson",
|
||||
"FluxaCoreNative.resolveManifestAssets",
|
||||
"FluxaCoreNative.mergeLiveManifest"
|
||||
),
|
||||
"data/src/main/java/com/fluxa/app/data/repository/StremioAddonResourceClient.kt" to listOf(
|
||||
"data/src/androidMain/kotlin/com/fluxa/app/data/repository/StremioAddonResourceClient.kt" to listOf(
|
||||
"FluxaCoreNative.parseAddonResourceResult",
|
||||
"FluxaCoreNative.parseExtraArgs"
|
||||
),
|
||||
"player/src/main/java/com/fluxa/app/player/TorrentServerEngine.kt" to listOf(
|
||||
"player/src/androidMain/kotlin/com/fluxa/app/player/TorrentServerEngine.kt" to listOf(
|
||||
"FluxaStreamingNative.startTorrentServer",
|
||||
"FluxaStreamingNative.stopTorrentServer"
|
||||
),
|
||||
"player/src/main/java/com/fluxa/app/player/TorrentCorePolicy.kt" to listOf(
|
||||
"player/src/androidMain/kotlin/com/fluxa/app/player/TorrentCorePolicy.kt" to listOf(
|
||||
"FluxaCoreNative.torrentRuntimeInfo",
|
||||
"FluxaCoreNative.torrentStatusInfo"
|
||||
),
|
||||
"player/src/main/java/com/fluxa/app/player/MediaPlayerController.kt" to listOf(
|
||||
"player/src/androidMain/kotlin/com/fluxa/app/player/MediaPlayerController.kt" to listOf(
|
||||
"FluxaStreamingNative.dvRewriteSegmentBytes"
|
||||
),
|
||||
"app/src/main/java/com/fluxa/app/ui/catalog/StreamSourceSelectionPolicy.kt" to listOf(
|
||||
"app/src/main/java/com/fluxa/app/ui/catalog/AndroidStreamSourceSelectionPolicy.kt" to listOf(
|
||||
"FluxaCoreNative.selectStreamIndex"
|
||||
),
|
||||
"app/src/main/java/com/fluxa/app/ui/catalog/ContinueWatchingListMerger.kt" to listOf(
|
||||
"FluxaCoreNative.mergeContinueWatchingDuplicates"
|
||||
),
|
||||
"data/src/main/java/com/fluxa/app/domain/discovery/DiscoverCatalogContentLoader.kt" to listOf(
|
||||
"data/src/androidMain/kotlin/com/fluxa/app/domain/discovery/DiscoverCatalogContentLoader.kt" to listOf(
|
||||
"FluxaCoreNative.filterDiscoverResults",
|
||||
"FluxaCoreNative.discoverCatalogCacheKey",
|
||||
"FluxaCoreNative.providerSearchTerms"
|
||||
|
|
@ -86,7 +86,7 @@ val rustCoreDelegateFiles = mapOf(
|
|||
"app/src/main/java/com/fluxa/app/domain/discovery/StreamDiscovery.kt" to listOf(
|
||||
"FluxaCoreNative.streamDiscoveryExecutionPolicy"
|
||||
),
|
||||
"data/src/main/java/com/fluxa/app/domain/discovery/MetadataFeeds.kt" to listOf(
|
||||
"data/src/androidMain/kotlin/com/fluxa/app/domain/discovery/MetadataFeeds.kt" to listOf(
|
||||
"FluxaCoreNative.normalizeContentType",
|
||||
"FluxaCoreNative.stableFeedPart",
|
||||
"FluxaCoreNative.effectiveMetadataFeedSelection",
|
||||
|
|
@ -95,7 +95,7 @@ val rustCoreDelegateFiles = mapOf(
|
|||
"FluxaCoreNative.orderedMetadataFeedKeys",
|
||||
"FluxaCoreNative.moveMetadataFeedOrder"
|
||||
),
|
||||
"data/src/main/java/com/fluxa/app/domain/ContentIdentity.kt" to listOf(
|
||||
"data/src/androidMain/kotlin/com/fluxa/app/domain/ContentIdentity.kt" to listOf(
|
||||
"FluxaCoreNative.contentTraktKey",
|
||||
"FluxaCoreNative.contentMergeKeys",
|
||||
"FluxaCoreNative.contentWatchedKeysBatch"
|
||||
|
|
@ -110,7 +110,7 @@ val rustCoreDelegateFiles = mapOf(
|
|||
"FluxaHeadlessEngine",
|
||||
"HeadlessPlatformEnvironment"
|
||||
),
|
||||
"data/src/main/java/com/fluxa/app/core/rust/FluxaCoreNative.kt" to listOf(
|
||||
"data/src/androidMain/kotlin/com/fluxa/app/core/rust/FluxaCoreNative.kt" to listOf(
|
||||
"NativeCoreCapabilitySet",
|
||||
"coreCapabilitiesJsonNative"
|
||||
),
|
||||
|
|
@ -132,7 +132,7 @@ val rustCoreDelegateFiles = mapOf(
|
|||
"FluxaCoreNative.subtitleLanguageMatches",
|
||||
"preferredSubtitleIndex"
|
||||
),
|
||||
"data/src/main/java/com/fluxa/app/data/repository/TraktIntegration.kt" to listOf(
|
||||
"data/src/androidMain/kotlin/com/fluxa/app/data/repository/TraktIntegration.kt" to listOf(
|
||||
"FluxaCoreNative.traktHasClient",
|
||||
"FluxaCoreNative.traktBearer",
|
||||
"FluxaCoreNative.traktScrobbleUrl",
|
||||
|
|
@ -145,7 +145,7 @@ val rustCoreDelegateFiles = mapOf(
|
|||
"FluxaCoreNative.traktScrobbleMediaId",
|
||||
"FluxaCoreNative.traktHistoryRequest"
|
||||
),
|
||||
"data/src/main/java/com/fluxa/app/data/repository/StremioRepository.kt" to listOf(
|
||||
"data/src/androidMain/kotlin/com/fluxa/app/data/repository/StremioRepository.kt" to listOf(
|
||||
"FluxaCoreNative.libraryContinueWatchingItems",
|
||||
"FluxaCoreNative.watchedVideoIds",
|
||||
"FluxaCoreNative.playbackProgressItem",
|
||||
|
|
@ -153,11 +153,11 @@ val rustCoreDelegateFiles = mapOf(
|
|||
"FluxaCoreNative.watchedStateItems",
|
||||
"FluxaCoreNative.traktHistoryRequest"
|
||||
),
|
||||
"data/src/main/java/com/fluxa/app/data/local/ProfileManager.kt" to listOf(
|
||||
"data/src/androidMain/kotlin/com/fluxa/app/data/local/ProfileManager.kt" to listOf(
|
||||
"FluxaCoreNative.sanitizeProfile",
|
||||
"FluxaCoreNative.profileLocalAddonsKey"
|
||||
),
|
||||
"data/src/main/java/com/fluxa/app/data/local/UserProfileSafePrefs.kt" to listOf(
|
||||
"data/src/androidMain/kotlin/com/fluxa/app/data/local/UserProfileSafePrefs.kt" to listOf(
|
||||
"FluxaCoreNative.safePlayerBufferCacheMb",
|
||||
"FluxaCoreNative.safeStreamSourceSelectionMode"
|
||||
),
|
||||
|
|
@ -269,6 +269,86 @@ tasks.register("checkKmpCommonBoundary") {
|
|||
}
|
||||
}
|
||||
|
||||
tasks.register("checkSharedTransportModels") {
|
||||
group = "verification"
|
||||
description = "Fails when portable account and streaming models return to Android-only source sets."
|
||||
|
||||
doLast {
|
||||
val forbiddenAndroidModels = listOf(
|
||||
"data/src/androidMain/kotlin/com/fluxa/app/data/remote/NuvioModels.kt",
|
||||
"data/src/androidMain/kotlin/com/fluxa/app/data/remote/StremioModels.kt",
|
||||
"data/src/androidMain/kotlin/com/fluxa/app/data/remote/TraktModels.kt",
|
||||
"data/src/androidMain/kotlin/com/fluxa/app/player/TorrentModels.kt"
|
||||
)
|
||||
val requiredCommonModels = listOf(
|
||||
"data/src/commonMain/kotlin/com/fluxa/app/data/remote/NuvioModels.kt",
|
||||
"data/src/commonMain/kotlin/com/fluxa/app/data/remote/StremioModels.kt",
|
||||
"data/src/commonMain/kotlin/com/fluxa/app/data/remote/TraktModels.kt",
|
||||
"data/src/commonMain/kotlin/com/fluxa/app/player/TorrentModels.kt"
|
||||
)
|
||||
val violations = forbiddenAndroidModels.filter { rootProject.file(it).exists() }
|
||||
.map { "$it must not exist" } +
|
||||
requiredCommonModels.filterNot { rootProject.file(it).exists() }
|
||||
.map { "$it is required" }
|
||||
if (violations.isNotEmpty()) {
|
||||
throw GradleException(violations.joinToString("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("checkLegacySourceSets") {
|
||||
group = "verification"
|
||||
description = "Fails when legacy Android compatibility source trees or mappings return."
|
||||
|
||||
doLast {
|
||||
val violations = mutableListOf<String>()
|
||||
listOf("data", "player").forEach { module ->
|
||||
val legacyRoot = rootProject.file("$module/src/main/java")
|
||||
if (legacyRoot.exists() && legacyRoot.walkTopDown().any { it.isFile }) {
|
||||
violations += "$module/src/main/java must remain empty"
|
||||
}
|
||||
val buildFile = rootProject.file("$module/build.gradle.kts").readText()
|
||||
if (buildFile.contains("src/main/java")) {
|
||||
violations += "$module/build.gradle.kts must not map src/main/java"
|
||||
}
|
||||
}
|
||||
if (violations.isNotEmpty()) {
|
||||
throw GradleException(violations.joinToString("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("checkNoDesktopTargets") {
|
||||
group = "verification"
|
||||
description = "Fails when desktop application targets or desktop source sets return."
|
||||
|
||||
doLast {
|
||||
val sourceSetViolations = listOf("core", "data", "player", "shared")
|
||||
.map { module -> rootProject.file("$module/src/desktop" + "Main") }
|
||||
.filter { sourceSet -> sourceSet.exists() }
|
||||
.map { sourceSet -> "${sourceSet.relativeTo(rootDir)} must not exist" }
|
||||
val buildFiles = fileTree(rootDir) {
|
||||
include("**/*.gradle.kts", "**/*.gradle")
|
||||
exclude("**/build/**", ".gradle/**")
|
||||
}
|
||||
val forbiddenTokens = listOf(
|
||||
"jvm(\"desktop\")",
|
||||
"desktop" + "Main",
|
||||
"compose.desktop" + ".application"
|
||||
)
|
||||
val buildViolations = buildFiles.files.flatMap { file ->
|
||||
val text = file.readText()
|
||||
forbiddenTokens.filter(text::contains).map { token ->
|
||||
"${file.relativeTo(rootDir)} must not declare $token"
|
||||
}
|
||||
}
|
||||
val violations = sourceSetViolations + buildViolations
|
||||
if (violations.isNotEmpty()) {
|
||||
throw GradleException(violations.joinToString("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("checkAppleTypedCatalogBridge") {
|
||||
group = "verification"
|
||||
description = "Fails when the Apple catalog bridge falls back to JSON or notification handoffs."
|
||||
|
|
@ -424,7 +504,7 @@ tasks.register("checkRustCoreBoundary") {
|
|||
.map { call -> "$relativePath must delegate to $call" }
|
||||
}
|
||||
|
||||
val urlFacade = rootProject.file("data/src/main/java/com/fluxa/app/domain/discovery/StremioAddonUrls.kt")
|
||||
val urlFacade = rootProject.file("data/src/androidMain/kotlin/com/fluxa/app/domain/discovery/StremioAddonUrls.kt")
|
||||
val duplicatedUrlLogic = if (urlFacade.exists()) {
|
||||
val text = urlFacade.readText()
|
||||
listOf("http://", "https://", "stremio://", "manifest.json", "Regex(")
|
||||
|
|
@ -485,7 +565,7 @@ tasks.register("checkFluxaCoreJniSymbols") {
|
|||
dependsOn("buildFluxaCoreHost")
|
||||
|
||||
doLast {
|
||||
val nativeFile = rootProject.file("data/src/main/java/com/fluxa/app/core/rust/FluxaCoreNative.kt")
|
||||
val nativeFile = rootProject.file("data/src/androidMain/kotlin/com/fluxa/app/core/rust/FluxaCoreNative.kt")
|
||||
val libraryFile = rustCoreProjectDir.resolve("target/debug/$rustHostLibraryName")
|
||||
if (!nativeFile.exists()) {
|
||||
throw GradleException("${nativeFile.relativeTo(rootDir)} is missing")
|
||||
|
|
@ -537,8 +617,8 @@ tasks.register("checkFluxaStreamingJniSymbols") {
|
|||
dependsOn("buildFluxaStreamingEngineHost")
|
||||
|
||||
doLast {
|
||||
val nativeFile = rootProject.file("player/src/main/java/com/fluxa/app/core/rust/FluxaStreamingNative.kt")
|
||||
val libraryFile = rustCoreProjectDir.resolve("fluxa-streaming-engine/target/debug/$rustStreamingHostLibraryName")
|
||||
val nativeFile = rootProject.file("player/src/androidMain/kotlin/com/fluxa/app/core/rust/FluxaStreamingNative.kt")
|
||||
val libraryFile = rustCoreProjectDir.resolve("target/debug/$rustStreamingHostLibraryName")
|
||||
if (!nativeFile.exists()) {
|
||||
throw GradleException("${nativeFile.relativeTo(rootDir)} is missing")
|
||||
}
|
||||
|
|
@ -588,7 +668,13 @@ tasks.register("qualityCheck") {
|
|||
dependsOn(
|
||||
"checkKotlinFileSize",
|
||||
"checkSharedUiBoundary",
|
||||
"checkKmpCommonBoundary",
|
||||
"checkSharedTransportModels",
|
||||
"checkLegacySourceSets",
|
||||
"checkNoDesktopTargets",
|
||||
"checkAppleTypedCatalogBridge",
|
||||
"checkAppleTvosKmpBoundary",
|
||||
"checkSharedPlayerBoundary",
|
||||
"checkRustCoreBoundary",
|
||||
"checkFluxaCoreJniSymbols",
|
||||
"checkFluxaStreamingJniSymbols",
|
||||
|
|
|
|||
|
|
@ -26,14 +26,22 @@ class FluxaHeadlessEffectRunner(
|
|||
private suspend fun drain(initial: NativeHeadlessEngineResult): NativeHeadlessEngineResult {
|
||||
var current = initial
|
||||
val patches = mutableListOf(initial)
|
||||
val pending = ArrayDeque<NativeHeadlessEffect>()
|
||||
val queuedIds = mutableSetOf<String>()
|
||||
initial.effects.forEach { effect ->
|
||||
if (queuedIds.add(effect.id)) pending.addLast(effect)
|
||||
}
|
||||
var remaining = maxEffectsPerDispatch
|
||||
while (current.effects.isNotEmpty() && remaining > 0) {
|
||||
current = engine.completeEffect(environment.execute(current.effects.first()))
|
||||
while (pending.isNotEmpty() && remaining > 0) {
|
||||
current = engine.completeEffect(environment.execute(pending.removeFirst()))
|
||||
patches += current
|
||||
current.effects.forEach { effect ->
|
||||
if (queuedIds.add(effect.id)) pending.addLast(effect)
|
||||
}
|
||||
remaining--
|
||||
}
|
||||
return NativeHeadlessEngineResult(
|
||||
effects = current.effects,
|
||||
effects = pending.toList(),
|
||||
stateProvider = { patches.fold(emptyMap<String, Any?>()) { state, patch -> state + patch.state } }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ data class NativeProfileSafePrefs(
|
|||
val forceSoftwareAudio: Boolean = false,
|
||||
val preferredPlayer: String = "exoplayer",
|
||||
val cardLayout: String = "vertical",
|
||||
val detailEpisodeViewMode: String = "modern",
|
||||
val continueWatchingLayout: String = "horizontal",
|
||||
val continueWatchingArtwork: String = "episode",
|
||||
val continueWatchingEnabled: Boolean = true,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,15 @@
|
|||
package com.fluxa.app.player
|
||||
|
||||
data class NativeTorrentRuntimeInfo(
|
||||
val normalizedLink: String = "",
|
||||
val selectedFileIdx: Int? = null,
|
||||
val selectedReason: String? = null,
|
||||
val fallbackFileIndexes: List<Int> = emptyList(),
|
||||
val streamUrl: String = ""
|
||||
)
|
||||
|
||||
data class NativeTorrentStatusInfo(
|
||||
val bufferProgress: Int = 0,
|
||||
val isPlayableEnough: Boolean = false,
|
||||
val statusKey: String = ""
|
||||
)
|
||||
|
|
@ -67,7 +67,6 @@ kotlin {
|
|||
implementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
androidMain {
|
||||
kotlin.srcDir("src/main/java")
|
||||
dependencies {
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.bundles.coroutines)
|
||||
|
|
|
|||
|
|
@ -311,83 +311,6 @@ data class NativeOfflineDownloadPlan(
|
|||
val streamTitle: String? = null
|
||||
)
|
||||
|
||||
data class NativeProfileSafePrefs(
|
||||
val language: String = "en",
|
||||
val subtitleSizePercent: Float = 100f,
|
||||
val subtitleSize: Float = 20f,
|
||||
val subtitleColor: Int = 0xFFFFFFFF.toInt(),
|
||||
val subtitleBackgroundColor: Int = 0x80000000.toInt(),
|
||||
val subtitleOutlineColor: Int = 0xFF000000.toInt(),
|
||||
val subtitleTextOpacity: Float = 1f,
|
||||
val subtitleBackgroundOpacity: Float = 0.5f,
|
||||
val subtitleOutlineOpacity: Float = 1f,
|
||||
val preferredSubtitleLanguage: String = "none",
|
||||
val preferredAudioLanguage: String = "none",
|
||||
val secondarySubtitleLanguage: String = "none",
|
||||
val secondaryAudioLanguage: String = "none",
|
||||
val ambientLight: Boolean = true,
|
||||
val forceSoftwareAudio: Boolean = false,
|
||||
val preferredPlayer: String = "exoplayer",
|
||||
val cardLayout: String = "vertical",
|
||||
val continueWatchingLayout: String = "horizontal",
|
||||
val continueWatchingArtwork: String = "episode",
|
||||
val continueWatchingEnabled: Boolean = true,
|
||||
val resolvedContinueWatchingLayout: String = "horizontal",
|
||||
val subtitleShadow: Boolean = true,
|
||||
val autoEnableSubtitles: Boolean = true,
|
||||
val autoSkipIntro: Boolean = false,
|
||||
val autoPlayNextEpisode: Boolean = true,
|
||||
val nextEpisodeThresholdPercent: Float = 90f,
|
||||
val watchedThresholdPercent: Float = 80f,
|
||||
val seekForwardSeconds: Long = 10L,
|
||||
val seekBackwardSeconds: Long = 10L,
|
||||
val playerBufferCacheMb: Int = 100,
|
||||
val playerForwardBufferSeconds: Long = 120L,
|
||||
val playerBackBufferSeconds: Long = 30L,
|
||||
val timezoneConversionEnabled: Boolean = true,
|
||||
val torrentWifiOnly: Boolean = false,
|
||||
val torrentMaxConnections: Long = 60L,
|
||||
val torrentSpeedPreset: String = "default",
|
||||
val torrentCachePreset: String = "auto",
|
||||
val appTheme: String = "dark",
|
||||
val accentColorArgb: Int = 0xFFFFFFFF.toInt(),
|
||||
val cardCornerPreset: String = "medium",
|
||||
val interfaceDensity: String = "medium",
|
||||
val amoledMode: Boolean = false,
|
||||
val posterWidthPreset: String = "medium",
|
||||
val posterLandscapeMode: Boolean = false,
|
||||
val posterHideTitles: Boolean = false,
|
||||
val animationsEnabled: Boolean = true,
|
||||
val reduceMotion: Boolean = false,
|
||||
val startPage: String = "home",
|
||||
val notificationsEnabled: Boolean = true,
|
||||
val alertNewEpisodes: Boolean = true,
|
||||
val automaticUpdates: Boolean = true,
|
||||
val backgroundPlayback: Boolean = false,
|
||||
val pictureInPicture: Boolean = true,
|
||||
val playbackSpeed: Float = 1f,
|
||||
val holdToSpeedEnabled: Boolean = true,
|
||||
val holdSpeed: Float = 2f,
|
||||
val dolbyVisionFallbackMode: String = "auto",
|
||||
val tunneledPlayback: Boolean = false,
|
||||
val useIntroDb: Boolean = true,
|
||||
val useAniSkip: Boolean = true,
|
||||
val defaultQuality: String = "1080p",
|
||||
val mobileDataUsage: String = "medium",
|
||||
val hdrPlayback: Boolean = true,
|
||||
val resumePlayback: Boolean = true,
|
||||
val autoplayMode: String = "next_episode",
|
||||
val streamSourceSelectionMode: String = "manual",
|
||||
val streamSourceRegexPattern: String = "",
|
||||
val tryBingeGroup: Boolean = false,
|
||||
val showHeroSection: Boolean = true,
|
||||
val traktTokenExpiresAt: Long = 0L,
|
||||
val traktLastSyncAt: Long = 0L,
|
||||
val traktLastSyncedItems: Long = 0L,
|
||||
val traktLastContinueWatchingCount: Long = 0L,
|
||||
val traktLastWatchlistCount: Long = 0L
|
||||
)
|
||||
|
||||
class FluxaHeadlessEngineHandle internal constructor(
|
||||
private val handle: Long,
|
||||
private val gson: Gson
|
||||
|
|
@ -25,44 +25,8 @@ import okhttp3.Request
|
|||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
data class OfflineSubtitleOption(
|
||||
val label: String,
|
||||
val language: String?,
|
||||
val url: String
|
||||
)
|
||||
|
||||
data class OfflineDownloadItem(
|
||||
val id: String,
|
||||
val profileId: String?,
|
||||
val language: String? = null,
|
||||
val metaId: String,
|
||||
val metaType: String,
|
||||
val title: String,
|
||||
val episodeTitle: String?,
|
||||
val videoId: String?,
|
||||
val poster: String?,
|
||||
val background: String?,
|
||||
val logo: String? = null,
|
||||
val localPosterPath: String? = null,
|
||||
val localBackgroundPath: String? = null,
|
||||
val localLogoPath: String? = null,
|
||||
val streamTitle: String? = null,
|
||||
val videoPath: String = "",
|
||||
val subtitlePath: String? = null,
|
||||
val subtitleLabel: String? = null,
|
||||
val subtitleLanguage: String? = null,
|
||||
val downloadId: Long = 0L,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val status: String = "queued",
|
||||
val progress: Int = 0,
|
||||
val downloadedBytes: Long = 0L,
|
||||
val totalBytes: Long = 0L,
|
||||
val speedBytesPerSecond: Long = 0L,
|
||||
val etaSeconds: Long = -1L,
|
||||
val error: String? = null
|
||||
) {
|
||||
val isPlayable: Boolean get() = status == "downloaded" && File(videoPath).exists()
|
||||
}
|
||||
val OfflineDownloadItem.isPlayable: Boolean
|
||||
get() = status == "downloaded" && File(videoPath).exists()
|
||||
|
||||
class OfflineDownloadManager private constructor(private val context: Context) {
|
||||
private val appContext = context.applicationContext
|
||||
|
|
@ -133,7 +97,8 @@ class OfflineDownloadManager private constructor(private val context: Context) {
|
|||
subtitlePath = downloadedSubtitle?.absolutePath,
|
||||
subtitleLabel = subtitle?.label,
|
||||
subtitleLanguage = subtitle?.language,
|
||||
downloadId = 0L
|
||||
downloadId = 0L,
|
||||
createdAt = System.currentTimeMillis()
|
||||
)
|
||||
upsert(item)
|
||||
val intent = Intent(appContext, OfflineDownloadService::class.java)
|
||||
|
|
@ -2,20 +2,25 @@ package com.fluxa.app.data.remote
|
|||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class NuvioCredentials(val email: String, val password: String)
|
||||
data class NuvioRefreshRequest(@SerializedName("refresh_token") val refreshToken: String)
|
||||
data class NuvioHealth(val status: String? = null)
|
||||
data class NuvioRefreshRequestDto(@SerializedName("refresh_token") val refreshToken: String)
|
||||
|
||||
data class NuvioUser(val id: String, val email: String)
|
||||
fun NuvioRefreshRequest.toDto(): NuvioRefreshRequestDto = NuvioRefreshRequestDto(refreshToken)
|
||||
|
||||
data class NuvioSession(
|
||||
data class NuvioSessionDto(
|
||||
@SerializedName("access_token") val accessToken: String,
|
||||
@SerializedName("refresh_token") val refreshToken: String,
|
||||
@SerializedName("expires_in") val expiresIn: Long?,
|
||||
val user: NuvioUser?
|
||||
)
|
||||
) {
|
||||
fun toDomain(): NuvioSession = NuvioSession(
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken,
|
||||
expiresIn = expiresIn,
|
||||
user = user
|
||||
)
|
||||
}
|
||||
|
||||
data class NuvioProfile(
|
||||
data class NuvioProfileDto(
|
||||
val id: String,
|
||||
@SerializedName("user_id") val userId: String? = null,
|
||||
@SerializedName("profile_index") val profileIndex: Int,
|
||||
|
|
@ -23,9 +28,11 @@ data class NuvioProfile(
|
|||
@SerializedName("avatar_color_hex") val avatarColorHex: String?,
|
||||
@SerializedName("avatar_id") val avatarId: String?,
|
||||
@SerializedName("avatar_url") val avatarUrl: String?
|
||||
)
|
||||
) {
|
||||
fun toDomain(): NuvioProfile = NuvioProfile(id, userId, profileIndex, name, avatarColorHex, avatarId, avatarUrl)
|
||||
}
|
||||
|
||||
data class NuvioAddon(
|
||||
data class NuvioAddonDto(
|
||||
val id: String? = null,
|
||||
@SerializedName("user_id") val userId: String? = null,
|
||||
@SerializedName("profile_id") val profileId: Int? = null,
|
||||
|
|
@ -33,9 +40,11 @@ data class NuvioAddon(
|
|||
val name: String?,
|
||||
val enabled: Boolean,
|
||||
@SerializedName("sort_order") val sortOrder: Int
|
||||
)
|
||||
) {
|
||||
fun toDomain(): NuvioAddon = NuvioAddon(id, userId, profileId, url, name, enabled, sortOrder)
|
||||
}
|
||||
|
||||
data class NuvioLibraryItem(
|
||||
data class NuvioLibraryItemDto(
|
||||
@SerializedName("content_id") val contentId: String,
|
||||
@SerializedName("content_type") val contentType: String,
|
||||
val name: String,
|
||||
|
|
@ -48,9 +57,14 @@ data class NuvioLibraryItem(
|
|||
@SerializedName("poster_shape") val posterShape: String? = null,
|
||||
@SerializedName("addon_base_url") val addonBaseUrl: String? = null,
|
||||
@SerializedName("added_at") val addedAt: Long? = null
|
||||
)
|
||||
) {
|
||||
fun toDomain(): NuvioLibraryItem = NuvioLibraryItem(
|
||||
contentId, contentType, name, poster, background, description, releaseInfo, imdbRating,
|
||||
genres, posterShape, addonBaseUrl, addedAt
|
||||
)
|
||||
}
|
||||
|
||||
data class NuvioWatchProgress(
|
||||
data class NuvioWatchProgressDto(
|
||||
@SerializedName("content_id") val contentId: String,
|
||||
@SerializedName("content_type") val contentType: String,
|
||||
@SerializedName("video_id") val videoId: String,
|
||||
|
|
@ -60,18 +74,24 @@ data class NuvioWatchProgress(
|
|||
val duration: Long,
|
||||
@SerializedName("last_watched") val lastWatched: Long,
|
||||
@SerializedName("progress_key") val progressKey: String? = null
|
||||
)
|
||||
) {
|
||||
fun toDomain(): NuvioWatchProgress = NuvioWatchProgress(
|
||||
contentId, contentType, videoId, season, episode, position, duration, lastWatched, progressKey
|
||||
)
|
||||
}
|
||||
|
||||
data class NuvioWatchedItem(
|
||||
data class NuvioWatchedItemDto(
|
||||
@SerializedName("content_id") val contentId: String,
|
||||
@SerializedName("content_type") val contentType: String,
|
||||
val title: String? = null,
|
||||
val season: Int?,
|
||||
val episode: Int?,
|
||||
@SerializedName("watched_at") val watchedAt: Long? = null
|
||||
)
|
||||
) {
|
||||
fun toDomain(): NuvioWatchedItem = NuvioWatchedItem(contentId, contentType, title, season, episode, watchedAt)
|
||||
}
|
||||
|
||||
data class NuvioCollectionFolderSource(
|
||||
data class NuvioCollectionFolderSourceDto(
|
||||
val provider: String? = null,
|
||||
@SerializedName("addonId") val addonId: String?,
|
||||
@SerializedName("catalogId") val catalogId: String?,
|
||||
|
|
@ -85,9 +105,14 @@ data class NuvioCollectionFolderSource(
|
|||
@SerializedName("sortBy") val sortBy: String? = null,
|
||||
@SerializedName("sortHow") val sortHow: String? = null,
|
||||
val filters: Map<String, Any?>? = null
|
||||
)
|
||||
) {
|
||||
fun toDomain(): NuvioCollectionFolderSource = NuvioCollectionFolderSource(
|
||||
provider, addonId, catalogId, type, genre, title, mediaType, traktListId,
|
||||
tmdbSourceType, tmdbId, sortBy, sortHow, filters
|
||||
)
|
||||
}
|
||||
|
||||
data class NuvioCollectionFolder(
|
||||
data class NuvioCollectionFolderDto(
|
||||
val id: String?,
|
||||
val title: String?,
|
||||
@SerializedName("coverImageUrl") val coverImageUrl: String?,
|
||||
|
|
@ -99,10 +124,15 @@ data class NuvioCollectionFolder(
|
|||
@SerializedName("heroVideoUrl") val heroVideoUrl: String? = null,
|
||||
@SerializedName("tileShape") val tileShape: String?,
|
||||
@SerializedName("hideTitle") val hideTitle: Boolean?,
|
||||
@SerializedName(value = "catalogSources", alternate = ["sources"]) val catalogSources: List<NuvioCollectionFolderSource>?
|
||||
)
|
||||
@SerializedName(value = "catalogSources", alternate = ["sources"]) val catalogSources: List<NuvioCollectionFolderSourceDto>?
|
||||
) {
|
||||
fun toDomain(): NuvioCollectionFolder = NuvioCollectionFolder(
|
||||
id, title, coverImageUrl, coverEmoji, focusGifUrl, focusGifEnabled, titleLogoUrl,
|
||||
heroBackdropUrl, heroVideoUrl, tileShape, hideTitle, catalogSources?.map { it.toDomain() }
|
||||
)
|
||||
}
|
||||
|
||||
data class NuvioCollection(
|
||||
data class NuvioCollectionDto(
|
||||
val id: String?,
|
||||
val title: String?,
|
||||
@SerializedName("backdropImageUrl") val backdropImageUrl: String?,
|
||||
|
|
@ -111,19 +141,30 @@ data class NuvioCollection(
|
|||
@SerializedName("viewMode") val viewMode: String?,
|
||||
@SerializedName("showAllTab") val showAllTab: Boolean?,
|
||||
@SerializedName("focusGlowEnabled") val focusGlowEnabled: Boolean? = null,
|
||||
val folders: List<NuvioCollectionFolder>?,
|
||||
val folders: List<NuvioCollectionFolderDto>?,
|
||||
val community: Map<String, Any?>? = null
|
||||
)
|
||||
) {
|
||||
fun toDomain(): NuvioCollection = NuvioCollection(
|
||||
id, title, backdropImageUrl, pinToTop, showOnHome, viewMode, showAllTab,
|
||||
focusGlowEnabled, folders?.map { it.toDomain() }, community
|
||||
)
|
||||
}
|
||||
|
||||
data class NuvioCollectionRow(
|
||||
@SerializedName("collections_json") val collectionsJson: List<NuvioCollection>?
|
||||
)
|
||||
data class NuvioCollectionRowDto(
|
||||
@SerializedName("collections_json") val collectionsJson: List<NuvioCollectionDto>?
|
||||
) {
|
||||
fun toDomain(): NuvioCollectionRow = NuvioCollectionRow(collectionsJson?.map { it.toDomain() })
|
||||
}
|
||||
|
||||
data class NuvioProfileSettingsRow(
|
||||
data class NuvioProfileSettingsRowDto(
|
||||
@SerializedName("settings_json") val settingsJson: Map<String, Any?>?
|
||||
)
|
||||
) {
|
||||
fun toDomain(): NuvioProfileSettingsRow = NuvioProfileSettingsRow(settingsJson)
|
||||
}
|
||||
|
||||
data class NuvioAvatar(
|
||||
data class NuvioAvatarDto(
|
||||
val id: String,
|
||||
@SerializedName("storage_path") val storagePath: String?
|
||||
)
|
||||
) {
|
||||
fun toDomain(): NuvioAvatar = NuvioAvatar(id, storagePath)
|
||||
}
|
||||
|
|
@ -14,19 +14,19 @@ interface NuvioService {
|
|||
suspend fun healthCheck(): Response<NuvioHealth>
|
||||
|
||||
@POST("auth/v1/signup")
|
||||
suspend fun signUp(@Body credentials: NuvioCredentials): Response<NuvioSession>
|
||||
suspend fun signUp(@Body credentials: NuvioCredentials): Response<NuvioSessionDto>
|
||||
|
||||
@POST("auth/v1/token")
|
||||
suspend fun signIn(@Query("grant_type") grantType: String = "password", @Body credentials: NuvioCredentials): Response<NuvioSession>
|
||||
suspend fun signIn(@Query("grant_type") grantType: String = "password", @Body credentials: NuvioCredentials): Response<NuvioSessionDto>
|
||||
|
||||
@POST("auth/v1/token")
|
||||
suspend fun refreshToken(@Query("grant_type") grantType: String = "refresh_token", @Body request: NuvioRefreshRequest): Response<NuvioSession>
|
||||
suspend fun refreshToken(@Query("grant_type") grantType: String = "refresh_token", @Body request: NuvioRefreshRequestDto): Response<NuvioSessionDto>
|
||||
|
||||
@GET("auth/v1/user")
|
||||
suspend fun getUser(@Header("Authorization") authorization: String): Response<NuvioUser>
|
||||
|
||||
@POST("rest/v1/rpc/sync_pull_profiles")
|
||||
suspend fun pullProfiles(@Header("Authorization") authorization: String, @Body body: Map<String, String> = emptyMap()): Response<List<NuvioProfile>>
|
||||
suspend fun pullProfiles(@Header("Authorization") authorization: String, @Body body: Map<String, String> = emptyMap()): Response<List<NuvioProfileDto>>
|
||||
|
||||
@GET("rest/v1/addons")
|
||||
suspend fun pullAddons(
|
||||
|
|
@ -34,7 +34,7 @@ interface NuvioService {
|
|||
@Query("select") select: String = "*",
|
||||
@Query("profile_id") profileId: String,
|
||||
@Query("order") order: String = "sort_order"
|
||||
): Response<List<NuvioAddon>>
|
||||
): Response<List<NuvioAddonDto>>
|
||||
|
||||
@POST("rest/v1/rpc/sync_push_profiles")
|
||||
suspend fun pushProfiles(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any?>): Response<Unit>
|
||||
|
|
@ -79,20 +79,20 @@ interface NuvioService {
|
|||
suspend fun pushProfileSettings(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any?>): Response<Unit>
|
||||
|
||||
@POST("rest/v1/rpc/sync_pull_library")
|
||||
suspend fun pullLibrary(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioLibraryItem>>
|
||||
suspend fun pullLibrary(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioLibraryItemDto>>
|
||||
|
||||
@POST("rest/v1/rpc/sync_pull_watch_progress")
|
||||
suspend fun pullWatchProgress(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioWatchProgress>>
|
||||
suspend fun pullWatchProgress(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioWatchProgressDto>>
|
||||
|
||||
@POST("rest/v1/rpc/sync_pull_watched_items")
|
||||
suspend fun pullWatchedItems(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioWatchedItem>>
|
||||
suspend fun pullWatchedItems(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioWatchedItemDto>>
|
||||
|
||||
@POST("rest/v1/rpc/sync_pull_collections")
|
||||
suspend fun pullCollections(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioCollectionRow>>
|
||||
suspend fun pullCollections(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioCollectionRowDto>>
|
||||
|
||||
@POST("rest/v1/rpc/sync_pull_profile_settings_blob")
|
||||
suspend fun pullProfileSettings(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioProfileSettingsRow>>
|
||||
suspend fun pullProfileSettings(@Header("Authorization") authorization: String, @Body body: Map<String, @JvmSuppressWildcards Any>): Response<List<NuvioProfileSettingsRowDto>>
|
||||
|
||||
@POST("rest/v1/rpc/get_avatar_catalog")
|
||||
suspend fun listAvatars(@Body body: Map<String, String> = emptyMap()): Response<List<NuvioAvatar>>
|
||||
suspend fun listAvatars(@Body body: Map<String, String> = emptyMap()): Response<List<NuvioAvatarDto>>
|
||||
}
|
||||
|
|
@ -12,17 +12,6 @@ import kotlinx.coroutines.coroutineScope
|
|||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Fans mark-watched / watchlist actions out to whichever of Trakt, Simkl, and MAL
|
||||
* the active profile has connected. Mirrors fluxa-desktop's pushMarkWatchedExternal /
|
||||
* pushWatchlistExternal (src/core/externalSync.ts) — each provider's push is independent
|
||||
* and failures are swallowed so one provider never blocks another or the local action.
|
||||
*
|
||||
* Each provider push only counts as successful when the HTTP response actually says so —
|
||||
* a 401 clears (Simkl) or refreshes-then-retries (MAL, which has a refresh token) the
|
||||
* stored credentials instead of failing silently forever. A null response means "there
|
||||
* was nothing to push" (e.g. no IMDB id) and is skipped without stamping a sync time.
|
||||
*/
|
||||
class ExternalSyncPushCoordinator @Inject constructor(
|
||||
private val api: TraktApi,
|
||||
private val repository: StremioRepository,
|
||||
|
|
@ -30,8 +19,8 @@ class ExternalSyncPushCoordinator @Inject constructor(
|
|||
private val nuvioSyncCoordinator: NuvioSyncCoordinator
|
||||
) {
|
||||
suspend fun pushMarkWatched(profile: UserProfile, meta: Meta, episodes: List<Video>, watched: Boolean) = coroutineScope {
|
||||
if (!profile.traktAccessToken.isNullOrBlank()) {
|
||||
launch { runCatching { pushTraktMarkWatched(profile.traktAccessToken!!, meta, episodes, watched) } }
|
||||
profile.traktAccessToken?.takeIf(String::isNotBlank)?.let { token ->
|
||||
launch { runCatching { pushTraktMarkWatched(token, meta, episodes, watched) } }
|
||||
}
|
||||
if (!profile.simklAccessToken.isNullOrBlank()) {
|
||||
launch { pushSimklWithTokenHandling(profile) { token -> pushSimklMarkWatched(token, meta, episodes, watched) } }
|
||||
|
|
@ -45,8 +34,8 @@ class ExternalSyncPushCoordinator @Inject constructor(
|
|||
}
|
||||
|
||||
suspend fun pushWatchlist(profile: UserProfile, meta: Meta, isInWatchlist: Boolean) = coroutineScope {
|
||||
if (!profile.traktAccessToken.isNullOrBlank()) {
|
||||
launch { runCatching { pushTraktWatchlist(profile.traktAccessToken!!, meta, isInWatchlist) } }
|
||||
profile.traktAccessToken?.takeIf(String::isNotBlank)?.let { token ->
|
||||
launch { runCatching { pushTraktWatchlist(token, meta, isInWatchlist) } }
|
||||
}
|
||||
if (isInWatchlist && !profile.simklAccessToken.isNullOrBlank()) {
|
||||
launch { pushSimklWithTokenHandling(profile) { token -> pushSimklWatchlist(token, meta) } }
|
||||
|
|
@ -68,9 +57,9 @@ class ExternalSyncPushCoordinator @Inject constructor(
|
|||
private suspend fun pushSimklWithTokenHandling(profile: UserProfile, call: suspend (String) -> Response<Unit>?) {
|
||||
val token = profile.simklAccessToken?.takeIf { it.isNotBlank() } ?: return
|
||||
val response = runCatching { call(token) }.getOrNull() ?: return
|
||||
when {
|
||||
response.isSuccessful -> profileManager.saveProfile(profile.copy(simklLastSyncAt = System.currentTimeMillis()))
|
||||
response.code() == 401 -> profileManager.saveProfile(profile.copy(simklAccessToken = null))
|
||||
when (ExternalSyncPolicy.afterResponse(ExternalSyncProvider.SIMKL, response.code())) {
|
||||
ExternalSyncAction.STAMP_SUCCESS -> profileManager.saveProfile(profile.copy(simklLastSyncAt = System.currentTimeMillis()))
|
||||
ExternalSyncAction.CLEAR_CREDENTIALS -> profileManager.saveProfile(profile.copy(simklAccessToken = null))
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
|
@ -78,11 +67,14 @@ class ExternalSyncPushCoordinator @Inject constructor(
|
|||
private suspend fun pushMalWithTokenHandling(profile: UserProfile, call: suspend (String) -> Response<Unit>?) {
|
||||
val token = profile.malAccessToken?.takeIf { it.isNotBlank() } ?: return
|
||||
val firstResponse = runCatching { call(token) }.getOrNull() ?: return
|
||||
if (firstResponse.isSuccessful) {
|
||||
profileManager.saveProfile(profile.copy(malLastSyncAt = System.currentTimeMillis()))
|
||||
return
|
||||
when (ExternalSyncPolicy.afterResponse(ExternalSyncProvider.MAL, firstResponse.code())) {
|
||||
ExternalSyncAction.STAMP_SUCCESS -> {
|
||||
profileManager.saveProfile(profile.copy(malLastSyncAt = System.currentTimeMillis()))
|
||||
return
|
||||
}
|
||||
ExternalSyncAction.REFRESH_CREDENTIALS -> Unit
|
||||
else -> return
|
||||
}
|
||||
if (firstResponse.code() != 401) return
|
||||
|
||||
val refreshToken = profile.malRefreshToken?.takeIf { it.isNotBlank() } ?: return
|
||||
val refreshed = runCatching { repository.refreshMalToken(refreshToken) }.getOrNull() ?: return
|
||||
|
|
@ -93,11 +85,11 @@ class ExternalSyncPushCoordinator @Inject constructor(
|
|||
)
|
||||
|
||||
val retryResponse = runCatching { call(refreshed.accessToken) }.getOrNull()
|
||||
when {
|
||||
retryResponse?.isSuccessful == true -> {
|
||||
when (ExternalSyncPolicy.afterRefreshRetry(retryResponse?.code())) {
|
||||
ExternalSyncAction.STAMP_SUCCESS -> {
|
||||
profileManager.saveProfile(refreshedProfile.copy(malLastSyncAt = System.currentTimeMillis()))
|
||||
}
|
||||
retryResponse?.code() == 401 -> {
|
||||
ExternalSyncAction.CLEAR_CREDENTIALS -> {
|
||||
profileManager.saveProfile(refreshedProfile.copy(malAccessToken = null, malRefreshToken = null, malTokenExpiresAt = null))
|
||||
}
|
||||
else -> profileManager.saveProfile(refreshedProfile)
|
||||
|
|
@ -151,30 +143,21 @@ class ExternalSyncPushCoordinator @Inject constructor(
|
|||
}
|
||||
|
||||
private suspend fun pushMalMarkWatched(token: String, meta: Meta, episodes: List<Video>): Response<Unit>? {
|
||||
if (meta.type != "series") return null
|
||||
val malId = MAL_ID_REGEX.find(meta.id)?.groupValues?.get(1)?.toIntOrNull() ?: return null
|
||||
val highestEpisode = episodes.mapNotNull { it.number }.maxOrNull() ?: return null
|
||||
val totalEpisodes = meta.episodesCount
|
||||
val status = if (totalEpisodes != null && highestEpisode >= totalEpisodes) "completed" else "watching"
|
||||
val update = ExternalSyncPolicy.malWatchedUpdate(meta, episodes) ?: return null
|
||||
return api.malUpdateListStatus(
|
||||
malId = malId,
|
||||
malId = update.malId,
|
||||
token = "Bearer $token",
|
||||
numWatchedEpisodes = highestEpisode,
|
||||
status = status
|
||||
numWatchedEpisodes = update.watchedEpisodes,
|
||||
status = update.status
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun pushMalWatchlist(token: String, meta: Meta): Response<Unit>? {
|
||||
if (meta.type != "series") return null
|
||||
val malId = MAL_ID_REGEX.find(meta.id)?.groupValues?.get(1)?.toIntOrNull() ?: return null
|
||||
val update = ExternalSyncPolicy.malWatchlistUpdate(meta) ?: return null
|
||||
return api.malUpdateListStatus(
|
||||
malId = malId,
|
||||
malId = update.malId,
|
||||
token = "Bearer $token",
|
||||
status = "plan_to_watch"
|
||||
status = update.status
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val MAL_ID_REGEX = Regex("^mal:(\\d+)$")
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,8 @@ import com.fluxa.app.data.remote.Meta
|
|||
import com.fluxa.app.data.remote.NuvioCredentials
|
||||
import com.fluxa.app.data.remote.NuvioService
|
||||
import com.fluxa.app.data.remote.NuvioSession
|
||||
import com.fluxa.app.data.remote.NuvioSessionDto
|
||||
import com.fluxa.app.data.remote.toDto
|
||||
import retrofit2.Response
|
||||
|
||||
enum class NuvioImportStep { PROFILE, ADDONS, LIBRARY, PROGRESS, HISTORY, COLLECTIONS }
|
||||
|
|
@ -27,7 +29,7 @@ class NuvioAccountImportCoordinator(
|
|||
val refreshToken = profile.nuvioRefreshToken?.takeIf { it.isNotBlank() } ?: return profile
|
||||
val expiresAt = profile.nuvioTokenExpiresAt ?: 0L
|
||||
if (!profile.nuvioAccessToken.isNullOrBlank() && expiresAt > System.currentTimeMillis() + 60_000L) return profile
|
||||
val session = nuvioService.refreshToken(request = com.fluxa.app.data.remote.NuvioRefreshRequest(refreshToken)).requireBody()
|
||||
val session = nuvioService.refreshToken(request = com.fluxa.app.data.remote.NuvioRefreshRequest(refreshToken).toDto()).requireBody().toDomain()
|
||||
val refreshed = profile.copy(
|
||||
nuvioAccessToken = session.accessToken,
|
||||
nuvioRefreshToken = session.refreshToken.ifBlank { refreshToken },
|
||||
|
|
@ -65,10 +67,10 @@ class NuvioAccountImportCoordinator(
|
|||
return import(refreshedProfile, session, onStep)
|
||||
}
|
||||
|
||||
private suspend inline fun authenticate(call: suspend () -> retrofit2.Response<NuvioSession>): Result<NuvioSession> {
|
||||
private suspend inline fun authenticate(call: suspend () -> retrofit2.Response<NuvioSessionDto>): Result<NuvioSession> {
|
||||
return try {
|
||||
val response = call()
|
||||
val session = response.body()
|
||||
val session = response.body()?.toDomain()
|
||||
if (response.isSuccessful && session != null) {
|
||||
Result.success(session)
|
||||
} else {
|
||||
|
|
@ -99,10 +101,10 @@ class NuvioAccountImportCoordinator(
|
|||
|
||||
var profile = connectedProfile
|
||||
val profiles = importOrDefault(NuvioImportStep.PROFILE, emptyList()) {
|
||||
nuvioService.pullProfiles(token).requireBody()
|
||||
nuvioService.pullProfiles(token).requireBody().map { it.toDomain() }
|
||||
}
|
||||
val avatars = importOrDefault(NuvioImportStep.PROFILE, emptyList()) {
|
||||
nuvioService.listAvatars().requireBody()
|
||||
nuvioService.listAvatars().requireBody().map { it.toDomain() }
|
||||
}
|
||||
val primary = profiles.firstOrNull { it.profileIndex == connectedProfile.nuvioProfileIndex }
|
||||
?: profiles.minByOrNull { it.profileIndex }
|
||||
|
|
@ -153,16 +155,16 @@ class NuvioAccountImportCoordinator(
|
|||
onStep(NuvioImportStep.PROFILE)
|
||||
|
||||
val addons = try {
|
||||
nuvioService.pullAddons(token, profileId = "eq.$profileIndex").requireBody()
|
||||
nuvioService.pullAddons(token, profileId = "eq.$profileIndex").requireBody().map { it.toDomain() }
|
||||
} catch (error: Exception) {
|
||||
Log.w("NuvioImport", "Import step ${NuvioImportStep.ADDONS} failed; continuing without it", error)
|
||||
null
|
||||
}
|
||||
if (addons != null) {
|
||||
val orderedAddons = addons.sortedBy { it.sortOrder }
|
||||
val addonState = NuvioImportPolicy.addonState(addons)
|
||||
profile = profile.copy(
|
||||
localAddons = orderedAddons.map { it.url }.distinct(),
|
||||
disabledLocalAddons = orderedAddons.filterNot { it.enabled }.map { it.url }.distinct()
|
||||
localAddons = addonState.installedUrls,
|
||||
disabledLocalAddons = addonState.disabledUrls
|
||||
)
|
||||
}
|
||||
onStep(NuvioImportStep.ADDONS)
|
||||
|
|
@ -173,45 +175,23 @@ class NuvioAccountImportCoordinator(
|
|||
Log.w("NuvioImport", "Import step ${NuvioImportStep.LIBRARY} failed; continuing without it", error)
|
||||
null
|
||||
}
|
||||
val metaById = libraryItems.orEmpty().associate { item ->
|
||||
item.contentId to Meta(
|
||||
id = item.contentId,
|
||||
name = item.name,
|
||||
type = item.contentType,
|
||||
poster = item.poster,
|
||||
background = item.background,
|
||||
description = item.description,
|
||||
releaseInfo = item.releaseInfo,
|
||||
imdbRating = item.imdbRating?.toString(),
|
||||
genres = item.genres
|
||||
)
|
||||
}
|
||||
val metaById = NuvioImportPolicy.libraryMetas(libraryItems.orEmpty())
|
||||
if (libraryItems != null) {
|
||||
watchlistManager.replaceWatchlist(metaById.values.toList())
|
||||
}
|
||||
onStep(NuvioImportStep.LIBRARY)
|
||||
|
||||
val watchProgress = try {
|
||||
nuvioService.pullWatchProgress(token, mapOf("p_profile_id" to profileIndex)).requireBody()
|
||||
nuvioService.pullWatchProgress(token, mapOf("p_profile_id" to profileIndex)).requireBody().map { it.toDomain() }
|
||||
} catch (error: Exception) {
|
||||
Log.w("NuvioImport", "Import step ${NuvioImportStep.PROGRESS} failed; keeping existing playback progress", error)
|
||||
null
|
||||
}
|
||||
val importedProgressVideoIds = watchProgress.orEmpty().mapTo(mutableSetOf()) { entry ->
|
||||
if (entry.season != null && entry.episode != null) {
|
||||
"${entry.contentId}:${entry.season}:${entry.episode}"
|
||||
} else {
|
||||
entry.videoId.ifBlank { entry.contentId }
|
||||
}
|
||||
}
|
||||
val importedProgressVideoIds = watchProgress.orEmpty().mapTo(mutableSetOf(), NuvioImportPolicy::progressVideoId)
|
||||
if (watchProgress != null) {
|
||||
watchlistManager.clearAllPlaybackProgress()
|
||||
}
|
||||
val latestProgressByContent = watchProgress.orEmpty()
|
||||
.groupBy { it.contentId }
|
||||
.values
|
||||
.mapNotNull { entries -> entries.maxWithOrNull(compareBy({ it.lastWatched }, { it.position })) }
|
||||
.sortedByDescending { it.lastWatched }
|
||||
val latestProgressByContent = NuvioImportPolicy.latestProgressByContent(watchProgress.orEmpty())
|
||||
for (entry in latestProgressByContent) {
|
||||
val detail = if (
|
||||
entry.contentType == "series" ||
|
||||
|
|
@ -247,11 +227,7 @@ class NuvioAccountImportCoordinator(
|
|||
continueWatchingBackground = episode?.thumbnail
|
||||
)
|
||||
}
|
||||
val videoId = if (entry.season != null && entry.episode != null) {
|
||||
"${entry.contentId}:${entry.season}:${entry.episode}"
|
||||
} else {
|
||||
entry.videoId.ifBlank { entry.contentId }
|
||||
}
|
||||
val videoId = NuvioImportPolicy.progressVideoId(entry)
|
||||
val isUpNextPlaceholder = entry.position <= NUVIO_UP_NEXT_POSITION_MS &&
|
||||
entry.duration == NUVIO_UP_NEXT_DURATION_MS
|
||||
watchlistManager.savePlaybackProgress(
|
||||
|
|
@ -277,79 +253,16 @@ class NuvioAccountImportCoordinator(
|
|||
if (watchedItems != null) {
|
||||
watchlistManager.clearAllWatchedEpisodes()
|
||||
}
|
||||
val watchedBySeries = mutableMapOf<String, MutableSet<String>>()
|
||||
for (item in watchedItems.orEmpty()) {
|
||||
val videoId = if (item.season != null && item.episode != null) {
|
||||
"${item.contentId}:${item.season}:${item.episode}"
|
||||
} else {
|
||||
item.contentId
|
||||
}
|
||||
if (videoId !in importedProgressVideoIds) {
|
||||
watchedBySeries.getOrPut(item.contentId) { mutableSetOf() }.add(videoId)
|
||||
}
|
||||
}
|
||||
val watchedBySeries = NuvioImportPolicy.watchedVideoIds(watchedItems.orEmpty(), importedProgressVideoIds)
|
||||
for ((seriesId, videoIds) in watchedBySeries) {
|
||||
watchlistManager.markEpisodesWatched(seriesId, videoIds)
|
||||
}
|
||||
onStep(NuvioImportStep.HISTORY)
|
||||
|
||||
val collectionRows = importOrDefault(NuvioImportStep.COLLECTIONS, emptyList()) {
|
||||
nuvioService.pullCollections(token, mapOf("p_profile_id" to profileIndex)).requireBody()
|
||||
}
|
||||
val collections = collectionRows.flatMap { it.collectionsJson.orEmpty() }.map { collection ->
|
||||
LibraryUserCollection(
|
||||
id = collection.id ?: java.util.UUID.randomUUID().toString(),
|
||||
title = collection.title ?: "",
|
||||
imageUrl = collection.backdropImageUrl,
|
||||
showOnHome = collection.showOnHome ?: true,
|
||||
pinToTop = collection.pinToTop,
|
||||
viewMode = collection.viewMode,
|
||||
showAllTab = collection.showAllTab,
|
||||
focusGlowEnabled = collection.focusGlowEnabled ?: true,
|
||||
community = collection.community,
|
||||
folders = collection.folders?.map { folder ->
|
||||
LibraryUserCollectionFolder(
|
||||
id = folder.id ?: java.util.UUID.randomUUID().toString(),
|
||||
title = folder.title ?: "",
|
||||
coverImageUrl = folder.coverImageUrl,
|
||||
coverEmoji = folder.coverEmoji,
|
||||
focusGifUrl = folder.focusGifUrl,
|
||||
focusGifEnabled = folder.focusGifEnabled ?: true,
|
||||
titleLogoUrl = folder.titleLogoUrl,
|
||||
heroBackdropUrl = folder.heroBackdropUrl,
|
||||
heroVideoUrl = folder.heroVideoUrl,
|
||||
shape = folder.tileShape.toNuvioFolderShape(),
|
||||
hideTitle = folder.hideTitle,
|
||||
catalogSources = folder.catalogSources?.map { source ->
|
||||
LibraryCatalogSource(
|
||||
addonId = source.addonId,
|
||||
catalogId = source.catalogId ?: "",
|
||||
type = source.type ?: "movie",
|
||||
genre = source.genre
|
||||
)
|
||||
}?.filter { it.catalogId.isNotBlank() }
|
||||
,
|
||||
sources = folder.catalogSources?.map { source ->
|
||||
LibraryRemoteSource(
|
||||
provider = source.provider ?: "addon",
|
||||
title = source.title,
|
||||
mediaType = source.mediaType,
|
||||
traktListId = source.traktListId,
|
||||
tmdbSourceType = source.tmdbSourceType,
|
||||
tmdbId = source.tmdbId,
|
||||
sortBy = source.sortBy,
|
||||
sortHow = source.sortHow,
|
||||
filters = source.filters,
|
||||
addonId = source.addonId,
|
||||
catalogId = source.catalogId,
|
||||
type = source.type,
|
||||
genre = source.genre
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
nuvioService.pullCollections(token, mapOf("p_profile_id" to profileIndex)).requireBody().map { it.toDomain() }
|
||||
}
|
||||
val collections = NuvioImportPolicy.collections(collectionRows) { java.util.UUID.randomUUID().toString() }
|
||||
if (collectionRows.isNotEmpty()) {
|
||||
profile = profile.copy(libraryCollections = collections)
|
||||
}
|
||||
|
|
@ -379,7 +292,7 @@ class NuvioAccountImportCoordinator(
|
|||
val page = nuvioService.pullLibrary(
|
||||
token,
|
||||
mapOf("p_profile_id" to profileIndex, "p_limit" to NUVIO_PAGE_SIZE, "p_offset" to offset)
|
||||
).requireBody()
|
||||
).requireBody().map { it.toDomain() }
|
||||
items += page
|
||||
offset += page.size
|
||||
} while (page.size == NUVIO_PAGE_SIZE)
|
||||
|
|
@ -393,7 +306,7 @@ class NuvioAccountImportCoordinator(
|
|||
val page = nuvioService.pullWatchedItems(
|
||||
token,
|
||||
mapOf("p_profile_id" to profileIndex, "p_page" to pageNumber, "p_page_size" to NUVIO_PAGE_SIZE)
|
||||
).requireBody()
|
||||
).requireBody().map { it.toDomain() }
|
||||
items += page
|
||||
pageNumber += 1
|
||||
} while (page.size == NUVIO_PAGE_SIZE)
|
||||
|
|
@ -407,12 +320,6 @@ class NuvioAccountImportCoordinator(
|
|||
}
|
||||
}
|
||||
|
||||
private fun String?.toNuvioFolderShape(): String = when (this?.trim()?.lowercase()) {
|
||||
"landscape", "wide" -> "wide"
|
||||
"square" -> "square"
|
||||
else -> "poster"
|
||||
}
|
||||
|
||||
private fun stableNuvioProfileId(profile: UserProfile, profileIndex: Int): String {
|
||||
val identity = profile.nuvioUserId ?: profile.nuvioEmail ?: profile.email
|
||||
return java.util.UUID.nameUUIDFromBytes("nuvio:$identity:$profileIndex".toByteArray()).toString()
|
||||
|
|
@ -6,6 +6,7 @@ import com.fluxa.app.data.local.safeDisabledLocalAddonIds
|
|||
import com.fluxa.app.data.local.LibraryUserCollection
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.fluxa.app.data.remote.NuvioRefreshRequest
|
||||
import com.fluxa.app.data.remote.toDto
|
||||
import com.fluxa.app.data.remote.NuvioService
|
||||
import com.fluxa.app.data.remote.Video
|
||||
import retrofit2.Response
|
||||
|
|
@ -27,7 +28,7 @@ class NuvioSyncCoordinator @Inject constructor(
|
|||
"Bearer $token",
|
||||
mapOf(
|
||||
"p_profile_id" to index,
|
||||
"p_collections_json" to current.safeLibraryCollections.map(LibraryUserCollection::toNuvioCollection)
|
||||
"p_collections_json" to current.safeLibraryCollections.map(NuvioSyncRequests::collection)
|
||||
)
|
||||
).requireSuccess()
|
||||
profileManager.saveProfile(current.copy(nuvioLastSyncAt = System.currentTimeMillis()))
|
||||
|
|
@ -47,7 +48,7 @@ class NuvioSyncCoordinator @Inject constructor(
|
|||
)
|
||||
}
|
||||
val authorization = "Bearer $token"
|
||||
val existing = nuvioService.pullAddons(authorization, profileId = "eq.$index").body()
|
||||
val existing = nuvioService.pullAddons(authorization, profileId = "eq.$index").body()?.map { it.toDomain() }
|
||||
?: throw IllegalStateException("Unable to load Nuvio add-ons")
|
||||
val desiredByUrl = desired.associateBy { it["url"] as String }
|
||||
nuvioService.pushAddons(
|
||||
|
|
@ -75,13 +76,13 @@ class NuvioSyncCoordinator @Inject constructor(
|
|||
val current = freshProfile(profile) ?: return
|
||||
val token = current.nuvioAccessToken ?: return
|
||||
val index = current.nuvioProfileIndex ?: return
|
||||
val remote = nuvioService.pullLibrary("Bearer $token", mapOf("p_profile_id" to index, "p_limit" to 500, "p_offset" to 0)).bodyOrNull()
|
||||
?.map { item -> item.toNuvioLibraryItem() }
|
||||
val remote = nuvioService.pullLibrary("Bearer $token", mapOf("p_profile_id" to index, "p_limit" to 500, "p_offset" to 0)).bodyOrNull()?.map { it.toDomain() }
|
||||
?.map(NuvioSyncRequests::libraryItem)
|
||||
?.toMutableList()
|
||||
?: return
|
||||
val existingIndex = remote.indexOfFirst { it["content_id"] == meta.id && it["content_type"] == meta.type }
|
||||
if (isInWatchlist && existingIndex >= 0) remote.removeAt(existingIndex)
|
||||
if (isInWatchlist) remote.add(meta.toNuvioLibraryItem())
|
||||
if (isInWatchlist) remote.add(NuvioSyncRequests.libraryItem(meta, System.currentTimeMillis()))
|
||||
else if (existingIndex >= 0) remote.removeAt(existingIndex)
|
||||
nuvioService.pushLibrary(
|
||||
"Bearer $token",
|
||||
|
|
@ -97,15 +98,7 @@ class NuvioSyncCoordinator @Inject constructor(
|
|||
val current = freshProfile(profile) ?: return
|
||||
val token = current.nuvioAccessToken ?: return
|
||||
val index = current.nuvioProfileIndex ?: return
|
||||
val items = if (meta.type == "movie") {
|
||||
listOf(mapOf("content_id" to meta.id, "content_type" to "movie", "title" to meta.name, "watched_at" to System.currentTimeMillis()))
|
||||
} else {
|
||||
episodes.mapNotNull { episode ->
|
||||
val season = episode.season ?: return@mapNotNull null
|
||||
val number = episode.number ?: return@mapNotNull null
|
||||
mapOf("content_id" to meta.id, "content_type" to meta.type, "title" to meta.name, "season" to season, "episode" to number, "watched_at" to System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
val items = NuvioSyncRequests.watchedItems(meta, episodes, System.currentTimeMillis())
|
||||
if (items.isEmpty()) return
|
||||
val keys = items.map { item ->
|
||||
mapOf(
|
||||
|
|
@ -127,31 +120,12 @@ class NuvioSyncCoordinator @Inject constructor(
|
|||
val current = freshProfile(profile) ?: return
|
||||
val token = current.nuvioAccessToken ?: return
|
||||
val index = current.nuvioProfileIndex ?: return
|
||||
val episode = videoId?.split(':')?.takeIf { it.size == 3 }
|
||||
val season = episode?.getOrNull(1)?.toIntOrNull()
|
||||
val episodeNumber = episode?.getOrNull(2)?.toIntOrNull()
|
||||
val progressKey = if (season != null && episodeNumber != null) {
|
||||
"${meta.id}_s${season}e$episodeNumber"
|
||||
} else {
|
||||
meta.id
|
||||
}
|
||||
val entry = NuvioSyncRequests.playbackProgress(meta, videoId, position, duration, System.currentTimeMillis())
|
||||
nuvioService.pushWatchProgress(
|
||||
"Bearer $token",
|
||||
mapOf(
|
||||
"p_profile_id" to index,
|
||||
"p_entries" to listOf(
|
||||
mapOf(
|
||||
"content_id" to meta.id,
|
||||
"content_type" to meta.type,
|
||||
"video_id" to (videoId ?: meta.id),
|
||||
"position" to position,
|
||||
"duration" to duration,
|
||||
"last_watched" to System.currentTimeMillis(),
|
||||
"season" to season,
|
||||
"episode" to episodeNumber,
|
||||
"progress_key" to progressKey
|
||||
)
|
||||
)
|
||||
"p_entries" to listOf(entry)
|
||||
)
|
||||
).requireSuccess()
|
||||
profileManager.saveProfile(current.copy(nuvioLastSyncAt = System.currentTimeMillis()))
|
||||
|
|
@ -162,7 +136,7 @@ class NuvioSyncCoordinator @Inject constructor(
|
|||
val expiresAt = profile.nuvioTokenExpiresAt ?: 0L
|
||||
if (expiresAt > System.currentTimeMillis() + 60_000L) return profile
|
||||
val refreshToken = profile.nuvioRefreshToken?.takeIf { it.isNotBlank() } ?: return profile
|
||||
val session = nuvioService.refreshToken(request = NuvioRefreshRequest(refreshToken)).bodyOrNull() ?: return null
|
||||
val session = nuvioService.refreshToken(request = NuvioRefreshRequest(refreshToken).toDto()).bodyOrNull()?.toDomain() ?: return null
|
||||
return profile.copy(
|
||||
nuvioAccessToken = session.accessToken.ifBlank { accessToken },
|
||||
nuvioRefreshToken = session.refreshToken.ifBlank { refreshToken },
|
||||
|
|
@ -173,69 +147,6 @@ class NuvioSyncCoordinator @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun LibraryUserCollection.toNuvioCollection(): Map<String, Any?> = mapOf(
|
||||
"id" to id,
|
||||
"title" to title,
|
||||
"backdropImageUrl" to imageUrl,
|
||||
"showOnHome" to showOnHome,
|
||||
"pinToTop" to pinToTop,
|
||||
"viewMode" to viewMode,
|
||||
"showAllTab" to showAllTab,
|
||||
"focusGlowEnabled" to focusGlowEnabled,
|
||||
"community" to community,
|
||||
"folders" to folders.orEmpty().map { folder ->
|
||||
mapOf(
|
||||
"id" to folder.id,
|
||||
"title" to folder.title,
|
||||
"coverImageUrl" to (folder.coverImageUrl ?: folder.imageUrl),
|
||||
"coverEmoji" to folder.coverEmoji,
|
||||
"focusGifUrl" to folder.focusGifUrl,
|
||||
"focusGifEnabled" to folder.focusGifEnabled,
|
||||
"titleLogoUrl" to folder.titleLogoUrl,
|
||||
"heroBackdropUrl" to folder.heroBackdropUrl,
|
||||
"heroVideoUrl" to folder.heroVideoUrl,
|
||||
"tileShape" to folder.shape,
|
||||
"hideTitle" to folder.hideTitle,
|
||||
"sources" to (folder.sources.orEmpty().ifEmpty {
|
||||
folder.catalogSources.orEmpty().map { source ->
|
||||
com.fluxa.app.data.local.LibraryRemoteSource(provider = "addon", addonId = source.addonId, catalogId = source.catalogId, type = source.type, genre = source.genre)
|
||||
}
|
||||
}).map { source ->
|
||||
mapOf("provider" to source.provider, "title" to source.title, "mediaType" to source.mediaType, "traktListId" to source.traktListId, "tmdbSourceType" to source.tmdbSourceType, "tmdbId" to source.tmdbId, "sortBy" to source.sortBy, "sortHow" to source.sortHow, "filters" to source.filters, "addonId" to source.addonId, "catalogId" to source.catalogId, "type" to source.type, "genre" to source.genre)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
private fun Meta.toNuvioLibraryItem(): Map<String, Any?> = mapOf(
|
||||
"content_id" to id,
|
||||
"content_type" to type,
|
||||
"name" to name,
|
||||
"poster" to poster,
|
||||
"background" to background,
|
||||
"description" to description,
|
||||
"release_info" to releaseInfo,
|
||||
"imdb_rating" to imdbRating?.toDoubleOrNull(),
|
||||
"genres" to genres,
|
||||
"poster_shape" to "POSTER",
|
||||
"added_at" to System.currentTimeMillis()
|
||||
)
|
||||
|
||||
private fun com.fluxa.app.data.remote.NuvioLibraryItem.toNuvioLibraryItem(): Map<String, Any?> = mapOf(
|
||||
"content_id" to contentId,
|
||||
"content_type" to contentType,
|
||||
"name" to name,
|
||||
"poster" to poster,
|
||||
"background" to background,
|
||||
"description" to description,
|
||||
"release_info" to releaseInfo,
|
||||
"imdb_rating" to imdbRating,
|
||||
"genres" to genres,
|
||||
"poster_shape" to (posterShape ?: "POSTER"),
|
||||
"addon_base_url" to addonBaseUrl,
|
||||
"added_at" to addedAt
|
||||
)
|
||||
|
||||
private fun <T> Response<T>.bodyOrNull(): T? = if (isSuccessful) body() else null
|
||||
|
||||
private fun Response<*>.requireSuccess() {
|
||||
|
|
@ -31,26 +31,11 @@ object SimklIntegration {
|
|||
return FluxaCoreNative.simklScrobbleBody(idsJson, isEpisode, season.toLong(), episode.toLong(), timePosSec, durationSec)
|
||||
}
|
||||
|
||||
/** Builds the {movies:[...]} or {shows:[{ids, seasons:[{number, episodes:[{number}]}]}]} body Simkl's
|
||||
* /sync/history (and /sync/history/remove) endpoints expect. */
|
||||
fun historyBody(imdbId: String, isSeries: Boolean, episodesBySeasonNumber: Map<Int, List<Int>>): Map<String, Any> {
|
||||
val ids = mapOf("imdb" to imdbId)
|
||||
return if (!isSeries) {
|
||||
mapOf("movies" to listOf(mapOf("ids" to ids)))
|
||||
} else {
|
||||
val seasons = episodesBySeasonNumber.map { (season, episodes) ->
|
||||
mapOf("number" to season, "episodes" to episodes.map { mapOf("number" to it) })
|
||||
}
|
||||
mapOf("shows" to listOf(mapOf("ids" to ids, "seasons" to seasons)))
|
||||
}
|
||||
return SimklSyncRequests.history(imdbId, isSeries, episodesBySeasonNumber)
|
||||
}
|
||||
|
||||
fun watchlistBody(imdbId: String, isSeries: Boolean): Map<String, Any> {
|
||||
val ids = mapOf("imdb" to imdbId)
|
||||
return if (isSeries) {
|
||||
mapOf("shows" to listOf(mapOf("ids" to ids, "to" to "plantowatch")))
|
||||
} else {
|
||||
mapOf("movies" to listOf(mapOf("ids" to ids, "to" to "plantowatch")))
|
||||
}
|
||||
return SimklSyncRequests.watchlist(imdbId, isSeries)
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import com.fluxa.app.data.remote.AuthRequest
|
|||
import com.fluxa.app.data.remote.Meta
|
||||
import com.fluxa.app.data.remote.MetaDetail
|
||||
import com.fluxa.app.data.remote.MetaDetailResponse
|
||||
import com.fluxa.app.data.remote.decodeMetaDetailPayload
|
||||
import com.fluxa.app.data.remote.Stream
|
||||
import com.fluxa.app.data.remote.StreamResponse
|
||||
import com.fluxa.app.data.remote.StremioService
|
||||
|
|
@ -307,11 +308,6 @@ class StremioAddonResourceClient @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun decodeMetaDetailPayload(json: String): MetaDetail? {
|
||||
return runCatching { stremioJson.decodeFromString<MetaDetailResponse>(json) }.getOrNull()?.meta
|
||||
?: runCatching { stremioJson.decodeFromString<MetaDetail>(json) }.getOrNull()
|
||||
}
|
||||
|
||||
private fun MetaDetail.applyAppExtras(): MetaDetail {
|
||||
val extras = appExtras ?: return this
|
||||
var updated = this
|
||||
|
|
@ -456,7 +452,7 @@ class StremioAddonResourceClient @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
internal fun mapAppExtraSeasonPosters(
|
||||
fun mapAppExtraSeasonPosters(
|
||||
videos: List<Video>,
|
||||
seasonsCount: Int?,
|
||||
posters: List<String?>
|
||||
|
|
@ -1,20 +1,9 @@
|
|||
package com.fluxa.app.data.repository
|
||||
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.fluxa.app.data.remote.TraktSyncItem
|
||||
|
||||
internal fun TraktSyncItem.toMeta(type: String, unknownName: () -> String): Meta? {
|
||||
val traktRes = movie ?: show ?: return null
|
||||
val id = TraktIntegration.contentIdFrom(traktRes.ids) ?: return null
|
||||
return Meta(
|
||||
id = id,
|
||||
name = traktRes.title ?: unknownName(),
|
||||
type = type,
|
||||
poster = null,
|
||||
releaseInfo = traktRes.year?.toString(),
|
||||
released = traktRes.year?.let { "$it-01-01" }
|
||||
)
|
||||
}
|
||||
internal fun TraktSyncItem.toMeta(type: String, unknownName: () -> String) =
|
||||
TraktSyncMapper.toMeta(this, type, unknownName, TraktIntegration::contentIdFrom)
|
||||
|
||||
internal suspend fun fetchTraktSyncPages(
|
||||
request: suspend (page: Int, limit: Int) -> retrofit2.Response<List<TraktSyncItem>>
|
||||
|
|
@ -7,31 +7,6 @@ import com.fluxa.app.domain.discovery.*
|
|||
import com.fluxa.app.core.rust.FluxaCoreNative
|
||||
import java.util.Locale
|
||||
|
||||
data class MetadataFeedOption(
|
||||
val key: String,
|
||||
val label: String,
|
||||
val transportUrl: String,
|
||||
val type: String,
|
||||
val id: String,
|
||||
val genre: String? = null
|
||||
)
|
||||
|
||||
data class DiscoverCatalogOption(
|
||||
val key: String,
|
||||
val label: String,
|
||||
val transportUrl: String,
|
||||
val type: String,
|
||||
val id: String,
|
||||
val genres: List<String>,
|
||||
val requiresGenre: Boolean = false
|
||||
)
|
||||
|
||||
data class Cs3CatalogFeedDescriptor(
|
||||
val pluginName: String,
|
||||
val catalogName: String,
|
||||
val catalogIndex: Int
|
||||
)
|
||||
|
||||
fun buildMetadataFeedOptions(addons: List<AddonDescriptor>, language: String? = "en"): List<MetadataFeedOption> {
|
||||
val addonFeeds = addons
|
||||
.flatMap { addon -> addon.toMetadataFeedOptions() }
|
||||
|
|
@ -2,7 +2,7 @@ package com.fluxa.app.player
|
|||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class TorrentStatus(
|
||||
data class AndroidTorrentStatus(
|
||||
@SerializedName("hash") val hash: String,
|
||||
@SerializedName("title") val title: String,
|
||||
@SerializedName("download_speed") val downloadSpeed: Double,
|
||||
|
|
@ -14,25 +14,28 @@ data class TorrentStatus(
|
|||
@SerializedName("preload") val preload: Int = 0,
|
||||
@SerializedName("loaded_size") val loadedSize: Long = 0,
|
||||
@SerializedName("preload_size") val preloadSize: Long = 0,
|
||||
@SerializedName("file_stats") val fileStats: List<TorrentFileStat>?
|
||||
)
|
||||
@SerializedName("file_stats") val fileStats: List<AndroidTorrentFileStat>?
|
||||
) {
|
||||
fun toShared(): TorrentStatus = TorrentStatus(
|
||||
hash = hash,
|
||||
title = title,
|
||||
downloadSpeed = downloadSpeed,
|
||||
activePeers = activePeers,
|
||||
totalPeers = totalPeers,
|
||||
progress = progress,
|
||||
stat = stat,
|
||||
statString = statString,
|
||||
preload = preload,
|
||||
loadedSize = loadedSize,
|
||||
preloadSize = preloadSize,
|
||||
fileStats = fileStats?.map(AndroidTorrentFileStat::toShared)
|
||||
)
|
||||
}
|
||||
|
||||
data class TorrentFileStat(
|
||||
data class AndroidTorrentFileStat(
|
||||
@SerializedName("id") val id: Int,
|
||||
@SerializedName("path") val path: String,
|
||||
@SerializedName("length") val length: Long
|
||||
)
|
||||
|
||||
data class NativeTorrentRuntimeInfo(
|
||||
val normalizedLink: String = "",
|
||||
val selectedFileIdx: Int? = null,
|
||||
val selectedReason: String? = null,
|
||||
val fallbackFileIndexes: List<Int> = emptyList(),
|
||||
val streamUrl: String = ""
|
||||
)
|
||||
|
||||
data class NativeTorrentStatusInfo(
|
||||
val bufferProgress: Int = 0,
|
||||
val isPlayableEnough: Boolean = false,
|
||||
val statusKey: String = ""
|
||||
)
|
||||
) {
|
||||
fun toShared(): TorrentFileStat = TorrentFileStat(id = id, path = path, length = length)
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package com.fluxa.app.data.local
|
||||
|
||||
data class OfflineSubtitleOption(
|
||||
val label: String,
|
||||
val language: String?,
|
||||
val url: String
|
||||
)
|
||||
|
||||
data class OfflineDownloadItem(
|
||||
val id: String,
|
||||
val profileId: String?,
|
||||
val language: String? = null,
|
||||
val metaId: String,
|
||||
val metaType: String,
|
||||
val title: String,
|
||||
val episodeTitle: String?,
|
||||
val videoId: String?,
|
||||
val poster: String?,
|
||||
val background: String?,
|
||||
val logo: String? = null,
|
||||
val localPosterPath: String? = null,
|
||||
val localBackgroundPath: String? = null,
|
||||
val localLogoPath: String? = null,
|
||||
val streamTitle: String? = null,
|
||||
val videoPath: String = "",
|
||||
val subtitlePath: String? = null,
|
||||
val subtitleLabel: String? = null,
|
||||
val subtitleLanguage: String? = null,
|
||||
val downloadId: Long = 0L,
|
||||
val createdAt: Long = 0L,
|
||||
val status: String = "queued",
|
||||
val progress: Int = 0,
|
||||
val downloadedBytes: Long = 0L,
|
||||
val totalBytes: Long = 0L,
|
||||
val speedBytesPerSecond: Long = 0L,
|
||||
val etaSeconds: Long = -1L,
|
||||
val error: String? = null
|
||||
)
|
||||
|
|
@ -8,6 +8,8 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
|
|||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.encoding.Decoder
|
||||
import kotlinx.serialization.encoding.Encoder
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonDecoder
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
|
|
@ -66,6 +68,13 @@ data class DetailTrailer(
|
|||
@Serializable
|
||||
data class MetaDetailResponse(val meta: MetaDetail? = null)
|
||||
|
||||
private val metaPayloadJson = Json { ignoreUnknownKeys = true; isLenient = true; coerceInputValues = true }
|
||||
|
||||
fun decodeMetaDetailPayload(payload: String): MetaDetail? {
|
||||
return runCatching { metaPayloadJson.decodeFromString<MetaDetailResponse>(payload) }.getOrNull()?.meta
|
||||
?: runCatching { metaPayloadJson.decodeFromString<MetaDetail>(payload) }.getOrNull()
|
||||
}
|
||||
|
||||
@Serializable(with = MetaDetailSerializer::class)
|
||||
data class MetaDetail(
|
||||
val id: String,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,136 @@
|
|||
package com.fluxa.app.data.remote
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerialName
|
||||
|
||||
@Serializable
|
||||
data class NuvioCredentials(val email: String, val password: String)
|
||||
|
||||
@Serializable
|
||||
data class NuvioRefreshRequest(@SerialName("refresh_token") val refreshToken: String)
|
||||
|
||||
@Serializable
|
||||
data class NuvioHealth(val status: String? = null)
|
||||
|
||||
@Serializable
|
||||
data class NuvioUser(val id: String, val email: String)
|
||||
|
||||
@Serializable
|
||||
data class NuvioSession(
|
||||
val accessToken: String,
|
||||
val refreshToken: String,
|
||||
val expiresIn: Long?,
|
||||
val user: NuvioUser?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NuvioProfile(
|
||||
val id: String,
|
||||
val userId: String? = null,
|
||||
val profileIndex: Int,
|
||||
val name: String?,
|
||||
val avatarColorHex: String?,
|
||||
val avatarId: String?,
|
||||
val avatarUrl: String?
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NuvioAddon(
|
||||
val id: String? = null,
|
||||
val userId: String? = null,
|
||||
val profileId: Int? = null,
|
||||
val url: String,
|
||||
val name: String?,
|
||||
val enabled: Boolean,
|
||||
val sortOrder: Int
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NuvioAvatar(val id: String, val storagePath: String?)
|
||||
|
||||
@Serializable
|
||||
data class NuvioLibraryItem(
|
||||
val contentId: String,
|
||||
val contentType: String,
|
||||
val name: String,
|
||||
val poster: String?,
|
||||
val background: String?,
|
||||
val description: String?,
|
||||
val releaseInfo: String?,
|
||||
val imdbRating: Double?,
|
||||
val genres: List<String>?,
|
||||
val posterShape: String? = null,
|
||||
val addonBaseUrl: String? = null,
|
||||
val addedAt: Long? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NuvioWatchProgress(
|
||||
val contentId: String,
|
||||
val contentType: String,
|
||||
val videoId: String,
|
||||
val season: Int?,
|
||||
val episode: Int?,
|
||||
val position: Long,
|
||||
val duration: Long,
|
||||
val lastWatched: Long,
|
||||
val progressKey: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NuvioWatchedItem(
|
||||
val contentId: String,
|
||||
val contentType: String,
|
||||
val title: String? = null,
|
||||
val season: Int?,
|
||||
val episode: Int?,
|
||||
val watchedAt: Long? = null
|
||||
)
|
||||
|
||||
data class NuvioCollectionFolderSource(
|
||||
val provider: String? = null,
|
||||
val addonId: String?,
|
||||
val catalogId: String?,
|
||||
val type: String?,
|
||||
val genre: String? = null,
|
||||
val title: String? = null,
|
||||
val mediaType: String? = null,
|
||||
val traktListId: Long? = null,
|
||||
val tmdbSourceType: String? = null,
|
||||
val tmdbId: Long? = null,
|
||||
val sortBy: String? = null,
|
||||
val sortHow: String? = null,
|
||||
val filters: Map<String, Any?>? = null
|
||||
)
|
||||
|
||||
data class NuvioCollectionFolder(
|
||||
val id: String?,
|
||||
val title: String?,
|
||||
val coverImageUrl: String?,
|
||||
val coverEmoji: String?,
|
||||
val focusGifUrl: String?,
|
||||
val focusGifEnabled: Boolean? = null,
|
||||
val titleLogoUrl: String?,
|
||||
val heroBackdropUrl: String? = null,
|
||||
val heroVideoUrl: String? = null,
|
||||
val tileShape: String?,
|
||||
val hideTitle: Boolean?,
|
||||
val catalogSources: List<NuvioCollectionFolderSource>?
|
||||
)
|
||||
|
||||
data class NuvioCollection(
|
||||
val id: String?,
|
||||
val title: String?,
|
||||
val backdropImageUrl: String?,
|
||||
val pinToTop: Boolean?,
|
||||
val showOnHome: Boolean? = null,
|
||||
val viewMode: String?,
|
||||
val showAllTab: Boolean?,
|
||||
val focusGlowEnabled: Boolean? = null,
|
||||
val folders: List<NuvioCollectionFolder>?,
|
||||
val community: Map<String, Any?>? = null
|
||||
)
|
||||
|
||||
data class NuvioCollectionRow(val collectionsJson: List<NuvioCollection>?)
|
||||
|
||||
data class NuvioProfileSettingsRow(val settingsJson: Map<String, Any?>?)
|
||||
|
|
@ -1,13 +1,19 @@
|
|||
package com.fluxa.app.data.remote
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class DatastoreRequest(val authKey: String, val collection: String)
|
||||
|
||||
@Serializable
|
||||
data class DatastoreResponse(val result: List<LibraryItem>)
|
||||
|
||||
@Serializable
|
||||
data class DatastorePutRequest(val authKey: String, val collection: String, val items: List<LibraryItem>)
|
||||
|
||||
@Serializable
|
||||
data class LibraryItem(
|
||||
@SerializedName("_id") val id: String,
|
||||
val _id: String,
|
||||
val name: String,
|
||||
val type: String,
|
||||
val poster: String?,
|
||||
|
|
@ -15,8 +21,11 @@ data class LibraryItem(
|
|||
val logo: String? = null,
|
||||
val state: LibraryItemState? = null,
|
||||
val lastWatched: String? = null
|
||||
)
|
||||
) {
|
||||
val id: String get() = _id
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class LibraryItemState(
|
||||
val lastWatched: String? = null,
|
||||
val timeOffset: Long? = 0,
|
||||
|
|
@ -26,7 +35,9 @@ data class LibraryItemState(
|
|||
val flaggedWatched: Int? = 0
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CatalogResponse(val metas: List<Meta>? = null)
|
||||
|
||||
data class StreamResponse(val streams: List<Stream>? = null)
|
||||
|
||||
data class SubtitleResponse(val subtitles: List<SubtitleData>? = null) // Stremio format
|
||||
data class SubtitleResponse(val subtitles: List<SubtitleData>? = null)
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
package com.fluxa.app.data.remote
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class TraktEpisode(
|
||||
val season: Int,
|
||||
val number: Int,
|
||||
|
|
@ -10,20 +11,16 @@ data class TraktEpisode(
|
|||
val ids: TraktIds? = null
|
||||
)
|
||||
|
||||
data class TraktTranslation(
|
||||
val title: String?,
|
||||
val overview: String?,
|
||||
val language: String?
|
||||
)
|
||||
@Serializable
|
||||
data class TraktTranslation(val title: String?, val overview: String?, val language: String?)
|
||||
|
||||
data class TraktSearchResult(
|
||||
val show: TraktShow?
|
||||
)
|
||||
@Serializable
|
||||
data class TraktSearchResult(val show: TraktShow?)
|
||||
|
||||
data class TraktShow(
|
||||
val ids: TraktIds
|
||||
)
|
||||
@Serializable
|
||||
data class TraktShow(val ids: TraktIds)
|
||||
|
||||
@Serializable
|
||||
data class TraktIds(
|
||||
val trakt: Int? = null,
|
||||
val imdb: String? = null,
|
||||
|
|
@ -32,6 +29,7 @@ data class TraktIds(
|
|||
val tvdb: Int? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraktSyncItem(
|
||||
val rank: Int? = null,
|
||||
val id: Long? = null,
|
||||
|
|
@ -40,23 +38,26 @@ data class TraktSyncItem(
|
|||
val seasons: List<TraktWatchedSeason>? = null
|
||||
)
|
||||
|
||||
data class TraktListItem(
|
||||
val movie: TraktSummary? = null,
|
||||
val show: TraktSummary? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraktWatchedSeason(
|
||||
val number: Int? = null,
|
||||
val episodes: List<TraktWatchedEpisode>? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraktWatchedEpisode(
|
||||
val number: Int? = null,
|
||||
val runtime: Int? = null,
|
||||
@SerializedName("last_watched_at") val lastWatchedAt: String? = null,
|
||||
val last_watched_at: String? = null,
|
||||
val plays: Int? = null
|
||||
)
|
||||
) {
|
||||
val lastWatchedAt: String? get() = last_watched_at
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TraktListItem(val movie: TraktSummary? = null, val show: TraktSummary? = null)
|
||||
|
||||
@Serializable
|
||||
data class TraktPlaybackItem(
|
||||
val id: Long? = null,
|
||||
val progress: Float? = null,
|
||||
|
|
@ -67,13 +68,10 @@ data class TraktPlaybackItem(
|
|||
val episode: TraktEpisode? = null
|
||||
)
|
||||
|
||||
data class TraktSummary(
|
||||
val title: String?,
|
||||
val year: Int?,
|
||||
val ids: TraktIds,
|
||||
val runtime: Int? = null
|
||||
)
|
||||
@Serializable
|
||||
data class TraktSummary(val title: String?, val year: Int?, val ids: TraktIds, val runtime: Int? = null)
|
||||
|
||||
@Serializable
|
||||
data class TraktHistoryItem(
|
||||
val id: Long?,
|
||||
val watched_at: String?,
|
||||
|
|
@ -84,13 +82,15 @@ data class TraktHistoryItem(
|
|||
val episode: TraktEpisode? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraktScrobbleRequest(
|
||||
val movie: TraktSummary? = null,
|
||||
val show: TraktSummary? = null,
|
||||
val episode: TraktScrobbleEpisode? = null,
|
||||
val progress: Float // 0 to 100
|
||||
val progress: Float
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraktScrobbleEpisode(
|
||||
val ids: TraktIds? = null,
|
||||
val season: Int? = null,
|
||||
|
|
@ -98,6 +98,7 @@ data class TraktScrobbleEpisode(
|
|||
val title: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraktScrobbleResponse(
|
||||
val action: String? = null,
|
||||
val progress: Float? = null,
|
||||
|
|
@ -108,50 +109,55 @@ data class TraktScrobbleResponse(
|
|||
val episode: TraktEpisode? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraktHistorySyncRequest(
|
||||
val movies: List<TraktHistoryMovie>? = null,
|
||||
val shows: List<TraktHistoryShow>? = null
|
||||
)
|
||||
|
||||
data class TraktHistoryMovie(
|
||||
val ids: TraktIds
|
||||
)
|
||||
@Serializable
|
||||
data class TraktHistoryMovie(val ids: TraktIds)
|
||||
|
||||
data class TraktHistoryShow(
|
||||
val ids: TraktIds,
|
||||
val seasons: List<TraktHistorySeason>? = null
|
||||
)
|
||||
@Serializable
|
||||
data class TraktHistoryShow(val ids: TraktIds, val seasons: List<TraktHistorySeason>? = null)
|
||||
|
||||
data class TraktHistorySeason(
|
||||
val number: Int,
|
||||
val episodes: List<TraktHistoryEpisode>? = null
|
||||
)
|
||||
@Serializable
|
||||
data class TraktHistorySeason(val number: Int, val episodes: List<TraktHistoryEpisode>? = null)
|
||||
|
||||
data class TraktHistoryEpisode(
|
||||
val number: Int
|
||||
)
|
||||
@Serializable
|
||||
data class TraktHistoryEpisode(val number: Int)
|
||||
|
||||
@Serializable
|
||||
data class TraktTrendingItem(
|
||||
val watchers: Int? = null,
|
||||
val movie: TraktSummary? = null,
|
||||
val show: TraktSummary? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraktAnticipatedItem(
|
||||
val list_count: Int? = null,
|
||||
val movie: TraktSummary? = null,
|
||||
val show: TraktSummary? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraktTokenResponse(
|
||||
@SerializedName("access_token") val accessToken: String,
|
||||
@SerializedName("token_type") val tokenType: String,
|
||||
@SerializedName("expires_in") val expiresIn: Long,
|
||||
@SerializedName("refresh_token") val refreshToken: String,
|
||||
@SerializedName("scope") val scope: String,
|
||||
@SerializedName("created_at") val createdAt: Long
|
||||
)
|
||||
val access_token: String,
|
||||
val token_type: String,
|
||||
val expires_in: Long,
|
||||
val refresh_token: String,
|
||||
val scope: String,
|
||||
val created_at: Long
|
||||
) {
|
||||
val accessToken: String get() = access_token
|
||||
val tokenType: String get() = token_type
|
||||
val expiresIn: Long get() = expires_in
|
||||
val refreshToken: String get() = refresh_token
|
||||
val createdAt: Long get() = created_at
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TraktTokenRequest(
|
||||
val code: String,
|
||||
val client_id: String,
|
||||
|
|
@ -160,6 +166,7 @@ data class TraktTokenRequest(
|
|||
val grant_type: String = "authorization_code"
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class TraktRefreshTokenRequest(
|
||||
val refresh_token: String,
|
||||
val client_id: String,
|
||||
|
|
@ -168,31 +175,40 @@ data class TraktRefreshTokenRequest(
|
|||
val grant_type: String = "refresh_token"
|
||||
)
|
||||
|
||||
data class TraktDeviceCodeRequest(
|
||||
val client_id: String
|
||||
)
|
||||
@Serializable
|
||||
data class TraktDeviceCodeRequest(val client_id: String)
|
||||
|
||||
@Serializable
|
||||
data class TraktDeviceCodeResponse(
|
||||
@SerializedName("device_code") val deviceCode: String,
|
||||
@SerializedName("user_code") val userCode: String,
|
||||
@SerializedName("verification_url") val verificationUrl: String,
|
||||
@SerializedName("expires_in") val expiresIn: Int,
|
||||
val device_code: String,
|
||||
val user_code: String,
|
||||
val verification_url: String,
|
||||
val expires_in: Int,
|
||||
val interval: Int
|
||||
)
|
||||
) {
|
||||
val deviceCode: String get() = device_code
|
||||
val userCode: String get() = user_code
|
||||
val verificationUrl: String get() = verification_url
|
||||
val expiresIn: Int get() = expires_in
|
||||
}
|
||||
|
||||
data class TraktDeviceTokenRequest(
|
||||
val code: String,
|
||||
val client_id: String,
|
||||
val client_secret: String
|
||||
)
|
||||
@Serializable
|
||||
data class TraktDeviceTokenRequest(val code: String, val client_id: String, val client_secret: String)
|
||||
|
||||
@Serializable
|
||||
data class ExternalOAuthTokenResponse(
|
||||
@SerializedName("access_token") val accessToken: String,
|
||||
@SerializedName("refresh_token") val refreshToken: String? = null,
|
||||
@SerializedName("token_type") val tokenType: String? = null,
|
||||
@SerializedName("expires_in") val expiresIn: Long? = null
|
||||
)
|
||||
val access_token: String,
|
||||
val refresh_token: String? = null,
|
||||
val token_type: String? = null,
|
||||
val expires_in: Long? = null
|
||||
) {
|
||||
val accessToken: String get() = access_token
|
||||
val refreshToken: String? get() = refresh_token
|
||||
val tokenType: String? get() = token_type
|
||||
val expiresIn: Long? get() = expires_in
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class AnilistTokenRequest(
|
||||
val grant_type: String = "authorization_code",
|
||||
val client_id: String,
|
||||
|
|
@ -201,48 +217,58 @@ data class AnilistTokenRequest(
|
|||
val code: String
|
||||
)
|
||||
|
||||
data class MalAnimeListResponse(
|
||||
val data: List<MalAnimeListEntry> = emptyList()
|
||||
)
|
||||
@Serializable
|
||||
data class MalMainPicture(val medium: String? = null, val large: String? = null)
|
||||
|
||||
data class MalAnimeListEntry(
|
||||
val node: MalAnimeNode,
|
||||
@SerializedName("list_status") val listStatus: MalListStatus? = null
|
||||
)
|
||||
@Serializable
|
||||
data class MalAnimeListResponse(val data: List<MalAnimeListEntry> = emptyList())
|
||||
|
||||
@Serializable
|
||||
data class MalAnimeListEntry(val node: MalAnimeNode, val list_status: MalListStatus? = null) {
|
||||
val listStatus: MalListStatus? get() = list_status
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MalAnimeNode(
|
||||
val id: Int,
|
||||
val title: String,
|
||||
@SerializedName("main_picture") val mainPicture: MalMainPicture? = null,
|
||||
@SerializedName("num_episodes") val numEpisodes: Int? = null
|
||||
)
|
||||
|
||||
data class MalMainPicture(
|
||||
val medium: String? = null,
|
||||
val large: String? = null
|
||||
)
|
||||
val main_picture: MalMainPicture? = null,
|
||||
val num_episodes: Int? = null
|
||||
) {
|
||||
val mainPicture: MalMainPicture? get() = main_picture
|
||||
val numEpisodes: Int? get() = num_episodes
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MalListStatus(
|
||||
val status: String? = null,
|
||||
@SerializedName("num_episodes_watched") val numEpisodesWatched: Int? = null,
|
||||
@SerializedName("updated_at") val updatedAt: String? = null
|
||||
)
|
||||
val num_episodes_watched: Int? = null,
|
||||
val updated_at: String? = null
|
||||
) {
|
||||
val numEpisodesWatched: Int? get() = num_episodes_watched
|
||||
val updatedAt: String? get() = updated_at
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SimklAllItemsResponse(
|
||||
val movies: List<SimklItem> = emptyList(),
|
||||
val shows: List<SimklItem> = emptyList(),
|
||||
val anime: List<SimklItem> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class SimklItem(
|
||||
val title: String? = null,
|
||||
val year: Int? = null,
|
||||
val ids: SimklIds? = null,
|
||||
val status: String? = null,
|
||||
@SerializedName("last_watched_at") val lastWatchedAt: String? = null,
|
||||
val last_watched_at: String? = null,
|
||||
val seasons: List<SimklSeason>? = null
|
||||
)
|
||||
) {
|
||||
val lastWatchedAt: String? get() = last_watched_at
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SimklIds(
|
||||
val imdb: String? = null,
|
||||
val tmdb: String? = null,
|
||||
|
|
@ -250,21 +276,18 @@ data class SimklIds(
|
|||
val simkl: Int? = null
|
||||
)
|
||||
|
||||
data class SimklSeason(
|
||||
val number: Int? = null,
|
||||
val episodes: List<SimklEpisode>? = null
|
||||
)
|
||||
@Serializable
|
||||
data class SimklSeason(val number: Int? = null, val episodes: List<SimklEpisode>? = null)
|
||||
|
||||
data class SimklEpisode(
|
||||
val number: Int? = null,
|
||||
@SerializedName("watched_at") val watchedAt: String? = null
|
||||
)
|
||||
@Serializable
|
||||
data class SimklEpisode(val number: Int? = null, val watched_at: String? = null) {
|
||||
val watchedAt: String? get() = watched_at
|
||||
}
|
||||
|
||||
data class SimklSearchResult(
|
||||
val type: String? = null,
|
||||
val ids: SimklIds? = null
|
||||
)
|
||||
@Serializable
|
||||
data class SimklSearchResult(val type: String? = null, val ids: SimklIds? = null)
|
||||
|
||||
@Serializable
|
||||
data class SimklEpisodeInfo(
|
||||
val season: Int? = null,
|
||||
val episode: Int? = null,
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
package com.fluxa.app.data.repository
|
||||
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.fluxa.app.data.remote.Video
|
||||
|
||||
enum class ExternalSyncProvider { SIMKL, MAL }
|
||||
|
||||
enum class ExternalSyncAction { STAMP_SUCCESS, CLEAR_CREDENTIALS, REFRESH_CREDENTIALS, KEEP_CREDENTIALS }
|
||||
|
||||
data class MalListUpdate(val malId: Int, val watchedEpisodes: Int?, val status: String)
|
||||
|
||||
object ExternalSyncPolicy {
|
||||
fun afterResponse(provider: ExternalSyncProvider, statusCode: Int): ExternalSyncAction = when {
|
||||
statusCode in 200..299 -> ExternalSyncAction.STAMP_SUCCESS
|
||||
statusCode == 401 && provider == ExternalSyncProvider.MAL -> ExternalSyncAction.REFRESH_CREDENTIALS
|
||||
statusCode == 401 -> ExternalSyncAction.CLEAR_CREDENTIALS
|
||||
else -> ExternalSyncAction.KEEP_CREDENTIALS
|
||||
}
|
||||
|
||||
fun afterRefreshRetry(statusCode: Int?): ExternalSyncAction = when {
|
||||
statusCode != null && statusCode in 200..299 -> ExternalSyncAction.STAMP_SUCCESS
|
||||
statusCode == 401 -> ExternalSyncAction.CLEAR_CREDENTIALS
|
||||
else -> ExternalSyncAction.KEEP_CREDENTIALS
|
||||
}
|
||||
|
||||
fun malWatchedUpdate(meta: Meta, episodes: List<Video>): MalListUpdate? {
|
||||
if (meta.type != "series") return null
|
||||
val malId = malId(meta.id) ?: return null
|
||||
val highestEpisode = episodes.mapNotNull(Video::number).maxOrNull() ?: return null
|
||||
val status = if (meta.episodesCount != null && highestEpisode >= meta.episodesCount) "completed" else "watching"
|
||||
return MalListUpdate(malId, highestEpisode, status)
|
||||
}
|
||||
|
||||
fun malWatchlistUpdate(meta: Meta): MalListUpdate? {
|
||||
if (meta.type != "series") return null
|
||||
return MalListUpdate(malId(meta.id) ?: return null, null, "plan_to_watch")
|
||||
}
|
||||
|
||||
private fun malId(contentId: String): Int? = Regex("^mal:(\\d+)$").find(contentId)?.groupValues?.get(1)?.toIntOrNull()
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
package com.fluxa.app.data.repository
|
||||
|
||||
import com.fluxa.app.data.local.LibraryCatalogSource
|
||||
import com.fluxa.app.data.local.LibraryRemoteSource
|
||||
import com.fluxa.app.data.local.LibraryUserCollection
|
||||
import com.fluxa.app.data.local.LibraryUserCollectionFolder
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.fluxa.app.data.remote.NuvioAddon
|
||||
import com.fluxa.app.data.remote.NuvioCollectionRow
|
||||
import com.fluxa.app.data.remote.NuvioLibraryItem
|
||||
import com.fluxa.app.data.remote.NuvioWatchProgress
|
||||
import com.fluxa.app.data.remote.NuvioWatchedItem
|
||||
|
||||
data class NuvioAddonState(val installedUrls: List<String>, val disabledUrls: List<String>)
|
||||
|
||||
object NuvioImportPolicy {
|
||||
fun addonState(addons: List<NuvioAddon>): NuvioAddonState {
|
||||
val ordered = addons.sortedBy(NuvioAddon::sortOrder)
|
||||
return NuvioAddonState(
|
||||
installedUrls = ordered.map(NuvioAddon::url).distinct(),
|
||||
disabledUrls = ordered.filterNot(NuvioAddon::enabled).map(NuvioAddon::url).distinct()
|
||||
)
|
||||
}
|
||||
|
||||
fun libraryMetas(items: List<NuvioLibraryItem>): Map<String, Meta> = items.associate { item ->
|
||||
item.contentId to Meta(
|
||||
id = item.contentId,
|
||||
name = item.name,
|
||||
type = item.contentType,
|
||||
poster = item.poster,
|
||||
background = item.background,
|
||||
description = item.description,
|
||||
releaseInfo = item.releaseInfo,
|
||||
imdbRating = item.imdbRating?.toString(),
|
||||
genres = item.genres
|
||||
)
|
||||
}
|
||||
|
||||
fun progressVideoId(entry: NuvioWatchProgress): String =
|
||||
if (entry.season != null && entry.episode != null) {
|
||||
"${entry.contentId}:${entry.season}:${entry.episode}"
|
||||
} else {
|
||||
entry.videoId.ifBlank { entry.contentId }
|
||||
}
|
||||
|
||||
fun latestProgressByContent(entries: List<NuvioWatchProgress>): List<NuvioWatchProgress> = entries
|
||||
.groupBy(NuvioWatchProgress::contentId)
|
||||
.values
|
||||
.mapNotNull { values -> values.maxWithOrNull(compareBy(NuvioWatchProgress::lastWatched, NuvioWatchProgress::position)) }
|
||||
.sortedByDescending(NuvioWatchProgress::lastWatched)
|
||||
|
||||
fun watchedVideoIds(
|
||||
items: List<NuvioWatchedItem>,
|
||||
progressVideoIds: Set<String>
|
||||
): Map<String, Set<String>> {
|
||||
val watched = mutableMapOf<String, MutableSet<String>>()
|
||||
items.forEach { item ->
|
||||
val videoId = if (item.season != null && item.episode != null) {
|
||||
"${item.contentId}:${item.season}:${item.episode}"
|
||||
} else {
|
||||
item.contentId
|
||||
}
|
||||
if (videoId !in progressVideoIds) watched.getOrPut(item.contentId, ::mutableSetOf).add(videoId)
|
||||
}
|
||||
return watched
|
||||
}
|
||||
|
||||
fun collections(rows: List<NuvioCollectionRow>, nextId: () -> String): List<LibraryUserCollection> =
|
||||
rows.flatMap { it.collectionsJson.orEmpty() }.map { collection ->
|
||||
LibraryUserCollection(
|
||||
id = collection.id ?: nextId(),
|
||||
title = collection.title.orEmpty(),
|
||||
imageUrl = collection.backdropImageUrl,
|
||||
showOnHome = collection.showOnHome ?: true,
|
||||
pinToTop = collection.pinToTop,
|
||||
viewMode = collection.viewMode,
|
||||
showAllTab = collection.showAllTab,
|
||||
focusGlowEnabled = collection.focusGlowEnabled ?: true,
|
||||
community = collection.community,
|
||||
folders = collection.folders?.map { folder ->
|
||||
LibraryUserCollectionFolder(
|
||||
id = folder.id ?: nextId(),
|
||||
title = folder.title.orEmpty(),
|
||||
coverImageUrl = folder.coverImageUrl,
|
||||
coverEmoji = folder.coverEmoji,
|
||||
focusGifUrl = folder.focusGifUrl,
|
||||
focusGifEnabled = folder.focusGifEnabled ?: true,
|
||||
titleLogoUrl = folder.titleLogoUrl,
|
||||
heroBackdropUrl = folder.heroBackdropUrl,
|
||||
heroVideoUrl = folder.heroVideoUrl,
|
||||
shape = folderShape(folder.tileShape),
|
||||
hideTitle = folder.hideTitle,
|
||||
catalogSources = folder.catalogSources?.map { source ->
|
||||
LibraryCatalogSource(source.addonId, source.catalogId.orEmpty(), source.type ?: "movie", source.genre)
|
||||
}?.filter { it.catalogId.isNotBlank() },
|
||||
sources = folder.catalogSources?.map { source ->
|
||||
LibraryRemoteSource(
|
||||
provider = source.provider ?: "addon",
|
||||
title = source.title,
|
||||
mediaType = source.mediaType,
|
||||
traktListId = source.traktListId,
|
||||
tmdbSourceType = source.tmdbSourceType,
|
||||
tmdbId = source.tmdbId,
|
||||
sortBy = source.sortBy,
|
||||
sortHow = source.sortHow,
|
||||
filters = source.filters,
|
||||
addonId = source.addonId,
|
||||
catalogId = source.catalogId,
|
||||
type = source.type,
|
||||
genre = source.genre
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private fun folderShape(value: String?): String = when (value?.trim()?.lowercase()) {
|
||||
"landscape", "wide" -> "wide"
|
||||
"square" -> "square"
|
||||
else -> "poster"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
package com.fluxa.app.data.repository
|
||||
|
||||
import com.fluxa.app.data.local.LibraryRemoteSource
|
||||
import com.fluxa.app.data.local.LibraryUserCollection
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.fluxa.app.data.remote.NuvioLibraryItem
|
||||
import com.fluxa.app.data.remote.Video
|
||||
|
||||
object NuvioSyncRequests {
|
||||
fun collection(collection: LibraryUserCollection): Map<String, Any?> = mapOf(
|
||||
"id" to collection.id,
|
||||
"title" to collection.title,
|
||||
"backdropImageUrl" to collection.imageUrl,
|
||||
"showOnHome" to collection.showOnHome,
|
||||
"pinToTop" to collection.pinToTop,
|
||||
"viewMode" to collection.viewMode,
|
||||
"showAllTab" to collection.showAllTab,
|
||||
"focusGlowEnabled" to collection.focusGlowEnabled,
|
||||
"community" to collection.community,
|
||||
"folders" to collection.folders.orEmpty().map { folder ->
|
||||
val sources = folder.sources.orEmpty().ifEmpty {
|
||||
folder.catalogSources.orEmpty().map { source ->
|
||||
LibraryRemoteSource("addon", addonId = source.addonId, catalogId = source.catalogId, type = source.type, genre = source.genre)
|
||||
}
|
||||
}
|
||||
mapOf(
|
||||
"id" to folder.id,
|
||||
"title" to folder.title,
|
||||
"coverImageUrl" to (folder.coverImageUrl ?: folder.imageUrl),
|
||||
"coverEmoji" to folder.coverEmoji,
|
||||
"focusGifUrl" to folder.focusGifUrl,
|
||||
"focusGifEnabled" to folder.focusGifEnabled,
|
||||
"titleLogoUrl" to folder.titleLogoUrl,
|
||||
"heroBackdropUrl" to folder.heroBackdropUrl,
|
||||
"heroVideoUrl" to folder.heroVideoUrl,
|
||||
"tileShape" to folder.shape,
|
||||
"hideTitle" to folder.hideTitle,
|
||||
"sources" to sources.map(::remoteSource)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
fun libraryItem(meta: Meta, addedAt: Long): Map<String, Any?> = mapOf(
|
||||
"content_id" to meta.id,
|
||||
"content_type" to meta.type,
|
||||
"name" to meta.name,
|
||||
"poster" to meta.poster,
|
||||
"background" to meta.background,
|
||||
"description" to meta.description,
|
||||
"release_info" to meta.releaseInfo,
|
||||
"imdb_rating" to meta.imdbRating?.toDoubleOrNull(),
|
||||
"genres" to meta.genres,
|
||||
"poster_shape" to "POSTER",
|
||||
"added_at" to addedAt
|
||||
)
|
||||
|
||||
fun libraryItem(item: NuvioLibraryItem): Map<String, Any?> = mapOf(
|
||||
"content_id" to item.contentId,
|
||||
"content_type" to item.contentType,
|
||||
"name" to item.name,
|
||||
"poster" to item.poster,
|
||||
"background" to item.background,
|
||||
"description" to item.description,
|
||||
"release_info" to item.releaseInfo,
|
||||
"imdb_rating" to item.imdbRating,
|
||||
"genres" to item.genres,
|
||||
"poster_shape" to (item.posterShape ?: "POSTER"),
|
||||
"addon_base_url" to item.addonBaseUrl,
|
||||
"added_at" to item.addedAt
|
||||
)
|
||||
|
||||
fun watchedItems(meta: Meta, episodes: List<Video>, watchedAt: Long): List<Map<String, Any?>> {
|
||||
if (meta.type == "movie") {
|
||||
return listOf(mapOf("content_id" to meta.id, "content_type" to "movie", "title" to meta.name, "watched_at" to watchedAt))
|
||||
}
|
||||
return episodes.mapNotNull { episode ->
|
||||
val season = episode.season ?: return@mapNotNull null
|
||||
val number = episode.number ?: return@mapNotNull null
|
||||
mapOf(
|
||||
"content_id" to meta.id,
|
||||
"content_type" to meta.type,
|
||||
"title" to meta.name,
|
||||
"season" to season,
|
||||
"episode" to number,
|
||||
"watched_at" to watchedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun playbackProgress(meta: Meta, videoId: String?, position: Long, duration: Long, watchedAt: Long): Map<String, Any?> {
|
||||
val parts = videoId?.split(':')?.takeIf { it.size == 3 }
|
||||
val season = parts?.getOrNull(1)?.toIntOrNull()
|
||||
val episode = parts?.getOrNull(2)?.toIntOrNull()
|
||||
val progressKey = if (season != null && episode != null) "${meta.id}_s${season}e$episode" else meta.id
|
||||
return mapOf(
|
||||
"content_id" to meta.id,
|
||||
"content_type" to meta.type,
|
||||
"video_id" to (videoId ?: meta.id),
|
||||
"position" to position,
|
||||
"duration" to duration,
|
||||
"last_watched" to watchedAt,
|
||||
"season" to season,
|
||||
"episode" to episode,
|
||||
"progress_key" to progressKey
|
||||
)
|
||||
}
|
||||
|
||||
private fun remoteSource(source: LibraryRemoteSource): Map<String, Any?> = mapOf(
|
||||
"provider" to source.provider,
|
||||
"title" to source.title,
|
||||
"mediaType" to source.mediaType,
|
||||
"traktListId" to source.traktListId,
|
||||
"tmdbSourceType" to source.tmdbSourceType,
|
||||
"tmdbId" to source.tmdbId,
|
||||
"sortBy" to source.sortBy,
|
||||
"sortHow" to source.sortHow,
|
||||
"filters" to source.filters,
|
||||
"addonId" to source.addonId,
|
||||
"catalogId" to source.catalogId,
|
||||
"type" to source.type,
|
||||
"genre" to source.genre
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package com.fluxa.app.data.repository
|
||||
|
||||
object SimklSyncRequests {
|
||||
fun history(imdbId: String, isSeries: Boolean, episodesBySeasonNumber: Map<Int, List<Int>>): Map<String, Any> {
|
||||
val ids = mapOf("imdb" to imdbId)
|
||||
if (!isSeries) return mapOf("movies" to listOf(mapOf("ids" to ids)))
|
||||
val seasons = episodesBySeasonNumber.map { (season, episodes) ->
|
||||
mapOf("number" to season, "episodes" to episodes.map { mapOf("number" to it) })
|
||||
}
|
||||
return mapOf("shows" to listOf(mapOf("ids" to ids, "seasons" to seasons)))
|
||||
}
|
||||
|
||||
fun watchlist(imdbId: String, isSeries: Boolean): Map<String, Any> {
|
||||
val ids = mapOf("imdb" to imdbId)
|
||||
return if (isSeries) {
|
||||
mapOf("shows" to listOf(mapOf("ids" to ids, "to" to "plantowatch")))
|
||||
} else {
|
||||
mapOf("movies" to listOf(mapOf("ids" to ids, "to" to "plantowatch")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package com.fluxa.app.data.repository
|
||||
|
||||
import com.fluxa.app.data.remote.Meta
|
||||
import com.fluxa.app.data.remote.TraktIds
|
||||
import com.fluxa.app.data.remote.TraktSyncItem
|
||||
|
||||
object TraktSyncMapper {
|
||||
fun toMeta(
|
||||
item: TraktSyncItem,
|
||||
type: String,
|
||||
unknownName: () -> String,
|
||||
resolveContentId: (TraktIds) -> String?
|
||||
): Meta? {
|
||||
val summary = item.movie ?: item.show ?: return null
|
||||
val id = resolveContentId(summary.ids) ?: return null
|
||||
return Meta(
|
||||
id = id,
|
||||
name = summary.title ?: unknownName(),
|
||||
type = type,
|
||||
poster = null,
|
||||
releaseInfo = summary.year?.toString(),
|
||||
released = summary.year?.let { "$it-01-01" }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.fluxa.app.domain.discovery
|
||||
|
||||
data class MetadataFeedOption(
|
||||
val key: String,
|
||||
val label: String,
|
||||
val transportUrl: String,
|
||||
val type: String,
|
||||
val id: String,
|
||||
val genre: String? = null
|
||||
)
|
||||
|
||||
data class DiscoverCatalogOption(
|
||||
val key: String,
|
||||
val label: String,
|
||||
val transportUrl: String,
|
||||
val type: String,
|
||||
val id: String,
|
||||
val genres: List<String>,
|
||||
val requiresGenre: Boolean = false
|
||||
)
|
||||
|
||||
data class Cs3CatalogFeedDescriptor(
|
||||
val pluginName: String,
|
||||
val catalogName: String,
|
||||
val catalogIndex: Int
|
||||
)
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
package com.fluxa.app.player
|
||||
|
||||
data class TorrentStatus(
|
||||
val hash: String,
|
||||
val title: String,
|
||||
val downloadSpeed: Double,
|
||||
val activePeers: Int,
|
||||
val totalPeers: Int,
|
||||
val progress: Double,
|
||||
val stat: Int = 0,
|
||||
val statString: String = "",
|
||||
val preload: Int = 0,
|
||||
val loadedSize: Long = 0,
|
||||
val preloadSize: Long = 0,
|
||||
val fileStats: List<TorrentFileStat>?
|
||||
)
|
||||
|
||||
data class TorrentFileStat(
|
||||
val id: Int,
|
||||
val path: String,
|
||||
val length: Long
|
||||
)
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
package com.fluxa.app.ui.catalog
|
||||
|
||||
import com.fluxa.app.data.remote.AddonManifest
|
||||
|
||||
fun addonManifestCapabilityRows(manifest: AddonManifest, language: String?): List<String> {
|
||||
val resourceOrder = manifest.resources.orEmpty().distinct()
|
||||
val types = (manifest.types.orEmpty() + manifest.catalogs.orEmpty().mapNotNull { it.type })
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
if (resourceOrder.isEmpty() || types.isEmpty()) return emptyList()
|
||||
return listOf("${types.joinToString(", ")} - ${resourceOrder.joinToString(", ")}")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue