From f54d0fd8a009bbf926ba66b8e13852e2ffe20a7d Mon Sep 17 00:00:00 2001 From: Bnyro Date: Fri, 10 Jan 2025 15:19:45 +0100 Subject: [PATCH 1/3] refactor: bind subscriptions and feed repositories to interfaces --- .../libretube/api/SubscriptionHelper.kt | 105 ++++-------------- .../repo/AccountSubscriptionsRepository.kt | 36 ++++++ .../github/libretube/repo/FeedRepository.kt | 7 ++ .../repo/LocalSubscriptionsRepository.kt | 39 +++++++ .../repo/PipedAccountFeedRepository.kt | 13 +++ .../repo/PipedNoAccountFeedRepository.kt | 23 ++++ .../libretube/repo/SubscriptionsRepository.kt | 11 ++ 7 files changed, 150 insertions(+), 84 deletions(-) create mode 100644 app/src/main/java/com/github/libretube/repo/AccountSubscriptionsRepository.kt create mode 100644 app/src/main/java/com/github/libretube/repo/FeedRepository.kt create mode 100644 app/src/main/java/com/github/libretube/repo/LocalSubscriptionsRepository.kt create mode 100644 app/src/main/java/com/github/libretube/repo/PipedAccountFeedRepository.kt create mode 100644 app/src/main/java/com/github/libretube/repo/PipedNoAccountFeedRepository.kt create mode 100644 app/src/main/java/com/github/libretube/repo/SubscriptionsRepository.kt diff --git a/app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt b/app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt index 199c50d5de..15d142ca8d 100644 --- a/app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt +++ b/app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt @@ -1,16 +1,15 @@ 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.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 @@ -19,29 +18,25 @@ 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 { + 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) = subscriptionsRepository.importSubscriptions(newChannels) + suspend fun getSubscriptions() = subscriptionsRepository.getSubscriptions() + suspend fun getFeed() = feedRepository.getFeed(false) + fun handleUnsubscribe( context: Context, channelId: String, @@ -68,62 +63,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) { - if (token.isNotEmpty()) { - runCatching { - RetrofitInstance.authApi.importSubscriptions(false, token, newChannels) - } - } else { - Database.localSubscriptionDao().insertAll(newChannels.map { LocalSubscription(it) }) - } - } - - suspend fun getSubscriptions(): List { - 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 { - 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(",") - ) - } - } - } } diff --git a/app/src/main/java/com/github/libretube/repo/AccountSubscriptionsRepository.kt b/app/src/main/java/com/github/libretube/repo/AccountSubscriptionsRepository.kt new file mode 100644 index 0000000000..f9264665bc --- /dev/null +++ b/app/src/main/java/com/github/libretube/repo/AccountSubscriptionsRepository.kt @@ -0,0 +1,36 @@ +package com.github.libretube.repo + +import com.github.libretube.api.RetrofitInstance +import com.github.libretube.api.obj.Subscribe +import com.github.libretube.api.obj.Subscription +import com.github.libretube.helpers.PreferenceHelper + +class AccountSubscriptionsRepository: SubscriptionsRepository { + private val token get() = PreferenceHelper.getToken() + + override suspend fun subscribe(channelId: String) { + runCatching { + RetrofitInstance.authApi.subscribe(token, Subscribe(channelId)) + } + } + + override suspend fun unsubscribe(channelId: String) { + runCatching { + RetrofitInstance.authApi.unsubscribe(token, Subscribe(channelId)) + } + } + + override suspend fun isSubscribed(channelId: String): Boolean? { + return runCatching { + RetrofitInstance.authApi.isSubscribed(channelId, token) + }.getOrNull()?.subscribed + } + + override suspend fun importSubscriptions(newChannels: List) { + RetrofitInstance.authApi.importSubscriptions(false, token, newChannels) + } + + override suspend fun getSubscriptions(): List { + return RetrofitInstance.authApi.subscriptions(token) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/repo/FeedRepository.kt b/app/src/main/java/com/github/libretube/repo/FeedRepository.kt new file mode 100644 index 0000000000..d679a7b5ad --- /dev/null +++ b/app/src/main/java/com/github/libretube/repo/FeedRepository.kt @@ -0,0 +1,7 @@ +package com.github.libretube.repo + +import com.github.libretube.api.obj.StreamItem + +interface FeedRepository { + suspend fun getFeed(forceRefresh: Boolean): List +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/repo/LocalSubscriptionsRepository.kt b/app/src/main/java/com/github/libretube/repo/LocalSubscriptionsRepository.kt new file mode 100644 index 0000000000..93cf8bd089 --- /dev/null +++ b/app/src/main/java/com/github/libretube/repo/LocalSubscriptionsRepository.kt @@ -0,0 +1,39 @@ +package com.github.libretube.repo + +import com.github.libretube.api.RetrofitInstance +import com.github.libretube.api.SubscriptionHelper.GET_SUBSCRIPTIONS_LIMIT +import com.github.libretube.api.obj.Subscription +import com.github.libretube.db.DatabaseHolder.Database +import com.github.libretube.db.obj.LocalSubscription + +class LocalSubscriptionsRepository: SubscriptionsRepository { + override suspend fun subscribe(channelId: String) { + Database.localSubscriptionDao().insert(LocalSubscription(channelId)) + } + + override suspend fun unsubscribe(channelId: String) { + Database.localSubscriptionDao().delete(LocalSubscription(channelId)) + } + + override suspend fun isSubscribed(channelId: String): Boolean { + return Database.localSubscriptionDao().includes(channelId) + } + + override suspend fun importSubscriptions(newChannels: List) { + Database.localSubscriptionDao().insertAll(newChannels.map { LocalSubscription(it) }) + } + + override suspend fun getSubscriptions(): List { + val subscriptions = Database.localSubscriptionDao().getAll().map { it.channelId } + + return when { + subscriptions.size > GET_SUBSCRIPTIONS_LIMIT -> + RetrofitInstance.authApi + .unauthenticatedSubscriptions(subscriptions) + + else -> RetrofitInstance.authApi.unauthenticatedSubscriptions( + subscriptions.joinToString(",") + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/repo/PipedAccountFeedRepository.kt b/app/src/main/java/com/github/libretube/repo/PipedAccountFeedRepository.kt new file mode 100644 index 0000000000..94cd28c7f2 --- /dev/null +++ b/app/src/main/java/com/github/libretube/repo/PipedAccountFeedRepository.kt @@ -0,0 +1,13 @@ +package com.github.libretube.repo + +import com.github.libretube.api.RetrofitInstance +import com.github.libretube.api.obj.StreamItem +import com.github.libretube.helpers.PreferenceHelper + +class PipedAccountFeedRepository: FeedRepository { + override suspend fun getFeed(forceRefresh: Boolean): List { + val token = PreferenceHelper.getToken() + + return RetrofitInstance.authApi.getFeed(token) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/repo/PipedNoAccountFeedRepository.kt b/app/src/main/java/com/github/libretube/repo/PipedNoAccountFeedRepository.kt new file mode 100644 index 0000000000..153636cfcb --- /dev/null +++ b/app/src/main/java/com/github/libretube/repo/PipedNoAccountFeedRepository.kt @@ -0,0 +1,23 @@ +package com.github.libretube.repo + +import com.github.libretube.api.RetrofitInstance +import com.github.libretube.api.SubscriptionHelper +import com.github.libretube.api.SubscriptionHelper.GET_SUBSCRIPTIONS_LIMIT +import com.github.libretube.api.obj.StreamItem +import com.github.libretube.extensions.toID + +class PipedNoAccountFeedRepository: FeedRepository { + override suspend fun getFeed(forceRefresh: Boolean): List { + val subscriptions = SubscriptionHelper.getSubscriptions().map { it.url.toID() } + + return when { + subscriptions.size > GET_SUBSCRIPTIONS_LIMIT -> + RetrofitInstance.authApi + .getUnauthenticatedFeed(subscriptions) + + else -> RetrofitInstance.authApi.getUnauthenticatedFeed( + subscriptions.joinToString(",") + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/repo/SubscriptionsRepository.kt b/app/src/main/java/com/github/libretube/repo/SubscriptionsRepository.kt new file mode 100644 index 0000000000..4138b6c818 --- /dev/null +++ b/app/src/main/java/com/github/libretube/repo/SubscriptionsRepository.kt @@ -0,0 +1,11 @@ +package com.github.libretube.repo + +import com.github.libretube.api.obj.Subscription + +interface SubscriptionsRepository { + suspend fun subscribe(channelId: String) + suspend fun unsubscribe(channelId: String) + suspend fun isSubscribed(channelId: String): Boolean? + suspend fun importSubscriptions(newChannels: List) + suspend fun getSubscriptions(): List +} \ No newline at end of file From 2698368aee72d0d759fd57c387f0c5bbabbc64f5 Mon Sep 17 00:00:00 2001 From: Bnyro Date: Fri, 10 Jan 2025 15:42:59 +0100 Subject: [PATCH 2/3] refactor: bind playlist repositories to interfaces --- .../github/libretube/api/PlaylistsHelper.kt | 124 ++++-------------- .../{api => repo}/LocalPlaylistsRepository.kt | 41 ++++-- .../libretube/repo/PipedPlaylistRepository.kt | 81 ++++++++++++ .../libretube/repo/PlaylistRepository.kt | 19 +++ .../ui/dialogs/DeletePlaylistDialog.kt | 8 +- .../ui/sheets/PlaylistOptionsBottomSheet.kt | 3 +- 6 files changed, 159 insertions(+), 117 deletions(-) rename app/src/main/java/com/github/libretube/{api => repo}/LocalPlaylistsRepository.kt (81%) create mode 100644 app/src/main/java/com/github/libretube/repo/PipedPlaylistRepository.kt create mode 100644 app/src/main/java/com/github/libretube/repo/PlaylistRepository.kt diff --git a/app/src/main/java/com/github/libretube/api/PlaylistsHelper.kt b/app/src/main/java/com/github/libretube/api/PlaylistsHelper.kt index e54ffdcf28..c158a4601f 100644 --- a/app/src/main/java/com/github/libretube/api/PlaylistsHelper.kt +++ b/app/src/main/java/com/github/libretube/api/PlaylistsHelper.kt @@ -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 @@ -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 = withContext(Dispatchers.IO) { - val playlists = if (loggedIn) { - RetrofitInstance.authApi.getUserPlaylists(token) - } else { - LocalPlaylistsRepository.getPlaylists() - } + val playlists = playlistsRepository.getPlaylists() sortPlaylists(playlists) } @@ -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? = null): List { + 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) = - 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? = null): List = - 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 diff --git a/app/src/main/java/com/github/libretube/api/LocalPlaylistsRepository.kt b/app/src/main/java/com/github/libretube/repo/LocalPlaylistsRepository.kt similarity index 81% rename from app/src/main/java/com/github/libretube/api/LocalPlaylistsRepository.kt rename to app/src/main/java/com/github/libretube/repo/LocalPlaylistsRepository.kt index 13f2dfa355..c993890030 100644 --- a/app/src/main/java/com/github/libretube/api/LocalPlaylistsRepository.kt +++ b/app/src/main/java/com/github/libretube/repo/LocalPlaylistsRepository.kt @@ -1,6 +1,9 @@ -package com.github.libretube.api +package com.github.libretube.repo +import com.github.libretube.api.PlaylistsHelper import com.github.libretube.api.PlaylistsHelper.MAX_CONCURRENT_IMPORT_CALLS +import com.github.libretube.api.RetrofitInstance +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 @@ -10,8 +13,8 @@ import com.github.libretube.extensions.parallelMap import com.github.libretube.helpers.ProxyHelper import com.github.libretube.obj.PipedImportPlaylist -object LocalPlaylistsRepository { - suspend fun getPlaylist(playlistId: String): Playlist { +class LocalPlaylistsRepository: PlaylistRepository { + override suspend fun getPlaylist(playlistId: String): Playlist { val relation = DatabaseHolder.Database.localPlaylistsDao().getAll() .first { it.playlist.id.toString() == playlistId } @@ -24,7 +27,7 @@ object LocalPlaylistsRepository { ) } - suspend fun getPlaylists(): List { + override suspend fun getPlaylists(): List { return DatabaseHolder.Database.localPlaylistsDao().getAll() .map { Playlists( @@ -37,7 +40,7 @@ object LocalPlaylistsRepository { } } - suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem) { + override suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem): Boolean { val localPlaylist = DatabaseHolder.Database.localPlaylistsDao().getAll() .first { it.playlist.id.toString() == playlistId } @@ -59,25 +62,31 @@ object LocalPlaylistsRepository { } } } + + return true } - suspend fun renamePlaylist(playlistId: String, newName: String) { + override suspend fun renamePlaylist(playlistId: String, newName: String): Boolean { val playlist = DatabaseHolder.Database.localPlaylistsDao().getAll() .first { it.playlist.id.toString() == playlistId }.playlist playlist.name = newName DatabaseHolder.Database.localPlaylistsDao().updatePlaylist(playlist) + + return true } - suspend fun changePlaylistDescription(playlistId: String, newDescription: String) { + override suspend fun changePlaylistDescription(playlistId: String, newDescription: String): Boolean { val playlist = DatabaseHolder.Database.localPlaylistsDao().getAll() .first { it.playlist.id.toString() == playlistId }.playlist playlist.description = newDescription DatabaseHolder.Database.localPlaylistsDao().updatePlaylist(playlist) + + return true } - suspend fun clonePlaylist(playlistId: String): String? { + override suspend fun clonePlaylist(playlistId: String): String { val playlist = RetrofitInstance.api.getPlaylist(playlistId) - val newPlaylist = createPlaylist(playlist.name ?: "Unknown name") ?: return null + val newPlaylist = createPlaylist(playlist.name ?: "Unknown name") PlaylistsHelper.addToPlaylist(newPlaylist, *playlist.relatedStreams.toTypedArray()) @@ -93,7 +102,7 @@ object LocalPlaylistsRepository { return playlistId } - suspend fun removeFromPlaylist(playlistId: String, index: Int) { + override suspend fun removeFromPlaylist(playlistId: String, index: Int): Boolean { val transaction = DatabaseHolder.Database.localPlaylistsDao().getAll() .first { it.playlist.id.toString() == playlistId } DatabaseHolder.Database.localPlaylistsDao().removePlaylistVideo( @@ -105,11 +114,13 @@ object LocalPlaylistsRepository { transaction.videos.getOrNull(1)?.thumbnailUrl.orEmpty() } DatabaseHolder.Database.localPlaylistsDao().updatePlaylist(transaction.playlist) + + return true } - suspend fun importPlaylists(playlists: List) { + override suspend fun importPlaylists(playlists: List) { for (playlist in playlists) { - val playlistId = createPlaylist(playlist.name!!) ?: return + val playlistId = createPlaylist(playlist.name!!) // if not logged in, all video information needs to become fetched manually // Only do so with `MAX_CONCURRENT_IMPORT_CALLS` videos at once to prevent performance issues @@ -125,13 +136,15 @@ object LocalPlaylistsRepository { } } - suspend fun createPlaylist(playlistName: String): String { + override suspend fun createPlaylist(playlistName: String): String { val playlist = LocalPlaylist(name = playlistName, thumbnailUrl = "") return DatabaseHolder.Database.localPlaylistsDao().createPlaylist(playlist).toString() } - suspend fun deletePlaylist(playlistId: String) { + override suspend fun deletePlaylist(playlistId: String): Boolean { DatabaseHolder.Database.localPlaylistsDao().deletePlaylistById(playlistId) DatabaseHolder.Database.localPlaylistsDao().deletePlaylistItemsByPlaylistId(playlistId) + + return true } } \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/repo/PipedPlaylistRepository.kt b/app/src/main/java/com/github/libretube/repo/PipedPlaylistRepository.kt new file mode 100644 index 0000000000..346bc87536 --- /dev/null +++ b/app/src/main/java/com/github/libretube/repo/PipedPlaylistRepository.kt @@ -0,0 +1,81 @@ +package com.github.libretube.repo + +import com.github.libretube.api.PlaylistsHelper +import com.github.libretube.api.RetrofitInstance +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.extensions.toID +import com.github.libretube.helpers.PreferenceHelper +import com.github.libretube.obj.PipedImportPlaylist + +class PipedPlaylistRepository: PlaylistRepository { + private fun Message.isOk() = this.message == "ok" + private val token get() = PreferenceHelper.getToken() + + override suspend fun getPlaylist(playlistId: String): Playlist { + return RetrofitInstance.authApi.getPlaylist(playlistId) + } + + override suspend fun getPlaylists(): List { + return RetrofitInstance.authApi.getUserPlaylists(token) + } + + override suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem): Boolean { + val playlist = EditPlaylistBody(playlistId, videoIds = videos.map { it.url!!.toID() }) + + return RetrofitInstance.authApi.addToPlaylist(token, playlist).isOk() + } + + override suspend fun renamePlaylist(playlistId: String, newName: String): Boolean { + val playlist = EditPlaylistBody(playlistId, newName = newName) + + return RetrofitInstance.authApi.renamePlaylist(token, playlist).isOk() + } + + override suspend fun changePlaylistDescription(playlistId: String, newDescription: String): Boolean { + val playlist = EditPlaylistBody(playlistId, description = newDescription) + + return RetrofitInstance.authApi.changePlaylistDescription(token, playlist).isOk() + } + + override suspend fun clonePlaylist(playlistId: String): String? { + return RetrofitInstance.authApi.clonePlaylist( + token, + EditPlaylistBody(playlistId) + ).playlistId + } + + override suspend fun removeFromPlaylist(playlistId: String, index: Int): Boolean { + return RetrofitInstance.authApi.removeFromPlaylist( + PreferenceHelper.getToken(), + EditPlaylistBody(playlistId = playlistId, index = index) + ).isOk() + } + + override suspend fun importPlaylists(playlists: List) { + for (playlist in playlists) { + val playlistId = PlaylistsHelper.createPlaylist(playlist.name!!) ?: return + val streams = playlist.videos.map { StreamItem(url = it) } + PlaylistsHelper.addToPlaylist(playlistId, *streams.toTypedArray()) + } + } + + override suspend fun createPlaylist(playlistName: String): String? { + return RetrofitInstance.authApi.createPlaylist( + token, + Playlists(name = playlistName) + ).playlistId + } + + override suspend fun deletePlaylist(playlistId: String): Boolean { + return runCatching { + RetrofitInstance.authApi.deletePlaylist( + PreferenceHelper.getToken(), + EditPlaylistBody(playlistId) + ).isOk() + }.getOrDefault(false) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/repo/PlaylistRepository.kt b/app/src/main/java/com/github/libretube/repo/PlaylistRepository.kt new file mode 100644 index 0000000000..0c99457ad4 --- /dev/null +++ b/app/src/main/java/com/github/libretube/repo/PlaylistRepository.kt @@ -0,0 +1,19 @@ +package com.github.libretube.repo + +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.obj.PipedImportPlaylist + +interface PlaylistRepository { + suspend fun getPlaylist(playlistId: String): Playlist + suspend fun getPlaylists(): List + suspend fun addToPlaylist(playlistId: String, vararg videos: StreamItem): Boolean + suspend fun renamePlaylist(playlistId: String, newName: String): Boolean + suspend fun changePlaylistDescription(playlistId: String, newDescription: String): Boolean + suspend fun clonePlaylist(playlistId: String): String? + suspend fun removeFromPlaylist(playlistId: String, index: Int): Boolean + suspend fun importPlaylists(playlists: List) + suspend fun createPlaylist(playlistName: String): String? + suspend fun deletePlaylist(playlistId: String): Boolean +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/ui/dialogs/DeletePlaylistDialog.kt b/app/src/main/java/com/github/libretube/ui/dialogs/DeletePlaylistDialog.kt index 4bdf78d5a4..fe5c4d6c63 100644 --- a/app/src/main/java/com/github/libretube/ui/dialogs/DeletePlaylistDialog.kt +++ b/app/src/main/java/com/github/libretube/ui/dialogs/DeletePlaylistDialog.kt @@ -10,8 +10,6 @@ import androidx.lifecycle.lifecycleScope import com.github.libretube.R import com.github.libretube.api.PlaylistsHelper import com.github.libretube.constants.IntentData -import com.github.libretube.enums.PlaylistType -import com.github.libretube.extensions.serializable import com.github.libretube.extensions.toastFromMainDispatcher import com.github.libretube.ui.sheets.PlaylistOptionsBottomSheet import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -21,14 +19,14 @@ import kotlinx.coroutines.withContext class DeletePlaylistDialog : DialogFragment() { private lateinit var playlistId: String - private lateinit var playlistType: PlaylistType + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) arguments?.let { playlistId = it.getString(IntentData.playlistId)!! - playlistType = it.serializable(IntentData.playlistType)!! } } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.deletePlaylist) @@ -39,7 +37,7 @@ class DeletePlaylistDialog : DialogFragment() { .apply { getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { lifecycleScope.launch(Dispatchers.IO) { - val success = PlaylistsHelper.deletePlaylist(playlistId, playlistType) + val success = PlaylistsHelper.deletePlaylist(playlistId) context.toastFromMainDispatcher( if (success) R.string.success else R.string.fail ) diff --git a/app/src/main/java/com/github/libretube/ui/sheets/PlaylistOptionsBottomSheet.kt b/app/src/main/java/com/github/libretube/ui/sheets/PlaylistOptionsBottomSheet.kt index b09eed713f..76f5b04f50 100644 --- a/app/src/main/java/com/github/libretube/ui/sheets/PlaylistOptionsBottomSheet.kt +++ b/app/src/main/java/com/github/libretube/ui/sheets/PlaylistOptionsBottomSheet.kt @@ -123,8 +123,7 @@ class PlaylistOptionsBottomSheet : BaseBottomSheet() { R.string.deletePlaylist -> { val newDeletePlaylistDialog = DeletePlaylistDialog() newDeletePlaylistDialog.arguments = bundleOf( - IntentData.playlistId to playlistId, - IntentData.playlistType to playlistType + IntentData.playlistId to playlistId ) newDeletePlaylistDialog.show(mFragmentManager, null) } From 579fae287ce6987024eaafbf2c2e01d92e3d806b Mon Sep 17 00:00:00 2001 From: Bnyro Date: Fri, 10 Jan 2025 17:32:15 +0100 Subject: [PATCH 3/3] feat: support for local feed extraction --- .../19.json | 663 ++++++++++++++++++ .../java/com/github/libretube/LibreTubeApp.kt | 3 + .../github/libretube/api/StreamsExtractor.kt | 20 +- .../libretube/api/SubscriptionHelper.kt | 5 +- .../github/libretube/api/obj/StreamItem.kt | 16 + .../com/github/libretube/api/obj/Streams.kt | 8 +- .../libretube/constants/PreferenceKeys.kt | 2 + .../com/github/libretube/db/AppDatabase.kt | 12 +- .../com/github/libretube/db/Converters.kt | 3 +- .../com/github/libretube/db/DatabaseHelper.kt | 7 +- .../com/github/libretube/db/DatabaseHolder.kt | 1 - .../libretube/db/dao/SubscriptionsFeedDao.kt | 22 + .../libretube/db/obj/SubscriptionsFeedItem.kt | 40 ++ .../libretube/db/obj/WatchHistoryItem.kt | 1 + .../github/libretube/extensions/LocalDate.kt | 12 + .../helpers/NewPipeExtractorInstance.kt | 16 + .../libretube/json/SafeInstantSerializer.kt | 5 +- .../repo/AccountSubscriptionsRepository.kt | 5 + .../libretube/repo/LocalFeedRepository.kt | 103 +++ .../repo/LocalSubscriptionsRepository.kt | 12 +- .../repo/PipedNoAccountFeedRepository.kt | 9 +- .../libretube/repo/SubscriptionsRepository.kt | 1 + .../libretube/services/DownloadService.kt | 5 +- .../libretube/ui/fragments/PlayerFragment.kt | 2 +- .../ui/fragments/SubscriptionsFragment.kt | 4 +- .../ui/fragments/WatchHistoryFragment.kt | 14 +- .../libretube/ui/models/HomeViewModel.kt | 2 +- .../ui/models/SubscriptionsViewModel.kt | 4 +- .../com/github/libretube/util/TextUtils.kt | 7 +- .../libretube/workers/NotificationWorker.kt | 2 +- app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/instance_settings.xml | 11 + 32 files changed, 948 insertions(+), 71 deletions(-) create mode 100644 app/schemas/com.github.libretube.db.AppDatabase/19.json create mode 100644 app/src/main/java/com/github/libretube/db/dao/SubscriptionsFeedDao.kt create mode 100644 app/src/main/java/com/github/libretube/db/obj/SubscriptionsFeedItem.kt create mode 100644 app/src/main/java/com/github/libretube/helpers/NewPipeExtractorInstance.kt create mode 100644 app/src/main/java/com/github/libretube/repo/LocalFeedRepository.kt diff --git a/app/schemas/com.github.libretube.db.AppDatabase/19.json b/app/schemas/com.github.libretube.db.AppDatabase/19.json new file mode 100644 index 0000000000..e0ce4d55d0 --- /dev/null +++ b/app/schemas/com.github.libretube.db.AppDatabase/19.json @@ -0,0 +1,663 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "74afbaf921a81ccd97447002122d5077", + "entities": [ + { + "tableName": "watchHistoryItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT, `uploadDate` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `thumbnailUrl` TEXT, `duration` INTEGER, `isShort` INTEGER NOT NULL, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "uploadDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploaderUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderAvatar", + "columnName": "uploaderAvatar", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isShort", + "columnName": "isShort", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "videoId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "watchPosition", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "videoId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "searchHistoryItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query` TEXT NOT NULL, PRIMARY KEY(`query`))", + "fields": [ + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "customInstance", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `apiUrl` TEXT NOT NULL, `frontendUrl` TEXT NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "apiUrl", + "columnName": "apiUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "frontendUrl", + "columnName": "frontendUrl", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "localSubscription", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`channelId` TEXT NOT NULL, PRIMARY KEY(`channelId`))", + "fields": [ + { + "fieldPath": "channelId", + "columnName": "channelId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "channelId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlistBookmark", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlistId` TEXT NOT NULL, `playlistName` TEXT, `thumbnailUrl` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `videos` INTEGER NOT NULL, PRIMARY KEY(`playlistId`))", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistName", + "columnName": "playlistName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploaderUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderAvatar", + "columnName": "uploaderAvatar", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "videos", + "columnName": "videos", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlistId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LocalPlaylist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT NOT NULL, `description` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "LocalPlaylistItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` INTEGER NOT NULL, `videoId` TEXT NOT NULL, `title` TEXT, `uploadDate` TEXT, `uploader` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `thumbnailUrl` TEXT, `duration` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "uploadDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploaderUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderAvatar", + "columnName": "uploaderAvatar", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT NOT NULL, `uploader` TEXT NOT NULL, `duration` INTEGER DEFAULT NULL, `uploadDate` TEXT, `thumbnailPath` TEXT, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "NULL" + }, + { + "fieldPath": "uploadDate", + "columnName": "uploadDate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailPath", + "columnName": "thumbnailPath", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "videoId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "downloadItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `type` TEXT NOT NULL, `videoId` TEXT NOT NULL, `fileName` TEXT NOT NULL, `path` TEXT NOT NULL, `url` TEXT, `format` TEXT, `quality` TEXT, `language` TEXT, `downloadSize` INTEGER NOT NULL, FOREIGN KEY(`videoId`) REFERENCES `download`(`videoId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fileName", + "columnName": "fileName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "path", + "columnName": "path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "quality", + "columnName": "quality", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "language", + "columnName": "language", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "downloadSize", + "columnName": "downloadSize", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_downloadItem_path", + "unique": true, + "columnNames": [ + "path" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_downloadItem_path` ON `${TABLE_NAME}` (`path`)" + } + ], + "foreignKeys": [ + { + "table": "download", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "videoId" + ], + "referencedColumns": [ + "videoId" + ] + } + ] + }, + { + "tableName": "downloadChapters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `videoId` TEXT NOT NULL, `name` TEXT NOT NULL, `start` INTEGER NOT NULL, `thumbnailUrl` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "start", + "columnName": "start", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "subscriptionGroups", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `channels` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "channels", + "columnName": "channels", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "feedItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`videoId` TEXT NOT NULL, `title` TEXT, `thumbnail` TEXT, `uploaderName` TEXT, `uploaderUrl` TEXT, `uploaderAvatar` TEXT, `duration` INTEGER, `views` INTEGER, `uploaderVerified` INTEGER NOT NULL, `uploaded` INTEGER NOT NULL, `shortDescription` TEXT, `isShort` INTEGER NOT NULL, PRIMARY KEY(`videoId`))", + "fields": [ + { + "fieldPath": "videoId", + "columnName": "videoId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnail", + "columnName": "thumbnail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderName", + "columnName": "uploaderName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploaderUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploaderAvatar", + "columnName": "uploaderAvatar", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "views", + "columnName": "views", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "uploaderVerified", + "columnName": "uploaderVerified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploaded", + "columnName": "uploaded", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shortDescription", + "columnName": "shortDescription", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isShort", + "columnName": "isShort", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "videoId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '74afbaf921a81ccd97447002122d5077')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/LibreTubeApp.kt b/app/src/main/java/com/github/libretube/LibreTubeApp.kt index 2b1d918e29..9a4620e064 100644 --- a/app/src/main/java/com/github/libretube/LibreTubeApp.kt +++ b/app/src/main/java/com/github/libretube/LibreTubeApp.kt @@ -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 @@ -55,6 +56,8 @@ class LibreTubeApp : Application() { * Dynamically create App Shortcuts */ ShortcutHelper.createShortcuts(this) + + NewPipeExtractorInstance.init() } /** diff --git a/app/src/main/java/com/github/libretube/api/StreamsExtractor.kt b/app/src/main/java/com/github/libretube/api/StreamsExtractor.kt index 0cbeb04c7a..c9fdecde8b 100644 --- a/app/src/main/java/com/github/libretube/api/StreamsExtractor.kt +++ b/app/src/main/java/com/github/libretube/api/StreamsExtractor.kt @@ -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, @@ -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, @@ -86,12 +76,12 @@ object StreamsExtractor { thumbnailUrl = resp.thumbnails.maxBy { it.height }.url, relatedStreams = resp.relatedItems.filterIsInstance().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, diff --git a/app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt b/app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt index 15d142ca8d..d932fca71c 100644 --- a/app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt +++ b/app/src/main/java/com/github/libretube/api/SubscriptionHelper.kt @@ -6,6 +6,7 @@ import com.github.libretube.constants.PreferenceKeys 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 @@ -26,6 +27,7 @@ object SubscriptionHelper { else -> LocalSubscriptionsRepository() } private val feedRepository: FeedRepository get() = when { + PreferenceHelper.getBoolean(PreferenceKeys.LOCAL_FEED_EXTRACTION, false) -> LocalFeedRepository() token.isNotEmpty() -> PipedAccountFeedRepository() else -> PipedNoAccountFeedRepository() } @@ -35,7 +37,8 @@ object SubscriptionHelper { suspend fun isSubscribed(channelId: String) = subscriptionsRepository.isSubscribed(channelId) suspend fun importSubscriptions(newChannels: List) = subscriptionsRepository.importSubscriptions(newChannels) suspend fun getSubscriptions() = subscriptionsRepository.getSubscriptions() - suspend fun getFeed() = feedRepository.getFeed(false) + suspend fun getSubscriptionChannelIds() = subscriptionsRepository.getSubscriptionChannelIds() + suspend fun getFeed(forceRefresh: Boolean) = feedRepository.getFeed(forceRefresh) fun handleUnsubscribe( context: Context, diff --git a/app/src/main/java/com/github/libretube/api/obj/StreamItem.kt b/app/src/main/java/com/github/libretube/api/obj/StreamItem.kt index 31f439ed6c..7003ed7b80 100644 --- a/app/src/main/java/com/github/libretube/api/obj/StreamItem.kt +++ b/app/src/main/java/com/github/libretube/api/obj/StreamItem.kt @@ -2,6 +2,7 @@ package com.github.libretube.api.obj import android.os.Parcelable import com.github.libretube.db.obj.LocalPlaylistItem +import com.github.libretube.db.obj.SubscriptionsFeedItem import com.github.libretube.extensions.toID import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @@ -40,6 +41,21 @@ data class StreamItem( ) } + fun toFeedItem() = SubscriptionsFeedItem( + videoId = url!!.toID(), + title = title, + thumbnail = thumbnail, + uploaderName = uploaderName, + uploaded = uploaded, + uploaderAvatar = uploaderAvatar, + uploaderUrl = uploaderUrl, + duration = duration, + uploaderVerified = uploaderVerified ?: false, + shortDescription = shortDescription, + views = views, + isShort = isShort + ) + companion object { const val TYPE_STREAM = "stream" const val TYPE_CHANNEL = "channel" diff --git a/app/src/main/java/com/github/libretube/api/obj/Streams.kt b/app/src/main/java/com/github/libretube/api/obj/Streams.kt index aa8fd0d16a..17ce5bac05 100644 --- a/app/src/main/java/com/github/libretube/api/obj/Streams.kt +++ b/app/src/main/java/com/github/libretube/api/obj/Streams.kt @@ -3,12 +3,11 @@ package com.github.libretube.api.obj import android.os.Parcelable import com.github.libretube.db.obj.DownloadItem import com.github.libretube.enums.FileType +import com.github.libretube.extensions.toLocalDate import com.github.libretube.helpers.ProxyHelper import com.github.libretube.json.SafeInstantSerializer import com.github.libretube.parcelable.DownloadData import kotlinx.datetime.Instant -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import kotlinx.serialization.SerialName @@ -100,8 +99,7 @@ data class Streams( uploaderName = uploader, uploaderUrl = uploaderUrl, uploaderAvatar = uploaderAvatar, - uploadedDate = uploadTimestamp?.toLocalDateTime(TimeZone.currentSystemDefault())?.date - ?.toString(), + uploadedDate = uploadTimestamp?.toLocalDate()?.toString(), uploaded = uploaded ?: uploadTimestamp?.toEpochMilliseconds() ?: 0, duration = duration, views = views, @@ -111,6 +109,6 @@ data class Streams( } companion object { - const val categoryMusic = "Music" + const val CATEGORY_MUSIC = "Music" } } diff --git a/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt b/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt index 18db42ce28..afe69ffebb 100644 --- a/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/github/libretube/constants/PreferenceKeys.kt @@ -113,6 +113,8 @@ object PreferenceKeys { const val HIDE_WATCHED_FROM_FEED = "hide_watched_from_feed" const val SELECTED_FEED_FILTERS = "filter_feed" const val FEED_SORT_ORDER = "sort_oder_feed" + const val LOCAL_FEED_EXTRACTION = "local_feed_extraction" + const val LAST_FEED_REFRESH_TIMESTAMP_MILLIS = "last_feed_refresh_timestamp_millis" // Advanced const val AUTOMATIC_UPDATE_CHECKS = "automatic_update_checks" diff --git a/app/src/main/java/com/github/libretube/db/AppDatabase.kt b/app/src/main/java/com/github/libretube/db/AppDatabase.kt index 1f877ffaa7..9a016dae52 100644 --- a/app/src/main/java/com/github/libretube/db/AppDatabase.kt +++ b/app/src/main/java/com/github/libretube/db/AppDatabase.kt @@ -11,6 +11,7 @@ import com.github.libretube.db.dao.LocalSubscriptionDao import com.github.libretube.db.dao.PlaylistBookmarkDao import com.github.libretube.db.dao.SearchHistoryDao import com.github.libretube.db.dao.SubscriptionGroupsDao +import com.github.libretube.db.dao.SubscriptionsFeedDao import com.github.libretube.db.dao.WatchHistoryDao import com.github.libretube.db.dao.WatchPositionDao import com.github.libretube.db.obj.CustomInstance @@ -23,6 +24,7 @@ import com.github.libretube.db.obj.LocalSubscription import com.github.libretube.db.obj.PlaylistBookmark import com.github.libretube.db.obj.SearchHistoryItem import com.github.libretube.db.obj.SubscriptionGroup +import com.github.libretube.db.obj.SubscriptionsFeedItem import com.github.libretube.db.obj.WatchHistoryItem import com.github.libretube.db.obj.WatchPosition @@ -39,15 +41,17 @@ import com.github.libretube.db.obj.WatchPosition Download::class, DownloadItem::class, DownloadChapter::class, - SubscriptionGroup::class + SubscriptionGroup::class, + SubscriptionsFeedItem::class ], - version = 18, + version = 19, autoMigrations = [ AutoMigration(from = 7, to = 8), AutoMigration(from = 8, to = 9), AutoMigration(from = 9, to = 10), AutoMigration(from = 10, to = 11), - AutoMigration(from = 16, to = 17) + AutoMigration(from = 16, to = 17), + AutoMigration(from = 18, to = 19) ] ) @TypeConverters(Converters::class) @@ -96,4 +100,6 @@ abstract class AppDatabase : RoomDatabase() { * Subscription groups */ abstract fun subscriptionGroupsDao(): SubscriptionGroupsDao + + abstract fun feedDao(): SubscriptionsFeedDao } diff --git a/app/src/main/java/com/github/libretube/db/Converters.kt b/app/src/main/java/com/github/libretube/db/Converters.kt index 71bfb289a2..fd7dc9bc4d 100644 --- a/app/src/main/java/com/github/libretube/db/Converters.kt +++ b/app/src/main/java/com/github/libretube/db/Converters.kt @@ -3,7 +3,6 @@ package com.github.libretube.db import androidx.room.TypeConverter import com.github.libretube.api.JsonHelper import kotlinx.datetime.LocalDate -import kotlinx.datetime.toLocalDate import kotlinx.serialization.encodeToString import java.nio.file.Path import kotlin.io.path.Path @@ -13,7 +12,7 @@ object Converters { fun localDateToString(localDate: LocalDate?) = localDate?.toString() @TypeConverter - fun stringToLocalDate(string: String?) = string?.toLocalDate() + fun stringToLocalDate(string: String?) = string?.let { LocalDate.parse(it) } @TypeConverter fun pathToString(path: Path?) = path?.toString() diff --git a/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt b/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt index 26b3b2900c..c0d422a8f1 100644 --- a/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt +++ b/app/src/main/java/com/github/libretube/db/DatabaseHelper.kt @@ -8,13 +8,11 @@ import com.github.libretube.db.obj.SearchHistoryItem import com.github.libretube.db.obj.WatchHistoryItem import com.github.libretube.enums.ContentFilter import com.github.libretube.extensions.toID +import com.github.libretube.extensions.toLocalDate import com.github.libretube.helpers.PreferenceHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext -import kotlinx.datetime.Instant -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime object DatabaseHelper { private const val MAX_SEARCH_HISTORY_SIZE = 20 @@ -28,8 +26,7 @@ object DatabaseHelper { val watchHistoryItem = WatchHistoryItem( videoId, stream.title, - Instant.fromEpochMilliseconds(stream.uploaded) - .toLocalDateTime(TimeZone.currentSystemDefault()).date, + stream.uploaded.toLocalDate(), stream.uploaderName, stream.uploaderUrl?.toID(), stream.uploaderAvatar, diff --git a/app/src/main/java/com/github/libretube/db/DatabaseHolder.kt b/app/src/main/java/com/github/libretube/db/DatabaseHolder.kt index b314eec6cd..b8440e624d 100644 --- a/app/src/main/java/com/github/libretube/db/DatabaseHolder.kt +++ b/app/src/main/java/com/github/libretube/db/DatabaseHolder.kt @@ -66,7 +66,6 @@ object DatabaseHolder { MIGRATION_15_16, MIGRATION_17_18 ) - .fallbackToDestructiveMigration() .build() } } diff --git a/app/src/main/java/com/github/libretube/db/dao/SubscriptionsFeedDao.kt b/app/src/main/java/com/github/libretube/db/dao/SubscriptionsFeedDao.kt new file mode 100644 index 0000000000..45396e615d --- /dev/null +++ b/app/src/main/java/com/github/libretube/db/dao/SubscriptionsFeedDao.kt @@ -0,0 +1,22 @@ +package com.github.libretube.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.github.libretube.db.obj.SubscriptionsFeedItem + +@Dao +interface SubscriptionsFeedDao { + @Query("SELECT * FROM feedItem ORDER BY uploaded DESC") + suspend fun getAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertAll(feedItems: List) + + @Query("DELETE FROM feedItem WHERE uploaded < :olderThan") + suspend fun cleanUpOlderThan(olderThan: Long) + + @Query("DELETE FROM feedItem") + suspend fun deleteAll() +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/db/obj/SubscriptionsFeedItem.kt b/app/src/main/java/com/github/libretube/db/obj/SubscriptionsFeedItem.kt new file mode 100644 index 0000000000..fa787556fe --- /dev/null +++ b/app/src/main/java/com/github/libretube/db/obj/SubscriptionsFeedItem.kt @@ -0,0 +1,40 @@ +package com.github.libretube.db.obj + +import androidx.room.Entity +import androidx.room.PrimaryKey +import com.github.libretube.api.obj.StreamItem +import com.github.libretube.extensions.toLocalDate + +@Entity(tableName = "feedItem") +data class SubscriptionsFeedItem( + @PrimaryKey + val videoId: String, + val title: String? = null, + val thumbnail: String? = null, + val uploaderName: String? = null, + val uploaderUrl: String? = null, + val uploaderAvatar: String? = null, + val duration: Long? = null, + val views: Long? = null, + val uploaderVerified: Boolean, + val uploaded: Long = 0, + val shortDescription: String? = null, + val isShort: Boolean = false +) { + fun toStreamItem() = StreamItem( + url = videoId, + type = StreamItem.TYPE_STREAM, + title = title, + thumbnail = thumbnail, + uploaderName = uploaderName, + uploaded = uploaded, + uploadedDate = uploaded.toLocalDate().toString(), + uploaderAvatar = uploaderAvatar, + uploaderUrl = uploaderUrl, + duration = duration, + uploaderVerified = uploaderVerified, + shortDescription = shortDescription, + views = views, + isShort = isShort + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/db/obj/WatchHistoryItem.kt b/app/src/main/java/com/github/libretube/db/obj/WatchHistoryItem.kt index 79b41fcb9a..93b3e68c97 100644 --- a/app/src/main/java/com/github/libretube/db/obj/WatchHistoryItem.kt +++ b/app/src/main/java/com/github/libretube/db/obj/WatchHistoryItem.kt @@ -30,6 +30,7 @@ data class WatchHistoryItem( thumbnail = thumbnailUrl, uploaderName = uploader, uploaded = uploadDate?.toMillis() ?: 0, + uploadedDate = uploadDate?.toString(), uploaderAvatar = uploaderAvatar, uploaderUrl = uploaderUrl, duration = duration, diff --git a/app/src/main/java/com/github/libretube/extensions/LocalDate.kt b/app/src/main/java/com/github/libretube/extensions/LocalDate.kt index 370d9a17a3..7974711e9d 100644 --- a/app/src/main/java/com/github/libretube/extensions/LocalDate.kt +++ b/app/src/main/java/com/github/libretube/extensions/LocalDate.kt @@ -1,7 +1,19 @@ package com.github.libretube.extensions +import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toLocalDateTime fun LocalDate.toMillis() = this.atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + +fun Long.toLocalDateTime() = + Instant.fromEpochMilliseconds(this).toLocalDateTime() + +fun Long.toLocalDate() = + Instant.fromEpochMilliseconds(this).toLocalDate() + +fun Instant.toLocalDateTime() = this.toLocalDateTime(TimeZone.currentSystemDefault()) + +fun Instant.toLocalDate() = this.toLocalDateTime().date \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/helpers/NewPipeExtractorInstance.kt b/app/src/main/java/com/github/libretube/helpers/NewPipeExtractorInstance.kt new file mode 100644 index 0000000000..1bf1a62d57 --- /dev/null +++ b/app/src/main/java/com/github/libretube/helpers/NewPipeExtractorInstance.kt @@ -0,0 +1,16 @@ +package com.github.libretube.helpers + +import com.github.libretube.util.NewPipeDownloaderImpl +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.ServiceList +import org.schabi.newpipe.extractor.StreamingService + +object NewPipeExtractorInstance { + val extractor: StreamingService by lazy { + NewPipe.getService(ServiceList.YouTube.serviceId) + } + + fun init() { + NewPipe.init(NewPipeDownloaderImpl()) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/json/SafeInstantSerializer.kt b/app/src/main/java/com/github/libretube/json/SafeInstantSerializer.kt index 0d063bb208..5fd01ef697 100644 --- a/app/src/main/java/com/github/libretube/json/SafeInstantSerializer.kt +++ b/app/src/main/java/com/github/libretube/json/SafeInstantSerializer.kt @@ -3,6 +3,7 @@ package com.github.libretube.json import android.util.Log import com.github.libretube.extensions.TAG import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.toInstant @@ -19,10 +20,10 @@ object SafeInstantSerializer : KSerializer { override fun deserialize(decoder: Decoder): Instant { val string = decoder.decodeString() return try { - string.toInstant() + Instant.parse(string) } catch (e: IllegalArgumentException) { Log.e(TAG(), "Error parsing date '$string'", e) - string.toLocalDate().atStartOfDayIn(TimeZone.currentSystemDefault()) + LocalDate.parse(string).atStartOfDayIn(TimeZone.currentSystemDefault()) } } diff --git a/app/src/main/java/com/github/libretube/repo/AccountSubscriptionsRepository.kt b/app/src/main/java/com/github/libretube/repo/AccountSubscriptionsRepository.kt index f9264665bc..270de8b197 100644 --- a/app/src/main/java/com/github/libretube/repo/AccountSubscriptionsRepository.kt +++ b/app/src/main/java/com/github/libretube/repo/AccountSubscriptionsRepository.kt @@ -3,6 +3,7 @@ package com.github.libretube.repo import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.obj.Subscribe import com.github.libretube.api.obj.Subscription +import com.github.libretube.extensions.toID import com.github.libretube.helpers.PreferenceHelper class AccountSubscriptionsRepository: SubscriptionsRepository { @@ -33,4 +34,8 @@ class AccountSubscriptionsRepository: SubscriptionsRepository { override suspend fun getSubscriptions(): List { return RetrofitInstance.authApi.subscriptions(token) } + + override suspend fun getSubscriptionChannelIds(): List { + return getSubscriptions().map { it.url.toID() } + } } \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/repo/LocalFeedRepository.kt b/app/src/main/java/com/github/libretube/repo/LocalFeedRepository.kt new file mode 100644 index 0000000000..215f11ff02 --- /dev/null +++ b/app/src/main/java/com/github/libretube/repo/LocalFeedRepository.kt @@ -0,0 +1,103 @@ +package com.github.libretube.repo + +import android.util.Log +import com.github.libretube.api.SubscriptionHelper +import com.github.libretube.api.obj.StreamItem +import com.github.libretube.constants.PreferenceKeys +import com.github.libretube.db.DatabaseHolder +import com.github.libretube.db.obj.SubscriptionsFeedItem +import com.github.libretube.extensions.parallelMap +import com.github.libretube.helpers.NewPipeExtractorInstance +import com.github.libretube.helpers.PreferenceHelper +import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_FRONTEND_URL +import org.schabi.newpipe.extractor.channel.ChannelInfo +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs +import org.schabi.newpipe.extractor.stream.StreamInfoItem +import java.time.Duration +import java.time.Instant + +class LocalFeedRepository : FeedRepository { + private val relevantTabs = + arrayOf(ChannelTabs.LIVESTREAMS, ChannelTabs.VIDEOS, ChannelTabs.SHORTS) + + override suspend fun getFeed(forceRefresh: Boolean): List { + val nowMillis = Instant.now().toEpochMilli() + + if (!forceRefresh) { + val feed = DatabaseHolder.Database.feedDao().getAll() + val oneDayAgo = nowMillis - Duration.ofDays(1).toMillis() + + // only refresh if feed is empty or last refresh was more than a day ago + val lastRefresh = + PreferenceHelper.getLong(PreferenceKeys.LAST_FEED_REFRESH_TIMESTAMP_MILLIS, 0) + if (feed.isNotEmpty() && lastRefresh > oneDayAgo) { + return DatabaseHolder.Database.feedDao().getAll() + .map(SubscriptionsFeedItem::toStreamItem) + } + } + + val minimumDateMillis = nowMillis - Duration.ofDays(MAX_FEED_AGE_DAYS).toMillis() + DatabaseHolder.Database.feedDao().cleanUpOlderThan(minimumDateMillis) + + refreshFeed(minimumDateMillis) + PreferenceHelper.putLong(PreferenceKeys.LAST_FEED_REFRESH_TIMESTAMP_MILLIS, nowMillis) + + return DatabaseHolder.Database.feedDao().getAll().map(SubscriptionsFeedItem::toStreamItem) + } + + private suspend fun refreshFeed(minimumDateMillis: Long) { + val channelIds = SubscriptionHelper.getSubscriptionChannelIds() + + for (channelIdChunk in channelIds.chunked(CHUNK_SIZE)) { + val collectedFeedItems = channelIdChunk.parallelMap { channelId -> + try { + getRelatedStreams(channelId) + } catch (e: Exception) { + Log.e(channelId, e.stackTraceToString()) + null + } + }.filterNotNull().flatten().map(StreamItem::toFeedItem) + .filter { it.uploaded > minimumDateMillis } + + DatabaseHolder.Database.feedDao().insertAll(collectedFeedItems) + } + } + + private suspend fun getRelatedStreams(channelId: String): List { + val channelInfo = ChannelInfo.getInfo("$YOUTUBE_FRONTEND_URL/channel/${channelId}") + val relevantInfoTabs = channelInfo.tabs.filter { tab -> + relevantTabs.any { tab.contentFilters.contains(it) } + } + + val related = relevantInfoTabs.parallelMap { tab -> + runCatching { + ChannelTabInfo.getInfo(NewPipeExtractorInstance.extractor, tab).relatedItems + }.getOrElse { emptyList() } + }.flatten().filterIsInstance() + + return related.map { item -> + StreamItem( + type = StreamItem.TYPE_STREAM, + url = item.url.replace(YOUTUBE_FRONTEND_URL, ""), + title = item.name, + uploaded = item.uploadDate?.offsetDateTime()?.toEpochSecond()?.times(1000) ?: 0, + uploadedDate = item.uploadDate?.offsetDateTime()?.toLocalDateTime()?.toLocalDate() + ?.toString(), + uploaderName = item.uploaderName, + uploaderUrl = item.uploaderUrl.replace(YOUTUBE_FRONTEND_URL, ""), + uploaderAvatar = channelInfo.avatars.maxByOrNull { it.height }?.url, + thumbnail = item.thumbnails.maxByOrNull { it.height }?.url, + duration = item.duration, + views = item.viewCount, + uploaderVerified = item.isUploaderVerified, + shortDescription = item.shortDescription + ) + } + } + + companion object { + private const val CHUNK_SIZE = 2 + private const val MAX_FEED_AGE_DAYS = 30L // 30 days + } +} \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/repo/LocalSubscriptionsRepository.kt b/app/src/main/java/com/github/libretube/repo/LocalSubscriptionsRepository.kt index 93cf8bd089..f64d599635 100644 --- a/app/src/main/java/com/github/libretube/repo/LocalSubscriptionsRepository.kt +++ b/app/src/main/java/com/github/libretube/repo/LocalSubscriptionsRepository.kt @@ -24,16 +24,20 @@ class LocalSubscriptionsRepository: SubscriptionsRepository { } override suspend fun getSubscriptions(): List { - val subscriptions = Database.localSubscriptionDao().getAll().map { it.channelId } + val channelIds = getSubscriptionChannelIds() return when { - subscriptions.size > GET_SUBSCRIPTIONS_LIMIT -> + channelIds.size > GET_SUBSCRIPTIONS_LIMIT -> RetrofitInstance.authApi - .unauthenticatedSubscriptions(subscriptions) + .unauthenticatedSubscriptions(channelIds) else -> RetrofitInstance.authApi.unauthenticatedSubscriptions( - subscriptions.joinToString(",") + channelIds.joinToString(",") ) } } + + override suspend fun getSubscriptionChannelIds(): List { + return Database.localSubscriptionDao().getAll().map { it.channelId } + } } \ No newline at end of file diff --git a/app/src/main/java/com/github/libretube/repo/PipedNoAccountFeedRepository.kt b/app/src/main/java/com/github/libretube/repo/PipedNoAccountFeedRepository.kt index 153636cfcb..29f48af86b 100644 --- a/app/src/main/java/com/github/libretube/repo/PipedNoAccountFeedRepository.kt +++ b/app/src/main/java/com/github/libretube/repo/PipedNoAccountFeedRepository.kt @@ -4,19 +4,18 @@ import com.github.libretube.api.RetrofitInstance import com.github.libretube.api.SubscriptionHelper import com.github.libretube.api.SubscriptionHelper.GET_SUBSCRIPTIONS_LIMIT import com.github.libretube.api.obj.StreamItem -import com.github.libretube.extensions.toID class PipedNoAccountFeedRepository: FeedRepository { override suspend fun getFeed(forceRefresh: Boolean): List { - val subscriptions = SubscriptionHelper.getSubscriptions().map { it.url.toID() } + val channelIds = SubscriptionHelper.getSubscriptionChannelIds() return when { - subscriptions.size > GET_SUBSCRIPTIONS_LIMIT -> + channelIds.size > GET_SUBSCRIPTIONS_LIMIT -> RetrofitInstance.authApi - .getUnauthenticatedFeed(subscriptions) + .getUnauthenticatedFeed(channelIds) else -> RetrofitInstance.authApi.getUnauthenticatedFeed( - subscriptions.joinToString(",") + channelIds.joinToString(",") ) } } diff --git a/app/src/main/java/com/github/libretube/repo/SubscriptionsRepository.kt b/app/src/main/java/com/github/libretube/repo/SubscriptionsRepository.kt index 4138b6c818..e97b0e9198 100644 --- a/app/src/main/java/com/github/libretube/repo/SubscriptionsRepository.kt +++ b/app/src/main/java/com/github/libretube/repo/SubscriptionsRepository.kt @@ -8,4 +8,5 @@ interface SubscriptionsRepository { suspend fun isSubscribed(channelId: String): Boolean? suspend fun importSubscriptions(newChannels: List) suspend fun getSubscriptions(): List + suspend fun getSubscriptionChannelIds(): List } \ No newline at end of file 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 2698280a3c..c5403aaf44 100644 --- a/app/src/main/java/com/github/libretube/services/DownloadService.kt +++ b/app/src/main/java/com/github/libretube/services/DownloadService.kt @@ -33,6 +33,7 @@ import com.github.libretube.enums.NotificationId import com.github.libretube.extensions.formatAsFileSize import com.github.libretube.extensions.getContentLength import com.github.libretube.extensions.parcelableExtra +import com.github.libretube.extensions.toLocalDate import com.github.libretube.extensions.toastFromMainDispatcher import com.github.libretube.extensions.toastFromMainThread import com.github.libretube.helpers.DownloadHelper @@ -56,8 +57,6 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -159,7 +158,7 @@ class DownloadService : LifecycleService() { streams.description, streams.uploader, streams.duration, - streams.uploadTimestamp?.toLocalDateTime(TimeZone.currentSystemDefault())?.date, + streams.uploadTimestamp?.toLocalDate(), thumbnailTargetPath ) Database.downloadDao().insertDownload(download) diff --git a/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt index 7bb4eec361..ae8a74e963 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/PlayerFragment.kt @@ -1059,7 +1059,7 @@ class PlayerFragment : Fragment(), OnlinePlayerOptions { // set media source and resolution in the beginning updateResolution(commonPlayerViewModel.isFullscreen.value == true) - if (streams.category == Streams.categoryMusic) { + if (streams.category == Streams.CATEGORY_MUSIC) { playerController.setPlaybackSpeed(1f) } } diff --git a/app/src/main/java/com/github/libretube/ui/fragments/SubscriptionsFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/SubscriptionsFragment.kt index c31d88b8a2..c9d53abfe3 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/SubscriptionsFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/SubscriptionsFragment.kt @@ -118,7 +118,7 @@ class SubscriptionsFragment : DynamicLayoutManagerFragment() { binding.subProgress.isVisible = true if (viewModel.videoFeed.value == null) { - viewModel.fetchFeed(requireContext()) + viewModel.fetchFeed(requireContext(), forceRefresh = false) } if (viewModel.subscriptions.value == null) { viewModel.fetchSubscriptions(requireContext()) @@ -134,7 +134,7 @@ class SubscriptionsFragment : DynamicLayoutManagerFragment() { binding.subRefresh.setOnRefreshListener { viewModel.fetchSubscriptions(requireContext()) - viewModel.fetchFeed(requireContext()) + viewModel.fetchFeed(requireContext(), forceRefresh = true) } binding.toggleSubs.isVisible = true diff --git a/app/src/main/java/com/github/libretube/ui/fragments/WatchHistoryFragment.kt b/app/src/main/java/com/github/libretube/ui/fragments/WatchHistoryFragment.kt index 0dcb7fd07c..c04ef7b371 100644 --- a/app/src/main/java/com/github/libretube/ui/fragments/WatchHistoryFragment.kt +++ b/app/src/main/java/com/github/libretube/ui/fragments/WatchHistoryFragment.kt @@ -18,7 +18,6 @@ import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.room.withTransaction import com.github.libretube.R -import com.github.libretube.api.obj.StreamItem import com.github.libretube.constants.PreferenceKeys import com.github.libretube.databinding.FragmentWatchHistoryBinding import com.github.libretube.db.DatabaseHelper @@ -180,18 +179,7 @@ class WatchHistoryFragment : DynamicLayoutManagerFragment() { binding.playAll.setOnClickListener { PlayingQueue.resetToDefaults() PlayingQueue.add( - *watchHistory.reversed().map { - StreamItem( - url = "/watch?v=${it.videoId}", - title = it.title, - thumbnail = it.thumbnailUrl, - uploaderName = it.uploader, - uploaderUrl = it.uploaderUrl, - uploaderAvatar = it.uploaderAvatar, - uploadedDate = it.uploadDate?.toString(), - duration = it.duration - ) - }.toTypedArray() + *watchHistory.reversed().map(WatchHistoryItem::toStreamItem).toTypedArray() ) NavigationHelper.navigateVideo( requireContext(), diff --git a/app/src/main/java/com/github/libretube/ui/models/HomeViewModel.kt b/app/src/main/java/com/github/libretube/ui/models/HomeViewModel.kt index 9a055e8f80..6f862aaba5 100644 --- a/app/src/main/java/com/github/libretube/ui/models/HomeViewModel.kt +++ b/app/src/main/java/com/github/libretube/ui/models/HomeViewModel.kt @@ -122,7 +122,7 @@ class HomeViewModel : ViewModel() { private suspend fun tryLoadFeed(subscriptionsViewModel: SubscriptionsViewModel): List { subscriptionsViewModel.videoFeed.value?.let { return it } - val feed = SubscriptionHelper.getFeed() + val feed = SubscriptionHelper.getFeed(forceRefresh = false) subscriptionsViewModel.videoFeed.postValue(feed) return if (hideWatched) feed.filterWatched() else feed diff --git a/app/src/main/java/com/github/libretube/ui/models/SubscriptionsViewModel.kt b/app/src/main/java/com/github/libretube/ui/models/SubscriptionsViewModel.kt index 2f74a4d300..f5aa3c75df 100644 --- a/app/src/main/java/com/github/libretube/ui/models/SubscriptionsViewModel.kt +++ b/app/src/main/java/com/github/libretube/ui/models/SubscriptionsViewModel.kt @@ -21,10 +21,10 @@ class SubscriptionsViewModel : ViewModel() { var subscriptions = MutableLiveData?>() - fun fetchFeed(context: Context) { + fun fetchFeed(context: Context, forceRefresh: Boolean) { viewModelScope.launch(Dispatchers.IO) { val videoFeed = try { - SubscriptionHelper.getFeed() + SubscriptionHelper.getFeed(forceRefresh = forceRefresh) } catch (e: Exception) { context.toastFromMainDispatcher(R.string.server_error) Log.e(TAG(), e.toString()) diff --git a/app/src/main/java/com/github/libretube/util/TextUtils.kt b/app/src/main/java/com/github/libretube/util/TextUtils.kt index 5a25f3e57c..aaef968e5b 100644 --- a/app/src/main/java/com/github/libretube/util/TextUtils.kt +++ b/app/src/main/java/com/github/libretube/util/TextUtils.kt @@ -9,10 +9,9 @@ import androidx.core.text.isDigitsOnly import com.github.libretube.BuildConfig import com.github.libretube.R import com.github.libretube.extensions.formatShort +import com.github.libretube.extensions.toLocalDate import com.google.common.math.IntMath.pow -import kotlinx.datetime.TimeZone import kotlinx.datetime.toJavaLocalDate -import kotlinx.datetime.toLocalDateTime import java.time.Instant import java.time.LocalDateTime import java.time.ZoneId @@ -51,9 +50,7 @@ object TextUtils { } fun localizeInstant(instant: kotlinx.datetime.Instant): String { - val date = instant.toLocalDateTime(TimeZone.currentSystemDefault()).date - - return localizeDate(date) + return localizeDate(instant.toLocalDate()) } /** diff --git a/app/src/main/java/com/github/libretube/workers/NotificationWorker.kt b/app/src/main/java/com/github/libretube/workers/NotificationWorker.kt index 252771e8c9..202234bb65 100644 --- a/app/src/main/java/com/github/libretube/workers/NotificationWorker.kt +++ b/app/src/main/java/com/github/libretube/workers/NotificationWorker.kt @@ -82,7 +82,7 @@ class NotificationWorker(appContext: Context, parameters: WorkerParameters) : // fetch the users feed val videoFeed = try { withContext(Dispatchers.IO) { - SubscriptionHelper.getFeed() + SubscriptionHelper.getFeed(forceRefresh = true) } } catch (e: Exception) { return false diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1a4be619a0..5a4ff5625a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -526,6 +526,8 @@ Would you like to play the video from the download folder? %1$s views Only delete already watched videos + Local feed extraction + Directly fetch the feed from YouTube. This may be significantly slower. Download Service diff --git a/app/src/main/res/xml/instance_settings.xml b/app/src/main/res/xml/instance_settings.xml index 37034dc1aa..8823f57e1c 100644 --- a/app/src/main/res/xml/instance_settings.xml +++ b/app/src/main/res/xml/instance_settings.xml @@ -91,4 +91,15 @@ + + + + + + \ No newline at end of file