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.
This commit is contained in:
Kingkor Roy Tirtho 2026-10-16 14:26:39 +06:00
parent 61dd89f3ed
commit 09d77211b9
5 changed files with 215 additions and 48 deletions

View File

@ -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.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
import org.schabi.newpipe.extractor.stream.StreamInfoItem 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 class NewPipeService {
actual suspend fun searchVideos(query: String): List<VideoSearchResult> { actual suspend fun searchVideos(query: String, kind: SearchKind): List<VideoSearchResult> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val extractor = YouTube.getSearchExtractor( val extractor = YouTube.getSearchExtractor(
query, query,
mutableListOf(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS), mutableListOf(
when (kind) {
SearchKind.Songs -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS
SearchKind.Videos -> YoutubeSearchQueryHandlerFactory.VIDEOS
}),
"" ""
) )
extractor.fetchPage() extractor.fetchPage()
@ -45,7 +52,7 @@ actual class NewPipeService {
title = item.name, title = item.name,
url = item.url, url = item.url,
uploader = item.uploaderName, uploader = item.uploaderName,
durationMs = item.duration, durationMs = secondsToMs(item.duration),
thumbnailUrl = item.thumbnails.first().url) thumbnailUrl = item.thumbnails.first().url)
} else { } else {
null null
@ -101,7 +108,7 @@ actual class NewPipeService {
title = extractor.name, title = extractor.name,
url = extractor.url, url = extractor.url,
uploader = extractor.uploaderName, uploader = extractor.uploaderName,
durationMs = extractor.length, durationMs = secondsToMs(extractor.length),
thumbnailUrl = extractor.thumbnails.first().url, thumbnailUrl = extractor.thumbnails.first().url,
// fallback to video streams if no audio streams are available // fallback to video streams if no audio streams are available
audioStreams = audioStreams.ifEmpty { audioStreams = audioStreams.ifEmpty {

View File

@ -17,7 +17,15 @@
package dev.krtirtho.spotube.core.newpipe 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() { expect class NewPipeService() {
suspend fun searchVideos(query: String): List<VideoSearchResult> suspend fun searchVideos(query: String, kind: SearchKind = SearchKind.Songs): List<VideoSearchResult>
suspend fun getVideoInfo(id: String): VideoInfo suspend fun getVideoInfo(id: String): VideoInfo
} }

View File

@ -21,6 +21,10 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body 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.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.request.parameter import io.ktor.client.request.parameter
@ -37,7 +41,10 @@ import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
class ISRCProviders { class ISRCProviders : KoinComponent {
private val logger by injectLogger<ISRCProviders>()
private val lenientJson = Json { isLenient = true; ignoreUnknownKeys = true }
companion object { companion object {
val greedyGreenCorp = val greedyGreenCorp =
listOf(-0x03, -0x01, 0x05, -0x0B, -0x03, 0x13) 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 <reified T> HttpResponse.decodeLogged(provider: String): T {
val text = bodyAsText()
logger.i { "[ISRC:$provider] status=${status.value} rawBody=$text" }
return lenientJson.decodeFromString<T>(text)
}
private var ifpiSessionToken: String? = null private var ifpiSessionToken: String? = null
private val ifpiDefaultHeaders = mapOf( private val ifpiDefaultHeaders = mapOf(
"Content-Type" to "application/json", "Content-Type" to "application/json",
@ -78,20 +96,23 @@ class ISRCProviders {
private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) { private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) {
if (ifpiSessionToken != null) return@withContext ifpiSessionToken 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") { val res = client.get("https://isrc-api.soundexchange.com/api/ext/login") {
headers.appendAll(ifpiDefaultHeaders) headers.appendAll(ifpiDefaultHeaders)
} }
if (res.status.value != 200) { if (res.status.value != 200) {
logger.w { "[ISRC:ifpi] session login failed status=${res.status.value} body=${res.bodyAsText()}" }
return@withContext null return@withContext null
} }
val body = res.body<IFPISessionResponse>() val body = res.decodeLogged<IFPISessionResponse>("ifpi-login")
ifpiSessionToken = body.token ifpiSessionToken = body.token
body.token body.token
} }
suspend fun ifpi(track: MetadataTrack): String? = withContext(Dispatchers.IO) { suspend fun ifpi(track: MetadataTrack): String? = withContext(Dispatchers.IO) {
logger.i { "[ISRC:ifpi] input: ${track.describe()}" }
val sessonToken = ifpiSessionToken() ?: return@withContext null val sessonToken = ifpiSessionToken() ?: return@withContext null
val input = IFPIRecordingRequestInput( val input = IFPIRecordingRequestInput(
searchFields = IFPIRecordingRequestInput.SearchFields( 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") { val res = client.post("https://isrc-api.soundexchange.com/api/ext/recordings") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
headers { headers {
@ -123,17 +145,28 @@ class ISRCProviders {
} }
if (res.status.value != 200) { if (res.status.value != 200) {
logger.w { "[ISRC:ifpi] search failed status=${res.status.value} body=${res.bodyAsText()}" }
return@withContext null return@withContext null
} }
val body = res.body<IFPIRecordingResponse>() val body = res.decodeLogged<IFPIRecordingResponse>("ifpi")
if (body.recordings.isEmpty()) { if (body.recordings.isEmpty()) {
logger.i { "[ISRC:ifpi] no recordings returned" }
return@withContext null 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 body.recordings.first().isrc
} }
suspend fun musicGateway(track: MetadataTrack): String? = withContext(Dispatchers.IO) { 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") { val res = client.get("https://www.musicgateway.com/isrc-finder") {
parameter("search", "${track.title} ${track.artists.joinToString(", ") { it.name }}") parameter("search", "${track.title} ${track.artists.joinToString(", ") { it.name }}")
parameter("type", "name") parameter("type", "name")
@ -141,39 +174,47 @@ class ISRCProviders {
} }
if (res.status != HttpStatusCode.OK) { if (res.status != HttpStatusCode.OK) {
logger.w { "[ISRC:musicGateway] failed status=${res.status.value} body=${res.bodyAsText()}" }
return@withContext null return@withContext null
} }
val body = res.body<MusicGatewayResponse>() val body = res.decodeLogged<MusicGatewayResponse>("musicGateway")
val results = body.result.tracks.items val results = body.result.tracks.items
if (results.isEmpty()) { if (results.isEmpty()) {
logger.i { "[ISRC:musicGateway] no results" }
return@withContext null return@withContext null
} }
var isrcCode: String? = null var isrcCode: String? = null
for (item in results) { for ((_, artists, externalIds, id, name) in results) {
val isExactTitle = item.name.equals(track.title, ignoreCase = true) val isExactTitle = name.equals(track.title, ignoreCase = true)
val isArtistMatch = item.artists.any { val isArtistMatch = artists.any {
it.name.equals( it.name.equals(
track.artists.firstOrNull()?.name ?: "", track.artists.firstOrNull()?.name ?: "",
ignoreCase = true 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)) { if (isSameId || (isExactTitle && isArtistMatch)) {
isrcCode = item.externalIds.isrc isrcCode = externalIds.isrc
break break
} }
} }
logger.i { "[ISRC:musicGateway] result isrc=$isrcCode" }
isrcCode isrcCode
} }
suspend fun soundplate(track: MetadataTrack): String? = withContext(Dispatchers.IO) { suspend fun soundplate(track: MetadataTrack): String? = withContext(Dispatchers.IO) {
val isGreedyGreenCorp = track.externalUri?.contains(greedyGreenCorp) ?: false val isGreedyGreenCorp = track.externalUri?.contains(greedyGreenCorp) ?: false
val uri = "https://open.$greedyGreenCorp.com/track/${track.id}" val uri = "https://open.$greedyGreenCorp.com/track/${track.id}"
logger.i { "[ISRC:soundplate] input: ${track.describe()} useUri=$isGreedyGreenCorp" }
val res = val res =
client.get("https://phpstack-822472-6184058.cloudwaysapps.com/api/$greedyGreenCorp.php") { client.get("https://phpstack-822472-6184058.cloudwaysapps.com/api/$greedyGreenCorp.php") {
if (isGreedyGreenCorp) { if (isGreedyGreenCorp) {
@ -185,16 +226,39 @@ class ISRCProviders {
} }
if (res.status.value != 200) { if (res.status.value != 200) {
logger.w { "[ISRC:soundplate] failed status=${res.status.value} body=${res.bodyAsText()}" }
return@withContext null return@withContext null
} }
val body = res.body<SoundplateResponse>() val body = res.decodeLogged<SoundplateResponse>("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 body.isrc
} }
suspend fun auto(track: MetadataTrack): String? { suspend fun auto(track: MetadataTrack): String? {
return runCatching { soundplate(track) }.getOrNull() logger.i { "[ISRC:auto] start: ${track.describe()}" }
?: runCatching { ifpi(track) }.getOrNull() val providers = listOf<Pair<String, suspend (MetadataTrack) -> String?>>(
?: runCatching { musicGateway(track) }.getOrNull() "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
} }
} }

View File

@ -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.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.newpipe.NewPipeService import dev.krtirtho.spotube.core.newpipe.NewPipeService
import dev.krtirtho.spotube.core.newpipe.SearchKind
import dev.krtirtho.spotube.core.newpipe.VideoSearchResult import dev.krtirtho.spotube.core.newpipe.VideoSearchResult
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -34,27 +35,61 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
companion object { companion object {
private val youtubeIDRegex = Regex("^[a-zA-Z0-9_-]{11}$") 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 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 { private fun transformVideo(track: MetadataTrack, video: VideoSearchResult): AudioSource.Basic {
var confidence = 0f var confidence = 0f
val isTitleMatch = track.title.lowercase() in video.title.lowercase() val videoTitle = normalize(video.title)
val isArtistInTitle = track.artists.any { it.name.lowercase() in video.title.lowercase() } val isTopicChannel = video.uploader.trim().endsWith("- Topic", ignoreCase = true)
val isAlbumInTitle = val uploader = normalize(video.uploader.replace(Regex("(?i)\\s*-\\s*topic$"), ""))
track.album != null && track.album!!.title.lowercase() in video.title.lowercase() val artistNames = track.artists.map { normalize(it.name) }.filter { it.isNotEmpty() }
val isUploaderMatch = val title = coreTitle(track.title)
video.uploader.lowercase() in (track.artists.firstOrNull()?.name?.lowercase() ?: "")
val isDurationMatch = val videoTokens = videoTitle.split(' ').toSet()
video.durationMs in (track.durationMs - 30_000)..(track.durationMs + 30_000) // Duration within ±30 seconds 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 (isTitleMatch) confidence += 0.3f
if (isUploaderMatch) 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 (isArtistInTitle) confidence += 0.1f
if (isAlbumInTitle) 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( return AudioSource.Basic(
id = video.id, id = video.id,
@ -125,25 +160,71 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
logger.i { "No YouTube ID or URL found, performing search with track metadata" } logger.i { "No YouTube ID or URL found, performing search with track metadata" }
val isrcCode = track.isrcCode ?: isrcProvider.auto(track) logger.i {
val searchQuery = "${track.title} - ${track.artists.joinToString(", ") { it.name }}".trim() "[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} " +
logger.i { "Searching for video with query: $searchQuery" } "externalUri=${track.externalUri}"
val videos = newPipeService.searchVideos(isrcCode ?: searchQuery) }
val rawIsrc = track.isrcCode ?: isrcProvider.auto(track)
logger.i { "Found ${videos.size} videos with query: $searchQuery" } val isrcCode = rawIsrc
var sources = videos.map { transformVideo(track, it) } ?.trim()?.uppercase()?.replace("-", "")
?.takeIf { isrcRegex.matches(it) }
if (isrcCode != null && ((sources.size < 2 && sources.any { it.confidence < 0.3f }) || sources.isEmpty())) { logger.i {
logger.i { "ISRC search failed, fallback to title search" } "[Search] isrc raw=$rawIsrc (from ${if (track.isrcCode != null) "track" else "providers"}) " +
val videos = newPipeService.searchVideos(searchQuery) "validated=$isrcCode"
sources = videos.map { transformVideo(track, it) }
} }
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 // Ordered from most to least trusted. The ISRC is only a hint: a text search for it on
return sources.sortedByDescending { it.confidence }.take(5) // 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<String, AudioSource.Basic>()
val searched = mutableSetOf<Pair<String, SearchKind>>()
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<AudioSource.Streamed> { override suspend fun getStreamsOfAudioSource(source: AudioSource.Basic): List<AudioSource.Streamed> {

View File

@ -26,12 +26,19 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
import kotlinx.coroutines.withContext 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 class NewPipeService {
actual suspend fun searchVideos(query: String): List<VideoSearchResult> { actual suspend fun searchVideos(query: String, kind: SearchKind): List<VideoSearchResult> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val extractor = YouTube.getSearchExtractor( val extractor = YouTube.getSearchExtractor(
query, query,
mutableListOf(YoutubeSearchQueryHandlerFactory.MUSIC_SONGS), mutableListOf(
when (kind) {
SearchKind.Songs -> YoutubeSearchQueryHandlerFactory.MUSIC_SONGS
SearchKind.Videos -> YoutubeSearchQueryHandlerFactory.VIDEOS
}),
"" ""
) )
extractor.fetchPage() extractor.fetchPage()
@ -43,7 +50,7 @@ actual class NewPipeService {
title = item.name, title = item.name,
url = item.url, url = item.url,
uploader = item.uploaderName ?: "Unknown", uploader = item.uploaderName ?: "Unknown",
durationMs = item.duration, durationMs = secondsToMs(item.duration),
thumbnailUrl = item.thumbnails.first().url, thumbnailUrl = item.thumbnails.first().url,
id = Url(item.url).parameters["v"] id = Url(item.url).parameters["v"]
?: throw IllegalArgumentException("Invalid YouTube URL: ${item.url}") ?: throw IllegalArgumentException("Invalid YouTube URL: ${item.url}")
@ -63,7 +70,7 @@ actual class NewPipeService {
title = extractor.name ?: "Unknown", title = extractor.name ?: "Unknown",
url = extractor.url ?: "https://www.youtube.com/watch?v=$id", url = extractor.url ?: "https://www.youtube.com/watch?v=$id",
uploader = extractor.uploaderName, uploader = extractor.uploaderName,
durationMs = extractor.length, durationMs = secondsToMs(extractor.length),
thumbnailUrl = extractor.thumbnails.first().url, thumbnailUrl = extractor.thumbnails.first().url,
audioStreams = extractor.audioStreams() audioStreams = extractor.audioStreams()
.filter { stream -> stream.isUrl() && stream.format != null } .filter { stream -> stream.isUrl() && stream.format != null }