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:
parent
198d2a9066
commit
90930ade11
3 changed files with 79 additions and 133 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue