feat: add progress indicator for local feed extraction

This commit is contained in:
Bnyro 2025-03-01 13:37:41 +01:00
parent ca58a2fc18
commit dc0be0d352
No known key found for this signature in database
9 changed files with 126 additions and 24 deletions

View File

@ -5,6 +5,7 @@ import com.github.libretube.R
import com.github.libretube.constants.PreferenceKeys import com.github.libretube.constants.PreferenceKeys
import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.repo.AccountSubscriptionsRepository import com.github.libretube.repo.AccountSubscriptionsRepository
import com.github.libretube.repo.FeedProgress
import com.github.libretube.repo.FeedRepository import com.github.libretube.repo.FeedRepository
import com.github.libretube.repo.LocalFeedRepository import com.github.libretube.repo.LocalFeedRepository
import com.github.libretube.repo.LocalSubscriptionsRepository import com.github.libretube.repo.LocalSubscriptionsRepository
@ -22,12 +23,18 @@ object SubscriptionHelper {
const val GET_SUBSCRIPTIONS_LIMIT = 100 const val GET_SUBSCRIPTIONS_LIMIT = 100
private val token get() = PreferenceHelper.getToken() private val token get() = PreferenceHelper.getToken()
private val subscriptionsRepository: SubscriptionsRepository get() = when { private val subscriptionsRepository: SubscriptionsRepository
get() = when {
token.isNotEmpty() -> AccountSubscriptionsRepository() token.isNotEmpty() -> AccountSubscriptionsRepository()
else -> LocalSubscriptionsRepository() else -> LocalSubscriptionsRepository()
} }
private val feedRepository: FeedRepository get() = when { private val feedRepository: FeedRepository
PreferenceHelper.getBoolean(PreferenceKeys.LOCAL_FEED_EXTRACTION, false) -> LocalFeedRepository() get() = when {
PreferenceHelper.getBoolean(
PreferenceKeys.LOCAL_FEED_EXTRACTION,
false
) -> LocalFeedRepository()
token.isNotEmpty() -> PipedAccountFeedRepository() token.isNotEmpty() -> PipedAccountFeedRepository()
else -> PipedNoAccountFeedRepository() else -> PipedNoAccountFeedRepository()
} }
@ -35,10 +42,13 @@ object SubscriptionHelper {
suspend fun subscribe(channelId: String) = subscriptionsRepository.subscribe(channelId) suspend fun subscribe(channelId: String) = subscriptionsRepository.subscribe(channelId)
suspend fun unsubscribe(channelId: String) = subscriptionsRepository.unsubscribe(channelId) suspend fun unsubscribe(channelId: String) = subscriptionsRepository.unsubscribe(channelId)
suspend fun isSubscribed(channelId: String) = subscriptionsRepository.isSubscribed(channelId) suspend fun isSubscribed(channelId: String) = subscriptionsRepository.isSubscribed(channelId)
suspend fun importSubscriptions(newChannels: List<String>) = subscriptionsRepository.importSubscriptions(newChannels) suspend fun importSubscriptions(newChannels: List<String>) =
subscriptionsRepository.importSubscriptions(newChannels)
suspend fun getSubscriptions() = subscriptionsRepository.getSubscriptions() suspend fun getSubscriptions() = subscriptionsRepository.getSubscriptions()
suspend fun getSubscriptionChannelIds() = subscriptionsRepository.getSubscriptionChannelIds() suspend fun getSubscriptionChannelIds() = subscriptionsRepository.getSubscriptionChannelIds()
suspend fun getFeed(forceRefresh: Boolean) = feedRepository.getFeed(forceRefresh) suspend fun getFeed(forceRefresh: Boolean, onProgressUpdate: (FeedProgress) -> Unit = {}) =
feedRepository.getFeed(forceRefresh, onProgressUpdate)
fun handleUnsubscribe( fun handleUnsubscribe(
context: Context, context: Context,

View File

@ -2,6 +2,14 @@ package com.github.libretube.repo
import com.github.libretube.api.obj.StreamItem import com.github.libretube.api.obj.StreamItem
data class FeedProgress(
val currentProgress: Int,
val total: Int
)
interface FeedRepository { interface FeedRepository {
suspend fun getFeed(forceRefresh: Boolean): List<StreamItem> suspend fun getFeed(
forceRefresh: Boolean,
onProgressUpdate: (FeedProgress) -> Unit
): List<StreamItem>
} }

View File

@ -13,7 +13,9 @@ import com.github.libretube.extensions.toID
import com.github.libretube.helpers.NewPipeExtractorInstance import com.github.libretube.helpers.NewPipeExtractorInstance
import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_FRONTEND_URL import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_FRONTEND_URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.channel.ChannelInfo import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
@ -33,7 +35,10 @@ class LocalFeedRepository : FeedRepository {
if (filter.isEnabled) tab else null if (filter.isEnabled) tab else null
}.toTypedArray() }.toTypedArray()
override suspend fun getFeed(forceRefresh: Boolean): List<StreamItem> { override suspend fun getFeed(
forceRefresh: Boolean,
onProgressUpdate: (FeedProgress) -> Unit
): List<StreamItem> {
val nowMillis = Instant.now().toEpochMilli() val nowMillis = Instant.now().toEpochMilli()
val minimumDateMillis = nowMillis - Duration.ofDays(MAX_FEED_AGE_DAYS).toMillis() val minimumDateMillis = nowMillis - Duration.ofDays(MAX_FEED_AGE_DAYS).toMillis()
@ -58,21 +63,31 @@ class LocalFeedRepository : FeedRepository {
} }
DatabaseHolder.Database.feedDao().cleanUpOlderThan(minimumDateMillis) DatabaseHolder.Database.feedDao().cleanUpOlderThan(minimumDateMillis)
refreshFeed(channelIds, minimumDateMillis) refreshFeed(channelIds, minimumDateMillis, onProgressUpdate)
PreferenceHelper.putLong(PreferenceKeys.LAST_FEED_REFRESH_TIMESTAMP_MILLIS, nowMillis) PreferenceHelper.putLong(PreferenceKeys.LAST_FEED_REFRESH_TIMESTAMP_MILLIS, nowMillis)
return DatabaseHolder.Database.feedDao().getAll().map(SubscriptionsFeedItem::toStreamItem) return DatabaseHolder.Database.feedDao().getAll().map(SubscriptionsFeedItem::toStreamItem)
} }
private suspend fun refreshFeed(channelIds: List<String>, minimumDateMillis: Long) { private suspend fun refreshFeed(
val extractionCount = AtomicInteger() channelIds: List<String>,
minimumDateMillis: Long,
onProgressUpdate: (FeedProgress) -> Unit
) {
if (channelIds.isEmpty()) return
val totalExtractionCount = AtomicInteger()
val chunkedExtractionCount = AtomicInteger()
withContext(Dispatchers.Main) {
onProgressUpdate(FeedProgress(0, channelIds.size))
}
for (channelIdChunk in channelIds.chunked(CHUNK_SIZE)) { for (channelIdChunk in channelIds.chunked(CHUNK_SIZE)) {
// add a delay after each BATCH_SIZE amount of visited channels // add a delay after each BATCH_SIZE amount of visited channels
val count = extractionCount.get(); val count = chunkedExtractionCount.get();
if (count >= BATCH_SIZE) { if (count >= BATCH_SIZE) {
delay(BATCH_DELAY.random()) delay(BATCH_DELAY.random())
extractionCount.set(0) chunkedExtractionCount.set(0)
} }
val collectedFeedItems = channelIdChunk.parallelMap { channelId -> val collectedFeedItems = channelIdChunk.parallelMap { channelId ->
@ -82,7 +97,12 @@ class LocalFeedRepository : FeedRepository {
Log.e(channelId, e.stackTraceToString()) Log.e(channelId, e.stackTraceToString())
null null
} finally { } finally {
extractionCount.incrementAndGet(); chunkedExtractionCount.incrementAndGet()
val currentProgress = totalExtractionCount.incrementAndGet()
withContext(Dispatchers.Main) {
onProgressUpdate(FeedProgress(currentProgress, channelIds.size))
}
} }
}.filterNotNull().flatten().map(StreamItem::toFeedItem) }.filterNotNull().flatten().map(StreamItem::toFeedItem)
@ -133,10 +153,12 @@ class LocalFeedRepository : FeedRepository {
companion object { companion object {
private const val CHUNK_SIZE = 2 private const val CHUNK_SIZE = 2
/** /**
* Maximum amount of feeds that should be fetched together, before a delay should be applied. * Maximum amount of feeds that should be fetched together, before a delay should be applied.
*/ */
private const val BATCH_SIZE = 50 private const val BATCH_SIZE = 50
/** /**
* Millisecond delay between two consecutive batches to avoid throttling. * Millisecond delay between two consecutive batches to avoid throttling.
*/ */

View File

@ -4,8 +4,11 @@ import com.github.libretube.api.RetrofitInstance
import com.github.libretube.api.obj.StreamItem import com.github.libretube.api.obj.StreamItem
import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.helpers.PreferenceHelper
class PipedAccountFeedRepository: FeedRepository { class PipedAccountFeedRepository : FeedRepository {
override suspend fun getFeed(forceRefresh: Boolean): List<StreamItem> { override suspend fun getFeed(
forceRefresh: Boolean,
onProgressUpdate: (FeedProgress) -> Unit
): List<StreamItem> {
val token = PreferenceHelper.getToken() val token = PreferenceHelper.getToken()
return RetrofitInstance.authApi.getFeed(token) return RetrofitInstance.authApi.getFeed(token)

View File

@ -5,8 +5,11 @@ import com.github.libretube.api.SubscriptionHelper
import com.github.libretube.api.SubscriptionHelper.GET_SUBSCRIPTIONS_LIMIT import com.github.libretube.api.SubscriptionHelper.GET_SUBSCRIPTIONS_LIMIT
import com.github.libretube.api.obj.StreamItem import com.github.libretube.api.obj.StreamItem
class PipedNoAccountFeedRepository: FeedRepository { class PipedNoAccountFeedRepository : FeedRepository {
override suspend fun getFeed(forceRefresh: Boolean): List<StreamItem> { override suspend fun getFeed(
forceRefresh: Boolean,
onProgressUpdate: (FeedProgress) -> Unit
): List<StreamItem> {
val channelIds = SubscriptionHelper.getSubscriptionChannelIds() val channelIds = SubscriptionHelper.getSubscriptionChannelIds()
return when { return when {

View File

@ -95,6 +95,7 @@ class SubscriptionsFragment : DynamicLayoutManagerFragment(R.layout.fragment_sub
_binding?.subFeed?.layoutManager = VideosAdapter.getLayout(requireContext(), gridItems) _binding?.subFeed?.layoutManager = VideosAdapter.getLayout(requireContext(), gridItems)
} }
@SuppressLint("SetTextI18n")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
_binding = FragmentSubscriptionsBinding.bind(view) _binding = FragmentSubscriptionsBinding.bind(view)
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
@ -150,6 +151,17 @@ class SubscriptionsFragment : DynamicLayoutManagerFragment(R.layout.fragment_sub
if (isCurrentTabSubChannels && it != null) showSubscriptions() if (isCurrentTabSubChannels && it != null) showSubscriptions()
} }
viewModel.feedProgress.observe(viewLifecycleOwner) { progress ->
if (progress == null || progress.currentProgress == progress.total) {
binding.feedProgressContainer.isGone = true
} else {
binding.feedProgressContainer.isVisible = true
binding.feedProgressText.text = "${progress.currentProgress}/${progress.total}"
binding.feedProgressBar.max = progress.total
binding.feedProgressBar.progress = progress.currentProgress
}
}
binding.subRefresh.setOnRefreshListener { binding.subRefresh.setOnRefreshListener {
viewModel.fetchSubscriptions(requireContext()) viewModel.fetchSubscriptions(requireContext())
viewModel.fetchFeed(requireContext(), forceRefresh = true) viewModel.fetchFeed(requireContext(), forceRefresh = true)

View File

@ -13,6 +13,7 @@ import com.github.libretube.extensions.TAG
import com.github.libretube.extensions.toID import com.github.libretube.extensions.toID
import com.github.libretube.extensions.toastFromMainDispatcher import com.github.libretube.extensions.toastFromMainDispatcher
import com.github.libretube.helpers.PreferenceHelper import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.repo.FeedProgress
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -20,11 +21,14 @@ class SubscriptionsViewModel : ViewModel() {
var videoFeed = MutableLiveData<List<StreamItem>?>() var videoFeed = MutableLiveData<List<StreamItem>?>()
var subscriptions = MutableLiveData<List<Subscription>?>() var subscriptions = MutableLiveData<List<Subscription>?>()
val feedProgress = MutableLiveData<FeedProgress?>()
fun fetchFeed(context: Context, forceRefresh: Boolean) { fun fetchFeed(context: Context, forceRefresh: Boolean) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val videoFeed = try { val videoFeed = try {
SubscriptionHelper.getFeed(forceRefresh = forceRefresh) SubscriptionHelper.getFeed(forceRefresh = forceRefresh) { feedProgress ->
this@SubscriptionsViewModel.feedProgress.postValue(feedProgress)
}
} catch (e: Exception) { } catch (e: Exception) {
context.toastFromMainDispatcher(R.string.server_error) context.toastFromMainDispatcher(R.string.server_error)
Log.e(TAG(), e.toString()) Log.e(TAG(), e.toString())

View File

@ -145,6 +145,45 @@
</HorizontalScrollView> </HorizontalScrollView>
<LinearLayout
android:visibility="gone"
android:id="@+id/feed_progress_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="10dp"
android:layout_marginTop="6dp"
android:layout_marginHorizontal="12dp"
android:orientation="vertical"
tools:visibility="visible">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/feed_progress_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="2dp"
tools:progress="70" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/updating_feed" />
<TextView
android:id="@+id/feed_progress_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
tools:text="5/20" />
</LinearLayout>
</LinearLayout>
</LinearLayout> </LinearLayout>
</com.google.android.material.appbar.CollapsingToolbarLayout> </com.google.android.material.appbar.CollapsingToolbarLayout>

View File

@ -531,6 +531,7 @@
<string name="local_feed_extraction">Local feed extraction</string> <string name="local_feed_extraction">Local feed extraction</string>
<string name="local_feed_extraction_summary">Directly fetch the feed from YouTube. This may be significantly slower.</string> <string name="local_feed_extraction_summary">Directly fetch the feed from YouTube. This may be significantly slower.</string>
<string name="show_upcoming_videos">Show upcoming videos</string> <string name="show_upcoming_videos">Show upcoming videos</string>
<string name="updating_feed">Updating feed …</string>
<!-- Notification channel strings --> <!-- Notification channel strings -->
<string name="download_channel_name">Download Service</string> <string name="download_channel_name">Download Service</string>