From 793284549a2fe1ad07f632f8f4cf442be6df9e7c Mon Sep 17 00:00:00 2001 From: Matthieu Rosset Date: Sun, 20 Sep 2026 15:14:48 +1000 Subject: [PATCH] feat: MangaUpdates Tier 3 search via associated names + LocalizedName write-back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Kavita series name is an English title but MangaUpdates indexes the series under its Japanese romanisation as the primary title, Tier 1 and Tier 2 matching both fail. This adds a Tier 3 fallback inside MangaUpdatesMetadataProvider that fetches the full series record for each search result and checks the associated names list. On a match, matchedAltTitle is set to the MangaUpdates primary title and returned with the metadata. Write-back: when Tier 3 fires, MetadataService writes the matched primary title back to Kavita as localizedName (via language=ja-ro on an alternative title). On subsequent runs the localised name appears in searchTitles and Tier 1 matches directly — Tier 3 is never needed again for that series. ComicInfo durability: any Kavita library scan resets series.LocalizedName to the value of LocalizedSeries in each chapter's ComicInfo. Without this fix the localised name written by the API is erased on the next scan. MetadataUpdater now patches localizedSeries in ComicInfo from matchedAltTitle (Tier 3 match) or, as a fallback, from the first language-tagged title in the raw provider metadata. New field matchedAltTitle: String? is nullable with a null default so there is no change to existing provider interfaces or config-applier contracts. --- .../kotlin/snd/komf/model/SeriesMetadata.kt | 1 + .../komf/providers/MetadataConfigApplier.kt | 1 + .../MangaUpdatesMetadataProvider.kt | 22 +++++++++++++++---- .../metadata/MetadataPostProcessor.kt | 1 + .../mediaserver/metadata/MetadataService.kt | 15 ++++++++++++- .../mediaserver/metadata/MetadataUpdater.kt | 22 ++++++++++++++++--- .../model/SeriesAndBookMetadata.kt | 1 + 7 files changed, 55 insertions(+), 8 deletions(-) diff --git a/komf-core/src/commonMain/kotlin/snd/komf/model/SeriesMetadata.kt b/komf-core/src/commonMain/kotlin/snd/komf/model/SeriesMetadata.kt index 1ffd6ff9..cf152f5c 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/model/SeriesMetadata.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/model/SeriesMetadata.kt @@ -45,6 +45,7 @@ data class ProviderSeriesMetadata( val id: ProviderSeriesId, val metadata: SeriesMetadata, val books: List = emptyList(), + val matchedAltTitle: String? = null, ) @Serializable diff --git a/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataConfigApplier.kt b/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataConfigApplier.kt index d5399b6f..18eb04d4 100644 --- a/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataConfigApplier.kt +++ b/komf-core/src/commonMain/kotlin/snd/komf/providers/MetadataConfigApplier.kt @@ -13,6 +13,7 @@ object MetadataConfigApplier { ProviderSeriesMetadata( id = id, books = getIfEnabled(books, config.books) ?: emptyList(), + matchedAltTitle = matchedAltTitle, metadata = SeriesMetadata( status = getIfEnabled(metadata.status, config.status), title = getIfEnabled(metadata.title, config.title), 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..ed6c940f 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 @@ -83,12 +83,26 @@ class MangaUpdatesMetadataProvider( val seriesName = matchQuery.seriesName val searchResults = client.searchSeries(seriesName.take(400), seriesTypes).results.map { it.record } - return searchResults + // Tier 1: match against primary title + val primaryMatch = searchResults .firstOrNull { nameMatcher.matches(seriesName, it.title.removeSuffix(" (Novel)")) } - ?.let { - val series = client.getSeries(it.id) + if (primaryMatch != null) { + val series = client.getSeries(primaryMatch.id) + val thumbnail = if (fetchSeriesCovers) client.getThumbnail(series) else null + return metadataMapper.toSeriesMetadata(series, thumbnail) + } + + // Tier 3: fetch full records and match against associated names + for (result in searchResults) { + val series = client.getSeries(result.id) + val associatedMatch = series.associated.firstOrNull { nameMatcher.matches(seriesName, it.title) } + if (associatedMatch != null) { val thumbnail = if (fetchSeriesCovers) client.getThumbnail(series) else null - metadataMapper.toSeriesMetadata(series, thumbnail) + return metadataMapper.toSeriesMetadata(series, thumbnail) + .copy(matchedAltTitle = series.title) } + } + + return null } } diff --git a/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataPostProcessor.kt b/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataPostProcessor.kt index 9268166d..bd135c4c 100644 --- a/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataPostProcessor.kt +++ b/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataPostProcessor.kt @@ -36,6 +36,7 @@ class MetadataPostProcessor( val bookMetadata = postProcessBooks(metadata.bookMetadata) return handleKomgaOneshot(seriesMetadata, bookMetadata) + .copy(matchedAltTitle = metadata.matchedAltTitle) } private fun postProcessSeries(series: SeriesMetadata): SeriesMetadata { 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..9b8bdd71 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 @@ -29,6 +29,7 @@ import snd.komf.mediaserver.model.MediaServerBook import snd.komf.mediaserver.model.MediaServerLibraryId import snd.komf.mediaserver.model.MediaServerSeries import snd.komf.mediaserver.model.MediaServerSeriesId +import snd.komf.mediaserver.model.MediaServerSeriesMetadataUpdate import snd.komf.mediaserver.model.SeriesAndBookMetadata import snd.komf.model.BookMetadata import snd.komf.model.BookQualifier @@ -41,6 +42,7 @@ import snd.komf.model.ProviderSeriesId import snd.komf.model.ProviderSeriesMetadata import snd.komf.model.SeriesBook import snd.komf.model.SeriesSearchResult +import snd.komf.model.SeriesTitle import snd.komf.providers.CoreProviders import snd.komf.providers.MetadataProvider import snd.komf.providers.ProvidersModule @@ -211,6 +213,17 @@ class MetadataService( eventFlow.emit(PostProcessingStartEvent) metadataUpdateService.updateMetadata(series, metadata) + + matchResult.second.matchedAltTitle?.let { altTitle -> + logger.info { "writing matched provider title \"$altTitle\" as localized name for series \"$seriesTitle\" ${series.id}" } + mediaServerClient.updateSeriesMetadata( + series.id, + MediaServerSeriesMetadataUpdate( + alternativeTitles = listOf(SeriesTitle(name = altTitle, type = null, language = "ja-ro")) + ) + ) + } + logger.info { "finished metadata update of series \"${seriesTitle}\" ${series.id}" } } @@ -238,7 +251,7 @@ class MetadataService( if (result != null) { logger.info { "found match: \"${result.metadata.titles.firstOrNull()?.name}\" from ${provider.providerName()} ${result.id}" } val bookMetadata = getBookMetadata(books, result, provider, bookEdition, eventFlow) - return SeriesAndBookMetadata(result.metadata, bookMetadata) + return SeriesAndBookMetadata(result.metadata, bookMetadata, result.matchedAltTitle) } } return null diff --git a/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataUpdater.kt b/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataUpdater.kt index c3cbb349..3f392d6e 100644 --- a/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataUpdater.kt +++ b/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/metadata/MetadataUpdater.kt @@ -93,13 +93,20 @@ class MetadataUpdater( processedMetadata: SeriesAndBookMetadata ) { val bookIdToWriteSeriesMetadata = bookToWriteSeriesMetadata(unprocessedMetadata.bookMetadata) + // Prefer the explicit matched alt title (Tier 3 search result); fall back to any + // language-tagged title from the unprocessed metadata (e.g. the ja-ro primary title + // from MangaUpdates). Post-processing strips titles when alternativeSeriesTitles=false, + // so we must source this from the unprocessed metadata. + val localizedSeriesName = unprocessedMetadata.matchedAltTitle + ?: unprocessedMetadata.seriesMetadata.titles.find { it.language != null }?.name processedMetadata.bookMetadata.forEach { (book, metadata) -> updateBookMetadata( book, metadata, processedMetadata.seriesMetadata, - book.id == bookIdToWriteSeriesMetadata + book.id == bookIdToWriteSeriesMetadata, + localizedSeriesName, ) } } @@ -108,7 +115,8 @@ class MetadataUpdater( book: MediaServerBook, metadata: BookMetadata?, seriesMeta: SeriesMetadata, - writeSeriesMetadata: Boolean + writeSeriesMetadata: Boolean, + localizedSeriesName: String? = null, ) { logger.info { "updating book ${book.name}" } updateModes.forEach { mode -> @@ -124,7 +132,15 @@ class MetadataUpdater( if (writeSeriesMetadata) metadataUpdateMapper.toSeriesComicInfo(seriesMeta, metadata) else metadataUpdateMapper.toComicInfo(metadata, seriesMeta) - comicInfo?.let { comicInfoWriter.writeMetadata(book.url, it) } + // The post-processor strips titles when alternativeSeriesTitles=false, so + // the mapper produces localizedSeries=null. Patch it from the value we + // computed from the unprocessed metadata so Kavita's scanner never resets + // series.LocalizedName to empty on rescan. + val patchedComicInfo = if (localizedSeriesName != null) + comicInfo?.copy(localizedSeries = comicInfo.localizedSeries ?: localizedSeriesName) + else comicInfo + + patchedComicInfo?.let { comicInfoWriter.writeMetadata(book.url, it) } } // UpdateMode.OPF -> { diff --git a/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/SeriesAndBookMetadata.kt b/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/SeriesAndBookMetadata.kt index 78f3ba08..3e099880 100644 --- a/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/SeriesAndBookMetadata.kt +++ b/komf-mediaserver/src/commonMain/kotlin/snd/komf/mediaserver/model/SeriesAndBookMetadata.kt @@ -6,4 +6,5 @@ import snd.komf.model.SeriesMetadata data class SeriesAndBookMetadata( val seriesMetadata: SeriesMetadata, val bookMetadata: Map, + val matchedAltTitle: String? = null, )