feat: add support for using local NewPipe Extractor

This commit is contained in:
Bnyro 2025-03-01 20:03:51 +01:00
parent 1dabd07de4
commit 7db8212c72
No known key found for this signature in database
19 changed files with 559 additions and 225 deletions

View File

@ -0,0 +1,7 @@
package com.github.libretube.api
class LocalStreamsExtractionPipedMediaServiceRepository: PipedMediaServiceRepository() {
private val newPipeDelegate = NewPipeMediaServiceRepository()
override suspend fun getStreams(videoId: String) = newPipeDelegate.getStreams(videoId)
}

View File

@ -9,16 +9,27 @@ import com.github.libretube.api.obj.SearchResult
import com.github.libretube.api.obj.SegmentData
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.api.obj.Streams
import com.github.libretube.helpers.PlayerHelper
interface MediaServiceRepository {
suspend fun getTrending(region: String): List<StreamItem>
suspend fun getStreams(videoId: String): Streams
suspend fun getComments(videoId: String): CommentsPage
suspend fun getSegments(videoId: String, category: String, actionType: String? = null): SegmentData
suspend fun getSegments(
videoId: String,
category: String,
actionType: String? = null
): SegmentData
suspend fun getDeArrowContent(videoIds: String): Map<String, DeArrowContent>
suspend fun getCommentsNextPage(videoId: String, nextPage: String): CommentsPage
suspend fun getSearchResults(searchQuery: String, filter: String): SearchResult
suspend fun getSearchResultsNextPage(searchQuery: String, filter: String, nextPage: String): SearchResult
suspend fun getSearchResultsNextPage(
searchQuery: String,
filter: String,
nextPage: String
): SearchResult
suspend fun getSuggestions(query: String): List<String>
suspend fun getChannel(channelId: String): Channel
suspend fun getChannelTab(data: String, nextPage: String? = null): ChannelTabResponse
@ -29,7 +40,12 @@ interface MediaServiceRepository {
companion object {
val instance by lazy {
PipedMediaServiceRepository()
if (PlayerHelper.disablePipedProxy && PlayerHelper.localStreamExtraction) {
// TODO: LocalStreamsExtractionPipedMediaServiceRepository()
NewPipeMediaServiceRepository()
} else {
PipedMediaServiceRepository()
}
}
}
}

View File

@ -0,0 +1,467 @@
package com.github.libretube.api
import com.github.libretube.api.obj.Channel
import com.github.libretube.api.obj.ChannelTab
import com.github.libretube.api.obj.ChannelTabResponse
import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.api.obj.Comment
import com.github.libretube.api.obj.CommentsPage
import com.github.libretube.api.obj.ContentItem
import com.github.libretube.api.obj.DeArrowContent
import com.github.libretube.api.obj.MetaInfo
import com.github.libretube.api.obj.PipedStream
import com.github.libretube.api.obj.Playlist
import com.github.libretube.api.obj.PreviewFrames
import com.github.libretube.api.obj.SearchResult
import com.github.libretube.api.obj.SegmentData
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.api.obj.StreamItem.Companion.TYPE_CHANNEL
import com.github.libretube.api.obj.StreamItem.Companion.TYPE_PLAYLIST
import com.github.libretube.api.obj.StreamItem.Companion.TYPE_STREAM
import com.github.libretube.api.obj.Streams
import com.github.libretube.api.obj.Subtitle
import com.github.libretube.extensions.toID
import com.github.libretube.helpers.NewPipeExtractorInstance
import com.github.libretube.helpers.PlayerHelper
import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_FRONTEND_URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.withContext
import kotlinx.datetime.toKotlinInstant
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import org.schabi.newpipe.extractor.InfoItem
import org.schabi.newpipe.extractor.Page
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
import org.schabi.newpipe.extractor.comments.CommentsInfo
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
import org.schabi.newpipe.extractor.kiosk.KioskInfo
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler
import org.schabi.newpipe.extractor.localization.ContentCountry
import org.schabi.newpipe.extractor.playlist.PlaylistInfo
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
import org.schabi.newpipe.extractor.search.SearchInfo
import org.schabi.newpipe.extractor.stream.AudioStream
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.VideoStream
private fun VideoStream.toPipedStream() = PipedStream(
url = content,
codec = codec,
format = format?.toString(),
height = height,
width = width,
quality = getResolution(),
mimeType = format?.mimeType,
bitrate = bitrate,
initStart = initStart,
initEnd = initEnd,
indexStart = indexStart,
indexEnd = indexEnd,
fps = fps,
contentLength = itagItem?.contentLength ?: 0L
)
private 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(
uploaderAvatarUrl: String? = null
) = StreamItem(
type = TYPE_STREAM,
url = url.toID(),
title = name,
uploaded = uploadDate?.offsetDateTime()?.toEpochSecond()?.times(1000) ?: -1,
uploadedDate = textualUploadDate ?: uploadDate?.offsetDateTime()?.toLocalDateTime()
?.toLocalDate()
?.toString(),
uploaderName = uploaderName,
uploaderUrl = uploaderUrl.toID(),
uploaderAvatar = uploaderAvatarUrl ?: uploaderAvatars.maxByOrNull { it.height }?.url,
thumbnail = thumbnails.maxByOrNull { it.height }?.url,
duration = duration,
views = viewCount,
uploaderVerified = isUploaderVerified,
shortDescription = shortDescription,
isShort = isShortFormContent
)
fun InfoItem.toContentItem() = when (this) {
is StreamInfoItem -> ContentItem(
url = url.toID(),
type = TYPE_STREAM,
thumbnail = thumbnails.maxByOrNull { it.height }?.url.orEmpty(),
title = name,
uploaderAvatar = uploaderAvatars.maxByOrNull { it.height }?.url.orEmpty(),
uploaderUrl = uploaderUrl.toID(),
uploaderName = uploaderName,
uploaded = uploadDate?.offsetDateTime()?.toInstant()?.toEpochMilli() ?: -1,
isShort = isShortFormContent,
views = viewCount,
shortDescription = shortDescription,
verified = isUploaderVerified,
duration = duration
)
is ChannelInfoItem -> ContentItem(
url = url.toID(),
name = name,
type = TYPE_CHANNEL,
thumbnail = thumbnails.maxByOrNull { it.height }?.url.orEmpty(),
subscribers = subscriberCount,
videos = streamCount
)
is PlaylistInfoItem -> ContentItem(
url = url.toID(),
type = TYPE_PLAYLIST,
title = name,
shortDescription = description.content,
thumbnail = thumbnails.maxByOrNull { it.height }?.url.orEmpty(),
videos = streamCount,
uploaderVerified = isUploaderVerified,
uploaderName = uploaderName,
uploaderUrl = uploaderUrl?.toID()
)
else -> null
}
fun ChannelInfo.toChannel() = Channel(
id = id,
name = name,
description = description,
verified = isVerified,
avatarUrl = avatars.maxByOrNull { it.height }?.url,
bannerUrl = banners.maxByOrNull { it.height }?.url,
tabs = tabs.filterNot { it.contentFilters.contains(ChannelTabs.VIDEOS) }
.map { ChannelTab(it.contentFilters.first().lowercase(), it.toTabDataString()) },
subscriberCount = subscriberCount
)
fun PlaylistInfo.toPlaylist() = Playlist(
name = name,
description = description?.content,
thumbnailUrl = thumbnails.maxByOrNull { it.height }?.url,
uploaderUrl = uploaderUrl.toID(),
bannerUrl = banners.maxByOrNull { it.height }?.url,
uploader = uploaderName,
uploaderAvatar = uploaderAvatars.maxByOrNull { it.height }?.url,
videos = streamCount.toInt(),
relatedStreams = relatedItems.map { it.toStreamItem() },
nextpage = nextPage?.toNextPageString()
)
fun CommentsInfoItem.toComment() = Comment(
author = uploaderName,
commentId = commentId,
commentText = commentText.content,
commentedTime = textualUploadDate,
commentorUrl = uploaderUrl.toID(),
hearted = isHeartedByUploader,
creatorReplied = hasCreatorReply(),
likeCount = likeCount.toLong(),
pinned = isPinned,
verified = isUploaderVerified,
replyCount = replyCount.toLong(),
repliesPage = replies?.toNextPageString(),
thumbnail = thumbnails.maxByOrNull { it.height }?.url.orEmpty()
)
// the following classes are necessary because kotlinx can't deserialize
// classes from external libraries as they're not annotated
@Serializable
private data class NextPage(
val url: String? = null,
val id: String? = null,
val ids: List<String>? = null,
val cookies: Map<String, String>? = null,
val body: String? = null
)
fun Page.toNextPageString() = JsonHelper.json.encodeToString(
NextPage(url, id, ids, cookies, body?.toString())
)
fun String.toPage(): Page = with(JsonHelper.json.decodeFromString<NextPage>(this)) {
return Page(url, id, ids, cookies, body?.toByteArray())
}
@Serializable
private data class TabData(
val originalUrl: String? = null,
val url: String? = null,
val id: String? = null,
val contentFilters: List<String>? = null,
val sortFilter: String? = null,
)
fun ListLinkHandler.toTabDataString() = JsonHelper.json.encodeToString(
TabData(originalUrl, url, id, contentFilters, sortFilter)
)
fun String.toListLinkHandler() = with(JsonHelper.json.decodeFromString<TabData>(this)) {
ListLinkHandler(originalUrl, url, id, contentFilters, sortFilter)
}
class NewPipeMediaServiceRepository : MediaServiceRepository {
override suspend fun getTrending(region: String): List<StreamItem> {
val kioskList = NewPipeExtractorInstance.extractor.kioskList
kioskList.forceContentCountry(ContentCountry(region))
val extractor = kioskList.defaultKioskExtractor
extractor.fetchPage()
val info = KioskInfo.getInfo(extractor)
return info.relatedItems.filterIsInstance<StreamInfoItem>().map { it.toStreamItem() }
}
override suspend fun getStreams(videoId: String): Streams = withContext(Dispatchers.IO) {
val respAsync = async {
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,
description = resp.description.content,
uploader = resp.uploaderName,
uploaderAvatar = resp.uploaderAvatars.maxBy { it.height }.url,
uploaderUrl = resp.uploaderUrl.toID(),
uploaderVerified = resp.isUploaderVerified,
uploaderSubscriberCount = resp.uploaderSubscriberCount,
category = resp.category,
views = resp.viewCount,
likes = resp.likeCount,
dislikes = dislikes,
license = resp.licence,
hls = resp.hlsUrl,
dash = resp.dashMpdUrl,
tags = resp.tags,
metaInfo = resp.metaInfo.map {
MetaInfo(
it.title,
it.content.content,
it.urls.map { url -> url.toString() },
it.urlTexts
)
},
visibility = resp.privacy.name.lowercase(),
duration = resp.duration,
uploadTimestamp = resp.uploadDate.offsetDateTime().toInstant().toKotlinInstant(),
uploaded = resp.uploadDate.offsetDateTime().toEpochSecond() * 1000,
thumbnailUrl = resp.thumbnails.maxBy { it.height }.url,
relatedStreams = resp.relatedItems
.filterIsInstance<StreamInfoItem>()
.map { item -> item.toStreamItem() },
chapters = resp.streamSegments.map {
ChapterSegment(
title = it.title,
image = it.previewUrl.orEmpty(),
start = it.startTimeSeconds.toLong()
)
},
audioStreams = resp.audioStreams.map { it.toPipedStream() },
videoStreams = resp.videoOnlyStreams.map { it.toPipedStream().copy(videoOnly = true) } +
resp.videoStreams.map { it.toPipedStream().copy(videoOnly = false) },
previewFrames = resp.previewFrames.map {
PreviewFrames(
it.urls,
it.frameWidth,
it.frameHeight,
it.totalCount,
it.durationPerFrame.toLong(),
it.framesPerPageX,
it.framesPerPageY
)
},
subtitles = resp.subtitles.map {
Subtitle(
it.content,
it.format?.mimeType,
it.displayLanguageName,
it.languageTag,
it.isAutoGenerated
)
}
)
}
override suspend fun getSegments(
videoId: String,
category: String,
actionType: String?
): SegmentData = SegmentData()
override suspend fun getDeArrowContent(videoIds: String): Map<String, DeArrowContent> =
emptyMap()
override suspend fun getSearchResults(searchQuery: String, filter: String): SearchResult {
val queryHandler = NewPipeExtractorInstance.extractor.searchQHFactory.fromQuery(
searchQuery,
listOf(filter),
null
)
val searchInfo = SearchInfo.getInfo(NewPipeExtractorInstance.extractor, queryHandler)
return SearchResult(
items = searchInfo.relatedItems.mapNotNull { it.toContentItem() },
nextpage = searchInfo.nextPage?.toNextPageString()
)
}
override suspend fun getSearchResultsNextPage(
searchQuery: String,
filter: String,
nextPage: String
): SearchResult {
val queryHandler = NewPipeExtractorInstance.extractor.searchQHFactory.fromQuery(
searchQuery,
listOf(filter),
null
)
val searchInfo = SearchInfo.getMoreItems(
NewPipeExtractorInstance.extractor,
queryHandler,
nextPage.toPage()
)
return SearchResult(
items = searchInfo.items.mapNotNull { it.toContentItem() },
nextpage = searchInfo.nextPage?.toNextPageString()
)
}
override suspend fun getSuggestions(query: String): List<String> {
return NewPipeExtractorInstance.extractor.suggestionExtractor.suggestionList(query)
}
private suspend fun getLatestVideos(channelInfo: ChannelInfo): Pair<List<StreamItem>, String?> {
val relatedTab = channelInfo.tabs.find { it.contentFilters.contains(ChannelTabs.VIDEOS) }
if (relatedTab != null) {
val relatedStreamsResp = getChannelTab(relatedTab.toTabDataString())
return relatedStreamsResp.content.map { it.toStreamItem() } to relatedStreamsResp.nextpage
}
return emptyList<StreamItem>() to null
}
override suspend fun getChannel(channelId: String): Channel {
val channelUrl = "$YOUTUBE_FRONTEND_URL/channel/${channelId}"
val channelInfo = ChannelInfo.getInfo(NewPipeExtractorInstance.extractor, channelUrl)
val channel = channelInfo.toChannel()
val relatedVideos = getLatestVideos(channelInfo)
channel.relatedStreams = relatedVideos.first
channel.nextpage = relatedVideos.second
return channel
}
override suspend fun getChannelTab(data: String, nextPage: String?): ChannelTabResponse {
val linkListHandler = data.toListLinkHandler()
val resp = ChannelTabInfo.getInfo(NewPipeExtractorInstance.extractor, linkListHandler)
val newNextPage = resp.nextPage?.toNextPageString()
val items = resp.relatedItems
.mapNotNull { it.toContentItem() }
return ChannelTabResponse(items, newNextPage)
}
override suspend fun getChannelByName(channelName: String): Channel {
val channelUrl = "$YOUTUBE_FRONTEND_URL/c/${channelName}"
val channelInfo = ChannelInfo.getInfo(NewPipeExtractorInstance.extractor, channelUrl)
val channel = channelInfo.toChannel()
val relatedVideos = getLatestVideos(channelInfo)
channel.relatedStreams = relatedVideos.first
channel.nextpage = relatedVideos.second
return channel
}
override suspend fun getChannelNextPage(channelId: String, nextPage: String): Channel {
val url = "${YOUTUBE_FRONTEND_URL}/channel/${channelId}/videos"
val listLinkHandler = ListLinkHandler(url, url, channelId, listOf("videos"), "")
val tab = getChannelTab(listLinkHandler.toTabDataString(), nextPage)
return Channel(
relatedStreams = tab.content.map { it.toStreamItem() },
nextpage = tab.nextpage
)
}
override suspend fun getPlaylist(playlistId: String): Playlist {
val playlistUrl = "${YOUTUBE_FRONTEND_URL}/playlist?list=${playlistId}"
val playlistInfo = PlaylistInfo.getInfo(playlistUrl)
return playlistInfo.toPlaylist()
}
override suspend fun getPlaylistNextPage(playlistId: String, nextPage: String): Playlist {
val playlistUrl = "${YOUTUBE_FRONTEND_URL}/playlist?list=${playlistId}"
val playlistInfo = PlaylistInfo.getMoreItems(
NewPipeExtractorInstance.extractor,
playlistUrl,
nextPage.toPage()
)
return Playlist(
relatedStreams = playlistInfo.items.map { it.toStreamItem() },
nextpage = playlistInfo.nextPage?.toNextPageString()
)
}
override suspend fun getComments(videoId: String): CommentsPage {
val url = "${YOUTUBE_FRONTEND_URL}/watch?v=$videoId"
val commentsInfo = CommentsInfo.getInfo(url)
return CommentsPage(
nextpage = commentsInfo.nextPage?.toNextPageString(),
disabled = commentsInfo.isCommentsDisabled,
commentCount = commentsInfo.commentsCount.toLong(),
comments = commentsInfo.relatedItems.map { it.toComment() }
)
}
override suspend fun getCommentsNextPage(videoId: String, nextPage: String): CommentsPage {
val url = "${YOUTUBE_FRONTEND_URL}/watch?v=$videoId"
val commentsInfo = CommentsInfo.getMoreItems(
NewPipeExtractorInstance.extractor,
url,
nextPage.toPage()
)
return CommentsPage(
nextpage = commentsInfo.nextPage?.toNextPageString(),
comments = commentsInfo.items.map { it.toComment() }
)
}
}

View File

@ -5,6 +5,7 @@ import com.github.libretube.api.obj.Channel
import com.github.libretube.api.obj.ChannelTabResponse
import com.github.libretube.api.obj.CommentsPage
import com.github.libretube.api.obj.DeArrowContent
import com.github.libretube.api.obj.Message
import com.github.libretube.api.obj.Playlist
import com.github.libretube.api.obj.SearchResult
import com.github.libretube.api.obj.SegmentData
@ -12,13 +13,23 @@ import com.github.libretube.api.obj.StreamItem
import com.github.libretube.api.obj.Streams
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.helpers.PreferenceHelper
import retrofit2.HttpException
class PipedMediaServiceRepository : MediaServiceRepository {
open class PipedMediaServiceRepository : MediaServiceRepository {
override suspend fun getTrending(region: String): List<StreamItem> =
api.getTrending(region)
override suspend fun getStreams(videoId: String): Streams =
api.getStreams(videoId)
override suspend fun getStreams(videoId: String): Streams {
return try {
api.getStreams(videoId)
} catch (e: HttpException) {
val errorMessage = e.response()?.errorBody()?.string()?.runCatching {
JsonHelper.json.decodeFromString<Message>(this).message
}?.getOrNull()
throw Exception(errorMessage)
}
}
override suspend fun getComments(videoId: String): CommentsPage =
api.getComments(videoId)

View File

@ -1,176 +0,0 @@
package com.github.libretube.api
import android.content.Context
import com.github.libretube.R
import com.github.libretube.api.obj.ChapterSegment
import com.github.libretube.api.obj.Message
import com.github.libretube.api.obj.MetaInfo
import com.github.libretube.api.obj.PipedStream
import com.github.libretube.api.obj.PreviewFrames
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.api.obj.Streams
import com.github.libretube.api.obj.Subtitle
import com.github.libretube.extensions.toID
import com.github.libretube.helpers.PlayerHelper
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 org.schabi.newpipe.extractor.stream.AudioStream
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.VideoStream
import retrofit2.HttpException
import java.io.IOException
fun VideoStream.toPipedStream() = PipedStream(
url = content,
codec = codec,
format = format?.toString(),
height = height,
width = width,
quality = getResolution(),
mimeType = format?.mimeType,
bitrate = bitrate,
initStart = initStart,
initEnd = initEnd,
indexStart = indexStart,
indexEnd = indexEnd,
fps = fps,
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(
uploaderAvatarUrl: String? = null
) = StreamItem(
type = StreamItem.TYPE_STREAM,
url = url.toID(),
title = name,
uploaded = uploadDate?.offsetDateTime()?.toEpochSecond()?.times(1000) ?: -1,
uploadedDate = textualUploadDate ?: uploadDate?.offsetDateTime()?.toLocalDateTime()
?.toLocalDate()
?.toString(),
uploaderName = uploaderName,
uploaderUrl = uploaderUrl.toID(),
uploaderAvatar = uploaderAvatarUrl ?: uploaderAvatars.maxByOrNull { it.height }?.url,
thumbnail = thumbnails.maxByOrNull { it.height }?.url,
duration = duration,
views = viewCount,
uploaderVerified = isUploaderVerified,
shortDescription = shortDescription,
isShort = isShortFormContent
)
object StreamsExtractor {
suspend fun extractStreams(videoId: String): Streams = withContext(Dispatchers.IO) {
if (!PlayerHelper.disablePipedProxy || !PlayerHelper.localStreamExtraction) {
return@withContext MediaServiceRepository.instance.getStreams(videoId).deArrow(videoId)
}
val respAsync = async {
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,
description = resp.description.content,
uploader = resp.uploaderName,
uploaderAvatar = resp.uploaderAvatars.maxBy { it.height }.url,
uploaderUrl = resp.uploaderUrl.toID(),
uploaderVerified = resp.isUploaderVerified,
uploaderSubscriberCount = resp.uploaderSubscriberCount,
category = resp.category,
views = resp.viewCount,
likes = resp.likeCount,
dislikes = dislikes,
license = resp.licence,
hls = resp.hlsUrl,
dash = resp.dashMpdUrl,
tags = resp.tags,
metaInfo = resp.metaInfo.map {
MetaInfo(
it.title,
it.content.content,
it.urls.map { url -> url.toString() },
it.urlTexts
)
},
visibility = resp.privacy.name.lowercase(),
duration = resp.duration,
uploadTimestamp = resp.uploadDate.offsetDateTime().toInstant().toKotlinInstant(),
uploaded = resp.uploadDate.offsetDateTime().toEpochSecond() * 1000,
thumbnailUrl = resp.thumbnails.maxBy { it.height }.url,
relatedStreams = resp.relatedItems
.filterIsInstance<StreamInfoItem>()
.map { item -> item.toStreamItem() },
chapters = resp.streamSegments.map {
ChapterSegment(
title = it.title,
image = it.previewUrl.orEmpty(),
start = it.startTimeSeconds.toLong()
)
},
audioStreams = resp.audioStreams.map { it.toPipedStream() },
videoStreams = resp.videoOnlyStreams.map { it.toPipedStream().copy(videoOnly = true) } +
resp.videoStreams.map { it.toPipedStream().copy(videoOnly = false) },
previewFrames = resp.previewFrames.map {
PreviewFrames(
it.urls,
it.frameWidth,
it.frameHeight,
it.totalCount,
it.durationPerFrame.toLong(),
it.framesPerPageX,
it.framesPerPageY
)
},
subtitles = resp.subtitles.map {
Subtitle(
it.content,
it.format?.mimeType,
it.displayLanguageName,
it.languageTag,
it.isAutoGenerated
)
}
).deArrow(videoId)
}
fun getExtractorErrorMessageString(context: Context, exception: Exception): String {
return when (exception) {
is IOException -> context.getString(R.string.unknown_error)
is HttpException -> exception.response()?.errorBody()?.string()?.runCatching {
JsonHelper.json.decodeFromString<Message>(this).message
}?.getOrNull() ?: context.getString(R.string.server_error)
else -> exception.localizedMessage.orEmpty()
}
}
}

View File

@ -9,7 +9,7 @@ data class Channel(
val avatarUrl: String? = null,
val bannerUrl: String? = null,
val description: String? = null,
val nextpage: String? = null,
var nextpage: String? = null,
val subscriberCount: Long = 0,
val verified: Boolean = false,
var relatedStreams: List<StreamItem> = emptyList(),

View File

@ -6,6 +6,4 @@ import kotlinx.serialization.Serializable
data class SearchResult(
var items: List<ContentItem> = emptyList(),
val nextpage: String? = null,
val suggestion: String? = null,
val corrected: Boolean? = null
)

View File

@ -3,7 +3,6 @@ package com.github.libretube.repo
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.api.PlaylistsHelper.MAX_CONCURRENT_IMPORT_CALLS
import com.github.libretube.api.StreamsExtractor
import com.github.libretube.api.obj.Playlist
import com.github.libretube.api.obj.Playlists
import com.github.libretube.api.obj.StreamItem
@ -125,7 +124,7 @@ class LocalPlaylistsRepository: PlaylistRepository {
// Only do so with `MAX_CONCURRENT_IMPORT_CALLS` videos at once to prevent performance issues
for (videoIdList in playlist.videos.chunked(MAX_CONCURRENT_IMPORT_CALLS)) {
val streams = videoIdList.parallelMap {
runCatching { StreamsExtractor.extractStreams(it) }
runCatching { MediaServiceRepository.instance.getStreams(it) }
.getOrNull()
?.toStreamItem(it)
}.filterNotNull()

View File

@ -20,7 +20,7 @@ import androidx.lifecycle.LifecycleService
import androidx.lifecycle.lifecycleScope
import com.github.libretube.LibreTubeApp.Companion.DOWNLOAD_CHANNEL_NAME
import com.github.libretube.R
import com.github.libretube.api.StreamsExtractor
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.obj.Streams
import com.github.libretube.constants.IntentData
import com.github.libretube.db.DatabaseHolder.Database
@ -124,12 +124,13 @@ class DownloadService : LifecycleService() {
lifecycleScope.launch(coroutineContext) {
val streams = try {
withContext(Dispatchers.IO) {
StreamsExtractor.extractStreams(videoId)
MediaServiceRepository.instance.getStreams(videoId)
}
} catch (e: IOException) {
toastFromMainDispatcher(getString(R.string.unknown_error))
return@launch
} catch (e: Exception) {
toastFromMainDispatcher(
StreamsExtractor.getExtractorErrorMessageString(this@DownloadService, e)
)
toastFromMainDispatcher(e.message ?: getString(R.string.server_error))
return@launch
}
@ -443,7 +444,7 @@ class DownloadService : LifecycleService() {
*/
private suspend fun regenerateLink(item: DownloadItem) {
val streams = runCatching {
StreamsExtractor.extractStreams(item.videoId)
MediaServiceRepository.instance.getStreams(item.videoId)
}.getOrNull() ?: return
val stream = when (item.type) {
FileType.AUDIO -> streams.audioStreams

View File

@ -13,7 +13,6 @@ import androidx.media3.exoplayer.hls.HlsMediaSource
import com.github.libretube.R
import com.github.libretube.api.JsonHelper
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.StreamsExtractor
import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.api.obj.Segment
import com.github.libretube.api.obj.Streams
@ -37,6 +36,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
import java.io.IOException
/**
* Loads the selected videos audio in background mode with a notification area.
@ -119,11 +119,12 @@ open class OnlinePlayerService : AbstractPlayerService() {
streams = withContext(Dispatchers.IO) {
try {
StreamsExtractor.extractStreams(videoId)
MediaServiceRepository.instance.getStreams(videoId)
} catch (e: IOException) {
toastFromMainDispatcher(getString(R.string.unknown_error))
return@withContext null
} catch (e: Exception) {
val errorMessage =
StreamsExtractor.getExtractorErrorMessageString(this@OnlinePlayerService, e)
this@OnlinePlayerService.toastFromMainDispatcher(errorMessage)
toastFromMainDispatcher(e.message ?: getString(R.string.server_error))
return@withContext null
}
} ?: return

View File

@ -12,7 +12,6 @@ import com.github.libretube.LibreTubeApp.Companion.PLAYLIST_DOWNLOAD_ENQUEUE_CHA
import com.github.libretube.R
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.api.StreamsExtractor
import com.github.libretube.api.obj.PipedStream
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.constants.IntentData
@ -137,7 +136,7 @@ class PlaylistDownloadEnqueueService : LifecycleService() {
for (stream in streams) {
val videoInfo = runCatching {
StreamsExtractor.extractStreams(stream.url!!.toID())
MediaServiceRepository.instance.getStreams(stream.url!!.toID())
}.getOrNull() ?: continue
val videoStream = getStream(videoInfo.videoStreams, maxVideoQuality)

View File

@ -6,7 +6,7 @@ import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.lifecycle.lifecycleScope
import com.github.libretube.R
import com.github.libretube.api.StreamsExtractor
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.constants.IntentData
import com.github.libretube.extensions.toastFromMainDispatcher
@ -41,7 +41,7 @@ class AddToPlaylistActivity : BaseActivity() {
lifecycleScope.launch(Dispatchers.IO) {
val videoInfo = if (PreferenceHelper.getToken().isEmpty()) {
try {
StreamsExtractor.extractStreams(videoId).toStreamItem(videoId)
MediaServiceRepository.instance.getStreams(videoId).toStreamItem(videoId)
} catch (e: Exception) {
toastFromMainDispatcher(R.string.unknown_error)
withContext(Dispatchers.Main) {

View File

@ -159,12 +159,10 @@ class SearchChannelAdapter : ListAdapter<ContentItem, SearchViewHolder>(
}
root.setOnLongClickListener {
val playlistId = item.url.toID()
val playlistName = item.name!!
val sheet = PlaylistOptionsBottomSheet()
sheet.arguments = bundleOf(
IntentData.playlistId to playlistId,
IntentData.playlistName to playlistName,
IntentData.playlistId to item.url.toID(),
IntentData.playlistName to item.name.orEmpty(),
IntentData.playlistType to PlaylistType.PUBLIC
)
sheet.show(

View File

@ -5,7 +5,6 @@ import android.content.DialogInterface
import android.os.Bundle
import android.text.InputFilter
import android.text.format.Formatter
import android.util.Log
import android.widget.Toast
import androidx.core.os.bundleOf
import androidx.core.view.isGone
@ -13,13 +12,12 @@ import androidx.fragment.app.DialogFragment
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.lifecycleScope
import com.github.libretube.R
import com.github.libretube.api.StreamsExtractor
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.obj.PipedStream
import com.github.libretube.api.obj.Streams
import com.github.libretube.api.obj.Subtitle
import com.github.libretube.constants.IntentData
import com.github.libretube.databinding.DialogDownloadBinding
import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.getWhileDigit
import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.helpers.DownloadHelper
@ -30,6 +28,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.IOException
class DownloadDialog : DialogFragment() {
private lateinit var videoId: String
@ -80,13 +79,13 @@ class DownloadDialog : DialogFragment() {
lifecycleScope.launch {
val response = try {
withContext(Dispatchers.IO) {
StreamsExtractor.extractStreams(videoId)
MediaServiceRepository.instance.getStreams(videoId)
}
} catch (e: IOException) {
context?.toastFromMainDispatcher(getString(R.string.unknown_error))
return@launch
} catch (e: Exception) {
Log.e(TAG(), e.stackTraceToString())
val context = context ?: return@launch
val errorMessage = StreamsExtractor.getExtractorErrorMessageString(context, e)
context.toastFromMainDispatcher(errorMessage)
context?.toastFromMainDispatcher(e.message ?: getString(R.string.server_error))
return@launch
}
initDownloadOptions(binding, response)

View File

@ -4,6 +4,8 @@ import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.obj.Comment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class CommentPagingSource(
private val videoId: String,
@ -13,9 +15,11 @@ class CommentPagingSource(
override suspend fun load(params: LoadParams<String>): LoadResult<String, Comment> {
return try {
val result = params.key?.let {
MediaServiceRepository.instance.getCommentsNextPage(videoId, it)
} ?: MediaServiceRepository.instance.getComments(videoId)
val result = withContext(Dispatchers.IO) {
params.key?.let {
MediaServiceRepository.instance.getCommentsNextPage(videoId, it)
} ?: MediaServiceRepository.instance.getComments(videoId)
}
if (result.commentCount > 0) onCommentCount(result.commentCount)

View File

@ -4,6 +4,8 @@ import androidx.paging.PagingSource
import androidx.paging.PagingState
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.obj.Comment
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class CommentRepliesPagingSource(
private val videoId: String,
@ -14,7 +16,9 @@ class CommentRepliesPagingSource(
override suspend fun load(params: LoadParams<String>): LoadResult<String, Comment> {
return try {
val key = params.key.orEmpty().ifEmpty { originalComment.repliesPage.orEmpty() }
val result = MediaServiceRepository.instance.getCommentsNextPage(videoId, key)
val result = withContext(Dispatchers.IO) {
MediaServiceRepository.instance.getCommentsNextPage(videoId, key)
}
val replies = result.comments.toMutableList()
if (params.key.isNullOrEmpty()) {

View File

@ -5,6 +5,8 @@ import androidx.paging.PagingState
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.obj.ContentItem
import com.github.libretube.util.deArrow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class SearchPagingSource(
private val searchQuery: String,
@ -14,9 +16,14 @@ class SearchPagingSource(
override suspend fun load(params: LoadParams<String>): LoadResult<String, ContentItem> {
return try {
val result = params.key?.let {
MediaServiceRepository.instance.getSearchResultsNextPage(searchQuery, searchFilter, it)
} ?: MediaServiceRepository.instance.getSearchResults(searchQuery, searchFilter)
val result = withContext(Dispatchers.IO) {
params.key?.let {
MediaServiceRepository.instance.getSearchResultsNextPage(
searchQuery, searchFilter, it
)
} ?: MediaServiceRepository.instance.getSearchResults(searchQuery, searchFilter)
}
LoadResult.Page(result.items.deArrow(), null, result.nextpage)
} catch (e: Exception) {
LoadResult.Error(e)

View File

@ -5,6 +5,7 @@ import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.obj.ContentItem
import com.github.libretube.api.obj.DeArrowContent
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.api.obj.StreamItem.Companion.TYPE_STREAM
import com.github.libretube.api.obj.Streams
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.extensions.toID
@ -89,7 +90,7 @@ object DeArrowUtil {
if (!PreferenceHelper.getBoolean(PreferenceKeys.DEARROW, false)) return contentItems
val videoIds = contentItems
.filter { it.type == "stream" }
.filter { it.type == TYPE_STREAM }
.map { it.url.toID() }
if (videoIds.isEmpty()) return contentItems

View File

@ -3,8 +3,6 @@ package com.github.libretube.util
import androidx.media3.common.Player
import com.github.libretube.api.MediaServiceRepository
import com.github.libretube.api.PlaylistsHelper
import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.StreamsExtractor
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.extensions.move
import com.github.libretube.extensions.runCatchingIO
@ -194,7 +192,7 @@ object PlayingQueue {
}.let { queueJobs.add(it) }
fun insertByVideoId(videoId: String) = runCatchingIO {
val streams = StreamsExtractor.extractStreams(videoId.toID())
val streams = MediaServiceRepository.instance.getStreams(videoId.toID())
add(streams.toStreamItem(videoId))
}