Merge branch 'fix/sentrybatch' into cmp-rewrite

This commit is contained in:
tapframe 2026-08-02 17:27:27 +05:30
commit 31547fcd6a
20 changed files with 1295 additions and 316 deletions

View file

@ -6,11 +6,14 @@ import android.content.SharedPreferences
internal object PluginStorage {
private const val preferencesName = "nuvio_plugins"
private const val pluginsStateKey = "plugins_state"
private const val scraperCodeDirectoryName = "nuvio_plugin_scrapers"
private var preferences: SharedPreferences? = null
private var scraperCodeStore: PluginScraperCodeFileStore? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
scraperCodeStore = PluginScraperCodeFileStore(context.filesDir.resolve(scraperCodeDirectoryName))
}
fun loadState(profileId: Int): String? =
@ -23,6 +26,19 @@ internal object PluginStorage {
?.apply()
}
fun hasScraperCode(profileId: Int, scraperId: String): Boolean =
scraperCodeStore?.contains(profileId, scraperId) == true
fun loadScraperCode(profileId: Int, scraperId: String): String? =
scraperCodeStore?.load(profileId, scraperId)
fun saveScraperCode(
profileId: Int,
scraperId: String,
code: String,
overwrite: Boolean,
): Boolean = scraperCodeStore?.save(profileId, scraperId, code, overwrite) == true
fun loadScraperSettings(scraperId: String): String? =
preferences?.getString("settings_${scraperId}", null)

View file

@ -0,0 +1,64 @@
package com.nuvio.app.features.plugins
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
import java.io.File
internal class PluginScraperCodeFileStore(
private val root: File,
) {
private val lock = SynchronizedObject()
fun contains(profileId: Int, scraperId: String): Boolean =
scraperCodeFile(profileId, scraperId).isFile
fun load(profileId: Int, scraperId: String): String? = synchronized(lock) {
val file = scraperCodeFile(profileId, scraperId).takeIf(File::isFile)
?: return@synchronized null
runCatching { file.readText() }.getOrNull()
}
fun save(
profileId: Int,
scraperId: String,
code: String,
overwrite: Boolean,
): Boolean {
val target = scraperCodeFile(profileId, scraperId)
if (!overwrite && target.isFile) return true
return synchronized(lock) {
if (!overwrite && target.isFile) return@synchronized true
val directory = target.parentFile ?: return@synchronized false
if (!directory.exists() && !directory.mkdirs()) return@synchronized false
val temporary = runCatching {
File.createTempFile("scraper-", ".tmp", directory)
}.getOrNull() ?: return@synchronized false
val backup = directory.resolve("${target.name}.backup")
try {
temporary.bufferedWriter().use { writer -> writer.write(code) }
if (!overwrite && target.isFile) return@synchronized true
if (backup.exists() && !backup.delete()) return@synchronized false
val hadTarget = target.exists()
if (hadTarget && !target.renameTo(backup)) return@synchronized false
if (!temporary.renameTo(target)) {
if (hadTarget) backup.renameTo(target)
return@synchronized false
}
if (backup.exists()) backup.delete()
true
} catch (_: Throwable) {
false
} finally {
if (temporary.exists()) temporary.delete()
if (backup.exists() && !target.exists()) backup.renameTo(target)
}
}
}
private fun scraperCodeFile(profileId: Int, scraperId: String): File {
val fileName = "${pluginDigestHex("SHA256", scraperId)}.js"
return root.resolve(profileId.toString()).resolve(fileName)
}
}

View file

@ -0,0 +1,33 @@
package com.nuvio.app.features.plugins
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PluginScraperCodeFileStoreTest {
@Test
fun scraperCodeRoundTripsWithoutRewritingUnlessRequested() {
val root = Files.createTempDirectory("nuvio-plugin-code").toFile()
try {
val store = PluginScraperCodeFileStore(root)
val scraperId = "https://plugins.example/manifest.json:scraper"
val original = "x".repeat(4 * 1024 * 1024)
val refreshed = "updated scraper"
assertFalse(store.contains(1, scraperId))
assertTrue(store.save(1, scraperId, original, overwrite = false))
assertEquals(original, store.load(1, scraperId))
assertTrue(store.save(1, scraperId, refreshed, overwrite = false))
assertEquals(original, store.load(1, scraperId))
assertTrue(store.save(1, scraperId, refreshed, overwrite = true))
assertEquals(refreshed, store.load(1, scraperId))
assertFalse(store.contains(2, scraperId))
} finally {
root.deleteRecursively()
}
}
}

View file

@ -50,5 +50,6 @@ internal actual object PlatformLocalAccountDataCleaner {
.clear()
.apply()
}
context.filesDir.resolve("nuvio_plugin_scrapers").deleteRecursively()
}
}

View file

@ -4,7 +4,6 @@ import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@ -15,7 +14,6 @@ import android.media.session.MediaSession
import android.media.session.PlaybackState
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.SystemClock
import android.util.Log
@ -29,9 +27,6 @@ import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.abs
private const val NOW_PLAYING_TAG = "NuvioNowPlaying"
private const val NOW_PLAYING_CHANNEL_ID = "nuvio_playback"
private const val NOW_PLAYING_NOTIFICATION_ID = 0x4E55
private const val SEEK_INTERVAL_MS = 10_000L
private const val MAX_ARTWORK_DOWNLOAD_BYTES = 12 * 1024 * 1024
private const val MAX_ARTWORK_EDGE_PX = 1_024
@ -40,7 +35,6 @@ private const val ACTION_PLAY = "com.nuvio.app.nowplaying.PLAY"
private const val ACTION_PAUSE = "com.nuvio.app.nowplaying.PAUSE"
private const val ACTION_REWIND = "com.nuvio.app.nowplaying.REWIND"
private const val ACTION_FAST_FORWARD = "com.nuvio.app.nowplaying.FAST_FORWARD"
private const val ACTION_START_FOREGROUND = "com.nuvio.app.nowplaying.START_FOREGROUND"
private data class AndroidNowPlayingMetadata(
val title: String,
@ -350,69 +344,6 @@ class PlayerNowPlayingActionReceiver : BroadcastReceiver() {
}
}
class PlayerNowPlayingService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action != ACTION_START_FOREGROUND) {
stopSelf(startId)
return START_NOT_STICKY
}
val notification = PlayerNowPlayingServiceState.notification
if (notification == null) {
stopSelf(startId)
return START_NOT_STICKY
}
startForeground(NOW_PLAYING_NOTIFICATION_ID, notification)
return START_NOT_STICKY
}
override fun onDestroy() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
super.onDestroy()
}
companion object {
internal fun publish(context: Context, notification: Notification) {
if (!AppFeaturePolicy.mediaPlaybackForegroundServiceEnabled) return
PlayerNowPlayingServiceState.notification = notification
val intent = Intent(context, PlayerNowPlayingService::class.java)
.setAction(ACTION_START_FOREGROUND)
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
context.getSystemService(NotificationManager::class.java)
?.notify(NOW_PLAYING_NOTIFICATION_ID, notification)
}.onFailure { error ->
Log.w(NOW_PLAYING_TAG, "Unable to publish playback notification", error)
}
}
internal fun hide(context: Context) {
if (!AppFeaturePolicy.mediaPlaybackForegroundServiceEnabled) return
PlayerNowPlayingServiceState.notification = null
runCatching { context.stopService(Intent(context, PlayerNowPlayingService::class.java)) }
context.getSystemService(NotificationManager::class.java)
?.cancel(NOW_PLAYING_NOTIFICATION_ID)
}
}
}
private object PlayerNowPlayingServiceState {
@Volatile
var notification: Notification? = null
}
private object AndroidNowPlayingActionDispatcher {
@Volatile
private var controllerRef: WeakReference<AndroidPlayerNowPlayingController>? = null

View file

@ -0,0 +1,154 @@
package com.nuvio.app.features.player
import android.app.Notification
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.util.Log
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.core.concurrent.ConflatedTaskDispatcher
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
internal const val NOW_PLAYING_TAG = "NuvioNowPlaying"
internal const val NOW_PLAYING_CHANNEL_ID = "nuvio_playback"
internal const val NOW_PLAYING_NOTIFICATION_ID = 0x4E55
private const val ACTION_START_FOREGROUND = "com.nuvio.app.nowplaying.START_FOREGROUND"
class PlayerNowPlayingService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action != ACTION_START_FOREGROUND) {
stopSelf(startId)
return START_NOT_STICKY
}
val notification = PlayerNowPlayingServiceState.notification
if (notification == null) {
stopSelf(startId)
return START_NOT_STICKY
}
startForeground(NOW_PLAYING_NOTIFICATION_ID, notification)
return START_NOT_STICKY
}
override fun onDestroy() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
PlayerNowPlayingServiceController.onServiceDestroyed(applicationContext)
super.onDestroy()
}
companion object {
internal fun publish(context: Context, notification: Notification) {
if (!AppFeaturePolicy.mediaPlaybackForegroundServiceEnabled) return
PlayerNowPlayingServiceController.publish(context.applicationContext, notification)
}
internal fun hide(context: Context) {
if (!AppFeaturePolicy.mediaPlaybackForegroundServiceEnabled) return
PlayerNowPlayingServiceController.hide(context.applicationContext)
}
}
}
private sealed interface PlayerNowPlayingServiceCommand {
data class Publish(
val context: Context,
val notification: Notification,
) : PlayerNowPlayingServiceCommand
data class Hide(val context: Context) : PlayerNowPlayingServiceCommand
}
private object PlayerNowPlayingServiceController {
private val executor = Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "NuvioNowPlayingService").apply { isDaemon = true }
}
private val startRequested = AtomicBoolean(false)
private val commands = ConflatedTaskDispatcher<PlayerNowPlayingServiceCommand>(
schedule = { task -> executor.execute(task) },
consume = ::execute,
)
fun publish(context: Context, notification: Notification) {
PlayerNowPlayingServiceState.notification = notification
commands.dispatch(PlayerNowPlayingServiceCommand.Publish(context, notification))
}
fun hide(context: Context) {
PlayerNowPlayingServiceState.notification = null
commands.dispatch(PlayerNowPlayingServiceCommand.Hide(context))
}
fun onServiceDestroyed(context: Context) {
startRequested.set(false)
val notification = PlayerNowPlayingServiceState.notification ?: return
commands.dispatch(PlayerNowPlayingServiceCommand.Publish(context, notification))
}
private fun execute(command: PlayerNowPlayingServiceCommand) {
when (command) {
is PlayerNowPlayingServiceCommand.Publish -> publishNow(command)
is PlayerNowPlayingServiceCommand.Hide -> hideNow(command)
}
}
private fun publishNow(command: PlayerNowPlayingServiceCommand.Publish) {
if (PlayerNowPlayingServiceState.notification !== command.notification) return
if (startRequested.compareAndSet(false, true)) {
val intent = Intent(command.context, PlayerNowPlayingService::class.java)
.setAction(ACTION_START_FOREGROUND)
val started = runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
command.context.startForegroundService(intent)
} else {
command.context.startService(intent)
}
}.onFailure { error ->
Log.w(NOW_PLAYING_TAG, "Unable to start playback service", error)
}.isSuccess
if (!started) {
startRequested.set(false)
return
}
}
if (PlayerNowPlayingServiceState.notification !== command.notification) return
runCatching {
command.context.getSystemService(NotificationManager::class.java)
?.notify(NOW_PLAYING_NOTIFICATION_ID, command.notification)
}.onFailure { error ->
Log.w(NOW_PLAYING_TAG, "Unable to publish playback notification", error)
}
}
private fun hideNow(command: PlayerNowPlayingServiceCommand.Hide) {
if (PlayerNowPlayingServiceState.notification != null) return
startRequested.set(false)
runCatching {
command.context.stopService(Intent(command.context, PlayerNowPlayingService::class.java))
}.onFailure { error ->
Log.w(NOW_PLAYING_TAG, "Unable to stop playback service", error)
}
runCatching {
command.context.getSystemService(NotificationManager::class.java)
?.cancel(NOW_PLAYING_NOTIFICATION_ID)
}.onFailure { error ->
Log.w(NOW_PLAYING_TAG, "Unable to hide playback notification", error)
}
}
}
private object PlayerNowPlayingServiceState {
@Volatile
var notification: Notification? = null
}

View file

@ -0,0 +1,60 @@
package com.nuvio.app.core.concurrent
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
internal class ConflatedTaskDispatcher<T>(
private val schedule: (() -> Unit) -> Unit,
private val consume: (T) -> Unit,
) {
private data class Pending<T>(val value: T)
private val lock = SynchronizedObject()
private var pending: Pending<T>? = null
private var drainScheduled = false
fun dispatch(value: T) {
synchronized(lock) {
pending = Pending(value)
}
scheduleDrain()
}
private fun scheduleDrain() {
val shouldSchedule = synchronized(lock) {
if (drainScheduled) {
false
} else {
drainScheduled = true
true
}
}
if (!shouldSchedule) return
try {
schedule(::drain)
} catch (error: Throwable) {
synchronized(lock) {
drainScheduled = false
}
throw error
}
}
private fun drain() {
try {
while (true) {
val next = synchronized(lock) {
pending.also { pending = null }
} ?: return
consume(next.value)
}
} finally {
val hasPending = synchronized(lock) {
drainScheduled = false
pending != null
}
if (hasPending) scheduleDrain()
}
}
}

View file

@ -134,9 +134,75 @@ internal data class StoredPluginScraper(
val logo: String? = null,
val contentLanguage: List<String> = emptyList(),
val formats: List<String>? = null,
val code: String,
val code: String? = null,
)
internal data class RestoredPluginScraper(
val scraper: PluginScraper,
val requiresMigration: Boolean,
)
internal fun PluginsUiState.toStoredPluginsState(): StoredPluginsState =
StoredPluginsState(
pluginsEnabled = pluginsEnabled,
groupStreamsByRepository = groupStreamsByRepository,
repositories = repositories.map { repository ->
StoredPluginRepository(
manifestUrl = repository.manifestUrl,
name = repository.name,
description = repository.description,
version = repository.version,
scraperCount = repository.scraperCount,
lastUpdated = repository.lastUpdated,
)
},
scrapers = scrapers.map(PluginScraper::toStoredPluginScraper),
)
internal fun PluginScraper.toStoredPluginScraper(): StoredPluginScraper =
StoredPluginScraper(
id = id,
repositoryUrl = repositoryUrl,
name = name,
description = description,
version = version,
filename = filename,
supportedTypes = supportedTypes,
enabled = enabled,
manifestEnabled = manifestEnabled,
hasSettings = hasSettings,
logo = logo,
contentLanguage = contentLanguage,
formats = formats,
code = null,
)
internal fun StoredPluginScraper.restorePluginScraper(
loadCachedCode: (String) -> String?,
): RestoredPluginScraper? {
val cachedCode = loadCachedCode(id)
val resolvedCode = cachedCode ?: code ?: return null
return RestoredPluginScraper(
scraper = PluginScraper(
id = id,
repositoryUrl = repositoryUrl,
name = name,
description = description,
version = version,
filename = filename,
supportedTypes = supportedTypes,
enabled = enabled,
manifestEnabled = manifestEnabled,
hasSettings = hasSettings,
logo = logo,
contentLanguage = contentLanguage,
formats = formats,
code = resolvedCode,
),
requiresMigration = code != null,
)
}
internal fun normalizePluginType(value: String): String =
when (value.lowercase()) {
"series", "show", "other" -> "tv"

View file

@ -0,0 +1,32 @@
package com.nuvio.app.features.watched
import com.nuvio.app.features.tracking.TrackingProviderId
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
internal class WatchedItemsStore {
private val lock = SynchronizedObject()
private val nuvioItems = mutableMapOf<String, WatchedItem>()
private val providerItems = mutableMapOf<TrackingProviderId, MutableMap<String, WatchedItem>>()
private val dirtyNuvioKeys = mutableSetOf<String>()
fun <T> read(
block: (
nuvioItems: Map<String, WatchedItem>,
providerItems: Map<TrackingProviderId, Map<String, WatchedItem>>,
dirtyNuvioKeys: Set<String>,
) -> T,
): T = synchronized(lock) {
block(nuvioItems, providerItems, dirtyNuvioKeys)
}
fun <T> update(
block: (
nuvioItems: MutableMap<String, WatchedItem>,
providerItems: MutableMap<TrackingProviderId, MutableMap<String, WatchedItem>>,
dirtyNuvioKeys: MutableSet<String>,
) -> T,
): T = synchronized(lock) {
block(nuvioItems, providerItems, dirtyNuvioKeys)
}
}

View file

@ -101,6 +101,23 @@ internal fun replaceWatchedItemsForSource(
target.putAll(replacement)
}
internal suspend fun <T> watchedProviderRefreshOrNull(
refresh: suspend () -> T,
onFailure: (Throwable) -> Unit,
): T? = try {
refresh()
} catch (error: CancellationException) {
throw error
} catch (error: Throwable) {
onFailure(error)
null
}
internal fun extraWatchedKeysChanged(
previous: Set<String>?,
current: Set<String>,
): Boolean = previous.orEmpty() != current
object WatchedRepository {
private data class WatchedRefreshOperation(
val profileId: Int,
@ -133,8 +150,7 @@ object WatchedRepository {
private var profileGeneration: Long = 0L
private var activeSource: WatchProgressSource = WatchProgressSource.NUVIO_SYNC
private var sourceGeneration: Long = 0L
private var nuvioItemsByKey: MutableMap<String, WatchedItem> = mutableMapOf()
private var providerItemsByKey: MutableMap<TrackingProviderId, MutableMap<String, WatchedItem>> = mutableMapOf()
private val itemsStore = WatchedItemsStore()
private var nuvioFullyWatchedSeriesKeys: Set<String> = emptySet()
private var providerFullyWatchedSeriesKeys: MutableMap<TrackingProviderId, Set<String>> = mutableMapOf()
private var expandedSiblingKeys: Set<String> = emptySet()
@ -143,7 +159,6 @@ object WatchedRepository {
private var loadedProviders: MutableSet<TrackingProviderId> = mutableSetOf()
private var nuvioHasLoadedRemote: Boolean = false
private var providersLoadedFromRemote: MutableSet<TrackingProviderId> = mutableSetOf()
private var nuvioDirtyWatchedKeys: MutableSet<String> = mutableSetOf()
private var lastSuccessfulPushEpochMs: Long = 0L
private var deltaCursorEventId: Long = 0L
private var deltaInitialized: Boolean = false
@ -185,8 +200,11 @@ object WatchedRepository {
profileGeneration += 1L
activeSource = WatchProgressSource.NUVIO_SYNC
sourceGeneration += 1L
nuvioItemsByKey.clear()
providerItemsByKey.clear()
itemsStore.update { nuvioItems, providerItems, dirtyNuvioKeys ->
nuvioItems.clear()
providerItems.clear()
dirtyNuvioKeys.clear()
}
nuvioFullyWatchedSeriesKeys = emptySet()
providerFullyWatchedSeriesKeys.clear()
expandedSiblingKeys = emptySet()
@ -195,7 +213,6 @@ object WatchedRepository {
loadedProviders.clear()
nuvioHasLoadedRemote = false
providersLoadedFromRemote.clear()
nuvioDirtyWatchedKeys.clear()
lastSuccessfulPushEpochMs = 0L
deltaCursorEventId = 0L
deltaInitialized = false
@ -209,8 +226,11 @@ object WatchedRepository {
activeSource = WatchProgressSource.NUVIO_SYNC
sourceGeneration += 1L
hasLoaded = true
nuvioItemsByKey.clear()
providerItemsByKey.clear()
itemsStore.update { nuvioItems, providerItems, dirtyNuvioKeys ->
nuvioItems.clear()
providerItems.clear()
dirtyNuvioKeys.clear()
}
nuvioFullyWatchedSeriesKeys = emptySet()
providerFullyWatchedSeriesKeys.clear()
expandedSiblingKeys = emptySet()
@ -219,7 +239,6 @@ object WatchedRepository {
loadedProviders.clear()
nuvioHasLoadedRemote = false
providersLoadedFromRemote.clear()
nuvioDirtyWatchedKeys.clear()
val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim()
if (payload.isNotEmpty()) {
@ -229,19 +248,19 @@ object WatchedRepository {
lastSuccessfulPushEpochMs = storedPayload.lastSuccessfulPushEpochMs
deltaCursorEventId = storedPayload.deltaCursorEventId
deltaInitialized = storedPayload.deltaInitialized
nuvioItemsByKey = storedPayload.items
val restoredItems = storedPayload.items
.map(WatchedItem::normalizedMarkedAt)
.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }
.toMutableMap()
nuvioDirtyWatchedKeys = storedPayload.dirtyWatchedKeys
.filterTo(mutableSetOf()) { key -> key in nuvioItemsByKey }
itemsStore.update { nuvioItems, _, dirtyNuvioKeys ->
nuvioItems.putAll(restoredItems)
dirtyNuvioKeys += storedPayload.dirtyWatchedKeys.filter { key -> key in restoredItems }
}
nuvioFullyWatchedSeriesKeys = storedPayload.fullyWatchedSeriesKeys
expandedSiblingKeys = storedPayload.expandedSiblingKeys
} else {
lastSuccessfulPushEpochMs = 0L
deltaCursorEventId = 0L
deltaInitialized = false
nuvioDirtyWatchedKeys.clear()
nuvioFullyWatchedSeriesKeys = emptySet()
}
@ -265,7 +284,9 @@ object WatchedRepository {
}
val previousSource = activeSource
source.providerId?.let { providerId ->
providerItemsByKey.getOrPut(providerId, ::mutableMapOf).clear()
itemsStore.update { _, providerItems, _ ->
providerItems.getOrPut(providerId, ::mutableMapOf).clear()
}
providerFullyWatchedSeriesKeys[providerId] = emptySet()
providerExtraWatchedKeys.remove(providerId)
loadedProviders -= providerId
@ -437,6 +458,7 @@ object WatchedRepository {
pageSize = watchedItemsPageSize,
)
val fullyWatchedSeriesKeys = adapter.pullFullyWatchedSeriesKeys(profileId)
val extraWatchedKeys = adapter.pullExtraWatchedKeys(profileId)
val source = operation.sourceOperation.source
log.i {
"Watched adapter result source=$source provider=${source.providerId?.storageId ?: "nuvio"} " +
@ -457,35 +479,36 @@ object WatchedRepository {
}
return false
}
val localAtApply = itemsForSource(operation.sourceOperation.source).values.toList()
val mergedSnapshot = mergeWatchedSnapshot(
serverItems = serverItems,
localItems = localAtApply,
dirtyKeys = if (operation.sourceOperation.source == WatchProgressSource.NUVIO_SYNC) {
nuvioDirtyWatchedKeys
} else {
emptySet()
},
)
replaceWatchedItemsForSource(
source = operation.sourceOperation.source,
nuvioItems = nuvioItemsByKey,
providerItems = providerItemsByKey,
replacement = mergedSnapshot.items,
)
fullyWatchedSeriesKeys?.let { keys ->
setFullyWatchedSeriesKeysForSource(operation.sourceOperation.source, keys)
itemsStore.update { nuvioItems, providerItems, dirtyNuvioKeys ->
val items = source.providerId
?.let { providerId -> providerItems[providerId]?.values.orEmpty() }
?: nuvioItems.values
val merged = mergeWatchedSnapshot(
serverItems = serverItems,
localItems = items.toList(),
dirtyKeys = if (source.providerId == null) dirtyNuvioKeys else emptySet(),
)
replaceWatchedItemsForSource(
source = source,
nuvioItems = nuvioItems,
providerItems = providerItems,
replacement = merged.items,
)
if (source.providerId == null) {
dirtyNuvioKeys.clear()
dirtyNuvioKeys += merged.dirtyKeys
}
}
val extraWatchedKeys = adapter.pullExtraWatchedKeys(profileId)
operation.sourceOperation.source.providerId?.let { providerId ->
fullyWatchedSeriesKeys?.let { keys ->
setFullyWatchedSeriesKeysForSource(source, keys)
}
source.providerId?.let { providerId ->
if (extraWatchedKeys.isNotEmpty()) {
providerExtraWatchedKeys[providerId] = extraWatchedKeys
}
loadedProviders += providerId
providersLoadedFromRemote += providerId
} ?: run {
nuvioDirtyWatchedKeys = mergedSnapshot.dirtyKeys.toMutableSet()
nuvioHasLoaded = true
nuvioHasLoadedRemote = true
if (resetDeltaState) {
@ -547,11 +570,13 @@ object WatchedRepository {
if (!isActiveOperation(operation)) return false
if (events.isEmpty()) break
applyWatchedDeltaEvents(
targetItems = nuvioItemsByKey,
dirtyKeys = nuvioDirtyWatchedKeys,
events = events,
)
itemsStore.update { nuvioItems, _, dirtyNuvioKeys ->
applyWatchedDeltaEvents(
targetItems = nuvioItems,
dirtyKeys = dirtyNuvioKeys,
events = events,
)
}
cursor = maxOf(cursor, events.maxOf { it.eventId })
deltaCursorEventId = cursor
deltaInitialized = true
@ -673,10 +698,13 @@ object WatchedRepository {
return remoteType.isSeriesLikeWatchedType() && localType.isSeriesLikeWatchedType()
}
private fun itemsForSource(source: WatchProgressSource): MutableMap<String, WatchedItem> =
source.providerId
?.let { providerId -> providerItemsByKey.getOrPut(providerId, ::mutableMapOf) }
?: nuvioItemsByKey
private fun itemsForSourceSnapshot(source: WatchProgressSource): List<WatchedItem> =
itemsStore.read { nuvioItems, providerItems, _ ->
val items = source.providerId
?.let { providerId -> providerItems[providerId]?.values.orEmpty() }
?: nuvioItems.values
items.toList()
}
private fun fullyWatchedSeriesKeysForSource(source: WatchProgressSource): Set<String> =
source.providerId
@ -697,16 +725,23 @@ object WatchedRepository {
private fun hasLoadedSource(source: WatchProgressSource): Boolean =
source.providerId?.let(loadedProviders::contains) ?: nuvioHasLoaded
private fun itemCountForSource(source: WatchProgressSource): Int = source.providerId
?.let { providerId -> providerItemsByKey[providerId]?.size ?: 0 }
?: nuvioItemsByKey.size
private fun itemCountForSource(source: WatchProgressSource): Int =
itemsStore.read { nuvioItems, providerItems, _ ->
source.providerId
?.let { providerId -> providerItems[providerId]?.size ?: 0 }
?: nuvioItems.size
}
fun toggleWatched(item: WatchedItem) {
ensureLoaded()
val source = activeSource
val targetItems = itemsForSource(source)
val key = watchedItemKey(item.type, item.id, item.season, item.episode)
if (targetItems.containsKey(key)) {
val isMarked = itemsStore.read { nuvioItems, providerItems, _ ->
source.providerId
?.let { providerId -> providerItems[providerId]?.containsKey(key) == true }
?: nuvioItems.containsKey(key)
}
if (isMarked) {
unmarkWatched(item)
} else {
markWatched(item)
@ -737,16 +772,20 @@ object WatchedRepository {
ensureLoaded()
if (items.isEmpty()) return
val source = activeSource
val targetItems = itemsForSource(source)
val markedAt = WatchedClock.nowEpochMs()
val timestampedItems = items.map { watchedItem ->
watchedItem.copy(markedAtEpochMs = markedAt)
}
timestampedItems.forEach { watchedItem ->
val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode)
targetItems[key] = watchedItem
if (source.providerId == null) {
nuvioDirtyWatchedKeys += key
itemsStore.update { nuvioItems, providerItems, dirtyNuvioKeys ->
val targetItems = source.providerId
?.let { providerId -> providerItems.getOrPut(providerId, ::mutableMapOf) }
?: nuvioItems
timestampedItems.forEach { watchedItem ->
val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode)
targetItems[key] = watchedItem
if (source.providerId == null) {
dirtyNuvioKeys += key
}
}
}
publish()
@ -790,25 +829,27 @@ object WatchedRepository {
ensureLoaded()
if (items.isEmpty()) return
val source = activeSource
val targetItems = itemsForSource(source)
val removedItems = items.mapNotNull { watchedItem ->
val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode)
targetItems.remove(key)?.let { storeItem ->
// Preserve videoId from the original request (store items don't have it)
if (watchedItem.videoId != null && storeItem.videoId == null) {
storeItem.copy(videoId = watchedItem.videoId)
} else {
storeItem
}
}?.also {
if (source.providerId == null) {
nuvioDirtyWatchedKeys -= key
}
// Optimistically remove from extra keys so publish() doesn't re-add it
source.providerId?.let { providerId ->
providerExtraWatchedKeys[providerId]?.let { extraKeys ->
if (key in extraKeys) {
providerExtraWatchedKeys[providerId] = extraKeys - key
val removedItems = itemsStore.update { nuvioItems, providerItems, dirtyNuvioKeys ->
val targetItems = source.providerId
?.let { providerId -> providerItems.getOrPut(providerId, ::mutableMapOf) }
?: nuvioItems
items.mapNotNull { watchedItem ->
val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode)
targetItems.remove(key)?.let { storeItem ->
if (watchedItem.videoId != null && storeItem.videoId == null) {
storeItem.copy(videoId = watchedItem.videoId)
} else {
storeItem
}
}?.also {
if (source.providerId == null) {
dirtyNuvioKeys -= key
}
source.providerId?.let { providerId ->
providerExtraWatchedKeys[providerId]?.let { extraKeys ->
if (key in extraKeys) {
providerExtraWatchedKeys[providerId] = extraKeys - key
}
}
}
}
@ -835,7 +876,13 @@ object WatchedRepository {
episode: Int? = null,
): Boolean {
ensureLoaded()
return itemsForSource(activeSource).containsKey(watchedItemKey(type, id, season, episode))
val source = activeSource
val key = watchedItemKey(type, id, season, episode)
return itemsStore.read { nuvioItems, providerItems, _ ->
source.providerId
?.let { providerId -> providerItems[providerId]?.containsKey(key) == true }
?: nuvioItems.containsKey(key)
}
}
fun isFullyWatchedSeries(id: String, type: String): Boolean {
@ -862,7 +909,7 @@ object WatchedRepository {
"Watched series reconciliation source=$activeSource content=${meta.type}:${meta.id} " +
"episodes=${meta.videos.size} shouldMarkSeries=$shouldMarkSeriesWatched " +
"hasSeriesMarker=$hasSeriesWatchedMarker " +
"matchingItems=${itemsForSource(activeSource).values.count { it.id == meta.id }}"
"matchingItems=${itemsForSourceSnapshot(activeSource).count { it.id == meta.id }}"
}
if (shouldMarkSeriesWatched) {
if (!hasSeriesWatchedMarker) {
@ -995,10 +1042,15 @@ object WatchedRepository {
}
private fun publish() {
val (nuvioItems, providerItems) = itemsStore.read { storedNuvioItems, storedProviderItems, _ ->
storedNuvioItems.values.toList() to storedProviderItems.mapValues { (_, itemsByKey) ->
itemsByKey.values.toList()
}
}
val items = watchedItemsForSource(
source = activeSource,
nuvioItems = nuvioItemsByKey.values,
providerItems = providerItemsByKey.mapValues { (_, itemsByKey) -> itemsByKey.values },
nuvioItems = nuvioItems,
providerItems = providerItems,
)
.map(WatchedItem::normalizedMarkedAt)
.sortedByDescending { it.markedAtEpochMs }
@ -1052,19 +1104,29 @@ object WatchedRepository {
adapter.observeExtraWatchedKeys(currentProfileId)
.distinctUntilChanged()
.collectLatest { extraKeys ->
val keysChanged = providerExtraWatchedKeys[providerId] != extraKeys
val keysChanged = extraWatchedKeysChanged(
previous = providerExtraWatchedKeys[providerId],
current = extraKeys,
)
if (keysChanged) {
providerExtraWatchedKeys[providerId] = extraKeys
// Re-pull items from provider to reflect snapshot changes
// (e.g. after remote episode removal)
val freshItems = adapter.pull(
profileId = currentProfileId,
pageSize = watchedItemsPageSize,
)
val freshItems = watchedProviderRefreshOrNull(
refresh = {
adapter.pull(
profileId = currentProfileId,
pageSize = watchedItemsPageSize,
)
},
onFailure = { error ->
log.w(error) { "Failed to refresh watched items from ${providerId.storageId}" }
},
) ?: return@collectLatest
val itemsByKey = freshItems.associateBy { item ->
watchedItemKey(item.type, item.id, item.season, item.episode)
}.toMutableMap()
providerItemsByKey[providerId] = itemsByKey
providerExtraWatchedKeys[providerId] = extraKeys
itemsStore.update { _, providerItems, _ ->
providerItems[providerId] = itemsByKey
}
publish()
}
}
@ -1077,19 +1139,22 @@ object WatchedRepository {
}
private fun persistNuvio() {
val (items, dirtyKeys) = itemsStore.read { nuvioItems, _, dirtyNuvioKeys ->
nuvioItems.values
.map(WatchedItem::normalizedMarkedAt)
.sortedByDescending { it.markedAtEpochMs } to dirtyNuvioKeys.toSet()
}
WatchedStorage.savePayload(
currentProfileId,
json.encodeToString(
StoredWatchedPayload(
items = nuvioItemsByKey.values
.map(WatchedItem::normalizedMarkedAt)
.sortedByDescending { it.markedAtEpochMs },
items = items,
fullyWatchedSeriesKeys = nuvioFullyWatchedSeriesKeys,
expandedSiblingKeys = expandedSiblingKeys,
lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs,
deltaCursorEventId = deltaCursorEventId,
deltaInitialized = deltaInitialized,
dirtyWatchedKeys = nuvioDirtyWatchedKeys.toSet(),
dirtyWatchedKeys = dirtyKeys,
),
),
)
@ -1101,26 +1166,31 @@ object WatchedRepository {
items: Collection<WatchedItem>,
) {
if (profileId != currentProfileId || operationGeneration != profileGeneration) return
val acknowledgedDirtyKeys = acknowledgeSuccessfulWatchedPush(
currentItems = nuvioItemsByKey,
dirtyKeys = nuvioDirtyWatchedKeys,
pushedItems = items,
)
val latestPushed = items
.asSequence()
.map { item -> normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs) }
.maxOrNull()
?: return
val updatedLastSuccessfulPushEpochMs = maxOf(lastSuccessfulPushEpochMs, latestPushed)
if (
acknowledgedDirtyKeys == nuvioDirtyWatchedKeys &&
updatedLastSuccessfulPushEpochMs == lastSuccessfulPushEpochMs
) {
return
val changed = itemsStore.update { nuvioItems, _, dirtyNuvioKeys ->
val acknowledgedDirtyKeys = acknowledgeSuccessfulWatchedPush(
currentItems = nuvioItems,
dirtyKeys = dirtyNuvioKeys,
pushedItems = items,
)
val updatedLastSuccessfulPushEpochMs = maxOf(lastSuccessfulPushEpochMs, latestPushed)
if (
acknowledgedDirtyKeys == dirtyNuvioKeys &&
updatedLastSuccessfulPushEpochMs == lastSuccessfulPushEpochMs
) {
false
} else {
dirtyNuvioKeys.clear()
dirtyNuvioKeys += acknowledgedDirtyKeys
lastSuccessfulPushEpochMs = updatedLastSuccessfulPushEpochMs
true
}
}
nuvioDirtyWatchedKeys = acknowledgedDirtyKeys.toMutableSet()
lastSuccessfulPushEpochMs = updatedLastSuccessfulPushEpochMs
persistNuvio()
if (changed) persistNuvio()
}
private suspend fun pushToTargetsForSource(

View file

@ -14,6 +14,7 @@ import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
@ -23,6 +24,71 @@ private const val BASE_URL = "https://api.trakt.tv"
private const val WATCHED_PAGE_LIMIT = 250
private const val WATCHED_MAX_PAGES = 1_000
private const val WATCHED_SHOWS_EXTENDED = "progress"
internal const val TRAKT_WATCHED_MAX_RESPONSE_BODY_BYTES = 8 * 1024 * 1024
private const val TRAKT_WATCHED_MAX_ATTEMPTS = 2
private const val TRAKT_WATCHED_MAX_RETRY_DELAY_MS = 60_000L
internal fun interface TraktWatchedHttpEngine {
suspend fun get(
url: String,
headers: Map<String, String>,
maxResponseBodyBytes: Int,
): RawHttpResponse
}
internal class TraktWatchedPageClient(
private val engine: TraktWatchedHttpEngine,
private val sleep: suspend (Long) -> Unit = { delayMs -> delay(delayMs) },
) {
suspend fun get(url: String, headers: Map<String, String>): RawHttpResponse {
repeat(TRAKT_WATCHED_MAX_ATTEMPTS) { attempt ->
val response = engine.get(
url = url,
headers = headers,
maxResponseBodyBytes = TRAKT_WATCHED_MAX_RESPONSE_BODY_BYTES,
)
if (response.status in 200..299) return response
if (!isTransientTraktWatchedStatus(response.status) || attempt == TRAKT_WATCHED_MAX_ATTEMPTS - 1) {
throw TraktWatchedHttpException(response.status)
}
sleep(
traktWatchedRetryDelayMs(
attempt = attempt,
retryAfterSeconds = response.headers.entries
.firstOrNull { (name, _) -> name.equals("retry-after", ignoreCase = true) }
?.value,
),
)
}
error("Trakt watched request exhausted without a response")
}
}
internal class TraktWatchedHttpException(
val status: Int,
) : Exception("Trakt watched request failed: $status")
internal fun isTransientTraktWatchedStatus(status: Int): Boolean =
status == 429 || status in 500..599
internal fun traktWatchedRetryDelayMs(attempt: Int, retryAfterSeconds: String?): Long =
retryAfterSeconds
?.trim()
?.toLongOrNull()
?.coerceAtLeast(0L)
?.times(1_000L)
?.coerceAtMost(TRAKT_WATCHED_MAX_RETRY_DELAY_MS)
?: (1_000L shl attempt.coerceIn(0, 5)).coerceAtMost(TRAKT_WATCHED_MAX_RETRY_DELAY_MS)
private val platformTraktWatchedHttpEngine = TraktWatchedHttpEngine { url, headers, maxResponseBodyBytes ->
httpRequestRaw(
method = "GET",
url = url,
headers = headers,
body = "",
maxResponseBodyBytes = maxResponseBodyBytes,
)
}
object TraktWatchedSyncAdapter : TrackingWatchedProvider {
@ -33,6 +99,7 @@ object TraktWatchedSyncAdapter : TrackingWatchedProvider {
encodeDefaults = false
explicitNulls = false
}
private val pageClient = TraktWatchedPageClient(platformTraktWatchedHttpEngine)
// ── pull ────────────────────────────────────────────────────────────
override suspend fun pull(
@ -118,15 +185,10 @@ object TraktWatchedSyncAdapter : TrackingWatchedProvider {
val items = mutableListOf<TraktWatchedMovieDto>()
var page = 1
while (page <= WATCHED_MAX_PAGES) {
val response = httpRequestRaw(
method = "GET",
val response = pageClient.get(
url = "$BASE_URL/sync/watched/movies?page=$page&limit=$WATCHED_PAGE_LIMIT",
headers = headers,
body = "",
)
if (response.status !in 200..299) {
error("Trakt watched movies request failed: ${response.status}")
}
val pageItems = json.decodeFromString<List<TraktWatchedMovieDto>>(response.body)
if (pageItems.isEmpty()) break
items.addAll(pageItems)
@ -144,15 +206,10 @@ object TraktWatchedSyncAdapter : TrackingWatchedProvider {
val items = mutableListOf<TraktWatchedShowDto>()
var page = 1
while (page <= WATCHED_MAX_PAGES) {
val response = httpRequestRaw(
method = "GET",
val response = pageClient.get(
url = "$BASE_URL/sync/watched/shows?page=$page&limit=$WATCHED_PAGE_LIMIT&extended=$WATCHED_SHOWS_EXTENDED",
headers = headers,
body = "",
)
if (response.status !in 200..299) {
error("Trakt watched shows request failed: ${response.status}")
}
val pageItems = json.decodeFromString<List<TraktWatchedShowDto>>(response.body)
if (pageItems.isEmpty()) break
items.addAll(pageItems)

View file

@ -0,0 +1,71 @@
package com.nuvio.app.core.concurrent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class ConflatedTaskDispatcherTest {
@Test
fun dispatchConsumesOnlyLatestPendingValue() {
val scheduled = ArrayDeque<() -> Unit>()
val consumed = mutableListOf<Int>()
val dispatcher = ConflatedTaskDispatcher<Int>(
schedule = scheduled::addLast,
consume = consumed::add,
)
dispatcher.dispatch(1)
dispatcher.dispatch(2)
dispatcher.dispatch(3)
assertEquals(1, scheduled.size)
assertEquals(emptyList(), consumed)
scheduled.removeFirst().invoke()
assertEquals(listOf(3), consumed)
}
@Test
fun valuesSubmittedWhileConsumingAreConflated() {
val scheduled = ArrayDeque<() -> Unit>()
val consumed = mutableListOf<Int>()
lateinit var dispatcher: ConflatedTaskDispatcher<Int>
dispatcher = ConflatedTaskDispatcher(
schedule = scheduled::addLast,
consume = { value ->
consumed += value
if (value == 1) {
dispatcher.dispatch(2)
dispatcher.dispatch(3)
}
},
)
dispatcher.dispatch(1)
scheduled.removeFirst().invoke()
assertEquals(listOf(1, 3), consumed)
assertEquals(0, scheduled.size)
}
@Test
fun consumerFailureDoesNotStallFutureDispatches() {
val scheduled = ArrayDeque<() -> Unit>()
val consumed = mutableListOf<Int>()
val dispatcher = ConflatedTaskDispatcher<Int>(
schedule = scheduled::addLast,
consume = { value ->
if (value == 1) error("failed")
consumed += value
},
)
dispatcher.dispatch(1)
assertFailsWith<IllegalStateException> {
scheduled.removeFirst().invoke()
}
dispatcher.dispatch(2)
scheduled.removeFirst().invoke()
assertEquals(listOf(2), consumed)
}
}

View file

@ -0,0 +1,66 @@
package com.nuvio.app.features.plugins
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class PluginPersistenceTest {
private val json = Json { encodeDefaults = true }
@Test
fun metadataStateDoesNotSerializeScraperSourceCode() {
val sourceCode = "module.exports = " + "x".repeat(4 * 1024 * 1024)
val stored = PluginsUiState(
scrapers = listOf(pluginScraper(sourceCode)),
).toStoredPluginsState()
val encoded = json.encodeToString(stored)
assertNull(stored.scrapers.single().code)
assertTrue(encoded.length < 2_048)
assertFalse(sourceCode in encoded)
}
@Test
fun cachedScraperCodeRestoresOfflineWithoutMigration() {
val sourceCode = "offline scraper source"
val stored = pluginScraper(sourceCode).toStoredPluginScraper()
val restored = stored.restorePluginScraper { scraperId ->
if (scraperId == stored.id) sourceCode else null
}
assertEquals(sourceCode, restored?.scraper?.code)
assertFalse(restored?.requiresMigration ?: true)
}
@Test
fun legacyEmbeddedScraperCodeIsPreservedForMigration() {
val sourceCode = "legacy scraper source"
val stored = pluginScraper(sourceCode)
.toStoredPluginScraper()
.copy(code = sourceCode)
val restored = stored.restorePluginScraper { null }
assertEquals(sourceCode, restored?.scraper?.code)
assertTrue(restored?.requiresMigration == true)
}
private fun pluginScraper(code: String): PluginScraper = PluginScraper(
id = "https://plugins.example/manifest.json:scraper",
repositoryUrl = "https://plugins.example/manifest.json",
name = "Scraper",
description = "",
version = "1.0.0",
filename = "scraper.js",
supportedTypes = listOf("movie", "tv"),
enabled = true,
manifestEnabled = true,
code = code,
)
}

View file

@ -275,7 +275,7 @@ class SimklAnimeWatchedResolutionTest {
}
@Test
fun `resolveAnimeEpisodeForSimkl returns unchanged for IMDB videoId prefix`() {
fun `resolveAnimeEpisodeForSimkl strips anime ids for IMDB videoId prefix`() {
val reference = TrackingMediaReference(
kind = TrackingMediaKind.ANIME,
title = "Some Anime",
@ -290,12 +290,14 @@ class SimklAnimeWatchedResolutionTest {
val resolved = reference.resolveAnimeEpisodeForSimkl()
// "tt2560140" prefix is not an anime prefix → unchanged
assertEquals(reference, resolved)
assertEquals("tt2560140", resolved.ids.imdb)
assertNull(resolved.ids.mal)
assertEquals(reference.episode, resolved.episode)
assertEquals(reference.catalog, resolved.catalog)
}
@Test
fun `resolveAnimeEpisodeForSimkl returns unchanged without catalog videoId`() {
fun `resolveAnimeEpisodeForSimkl strips anime ids without catalog videoId`() {
val reference = TrackingMediaReference(
kind = TrackingMediaKind.ANIME,
title = "Some Anime",
@ -306,7 +308,9 @@ class SimklAnimeWatchedResolutionTest {
val resolved = reference.resolveAnimeEpisodeForSimkl()
assertEquals(reference, resolved)
assertNull(resolved.ids.mal)
assertEquals(reference.episode, resolved.episode)
assertNull(resolved.catalog)
}
// ──────────────────────────────────────────────────────────────────────────
@ -432,14 +436,14 @@ class SimklAnimeWatchedResolutionTest {
// ──────────────────────────────────────────────────────────────────────────
@Test
fun `library projection uses anime type for anime entries`() {
fun `library projection uses series type for episodic anime entries`() {
val entry = animeEntry(simklId = 39687, imdb = "tt2560140", mal = 16498)
val snapshot = SimklSyncSnapshot(entries = listOf(entry))
val projection = snapshot.toSimklLibraryProjection()
val item = projection.items.singleOrNull { it.id == "tt2560140" }
assertNotNull(item)
assertEquals("anime", item.type)
assertEquals("series", item.type)
}
@Test

View file

@ -0,0 +1,55 @@
package com.nuvio.app.features.watched
import com.nuvio.app.features.tracking.TrackingProviderId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
class WatchedItemsStoreTest {
@Test
fun `concurrent updates publish coherent item snapshots`() = runBlocking {
val store = WatchedItemsStore()
coroutineScope {
repeat(4) { writer ->
launch(Dispatchers.Default) {
repeat(500) { index ->
val key = "$writer:$index"
val item = WatchedItem(
id = key,
type = "movie",
name = key,
markedAtEpochMs = index.toLong(),
)
store.update { nuvioItems, providerItems, dirtyNuvioKeys ->
nuvioItems[key] = item
providerItems
.getOrPut(TrackingProviderId.TRAKT, ::mutableMapOf)[key] = item
dirtyNuvioKeys += key
}
}
}
}
repeat(4) {
launch(Dispatchers.Default) {
repeat(500) {
store.read { nuvioItems, providerItems, dirtyNuvioKeys ->
val nuvioKeys = nuvioItems.keys.toSet()
assertEquals(nuvioKeys, providerItems[TrackingProviderId.TRAKT].orEmpty().keys)
assertEquals(nuvioKeys, dirtyNuvioKeys)
}
}
}
}
}
store.read { nuvioItems, providerItems, dirtyNuvioKeys ->
assertEquals(2_000, nuvioItems.size)
assertEquals(nuvioItems.keys, providerItems[TrackingProviderId.TRAKT].orEmpty().keys)
assertEquals(nuvioItems.keys, dirtyNuvioKeys)
}
}
}

View file

@ -4,12 +4,61 @@ import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.tracking.TrackingProviderId
import com.nuvio.app.features.tracking.WatchProgressSource
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class WatchedRepositoryTest {
@Test
fun emptyProviderExtraKeys_doNotTriggerInitialRefresh() {
assertFalse(extraWatchedKeysChanged(previous = null, current = emptySet()))
}
@Test
fun populatedProviderExtraKeys_triggerRefreshFromEmptyState() {
assertTrue(extraWatchedKeysChanged(previous = null, current = setOf("series:tt1:-1:-1")))
}
@Test
fun changedProviderExtraKeys_triggerRefresh() {
assertTrue(
extraWatchedKeysChanged(
previous = setOf("series:tt1:-1:-1"),
current = setOf("series:tt2:-1:-1"),
),
)
}
@Test
fun providerRefreshFailure_isContainedWithoutReplacingState() = runBlocking {
val failure = IllegalStateException("rate limited")
var observedFailure: Throwable? = null
val result = watchedProviderRefreshOrNull(
refresh = { throw failure },
onFailure = { observedFailure = it },
)
assertNull(result)
assertEquals(failure, observedFailure)
}
@Test
fun providerRefreshCancellation_isNotContained() = runBlocking {
assertFailsWith<CancellationException> {
watchedProviderRefreshOrNull(
refresh = { throw CancellationException("cancelled") },
onFailure = {},
)
}
Unit
}
@Test
fun watchedItemKey_isTypeAware() {
assertEquals("movie:tt1:-1:-1", watchedItemKey(type = "movie", id = "tt1"))

View file

@ -0,0 +1,91 @@
package com.nuvio.app.features.watching.sync
import com.nuvio.app.features.addons.DefaultRawHttpResponseMaxBytes
import com.nuvio.app.features.addons.RawHttpResponse
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
class TraktWatchedSyncAdapterTest {
@Test
fun `watched history responses larger than generic limit remain complete`() = runBlocking {
val body = "x".repeat(DefaultRawHttpResponseMaxBytes + 1)
var requestedLimit = 0
val client = TraktWatchedPageClient(
TraktWatchedHttpEngine { _, _, maxResponseBodyBytes ->
requestedLimit = maxResponseBodyBytes
val truncated = body.length > maxResponseBodyBytes
RawHttpResponse(
status = 200,
statusText = "OK",
url = "https://api.trakt.tv/sync/watched/shows",
body = if (truncated) body.take(maxResponseBodyBytes) else body,
headers = emptyMap(),
)
},
)
val response = client.get(
url = "https://api.trakt.tv/sync/watched/shows",
headers = emptyMap(),
)
assertEquals(TRAKT_WATCHED_MAX_RESPONSE_BODY_BYTES, requestedLimit)
assertFalse(response.body.length < body.length)
assertEquals(body, response.body)
}
@Test
fun `rate limited watched request retries once after retry after`() = runBlocking {
var attempts = 0
val delays = mutableListOf<Long>()
val client = TraktWatchedPageClient(
engine = TraktWatchedHttpEngine { _, _, _ ->
attempts += 1
response(
status = if (attempts == 1) 429 else 200,
headers = if (attempts == 1) mapOf("Retry-After" to "2") else emptyMap(),
)
},
sleep = delays::add,
)
val result = client.get("https://api.trakt.tv/sync/watched/shows", emptyMap())
assertEquals(200, result.status)
assertEquals(2, attempts)
assertEquals(listOf(2_000L), delays)
}
@Test
fun `repeated rate limit becomes a bounded typed failure`() = runBlocking {
var attempts = 0
val client = TraktWatchedPageClient(
engine = TraktWatchedHttpEngine { _, _, _ ->
attempts += 1
response(status = 429)
},
sleep = {},
)
val error = assertFailsWith<TraktWatchedHttpException> {
client.get("https://api.trakt.tv/sync/watched/shows", emptyMap())
}
assertEquals(429, error.status)
assertEquals(2, attempts)
}
private fun response(
status: Int,
headers: Map<String, String> = emptyMap(),
): RawHttpResponse = RawHttpResponse(
status = status,
statusText = "",
url = "https://api.trakt.tv/sync/watched/shows",
body = "[]",
headers = headers,
)
}

View file

@ -9,6 +9,9 @@ import com.nuvio.app.features.plugins.runtime.PluginRuntime
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.query.Order
import io.github.jan.supabase.postgrest.rpc
import kotlinx.atomicfu.atomic
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@ -52,6 +55,18 @@ private data class PluginPushItem(
@SerialName("sort_order") val sortOrder: Int = 0,
)
private data class PluginPersistenceSnapshot(
val profileId: Int,
val generation: Long,
val revision: Long,
val state: PluginsUiState,
)
private data class LoadedPluginState(
val state: PluginsUiState,
val requiresMigration: Boolean,
)
actual object PluginRepository {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val log = Logger.withTag("PluginRepository")
@ -64,6 +79,10 @@ actual object PluginRepository {
private var pulledFromServer = false
private var currentProfileId = 1
private val activeRefreshJobs = mutableMapOf<String, Job>()
private val persistenceGeneration = atomic(0L)
private val persistenceRevision = atomic(0L)
private val persistenceLock = SynchronizedObject()
private val persistedRevisionByProfile = mutableMapOf<Int, Long>()
actual fun initialize() {
val effectiveProfileId = resolveEffectiveProfileId(ProfileRepository.activeProfileId)
@ -89,6 +108,7 @@ actual object PluginRepository {
actual fun clearLocalState() {
cancelActiveRefreshes()
persistenceGeneration.incrementAndGet()
currentProfileId = 1
initialized = false
pulledFromServer = false
@ -358,59 +378,71 @@ actual object PluginRepository {
private suspend fun fetchRepositoryData(
manifestUrl: String,
previousScrapers: Map<String, PluginScraper>,
): Pair<PluginRepositoryItem, List<PluginScraper>> = withContext(Dispatchers.Default) {
val payload = httpGetText(manifestUrl)
val manifest = PluginManifestParser.parse(payload)
val baseUrl = manifestUrl.substringBefore("?").removeSuffix("/manifest.json")
): Pair<PluginRepositoryItem, List<PluginScraper>> {
val storageProfileId = currentProfileId
return withContext(Dispatchers.Default) {
val payload = httpGetText(manifestUrl)
val manifest = PluginManifestParser.parse(payload)
val baseUrl = manifestUrl.substringBefore("?").removeSuffix("/manifest.json")
val scrapers = manifest.scrapers
.filter { scraper -> scraper.isSupportedOnCurrentPlatform() }
.mapNotNull { info ->
val codeUrl = if (info.filename.startsWith("http://") || info.filename.startsWith("https://")) {
info.filename
} else {
"$baseUrl/${info.filename.trimStart('/')}"
}
runCatching {
val code = httpGetText(codeUrl)
val scraperId = "${manifestUrl.lowercase()}:${info.id}"
val previous = previousScrapers[scraperId]
val enabled = when {
!info.enabled -> false
previous != null -> previous.enabled
else -> info.enabled
val scrapers = manifest.scrapers
.filter { scraper -> scraper.isSupportedOnCurrentPlatform() }
.mapNotNull { info ->
val codeUrl = if (info.filename.startsWith("http://") || info.filename.startsWith("https://")) {
info.filename
} else {
"$baseUrl/${info.filename.trimStart('/')}"
}
runCatching {
val code = httpGetText(codeUrl)
val scraperId = "${manifestUrl.lowercase()}:${info.id}"
val cached = PluginStorage.saveScraperCode(
profileId = storageProfileId,
scraperId = scraperId,
code = code,
overwrite = true,
)
if (!cached) {
log.w { "Failed to cache plugin scraper $scraperId" }
}
val previous = previousScrapers[scraperId]
val enabled = when {
!info.enabled -> false
previous != null -> previous.enabled
else -> info.enabled
}
PluginScraper(
id = scraperId,
repositoryUrl = manifestUrl,
name = info.name,
description = info.description.orEmpty(),
version = info.version,
filename = info.filename,
supportedTypes = info.supportedTypes,
enabled = enabled,
manifestEnabled = info.enabled,
hasSettings = info.hasSettings,
logo = info.logo,
contentLanguage = info.contentLanguage ?: emptyList(),
formats = info.formats ?: info.supportedFormats,
code = code,
)
}.getOrNull()
}
PluginScraper(
id = scraperId,
repositoryUrl = manifestUrl,
name = info.name,
description = info.description.orEmpty(),
version = info.version,
filename = info.filename,
supportedTypes = info.supportedTypes,
enabled = enabled,
manifestEnabled = info.enabled,
hasSettings = info.hasSettings,
logo = info.logo,
contentLanguage = info.contentLanguage ?: emptyList(),
formats = info.formats ?: info.supportedFormats,
code = code,
)
}.getOrNull()
}
val repo = PluginRepositoryItem(
manifestUrl = manifestUrl,
name = manifest.name,
description = manifest.description,
version = manifest.version,
scraperCount = scrapers.size,
lastUpdated = currentEpochMillis(),
isRefreshing = false,
errorMessage = null,
)
repo to scrapers
val repo = PluginRepositoryItem(
manifestUrl = manifestUrl,
name = manifest.name,
description = manifest.description,
version = manifest.version,
scraperCount = scrapers.size,
lastUpdated = currentEpochMillis(),
isRefreshing = false,
errorMessage = null,
)
repo to scrapers
}
}
private fun PluginManifestScraper.isSupportedOnCurrentPlatform(): Boolean {
@ -460,39 +492,49 @@ actual object PluginRepository {
}
private fun persist() {
val state = _uiState.value
val payload = StoredPluginsState(
pluginsEnabled = state.pluginsEnabled,
groupStreamsByRepository = state.groupStreamsByRepository,
repositories = state.repositories.map { repo ->
StoredPluginRepository(
manifestUrl = repo.manifestUrl,
name = repo.name,
description = repo.description,
version = repo.version,
scraperCount = repo.scraperCount,
lastUpdated = repo.lastUpdated,
)
},
scrapers = state.scrapers.map { scraper ->
StoredPluginScraper(
id = scraper.id,
repositoryUrl = scraper.repositoryUrl,
name = scraper.name,
description = scraper.description,
version = scraper.version,
filename = scraper.filename,
supportedTypes = scraper.supportedTypes,
enabled = scraper.enabled,
manifestEnabled = scraper.manifestEnabled,
hasSettings = scraper.hasSettings,
logo = scraper.logo,
contentLanguage = scraper.contentLanguage,
formats = scraper.formats,
code = scraper.code,
) },
val snapshot = PluginPersistenceSnapshot(
profileId = currentProfileId,
generation = persistenceGeneration.value,
revision = persistenceRevision.incrementAndGet(),
state = _uiState.value,
)
PluginStorage.saveState(currentProfileId, json.encodeToString(payload))
val requiresCodeWrite = snapshot.state.scrapers.any { scraper ->
!PluginStorage.hasScraperCode(snapshot.profileId, scraper.id)
}
if (requiresCodeWrite) {
scope.launch { persist(snapshot) }
} else {
persist(snapshot)
}
}
private fun persist(snapshot: PluginPersistenceSnapshot) {
if (snapshot.generation != persistenceGeneration.value) return
var cached = true
snapshot.state.scrapers.forEach { scraper ->
val scraperCached = PluginStorage.saveScraperCode(
profileId = snapshot.profileId,
scraperId = scraper.id,
code = scraper.code,
overwrite = false,
)
cached = scraperCached && cached
}
if (!cached || snapshot.generation != persistenceGeneration.value) {
if (!cached) {
log.w { "Failed to persist plugin scraper cache for profile ${snapshot.profileId}" }
}
return
}
synchronized(persistenceLock) {
if (snapshot.generation != persistenceGeneration.value) return@synchronized
val persistedRevision = persistedRevisionByProfile[snapshot.profileId] ?: Long.MIN_VALUE
if (snapshot.revision < persistedRevision) return@synchronized
val payload = snapshot.state.toStoredPluginsState()
PluginStorage.saveState(snapshot.profileId, json.encodeToString(payload))
persistedRevisionByProfile[snapshot.profileId] = snapshot.revision
}
}
private fun loadStoredState(profileId: Int): StoredPluginsState? {
@ -517,49 +559,45 @@ actual object PluginRepository {
}
currentProfileId = profileId
_uiState.value = loadStateAsUiState(profileId)
val loadedState = loadStateAsUiState(profileId)
_uiState.value = loadedState.state
initialized = true
if (loadedState.requiresMigration) persist()
}
private fun loadStateAsUiState(profileId: Int): PluginsUiState {
private fun loadStateAsUiState(profileId: Int): LoadedPluginState {
val stored = loadStoredState(profileId)
return PluginsUiState(
pluginsEnabled = stored?.pluginsEnabled ?: true,
groupStreamsByRepository = stored?.groupStreamsByRepository ?: false,
repositories = stored?.repositories
?.map {
PluginRepositoryItem(
manifestUrl = it.manifestUrl,
name = it.name,
description = it.description,
version = it.version,
scraperCount = it.scraperCount,
lastUpdated = it.lastUpdated,
isRefreshing = false,
errorMessage = null,
)
}
?: emptyList(),
scrapers = stored?.scrapers
?.map {
PluginScraper(
id = it.id,
repositoryUrl = it.repositoryUrl,
name = it.name,
description = it.description,
version = it.version,
filename = it.filename,
supportedTypes = it.supportedTypes,
enabled = it.enabled,
manifestEnabled = it.manifestEnabled,
hasSettings = it.hasSettings,
logo = it.logo,
contentLanguage = it.contentLanguage,
formats = it.formats,
code = it.code,
)
}
?: emptyList(),
var requiresMigration = false
val scrapers = stored?.scrapers
?.mapNotNull { storedScraper ->
storedScraper.restorePluginScraper { scraperId ->
PluginStorage.loadScraperCode(profileId, scraperId)
}?.also { restored ->
requiresMigration = requiresMigration || restored.requiresMigration
}?.scraper
}
?: emptyList()
return LoadedPluginState(
state = PluginsUiState(
pluginsEnabled = stored?.pluginsEnabled ?: true,
groupStreamsByRepository = stored?.groupStreamsByRepository ?: false,
repositories = stored?.repositories
?.map {
PluginRepositoryItem(
manifestUrl = it.manifestUrl,
name = it.name,
description = it.description,
version = it.version,
scraperCount = it.scraperCount,
lastUpdated = it.lastUpdated,
isRefreshing = false,
errorMessage = null,
)
}
?: emptyList(),
scrapers = scrapers,
),
requiresMigration = requiresMigration,
)
}

View file

@ -1,10 +1,30 @@
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
package com.nuvio.app.features.plugins
import kotlinx.atomicfu.locks.SynchronizedObject
import kotlinx.atomicfu.locks.synchronized
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned
import platform.Foundation.NSFileManager
import platform.Foundation.NSHomeDirectory
import platform.Foundation.NSUserDefaults
import platform.Foundation.timeIntervalSince1970
import platform.posix.SEEK_END
import platform.posix.fclose
import platform.posix.fopen
import platform.posix.fread
import platform.posix.fseek
import platform.posix.ftell
import platform.posix.fwrite
import platform.posix.rewind
internal object PluginStorage {
private const val pluginsStateKey = "plugins_state"
private const val scraperCodeDirectoryName = "nuvio_plugin_scrapers"
private val scraperCodeLock = SynchronizedObject()
fun loadState(profileId: Int): String? =
NSUserDefaults.standardUserDefaults.stringForKey("${pluginsStateKey}_$profileId")
@ -16,6 +36,53 @@ internal object PluginStorage {
)
}
fun hasScraperCode(profileId: Int, scraperId: String): Boolean =
NSFileManager.defaultManager.fileExistsAtPath(scraperCodePath(profileId, scraperId))
fun loadScraperCode(profileId: Int, scraperId: String): String? = synchronized(scraperCodeLock) {
readUtf8File(scraperCodePath(profileId, scraperId))
}
fun saveScraperCode(
profileId: Int,
scraperId: String,
code: String,
overwrite: Boolean,
): Boolean {
val manager = NSFileManager.defaultManager
val target = scraperCodePath(profileId, scraperId)
if (!overwrite && manager.fileExistsAtPath(target)) return true
return synchronized(scraperCodeLock) {
val directory = scraperCodeDirectory(profileId)
if (!manager.createDirectoryAtPath(directory, true, null, null)) return@synchronized false
if (!overwrite && manager.fileExistsAtPath(target)) return@synchronized true
val temporary = "$target.tmp"
val backup = "$target.backup"
if (!writeUtf8File(temporary, code)) return@synchronized false
try {
if (!overwrite && manager.fileExistsAtPath(target)) return@synchronized true
if (manager.fileExistsAtPath(backup) && !manager.removeItemAtPath(backup, null)) {
return@synchronized false
}
val hadTarget = manager.fileExistsAtPath(target)
if (hadTarget && !manager.moveItemAtPath(target, backup, null)) return@synchronized false
if (!manager.moveItemAtPath(temporary, target, null)) {
if (hadTarget) manager.moveItemAtPath(backup, target, null)
return@synchronized false
}
if (manager.fileExistsAtPath(backup)) manager.removeItemAtPath(backup, null)
true
} finally {
if (manager.fileExistsAtPath(temporary)) manager.removeItemAtPath(temporary, null)
if (manager.fileExistsAtPath(backup) && !manager.fileExistsAtPath(target)) {
manager.moveItemAtPath(backup, target, null)
}
}
}
}
fun loadScraperSettings(scraperId: String): String? =
NSUserDefaults.standardUserDefaults.stringForKey("settings_${scraperId}")
@ -25,6 +92,51 @@ internal object PluginStorage {
forKey = "settings_${scraperId}",
)
}
private fun scraperCodeDirectory(profileId: Int): String =
"${NSHomeDirectory()}/Library/Application Support/$scraperCodeDirectoryName/$profileId"
private fun scraperCodePath(profileId: Int, scraperId: String): String =
"${scraperCodeDirectory(profileId)}/${pluginDigestHex("SHA256", scraperId)}.js"
}
@OptIn(ExperimentalForeignApi::class)
private fun readUtf8File(path: String): String? {
val file = fopen(path, "rb") ?: return null
return try {
if (fseek(file, 0L, SEEK_END) != 0) return null
val size = ftell(file)
if (size < 0L || size > Int.MAX_VALUE.toLong()) return null
rewind(file)
val bytes = ByteArray(size.toInt())
if (bytes.isNotEmpty()) {
val read = bytes.usePinned { pinned ->
fread(pinned.addressOf(0), 1.convert(), bytes.size.convert(), file)
}
if (read.toLong() != bytes.size.toLong()) return null
}
bytes.decodeToString()
} finally {
fclose(file)
}
}
@OptIn(ExperimentalForeignApi::class)
private fun writeUtf8File(path: String, value: String): Boolean {
val bytes = value.encodeToByteArray()
val file = fopen(path, "wb") ?: return false
return try {
if (bytes.isEmpty()) {
true
} else {
val written = bytes.usePinned { pinned ->
fwrite(pinned.addressOf(0), 1.convert(), bytes.size.convert(), file)
}
written.toLong() == bytes.size.toLong()
}
} finally {
fclose(file)
}
}
internal fun currentPluginPlatform(): String = "ios"

View file

@ -1,6 +1,10 @@
@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class)
package com.nuvio.app.core.storage
import platform.Foundation.NSUserDefaults
import platform.Foundation.NSFileManager
import platform.Foundation.NSHomeDirectory
import com.nuvio.app.features.profiles.MAX_PROFILES
internal actual object PlatformLocalAccountDataCleaner {
@ -90,5 +94,10 @@ internal actual object PlatformLocalAccountDataCleaner {
defaults.removeObjectForKey(keyString)
}
}
val scraperCodePath = "${NSHomeDirectory()}/Library/Application Support/nuvio_plugin_scrapers"
if (NSFileManager.defaultManager.fileExistsAtPath(scraperCodePath)) {
NSFileManager.defaultManager.removeItemAtPath(scraperCodePath, null)
}
}
}