diff --git a/app/src/main/java/com/nuvio/tv/core/plugin/PluginManager.kt b/app/src/main/java/com/nuvio/tv/core/plugin/PluginManager.kt index 3e2ea8d5..b52034be 100644 --- a/app/src/main/java/com/nuvio/tv/core/plugin/PluginManager.kt +++ b/app/src/main/java/com/nuvio/tv/core/plugin/PluginManager.kt @@ -35,6 +35,7 @@ private const val TAG = "PluginManager" private const val MAX_CONCURRENT_SCRAPERS = 5 private const val MAX_RESULT_ITEMS = 150 private const val MAX_RESPONSE_SIZE = 5 * 1024 * 1024L +private const val MANIFEST_SUFFIX = "/manifest.json" @Singleton class PluginManager @Inject constructor( @@ -64,7 +65,16 @@ class PluginManager @Inject constructor( return sb.toString() } - private fun normalizeUrl(url: String): String = url.trimEnd('/').lowercase() + private fun canonicalizeManifestUrl(url: String): String { + val trimmed = url.trim().trimEnd('/') + return if (trimmed.endsWith(MANIFEST_SUFFIX, ignoreCase = true)) { + trimmed + } else { + "$trimmed$MANIFEST_SUFFIX" + } + } + + private fun normalizeUrl(url: String): String = canonicalizeManifestUrl(url).lowercase() // Single-flight map to prevent duplicate scraper executions private val inFlightScrapers = ConcurrentHashMap>>() @@ -112,17 +122,18 @@ class PluginManager @Inject constructor( */ suspend fun addRepository(manifestUrl: String): Result = withContext(Dispatchers.IO) { try { - Log.d(TAG, "Adding repository from: $manifestUrl") + val canonicalManifestUrl = canonicalizeManifestUrl(manifestUrl) + Log.d(TAG, "Adding repository from: $canonicalManifestUrl") // Fetch manifest - val manifest = fetchManifest(manifestUrl) + val manifest = fetchManifest(canonicalManifestUrl) ?: return@withContext Result.failure(Exception("Failed to fetch manifest")) // Create repository val repo = PluginRepository( id = UUID.randomUUID().toString(), name = manifest.name, - url = manifestUrl, + url = canonicalManifestUrl, enabled = true, lastUpdated = System.currentTimeMillis(), scraperCount = manifest.scrapers.size @@ -132,7 +143,7 @@ class PluginManager @Inject constructor( dataStore.addRepository(repo) // Download and save scrapers - downloadScrapers(repo.id, manifestUrl, manifest.scrapers) + downloadScrapers(repo.id, canonicalManifestUrl, manifest.scrapers) Log.d(TAG, "Repository added: ${repo.name} with ${manifest.scrapers.size} scrapers") triggerRemoteSync() @@ -170,7 +181,7 @@ class PluginManager @Inject constructor( removeMissingLocal: Boolean = true ) { val normalizedRemote = remoteUrls - .map { it.trim() } + .map { canonicalizeManifestUrl(it) } .filter { it.isNotEmpty() } .distinctBy { normalizeUrl(it) } val remoteUrlSet = normalizedRemote.map { normalizeUrl(it) }.toSet() diff --git a/app/src/main/java/com/nuvio/tv/data/local/AddonPreferences.kt b/app/src/main/java/com/nuvio/tv/data/local/AddonPreferences.kt index 2fc5ea2d..430c7712 100644 --- a/app/src/main/java/com/nuvio/tv/data/local/AddonPreferences.kt +++ b/app/src/main/java/com/nuvio/tv/data/local/AddonPreferences.kt @@ -25,6 +25,16 @@ class AddonPreferences @Inject constructor( private val gson = Gson() private val orderedUrlsKey = stringPreferencesKey("installed_addon_urls_ordered") private val legacyUrlsKey = stringSetPreferencesKey("installed_addon_urls") + private val manifestSuffix = "/manifest.json" + + private fun canonicalizeUrl(url: String): String { + val trimmed = url.trim().trimEnd('/') + return if (trimmed.endsWith(manifestSuffix, ignoreCase = true)) { + trimmed.dropLast(manifestSuffix.length).trimEnd('/') + } else { + trimmed + } + } val installedAddonUrls: Flow> = context.dataStore.data .map { preferences -> @@ -51,8 +61,8 @@ class AddonPreferences @Inject constructor( suspend fun addAddon(url: String) { context.dataStore.edit { preferences -> val current = getCurrentList(preferences) - val normalizedUrl = url.trimEnd('/') - if (current.any { it.trimEnd('/').equals(normalizedUrl, ignoreCase = true) }) return@edit + val normalizedUrl = canonicalizeUrl(url) + if (current.any { canonicalizeUrl(it).equals(normalizedUrl, ignoreCase = true) }) return@edit preferences[orderedUrlsKey] = gson.toJson(current + normalizedUrl) } } @@ -60,9 +70,11 @@ class AddonPreferences @Inject constructor( suspend fun removeAddon(url: String) { context.dataStore.edit { preferences -> val current = getCurrentList(preferences).toMutableList() - val normalizedUrl = url.trimEnd('/') + val normalizedUrl = canonicalizeUrl(url) - val indexToRemove = current.indexOfFirst { it.trimEnd('/') == normalizedUrl } + val indexToRemove = current.indexOfFirst { + canonicalizeUrl(it).equals(normalizedUrl, ignoreCase = true) + } if (indexToRemove != -1) { current.removeAt(indexToRemove) } @@ -72,7 +84,7 @@ class AddonPreferences @Inject constructor( suspend fun setAddonOrder(urls: List) { context.dataStore.edit { preferences -> - preferences[orderedUrlsKey] = gson.toJson(urls) + preferences[orderedUrlsKey] = gson.toJson(urls.map(::canonicalizeUrl)) } } diff --git a/app/src/main/java/com/nuvio/tv/data/repository/AddonRepositoryImpl.kt b/app/src/main/java/com/nuvio/tv/data/repository/AddonRepositoryImpl.kt index 64750da0..da24b109 100644 --- a/app/src/main/java/com/nuvio/tv/data/repository/AddonRepositoryImpl.kt +++ b/app/src/main/java/com/nuvio/tv/data/repository/AddonRepositoryImpl.kt @@ -42,13 +42,23 @@ class AddonRepositoryImpl @Inject constructor( private const val TAG = "AddonRepository" private const val MANIFEST_CACHE_PREFS = "addon_manifest_cache" private const val MANIFEST_CACHE_KEY = "manifests" + private const val MANIFEST_SUFFIX = "/manifest.json" } private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var syncJob: Job? = null var isSyncingFromRemote = false - private fun normalizeUrl(url: String): String = url.trimEnd('/').lowercase() + private fun canonicalizeUrl(url: String): String { + val trimmed = url.trim().trimEnd('/') + return if (trimmed.endsWith(MANIFEST_SUFFIX, ignoreCase = true)) { + trimmed.dropLast(MANIFEST_SUFFIX.length).trimEnd('/') + } else { + trimmed + } + } + + private fun normalizeUrl(url: String): String = canonicalizeUrl(url).lowercase() private fun triggerRemoteSync() { if (isSyncingFromRemote) return @@ -93,7 +103,7 @@ class AddonRepositoryImpl @Inject constructor( preferences.installedAddonUrls.flatMapLatest { urls -> flow { // Emit cached addons immediately (now includes disk-persisted cache) - val cached = urls.mapNotNull { manifestCache[it.trimEnd('/')] } + val cached = urls.mapNotNull { manifestCache[canonicalizeUrl(it)] } if (cached.isNotEmpty()) { emit(applyDisplayNames(cached)) } @@ -103,7 +113,7 @@ class AddonRepositoryImpl @Inject constructor( async { when (val result = fetchAddon(url)) { is NetworkResult.Success -> result.data - else -> manifestCache[url.trimEnd('/')] + else -> manifestCache[canonicalizeUrl(url)] } } }.awaitAll().filterNotNull() @@ -116,7 +126,7 @@ class AddonRepositoryImpl @Inject constructor( } override suspend fun fetchAddon(baseUrl: String): NetworkResult { - val cleanBaseUrl = baseUrl.trimEnd('/') + val cleanBaseUrl = canonicalizeUrl(baseUrl) val manifestUrl = "$cleanBaseUrl/manifest.json" return when (val result = safeApiCall { api.getManifest(manifestUrl) }) { @@ -126,19 +136,22 @@ class AddonRepositoryImpl @Inject constructor( persistManifestCacheToDisk() NetworkResult.Success(addon) } - is NetworkResult.Error -> result + is NetworkResult.Error -> { + Log.w(TAG, "Failed to fetch addon manifest for url=$cleanBaseUrl code=${result.code} message=${result.message}") + result + } NetworkResult.Loading -> NetworkResult.Loading } } override suspend fun addAddon(url: String) { - val cleanUrl = url.trimEnd('/') + val cleanUrl = canonicalizeUrl(url) preferences.addAddon(cleanUrl) triggerRemoteSync() } override suspend fun removeAddon(url: String) { - val cleanUrl = url.trimEnd('/') + val cleanUrl = canonicalizeUrl(url) manifestCache.remove(cleanUrl) preferences.removeAddon(cleanUrl) triggerRemoteSync() @@ -154,7 +167,7 @@ class AddonRepositoryImpl @Inject constructor( removeMissingLocal: Boolean = true ) { val normalizedRemote = remoteUrls - .map { it.trimEnd('/') } + .map { canonicalizeUrl(it) } .filter { it.isNotBlank() } .distinctBy { normalizeUrl(it) } val remoteSet = normalizedRemote.map { normalizeUrl(it) }.toSet() @@ -175,16 +188,16 @@ class AddonRepositoryImpl @Inject constructor( val currentUrls = preferences.installedAddonUrls.first() val currentByNormalizedUrl = linkedMapOf() currentUrls.forEach { url -> - currentByNormalizedUrl.putIfAbsent(normalizeUrl(url), url.trimEnd('/')) + currentByNormalizedUrl.putIfAbsent(normalizeUrl(url), canonicalizeUrl(url)) } val remoteOrdered = normalizedRemote .mapNotNull { currentByNormalizedUrl[normalizeUrl(it)] } val extras = currentUrls - .map { it.trimEnd('/') } + .map { canonicalizeUrl(it) } .filter { normalizeUrl(it) !in remoteSet } val reordered = if (removeMissingLocal) remoteOrdered else remoteOrdered + extras - if (reordered != currentUrls.map { it.trimEnd('/') }) { + if (reordered != currentUrls.map { canonicalizeUrl(it) }) { preferences.setAddonOrder(reordered) } }