diff --git a/app/src/main/java/com/github/libretube/api/LocalStreamsExtractionPipedMediaServiceRepository.kt b/app/src/main/java/com/github/libretube/api/LocalStreamsExtractionPipedMediaServiceRepository.kt new file mode 100644 index 000000000..3803818e7 --- /dev/null +++ b/app/src/main/java/com/github/libretube/api/LocalStreamsExtractionPipedMediaServiceRepository.kt @@ -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) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/api/MediaServiceRepository.kt b/app/src/main/java/com/github/libretube/api/MediaServiceRepository.kt index bbbbdc753..9212a5136 100644 --- a/app/src/main/java/com/github/libretube/api/MediaServiceRepository.kt +++ b/app/src/main/java/com/github/libretube/api/MediaServiceRepository.kt @@ -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 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 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 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() + } } } } \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/api/NewPipeMediaServiceRepository.kt b/app/src/main/java/com/github/libretube/api/NewPipeMediaServiceRepository.kt new file mode 100644 index 000000000..933403f6b --- /dev/null +++ b/app/src/main/java/com/github/libretube/api/NewPipeMediaServiceRepository.kt @@ -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? = null, + val cookies: Map? = 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(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? = null, + val sortFilter: String? = null, +) + +fun ListLinkHandler.toTabDataString() = JsonHelper.json.encodeToString( + TabData(originalUrl, url, id, contentFilters, sortFilter) +) + +fun String.toListLinkHandler() = with(JsonHelper.json.decodeFromString(this)) { + ListLinkHandler(originalUrl, url, id, contentFilters, sortFilter) +} + +class NewPipeMediaServiceRepository : MediaServiceRepository { + override suspend fun getTrending(region: String): List { + val kioskList = NewPipeExtractorInstance.extractor.kioskList + kioskList.forceContentCountry(ContentCountry(region)) + + val extractor = kioskList.defaultKioskExtractor + extractor.fetchPage() + + val info = KioskInfo.getInfo(extractor) + return info.relatedItems.filterIsInstance().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() + .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 = + 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 { + return NewPipeExtractorInstance.extractor.suggestionExtractor.suggestionList(query) + } + + private suspend fun getLatestVideos(channelInfo: ChannelInfo): Pair, 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() 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() } + ) + } +} diff --git a/app/src/main/java/com/github/libretube/api/PipedMediaServiceRepository.kt b/app/src/main/java/com/github/libretube/api/PipedMediaServiceRepository.kt index a7aef9d72..c1b9c1a92 100644 --- a/app/src/main/java/com/github/libretube/api/PipedMediaServiceRepository.kt +++ b/app/src/main/java/com/github/libretube/api/PipedMediaServiceRepository.kt @@ -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 = 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(this).message + }?.getOrNull() + + throw Exception(errorMessage) + } + } override suspend fun getComments(videoId: String): CommentsPage = api.getComments(videoId) diff --git a/app/src/main/java/com/github/libretube/api/StreamsExtractor.kt b/app/src/main/java/com/github/libretube/api/StreamsExtractor.kt deleted file mode 100644 index 1436decb3..000000000 --- a/app/src/main/java/com/github/libretube/api/StreamsExtractor.kt +++ /dev/null @@ -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() - .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(this).message - }?.getOrNull() ?: context.getString(R.string.server_error) - - else -> exception.localizedMessage.orEmpty() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/api/obj/Channel.kt b/app/src/main/java/com/github/libretube/api/obj/Channel.kt index db7cb3f1e..fcaf1381d 100644 --- a/app/src/main/java/com/github/libretube/api/obj/Channel.kt +++ b/app/src/main/java/com/github/libretube/api/obj/Channel.kt @@ -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 = emptyList(), diff --git a/app/src/main/java/com/github/libretube/api/obj/SearchResult.kt b/app/src/main/java/com/github/libretube/api/obj/SearchResult.kt index 723fff9bb..06370fb59 100644 --- a/app/src/main/java/com/github/libretube/api/obj/SearchResult.kt +++ b/app/src/main/java/com/github/libretube/api/obj/SearchResult.kt @@ -6,6 +6,4 @@ import kotlinx.serialization.Serializable data class SearchResult( var items: List = emptyList(), val nextpage: String? = null, - val suggestion: String? = null, - val corrected: Boolean? = null ) diff --git a/app/src/main/java/com/github/libretube/repo/LocalPlaylistsRepository.kt b/app/src/main/java/com/github/libretube/repo/LocalPlaylistsRepository.kt index ba0d858e8..b53d5ce8c 100644 --- a/app/src/main/java/com/github/libretube/repo/LocalPlaylistsRepository.kt +++ b/app/src/main/java/com/github/libretube/repo/LocalPlaylistsRepository.kt @@ -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() diff --git a/app/src/main/java/com/github/libretube/services/DownloadService.kt b/app/src/main/java/com/github/libretube/services/DownloadService.kt index 0428be429..e7975c3cb 100644 --- a/app/src/main/java/com/github/libretube/services/DownloadService.kt +++ b/app/src/main/java/com/github/libretube/services/DownloadService.kt @@ -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 diff --git a/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt b/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt index 56166588e..e6d002b1f 100644 --- a/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt +++ b/app/src/main/java/com/github/libretube/services/OnlinePlayerService.kt @@ -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 diff --git a/app/src/main/java/com/github/libretube/services/PlaylistDownloadEnqueueService.kt b/app/src/main/java/com/github/libretube/services/PlaylistDownloadEnqueueService.kt index 56e85b25a..d14177ff6 100644 --- a/app/src/main/java/com/github/libretube/services/PlaylistDownloadEnqueueService.kt +++ b/app/src/main/java/com/github/libretube/services/PlaylistDownloadEnqueueService.kt @@ -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) diff --git a/app/src/main/java/com/github/libretube/ui/activities/AddToPlaylistActivity.kt b/app/src/main/java/com/github/libretube/ui/activities/AddToPlaylistActivity.kt index d24037ce4..2d8f3b273 100644 --- a/app/src/main/java/com/github/libretube/ui/activities/AddToPlaylistActivity.kt +++ b/app/src/main/java/com/github/libretube/ui/activities/AddToPlaylistActivity.kt @@ -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) { diff --git a/app/src/main/java/com/github/libretube/ui/adapters/SearchChannelAdapter.kt b/app/src/main/java/com/github/libretube/ui/adapters/SearchChannelAdapter.kt index ad55a6653..8a35f2b4b 100644 --- a/app/src/main/java/com/github/libretube/ui/adapters/SearchChannelAdapter.kt +++ b/app/src/main/java/com/github/libretube/ui/adapters/SearchChannelAdapter.kt @@ -159,12 +159,10 @@ class SearchChannelAdapter : ListAdapter( } 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( diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/DownloadDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/DownloadDialog.kt index ddd431c08..1e8865cfd 100644 --- a/app/src/main/java/com/github/libretube/ui/dialogs/DownloadDialog.kt +++ b/app/src/main/java/com/github/libretube/ui/dialogs/DownloadDialog.kt @@ -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) diff --git a/app/src/main/java/com/github/libretube/ui/models/sources/CommentPagingSource.kt b/app/src/main/java/com/github/libretube/ui/models/sources/CommentPagingSource.kt index abba56dfc..38f2a8298 100644 --- a/app/src/main/java/com/github/libretube/ui/models/sources/CommentPagingSource.kt +++ b/app/src/main/java/com/github/libretube/ui/models/sources/CommentPagingSource.kt @@ -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): LoadResult { 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) diff --git a/app/src/main/java/com/github/libretube/ui/models/sources/CommentRepliesPagingSource.kt b/app/src/main/java/com/github/libretube/ui/models/sources/CommentRepliesPagingSource.kt index b7725ba46..12af258c2 100644 --- a/app/src/main/java/com/github/libretube/ui/models/sources/CommentRepliesPagingSource.kt +++ b/app/src/main/java/com/github/libretube/ui/models/sources/CommentRepliesPagingSource.kt @@ -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): LoadResult { 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()) { diff --git a/app/src/main/java/com/github/libretube/ui/models/sources/SearchPagingSource.kt b/app/src/main/java/com/github/libretube/ui/models/sources/SearchPagingSource.kt index d2c0d283a..9390c2c54 100644 --- a/app/src/main/java/com/github/libretube/ui/models/sources/SearchPagingSource.kt +++ b/app/src/main/java/com/github/libretube/ui/models/sources/SearchPagingSource.kt @@ -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): LoadResult { 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) diff --git a/app/src/main/java/com/github/libretube/util/DeArrowUtil.kt b/app/src/main/java/com/github/libretube/util/DeArrowUtil.kt index 866990d37..310b58c1d 100644 --- a/app/src/main/java/com/github/libretube/util/DeArrowUtil.kt +++ b/app/src/main/java/com/github/libretube/util/DeArrowUtil.kt @@ -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 diff --git a/app/src/main/java/com/github/libretube/util/PlayingQueue.kt b/app/src/main/java/com/github/libretube/util/PlayingQueue.kt index bd2a3b97a..485a2a924 100644 --- a/app/src/main/java/com/github/libretube/util/PlayingQueue.kt +++ b/app/src/main/java/com/github/libretube/util/PlayingQueue.kt @@ -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)) }