mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
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:
parent
61dd89f3ed
commit
09d77211b9
@ -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<VideoSearchResult> {
|
||||
actual suspend fun searchVideos(query: String, kind: SearchKind): List<VideoSearchResult> {
|
||||
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 {
|
||||
|
||||
@ -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<VideoSearchResult>
|
||||
suspend fun searchVideos(query: String, kind: SearchKind = SearchKind.Songs): List<VideoSearchResult>
|
||||
suspend fun getVideoInfo(id: String): VideoInfo
|
||||
}
|
||||
@ -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<ISRCProviders>()
|
||||
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 <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 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<IFPISessionResponse>()
|
||||
val body = res.decodeLogged<IFPISessionResponse>("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<IFPIRecordingResponse>()
|
||||
val body = res.decodeLogged<IFPIRecordingResponse>("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<MusicGatewayResponse>()
|
||||
val body = res.decodeLogged<MusicGatewayResponse>("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<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
|
||||
}
|
||||
|
||||
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<Pair<String, suspend (MetadataTrack) -> 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
|
||||
}
|
||||
}
|
||||
@ -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<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> {
|
||||
|
||||
@ -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<VideoSearchResult> {
|
||||
actual suspend fun searchVideos(query: String, kind: SearchKind): List<VideoSearchResult> {
|
||||
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 }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user