refactor: standardize URL handling with canonicalization for manifest paths
This commit is contained in:
parent
daa72ab245
commit
3a472435fe
3 changed files with 58 additions and 22 deletions
|
|
@ -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<String, kotlinx.coroutines.Deferred<List<LocalScraperResult>>>()
|
||||
|
|
@ -112,17 +122,18 @@ class PluginManager @Inject constructor(
|
|||
*/
|
||||
suspend fun addRepository(manifestUrl: String): Result<PluginRepository> = 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()
|
||||
|
|
|
|||
|
|
@ -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<List<String>> = 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<String>) {
|
||||
context.dataStore.edit { preferences ->
|
||||
preferences[orderedUrlsKey] = gson.toJson(urls)
|
||||
preferences[orderedUrlsKey] = gson.toJson(urls.map(::canonicalizeUrl))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<Addon> {
|
||||
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<String, String>()
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue