diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt index 525b6c6f8..613f767cd 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.android.kt @@ -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) diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginScraperCodeFileStore.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginScraperCodeFileStore.android.kt new file mode 100644 index 000000000..3d20c33df --- /dev/null +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/plugins/PluginScraperCodeFileStore.android.kt @@ -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) + } +} diff --git a/composeApp/src/androidHostTest/kotlin/com/nuvio/app/features/plugins/PluginScraperCodeFileStoreTest.kt b/composeApp/src/androidHostTest/kotlin/com/nuvio/app/features/plugins/PluginScraperCodeFileStoreTest.kt new file mode 100644 index 000000000..7fee71267 --- /dev/null +++ b/composeApp/src/androidHostTest/kotlin/com/nuvio/app/features/plugins/PluginScraperCodeFileStoreTest.kt @@ -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() + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index 80052d89c..80a0d0b1e 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -50,5 +50,6 @@ internal actual object PlatformLocalAccountDataCleaner { .clear() .apply() } + context.filesDir.resolve("nuvio_plugin_scrapers").deleteRecursively() } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt index f89986bb4..a0f044c9b 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt @@ -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? = null diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingService.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingService.android.kt new file mode 100644 index 000000000..e1644c542 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingService.android.kt @@ -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( + 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 +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/concurrent/ConflatedTaskDispatcher.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/concurrent/ConflatedTaskDispatcher.kt new file mode 100644 index 000000000..207128009 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/concurrent/ConflatedTaskDispatcher.kt @@ -0,0 +1,60 @@ +package com.nuvio.app.core.concurrent + +import kotlinx.atomicfu.locks.SynchronizedObject +import kotlinx.atomicfu.locks.synchronized + +internal class ConflatedTaskDispatcher( + private val schedule: (() -> Unit) -> Unit, + private val consume: (T) -> Unit, +) { + private data class Pending(val value: T) + + private val lock = SynchronizedObject() + private var pending: Pending? = 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() + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt index 98769a634..e4bc2d173 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginModels.kt @@ -134,9 +134,75 @@ internal data class StoredPluginScraper( val logo: String? = null, val contentLanguage: List = emptyList(), val formats: List? = 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" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedItemsStore.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedItemsStore.kt new file mode 100644 index 000000000..06ce6bc41 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedItemsStore.kt @@ -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() + private val providerItems = mutableMapOf>() + private val dirtyNuvioKeys = mutableSetOf() + + fun read( + block: ( + nuvioItems: Map, + providerItems: Map>, + dirtyNuvioKeys: Set, + ) -> T, + ): T = synchronized(lock) { + block(nuvioItems, providerItems, dirtyNuvioKeys) + } + + fun update( + block: ( + nuvioItems: MutableMap, + providerItems: MutableMap>, + dirtyNuvioKeys: MutableSet, + ) -> T, + ): T = synchronized(lock) { + block(nuvioItems, providerItems, dirtyNuvioKeys) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index d65129a5b..8a389782f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -101,6 +101,23 @@ internal fun replaceWatchedItemsForSource( target.putAll(replacement) } +internal suspend fun 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?, + current: Set, +): 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 = mutableMapOf() - private var providerItemsByKey: MutableMap> = mutableMapOf() + private val itemsStore = WatchedItemsStore() private var nuvioFullyWatchedSeriesKeys: Set = emptySet() private var providerFullyWatchedSeriesKeys: MutableMap> = mutableMapOf() private var expandedSiblingKeys: Set = emptySet() @@ -143,7 +159,6 @@ object WatchedRepository { private var loadedProviders: MutableSet = mutableSetOf() private var nuvioHasLoadedRemote: Boolean = false private var providersLoadedFromRemote: MutableSet = mutableSetOf() - private var nuvioDirtyWatchedKeys: MutableSet = 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 = - source.providerId - ?.let { providerId -> providerItemsByKey.getOrPut(providerId, ::mutableMapOf) } - ?: nuvioItemsByKey + private fun itemsForSourceSnapshot(source: WatchProgressSource): List = + itemsStore.read { nuvioItems, providerItems, _ -> + val items = source.providerId + ?.let { providerId -> providerItems[providerId]?.values.orEmpty() } + ?: nuvioItems.values + items.toList() + } private fun fullyWatchedSeriesKeysForSource(source: WatchProgressSource): Set = 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, ) { 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( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt index 755e14bf1..9534f31ea 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt @@ -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, + 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): 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() 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>(response.body) if (pageItems.isEmpty()) break items.addAll(pageItems) @@ -144,15 +206,10 @@ object TraktWatchedSyncAdapter : TrackingWatchedProvider { val items = mutableListOf() 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>(response.body) if (pageItems.isEmpty()) break items.addAll(pageItems) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/concurrent/ConflatedTaskDispatcherTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/concurrent/ConflatedTaskDispatcherTest.kt new file mode 100644 index 000000000..6a5211ecb --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/concurrent/ConflatedTaskDispatcherTest.kt @@ -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() + val dispatcher = ConflatedTaskDispatcher( + 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() + lateinit var dispatcher: ConflatedTaskDispatcher + 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() + val dispatcher = ConflatedTaskDispatcher( + schedule = scheduled::addLast, + consume = { value -> + if (value == 1) error("failed") + consumed += value + }, + ) + + dispatcher.dispatch(1) + assertFailsWith { + scheduled.removeFirst().invoke() + } + dispatcher.dispatch(2) + scheduled.removeFirst().invoke() + + assertEquals(listOf(2), consumed) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/plugins/PluginPersistenceTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/plugins/PluginPersistenceTest.kt new file mode 100644 index 000000000..7c6980a81 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/plugins/PluginPersistenceTest.kt @@ -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, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt index 2db3df537..f7fe5c015 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/simkl/SimklAnimeWatchedResolutionTest.kt @@ -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 diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedItemsStoreTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedItemsStoreTest.kt new file mode 100644 index 000000000..71a98c739 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedItemsStoreTest.kt @@ -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) + } + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt index 38f65f330..3eebb57dd 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt @@ -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 { + watchedProviderRefreshOrNull( + refresh = { throw CancellationException("cancelled") }, + onFailure = {}, + ) + } + Unit + } + @Test fun watchedItemKey_isTypeAware() { assertEquals("movie:tt1:-1:-1", watchedItemKey(type = "movie", id = "tt1")) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapterTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapterTest.kt new file mode 100644 index 000000000..d63af85aa --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapterTest.kt @@ -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() + 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 { + client.get("https://api.trakt.tv/sync/watched/shows", emptyMap()) + } + + assertEquals(429, error.status) + assertEquals(2, attempts) + } + + private fun response( + status: Int, + headers: Map = emptyMap(), + ): RawHttpResponse = RawHttpResponse( + status = status, + statusText = "", + url = "https://api.trakt.tv/sync/watched/shows", + body = "[]", + headers = headers, + ) +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt index f935d9616..ce3a99384 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt @@ -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() + private val persistenceGeneration = atomic(0L) + private val persistenceRevision = atomic(0L) + private val persistenceLock = SynchronizedObject() + private val persistedRevisionByProfile = mutableMapOf() 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, - ): Pair> = withContext(Dispatchers.Default) { - val payload = httpGetText(manifestUrl) - val manifest = PluginManifestParser.parse(payload) - val baseUrl = manifestUrl.substringBefore("?").removeSuffix("/manifest.json") + ): Pair> { + 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, ) } diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt index 1a8ddaac6..a359eea65 100644 --- a/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt +++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/features/plugins/PluginPlatform.ios.kt @@ -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" diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index 7b6f649a4..bfc87b28c 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -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) + } } }