Merge branch 'addon' into cmp-rewrite

This commit is contained in:
tapframe 2026-05-22 20:03:20 +05:30
commit 9d41785887
31 changed files with 528 additions and 128 deletions

View file

@ -19,6 +19,7 @@ import java.util.concurrent.TimeUnit
actual object AddonStorage {
private const val preferencesName = "nuvio_addons"
private const val addonUrlsKey = "installed_manifest_urls"
private const val addonEnabledStatesKey = "installed_manifest_enabled_states"
private var preferences: SharedPreferences? = null
@ -41,6 +42,34 @@ actual object AddonStorage {
?.putString("${addonUrlsKey}_$profileId", urls.joinToString(separator = "\n"))
?.apply()
}
actual fun loadAddonEnabledStates(profileId: Int): Map<String, Boolean> =
preferences
?.getString("${addonEnabledStatesKey}_$profileId", null)
.orEmpty()
.lineSequence()
.mapNotNull(::parseEnabledStateLine)
.toMap()
actual fun saveAddonEnabledStates(profileId: Int, states: Map<String, Boolean>) {
val payload = states.entries.joinToString(separator = "\n") { (url, enabled) ->
"$url\t$enabled"
}
preferences
?.edit()
?.putString("${addonEnabledStatesKey}_$profileId", payload)
?.apply()
}
}
private fun parseEnabledStateLine(line: String): Pair<String, Boolean>? {
val url = line.substringBefore("\t").trim().takeIf { it.isNotEmpty() } ?: return null
val rawEnabled = line.substringAfter("\t", "true").trim().lowercase()
val enabled = when (rawEnabled) {
"false" -> false
else -> true
}
return url to enabled
}
private val addonHttpClient = OkHttpClient.Builder()

View file

@ -25,6 +25,7 @@
<string name="addons_badge_active">Active</string>
<string name="addons_badge_catalogs">%1$d catalogs</string>
<string name="addons_badge_configurable">Configurable</string>
<string name="addons_badge_disabled">Disabled</string>
<string name="addons_badge_refreshing">Refreshing</string>
<string name="addons_badge_resources">%1$d resources</string>
<string name="addons_badge_unavailable">Unavailable</string>

View file

@ -103,6 +103,7 @@ import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported
import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
import com.nuvio.app.features.auth.AuthScreen
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.catalog.CatalogRepository
import com.nuvio.app.features.catalog.CatalogScreen
import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL
@ -622,6 +623,7 @@ private fun MainAppContent(
val addonProbeTargets = remember(addonsUiState.addons) {
addonsUiState.addons
.enabledAddons()
.mapNotNull { it.manifest?.transportUrl }
.distinct()
.sorted()

View file

@ -50,11 +50,12 @@ data class ManagedAddon(
val manifestUrl: String,
val manifest: AddonManifest? = null,
val userSetName: String? = null,
val enabled: Boolean = true,
val isRefreshing: Boolean = false,
val errorMessage: String? = null,
) {
val isActive: Boolean
get() = manifest != null
get() = enabled && manifest != null
val displayTitle: String
get() = userSetName?.takeIf { it.isNotBlank() && it != manifest?.name }
@ -78,9 +79,12 @@ internal fun List<ManagedAddon>.toOverview(): AddonOverview =
AddonOverview(
totalAddons = size,
activeAddons = count { it.isActive },
totalCatalogs = sumOf { it.manifest?.catalogs?.size ?: 0 },
totalCatalogs = filter { it.enabled }.sumOf { it.manifest?.catalogs?.size ?: 0 },
)
internal fun List<ManagedAddon>.enabledAddons(): List<ManagedAddon> =
filter { it.enabled }
sealed interface AddAddonResult {
data class Success(val manifest: AddonManifest) : AddAddonResult
data class Error(val message: String) : AddAddonResult

View file

@ -3,6 +3,8 @@ package com.nuvio.app.features.addons
internal expect object AddonStorage {
fun loadInstalledAddonUrls(profileId: Int): List<String>
fun saveInstalledAddonUrls(profileId: Int, urls: List<String>)
fun loadAddonEnabledStates(profileId: Int): Map<String, Boolean>
fun saveAddonEnabledStates(profileId: Int, states: Map<String, Boolean>)
}
data class RawHttpResponse(

View file

@ -62,19 +62,24 @@ object AddonRepository {
log.d { "initialize() — loading local addons for profile $currentProfileId" }
val storedUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId))
val enabledByUrl = loadLocalEnabledStates()
log.d { "initialize() — local addon count: ${storedUrls.size}" }
if (storedUrls.isEmpty()) return
val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl)
_uiState.value = AddonsUiState(
addons = storedUrls.map { manifestUrl ->
existingByUrl[manifestUrl].toPendingAddon(manifestUrl)
existingByUrl[manifestUrl].toPendingAddon(
manifestUrl = manifestUrl,
enabled = enabledByUrl[manifestUrl],
)
},
)
storedUrls.forEach { manifestUrl ->
val existing = existingByUrl[manifestUrl]
if (existing == null || (existing.manifest == null && !existing.isRefreshing)) {
val addon = _uiState.value.addons.firstOrNull { it.manifestUrl == manifestUrl }
if (addon?.enabled == true && (existing == null || (addon.manifest == null && !addon.isRefreshing))) {
refreshAddon(manifestUrl)
}
}
@ -110,30 +115,35 @@ object AddonRepository {
}
.decodeList<AddonRow>()
val namesByUrl = mutableMapOf<String, String>()
val rowsByUrl = linkedMapOf<String, AddonRow>()
rows.forEach { row ->
if (!row.name.isNullOrBlank()) {
namesByUrl[ensureManifestSuffix(row.url)] = row.name
val manifestUrl = ensureManifestSuffix(row.url)
if (!rowsByUrl.containsKey(manifestUrl)) {
rowsByUrl[manifestUrl] = row.copy(url = manifestUrl)
}
}
val urls = dedupeManifestUrls(rows.map { it.url })
val urls = rowsByUrl.keys.toList()
log.i { "pullFromServer() — server returned ${rows.size} addons" }
urls.forEachIndexed { i, u -> log.d { " server[$i]: $u" } }
if (urls.isEmpty() && !pulledFromServer) {
val localUrls = AddonStorage.loadInstalledAddonUrls(currentProfileId)
val localUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId))
log.i { "pullFromServer() — server empty, local has ${localUrls.size} addons" }
if (localUrls.isNotEmpty()) {
log.i { "pullFromServer() — migrating local addons to server for profile $currentProfileId" }
initialize()
pulledFromServer = true
val enabledByUrl = loadLocalEnabledStates()
val addons = localUrls.mapIndexed { index, addonUrl ->
val manifestUrl = ensureManifestSuffix(addonUrl)
AddonPushItem(
url = addonUrl,
url = manifestUrl,
name = _uiState.value.addons
.find { it.manifestUrl == addonUrl }?.manifest?.name ?: "",
enabled = true,
.find { it.manifestUrl == manifestUrl }?.manifest?.name ?: "",
enabled = enabledByUrl[manifestUrl]
?: _uiState.value.addons.find { it.manifestUrl == manifestUrl }?.enabled
?: true,
sortOrder = index,
)
}
@ -151,16 +161,21 @@ object AddonRepository {
val localUrls = dedupeManifestUrls(AddonStorage.loadInstalledAddonUrls(currentProfileId))
if (localUrls.isNotEmpty()) {
log.w { "pullFromServer() — remote empty while local has ${localUrls.size} addons; preserving local addons" }
val enabledByUrl = loadLocalEnabledStates()
val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl)
_uiState.value = AddonsUiState(
addons = localUrls.map { url ->
existingByUrl[url].toPendingAddon(url)
existingByUrl[url].toPendingAddon(
manifestUrl = url,
enabled = enabledByUrl[url],
)
},
)
persist()
localUrls.forEach { url ->
val existing = existingByUrl[url]
if (existing == null || (existing.manifest == null && !existing.isRefreshing)) {
val addon = _uiState.value.addons.firstOrNull { it.manifestUrl == url }
if (addon?.enabled == true && (existing == null || (addon.manifest == null && !addon.isRefreshing))) {
refreshAddon(url)
}
}
@ -173,13 +188,19 @@ object AddonRepository {
val existingByUrl = _uiState.value.addons.associateBy(ManagedAddon::manifestUrl)
_uiState.value = AddonsUiState(
addons = urls.map { url ->
existingByUrl[url].toPendingAddon(url, namesByUrl[url])
val row = rowsByUrl[url]
existingByUrl[url].toPendingAddon(
manifestUrl = url,
userSetName = row?.name?.takeIf { it.isNotBlank() },
enabled = row?.enabled,
)
},
)
persist()
urls.forEach { url ->
val existing = existingByUrl[url]
if (existing == null || (existing.manifest == null && !existing.isRefreshing)) {
val addon = _uiState.value.addons.firstOrNull { it.manifestUrl == url }
if (addon?.enabled == true && (existing == null || (addon.manifest == null && !addon.isRefreshing))) {
refreshAddon(url)
}
}
@ -194,7 +215,9 @@ object AddonRepository {
suspend fun awaitManifestsLoaded() {
if (_uiState.value.addons.isEmpty()) return
uiState.first { state ->
state.addons.isEmpty() || state.addons.any { it.manifest != null }
state.addons.isEmpty() ||
state.addons.any { it.manifest != null } ||
state.addons.none { it.isRefreshing }
}
}
@ -273,8 +296,30 @@ object AddonRepository {
pushToServer()
}
fun setAddonEnabled(manifestUrl: String, enabled: Boolean) {
if (isUsingPrimaryAddonsFromSecondaryProfile()) return
var shouldRefresh = false
_uiState.update { current ->
current.copy(
addons = current.addons.map { addon ->
if (addon.manifestUrl != manifestUrl || addon.enabled == enabled) {
addon
} else {
shouldRefresh = enabled && addon.manifest == null && !addon.isRefreshing
addon.copy(enabled = enabled)
}
},
)
}
persist()
pushToServer()
if (shouldRefresh) {
refreshAddon(manifestUrl)
}
}
fun refreshAll() {
_uiState.value.addons.distinctBy { it.manifestUrl }.forEach { addon ->
_uiState.value.addons.filter { it.enabled }.distinctBy { it.manifestUrl }.forEach { addon ->
refreshAddon(addon.manifestUrl)
}
}
@ -339,13 +384,13 @@ object AddonRepository {
val addons = _uiState.value.addons
.distinctBy { it.manifestUrl }
.mapIndexed { index, addon ->
AddonPushItem(
url = addon.manifestUrl,
name = addon.userSetName?.takeIf { it.isNotBlank() } ?: addon.manifest?.name ?: "",
enabled = true,
sortOrder = index,
)
}
AddonPushItem(
url = addon.manifestUrl,
name = addon.userSetName?.takeIf { it.isNotBlank() } ?: addon.manifest?.name ?: "",
enabled = addon.enabled,
sortOrder = index,
)
}
log.d { "pushToServer() — profileId=$profileId, pushing ${addons.size} addons" }
val params = buildJsonObject {
put("p_profile_id", profileId)
@ -377,12 +422,21 @@ object AddonRepository {
}
private fun persist() {
val addons = _uiState.value.addons
AddonStorage.saveInstalledAddonUrls(
currentProfileId,
dedupeManifestUrls(_uiState.value.addons.map { it.manifestUrl }),
dedupeManifestUrls(addons.map { it.manifestUrl }),
)
AddonStorage.saveAddonEnabledStates(
currentProfileId,
addons.associate { it.manifestUrl to it.enabled },
)
}
private fun loadLocalEnabledStates(): Map<String, Boolean> =
AddonStorage.loadAddonEnabledStates(currentProfileId)
.mapKeys { (url, _) -> ensureManifestSuffix(url) }
private fun cancelActiveRefreshes() {
activeRefreshJobs.values.forEach(Job::cancel)
activeRefreshJobs.clear()
@ -399,27 +453,35 @@ object AddonRepository {
}
}
private fun ManagedAddon?.toPendingAddon(manifestUrl: String, userSetName: String? = null): ManagedAddon =
private fun ManagedAddon?.toPendingAddon(
manifestUrl: String,
userSetName: String? = null,
enabled: Boolean? = null,
): ManagedAddon =
when {
this == null -> ManagedAddon(
manifestUrl = manifestUrl,
isRefreshing = true,
isRefreshing = enabled ?: true,
userSetName = userSetName,
enabled = enabled ?: true,
)
manifest != null -> copy(
manifestUrl = manifestUrl,
isRefreshing = false,
userSetName = userSetName ?: this.userSetName,
enabled = enabled ?: this.enabled,
)
isRefreshing -> copy(
manifestUrl = manifestUrl,
userSetName = userSetName ?: this.userSetName,
enabled = enabled ?: this.enabled,
)
else -> copy(
manifestUrl = manifestUrl,
isRefreshing = true,
isRefreshing = enabled ?: this.enabled,
errorMessage = null,
userSetName = userSetName ?: this.userSetName,
enabled = enabled ?: this.enabled,
)
}

View file

@ -23,6 +23,8 @@ import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
@ -157,6 +159,9 @@ internal fun AddonsSettingsPageContent(
null
},
onRefreshClick = { AddonRepository.refreshAddon(addon.manifestUrl) },
onEnabledChange = { enabled ->
AddonRepository.setAddonEnabled(addon.manifestUrl, enabled)
},
onConfigureClick = if (showConfigureAction && !configureUrl.isNullOrBlank()) {
{
runCatching {
@ -347,6 +352,7 @@ private fun InstalledAddonCard(
onMoveUpClick: (() -> Unit)?,
onMoveDownClick: (() -> Unit)?,
onRefreshClick: () -> Unit,
onEnabledChange: (Boolean) -> Unit,
onConfigureClick: (() -> Unit)?,
onDeleteClick: () -> Unit,
) {
@ -380,6 +386,17 @@ private fun InstalledAddonCard(
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Switch(
checked = addon.enabled,
onCheckedChange = onEnabledChange,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onPrimary,
checkedTrackColor = MaterialTheme.colorScheme.primary,
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant,
),
)
}
Spacer(modifier = Modifier.height(12.dp))
@ -438,6 +455,7 @@ private fun InstalledAddonCard(
) {
NuvioInfoBadge(
text = when {
!addon.enabled -> stringResource(Res.string.addons_badge_disabled)
addon.isRefreshing -> stringResource(Res.string.addons_badge_refreshing)
manifest != null -> stringResource(Res.string.addons_badge_active)
else -> stringResource(Res.string.addons_badge_unavailable)

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.collection
import com.nuvio.app.features.addons.AddonCatalog
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.addons.enabledAddons
internal data class ResolvedCollectionCatalog(
val addon: ManagedAddon,
@ -11,13 +12,14 @@ internal data class ResolvedCollectionCatalog(
internal fun List<ManagedAddon>.findCollectionCatalog(
source: CollectionCatalogSource,
): ResolvedCollectionCatalog? {
val declaredAddon = firstOrNull { it.manifest?.id == source.addonId }
val activeAddons = enabledAddons()
val declaredAddon = activeAddons.firstOrNull { it.manifest?.id == source.addonId }
val declaredCatalog = declaredAddon?.manifest?.catalogs?.findSourceCatalog(source)
if (declaredAddon != null && declaredCatalog != null) {
return ResolvedCollectionCatalog(addon = declaredAddon, catalog = declaredCatalog)
}
return firstNotNullOfOrNull { addon ->
return activeAddons.firstNotNullOfOrNull { addon ->
val catalog = addon.manifest?.catalogs?.find {
it.id == source.catalogId && it.type == source.type
} ?: return@firstNotNullOfOrNull null
@ -40,4 +42,3 @@ private fun List<AddonCatalog>.findSourceCatalog(source: CollectionCatalogSource
private fun List<AvailableCatalog>.findSourceCatalog(source: CollectionCatalogSource): AvailableCatalog? =
find { it.catalogId == source.catalogId && it.type == source.type }
?: find { it.catalogId == source.catalogId.substringBefore(",") && it.type == source.type }

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.collection
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.addons.enabledAddons
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
@ -240,7 +241,7 @@ object CollectionRepository {
fun generateId(): String = Uuid.random().toString()
fun getAvailableCatalogs(): List<AvailableCatalog> {
val addons = AddonRepository.uiState.value.addons
val addons = AddonRepository.uiState.value.addons.enabledAddons()
return addons.mapNotNull { addon ->
val manifest = addon.manifest ?: return@mapNotNull null
addon to manifest

View file

@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonManifest
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.filterReleasedItems
@ -261,6 +262,7 @@ object MetaDetailsRepository {
private fun findMetaManifests(type: String, id: String): List<AddonManifest> =
AddonRepository.uiState.value.addons
.enabledAddons()
.mapNotNull { it.manifest }
.filter { manifest ->
manifest.resources.any { resource ->

View file

@ -344,6 +344,22 @@ fun MetaDetailsScreen(
val progressByVideoId = remember(watchProgressUiState.entries) {
watchProgressUiState.byVideoId
}
LaunchedEffect(
meta.id,
meta.type,
todayIsoDate,
watchedUiState.isLoaded,
watchProgressUiState.hasLoadedRemoteProgress,
watchedUiState.watchedKeys,
watchProgressUiState.entries,
) {
if (watchedUiState.isLoaded && watchProgressUiState.hasLoadedRemoteProgress) {
WatchingActions.reconcileSeriesWatchedState(
meta = meta,
todayIsoDate = todayIsoDate,
)
}
}
val movieProgress = progressByVideoId[meta.id]
?.takeUnless { it.isCompleted }
val cwPrefs by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle()

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.home
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.catalog.supportsPagination
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res
@ -19,7 +20,7 @@ data class HomeCatalogDefinition(
)
fun buildHomeCatalogDefinitions(addons: List<ManagedAddon>): List<HomeCatalogDefinition> =
addons.mapNotNull { addon ->
addons.enabledAddons().mapNotNull { addon ->
val manifest = addon.manifest ?: return@mapNotNull null
addon to manifest
}.flatMap { (addon, manifest) ->

View file

@ -2,6 +2,7 @@ package com.nuvio.app.features.home
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.catalog.fetchCatalogPage
import com.nuvio.app.features.collection.Collection
import com.nuvio.app.features.collection.CollectionRepository
@ -41,7 +42,8 @@ object HomeRepository {
private var lastErrorMessage: String? = null
fun refresh(addons: List<ManagedAddon>, force: Boolean = false) {
val requests = buildHomeCatalogDefinitions(addons)
val activeAddons = addons.enabledAddons()
val requests = buildHomeCatalogDefinitions(activeAddons)
currentDefinitions = requests
val requestKeys = requests.mapTo(mutableSetOf(), HomeCatalogDefinition::key)
cachedSections = cachedSections.filterKeys(requestKeys::contains)
@ -71,7 +73,7 @@ object HomeRepository {
requestKey = requestKey,
)
ensureCollectionHeroFallback(
addons = addons,
addons = activeAddons,
force = force,
requestKey = requestKey,
)
@ -135,7 +137,7 @@ object HomeRepository {
requestKey = requestKey,
)
ensureCollectionHeroFallback(
addons = addons,
addons = activeAddons,
force = force,
requestKey = requestKey,
)
@ -148,7 +150,7 @@ object HomeRepository {
requestKey = activeRequestKey ?: lastRequestKey,
)
ensureCollectionHeroFallback(
addons = AddonRepository.uiState.value.addons,
addons = AddonRepository.uiState.value.addons.enabledAddons(),
force = false,
requestKey = activeRequestKey ?: lastRequestKey,
)

View file

@ -21,6 +21,7 @@ import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.cloud.CloudLibraryContentType
import com.nuvio.app.features.cloud.CloudLibraryRepository
import com.nuvio.app.features.cloud.CloudLibraryUiState
@ -47,6 +48,7 @@ import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.CachedInProgressItem
import com.nuvio.app.features.watchprogress.CachedNextUpItem
import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
import com.nuvio.app.features.watchprogress.ContinueWatchingLimit
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
@ -80,6 +82,7 @@ import kotlinx.coroutines.sync.withPermit
import com.nuvio.app.features.home.components.ContinueWatchingLayout
import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth
import com.nuvio.app.features.home.components.rememberContinueWatchingLayout
import kotlinx.coroutines.CancellationException
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
@ -144,7 +147,7 @@ fun HomeScreen(
NetworkCondition.Online -> {
if (observedOfflineState) {
observedOfflineState = false
HomeRepository.refresh(addonsUiState.addons, force = true)
HomeRepository.refresh(addonsUiState.addons.enabledAddons(), force = true)
}
}
@ -367,8 +370,14 @@ fun HomeScreen(
cloudLibraryUiState = cloudLibraryUiState,
)
}
val availableManifests = remember(addonsUiState.addons) {
addonsUiState.addons.mapNotNull { addon -> addon.manifest }
val enabledAddons = remember(addonsUiState.addons) {
addonsUiState.addons.enabledAddons()
}
val isRefreshingEnabledAddons = remember(enabledAddons) {
enabledAddons.any { addon -> addon.isRefreshing }
}
val availableManifests = remember(enabledAddons) {
enabledAddons.mapNotNull { addon -> addon.manifest }
}
val metaProviderKey = remember(availableManifests) {
@ -394,8 +403,8 @@ fun HomeScreen(
LaunchedEffect(catalogRefreshKey) {
if (catalogRefreshKey.isEmpty()) return@LaunchedEffect
HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons)
HomeRepository.refresh(addonsUiState.addons)
HomeCatalogSettingsRepository.syncCatalogs(enabledAddons)
HomeRepository.refresh(enabledAddons)
}
LaunchedEffect(collections) {
@ -409,8 +418,10 @@ fun HomeScreen(
metaProviderKey,
continueWatchingPreferences.showUnairedNextUp,
continueWatchingPreferences.upNextFromFurthestEpisode,
isRefreshingEnabledAddons,
watchProgressUiState.entries,
watchedUiState.items,
watchedUiState.isLoaded,
) {
if (completedSeriesCandidates.isEmpty()) {
nextUpItemsBySeries = emptyMap()
@ -418,6 +429,14 @@ fun HomeScreen(
return@LaunchedEffect
}
if (!isTraktProgressActive && !watchedUiState.isLoaded) {
return@LaunchedEffect
}
if (isRefreshingEnabledAddons) {
return@LaunchedEffect
}
val cachedResolvedNextUpItems = completedSeriesCandidates.mapNotNull { candidate ->
val cached = cachedNextUpItems[candidate.content.id] ?: return@mapNotNull null
val item = cached.second
@ -432,6 +451,7 @@ fun HomeScreen(
val candidatesToResolve = completedSeriesCandidates.filter { candidate ->
candidate.content.id !in cachedResolvedNextUpItems
}
val resolutionCandidates = candidatesToResolve.take(NEXT_UP_INITIAL_RESOLUTION_LIMIT)
if (candidatesToResolve.isEmpty()) {
nextUpItemsBySeries = cachedResolvedNextUpItems
processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate ->
@ -450,46 +470,54 @@ fun HomeScreen(
}
val todayIsoDate = CurrentDateProvider.todayIsoDate()
val semaphore = Semaphore(4)
val freshResults = candidatesToResolve.map { completedEntry ->
async {
semaphore.withPermit {
val meta = MetaDetailsRepository.fetch(
type = completedEntry.content.type,
id = completedEntry.content.id,
)
if (meta == null) {
return@withPermit null
val semaphore = Semaphore(NEXT_UP_RESOLUTION_CONCURRENCY)
val freshResults = mutableMapOf<String, Pair<Long, ContinueWatchingItem>>()
val processedFreshContentIds = mutableSetOf<String>()
val candidateBatches = resolutionCandidates.chunked(NEXT_UP_RESOLUTION_BATCH_SIZE)
for (batch in candidateBatches) {
val batchResults = batch.map { completedEntry ->
async {
semaphore.withPermit {
resolveHomeNextUpCandidate(
completedEntry = completedEntry,
watchProgressEntries = watchProgressUiState.entries,
watchedItems = watchedUiState.items,
todayIsoDate = todayIsoDate,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
dismissedNextUpKeys = continueWatchingPreferences.dismissedNextUpKeys,
)
}
val action = meta.seriesPrimaryAction(
content = completedEntry.content,
entries = watchProgressUiState.entries,
watchedItems = watchedUiState.items,
todayIsoDate = todayIsoDate,
preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode,
showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp,
)
if (action?.resumePositionMs != null) {
return@withPermit null
}
val nextEpisode = action?.let { meta.videoForSeriesAction(it) }
if (nextEpisode == null) {
return@withPermit null
}
val item = completedEntry.toContinueWatchingSeed(meta)
.toUpNextContinueWatchingItem(nextEpisode)
if (nextUpDismissKey(item.parentMetaId, item.nextUpSeedSeasonNumber, item.nextUpSeedEpisodeNumber) in continueWatchingPreferences.dismissedNextUpKeys) {
return@withPermit null
}
completedEntry.content.id to (completedEntry.markedAtEpochMs to item)
}
}.awaitAll()
batch.forEach { candidate -> processedFreshContentIds += candidate.content.id }
val resolvedBeforeBatch = freshResults.size
batchResults.filterNotNull().forEach { (contentId, item) ->
freshResults[contentId] = item
}
}.awaitAll().filterNotNull().toMap()
val batchResolvedCount = freshResults.size - resolvedBeforeBatch
if (batchResolvedCount > 0) {
val progressiveResults = cachedResolvedNextUpItems + freshResults
nextUpItemsBySeries = progressiveResults
processedNextUpContentIds = (
cachedResolvedNextUpItems.keys +
processedFreshContentIds
).toSet()
}
if (cachedResolvedNextUpItems.size + freshResults.size >= ContinueWatchingLimit) {
break
}
}
val results = cachedResolvedNextUpItems + freshResults
nextUpItemsBySeries = results
processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate ->
candidate.content.id
}
processedNextUpContentIds = (
cachedResolvedNextUpItems.keys +
processedFreshContentIds
).toSet()
saveContinueWatchingSnapshots(
nextUpItemsBySeries = results,
@ -498,9 +526,9 @@ fun HomeScreen(
)
}
val hasActiveAddons = addonsUiState.addons.any { it.manifest != null }
val hasActiveAddons = enabledAddons.any { it.manifest != null }
val showHeroSlot = homeSettingsUiState.heroEnabled
val isResolvingHeroSources = addonsUiState.addons.any { it.isRefreshing } || homeUiState.isLoading
val isResolvingHeroSources = enabledAddons.any { it.isRefreshing } || homeUiState.isLoading
val showHeroSkeleton = showHeroSlot &&
homeUiState.heroItems.isEmpty() &&
isResolvingHeroSources
@ -591,7 +619,7 @@ fun HomeScreen(
}
when {
addonsUiState.addons.none { it.manifest != null } && !hasRenderableCollectionRows -> {
!hasActiveAddons && !hasRenderableCollectionRows -> {
if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) {
item {
HomeContinueWatchingSection(
@ -650,7 +678,7 @@ fun HomeScreen(
modifier = Modifier.padding(horizontal = 16.dp),
onRetry = {
NetworkStatusRepository.requestRefresh(force = true)
HomeRepository.refresh(addonsUiState.addons, force = true)
HomeRepository.refresh(addonsUiState.addons.enabledAddons(), force = true)
},
)
} else {
@ -726,6 +754,9 @@ fun HomeScreen(
private const val HOME_CATALOG_PREVIEW_LIMIT = 18
private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L
private const val NEXT_UP_INITIAL_RESOLUTION_LIMIT = ContinueWatchingLimit * 2
private const val NEXT_UP_RESOLUTION_CONCURRENCY = 8
private const val NEXT_UP_RESOLUTION_BATCH_SIZE = NEXT_UP_RESOLUTION_CONCURRENCY
internal fun filterEntriesForTraktContinueWatchingWindow(
entries: List<WatchProgressEntry>,
@ -820,6 +851,49 @@ internal fun filterNextUpItemsByCurrentSeeds(
item.nextUpSeedEpisodeNumber == currentSeed.second
}
private suspend fun resolveHomeNextUpCandidate(
completedEntry: CompletedSeriesCandidate,
watchProgressEntries: List<WatchProgressEntry>,
watchedItems: List<WatchedItem>,
todayIsoDate: String,
preferFurthestEpisode: Boolean,
showUnairedNextUp: Boolean,
dismissedNextUpKeys: Set<String>,
): Pair<String, Pair<Long, ContinueWatchingItem>>? {
val contentId = completedEntry.content.id
val meta = try {
MetaDetailsRepository.fetch(
type = completedEntry.content.type,
id = contentId,
)
} catch (error: Throwable) {
if (error is CancellationException) throw error
null
}
if (meta == null) return null
val action = meta.seriesPrimaryAction(
content = completedEntry.content,
entries = watchProgressEntries,
watchedItems = watchedItems,
todayIsoDate = todayIsoDate,
preferFurthestEpisode = preferFurthestEpisode,
showUnairedNextUp = showUnairedNextUp,
)
if (action == null) return null
if (action.resumePositionMs != null) return null
val nextEpisode = meta.videoForSeriesAction(action)
if (nextEpisode == null) return null
val item = completedEntry.toContinueWatchingSeed(meta)
.toUpNextContinueWatchingItem(nextEpisode)
if (nextUpDismissKey(item.parentMetaId, item.nextUpSeedSeasonNumber, item.nextUpSeedEpisodeNumber) in dismissedNextUpKeys) {
return null
}
return contentId to (completedEntry.markedAtEpochMs to item)
}
private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaVideo? {
if (action.seasonNumber != null && action.episodeNumber != null) {
videos.firstOrNull { video ->

View file

@ -46,6 +46,7 @@ import com.nuvio.app.features.debrid.toastMessage
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.AddonResource
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.details.MetaVideo
@ -1166,6 +1167,7 @@ fun PlayerScreen(
)
val installedAddonNames = AddonRepository.uiState.value.addons
.enabledAddons()
.map { it.displayTitle }
.toSet()
val debridSettings = DebridSettingsRepository.snapshot()
@ -2289,7 +2291,7 @@ private fun buildAddonSubtitleFetchKey(
): String? {
val normalizedType = type?.takeIf { it.isNotBlank() } ?: return null
val normalizedVideoId = videoId?.takeIf { it.isNotBlank() } ?: return null
val compatibleSubtitleAddons = addons.mapNotNull { addon ->
val compatibleSubtitleAddons = addons.enabledAddons().mapNotNull { addon ->
val manifest = addon.manifest ?: return@mapNotNull null
val supportsSubtitles = manifest.resources.any { resource ->
resource.isCompatibleSubtitleResource(

View file

@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.debrid.DebridStreamPresentation
@ -158,7 +159,7 @@ object PlayerStreamsRepository {
return
}
val installedAddons = AddonRepository.uiState.value.addons
val installedAddons = AddonRepository.uiState.value.addons.enabledAddons()
val installedAddonNames = installedAddons.map { it.displayTitle }.toSet()
PlayerSettingsRepository.ensureLoaded()
val playerSettings = PlayerSettingsRepository.uiState.value

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.player
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.AddonResource
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.addons.httpGetText
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
@ -49,7 +50,7 @@ object SubtitleRepository {
_error.value = null
_addonSubtitles.value = emptyList()
val addons = AddonRepository.uiState.value.addons
val addons = AddonRepository.uiState.value.addons.enabledAddons()
val allSubs = mutableListOf<AddonSubtitle>()
for (addon in addons) {

View file

@ -5,6 +5,7 @@ import com.nuvio.app.core.i18n.localizedMediaTypeLabel
import com.nuvio.app.features.addons.AddonCatalog
import com.nuvio.app.features.addons.AddonExtraProperty
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.catalog.CatalogPage
import com.nuvio.app.features.catalog.buildCatalogUrl
import com.nuvio.app.features.catalog.fetchCatalogPage
@ -50,7 +51,7 @@ object SearchRepository {
return
}
val activeAddons = addons.filter { it.manifest != null }
val activeAddons = addons.enabledAddons().filter { it.manifest != null }
if (activeAddons.isEmpty()) {
activeJob?.cancel()
lastRequestKey = null
@ -173,7 +174,7 @@ object SearchRepository {
}
fun refreshDiscover(addons: List<ManagedAddon>) {
val activeAddons = addons.filter { it.manifest != null }
val activeAddons = addons.enabledAddons().filter { it.manifest != null }
if (activeAddons.isEmpty()) {
activeDiscoverJob?.cancel()
discoverSources = emptyList()

View file

@ -49,6 +49,7 @@ import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.home.components.HomeCatalogRowSection
@ -128,7 +129,7 @@ fun SearchScreen(
}
val addonRefreshKey = remember(addonsUiState.addons) {
addonsUiState.addons.mapNotNull { addon ->
addonsUiState.addons.enabledAddons().mapNotNull { addon ->
val manifest = addon.manifest ?: return@mapNotNull null
buildString {
append(manifest.transportUrl)

View file

@ -51,6 +51,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.player.AudioLanguageOption
import com.nuvio.app.features.player.AvailableLanguageOptions
import com.nuvio.app.features.player.ExternalPlayerApp
@ -999,6 +1000,7 @@ private fun PlaybackSettingsSection(
if (showAutoPlayAddonSelectionDialog) {
val addonNames = addonUiState.addons
.enabledAddons()
.mapNotNull { it.manifest }
.filter { manifest -> manifest.resources.any { resource -> resource.name == "stream" } }
.map { it.name }

View file

@ -11,6 +11,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioScreenHeader
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.plugins.PluginRepository
@ -31,10 +32,11 @@ fun HomescreenSettingsScreen(
) {
val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle()
val homescreenCatalogRefreshKey = remember(addonsUiState.addons) {
val allManifestsSettled = addonsUiState.addons.isNotEmpty() &&
addonsUiState.addons.none { it.isRefreshing }
val enabledAddons = addonsUiState.addons.enabledAddons()
val allManifestsSettled = enabledAddons.isNotEmpty() &&
enabledAddons.none { it.isRefreshing }
if (!allManifestsSettled) return@remember emptyList<String>()
addonsUiState.addons.mapNotNull { addon ->
enabledAddons.mapNotNull { addon ->
val manifest = addon.manifest ?: return@mapNotNull null
buildString {
append(manifest.transportUrl)
@ -58,7 +60,7 @@ fun HomescreenSettingsScreen(
LaunchedEffect(homescreenCatalogRefreshKey) {
if (homescreenCatalogRefreshKey.isEmpty()) return@LaunchedEffect
HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons)
HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons.enabledAddons())
}
LaunchedEffect(collections) {

View file

@ -59,6 +59,7 @@ import com.nuvio.app.features.details.MetaScreenSettingsUiState
import com.nuvio.app.core.ui.PosterCardStyleRepository
import com.nuvio.app.core.ui.PosterCardStyleUiState
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.debrid.DebridSettings
import com.nuvio.app.features.debrid.DebridSettingsRepository
import com.nuvio.app.features.home.HomeCatalogSettingsItem
@ -157,10 +158,11 @@ fun SettingsScreen(
AddonRepository.uiState
}.collectAsStateWithLifecycle()
val homescreenCatalogRefreshKey = remember(addonsUiState.addons) {
val allManifestsSettled = addonsUiState.addons.isNotEmpty() &&
addonsUiState.addons.none { it.isRefreshing }
val enabledAddons = addonsUiState.addons.enabledAddons()
val allManifestsSettled = enabledAddons.isNotEmpty() &&
enabledAddons.none { it.isRefreshing }
if (!allManifestsSettled) return@remember emptyList<String>()
addonsUiState.addons.mapNotNull { addon ->
enabledAddons.mapNotNull { addon ->
val manifest = addon.manifest ?: return@mapNotNull null
buildString {
append(manifest.transportUrl)
@ -195,7 +197,7 @@ fun SettingsScreen(
LaunchedEffect(homescreenCatalogRefreshKey) {
if (homescreenCatalogRefreshKey.isEmpty()) return@LaunchedEffect
HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons)
HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons.enabledAddons())
}
LaunchedEffect(Unit) {

View file

@ -5,6 +5,7 @@ import com.nuvio.app.features.addons.AddonManifest
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.debrid.DebridSettings
import com.nuvio.app.features.debrid.DebridSettingsRepository
@ -213,6 +214,7 @@ object AddonStreamWarmupRepository {
AddonRepository.initialize()
val addonTargets = AddonRepository.uiState.value.addons
.enabledAddons()
.mapNotNull { addon -> addon.toWarmupTarget(normalizedType, normalizedVideoId) }
if (addonTargets.isEmpty()) return null

View file

@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.debrid.DirectDebridStreamPreparer
import com.nuvio.app.features.debrid.DebridSettingsRepository
@ -152,7 +153,7 @@ object StreamsRepository {
return
}
val installedAddons = AddonRepository.uiState.value.addons
val installedAddons = AddonRepository.uiState.value.addons.enabledAddons()
val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) {
PluginRepository.getEnabledScrapersForType(type)
} else {

View file

@ -201,6 +201,8 @@ object WatchedRepository {
todayIsoDate: String,
isEpisodeCompleted: (com.nuvio.app.features.details.MetaVideo) -> Boolean = { false },
) {
if (!meta.type.isSeriesLikeWatchedType()) return
ensureLoaded()
val shouldMarkSeriesWatched = meta.hasWatchedAllMainSeasonEpisodes(todayIsoDate) { episode ->
isWatched(
@ -211,11 +213,12 @@ object WatchedRepository {
) || isEpisodeCompleted(episode)
}
val seriesWatchedItem = meta.toSeriesWatchedItem()
val hasSeriesWatchedMarker = isWatched(id = meta.id, type = meta.type)
if (shouldMarkSeriesWatched) {
if (!isWatched(id = meta.id, type = meta.type)) {
if (!hasSeriesWatchedMarker) {
markWatched(seriesWatchedItem)
}
} else if (isWatched(id = meta.id, type = meta.type)) {
} else if (hasSeriesWatchedMarker) {
unmarkWatched(seriesWatchedItem)
}
}
@ -355,3 +358,6 @@ internal fun shouldUseTraktWatchedSync(
isAuthenticated = isAuthenticated,
source = source,
)
private fun String.isSeriesLikeWatchedType(): Boolean =
trim().lowercase() in setOf("series", "show", "tv", "tvshow")

View file

@ -7,7 +7,7 @@ import com.nuvio.app.features.home.MetaPreview
import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.episodePlaybackId
import com.nuvio.app.features.watched.releasedPlayableEpisodes
import com.nuvio.app.features.watched.releasedMainSeasonEpisodes
import com.nuvio.app.features.watched.toEpisodeWatchedItem
import com.nuvio.app.features.watched.toSeriesWatchedItem
import com.nuvio.app.features.watched.toWatchedItem
@ -23,7 +23,7 @@ object WatchingActions {
private val actionScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
suspend fun togglePosterWatched(preview: MetaPreview) {
if (preview.type != "series") {
if (!preview.type.isSeriesLikeType()) {
WatchedRepository.toggleWatched(preview.toWatchedItem(markedAtEpochMs = 0L))
return
}
@ -34,14 +34,23 @@ object WatchingActions {
)
val meta = MetaDetailsRepository.fetch(type = preview.type, id = preview.id)
if (meta == null) {
WatchedRepository.toggleWatched(preview.toWatchedItem(markedAtEpochMs = 0L))
if (isCurrentlyWatched) {
WatchedRepository.unmarkWatched(preview.toWatchedItem(markedAtEpochMs = 0L))
}
return
}
val todayIsoDate = CurrentDateProvider.todayIsoDate()
val releasedMainEpisodes = meta.releasedMainSeasonEpisodes(todayIsoDate)
if (releasedMainEpisodes.isEmpty()) {
if (isCurrentlyWatched) {
WatchedRepository.unmarkWatched(meta.toSeriesWatchedItem())
}
return
}
val seriesItems = buildList {
add(meta.toSeriesWatchedItem())
addAll(meta.releasedPlayableEpisodes(todayIsoDate).map(meta::toEpisodeWatchedItem))
addAll(releasedMainEpisodes.map(meta::toEpisodeWatchedItem))
}
if (isCurrentlyWatched) {
@ -49,7 +58,7 @@ object WatchingActions {
} else {
WatchedRepository.markWatched(seriesItems)
WatchProgressRepository.clearProgress(
meta.releasedPlayableEpisodes(todayIsoDate).map(meta::episodePlaybackId),
releasedMainEpisodes.map(meta::episodePlaybackId),
)
}
}
@ -97,6 +106,8 @@ object WatchingActions {
meta: MetaDetails,
todayIsoDate: String = CurrentDateProvider.todayIsoDate(),
) {
if (!meta.type.isSeriesLikeType()) return
WatchedRepository.reconcileSeriesWatchedState(
meta = meta,
todayIsoDate = todayIsoDate,
@ -149,3 +160,6 @@ object WatchingActions {
reconcileSeriesWatchedState(meta)
}
}
private fun String.isSeriesLikeType(): Boolean =
trim().lowercase() in setOf("series", "show", "tv", "tvshow")

View file

@ -70,7 +70,9 @@ internal object ContinueWatchingEnrichmentCache {
fun getSnapshots(): Pair<List<CachedNextUpItem>, List<CachedInProgressItem>> {
val payload = loadPayload()
return (payload?.nextUp ?: emptyList()) to (payload?.inProgress ?: emptyList())
val nextUp = payload?.nextUp ?: emptyList()
val inProgress = payload?.inProgress ?: emptyList()
return nextUp to inProgress
}
fun saveSnapshots(
@ -80,7 +82,9 @@ internal object ContinueWatchingEnrichmentCache {
) {
val payload = CachedEnrichmentPayload(nextUp = nextUp, inProgress = inProgress)
val payloadHash = payload.hashCode()
if (!force && lastPayloadHash == payloadHash) return
if (!force && lastPayloadHash == payloadHash) {
return
}
val encoded = runCatching {
json.encodeToString(payload)

View file

@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.core.auth.AuthRepository
import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.player.PlayerPlaybackSnapshot
import com.nuvio.app.features.profiles.ProfileRepository
@ -20,15 +21,28 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeoutOrNull
private const val NUVIO_SYNC_PERIODIC_INTERVAL_MS = 5L * 60L * 1000L
private const val WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY = 4
private data class RemoteMetadataResolutionResult(
val key: Pair<String, String>,
val entries: List<WatchProgressEntry>,
val meta: MetaDetails?,
)
object WatchProgressRepository {
private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -189,6 +203,11 @@ object WatchProgressRepository {
sinceLastWatched = sinceLastWatched,
)
val isIncrementalPull = sinceLastWatched != null
if (isIncrementalPull && serverEntries.isEmpty()) {
hasLoaded = true
hasCompletedInitialNuvioSyncPull = true
return@runCatching
}
val oldLocal = entriesByVideoId.toMap()
val newMap = if (isIncrementalPull) {
entriesByVideoId.toMutableMap()
@ -245,8 +264,10 @@ object WatchProgressRepository {
)
private fun resolveRemoteMetadata() {
val needsResolution = entriesByVideoId.values
val missingMetadataEntries = entriesByVideoId.values
.filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() }
val entriesToResolve = missingMetadataEntries.continueWatchingEntries(limit = ContinueWatchingLimit)
val needsResolution = entriesToResolve
.groupBy { it.parentMetaId to it.contentType }
if (needsResolution.isEmpty()) {
@ -262,41 +283,79 @@ object WatchProgressRepository {
return@launch
}
for ((key, entries) in needsResolution) {
val (metaId, metaType) = key
val meta = runCatching {
MetaDetailsRepository.fetch(metaType, metaId)
}.getOrNull()
var resolvedEntries = 0
val semaphore = Semaphore(WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY)
val resolutionResults = coroutineScope {
needsResolution.map { (key, entries) ->
async {
semaphore.withPermit {
fetchRemoteMetadataGroup(key = key, entries = entries)
}
}
}.awaitAll()
}
for (result in resolutionResults) {
ensureActive()
val meta = result.meta
if (meta == null) {
continue
}
for (entry in entries) {
val episodeVideo = if (entry.seasonNumber != null && entry.episodeNumber != null) {
var appliedEntries = 0
for (entry in result.entries) {
val current = entriesByVideoId[entry.videoId] ?: continue
val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) {
meta.videos.find { v ->
v.season == entry.seasonNumber && v.episode == entry.episodeNumber
v.season == current.seasonNumber && v.episode == current.episodeNumber
}
} else null
entriesByVideoId[entry.videoId] = entry.copy(
entriesByVideoId[current.videoId] = current.copy(
title = meta.name,
poster = meta.poster,
background = meta.background,
logo = meta.logo,
episodeTitle = episodeVideo?.title ?: entry.episodeTitle,
episodeThumbnail = episodeVideo?.thumbnail ?: entry.episodeThumbnail,
episodeTitle = episodeVideo?.title ?: current.episodeTitle,
episodeThumbnail = episodeVideo?.thumbnail ?: current.episodeThumbnail,
pauseDescription = episodeVideo?.overview
?: meta.description
?: entry.pauseDescription,
?: current.pauseDescription,
)
appliedEntries += 1
}
if (appliedEntries == 0) {
continue
}
publish()
resolvedEntries += appliedEntries
}
if (resolvedEntries > 0) {
publish()
persist()
}
persist()
}
}
private suspend fun fetchRemoteMetadataGroup(
key: Pair<String, String>,
entries: List<WatchProgressEntry>,
): RemoteMetadataResolutionResult {
val (metaId, metaType) = key
val meta = try {
MetaDetailsRepository.fetch(metaType, metaId)
} catch (error: CancellationException) {
throw error
} catch (_: Throwable) {
null
}
return RemoteMetadataResolutionResult(
key = key,
entries = entries,
meta = meta,
)
}
fun upsertPlaybackProgress(
session: WatchProgressPlaybackSession,
snapshot: PlayerPlaybackSnapshot,
@ -494,13 +553,14 @@ object WatchProgressRepository {
private fun publish() {
val entries = currentEntries()
val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs }
val hasLoadedRemoteProgress = if (shouldUseTraktProgress()) {
TraktProgressRepository.uiState.value.hasLoadedRemoteProgress
} else {
hasLoaded
}
_uiState.value = WatchProgressUiState(
entries = sortedEntries,
hasLoadedRemoteProgress = if (shouldUseTraktProgress()) {
TraktProgressRepository.uiState.value.hasLoadedRemoteProgress
} else {
hasLoaded
},
hasLoadedRemoteProgress = hasLoadedRemoteProgress,
)
}

View file

@ -0,0 +1,50 @@
package com.nuvio.app.features.addons
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class AddonModelsTest {
@Test
fun `disabled addon is installed but not active`() {
val addon = ManagedAddon(
manifestUrl = "https://example.test/manifest.json",
manifest = manifest(),
enabled = false,
)
assertFalse(addon.isActive)
assertEquals(0, listOf(addon).toOverview().activeAddons)
assertEquals(0, listOf(addon).toOverview().totalCatalogs)
}
@Test
fun `enabled addons helper filters disabled addons`() {
val enabled = ManagedAddon(
manifestUrl = "https://enabled.example/manifest.json",
manifest = manifest(id = "enabled"),
enabled = true,
)
val disabled = ManagedAddon(
manifestUrl = "https://disabled.example/manifest.json",
manifest = manifest(id = "disabled"),
enabled = false,
)
assertEquals(listOf(enabled), listOf(enabled, disabled).enabledAddons())
assertTrue(enabled.isActive)
}
}
private fun manifest(id: String = "addon") = AddonManifest(
id = id,
name = id,
description = "",
version = "1.0.0",
resources = listOf(AddonResource(name = "catalog", types = listOf("movie"))),
types = listOf("movie"),
catalogs = listOf(AddonCatalog(type = "movie", id = "popular", name = "Popular")),
transportUrl = "https://$id.example/manifest.json",
)

View file

@ -19,6 +19,7 @@ import platform.Foundation.NSUserDefaults
actual object AddonStorage {
private const val addonUrlsKey = "installed_manifest_urls"
private const val addonEnabledStatesKey = "installed_manifest_enabled_states"
actual fun loadInstalledAddonUrls(profileId: Int): List<String> =
NSUserDefaults.standardUserDefaults
@ -35,6 +36,34 @@ actual object AddonStorage {
forKey = "${addonUrlsKey}_$profileId",
)
}
actual fun loadAddonEnabledStates(profileId: Int): Map<String, Boolean> =
NSUserDefaults.standardUserDefaults
.stringForKey("${addonEnabledStatesKey}_$profileId")
.orEmpty()
.lineSequence()
.mapNotNull(::parseEnabledStateLine)
.toMap()
actual fun saveAddonEnabledStates(profileId: Int, states: Map<String, Boolean>) {
val payload = states.entries.joinToString(separator = "\n") { (url, enabled) ->
"$url\t$enabled"
}
NSUserDefaults.standardUserDefaults.setObject(
payload,
forKey = "${addonEnabledStatesKey}_$profileId",
)
}
}
private fun parseEnabledStateLine(line: String): Pair<String, Boolean>? {
val url = line.substringBefore("\t").trim().takeIf { it.isNotEmpty() } ?: return null
val rawEnabled = line.substringAfter("\t", "true").trim().lowercase()
val enabled = when (rawEnabled) {
"false" -> false
else -> true
}
return url to enabled
}
private val addonHttpClient = HttpClient(Darwin) {

View file

@ -128,7 +128,14 @@ fi
# ── Colour-coding function ──────────────────────────────────────────────────
colorize_line() {
local line="$1"
local level="${line:0:1}"
local level=""
if [[ "$line" =~ ^[[:space:]]*[0-9]{2}-[0-9]{2}[[:space:]]+[0-9:.]+[[:space:]]+([VDIWEF])/ ]]; then
level="${BASH_REMATCH[1]}"
elif [[ "$line" =~ ^([VDIWEF])/ ]]; then
level="${BASH_REMATCH[1]}"
else
level="${line:0:1}"
fi
local clr=""
case "$level" in
V) clr="$CLR_V" ;;
@ -160,7 +167,7 @@ stream_logcat_colored() {
local value="$2"
local noise_re="$NOISE_TAGS"
"${ADB[@]}" logcat -v brief "$mode" "$value" 2>/dev/null \
"${ADB[@]}" logcat -v time "$mode" "$value" 2>/dev/null \
| while IFS= read -r raw_line; do
if [[ "$raw_line" =~ $noise_re ]]; then
continue