From b6799d27158ae92027cb4d188d24fa712270d49b Mon Sep 17 00:00:00 2001 From: sloth-of-all-trades Date: Tue, 7 Jul 2026 08:56:12 +0200 Subject: [PATCH] fix: graceful per-provider failure handling When a metadata provider fails (403, 500, timeout, Cloudflare block), the error is now caught and logged for that provider only. The search /match / aggregation continues with remaining providers instead of aborting the entire operation with a 500 error. Fixes #216 --- .../mediaserver/metadata/MetadataService.kt | 54 ++++++++++++++----- 1 file changed, 40 insertions(+), 14 deletions(-) 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 ef1827a7..72a5ba7f 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 @@ -70,14 +70,32 @@ class MetadataService( val providers = metadataProviders.providers(libraryId.value) return providers - .map { coroutineScope.async { it.searchSeries(seriesName) } } + .map { provider -> + coroutineScope.async { + try { + provider.searchSeries(seriesName) + } catch (e: Exception) { + logger.error(e) { "search failed for provider ${provider.providerName()}" } + emptyList() + } + } + } .flatMap { it.await() } } suspend fun searchSeriesMetadata(seriesName: String): Collection { val providers = metadataProviders.defaultProvidersList() return providers - .map { coroutineScope.async { it.searchSeries(seriesName) } } + .map { provider -> + coroutineScope.async { + try { + provider.searchSeries(seriesName) + } catch (e: Exception) { + logger.error(e) { "search failed for provider ${provider.providerName()}" } + emptyList() + } + } + } .flatMap { it.await() } } @@ -231,7 +249,9 @@ class MetadataService( val result = try { provider.matchSeriesMetadata(createMatchQuery(searchTitle, series, books)) } catch (e: Exception) { - throw ProviderException(provider.providerName(), e) + logger.error(e) { "match failed for provider ${provider.providerName()}" } + eventFlow.emit(ProviderErrorEvent(provider.providerName(), "${e::class.simpleName}: ${e.message}")) + null } if (result != null) { @@ -268,7 +288,8 @@ class MetadataService( }.toMap() } catch (e: Exception) { - throw ProviderException(provider.providerName(), e) + logger.error(e) { "book metadata fetch failed for provider ${provider.providerName()}" } + metadataMatch.mapValues { it.value?.let { null } } } } @@ -333,17 +354,22 @@ class MetadataService( return providers .map { provider -> coroutineScope.async { - matchSeries( - series = series, - books = books, - searchTitles = searchTitles, - provider = provider, - bookEdition = edition, - eventFlow = eventFlow - ).also { - eventFlow.emit(ProviderCompletedEvent(provider.providerName())) + try { + matchSeries( + series = series, + books = books, + searchTitles = searchTitles, + provider = provider, + bookEdition = edition, + eventFlow = eventFlow + ).also { + eventFlow.emit(ProviderCompletedEvent(provider.providerName())) + } + } catch (e: Exception) { + logger.error(e) { "aggregation failed for provider ${provider.providerName()}" } + eventFlow.emit(ProviderErrorEvent(provider.providerName(), "${e::class.simpleName}: ${e.message}")) + null } - } } .mapNotNull { it.await() }