refactor: optimize extraction performance & simplify code

+ Update NewPipeExtractor
+ Simplify NewPipeDownloaderImpl.kt implementation
+ Add parallel coroutines and avatar caching to StreamsExtractor.kt
+ Add batching to DeArrow API calls.

feat: optimize extraction performance & simplify code

+ fixes

spacing
This commit is contained in:
🤖 2025-02-18 04:09:16 -05:00 committed by Bnyro
parent 470c3bb714
commit 24a06351b2
No known key found for this signature in database
3 changed files with 118 additions and 106 deletions

View File

@ -13,17 +13,22 @@ import com.github.libretube.api.obj.Subtitle
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import com.github.libretube.helpers.PlayerHelper import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_FRONTEND_URL import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_FRONTEND_URL
import com.github.libretube.util.deArrow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import kotlinx.datetime.toKotlinInstant import kotlinx.datetime.toKotlinInstant
import org.schabi.newpipe.extractor.stream.AudioStream
import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.VideoStream import org.schabi.newpipe.extractor.stream.VideoStream
import retrofit2.HttpException import retrofit2.HttpException
import java.io.IOException import java.io.IOException
fun VideoStream.toPipedStream(): PipedStream = PipedStream( fun VideoStream.toPipedStream() = PipedStream(
url = content, url = content,
codec = codec, codec = codec,
format = format.toString(), format = format?.toString(),
height = height, height = height,
width = width, width = width,
quality = getResolution(), quality = getResolution(),
@ -37,14 +42,34 @@ fun VideoStream.toPipedStream(): PipedStream = PipedStream(
contentLength = itagItem?.contentLength ?: 0L contentLength = itagItem?.contentLength ?: 0L
) )
fun AudioStream.toPipedStream() = PipedStream(
url = content,
format = format?.toString(),
quality = "$averageBitrate bits",
bitrate = bitrate,
mimeType = format?.mimeType,
initStart = initStart,
initEnd = initEnd,
indexStart = indexStart,
indexEnd = indexEnd,
contentLength = itagItem?.contentLength ?: 0L,
codec = codec,
audioTrackId = audioTrackId,
audioTrackName = audioTrackName,
audioTrackLocale = audioLocale?.toLanguageTag(),
audioTrackType = audioTrackType?.name,
videoOnly = false
)
fun StreamInfoItem.toStreamItem( fun StreamInfoItem.toStreamItem(
uploaderAvatarUrl: String? = null uploaderAvatarUrl: String? = null
): StreamItem = StreamItem( ) = StreamItem(
type = StreamItem.TYPE_STREAM, type = StreamItem.TYPE_STREAM,
url = url.toID(), url = url.toID(),
title = name, title = name,
uploaded = uploadDate?.offsetDateTime()?.toEpochSecond()?.times(1000) ?: -1, uploaded = uploadDate?.offsetDateTime()?.toEpochSecond()?.times(1000) ?: -1,
uploadedDate = textualUploadDate ?: uploadDate?.offsetDateTime()?.toLocalDateTime()?.toLocalDate() uploadedDate = textualUploadDate ?: uploadDate?.offsetDateTime()?.toLocalDateTime()
?.toLocalDate()
?.toString(), ?.toString(),
uploaderName = uploaderName, uploaderName = uploaderName,
uploaderUrl = uploaderUrl.toID(), uploaderUrl = uploaderUrl.toID(),
@ -58,13 +83,22 @@ fun StreamInfoItem.toStreamItem(
) )
object StreamsExtractor { object StreamsExtractor {
suspend fun extractStreams(videoId: String): Streams { suspend fun extractStreams(videoId: String): Streams = withContext(Dispatchers.IO) {
if (!PlayerHelper.disablePipedProxy || !PlayerHelper.localStreamExtraction) { if (!PlayerHelper.disablePipedProxy || !PlayerHelper.localStreamExtraction) {
return RetrofitInstance.api.getStreams(videoId) return@withContext RetrofitInstance.api.getStreams(videoId).deArrow(videoId)
} }
val resp = StreamInfo.getInfo("${YOUTUBE_FRONTEND_URL}/watch?v=$videoId") val respAsync = async {
return Streams( StreamInfo.getInfo("$YOUTUBE_FRONTEND_URL/watch?v=$videoId")
}
val dislikesAsync = async {
if (PlayerHelper.localRYD) runCatching {
RetrofitInstance.externalApi.getVotes(videoId).dislikes
}.getOrElse { -1 } else -1
}
val (resp, dislikes) = Pair(respAsync.await(), dislikesAsync.await())
Streams(
title = resp.name, title = resp.name,
description = resp.description.content, description = resp.description.content,
uploader = resp.uploaderName, uploader = resp.uploaderName,
@ -75,9 +109,7 @@ object StreamsExtractor {
category = resp.category, category = resp.category,
views = resp.viewCount, views = resp.viewCount,
likes = resp.likeCount, likes = resp.likeCount,
dislikes = if (PlayerHelper.localRYD) runCatching { dislikes = dislikes,
RetrofitInstance.externalApi.getVotes(videoId).dislikes
}.getOrElse { -1 } else -1,
license = resp.licence, license = resp.licence,
hls = resp.hlsUrl, hls = resp.hlsUrl,
dash = resp.dashMpdUrl, dash = resp.dashMpdUrl,
@ -95,7 +127,9 @@ object StreamsExtractor {
uploadTimestamp = resp.uploadDate.offsetDateTime().toInstant().toKotlinInstant(), uploadTimestamp = resp.uploadDate.offsetDateTime().toInstant().toKotlinInstant(),
uploaded = resp.uploadDate.offsetDateTime().toEpochSecond() * 1000, uploaded = resp.uploadDate.offsetDateTime().toEpochSecond() * 1000,
thumbnailUrl = resp.thumbnails.maxBy { it.height }.url, thumbnailUrl = resp.thumbnails.maxBy { it.height }.url,
relatedStreams = resp.relatedItems.filterIsInstance<StreamInfoItem>().map(StreamInfoItem::toStreamItem), relatedStreams = resp.relatedItems
.filterIsInstance<StreamInfoItem>()
.map { item -> item.toStreamItem() },
chapters = resp.streamSegments.map { chapters = resp.streamSegments.map {
ChapterSegment( ChapterSegment(
title = it.title, title = it.title,
@ -103,31 +137,9 @@ object StreamsExtractor {
start = it.startTimeSeconds.toLong() start = it.startTimeSeconds.toLong()
) )
}, },
audioStreams = resp.audioStreams.map { audioStreams = resp.audioStreams.map { it.toPipedStream() },
PipedStream( videoStreams = resp.videoOnlyStreams.map { it.toPipedStream().copy(videoOnly = true) } +
url = it.content, resp.videoStreams.map { it.toPipedStream().copy(videoOnly = false) },
format = it.format?.toString(),
quality = "${it.averageBitrate} bits",
bitrate = it.bitrate,
mimeType = it.format?.mimeType,
initStart = it.initStart,
initEnd = it.initEnd,
indexStart = it.indexStart,
indexEnd = it.indexEnd,
contentLength = it.itagItem?.contentLength ?: 0L,
codec = it.codec,
audioTrackId = it.audioTrackId,
audioTrackName = it.audioTrackName,
audioTrackLocale = it.audioLocale?.toLanguageTag(),
audioTrackType = it.audioTrackType?.name,
videoOnly = false
)
},
videoStreams = resp.videoOnlyStreams.map {
it.toPipedStream().copy(videoOnly = true)
} + resp.videoStreams.map {
it.toPipedStream().copy(videoOnly = false)
},
previewFrames = resp.previewFrames.map { previewFrames = resp.previewFrames.map {
PreviewFrames( PreviewFrames(
it.urls, it.urls,
@ -148,7 +160,7 @@ object StreamsExtractor {
it.isAutoGenerated it.isAutoGenerated
) )
} }
) ).deArrow(videoId)
} }
fun getExtractorErrorMessageString(context: Context, exception: Exception): String { fun getExtractorErrorMessageString(context: Context, exception: Exception): String {
@ -157,6 +169,7 @@ object StreamsExtractor {
is HttpException -> exception.response()?.errorBody()?.string()?.runCatching { is HttpException -> exception.response()?.errorBody()?.string()?.runCatching {
JsonHelper.json.decodeFromString<Message>(this).message JsonHelper.json.decodeFromString<Message>(this).message
}?.getOrNull() ?: context.getString(R.string.server_error) }?.getOrNull() ?: context.getString(R.string.server_error)
else -> exception.localizedMessage.orEmpty() else -> exception.localizedMessage.orEmpty()
} }
} }

View File

@ -13,20 +13,22 @@ import java.util.TreeSet
object DeArrowUtil { object DeArrowUtil {
private fun extractTitleAndThumbnail(content: DeArrowContent): Pair<String?, String?> { private fun extractTitleAndThumbnail(content: DeArrowContent): Pair<String?, String?> {
val newTitle = content.titles.firstOrNull { it.votes >= 0 || it.locked }?.title val title = content.titles.firstOrNull { it.votes >= 0 || it.locked }?.title
val newThumbnail = content.thumbnails.firstOrNull { val thumbnail = content.thumbnails.firstOrNull {
it.thumbnail != null && !it.original && (it.votes >= 0 || it.locked) it.thumbnail != null && !it.original && (it.votes >= 0 || it.locked)
}?.thumbnail }?.thumbnail
return newTitle to newThumbnail
return title to thumbnail
} }
private suspend fun fetchDeArrowContent(videoIds: List<String>): Map<String, DeArrowContent>? { private suspend fun fetchDeArrowContent(videoIds: List<String>): Map<String, DeArrowContent>? {
val videoIdsString = videoIds.mapTo(TreeSet()) { it }.joinToString(",") val videoIdsString = videoIds.mapTo(TreeSet()) { it }.joinToString(",")
return try { return try {
RetrofitInstance.api.getDeArrowContent(videoIdsString) RetrofitInstance.api.getDeArrowContent(videoIdsString)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(this::class.java.name, e.toString()) Log.e(this::class.java.name, "Failed to fetch DeArrow content: ${e.message}")
null null
} }
} }
@ -37,19 +39,19 @@ object DeArrowUtil {
suspend fun deArrowStreams(streams: Streams, vidId: String): Streams { suspend fun deArrowStreams(streams: Streams, vidId: String): Streams {
if (!PreferenceHelper.getBoolean(PreferenceKeys.DEARROW, false)) return streams if (!PreferenceHelper.getBoolean(PreferenceKeys.DEARROW, false)) return streams
val videoIds = listOf(vidId) + streams.relatedStreams.map { it.url!!.toID() } val videoIds = listOf(vidId) + streams.relatedStreams.mapNotNull { it.url?.toID() }
val response = fetchDeArrowContent(videoIds) ?: return streams val response = fetchDeArrowContent(videoIds) ?: return streams
for ((videoId, data) in response.entries) { response[vidId]?.let { data ->
val (newTitle, newThumbnail) = extractTitleAndThumbnail(data) val (newTitle, newThumbnail) = extractTitleAndThumbnail(data)
if (videoId == vidId) {
if (newTitle != null) streams.title = newTitle if (newTitle != null) streams.title = newTitle
if (newThumbnail != null) streams.thumbnailUrl = newThumbnail if (newThumbnail != null) streams.thumbnailUrl = newThumbnail
} else { }
val streamItem = streams.relatedStreams
.firstOrNull { it.url?.toID() == videoId } ?: continue
streams.relatedStreams.forEach { streamItem ->
val videoId = streamItem.url?.toID() ?: return@forEach
response[videoId]?.let { data ->
val (newTitle, newThumbnail) = extractTitleAndThumbnail(data)
if (newTitle != null) streamItem.title = newTitle if (newTitle != null) streamItem.title = newTitle
if (newThumbnail != null) streamItem.thumbnail = newThumbnail if (newThumbnail != null) streamItem.thumbnail = newThumbnail
} }
@ -63,54 +65,53 @@ object DeArrowUtil {
*/ */
suspend fun deArrowStreamItems(streamItems: List<StreamItem>): List<StreamItem> { suspend fun deArrowStreamItems(streamItems: List<StreamItem>): List<StreamItem> {
if (!PreferenceHelper.getBoolean(PreferenceKeys.DEARROW, false)) return streamItems if (!PreferenceHelper.getBoolean(PreferenceKeys.DEARROW, false)) return streamItems
if (streamItems.isEmpty()) return streamItems
val response = fetchDeArrowContent(streamItems.map{ it.url!!.toID() }) ?: return streamItems val videoIds = streamItems.mapNotNull { it.url?.toID() }
val response = fetchDeArrowContent(videoIds) ?: return streamItems
for ((videoId, data) in response.entries) { streamItems.forEach { streamItem ->
val videoId = streamItem.url?.toID() ?: return@forEach
response[videoId]?.let { data ->
val (newTitle, newThumbnail) = extractTitleAndThumbnail(data) val (newTitle, newThumbnail) = extractTitleAndThumbnail(data)
val streamItem = streamItems.firstOrNull { it.url?.toID() == videoId } ?: continue
if (newTitle != null) streamItem.title = newTitle if (newTitle != null) streamItem.title = newTitle
if (newThumbnail != null) streamItem.thumbnail = newThumbnail if (newThumbnail != null) streamItem.thumbnail = newThumbnail
} }
}
return streamItems return streamItems
} }
/** /**
* Apply the new titles and thumbnails generated by DeArrow to the stream items * Apply the new titles and thumbnails generated by DeArrow to the content items
*/ */
suspend fun deArrowContentItems(contentItems: List<ContentItem>): List<ContentItem> { suspend fun deArrowContentItems(contentItems: List<ContentItem>): List<ContentItem> {
if (!PreferenceHelper.getBoolean(PreferenceKeys.DEARROW, false)) return contentItems if (!PreferenceHelper.getBoolean(PreferenceKeys.DEARROW, false)) return contentItems
val videoIds = contentItems.filter { it.type == "stream" } val videoIds = contentItems
.filter { it.type == "stream" }
.map { it.url.toID() } .map { it.url.toID() }
if (videoIds.isEmpty()) return contentItems if (videoIds.isEmpty()) return contentItems
val response = fetchDeArrowContent(videoIds) ?: return contentItems val response = fetchDeArrowContent(videoIds) ?: return contentItems
for ((videoId, data) in response.entries) { contentItems.forEach { contentItem ->
val videoId = contentItem.url.toID()
response[videoId]?.let { data ->
val (newTitle, newThumbnail) = extractTitleAndThumbnail(data) val (newTitle, newThumbnail) = extractTitleAndThumbnail(data)
val contentItem = contentItems.firstOrNull { it.url.toID() == videoId } ?: continue if (newTitle != null) contentItem.title = newTitle
if (newThumbnail != null) contentItem.thumbnail = newThumbnail
if (newTitle != null) { contentItem.title = newTitle }
if (newThumbnail != null) { contentItem.thumbnail = newThumbnail }
} }
}
return contentItems return contentItems
} }
} }
/**
* If enabled in the preferences, this overrides the video's thumbnail and title with the one
* provided by the DeArrow project
*/
@JvmName("deArrowStreamItems") @JvmName("deArrowStreamItems")
suspend fun List<StreamItem>.deArrow() = DeArrowUtil.deArrowStreamItems(this) suspend fun List<StreamItem>.deArrow() = DeArrowUtil.deArrowStreamItems(this)
/**
* If enabled in the preferences, this overrides the video's thumbnail and title with the one
* provided by the DeArrow project
*/
@JvmName("deArrowContentItems") @JvmName("deArrowContentItems")
suspend fun List<ContentItem>.deArrow() = DeArrowUtil.deArrowContentItems(this) suspend fun List<ContentItem>.deArrow() = DeArrowUtil.deArrowContentItems(this)

View File

@ -1,60 +1,58 @@
package com.github.libretube.util package com.github.libretube.util
import java.io.IOException
import java.util.concurrent.TimeUnit
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import org.schabi.newpipe.extractor.downloader.Downloader import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.extractor.downloader.Request import org.schabi.newpipe.extractor.downloader.Request
import org.schabi.newpipe.extractor.downloader.Response import org.schabi.newpipe.extractor.downloader.Response
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import java.io.IOException
class NewPipeDownloaderImpl : Downloader() { class NewPipeDownloaderImpl : Downloader() {
private val client: OkHttpClient = OkHttpClient.Builder() private val client = OkHttpClient.Builder()
.readTimeout(READ_TIMEOUT_SECONDS, TimeUnit.SECONDS)
.build() .build()
@Throws(IOException::class, ReCaptchaException::class) @Throws(IOException::class, ReCaptchaException::class)
override fun execute(request: Request): Response { override fun execute(request: Request): Response {
val httpMethod = request.httpMethod()
val url = request.url() val url = request.url()
val headers = request.headers()
val requestBody = request.dataToSend()?.let { val dataToSend = request.dataToSend()
it.toRequestBody(APPLICATION_JSON, 0, it.size)
}
val requestBuilder = okhttp3.Request.Builder() val requestBuilder = okhttp3.Request.Builder()
.method(request.httpMethod(), requestBody) .method(httpMethod, dataToSend?.toRequestBody())
.url(url) .url(url)
.addHeader(USER_AGENT_HEADER_NAME, USER_AGENT) .addHeader("User-Agent", USER_AGENT)
for ((headerName, headerValueList) in request.headers()) { for ((headerKey, headerValues) in headers) {
requestBuilder.removeHeader(headerName) requestBuilder.removeHeader(headerKey)
for (headerValue in headerValueList) { for (headerValue in headerValues) {
requestBuilder.addHeader(headerName, headerValue) requestBuilder.addHeader(headerKey, headerValue)
} }
} }
val response = client.newCall(requestBuilder.build()).execute() val response = client.newCall(requestBuilder.build()).execute()
if (response.code == CAPTCHA_STATUS_CODE) {
return when (response.code) {
429 -> {
response.close() response.close()
throw ReCaptchaException("reCaptcha Challenge requested", url) throw ReCaptchaException("reCaptcha Challenge requested", url)
} }
return Response( else -> {
val responseBodyToReturn = response.body?.string()
Response(
response.code, response.code,
response.message, response.message,
response.headers.toMultimap(), response.headers.toMultimap(),
response.body?.string(), responseBodyToReturn,
response.request.url.toString() response.request.url.toString()
) )
} }
}
}
companion object { companion object {
private const val USER_AGENT_HEADER_NAME = "User-Agent" private const val USER_AGENT =
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; rv:102.0) Gecko/20100101 Firefox/102.0" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0"
private const val CAPTCHA_STATUS_CODE = 429
private val APPLICATION_JSON = "application/json".toMediaType()
private const val READ_TIMEOUT_SECONDS = 30L
} }
} }