Path C-5: channel + sub feed + moreFromChannel swap to strawcore

Three ViewModels move from NewPipeExtractor (Java) to strawcore (rustypipe):

- ChannelViewModel.load — ChannelInfo.getInfo + ChannelTabInfo.getInfo
  collapse into one uniffi.strawcore.channelInfo() round-trip.

- SubscriptionFeedViewModel.refresh — per-channel parallel fan-out now
  fires uniffi.strawcore.channelInfo() per sub instead of two NPE round-
  trips. Halves the network work for the home sub-feed. Semaphore +
  timeout + cancel-on-respawn audit guards preserved.

- VideoDetailViewModel.moreFromChannel — was the last NPE call site in
  the load() path. Now strawcore.channelInfo(uploaderUrl).videos filtered
  + mapped. The unused withContext(Dispatchers.IO) wrapper for the
  channel fetch is gone (strawcore is suspend on tokio).

NewPipeExtractor is now reachable only from non-ViewModel code:
NewPipeDownloader.kt (OkHttp adapter), StrawApp.NewPipe.init(),
util/Thumbnails.kt. C-6 deletes all three.
This commit is contained in:
Kayos 2026-05-24 13:21:33 -07:00
parent 198d2a9066
commit 90930ade11
3 changed files with 79 additions and 133 deletions

View file

@ -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)
}
// 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<StreamItem> = if (videosTab != null) {
withContext(Dispatchers.IO) {
runCatching {
ChannelTabInfo.getInfo(service, videosTab)
.relatedItems
.filterIsInstance<StreamInfoItem>()
.map {
val ch = uniffi.strawcore.channelInfo(channelUrl)
val videos = ch.videos.map { v ->
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,
url = v.url,
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader,
uploaderUrl = v.uploaderUrl,
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.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) {

View file

@ -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<com.sulkta.straw.feature.search.StreamItem> =
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<StreamInfoItem>()
else runCatching {
val ch = uniffi.strawcore.channelInfo(info.uploaderUrl)
ch.videos
.filter { it.url != streamUrl }
.take(20)
.map { si ->
.map { v ->
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,
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,

View file

@ -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,34 +77,26 @@ 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 items = 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<StreamItem>()
ChannelTabInfo.getInfo(service, tab)
.relatedItems
.filterIsInstance<StreamInfoItem>()
val info = uniffi.strawcore.channelInfo(ch.url)
info.videos
.take(perChannelMax)
.map { si ->
.map { v ->
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,
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 {
@ -125,12 +112,11 @@ class SubscriptionFeedViewModel : ViewModel() {
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.
// 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,