Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support for local feed extraction #6938

Merged
merged 3 commits into from
Jan 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
663 changes: 663 additions & 0 deletions app/schemas/com.github.libretube.db.AppDatabase/19.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions app/src/main/java/com/github/libretube/LibreTubeApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationManagerCompat
import androidx.work.ExistingPeriodicWorkPolicy
import com.github.libretube.helpers.ImageHelper
import com.github.libretube.helpers.NewPipeExtractorInstance
import com.github.libretube.helpers.NotificationHelper
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.helpers.ProxyHelper
Expand Down Expand Up @@ -55,6 +56,8 @@ class LibreTubeApp : Application() {
* Dynamically create App Shortcuts
*/
ShortcutHelper.createShortcuts(this)

NewPipeExtractorInstance.init()
}

/**
Expand Down
124 changes: 28 additions & 96 deletions app/src/main/java/com/github/libretube/api/PlaylistsHelper.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package com.github.libretube.api

import androidx.core.text.isDigitsOnly
import com.github.libretube.api.obj.EditPlaylistBody
import com.github.libretube.api.obj.Message
import com.github.libretube.api.obj.Playlist
import com.github.libretube.api.obj.Playlists
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.enums.PlaylistType
import com.github.libretube.extensions.toID
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.obj.PipedImportPlaylist
import com.github.libretube.repo.LocalPlaylistsRepository
import com.github.libretube.repo.PipedPlaylistRepository
import com.github.libretube.repo.PlaylistRepository
import com.github.libretube.util.deArrow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
Expand All @@ -24,14 +24,14 @@ object PlaylistsHelper {

private val token get() = PreferenceHelper.getToken()
val loggedIn: Boolean get() = token.isNotEmpty()
private fun Message.isOk() = this.message == "ok"
private val playlistsRepository: PlaylistRepository
get() = when {
loggedIn -> PipedPlaylistRepository()
else -> LocalPlaylistsRepository()
}

suspend fun getPlaylists(): List<Playlists> = withContext(Dispatchers.IO) {
val playlists = if (loggedIn) {
RetrofitInstance.authApi.getUserPlaylists(token)
} else {
LocalPlaylistsRepository.getPlaylists()
}
val playlists = playlistsRepository.getPlaylists()
sortPlaylists(playlists)
}

Expand All @@ -52,109 +52,41 @@ object PlaylistsHelper {
suspend fun getPlaylist(playlistId: String): Playlist {
// load locally stored playlists with the auth api
return when (getPrivatePlaylistType(playlistId)) {
PlaylistType.PRIVATE -> RetrofitInstance.authApi.getPlaylist(playlistId)
PlaylistType.PUBLIC -> RetrofitInstance.api.getPlaylist(playlistId)
PlaylistType.LOCAL -> LocalPlaylistsRepository.getPlaylist(playlistId)
else -> playlistsRepository.getPlaylist(playlistId)
}.apply {
relatedStreams = relatedStreams.deArrow()
}
}

suspend fun createPlaylist(playlistName: String): String? {
return if (!loggedIn) {
LocalPlaylistsRepository.createPlaylist(playlistName)
} else {
RetrofitInstance.authApi.createPlaylist(
token,
Playlists(name = playlistName)
).playlistId
}
}

suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem): Boolean {
if (!loggedIn) {
LocalPlaylistsRepository.addToPlaylist(playlistId, *videos)
return true
suspend fun getAllPlaylistsWithVideos(playlistIds: List<String>? = null): List<Playlist> {
return withContext(Dispatchers.IO) {
(playlistIds ?: getPlaylists().map { it.id!! })
.map { async { getPlaylist(it) } }
.awaitAll()
}

val playlist = EditPlaylistBody(playlistId, videoIds = videos.map { it.url!!.toID() })
return RetrofitInstance.authApi.addToPlaylist(token, playlist).isOk()
}

suspend fun renamePlaylist(playlistId: String, newName: String): Boolean {
if (!loggedIn) {
LocalPlaylistsRepository.renamePlaylist(playlistId, newName)
return true
}
suspend fun createPlaylist(playlistName: String) =
playlistsRepository.createPlaylist(playlistName)

val playlist = EditPlaylistBody(playlistId, newName = newName)
return RetrofitInstance.authApi.renamePlaylist(token, playlist).isOk()
}
suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem) =
playlistsRepository.addToPlaylist(playlistId, *videos)

suspend fun changePlaylistDescription(playlistId: String, newDescription: String): Boolean {
if (!loggedIn) {
LocalPlaylistsRepository.changePlaylistDescription(playlistId, newDescription)
return true
}
suspend fun renamePlaylist(playlistId: String, newName: String) =
playlistsRepository.renamePlaylist(playlistId, newName)

val playlist = EditPlaylistBody(playlistId, description = newDescription)
return RetrofitInstance.authApi.changePlaylistDescription(token, playlist).isOk()
}
suspend fun changePlaylistDescription(playlistId: String, newDescription: String) =
playlistsRepository.changePlaylistDescription(playlistId, newDescription)

suspend fun removeFromPlaylist(playlistId: String, index: Int): Boolean {
if (!loggedIn) {
LocalPlaylistsRepository.removeFromPlaylist(playlistId, index)
return true
}

return RetrofitInstance.authApi.removeFromPlaylist(
PreferenceHelper.getToken(),
EditPlaylistBody(playlistId = playlistId, index = index)
).isOk()
}
suspend fun removeFromPlaylist(playlistId: String, index: Int) =
playlistsRepository.removeFromPlaylist(playlistId, index)

suspend fun importPlaylists(playlists: List<PipedImportPlaylist>) =
withContext(Dispatchers.IO) {
if (!loggedIn) return@withContext LocalPlaylistsRepository.importPlaylists(playlists)

for (playlist in playlists) {
val playlistId = createPlaylist(playlist.name!!) ?: return@withContext
val streams = playlist.videos.map { StreamItem(url = it) }
addToPlaylist(playlistId, *streams.toTypedArray())
}
}

suspend fun getAllPlaylistsWithVideos(playlistIds: List<String>? = null): List<Playlist> =
withContext(Dispatchers.IO) {
(playlistIds ?: getPlaylists().map { it.id!! })
.map { async { getPlaylist(it) } }
.awaitAll()
}
playlistsRepository.importPlaylists(playlists)

suspend fun clonePlaylist(playlistId: String): String? {
if (!loggedIn) {
return LocalPlaylistsRepository.clonePlaylist(playlistId)
}

return RetrofitInstance.authApi.clonePlaylist(
token,
EditPlaylistBody(playlistId)
).playlistId
}

suspend fun deletePlaylist(playlistId: String, playlistType: PlaylistType): Boolean {
if (playlistType == PlaylistType.LOCAL) {
LocalPlaylistsRepository.deletePlaylist(playlistId)
return true
}

return runCatching {
RetrofitInstance.authApi.deletePlaylist(
PreferenceHelper.getToken(),
EditPlaylistBody(playlistId)
).isOk()
}.getOrDefault(false)
}
suspend fun clonePlaylist(playlistId: String) = playlistsRepository.clonePlaylist(playlistId)
suspend fun deletePlaylist(playlistId: String) = playlistsRepository.deletePlaylist(playlistId)

fun getPrivatePlaylistType(): PlaylistType {
return if (loggedIn) PlaylistType.PRIVATE else PlaylistType.LOCAL
Expand Down
20 changes: 5 additions & 15 deletions app/src/main/java/com/github/libretube/api/StreamsExtractor.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,13 @@ 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.helpers.PlayerHelper
import com.github.libretube.util.NewPipeDownloaderImpl
import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_FRONTEND_URL
import kotlinx.datetime.toKotlinInstant
import org.schabi.newpipe.extractor.NewPipe
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
import java.lang.Exception

fun VideoStream.toPipedStream(): PipedStream = PipedStream(
url = content,
Expand All @@ -39,26 +37,18 @@ fun VideoStream.toPipedStream(): PipedStream = PipedStream(
)

object StreamsExtractor {
// val npe by lazy {
// NewPipe.getService(ServiceList.YouTube.serviceId)
// }

init {
NewPipe.init(NewPipeDownloaderImpl())
}

suspend fun extractStreams(videoId: String): Streams {
if (!PlayerHelper.disablePipedProxy || !PlayerHelper.localStreamExtraction) {
return RetrofitInstance.api.getStreams(videoId)
}

val resp = StreamInfo.getInfo("https://www.youtube.com/watch?v=$videoId")
val resp = StreamInfo.getInfo("${YOUTUBE_FRONTEND_URL}/watch?v=$videoId")
return Streams(
title = resp.name,
description = resp.description.content,
uploader = resp.uploaderName,
uploaderAvatar = resp.uploaderAvatars.maxBy { it.height }.url,
uploaderUrl = resp.uploaderUrl.replace("https://www.youtube.com", ""),
uploaderUrl = resp.uploaderUrl.replace(YOUTUBE_FRONTEND_URL, ""),
uploaderVerified = resp.isUploaderVerified,
uploaderSubscriberCount = resp.uploaderSubscriberCount,
category = resp.category,
Expand Down Expand Up @@ -86,12 +76,12 @@ object StreamsExtractor {
thumbnailUrl = resp.thumbnails.maxBy { it.height }.url,
relatedStreams = resp.relatedItems.filterIsInstance<StreamInfoItem>().map {
StreamItem(
it.url.replace("https://www.youtube.com", ""),
it.url.replace(YOUTUBE_FRONTEND_URL, ""),
StreamItem.TYPE_STREAM,
it.name,
it.thumbnails.maxBy { image -> image.height }.url,
it.uploaderName,
it.uploaderUrl.replace("https://www.youtube.com", ""),
it.uploaderUrl.replace(YOUTUBE_FRONTEND_URL, ""),
it.uploaderAvatars.maxBy { image -> image.height }.url,
it.textualUploadDate,
it.duration,
Expand Down
108 changes: 24 additions & 84 deletions app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package com.github.libretube.api

import android.content.Context
import android.util.Log
import com.github.libretube.R
import com.github.libretube.api.obj.StreamItem
import com.github.libretube.api.obj.Subscribe
import com.github.libretube.api.obj.Subscription
import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.db.DatabaseHolder.Database
import com.github.libretube.db.obj.LocalSubscription
import com.github.libretube.extensions.TAG
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.repo.AccountSubscriptionsRepository
import com.github.libretube.repo.FeedRepository
import com.github.libretube.repo.LocalFeedRepository
import com.github.libretube.repo.LocalSubscriptionsRepository
import com.github.libretube.repo.PipedAccountFeedRepository
import com.github.libretube.repo.PipedNoAccountFeedRepository
import com.github.libretube.repo.SubscriptionsRepository
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.runBlocking

Expand All @@ -19,29 +19,27 @@ object SubscriptionHelper {
* The maximum number of channel IDs that can be passed via a GET request for fetching
* the subscriptions list and the feed
*/
private const val GET_SUBSCRIPTIONS_LIMIT = 100
private val token get() = PreferenceHelper.getToken()
const val GET_SUBSCRIPTIONS_LIMIT = 100

suspend fun subscribe(channelId: String) {
if (token.isNotEmpty()) {
runCatching {
RetrofitInstance.authApi.subscribe(token, Subscribe(channelId))
}
} else {
Database.localSubscriptionDao().insert(LocalSubscription(channelId))
}
private val token get() = PreferenceHelper.getToken()
private val subscriptionsRepository: SubscriptionsRepository get() = when {
token.isNotEmpty() -> AccountSubscriptionsRepository()
else -> LocalSubscriptionsRepository()
}

suspend fun unsubscribe(channelId: String) {
if (token.isNotEmpty()) {
runCatching {
RetrofitInstance.authApi.unsubscribe(token, Subscribe(channelId))
}
} else {
Database.localSubscriptionDao().delete(LocalSubscription(channelId))
}
private val feedRepository: FeedRepository get() = when {
PreferenceHelper.getBoolean(PreferenceKeys.LOCAL_FEED_EXTRACTION, false) -> LocalFeedRepository()
token.isNotEmpty() -> PipedAccountFeedRepository()
else -> PipedNoAccountFeedRepository()
}

suspend fun subscribe(channelId: String) = subscriptionsRepository.subscribe(channelId)
suspend fun unsubscribe(channelId: String) = subscriptionsRepository.unsubscribe(channelId)
suspend fun isSubscribed(channelId: String) = subscriptionsRepository.isSubscribed(channelId)
suspend fun importSubscriptions(newChannels: List<String>) = subscriptionsRepository.importSubscriptions(newChannels)
suspend fun getSubscriptions() = subscriptionsRepository.getSubscriptions()
suspend fun getSubscriptionChannelIds() = subscriptionsRepository.getSubscriptionChannelIds()
suspend fun getFeed(forceRefresh: Boolean) = feedRepository.getFeed(forceRefresh)

fun handleUnsubscribe(
context: Context,
channelId: String,
Expand All @@ -68,62 +66,4 @@ object SubscriptionHelper {
.setNegativeButton(R.string.cancel, null)
.show()
}

suspend fun isSubscribed(channelId: String): Boolean? {
if (token.isNotEmpty()) {
val isSubscribed = try {
RetrofitInstance.authApi.isSubscribed(channelId, token)
} catch (e: Exception) {
Log.e(TAG(), e.toString())
return null
}
return isSubscribed.subscribed
} else {
return Database.localSubscriptionDao().includes(channelId)
}
}

suspend fun importSubscriptions(newChannels: List<String>) {
if (token.isNotEmpty()) {
runCatching {
RetrofitInstance.authApi.importSubscriptions(false, token, newChannels)
}
} else {
Database.localSubscriptionDao().insertAll(newChannels.map { LocalSubscription(it) })
}
}

suspend fun getSubscriptions(): List<Subscription> {
return if (token.isNotEmpty()) {
RetrofitInstance.authApi.subscriptions(token)
} else {
val subscriptions = Database.localSubscriptionDao().getAll().map { it.channelId }
when {
subscriptions.size > GET_SUBSCRIPTIONS_LIMIT ->
RetrofitInstance.authApi
.unauthenticatedSubscriptions(subscriptions)

else -> RetrofitInstance.authApi.unauthenticatedSubscriptions(
subscriptions.joinToString(",")
)
}
}
}

suspend fun getFeed(): List<StreamItem> {
return if (token.isNotEmpty()) {
RetrofitInstance.authApi.getFeed(token)
} else {
val subscriptions = Database.localSubscriptionDao().getAll().map { it.channelId }
when {
subscriptions.size > GET_SUBSCRIPTIONS_LIMIT ->
RetrofitInstance.authApi
.getUnauthenticatedFeed(subscriptions)

else -> RetrofitInstance.authApi.getUnauthenticatedFeed(
subscriptions.joinToString(",")
)
}
}
}
}
Loading
Loading