From 73c7134d780da70f1b4aeb19ed2718cd97b44b69 Mon Sep 17 00:00:00 2001 From: matt1432 Date: Sat, 29 Nov 2025 16:30:32 -0500 Subject: [PATCH 01/13] feat: init Comicvine cache --- .../komf/providers/MetadataProvidersConfig.kt | 1 + .../snd/komf/providers/ProvidersModule.kt | 10 ++- .../providers/comicvine/ComicVineCache.kt | 89 +++++++++++++++++++ .../providers/comicvine/ComicVineClient.kt | 78 +++++++++++++--- 4 files changed, 164 insertions(+), 14 deletions(-) create mode 100644 komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvidersConfig.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvidersConfig.kt index 56d7814f..9863f98c 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvidersConfig.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvidersConfig.kt @@ -25,6 +25,7 @@ data class MetadataProvidersConfig( val nameMatchingMode: NameMatchingMode = NameMatchingMode.CLOSEST_MATCH, val defaultProviders: ProvidersConfig = ProvidersConfig(), val libraryProviders: Map = emptyMap(), + val cacheDatabaseFile: String = "./cv_cache.db", ) @Serializable diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ProvidersModule.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ProvidersModule.kt index 9bcf148b..76f7fcb2 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/ProvidersModule.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ProvidersModule.kt @@ -100,6 +100,7 @@ class ProvidersModule( comicVineIssueName = config.comicVineIssueName, comicVineIdFormat = config.comicVineIdFormat, bangumiToken = config.bangumiToken, + cacheDatabaseFile = config.cacheDatabaseFile, ) val libraryProviders = config.libraryProviders .map { (libraryId, libraryConfig) -> @@ -112,6 +113,7 @@ class ProvidersModule( comicVineIssueName = config.comicVineIssueName, comicVineIdFormat = config.comicVineIdFormat, bangumiToken = config.bangumiToken, + cacheDatabaseFile = config.cacheDatabaseFile, ) } .toMap() @@ -287,6 +289,7 @@ class ProvidersModule( comicVineIssueName: String?, comicVineIdFormat: String?, bangumiToken: String?, + cacheDatabaseFile: String, ): MetadataProvidersContainer { return MetadataProvidersContainer( mangaupdates = createMangaUpdatesMetadataProvider( @@ -346,6 +349,7 @@ class ProvidersModule( comicVineIdFormat = comicVineIdFormat, rateLimiter = comicVineRateLimiter, defaultNameMatcher = defaultNameMatcher, + cacheDatabaseFile = cacheDatabaseFile, ), comicVinePriority = config.comicVine.priority, mangaBaka = createMangaBakaMetadataProvider( @@ -606,6 +610,7 @@ class ProvidersModule( comicVineIdFormat: String?, rateLimiter: ComicVineRateLimiter, defaultNameMatcher: NameSimilarityMatcher, + cacheDatabaseFile: String, ): ComicVineMetadataProvider? { if (config.enabled.not()) return null requireNotNull(apiKey) { "Api key is not configured for ComicVine provider" } @@ -619,7 +624,8 @@ class ProvidersModule( }, apiKey = apiKey, comicVineSearchLimit = comicVineSearchLimit, - rateLimiter = rateLimiter + rateLimiter = rateLimiter, + cacheDatabaseFile = cacheDatabaseFile ) val metadataMapper = ComicVineMetadataMapper( seriesMetadataConfig = config.seriesMetadata, @@ -774,4 +780,4 @@ class ProvidersModule( } -} \ No newline at end of file +} diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt new file mode 100644 index 00000000..7c62f7e8 --- /dev/null +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt @@ -0,0 +1,89 @@ +package snd.komf.providers.comicvine + +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.core.and +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.core.greater +import org.jetbrains.exposed.v1.datetime.* +import org.jetbrains.exposed.v1.jdbc.select +import org.jetbrains.exposed.v1.jdbc.upsert +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import java.nio.file.Path +import java.io.File +import java.time.temporal.ChronoUnit +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Instant +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toInstant +import kotlinx.datetime.toLocalDateTime +import kotlinx.datetime.plus +import kotlinx.datetime.DateTimeUnit + +object CacheTable : Table("cache") { + val queryCol = text("query") + override val primaryKey = PrimaryKey(queryCol) + + val timestampCol = timestamp("timestamp") + + val responseCol = text("response") +} + +class ComicVineCache( + private val databaseFile: String, + private val expiry: Int = 30, +) { + private val databasePath = Path.of(databaseFile) + private val database = Database.connect("jdbc:sqlite:$databasePath", driver = "org.sqlite.JDBC") + + init { + transaction(db = database) { + SchemaUtils.create(CacheTable) + } + } + + private fun getExpiryTimestamp(): Instant { + return Clock.System.now() + .toLocalDateTime(TimeZone.UTC) + .toInstant(TimeZone.UTC) + .plus(value = expiry * 24, DateTimeUnit.HOUR) + } + + private fun getNowTimestamp(): Instant { + return Clock.System.now() + .toLocalDateTime(TimeZone.UTC) + .toInstant(TimeZone.UTC) + } + + private fun maskApiKey(url: String): String { + return url.replace( + Regex("""api_key=[^&]+"""), + "api_key=*****" + ) + } + + fun addEntry(url: String, response: String) { + transaction(db = database) { + CacheTable.upsert { + it[queryCol] = maskApiKey(url) + it[responseCol] = response + it[timestampCol] = getExpiryTimestamp() + } + } + } + + suspend fun getEntry(url: String): String? { + return transaction(db = database) { + CacheTable + .select(CacheTable.responseCol).where { + (CacheTable.queryCol eq maskApiKey(url)) and + @OptIn(kotlin.time.ExperimentalTime::class) + (CacheTable.timestampCol greater getNowTimestamp()) + } + .firstOrNull() + ?.get(CacheTable.responseCol) + } + } +} diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt index 27ea8833..4130d0e4 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt @@ -3,6 +3,12 @@ package snd.komf.providers.comicvine import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* +import io.ktor.http.URLBuilder +import io.ktor.http.ParametersBuilder +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import kotlin.text.Regex +import kotlinx.serialization.json.Json import snd.komf.model.Image import snd.komf.providers.comicvine.ComicVineClient.ComicVineTypeId.ISSUE import snd.komf.providers.comicvine.ComicVineClient.ComicVineTypeId.VOLUME @@ -22,7 +28,26 @@ class ComicVineClient( private val apiKey: String, private val comicVineSearchLimit: Int? = 10, private val rateLimiter: ComicVineRateLimiter, + private val cacheDatabaseFile: String, ) { + val cache = ComicVineCache(cacheDatabaseFile) + + fun buildUrlString( + url: String, + ): String { + val params = sortedMapOf( + Pair("api_key", apiKey), + Pair("format", "json"), + ) + + val encodedParams = params.entries.joinToString("&") { (key, value) -> + val k = URLEncoder.encode(key, StandardCharsets.UTF_8) + val v = URLEncoder.encode(value, StandardCharsets.UTF_8) + "$k=$v" + } + + return "$url?$encodedParams" + } suspend fun searchVolume(name: String): ComicVineSearchResult> { rateLimiter.searchAcquire() @@ -37,27 +62,56 @@ class ComicVineClient( suspend fun getVolume(id: ComicVineVolumeId): ComicVineSearchResult { rateLimiter.volumeAcquire() - return ktor.get("$baseUrl/volume/${VOLUME.id}-${id.value}/") { - parameter("format", "json") - parameter("api_key", apiKey) - }.body() + + val url = buildUrlString("$baseUrl/volume/${VOLUME.id}-${id.value}/") + + val cachedResult = cache.getEntry(url) + + if (cachedResult != null) { + return Json.decodeFromString(cachedResult); + } + + val response: ComicVineSearchResult = ktor.get(url).body() + + cache.addEntry(url, Json.encodeToString(response)) + + return response } suspend fun getIssue(id: ComicVineIssueId): ComicVineSearchResult { rateLimiter.issueAcquire() - return ktor.get("$baseUrl/issue/${ISSUE.id}-${id.value}/") { - parameter("format", "json") - parameter("api_key", apiKey) - }.body() + + val url = buildUrlString("$baseUrl/issue/${ISSUE.id}-${id.value}/") + + val cachedResult = cache.getEntry(url) + + if (cachedResult != null) { + return Json.decodeFromString(cachedResult); + } + + val response: ComicVineSearchResult = ktor.get(url).body() + + cache.addEntry(url, Json.encodeToString(response)) + + return response } suspend fun getStoryArc(id: ComicVineStoryArcId): ComicVineSearchResult { rateLimiter.storyArcAcquire() - return ktor.get("$baseUrl/story_arc/${ComicVineTypeId.STORY_ARC.id}-${id.value}/") { - parameter("format", "json") - parameter("api_key", apiKey) - }.body() + val url = buildUrlString("$baseUrl/story_arc/${ComicVineTypeId.STORY_ARC.id}-${id.value}/") + + val cachedResult = cache.getEntry(url) + + if (cachedResult != null) { + return Json.decodeFromString(cachedResult); + } + + val response: ComicVineSearchResult = ktor.get(url).body() + + cache.addEntry(url, Json.encodeToString(response)) + + return response } suspend fun getCover(url: String): Image { From d5b9f97b12633ab666bbe109f254533acc63680a Mon Sep 17 00:00:00 2001 From: matt1432 Date: Sat, 29 Nov 2025 16:37:15 -0500 Subject: [PATCH 02/13] chore: remove unneeded OptIn --- .../kotlin/snd/komf/providers/comicvine/ComicVineCache.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt index 7c62f7e8..aa1b94a2 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt @@ -79,7 +79,6 @@ class ComicVineCache( CacheTable .select(CacheTable.responseCol).where { (CacheTable.queryCol eq maskApiKey(url)) and - @OptIn(kotlin.time.ExperimentalTime::class) (CacheTable.timestampCol greater getNowTimestamp()) } .firstOrNull() From 449660ae119c33aed1254b14bfdebb809efab3bf Mon Sep 17 00:00:00 2001 From: matt1432 Date: Sat, 29 Nov 2025 17:03:59 -0500 Subject: [PATCH 03/13] refactor: clean up ComicVineClient code --- .../providers/comicvine/ComicVineClient.kt | 65 ++++++------------- 1 file changed, 21 insertions(+), 44 deletions(-) diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt index 4130d0e4..81baa58d 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt @@ -30,9 +30,9 @@ class ComicVineClient( private val rateLimiter: ComicVineRateLimiter, private val cacheDatabaseFile: String, ) { - val cache = ComicVineCache(cacheDatabaseFile) + private val cache = ComicVineCache(cacheDatabaseFile) - fun buildUrlString( + private fun buildUrlString( url: String, ): String { val params = sortedMapOf( @@ -49,6 +49,22 @@ class ComicVineClient( return "$url?$encodedParams" } + private suspend fun getCachedApi(url: String): ComicVineSearchResult { + val fullUrl = buildUrlString(url) + + val cachedResult = cache.getEntry(fullUrl) + + if (cachedResult != null) { + return Json.decodeFromString(cachedResult); + } + + val response: ComicVineSearchResult = ktor.get(fullUrl).body() + + cache.addEntry(fullUrl, Json.encodeToString(response)) + + return response + } + suspend fun searchVolume(name: String): ComicVineSearchResult> { rateLimiter.searchAcquire() return ktor.get("$baseUrl/search/") { @@ -62,56 +78,17 @@ class ComicVineClient( suspend fun getVolume(id: ComicVineVolumeId): ComicVineSearchResult { rateLimiter.volumeAcquire() - - val url = buildUrlString("$baseUrl/volume/${VOLUME.id}-${id.value}/") - - val cachedResult = cache.getEntry(url) - - if (cachedResult != null) { - return Json.decodeFromString(cachedResult); - } - - val response: ComicVineSearchResult = ktor.get(url).body() - - cache.addEntry(url, Json.encodeToString(response)) - - return response + return getCachedApi("$baseUrl/volume/${VOLUME.id}-${id.value}/") } suspend fun getIssue(id: ComicVineIssueId): ComicVineSearchResult { rateLimiter.issueAcquire() - - val url = buildUrlString("$baseUrl/issue/${ISSUE.id}-${id.value}/") - - val cachedResult = cache.getEntry(url) - - if (cachedResult != null) { - return Json.decodeFromString(cachedResult); - } - - val response: ComicVineSearchResult = ktor.get(url).body() - - cache.addEntry(url, Json.encodeToString(response)) - - return response + return getCachedApi("$baseUrl/issue/${ISSUE.id}-${id.value}/") } suspend fun getStoryArc(id: ComicVineStoryArcId): ComicVineSearchResult { rateLimiter.storyArcAcquire() - - val url = buildUrlString("$baseUrl/story_arc/${ComicVineTypeId.STORY_ARC.id}-${id.value}/") - - val cachedResult = cache.getEntry(url) - - if (cachedResult != null) { - return Json.decodeFromString(cachedResult); - } - - val response: ComicVineSearchResult = ktor.get(url).body() - - cache.addEntry(url, Json.encodeToString(response)) - - return response + return getCachedApi("$baseUrl/story_arc/${ComicVineTypeId.STORY_ARC.id}-${id.value}/") } suspend fun getCover(url: String): Image { From d807d0a94673f2661aa4ad94135b959cd0a44ea7 Mon Sep 17 00:00:00 2001 From: matt1432 Date: Sat, 29 Nov 2025 18:07:40 -0500 Subject: [PATCH 04/13] feat: add cacheDatabaseExpiry setting --- .../kotlin/snd/komf/providers/MetadataProvidersConfig.kt | 1 + .../kotlin/snd/komf/providers/ProvidersModule.kt | 8 +++++++- .../kotlin/snd/komf/providers/comicvine/ComicVineCache.kt | 2 +- .../snd/komf/providers/comicvine/ComicVineClient.kt | 6 +++++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvidersConfig.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvidersConfig.kt index 9863f98c..211db01f 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvidersConfig.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvidersConfig.kt @@ -26,6 +26,7 @@ data class MetadataProvidersConfig( val defaultProviders: ProvidersConfig = ProvidersConfig(), val libraryProviders: Map = emptyMap(), val cacheDatabaseFile: String = "./cv_cache.db", + val cacheDatabaseExpiry: Int = 14, ) @Serializable diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/ProvidersModule.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/ProvidersModule.kt index 76f7fcb2..d13e94c5 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/ProvidersModule.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/ProvidersModule.kt @@ -101,6 +101,7 @@ class ProvidersModule( comicVineIdFormat = config.comicVineIdFormat, bangumiToken = config.bangumiToken, cacheDatabaseFile = config.cacheDatabaseFile, + cacheDatabaseExpiry = config.cacheDatabaseExpiry, ) val libraryProviders = config.libraryProviders .map { (libraryId, libraryConfig) -> @@ -114,6 +115,7 @@ class ProvidersModule( comicVineIdFormat = config.comicVineIdFormat, bangumiToken = config.bangumiToken, cacheDatabaseFile = config.cacheDatabaseFile, + cacheDatabaseExpiry = config.cacheDatabaseExpiry, ) } .toMap() @@ -290,6 +292,7 @@ class ProvidersModule( comicVineIdFormat: String?, bangumiToken: String?, cacheDatabaseFile: String, + cacheDatabaseExpiry: Int, ): MetadataProvidersContainer { return MetadataProvidersContainer( mangaupdates = createMangaUpdatesMetadataProvider( @@ -350,6 +353,7 @@ class ProvidersModule( rateLimiter = comicVineRateLimiter, defaultNameMatcher = defaultNameMatcher, cacheDatabaseFile = cacheDatabaseFile, + cacheDatabaseExpiry = cacheDatabaseExpiry, ), comicVinePriority = config.comicVine.priority, mangaBaka = createMangaBakaMetadataProvider( @@ -611,6 +615,7 @@ class ProvidersModule( rateLimiter: ComicVineRateLimiter, defaultNameMatcher: NameSimilarityMatcher, cacheDatabaseFile: String, + cacheDatabaseExpiry: Int, ): ComicVineMetadataProvider? { if (config.enabled.not()) return null requireNotNull(apiKey) { "Api key is not configured for ComicVine provider" } @@ -625,7 +630,8 @@ class ProvidersModule( apiKey = apiKey, comicVineSearchLimit = comicVineSearchLimit, rateLimiter = rateLimiter, - cacheDatabaseFile = cacheDatabaseFile + cacheDatabaseFile = cacheDatabaseFile, + cacheDatabaseExpiry = cacheDatabaseExpiry, ) val metadataMapper = ComicVineMetadataMapper( seriesMetadataConfig = config.seriesMetadata, diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt index aa1b94a2..6f428339 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt @@ -33,7 +33,7 @@ object CacheTable : Table("cache") { class ComicVineCache( private val databaseFile: String, - private val expiry: Int = 30, + private val expiry: Int, ) { private val databasePath = Path.of(databaseFile) private val database = Database.connect("jdbc:sqlite:$databasePath", driver = "org.sqlite.JDBC") diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt index 81baa58d..d3918e85 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt @@ -23,14 +23,18 @@ import snd.komf.providers.comicvine.model.ComicVineVolumeSearch private const val baseUrl = "https://comicvine.gamespot.com/api" +// TODO: handle when cacheDatabaseExpiry is 0 +// TODO: allow passing more parameters to buildUrlString + class ComicVineClient( private val ktor: HttpClient, private val apiKey: String, private val comicVineSearchLimit: Int? = 10, private val rateLimiter: ComicVineRateLimiter, private val cacheDatabaseFile: String, + private val cacheDatabaseExpiry: Int, ) { - private val cache = ComicVineCache(cacheDatabaseFile) + private val cache = ComicVineCache(cacheDatabaseFile, cacheDatabaseExpiry) private fun buildUrlString( url: String, From 504aa895d50ef9863954d5d825fe35486384bed4 Mon Sep 17 00:00:00 2001 From: matt1432 Date: Sat, 29 Nov 2025 18:30:05 -0500 Subject: [PATCH 05/13] fix(cvCache): inline and reify getCachedApi --- .../kotlin/snd/komf/providers/comicvine/ComicVineClient.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt index d3918e85..b17dc33e 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt @@ -53,7 +53,7 @@ class ComicVineClient( return "$url?$encodedParams" } - private suspend fun getCachedApi(url: String): ComicVineSearchResult { + private suspend inline fun getCachedApi(url: String): ComicVineSearchResult { val fullUrl = buildUrlString(url) val cachedResult = cache.getEntry(fullUrl) From e679fdb288ed6bb12e80e7093c9a3c69551707ea Mon Sep 17 00:00:00 2001 From: matt1432 Date: Sat, 29 Nov 2025 19:30:15 -0500 Subject: [PATCH 06/13] feat: allow passing more parameters to buildUrlString --- .../snd/komf/providers/comicvine/ComicVineClient.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt index b17dc33e..13078604 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt @@ -24,7 +24,6 @@ import snd.komf.providers.comicvine.model.ComicVineVolumeSearch private const val baseUrl = "https://comicvine.gamespot.com/api" // TODO: handle when cacheDatabaseExpiry is 0 -// TODO: allow passing more parameters to buildUrlString class ComicVineClient( private val ktor: HttpClient, @@ -38,13 +37,14 @@ class ComicVineClient( private fun buildUrlString( url: String, + params: Map = mapOf(), ): String { - val params = sortedMapOf( + val finalParams = sortedMapOf( Pair("api_key", apiKey), Pair("format", "json"), - ) + ) + params - val encodedParams = params.entries.joinToString("&") { (key, value) -> + val encodedParams = finalParams.entries.joinToString("&") { (key, value) -> val k = URLEncoder.encode(key, StandardCharsets.UTF_8) val v = URLEncoder.encode(value, StandardCharsets.UTF_8) "$k=$v" From 15c064dc8c7256231559e603b81201aa6a4be395 Mon Sep 17 00:00:00 2001 From: matt1432 Date: Sat, 29 Nov 2025 19:40:19 -0500 Subject: [PATCH 07/13] feat(cvCache): don't expire when expiry is set to 0 --- .../snd/komf/providers/comicvine/ComicVineCache.kt | 11 +++++++++++ .../snd/komf/providers/comicvine/ComicVineClient.kt | 2 -- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt index 6f428339..398f193c 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt @@ -75,6 +75,17 @@ class ComicVineCache( } suspend fun getEntry(url: String): String? { + if (expiry == 0) { + return transaction(db = database) { + CacheTable + .select(CacheTable.responseCol).where { + CacheTable.queryCol eq maskApiKey(url) + } + .firstOrNull() + ?.get(CacheTable.responseCol) + } + } + return transaction(db = database) { CacheTable .select(CacheTable.responseCol).where { diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt index 13078604..fe3a0d23 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt @@ -23,8 +23,6 @@ import snd.komf.providers.comicvine.model.ComicVineVolumeSearch private const val baseUrl = "https://comicvine.gamespot.com/api" -// TODO: handle when cacheDatabaseExpiry is 0 - class ComicVineClient( private val ktor: HttpClient, private val apiKey: String, From 82c5edeca119c7c8877fefc54493b43b5ca935ae Mon Sep 17 00:00:00 2001 From: matt1432 Date: Sat, 29 Nov 2025 20:29:25 -0500 Subject: [PATCH 08/13] docs: add explanation for new options in README --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index b78985dc..b6132d9c 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,8 @@ metadataProviders: comicVineApiKey: # required for comicVine provider https://comicvine.gamespot.com/api/ env:KOMF_METADATA_PROVIDERS_COMIC_VINE_API_KEY comicVineSearchLimit: # define ComicVine search result Limit, default is 10 comicVineIssueName: # string that contains "{number}" which will be replaced by the issue number ie. "Issue #{number}". Used when an issue has no name on ComicVine, default is null + cacheDatabaseFile: # cache database file location. default is "./cv_cache.db" + cacheDatabaseExpiry: # number of days after which an entry in the cache is considered expired. default is 14 comicVineIdFormat: # string that contains "{id}" which will serve to parse the ComicVine volume of a given book from its title or folder name ie. "[cv-{id}]" which will correctly identify '.../Uncanny X-Men Omnibus (2006) [cv-27512]' as being [4050-27512](https://comicvine.gamespot.com/uncanny-x-men-omnibus/4050-27512/) bangumiToken: # bangumi provider require a token to show nsfw items https://next.bgm.tv/demo/access-token env:KOMF_METADATA_PROVIDERS_BANGUMI_TOKEN defaultProviders: From 6980f80830e5e8625785ec368392b05b2e069a7f Mon Sep 17 00:00:00 2001 From: matt1432 Date: Sat, 14 Mar 2026 18:03:21 -0400 Subject: [PATCH 09/13] fix: update cv cache schema --- .../providers/comicvine/ComicVineCache.kt | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt index 398f193c..25d36fae 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt @@ -22,11 +22,11 @@ import kotlinx.datetime.toLocalDateTime import kotlinx.datetime.plus import kotlinx.datetime.DateTimeUnit -object CacheTable : Table("cache") { - val queryCol = text("query") - override val primaryKey = PrimaryKey(queryCol) +object QueriesTable : Table("queries") { + val urlCol = text("url") + override val primaryKey = PrimaryKey(urlCol) - val timestampCol = timestamp("timestamp") + val createdAtCol = timestamp("created_at") val responseCol = text("response") } @@ -40,7 +40,7 @@ class ComicVineCache( init { transaction(db = database) { - SchemaUtils.create(CacheTable) + SchemaUtils.create(QueriesTable) } } @@ -66,10 +66,10 @@ class ComicVineCache( fun addEntry(url: String, response: String) { transaction(db = database) { - CacheTable.upsert { - it[queryCol] = maskApiKey(url) + QueriesTable.upsert { + it[urlCol] = maskApiKey(url) it[responseCol] = response - it[timestampCol] = getExpiryTimestamp() + it[createdAtCol] = getExpiryTimestamp() } } } @@ -77,23 +77,23 @@ class ComicVineCache( suspend fun getEntry(url: String): String? { if (expiry == 0) { return transaction(db = database) { - CacheTable - .select(CacheTable.responseCol).where { - CacheTable.queryCol eq maskApiKey(url) + QueriesTable + .select(QueriesTable.responseCol).where { + QueriesTable.urlCol eq maskApiKey(url) } .firstOrNull() - ?.get(CacheTable.responseCol) + ?.get(QueriesTable.responseCol) } } return transaction(db = database) { - CacheTable - .select(CacheTable.responseCol).where { - (CacheTable.queryCol eq maskApiKey(url)) and - (CacheTable.timestampCol greater getNowTimestamp()) + QueriesTable + .select(QueriesTable.responseCol).where { + (QueriesTable.urlCol eq maskApiKey(url)) and + (QueriesTable.createdAtCol greater getNowTimestamp()) } .firstOrNull() - ?.get(CacheTable.responseCol) + ?.get(QueriesTable.responseCol) } } } From ee5e1dea73e0a9e159c731e344cc8f5bae8dc2c7 Mon Sep 17 00:00:00 2001 From: matt1432 Date: Sat, 14 Mar 2026 18:26:16 -0400 Subject: [PATCH 10/13] docs: mention setting expiry to 0 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b6132d9c..34816705 100644 --- a/README.md +++ b/README.md @@ -176,7 +176,7 @@ metadataProviders: comicVineSearchLimit: # define ComicVine search result Limit, default is 10 comicVineIssueName: # string that contains "{number}" which will be replaced by the issue number ie. "Issue #{number}". Used when an issue has no name on ComicVine, default is null cacheDatabaseFile: # cache database file location. default is "./cv_cache.db" - cacheDatabaseExpiry: # number of days after which an entry in the cache is considered expired. default is 14 + cacheDatabaseExpiry: # number of days after which an entry in the cache is considered expired. set it to 0 for unlimited. default is 14 comicVineIdFormat: # string that contains "{id}" which will serve to parse the ComicVine volume of a given book from its title or folder name ie. "[cv-{id}]" which will correctly identify '.../Uncanny X-Men Omnibus (2006) [cv-27512]' as being [4050-27512](https://comicvine.gamespot.com/uncanny-x-men-omnibus/4050-27512/) bangumiToken: # bangumi provider require a token to show nsfw items https://next.bgm.tv/demo/access-token env:KOMF_METADATA_PROVIDERS_BANGUMI_TOKEN defaultProviders: From 0505bc22ed4b7595cbde6c75ff447cf9676e90af Mon Sep 17 00:00:00 2001 From: matt1432 Date: Sat, 14 Mar 2026 18:59:37 -0400 Subject: [PATCH 11/13] fix: don't cache story arcs --- .../kotlin/snd/komf/providers/comicvine/ComicVineClient.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt index fe3a0d23..c2bbdd59 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt @@ -90,7 +90,10 @@ class ComicVineClient( suspend fun getStoryArc(id: ComicVineStoryArcId): ComicVineSearchResult { rateLimiter.storyArcAcquire() - return getCachedApi("$baseUrl/story_arc/${ComicVineTypeId.STORY_ARC.id}-${id.value}/") + return ktor.get("$baseUrl/story_arc/${ComicVineTypeId.STORY_ARC.id}-${id.value}/") { + parameter("format", "json") + parameter("api_key", apiKey) + }.body() } suspend fun getCover(url: String): Image { From f2bef243a886ac119b70cbd2bb47c33c45a4392e Mon Sep 17 00:00:00 2001 From: matt1432 Date: Sun, 15 Mar 2026 02:30:39 -0400 Subject: [PATCH 12/13] feat: add endpoint to clear cache of volume --- .../kotlin/snd/komf/app/api/MetadataRoutes.kt | 32 ++++++++++++++++++- .../deprecated/DeprecatedMetadataRoutes.kt | 29 +++++++++++++++++ .../snd/komf/providers/MetadataProvider.kt | 2 ++ .../anilist/AniListMetadataProvider.kt | 4 +++ .../bangumi/BangumiMetadataProvider.kt | 4 +++ .../bookwalker/BookWalkerMetadataProvider.kt | 4 +++ .../providers/comicvine/ComicVineCache.kt | 9 ++++++ .../providers/comicvine/ComicVineClient.kt | 24 ++++++++++++++ .../comicvine/ComicVineMetadataProvider.kt | 4 +++ .../komf/providers/mal/MalMetadataProvider.kt | 4 +++ .../mangabaka/MangaBakaMetadataProvider.kt | 4 +++ .../mangadex/MangaDexMetadataProvider.kt | 4 +++ .../MangaUpdatesMetadataProvider.kt | 4 +++ .../komf/providers/viz/VizMetadataProvider.kt | 4 +++ .../webtoons/WebtoonsMetadataProvider.kt | 4 +++ .../yenpress/YenPressMetadataProvider.kt | 4 +++ .../mediaserver/metadata/MetadataService.kt | 11 +++++++ 17 files changed, 150 insertions(+), 1 deletion(-) diff --git a/komf-app/src/main/kotlin/snd/komf/app/api/MetadataRoutes.kt b/komf-app/src/main/kotlin/snd/komf/app/api/MetadataRoutes.kt index 0d169b70..bf8d6c2e 100644 --- a/komf-app/src/main/kotlin/snd/komf/app/api/MetadataRoutes.kt +++ b/komf-app/src/main/kotlin/snd/komf/app/api/MetadataRoutes.kt @@ -49,6 +49,8 @@ class MetadataRoutes( resetSeriesRoute() resetLibraryRoute() + + clearSeriesCacheRoute() } } @@ -144,6 +146,34 @@ class MetadataRoutes( } } + private fun Route.clearSeriesCacheRoute() { + post("/cache/library/{libraryId}/series/{seriesId}/clear") { + val libraryId = call.parameters.getOrFail("libraryId") + val seriesId = MediaServerSeriesId(call.parameters.getOrFail("seriesId")) + val series = mediaServerClient + .first() + .getSeries(MediaServerSeriesId(seriesId.value)) + + series.metadata.links.forEach { + if (it.url.contains("comicvine.gamespot.com")) { + val providerSeriesId = it.url.trimEnd('/').substringAfterLast('-') + metadataServiceProvider + .first() + .metadataServiceFor(libraryId) + .clearSeriesCache( + libraryId, + CoreProviders.COMIC_VINE, + ProviderSeriesId(providerSeriesId), + ) + + call.respond(HttpStatusCode.Accepted, "") + } + } + + call.respond(HttpStatusCode.NoContent, "") + } + } + private fun Route.matchSeriesRoute() { post("/match/library/{libraryId}/series/{seriesId}") { @@ -190,4 +220,4 @@ class MetadataRoutes( } } -} \ No newline at end of file +} diff --git a/komf-app/src/main/kotlin/snd/komf/app/api/deprecated/DeprecatedMetadataRoutes.kt b/komf-app/src/main/kotlin/snd/komf/app/api/deprecated/DeprecatedMetadataRoutes.kt index 449da1b9..9fd6832e 100644 --- a/komf-app/src/main/kotlin/snd/komf/app/api/deprecated/DeprecatedMetadataRoutes.kt +++ b/komf-app/src/main/kotlin/snd/komf/app/api/deprecated/DeprecatedMetadataRoutes.kt @@ -41,6 +41,7 @@ class DeprecatedMetadataRoutes( matchLibraryRoute() resetSeriesRoute() resetLibraryRoute() + clearSeriesCacheRoute() } } } @@ -115,6 +116,34 @@ class DeprecatedMetadataRoutes( } } + private fun Route.clearSeriesCacheRoute() { + post("/cache/library/{libraryId}/series/{seriesId}/clear") { + val libraryId = call.parameters.getOrFail("libraryId") + val seriesId = MediaServerSeriesId(call.parameters.getOrFail("seriesId")) + val series = mediaServerClient + .first() + .getSeries(MediaServerSeriesId(seriesId.value)) + + series.metadata.links.forEach { + if (it.url.contains("comicvine.gamespot.com")) { + val providerSeriesId = it.url.trimEnd('/').substringAfterLast('-') + metadataServiceProvider + .first() + .metadataServiceFor(libraryId) + .clearSeriesCache( + libraryId, + CoreProviders.COMIC_VINE, + ProviderSeriesId(providerSeriesId), + ) + + call.respond(HttpStatusCode.Accepted, "") + } + } + + call.respond(HttpStatusCode.NoContent, "") + } + } + private fun Route.matchLibraryRoute() { post("/match/library/{libraryId}") { val libraryId = MediaServerLibraryId(call.parameters.getOrFail("libraryId")) diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvider.kt index fdd30d35..46d58600 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvider.kt @@ -15,6 +15,8 @@ interface MetadataProvider { suspend fun getSeriesCover(seriesId: ProviderSeriesId): Image? + suspend fun clearSeriesCache(providerSeriesId: ProviderSeriesId) + suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata suspend fun searchSeries(seriesName: String, limit: Int = 5): Collection diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/anilist/AniListMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/anilist/AniListMetadataProvider.kt index 924cedab..f4321ad7 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/anilist/AniListMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/anilist/AniListMetadataProvider.kt @@ -42,6 +42,10 @@ class AniListMetadataProvider( return client.getThumbnail(series) } + override suspend fun clearSeriesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { throw UnsupportedOperationException() } diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/bangumi/BangumiMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/bangumi/BangumiMetadataProvider.kt index 8ec07db1..de3d977b 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/bangumi/BangumiMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/bangumi/BangumiMetadataProvider.kt @@ -46,6 +46,10 @@ class BangumiMetadataProvider( return client.getThumbnail(series) } + override suspend fun clearSeriesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { val book = client.getSubject(bookId.id.toLong()) val thumbnail = if (fetchSeriesCovers) client.getThumbnail(book) else null diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/bookwalker/BookWalkerMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/bookwalker/BookWalkerMetadataProvider.kt index a7e68f00..e422f441 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/bookwalker/BookWalkerMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/bookwalker/BookWalkerMetadataProvider.kt @@ -54,6 +54,10 @@ class BookWalkerMetadataProvider( return series.image?.let { fetchCover(it) } } + override suspend fun clearSeriesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { val book = repository.getBook(BookWalkerBookId(bookId.id)) val cover = if (fetchBookCovers) book.image?.let { fetchCover(it) } else null diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt index 25d36fae..22a5a659 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineCache.kt @@ -7,6 +7,7 @@ import org.jetbrains.exposed.v1.core.and import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.core.greater import org.jetbrains.exposed.v1.datetime.* +import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.select import org.jetbrains.exposed.v1.jdbc.upsert import org.jetbrains.exposed.v1.jdbc.transactions.transaction @@ -74,6 +75,14 @@ class ComicVineCache( } } + fun removeEntry(url: String) { + transaction(db = database) { + QueriesTable.deleteWhere { + QueriesTable.urlCol eq maskApiKey(url) + } + } + } + suspend fun getEntry(url: String): String? { if (expiry == 0) { return transaction(db = database) { diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt index c2bbdd59..642d09c0 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt @@ -83,6 +83,30 @@ class ComicVineClient( return getCachedApi("$baseUrl/volume/${VOLUME.id}-${id.value}/") } + suspend fun clearVolumeCache(id: ComicVineVolumeId) { + val url = buildUrlString("$baseUrl/volume/${VOLUME.id}-${id.value}/") + + val cachedResult = cache.getEntry(url) + + if (cachedResult == null) { + return + } + + val response: ComicVineSearchResult = Json.decodeFromString(cachedResult); + + response.results.issues?.forEach { + val issueUrl = buildUrlString("$baseUrl/issue/${ISSUE.id}-${it.id}/") + + val cachedIssueResult = cache.getEntry(issueUrl) + + if (cachedIssueResult != null) { + cache.removeEntry(issueUrl) + } + } + + cache.removeEntry(url) + } + suspend fun getIssue(id: ComicVineIssueId): ComicVineSearchResult { rateLimiter.issueAcquire() return getCachedApi("$baseUrl/issue/${ISSUE.id}-${id.value}/") diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineMetadataProvider.kt index 0178ad94..15524ee1 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineMetadataProvider.kt @@ -63,6 +63,10 @@ class ComicVineMetadataProvider( return series.image?.let { getCover(it) } } + override suspend fun clearSeriesCache(providerSeriesId: ProviderSeriesId) { + client.clearVolumeCache(providerSeriesId.toComicVineVolumeId()) + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { val issue = handleResult(client.getIssue(bookId.toComicVineIssueId())) val storyArcs = issue.storyArcCredits?.let { credits -> diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/mal/MalMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/mal/MalMetadataProvider.kt index b60b8e56..64888ce0 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/mal/MalMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/mal/MalMetadataProvider.kt @@ -55,6 +55,10 @@ class MalMetadataProvider( return malClient.getThumbnail(series) } + override suspend fun clearSeriesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { throw UnsupportedOperationException() } diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/mangabaka/MangaBakaMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/mangabaka/MangaBakaMetadataProvider.kt index dede4746..f54b19da 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/mangabaka/MangaBakaMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/mangabaka/MangaBakaMetadataProvider.kt @@ -56,6 +56,10 @@ class MangaBakaMetadataProvider( return fetchCover(series) } + override suspend fun clearSeriesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata( seriesId: ProviderSeriesId, bookId: ProviderBookId diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/mangadex/MangaDexMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/mangadex/MangaDexMetadataProvider.kt index 7a68a925..a1be1fa4 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/mangadex/MangaDexMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/mangadex/MangaDexMetadataProvider.kt @@ -37,6 +37,10 @@ class MangaDexMetadataProvider( return series.getCoverArt()?.let { client.getCover(series.id, it.attributes.fileName) } } + override suspend fun clearSeriesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { val cover = if (fetchBookCovers) client.getCover(MangaDexMangaId(seriesId.value), bookId.id) else null return metadataMapper.toBookMetadata(bookId.id, cover) diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/mangaupdates/MangaUpdatesMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/mangaupdates/MangaUpdatesMetadataProvider.kt index bf04def3..dc4a76d1 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/mangaupdates/MangaUpdatesMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/mangaupdates/MangaUpdatesMetadataProvider.kt @@ -67,6 +67,10 @@ class MangaUpdatesMetadataProvider( return client.getThumbnail(series) } + override suspend fun clearSeriesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { throw UnsupportedOperationException() } diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/viz/VizMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/viz/VizMetadataProvider.kt index a8129ae3..7062687b 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/viz/VizMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/viz/VizMetadataProvider.kt @@ -45,6 +45,10 @@ class VizMetadataProvider( return getThumbnail(series.coverUrl) } + override suspend fun clearSeriesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { val bookMetadata = getBook(VizBookId(bookId.id)) val thumbnail = if (fetchBookCovers) getThumbnail(bookMetadata.coverUrl) else null diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/webtoons/WebtoonsMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/webtoons/WebtoonsMetadataProvider.kt index 27e80d0d..a27630cf 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/webtoons/WebtoonsMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/webtoons/WebtoonsMetadataProvider.kt @@ -50,6 +50,10 @@ class WebtoonsMetadataProvider( return client.getSeriesThumbnail(series) } + override suspend fun clearSeriesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata( seriesId: ProviderSeriesId, bookId: ProviderBookId ): ProviderBookMetadata { diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/yenpress/YenPressMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/yenpress/YenPressMetadataProvider.kt index 707a9ed8..d1400151 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/yenpress/YenPressMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/yenpress/YenPressMetadataProvider.kt @@ -55,6 +55,10 @@ class YenPressMetadataProvider( } } + override suspend fun clearSeriesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { val bookMetadata = client.getBook(YenPressBookId(bookId.id)) val thumbnail = if (fetchBookCovers) client.getBookThumbnail(bookMetadata) else null diff --git a/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataService.kt b/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataService.kt index 129f31ab..5bc57b80 100644 --- a/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataService.kt +++ b/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataService.kt @@ -134,6 +134,17 @@ class MetadataService( return jobId } + fun clearSeriesCache( + libraryId: String, + providerName: CoreProviders, + providerSeriesId: ProviderSeriesId, + ) { + coroutineScope.launch { + val provider = metadataProviders.provider(libraryId, providerName) ?: throw RuntimeException() + provider.clearSeriesCache(providerSeriesId) + } + } + fun matchLibraryMetadata(libraryId: MediaServerLibraryId) { coroutineScope.launch { var errorCount = 0 From f15d211cf46430e9aa5f88f29db82ba789bdf57d Mon Sep 17 00:00:00 2001 From: matt1432 Date: Sun, 31 May 2026 23:36:20 -0400 Subject: [PATCH 13/13] feat: add endpoint to clear cache of volume or volume's issues --- .../kotlin/snd/komf/app/api/MetadataRoutes.kt | 29 +++++++++++++++++++ .../deprecated/DeprecatedMetadataRoutes.kt | 29 +++++++++++++++++++ .../snd/komf/providers/MetadataProvider.kt | 2 ++ .../anilist/AniListMetadataProvider.kt | 4 +++ .../bangumi/BangumiMetadataProvider.kt | 4 +++ .../bookwalker/BookWalkerMetadataProvider.kt | 4 +++ .../providers/comicvine/ComicVineClient.kt | 18 ++++++++++-- .../comicvine/ComicVineMetadataProvider.kt | 4 +++ .../komf/providers/mal/MalMetadataProvider.kt | 4 +++ .../mangabaka/MangaBakaMetadataProvider.kt | 4 +++ .../mangadex/MangaDexMetadataProvider.kt | 4 +++ .../MangaUpdatesMetadataProvider.kt | 4 +++ .../komf/providers/viz/VizMetadataProvider.kt | 4 +++ .../webtoons/WebtoonsMetadataProvider.kt | 4 +++ .../yenpress/YenPressMetadataProvider.kt | 4 +++ .../mediaserver/metadata/MetadataService.kt | 11 +++++++ 16 files changed, 130 insertions(+), 3 deletions(-) diff --git a/komf-app/src/main/kotlin/snd/komf/app/api/MetadataRoutes.kt b/komf-app/src/main/kotlin/snd/komf/app/api/MetadataRoutes.kt index bf8d6c2e..b1fd2277 100644 --- a/komf-app/src/main/kotlin/snd/komf/app/api/MetadataRoutes.kt +++ b/komf-app/src/main/kotlin/snd/komf/app/api/MetadataRoutes.kt @@ -51,6 +51,7 @@ class MetadataRoutes( resetLibraryRoute() clearSeriesCacheRoute() + clearSeriesIssuesCacheRoute() } } @@ -174,6 +175,34 @@ class MetadataRoutes( } } + private fun Route.clearSeriesIssuesCacheRoute() { + post("/cache/library/{libraryId}/series/{seriesId}/clearissues") { + val libraryId = call.parameters.getOrFail("libraryId") + val seriesId = MediaServerSeriesId(call.parameters.getOrFail("seriesId")) + val series = mediaServerClient + .first() + .getSeries(MediaServerSeriesId(seriesId.value)) + + series.metadata.links.forEach { + if (it.url.contains("comicvine.gamespot.com")) { + val providerSeriesId = it.url.trimEnd('/').substringAfterLast('-') + metadataServiceProvider + .first() + .metadataServiceFor(libraryId) + .clearSeriesIssuesCache( + libraryId, + CoreProviders.COMIC_VINE, + ProviderSeriesId(providerSeriesId), + ) + + call.respond(HttpStatusCode.Accepted, "") + } + } + + call.respond(HttpStatusCode.NoContent, "") + } + } + private fun Route.matchSeriesRoute() { post("/match/library/{libraryId}/series/{seriesId}") { diff --git a/komf-app/src/main/kotlin/snd/komf/app/api/deprecated/DeprecatedMetadataRoutes.kt b/komf-app/src/main/kotlin/snd/komf/app/api/deprecated/DeprecatedMetadataRoutes.kt index 9fd6832e..1f414f5f 100644 --- a/komf-app/src/main/kotlin/snd/komf/app/api/deprecated/DeprecatedMetadataRoutes.kt +++ b/komf-app/src/main/kotlin/snd/komf/app/api/deprecated/DeprecatedMetadataRoutes.kt @@ -42,6 +42,7 @@ class DeprecatedMetadataRoutes( resetSeriesRoute() resetLibraryRoute() clearSeriesCacheRoute() + clearSeriesIssuesCacheRoute() } } } @@ -144,6 +145,34 @@ class DeprecatedMetadataRoutes( } } + private fun Route.clearSeriesIssuesCacheRoute() { + post("/cache/library/{libraryId}/series/{seriesId}/clearissues") { + val libraryId = call.parameters.getOrFail("libraryId") + val seriesId = MediaServerSeriesId(call.parameters.getOrFail("seriesId")) + val series = mediaServerClient + .first() + .getSeries(MediaServerSeriesId(seriesId.value)) + + series.metadata.links.forEach { + if (it.url.contains("comicvine.gamespot.com")) { + val providerSeriesId = it.url.trimEnd('/').substringAfterLast('-') + metadataServiceProvider + .first() + .metadataServiceFor(libraryId) + .clearSeriesIssuesCache( + libraryId, + CoreProviders.COMIC_VINE, + ProviderSeriesId(providerSeriesId), + ) + + call.respond(HttpStatusCode.Accepted, "") + } + } + + call.respond(HttpStatusCode.NoContent, "") + } + } + private fun Route.matchLibraryRoute() { post("/match/library/{libraryId}") { val libraryId = MediaServerLibraryId(call.parameters.getOrFail("libraryId")) diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvider.kt index 46d58600..32d2bdd3 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataProvider.kt @@ -17,6 +17,8 @@ interface MetadataProvider { suspend fun clearSeriesCache(providerSeriesId: ProviderSeriesId) + suspend fun clearSeriesIssuesCache(providerSeriesId: ProviderSeriesId) + suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata suspend fun searchSeries(seriesName: String, limit: Int = 5): Collection diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/anilist/AniListMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/anilist/AniListMetadataProvider.kt index f4321ad7..ea358c4e 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/anilist/AniListMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/anilist/AniListMetadataProvider.kt @@ -46,6 +46,10 @@ class AniListMetadataProvider( throw UnsupportedOperationException() } + override suspend fun clearSeriesIssuesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { throw UnsupportedOperationException() } diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/bangumi/BangumiMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/bangumi/BangumiMetadataProvider.kt index de3d977b..4d397321 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/bangumi/BangumiMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/bangumi/BangumiMetadataProvider.kt @@ -50,6 +50,10 @@ class BangumiMetadataProvider( throw UnsupportedOperationException() } + override suspend fun clearSeriesIssuesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { val book = client.getSubject(bookId.id.toLong()) val thumbnail = if (fetchSeriesCovers) client.getThumbnail(book) else null diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/bookwalker/BookWalkerMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/bookwalker/BookWalkerMetadataProvider.kt index e422f441..2eb2415a 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/bookwalker/BookWalkerMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/bookwalker/BookWalkerMetadataProvider.kt @@ -58,6 +58,10 @@ class BookWalkerMetadataProvider( throw UnsupportedOperationException() } + override suspend fun clearSeriesIssuesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { val book = repository.getBook(BookWalkerBookId(bookId.id)) val cover = if (fetchBookCovers) book.image?.let { fetchCover(it) } else null diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt index 642d09c0..c0628981 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineClient.kt @@ -92,7 +92,21 @@ class ComicVineClient( return } - val response: ComicVineSearchResult = Json.decodeFromString(cachedResult); + cache.removeEntry(url) + } + + suspend fun clearVolumeIssuesCache(id: ComicVineVolumeId) { + val url = buildUrlString("$baseUrl/volume/${VOLUME.id}-${id.value}/") + + val cachedResult = cache.getEntry(url) + val response: ComicVineSearchResult + + if (cachedResult == null) { + response = getCachedApi(url) + } + else { + response = Json.decodeFromString(cachedResult); + } response.results.issues?.forEach { val issueUrl = buildUrlString("$baseUrl/issue/${ISSUE.id}-${it.id}/") @@ -103,8 +117,6 @@ class ComicVineClient( cache.removeEntry(issueUrl) } } - - cache.removeEntry(url) } suspend fun getIssue(id: ComicVineIssueId): ComicVineSearchResult { diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineMetadataProvider.kt index 15524ee1..bab29a8b 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/comicvine/ComicVineMetadataProvider.kt @@ -67,6 +67,10 @@ class ComicVineMetadataProvider( client.clearVolumeCache(providerSeriesId.toComicVineVolumeId()) } + override suspend fun clearSeriesIssuesCache(providerSeriesId: ProviderSeriesId) { + client.clearVolumeIssuesCache(providerSeriesId.toComicVineVolumeId()) + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { val issue = handleResult(client.getIssue(bookId.toComicVineIssueId())) val storyArcs = issue.storyArcCredits?.let { credits -> diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/mal/MalMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/mal/MalMetadataProvider.kt index 64888ce0..4c838350 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/mal/MalMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/mal/MalMetadataProvider.kt @@ -59,6 +59,10 @@ class MalMetadataProvider( throw UnsupportedOperationException() } + override suspend fun clearSeriesIssuesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { throw UnsupportedOperationException() } diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/mangabaka/MangaBakaMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/mangabaka/MangaBakaMetadataProvider.kt index f54b19da..baa86a98 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/mangabaka/MangaBakaMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/mangabaka/MangaBakaMetadataProvider.kt @@ -60,6 +60,10 @@ class MangaBakaMetadataProvider( throw UnsupportedOperationException() } + override suspend fun clearSeriesIssuesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata( seriesId: ProviderSeriesId, bookId: ProviderBookId diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/mangadex/MangaDexMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/mangadex/MangaDexMetadataProvider.kt index a1be1fa4..269fa3b4 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/mangadex/MangaDexMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/mangadex/MangaDexMetadataProvider.kt @@ -41,6 +41,10 @@ class MangaDexMetadataProvider( throw UnsupportedOperationException() } + override suspend fun clearSeriesIssuesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { val cover = if (fetchBookCovers) client.getCover(MangaDexMangaId(seriesId.value), bookId.id) else null return metadataMapper.toBookMetadata(bookId.id, cover) diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/mangaupdates/MangaUpdatesMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/mangaupdates/MangaUpdatesMetadataProvider.kt index dc4a76d1..b4a99ce8 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/mangaupdates/MangaUpdatesMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/mangaupdates/MangaUpdatesMetadataProvider.kt @@ -71,6 +71,10 @@ class MangaUpdatesMetadataProvider( throw UnsupportedOperationException() } + override suspend fun clearSeriesIssuesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { throw UnsupportedOperationException() } diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/viz/VizMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/viz/VizMetadataProvider.kt index 7062687b..0c4478cb 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/viz/VizMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/viz/VizMetadataProvider.kt @@ -49,6 +49,10 @@ class VizMetadataProvider( throw UnsupportedOperationException() } + override suspend fun clearSeriesIssuesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { val bookMetadata = getBook(VizBookId(bookId.id)) val thumbnail = if (fetchBookCovers) getThumbnail(bookMetadata.coverUrl) else null diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/webtoons/WebtoonsMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/webtoons/WebtoonsMetadataProvider.kt index a27630cf..2424b23a 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/webtoons/WebtoonsMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/webtoons/WebtoonsMetadataProvider.kt @@ -54,6 +54,10 @@ class WebtoonsMetadataProvider( throw UnsupportedOperationException() } + override suspend fun clearSeriesIssuesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata( seriesId: ProviderSeriesId, bookId: ProviderBookId ): ProviderBookMetadata { diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/yenpress/YenPressMetadataProvider.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/yenpress/YenPressMetadataProvider.kt index d1400151..2595bf62 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/yenpress/YenPressMetadataProvider.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/yenpress/YenPressMetadataProvider.kt @@ -59,6 +59,10 @@ class YenPressMetadataProvider( throw UnsupportedOperationException() } + override suspend fun clearSeriesIssuesCache(providerSeriesId: ProviderSeriesId) { + throw UnsupportedOperationException() + } + override suspend fun getBookMetadata(seriesId: ProviderSeriesId, bookId: ProviderBookId): ProviderBookMetadata { val bookMetadata = client.getBook(YenPressBookId(bookId.id)) val thumbnail = if (fetchBookCovers) client.getBookThumbnail(bookMetadata) else null diff --git a/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataService.kt b/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataService.kt index 5bc57b80..e4651263 100644 --- a/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataService.kt +++ b/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataService.kt @@ -145,6 +145,17 @@ class MetadataService( } } + fun clearSeriesIssuesCache( + libraryId: String, + providerName: CoreProviders, + providerSeriesId: ProviderSeriesId, + ) { + coroutineScope.launch { + val provider = metadataProviders.provider(libraryId, providerName) ?: throw RuntimeException() + provider.clearSeriesIssuesCache(providerSeriesId) + } + } + fun matchLibraryMetadata(libraryId: MediaServerLibraryId) { coroutineScope.launch { var errorCount = 0