diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt index e3cb6b2e4..90dd8ea05 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt @@ -1,6 +1,10 @@ /* * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later + * + * Phase U-4 / Path C-5: ChannelInfo + Videos tab moved to strawcore + * (rustypipe). The two separate ChannelInfo.getInfo + ChannelTabInfo.getInfo + * calls collapse into one Rust round-trip. */ package com.sulkta.straw.feature.channel @@ -8,19 +12,10 @@ package com.sulkta.straw.feature.channel import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.sulkta.straw.feature.search.StreamItem -import com.sulkta.straw.util.bestThumbnail -import kotlinx.coroutines.Dispatchers -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.ServiceList import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -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 -import org.schabi.newpipe.extractor.stream.StreamInfoItem data class ChannelUiState( val loading: Boolean = true, @@ -40,43 +35,24 @@ class ChannelViewModel : ViewModel() { _ui.value = ChannelUiState(loading = true) viewModelScope.launch { try { - val service = NewPipe.getService(ServiceList.YouTube.serviceId) - val info = withContext(Dispatchers.IO) { - ChannelInfo.getInfo(service, channelUrl) + val ch = uniffi.strawcore.channelInfo(channelUrl) + val videos = ch.videos.map { v -> + StreamItem( + url = v.url, + title = v.title.ifBlank { "(no title)" }, + uploader = v.uploader, + uploaderUrl = v.uploaderUrl, + thumbnail = v.thumbnail, + durationSeconds = v.durationSeconds, + viewCount = v.viewCount, + ) } - // AUD-HIGH: pick the Videos tab specifically rather than - // info.tabs.firstOrNull() which is YouTube's "Home" (a - // curated mix that mostly drops via filterIsInstance). - val videosTab = info.tabs.firstOrNull { - it.contentFilters.contains(ChannelTabs.VIDEOS) - } ?: info.tabs.firstOrNull() - val videos: List = if (videosTab != null) { - withContext(Dispatchers.IO) { - runCatching { - ChannelTabInfo.getInfo(service, videosTab) - .relatedItems - .filterIsInstance() - .map { - StreamItem( - url = it.url, - title = it.name ?: "(no title)", - uploader = it.uploaderName ?: info.name ?: "", - uploaderUrl = it.uploaderUrl ?: channelUrl, - thumbnail = bestThumbnail(it.thumbnails), - durationSeconds = it.duration, - viewCount = it.viewCount, - ) - } - }.getOrDefault(emptyList()) - } - } else emptyList() - _ui.value = ChannelUiState( loading = false, - name = info.name ?: "", - subscriberCount = info.subscriberCount, - banner = bestThumbnail(info.banners), - avatar = bestThumbnail(info.avatars), + name = ch.name, + subscriberCount = ch.subscriberCount, + banner = ch.banner, + avatar = ch.avatar, videos = videos, ) } catch (t: Throwable) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 880194d30..96579977d 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -17,19 +17,12 @@ import com.sulkta.straw.data.WatchHistoryItem import com.sulkta.straw.net.RydClient import com.sulkta.straw.net.RydVotes import com.sulkta.straw.net.SponsorBlockClient -import com.sulkta.straw.util.bestThumbnail import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.ServiceList -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 data class VideoDetail( val id: String, @@ -111,36 +104,27 @@ class VideoDetailViewModel : ViewModel() { ) } - // More from this channel — still on NewPipeExtractor for now. - // C-5 will swap this to strawcore.channelInfo(url). + // More from this channel via strawcore.channelInfo — one + // Rust round-trip returns the channel's Videos tab pre-mapped. val moreFromChannel: List = if (info.uploaderUrl.isNullOrBlank()) emptyList() - else withContext(Dispatchers.IO) { - runCatching { - val service = NewPipe.getService(ServiceList.YouTube.serviceId) - val ch = ChannelInfo.getInfo(service, info.uploaderUrl) - val videosTab = ch.tabs.firstOrNull { - it.contentFilters.contains(ChannelTabs.VIDEOS) - } ?: ch.tabs.firstOrNull() - if (videosTab == null) emptyList() - else ChannelTabInfo.getInfo(service, videosTab) - .relatedItems - .filterIsInstance() - .filter { it.url != streamUrl } - .take(20) - .map { si -> - com.sulkta.straw.feature.search.StreamItem( - url = si.url, - title = si.name ?: "(no title)", - uploader = si.uploaderName ?: uploader, - uploaderUrl = si.uploaderUrl ?: info.uploaderUrl, - thumbnail = bestThumbnail(si.thumbnails), - durationSeconds = si.duration, - viewCount = si.viewCount, - ) - } - }.getOrDefault(emptyList()) - } + else runCatching { + val ch = uniffi.strawcore.channelInfo(info.uploaderUrl) + ch.videos + .filter { it.url != streamUrl } + .take(20) + .map { v -> + com.sulkta.straw.feature.search.StreamItem( + url = v.url, + title = v.title.ifBlank { "(no title)" }, + uploader = v.uploader.ifBlank { uploader }, + uploaderUrl = v.uploaderUrl ?: info.uploaderUrl, + thumbnail = v.thumbnail, + durationSeconds = v.durationSeconds, + viewCount = v.viewCount, + ) + } + }.getOrDefault(emptyList()) _ui.value = VideoDetailUiState( loading = false, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index 93ca74467..d7f27b11c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -3,8 +3,12 @@ * SPDX-License-Identifier: GPL-3.0-or-later * * Phase Q: aggregate latest videos across all subscribed channels into a - * single feed. Fans out per-channel ChannelInfo + ChannelTabs.VIDEOS - * fetches in parallel, merges by view count desc, caps at 200 items. + * single feed. Fans out per-channel channelInfo() fetches in parallel, + * merges by view count desc, caps at 200 items. + * + * Path C-5: each per-channel fetch is now ONE strawcore.channelInfo() + * call instead of two NewPipeExtractor round-trips (ChannelInfo.getInfo + + * ChannelTabInfo.getInfo). Halves the network work for the feed. * * Audit fixes (2026-05-24 pass #2): * HIGH-6: cancel any prior in-flight refresh when a new one starts, cap @@ -20,9 +24,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.search.StreamItem -import com.sulkta.straw.util.bestThumbnail import com.sulkta.straw.util.strawLogW -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -34,14 +36,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.ServiceList -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 data class SubscriptionFeedUiState( val loading: Boolean = false, @@ -82,55 +77,46 @@ class SubscriptionFeedViewModel : ViewModel() { _ui.update { it.copy(loading = true, error = null) } inFlight = viewModelScope.launch { try { - val items = withContext(Dispatchers.IO) { - val service = NewPipe.getService(ServiceList.YouTube.serviceId) - val perChannelMax = 5 - val gate = Semaphore(parallelism) - coroutineScope { - val deferreds = channels.map { ch -> - async { - gate.withPermit { - withTimeoutOrNull(perChannelTimeoutMs) { - runCatching { - val info = ChannelInfo.getInfo(service, ch.url) - val tab = info.tabs.firstOrNull { - it.contentFilters.contains(ChannelTabs.VIDEOS) - } ?: info.tabs.firstOrNull() - ?: return@runCatching emptyList() - ChannelTabInfo.getInfo(service, tab) - .relatedItems - .filterIsInstance() - .take(perChannelMax) - .map { si -> - StreamItem( - url = si.url, - title = si.name ?: "(no title)", - uploader = si.uploaderName ?: ch.name, - uploaderUrl = si.uploaderUrl ?: ch.url, - thumbnail = bestThumbnail(si.thumbnails), - durationSeconds = si.duration, - viewCount = si.viewCount, - ) - } - }.onFailure { - strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" } - }.getOrDefault(emptyList()) - } ?: run { - strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" } - emptyList() - } + val perChannelMax = 5 + val gate = Semaphore(parallelism) + val items = coroutineScope { + val deferreds = channels.map { ch -> + async { + gate.withPermit { + withTimeoutOrNull(perChannelTimeoutMs) { + runCatching { + val info = uniffi.strawcore.channelInfo(ch.url) + info.videos + .take(perChannelMax) + .map { v -> + StreamItem( + url = v.url, + title = v.title.ifBlank { "(no title)" }, + uploader = v.uploader.ifBlank { ch.name }, + uploaderUrl = v.uploaderUrl ?: ch.url, + thumbnail = v.thumbnail, + durationSeconds = v.durationSeconds, + viewCount = v.viewCount, + ) + } + }.onFailure { + strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" } + }.getOrDefault(emptyList()) + } ?: run { + strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" } + emptyList() } } } - deferreds.awaitAll() } - .flatten() - // No reliable upload-timestamp from extractor's StreamInfoItem - // in all cases — sort by view count desc as a soft proxy for - // recency-popularity within the recent window. - .sortedByDescending { it.viewCount } - .take(200) + deferreds.awaitAll() } + .flatten() + // No reliable upload-timestamp on the search-item shape — sort + // by view count desc as a soft proxy for recency-popularity + // within the recent window. + .sortedByDescending { it.viewCount } + .take(200) _ui.update { SubscriptionFeedUiState( loading = false,