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

View File

@ -2,6 +2,14 @@ package com.github.libretube.repo
import com.github.libretube.api.obj.StreamItem
data class FeedProgress(
val currentProgress: Int,
val total: Int
)
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.PreferenceHelper
import com.github.libretube.ui.dialogs.ShareDialog.Companion.YOUTUBE_FRONTEND_URL
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
@ -33,7 +35,10 @@ class LocalFeedRepository : FeedRepository {
if (filter.isEnabled) tab else null
}.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 minimumDateMillis = nowMillis - Duration.ofDays(MAX_FEED_AGE_DAYS).toMillis()
@ -58,21 +63,31 @@ class LocalFeedRepository : FeedRepository {
}
DatabaseHolder.Database.feedDao().cleanUpOlderThan(minimumDateMillis)
refreshFeed(channelIds, minimumDateMillis)
refreshFeed(channelIds, minimumDateMillis, onProgressUpdate)
PreferenceHelper.putLong(PreferenceKeys.LAST_FEED_REFRESH_TIMESTAMP_MILLIS, nowMillis)
return DatabaseHolder.Database.feedDao().getAll().map(SubscriptionsFeedItem::toStreamItem)
}
private suspend fun refreshFeed(channelIds: List<String>, minimumDateMillis: Long) {
val extractionCount = AtomicInteger()
private suspend fun refreshFeed(
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)) {
// add a delay after each BATCH_SIZE amount of visited channels
val count = extractionCount.get();
val count = chunkedExtractionCount.get();
if (count >= BATCH_SIZE) {
delay(BATCH_DELAY.random())
extractionCount.set(0)
chunkedExtractionCount.set(0)
}
val collectedFeedItems = channelIdChunk.parallelMap { channelId ->
@ -82,7 +97,12 @@ class LocalFeedRepository : FeedRepository {
Log.e(channelId, e.stackTraceToString())
null
} finally {
extractionCount.incrementAndGet();
chunkedExtractionCount.incrementAndGet()
val currentProgress = totalExtractionCount.incrementAndGet()
withContext(Dispatchers.Main) {
onProgressUpdate(FeedProgress(currentProgress, channelIds.size))
}
}
}.filterNotNull().flatten().map(StreamItem::toFeedItem)
@ -133,10 +153,12 @@ class LocalFeedRepository : FeedRepository {
companion object {
private const val CHUNK_SIZE = 2
/**
* Maximum amount of feeds that should be fetched together, before a delay should be applied.
*/
private const val BATCH_SIZE = 50
/**
* 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.helpers.PreferenceHelper
class PipedAccountFeedRepository: FeedRepository {
override suspend fun getFeed(forceRefresh: Boolean): List<StreamItem> {
class PipedAccountFeedRepository : FeedRepository {
override suspend fun getFeed(
forceRefresh: Boolean,
onProgressUpdate: (FeedProgress) -> Unit
): List<StreamItem> {
val token = PreferenceHelper.getToken()
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.obj.StreamItem
class PipedNoAccountFeedRepository: FeedRepository {
override suspend fun getFeed(forceRefresh: Boolean): List<StreamItem> {
class PipedNoAccountFeedRepository : FeedRepository {
override suspend fun getFeed(
forceRefresh: Boolean,
onProgressUpdate: (FeedProgress) -> Unit
): List<StreamItem> {
val channelIds = SubscriptionHelper.getSubscriptionChannelIds()
return when {

View File

@ -95,6 +95,7 @@ class SubscriptionsFragment : DynamicLayoutManagerFragment(R.layout.fragment_sub
_binding?.subFeed?.layoutManager = VideosAdapter.getLayout(requireContext(), gridItems)
}
@SuppressLint("SetTextI18n")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
_binding = FragmentSubscriptionsBinding.bind(view)
super.onViewCreated(view, savedInstanceState)
@ -150,6 +151,17 @@ class SubscriptionsFragment : DynamicLayoutManagerFragment(R.layout.fragment_sub
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 {
viewModel.fetchSubscriptions(requireContext())
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.toastFromMainDispatcher
import com.github.libretube.helpers.PreferenceHelper
import com.github.libretube.repo.FeedProgress
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -20,11 +21,14 @@ class SubscriptionsViewModel : ViewModel() {
var videoFeed = MutableLiveData<List<StreamItem>?>()
var subscriptions = MutableLiveData<List<Subscription>?>()
val feedProgress = MutableLiveData<FeedProgress?>()
fun fetchFeed(context: Context, forceRefresh: Boolean) {
viewModelScope.launch(Dispatchers.IO) {
val videoFeed = try {
SubscriptionHelper.getFeed(forceRefresh = forceRefresh)
SubscriptionHelper.getFeed(forceRefresh = forceRefresh) { feedProgress ->
this@SubscriptionsViewModel.feedProgress.postValue(feedProgress)
}
} catch (e: Exception) {
context.toastFromMainDispatcher(R.string.server_error)
Log.e(TAG(), e.toString())

View File

@ -145,6 +145,45 @@
</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>
</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_summary">Directly fetch the feed from YouTube. This may be significantly slower.</string>
<string name="show_upcoming_videos">Show upcoming videos</string>
<string name="updating_feed">Updating feed …</string>
<!-- Notification channel strings -->
<string name="download_channel_name">Download Service</string>