From 09d77211b9f7b9687410a0ae11dc2dfd85ddb548 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Fri, 16 Oct 2026 14:26:39 +0600 Subject: [PATCH] fix(newpipe): fall back through multiple searches when ISRC search misses NewPipe durations are seconds, not ms, so duration scoring never matched. Fix the uploader match direction, normalize titles, and walk a ladder of searches (ISRC, songs, videos, title only) until a confident match is found. Return nothing rather than unrelated results. Add detailed ISRC/NewPipe logs. --- .../core/newpipe/NewPipeService.androidJvm.kt | 15 +- .../spotube/core/newpipe/NewPipeService.kt | 10 +- .../plugin_apis/newpipe_yt/ISRCProviders.kt | 92 ++++++++++-- .../newpipe_yt/RealNewPipeAudioAPI.kt | 131 ++++++++++++++---- .../core/newpipe/NewPipeService.ios.kt | 15 +- 5 files changed, 215 insertions(+), 48 deletions(-) diff --git a/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt b/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt index 5231f3b4..c476f876 100644 --- a/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt +++ b/composeApp/src/androidJvmMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.androidJvm.kt @@ -26,12 +26,19 @@ import org.schabi.newpipe.extractor.ServiceList.YouTube import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory import org.schabi.newpipe.extractor.stream.StreamInfoItem +/** NewPipe reports durations in seconds (-1 when unknown). */ +private fun secondsToMs(seconds: Long): Long = if (seconds > 0) seconds * 1000 else 0 + actual class NewPipeService { - actual suspend fun searchVideos(query: String): List { + actual suspend fun searchVideos(query: String, kind: SearchKind): List { return withContext(Dispatchers.IO) { val extractor = YouTube.getSearchExtractor( query, - mutableListOf(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS), + mutableListOf( + when (kind) { + SearchKind.Songs -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS + SearchKind.Videos -> YoutubeSearchQueryHandlerFactory.VIDEOS + }), "" ) extractor.fetchPage() @@ -45,7 +52,7 @@ actual class NewPipeService { title = item.name, url = item.url, uploader = item.uploaderName, - durationMs = item.duration, + durationMs = secondsToMs(item.duration), thumbnailUrl = item.thumbnails.first().url) } else { null @@ -101,7 +108,7 @@ actual class NewPipeService { title = extractor.name, url = extractor.url, uploader = extractor.uploaderName, - durationMs = extractor.length, + durationMs = secondsToMs(extractor.length), thumbnailUrl = extractor.thumbnails.first().url, // fallback to video streams if no audio streams are available audioStreams = audioStreams.ifEmpty { diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.kt index 5cbb3c81..880a587f 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.kt @@ -17,7 +17,15 @@ package dev.krtirtho.spotube.core.newpipe +enum class SearchKind { + /** YouTube Music "Songs" catalogue. */ + Songs, + + /** Regular YouTube videos; covers tracks that are not in the Music songs catalogue. */ + Videos +} + expect class NewPipeService() { - suspend fun searchVideos(query: String): List + suspend fun searchVideos(query: String, kind: SearchKind = SearchKind.Songs): List suspend fun getVideoInfo(id: String): VideoInfo } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt index e36e3ad7..d10aa317 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/ISRCProviders.kt @@ -21,6 +21,10 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.tools.user_agents.UserAgents import io.ktor.client.HttpClient import io.ktor.client.call.body +import io.ktor.client.statement.HttpResponse +import io.ktor.client.statement.bodyAsText +import dev.krtirtho.spotube.core.di.injectLogger +import org.koin.core.component.KoinComponent import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.request.get import io.ktor.client.request.parameter @@ -37,7 +41,10 @@ import kotlinx.coroutines.IO import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json -class ISRCProviders { +class ISRCProviders : KoinComponent { + private val logger by injectLogger() + private val lenientJson = Json { isLenient = true; ignoreUnknownKeys = true } + companion object { val greedyGreenCorp = listOf(-0x03, -0x01, 0x05, -0x0B, -0x03, 0x13) @@ -57,6 +64,17 @@ class ISRCProviders { } } + private fun MetadataTrack.describe() = + "id=$id title='$title' artists=${artists.map { it.name }} album='${album?.title}' " + + "durationMs=$durationMs isrc=$isrcCode externalUri=$externalUri" + + /** Logs the full raw body (so parse failures are diagnosable) then decodes it. */ + private suspend inline fun HttpResponse.decodeLogged(provider: String): T { + val text = bodyAsText() + logger.i { "[ISRC:$provider] status=${status.value} rawBody=$text" } + return lenientJson.decodeFromString(text) + } + private var ifpiSessionToken: String? = null private val ifpiDefaultHeaders = mapOf( "Content-Type" to "application/json", @@ -78,20 +96,23 @@ class ISRCProviders { private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) { if (ifpiSessionToken != null) return@withContext ifpiSessionToken + logger.i { "[ISRC:ifpi] requesting session token" } val res = client.get("https://isrc-api.soundexchange.com/api/ext/login") { headers.appendAll(ifpiDefaultHeaders) } if (res.status.value != 200) { + logger.w { "[ISRC:ifpi] session login failed status=${res.status.value} body=${res.bodyAsText()}" } return@withContext null } - val body = res.body() + val body = res.decodeLogged("ifpi-login") ifpiSessionToken = body.token body.token } suspend fun ifpi(track: MetadataTrack): String? = withContext(Dispatchers.IO) { + logger.i { "[ISRC:ifpi] input: ${track.describe()}" } val sessonToken = ifpiSessionToken() ?: return@withContext null val input = IFPIRecordingRequestInput( searchFields = IFPIRecordingRequestInput.SearchFields( @@ -113,6 +134,7 @@ class ISRCProviders { ) ) + logger.i { "[ISRC:ifpi] request searchFields=${input.searchFields}" } val res = client.post("https://isrc-api.soundexchange.com/api/ext/recordings") { contentType(ContentType.Application.Json) headers { @@ -123,17 +145,28 @@ class ISRCProviders { } if (res.status.value != 200) { + logger.w { "[ISRC:ifpi] search failed status=${res.status.value} body=${res.bodyAsText()}" } return@withContext null } - val body = res.body() + val body = res.decodeLogged("ifpi") if (body.recordings.isEmpty()) { + logger.i { "[ISRC:ifpi] no recordings returned" } return@withContext null } + body.recordings.forEachIndexed { i, r -> + logger.i { + "[ISRC:ifpi] candidate #$i isrc=${r.isrc} title='${r.recordingTitle}' " + + "artist='${r.recordingArtistName}' duration=${r.duration} year=${r.recordingYear} " + + "version=${r.recordingVersion} valid=${r.isValidIsrc}" + } + } + logger.i { "[ISRC:ifpi] picking FIRST candidate isrc=${body.recordings.first().isrc} (no artist/title validation)" } body.recordings.first().isrc } suspend fun musicGateway(track: MetadataTrack): String? = withContext(Dispatchers.IO) { + logger.i { "[ISRC:musicGateway] input: ${track.describe()}" } val res = client.get("https://www.musicgateway.com/isrc-finder") { parameter("search", "${track.title} ${track.artists.joinToString(", ") { it.name }}") parameter("type", "name") @@ -141,39 +174,47 @@ class ISRCProviders { } if (res.status != HttpStatusCode.OK) { + logger.w { "[ISRC:musicGateway] failed status=${res.status.value} body=${res.bodyAsText()}" } return@withContext null } - val body = res.body() + val body = res.decodeLogged("musicGateway") val results = body.result.tracks.items if (results.isEmpty()) { + logger.i { "[ISRC:musicGateway] no results" } return@withContext null } var isrcCode: String? = null - for (item in results) { - val isExactTitle = item.name.equals(track.title, ignoreCase = true) - val isArtistMatch = item.artists.any { + for ((_, artists, externalIds, id, name) in results) { + val isExactTitle = name.equals(track.title, ignoreCase = true) + val isArtistMatch = artists.any { it.name.equals( track.artists.firstOrNull()?.name ?: "", ignoreCase = true ) } - val isSameId = item.id == track.id + val isSameId = id == track.id + logger.i { + "[ISRC:musicGateway] candidate id=$id name='$name' artists=${artists.map { it.name }} " + + "isrc=${externalIds.isrc} exactTitle=$isExactTitle artistMatch=$isArtistMatch sameId=$isSameId" + } if (isSameId || (isExactTitle && isArtistMatch)) { - isrcCode = item.externalIds.isrc + isrcCode = externalIds.isrc break } } + logger.i { "[ISRC:musicGateway] result isrc=$isrcCode" } isrcCode } suspend fun soundplate(track: MetadataTrack): String? = withContext(Dispatchers.IO) { val isGreedyGreenCorp = track.externalUri?.contains(greedyGreenCorp) ?: false val uri = "https://open.$greedyGreenCorp.com/track/${track.id}" + logger.i { "[ISRC:soundplate] input: ${track.describe()} useUri=$isGreedyGreenCorp" } val res = client.get("https://phpstack-822472-6184058.cloudwaysapps.com/api/$greedyGreenCorp.php") { if (isGreedyGreenCorp) { @@ -185,16 +226,39 @@ class ISRCProviders { } if (res.status.value != 200) { + logger.w { "[ISRC:soundplate] failed status=${res.status.value} body=${res.bodyAsText()}" } return@withContext null } - val body = res.body() + val body = res.decodeLogged("soundplate") + logger.i { + "[ISRC:soundplate] returned isrc=${body.isrc} name='${body.name}' artist='${body.artist}' " + + "album='${body.album}' year=${body.year} (input title='${track.title}' " + + "artist='${track.artists.firstOrNull()?.name}') — not validated against input" + } body.isrc } suspend fun auto(track: MetadataTrack): String? { - return runCatching { soundplate(track) }.getOrNull() - ?: runCatching { ifpi(track) }.getOrNull() - ?: runCatching { musicGateway(track) }.getOrNull() + logger.i { "[ISRC:auto] start: ${track.describe()}" } + val providers = listOf String?>>( + "soundplate" to ::soundplate, + "ifpi" to ::ifpi, + "musicGateway" to ::musicGateway) + for ((name, provider) in providers) { + val result = runCatching { provider(track) } + result.exceptionOrNull()?.let { + if (it is kotlinx.coroutines.CancellationException) throw it + logger.w(it) { "[ISRC:auto] provider $name threw" } + } + val isrc = result.getOrNull() + logger.i { "[ISRC:auto] provider $name -> $isrc" } + if (isrc != null) { + logger.i { "[ISRC:auto] using isrc=$isrc from $name" } + return isrc + } + } + logger.w { "[ISRC:auto] no provider produced an ISRC" } + return null } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt index 92b84513..9cd248bb 100644 --- a/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt +++ b/composeApp/src/commonMain/kotlin/dev/krtirtho/spotube/core/zipline/plugin_apis/newpipe_yt/RealNewPipeAudioAPI.kt @@ -25,6 +25,7 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.newpipe.NewPipeService +import dev.krtirtho.spotube.core.newpipe.SearchKind import dev.krtirtho.spotube.core.newpipe.VideoSearchResult import org.koin.core.component.KoinComponent @@ -34,27 +35,61 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { companion object { private val youtubeIDRegex = Regex("^[a-zA-Z0-9_-]{11}$") + private val isrcRegex = Regex("^[A-Z]{2}[A-Z0-9]{3}\\d{7}$") + private const val minConfidence = 0.3f + private const val confidentMatch = 0.6f } private val isrcProvider = ISRCProviders() + private fun normalize(value: String): String = + value.lowercase() + .replace(Regex("[^\\p{L}\\p{N}]+"), " ") + .trim() + + /** Drops "(feat. X)", "[Remastered]" style suffixes so the core title can be matched. */ + private fun coreTitle(title: String): String = + normalize(title.replace(Regex("[(\\[].*?[)\\]]"), " ")) + private fun transformVideo(track: MetadataTrack, video: VideoSearchResult): AudioSource.Basic { var confidence = 0f - val isTitleMatch = track.title.lowercase() in video.title.lowercase() - val isArtistInTitle = track.artists.any { it.name.lowercase() in video.title.lowercase() } - val isAlbumInTitle = - track.album != null && track.album!!.title.lowercase() in video.title.lowercase() - val isUploaderMatch = - video.uploader.lowercase() in (track.artists.firstOrNull()?.name?.lowercase() ?: "") - val isDurationMatch = - video.durationMs in (track.durationMs - 30_000)..(track.durationMs + 30_000) // Duration within ±30 seconds + val videoTitle = normalize(video.title) + val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true) + val uploader = normalize(video.uploader.replace(Regex("(?i)\\s*-\\s*topic$"), "")) + val artistNames = track.artists.map { normalize(it.name) }.filter { it.isNotEmpty() } + val title = coreTitle(track.title) + + val videoTokens = videoTitle.split(' ').toSet() + val isTitleMatch = title.isNotEmpty() && + (title in videoTitle || title.split(' ').all { it in videoTokens }) + val isArtistInTitle = artistNames.any { it in videoTitle } + val isAlbumInTitle = track.album?.title?.let { normalize(it) } + ?.takeIf { it.isNotEmpty() }?.let { it in videoTitle } ?: false + val isUploaderMatch = uploader.isNotEmpty() && artistNames.any { it in uploader || uploader in it } + // Unknown duration (0) must neither reward nor punish + val durationDiff = if (video.durationMs > 0) kotlin.math.abs(video.durationMs - track.durationMs) else Long.MAX_VALUE if (isTitleMatch) confidence += 0.3f if (isUploaderMatch) confidence += 0.3f - if (isDurationMatch) confidence += 0.2f // Duration within ±30 seconds + if (durationDiff <= 10_000) confidence += 0.2f + else if (durationDiff <= 30_000) confidence += 0.1f if (isArtistInTitle) confidence += 0.1f if (isAlbumInTitle) confidence += 0.1f + // Auto-generated "Artist - Topic" uploads are the studio audio, preferred over videos + if (isTopicChannel && isUploaderMatch) confidence += 0.1f + confidence = confidence.coerceAtMost(1f) + + logger.i { + "[Match] video id=${video.id} title='${video.title}' uploader='${video.uploader}' " + + "durationMs=${video.durationMs} url=${video.url}\n" + + " vs track title='${track.title}' artists=${track.artists.map { it.name }} " + + "album='${track.album?.title}' durationMs=${track.durationMs}\n" + + " normalized: videoTitle='$videoTitle' uploader='$uploader' coreTitle='$title' artists=$artistNames\n" + + " titleMatch=$isTitleMatch uploaderMatch=$isUploaderMatch topic=$isTopicChannel " + + "artistInTitle=$isArtistInTitle albumInTitle=$isAlbumInTitle durationDiffMs=$durationDiff " + + "=> confidence=$confidence" + } return AudioSource.Basic( id = video.id, @@ -125,25 +160,71 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent { logger.i { "No YouTube ID or URL found, performing search with track metadata" } - val isrcCode = track.isrcCode ?: isrcProvider.auto(track) - val searchQuery = "${track.title} - ${track.artists.joinToString(", ") { it.name }}".trim() - - logger.i { "Searching for video with query: $searchQuery" } - val videos = newPipeService.searchVideos(isrcCode ?: searchQuery) - - logger.i { "Found ${videos.size} videos with query: $searchQuery" } - var sources = videos.map { transformVideo(track, it) } - - if (isrcCode != null && ((sources.size < 2 && sources.any { it.confidence < 0.3f }) || sources.isEmpty())) { - logger.i { "ISRC search failed, fallback to title search" } - val videos = newPipeService.searchVideos(searchQuery) - sources = videos.map { transformVideo(track, it) } + logger.i { + "[Search] input track id=${track.id} title='${track.title}' artists=${track.artists.map { it.name }} " + + "album='${track.album?.title}' durationMs=${track.durationMs} trackIsrc=${track.isrcCode} " + + "externalUri=${track.externalUri}" + } + val rawIsrc = track.isrcCode ?: isrcProvider.auto(track) + val isrcCode = rawIsrc + ?.trim()?.uppercase()?.replace("-", "") + ?.takeIf { isrcRegex.matches(it) } + logger.i { + "[Search] isrc raw=$rawIsrc (from ${if (track.isrcCode != null) "track" else "providers"}) " + + "validated=$isrcCode" } - logger.i { "Found ${sources.size} sources with confidence >= 0.3f" } + val artistText = track.artists.joinToString(" ") { it.name }.trim() + val firstArtist = track.artists.firstOrNull()?.name?.trim().orEmpty() + val plainTitle = track.title.replace(Regex("[(\\[].*?[)\\]]"), " ").replace(Regex("\\s+"), " ").trim() - // Return 5 highest to lowest confidence - return sources.sortedByDescending { it.confidence }.take(5) + // Ordered from most to least trusted. The ISRC is only a hint: a text search for it on + // YouTube Music often returns unrelated songs, so every step is scored against the track + // and the next step runs until one result clears [confidentMatch]. + val plan = buildList { + if (isrcCode != null) add("isrc" to (isrcCode to SearchKind.Songs)) + add("songs: title+artist" to ("${track.title} $artistText".trim() to SearchKind.Songs)) + if (plainTitle.isNotEmpty() && firstArtist.isNotEmpty() && plainTitle != track.title) { + add("songs: plain title+first artist" to ("$plainTitle $firstArtist" to SearchKind.Songs)) + } + add("videos: title+artist" to ("${track.title} $artistText".trim() to SearchKind.Videos)) + add("songs: title only" to (plainTitle.ifEmpty { track.title } to SearchKind.Songs)) + } + + val sources = linkedMapOf() + val searched = mutableSetOf>() + for ((stage, request) in plan) { + if (sources.values.any { it.confidence >= confidentMatch }) break + if (!searched.add(request)) continue + val (query, kind) = request + val raw = runCatching { newPipeService.searchVideos(query, kind) } + .onFailure { + if (it is kotlinx.coroutines.CancellationException) throw it + logger.w(it) { "[Search] stage '$stage' failed for query '$query' ($kind)" } + }.getOrDefault(emptyList()) + logger.i { "[Search] stage '$stage' query='$query' kind=$kind -> ${raw.size} results: $raw" } + val scored = raw.map { transformVideo(track, it) } + logger.i { + "[Search] stage '$stage' best confidence=${scored.maxOfOrNull { it.confidence }} " + + "(need >= $confidentMatch to stop)" + } + scored.forEach { s -> + val existing = sources[s.id] + if (existing == null || existing.confidence < s.confidence) sources[s.id] = s + } + } + + val ranked = sources.values.sortedByDescending { it.confidence } + val matched = ranked.filter { it.confidence >= minConfidence } + logger.i { "[Search] ${matched.size} of ${ranked.size} candidates have confidence >= $minConfidence" } + logger.i { + "[Search] final ranking:\n" + matched.take(5).mapIndexed { i, s -> + " #$i ${s.id} conf=${s.confidence} '${s.title}' by '${s.artist}'" + }.joinToString("\n") + } + + // Never play a result that does not match the track at all + return matched.take(5) } override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List { diff --git a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt index 16820c4f..26a76fab 100644 --- a/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt +++ b/composeApp/src/iosMain/kotlin/dev/krtirtho/spotube/core/newpipe/NewPipeService.ios.kt @@ -26,12 +26,19 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.IO import kotlinx.coroutines.withContext +/** NewPipe reports durations in seconds (-1 when unknown). */ +private fun secondsToMs(seconds: Long): Long = if (seconds > 0) seconds * 1000 else 0 + actual class NewPipeService { - actual suspend fun searchVideos(query: String): List { + actual suspend fun searchVideos(query: String, kind: SearchKind): List { return withContext(Dispatchers.IO) { val extractor = YouTube.getSearchExtractor( query, - mutableListOf(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS), + mutableListOf( + when (kind) { + SearchKind.Songs -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS + SearchKind.Videos -> YoutubeSearchQueryHandlerFactory.VIDEOS + }), "" ) extractor.fetchPage() @@ -43,7 +50,7 @@ actual class NewPipeService { title = item.name, url = item.url, uploader = item.uploaderName ?: "Unknown", - durationMs = item.duration, + durationMs = secondsToMs(item.duration), thumbnailUrl = item.thumbnails.first().url, id = Url(item.url).parameters["v"] ?: throw IllegalArgumentException("Invalid YouTube URL: ${item.url}") @@ -63,7 +70,7 @@ actual class NewPipeService { title = extractor.name ?: "Unknown", url = extractor.url ?: "https://www.youtube.com/watch?v=$id", uploader = extractor.uploaderName, - durationMs = extractor.length, + durationMs = secondsToMs(extractor.length), thumbnailUrl = extractor.thumbnails.first().url, audioStreams = extractor.audioStreams() .filter { stream -> stream.isUrl() && stream.format != null }