v0.1.0-X (vc=12): revert to NewPipeExtractor for working playback

Phase U (rustypipe Rust extractor) rolled back. Symptom: black screen
on play, root cause: rustypipe 0.11.4's JS deobfuscator can't parse
current YouTube player.js (YT changed the obfuscation pattern, no
upstream rustypipe release since June 2025). Switching clients
(Web → TV → Android/Ios) didn't help — the deobfuscator init fires
universally.

Kept in place for the future:
- rust/strawcore/ Cargo workspace + UniFFI scaffolding
- build-host runtime install (rustup + 4 Android targets +
  cargo-ndk + NDK r27c)
- The U-2..U-5 commits in history (re-runnable when rustypipe is
  fixed or we fork it).

Restored from commit 9c4b3f831 (v0.1.0-T):
- NewPipe.init() in StrawApp.onCreate
- libs.newpipe.extractor + libs.squareup.okhttp deps
- NewPipeDownloader.kt + Thumbnails.kt
- ViewModels (Search/VideoDetail/Player/Channel/SubscriptionFeed) on
  NewPipeExtractor calls
- VideoDetailScreen Download dialog using NewPipe's StreamInfo

Future-direction memo: openclaw-workspace/memory/project_rustypipe_fork.md
— fork plan + revival path for the Rust extractor when we're ready
to maintain it.

Verified working in the Android emulator: dQw4w9WgXcQ plays, ExoPlayer
reports state=PLAYING(3), position advancing, video surface rendering.
This commit is contained in:
Sulkta 2026-05-24 09:54:59 -07:00
parent 04b0a8dbe9
commit cc3585549a
17 changed files with 356 additions and 304 deletions

View file

@ -15,6 +15,6 @@ const val NEWPIPE_APPLICATION_ID_OLD = "org.schabi.newpipe"
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// Sulkta fork — Straw // Sulkta fork — Straw
const val STRAW_VERSION_CODE = 11 const val STRAW_VERSION_CODE = 12
const val STRAW_VERSION_NAME = "0.1.0-W2" const val STRAW_VERSION_NAME = "0.1.0-X"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -129,14 +129,18 @@ pub async fn stream_info(url: String) -> Result<StreamInfo, StrawcoreError> {
log::info!("strawcore::stream_info id={}", id); log::info!("strawcore::stream_info id={}", id);
let rp = RustyPipe::new(); let rp = RustyPipe::new();
// rustypipe's default `player()` uses the Web client first. Those URLs // rustypipe's default `player()` uses the Web client first, which
// come back signed against the Web fetch's session/UA — ExoPlayer can't // returns signed URLs that need JS deobfuscation. Even the TV (TVHTML5)
// replay them (404/403/black screen). Force the TV embedded + iOS // client signs URLs nowadays, so deobfuscation runs and currently
// clients, both of which return ungated direct-play URLs the way // fails ("could not extract sig fn name") because YT changed the
// NewPipe's resolver does. // obfuscation pattern after rustypipe 0.11.4's last cut.
//
// Android and iOS YT-app clients serve URLs UNSIGNED — no sig
// decryption needed, ExoPlayer plays them directly. This is the same
// path NewPipe uses for its mobile + iOS-embed strategies.
let player = rp let player = rp
.query() .query()
.player_from_clients(&id, &[ClientType::Tv, ClientType::Ios]) .player_from_clients(&id, &[ClientType::Android, ClientType::Ios])
.await?; .await?;
let details = &player.details; let details = &player.details;

View file

@ -94,8 +94,8 @@ dependencies {
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp) implementation(libs.coil.network.okhttp)
// Phase U-5: NewPipeExtractor (Java) torn out — strawcore handles // NewPipeExtractor (JVM/Android-only) + its OkHttp dep
// all YT extraction. OkHttp stays for RYD + SponsorBlock JSON clients. implementation(libs.newpipe.extractor)
implementation(libs.squareup.okhttp) implementation(libs.squareup.okhttp)
// JSON for SponsorBlock + Return YouTube Dislike clients // JSON for SponsorBlock + Return YouTube Dislike clients
@ -110,98 +110,4 @@ dependencies {
implementation("androidx.media3:media3-session:1.4.1") implementation("androidx.media3:media3-session:1.4.1")
// Guava ListenableFuture support for awaiting MediaController connect. // Guava ListenableFuture support for awaiting MediaController connect.
implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0") implementation("androidx.concurrent:concurrent-futures-ktx:1.2.0")
// strawcore — Rust YouTube extractor via UniFFI/JNA. Built by the
// cargoBuild + uniffiBindgen tasks below; phase U-2+ exposes search /
// streamInfo / channelInfo to replace NewPipeExtractor.
implementation("net.java.dev.jna:jna:5.14.0@aar")
} }
// =============================================================================
// Phase U-1 — Rust core build glue.
//
// Two tasks chain into the Android build:
// cargoBuild — cross-compiles rust/strawcore for the four Android ABIs
// via cargo-ndk and drops the .so files in strawApp/src/main/jniLibs/.
// uniffiBindgen — generates the Kotlin bindings from the freshly-built lib
// into strawApp/src/main/java/uniffi/strawcore/.
//
// Both depend on:
// - cargo + rustup with the four Android targets installed
// - cargo-ndk on PATH
// - ANDROID_NDK_HOME pointing at an NDK with the right toolchains
// All of that lives in the build-host container (see internal-infra
// containers/build-host/Dockerfile + ad-hoc install notes 2026-05-24).
// =============================================================================
val rustRoot = file("../rust").absolutePath
val jniLibsDir = file("src/main/jniLibs").absolutePath
val bindingsDir = file("src/main/java").absolutePath
// Resolve cargo + the NDK by absolute path so the Gradle Exec tasks don't
// depend on whatever PATH the user invoked gradle with. Fall back to env
// var (CARGO_HOME) if set, else the build-host default.
val cargoHome: String = System.getenv("CARGO_HOME") ?: "/caches/cargo"
val cargoBin: String = "$cargoHome/bin/cargo"
val ndkHome: String = System.getenv("ANDROID_NDK_HOME")
?: System.getenv("ANDROID_NDK_ROOT")
?: "/caches/android-sdk/ndk/27.2.12479018"
val cargoBuild by tasks.registering(Exec::class) {
group = "rust"
description = "Cross-compile strawcore for all Android ABIs via cargo-ndk."
workingDir = file(rustRoot)
environment("ANDROID_NDK_HOME", ndkHome)
environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
commandLine = listOf(
cargoBin, "ndk",
"-t", "arm64-v8a",
"-t", "armeabi-v7a",
"-t", "x86",
"-t", "x86_64",
"-o", jniLibsDir,
"build", "--release", "-p", "strawcore",
)
standardOutput = System.out
errorOutput = System.err
}
// Build a host-arch debug .so for uniffi-bindgen to read metadata from.
// Cross-compiled Android .so files have the same UniFFI metadata symbols,
// but the release profile's strip+LTO can strip the sections in a way that
// trips bindgen's library-mode reader. Build host debug separately.
val cargoBuildHost by tasks.registering(Exec::class) {
group = "rust"
description = "Build host-arch debug strawcore so bindgen can read its UniFFI metadata."
workingDir = file(rustRoot)
environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
commandLine = listOf(cargoBin, "build", "-p", "strawcore")
standardOutput = System.out
errorOutput = System.err
}
val uniffiBindgen by tasks.registering(Exec::class) {
group = "rust"
description = "Generate Kotlin bindings for strawcore via uniffi-bindgen."
dependsOn(cargoBuildHost)
workingDir = file(rustRoot)
environment("PATH", "$cargoHome/bin:${System.getenv("PATH") ?: ""}")
commandLine = listOf(
cargoBin, "run", "--quiet", "--bin", "uniffi-bindgen", "--",
"generate",
"--library", "target/debug/libstrawcore.so",
"--crate", "strawcore",
"--language", "kotlin",
"--no-format",
"--out-dir", bindingsDir,
)
standardOutput = System.out
errorOutput = System.err
}
// Make sure Android's JNI-libs merge picks up the freshly built .so files,
// and Kotlin compilation can resolve the generated bindings.
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }
.configureEach { dependsOn(cargoBuild) }
tasks.matching { it.name.startsWith("compile") && it.name.endsWith("Kotlin") }
.configureEach { dependsOn(uniffiBindgen) }

View file

@ -1,9 +1,6 @@
/* /*
* SPDX-FileCopyrightText: 2026 Sulkta * SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase U-5: NewPipeExtractor (Java) torn out. All YT extraction goes
* through strawcore (Rust + rustypipe + UniFFI) now.
*/ */
package com.sulkta.straw package com.sulkta.straw
@ -12,21 +9,21 @@ import android.app.Application
import com.sulkta.straw.data.History import com.sulkta.straw.data.History
import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.extractor.NewPipeDownloader
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.localization.ContentCountry
import org.schabi.newpipe.extractor.localization.Localization
class StrawApp : Application() { class StrawApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
NewPipe.init(
NewPipeDownloader.init(),
Localization("en", "US"),
ContentCountry("US"),
)
History.init(this) History.init(this)
Settings.init(this) Settings.init(this)
Subscriptions.init(this) Subscriptions.init(this)
// Load strawcore native + route its logs into android logcat under
// the "strawcore" tag.
runCatching {
System.loadLibrary("strawcore")
uniffi.strawcore.initLogging()
}.onFailure {
android.util.Log.w("StrawApp", "strawcore not loaded: ${it.message}")
}
} }
} }

View file

@ -0,0 +1,96 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Minimal OkHttp-backed implementation of NewPipeExtractor's Downloader.
* No cookies, no recaptcha handling anonymous browsing only. Modeled after
* NewPipe's DownloaderImpl but trimmed down for fork scope.
*/
package com.sulkta.straw.extractor
import com.sulkta.straw.net.NEWPIPE_MAX_BYTES
import com.sulkta.straw.net.cappedString
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.extractor.downloader.Request
import org.schabi.newpipe.extractor.downloader.Response
import java.io.IOException
import java.util.concurrent.TimeUnit
class NewPipeDownloader private constructor(
private val client: OkHttpClient,
) : Downloader() {
override fun execute(request: Request): Response {
val httpMethod = request.httpMethod()
val url = request.url()
val headers = request.headers()
val data: ByteArray? = request.dataToSend()
val requestBody = data?.toRequestBody(null)
val okBuilder = okhttp3.Request.Builder()
.method(httpMethod, requestBody)
.url(url)
// AUD-HIGH: copy NPE headers BEFORE adding our explicit UA so the
// explicit UA wins; guard against header values containing \r/\n
// which OkHttp's addHeader rejects via IAE (turning a poisoned
// response into an app crash).
headers.forEach { (name, values) ->
if (name.equals("User-Agent", ignoreCase = true)) return@forEach
okBuilder.removeHeader(name)
values.forEach { value ->
runCatching { okBuilder.addHeader(name, value) }
}
}
okBuilder.removeHeader("User-Agent")
okBuilder.addHeader("User-Agent", USER_AGENT)
val okResponse = client.newCall(okBuilder.build()).execute()
val body = okResponse.body
// AUD-HIGH: bounded read to defend against OOM via gigabyte response.
val bodyString = body?.cappedString(NEWPIPE_MAX_BYTES) ?: ""
val responseHeaders = okResponse.headers.toMultimap()
val latestUrl = okResponse.request.url.toString()
if (okResponse.code == 429) {
okResponse.close()
throw IOException("HTTP 429 — rate limited")
}
okResponse.close()
return Response(
okResponse.code,
okResponse.message,
responseHeaders,
bodyString,
latestUrl,
)
}
companion object {
const val USER_AGENT =
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) " +
"Chrome/120.0.0.0 Mobile Safari/537.36"
@Volatile private var instance: NewPipeDownloader? = null
fun init(builder: OkHttpClient.Builder? = null): NewPipeDownloader {
val client = (builder ?: OkHttpClient.Builder())
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(30, TimeUnit.SECONDS)
.build()
val d = NewPipeDownloader(client)
instance = d
return d
}
fun get(): NewPipeDownloader = instance
?: error("NewPipeDownloader not initialized — call init() first")
fun client(): OkHttpClient = get().client
}
}

View file

@ -1,10 +1,6 @@
/* /*
* SPDX-FileCopyrightText: 2026 Sulkta * SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase U-4: 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 package com.sulkta.straw.feature.channel
@ -12,10 +8,19 @@ package com.sulkta.straw.feature.channel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.sulkta.straw.feature.search.StreamItem 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch 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( data class ChannelUiState(
val loading: Boolean = true, val loading: Boolean = true,
@ -35,24 +40,43 @@ class ChannelViewModel : ViewModel() {
_ui.value = ChannelUiState(loading = true) _ui.value = ChannelUiState(loading = true)
viewModelScope.launch { viewModelScope.launch {
try { try {
val ch = uniffi.strawcore.channelInfo(channelUrl) val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val videos = ch.videos.map { v -> val info = withContext(Dispatchers.IO) {
StreamItem( ChannelInfo.getInfo(service, channelUrl)
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<StreamItem> = if (videosTab != null) {
withContext(Dispatchers.IO) {
runCatching {
ChannelTabInfo.getInfo(service, videosTab)
.relatedItems
.filterIsInstance<StreamInfoItem>()
.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( _ui.value = ChannelUiState(
loading = false, loading = false,
name = ch.name, name = info.name ?: "",
subscriberCount = ch.subscriberCount, subscriberCount = info.subscriberCount,
banner = ch.banner, banner = bestThumbnail(info.banners),
avatar = ch.avatar, avatar = bestThumbnail(info.avatars),
videos = videos, videos = videos,
) )
} catch (t: Throwable) { } catch (t: Throwable) {

View file

@ -190,7 +190,7 @@ fun VideoDetailScreen(
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
if (showDownloadDialog) { if (showDownloadDialog) {
val info = state.info // uniffi.strawcore.StreamInfo cached on the UI state val info = state.streamInfo
AlertDialog( AlertDialog(
onDismissRequest = { showDownloadDialog = false }, onDismissRequest = { showDownloadDialog = false },
title = { Text("Download") }, title = { Text("Download") },
@ -208,9 +208,10 @@ fun VideoDetailScreen(
confirmButton = { confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { Button(onClick = {
val audio = info?.audioOnly val audio = info?.audioStreams
?.maxByOrNull { it.bitrate } ?.filter { it.content?.isNotBlank() == true }
?.url ?.maxByOrNull { it.bitrate ?: 0 }
?.content
if (audio != null) { if (audio != null) {
val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio) val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
val msg = if (id > 0) "audio queued" else "download refused (bad URL)" val msg = if (id > 0) "audio queued" else "download refused (bad URL)"
@ -221,12 +222,14 @@ fun VideoDetailScreen(
showDownloadDialog = false showDownloadDialog = false
}) { Text("Audio") } }) { Text("Audio") }
Button(onClick = { Button(onClick = {
val video = info?.combined val video = info?.videoStreams
?.maxByOrNull { it.bitrate } ?.filter { it.content?.isNotBlank() == true }
?.url ?.maxByOrNull { it.bitrate ?: 0 }
?: info?.videoOnly ?.content
?.maxByOrNull { it.bitrate } ?: info?.videoOnlyStreams
?.url ?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
if (video != null) { if (video != null) {
val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video) val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video)
val msg = if (id > 0) "video queued" else "download refused (bad URL)" val msg = if (id > 0) "video queued" else "download refused (bad URL)"

View file

@ -1,11 +1,6 @@
/* /*
* SPDX-FileCopyrightText: 2026 Sulkta * SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase U-3: extractor moved from NewPipeExtractor (Java) to strawcore
* (Rust + rustypipe), called as a UniFFI suspend fun. The shape of
* VideoDetail and the on-screen behavior are unchanged; only the engine
* underneath flipped.
*/ */
package com.sulkta.straw.feature.detail package com.sulkta.straw.feature.detail
@ -15,16 +10,18 @@ import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.History import com.sulkta.straw.data.History
import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Settings
import com.sulkta.straw.data.WatchHistoryItem import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.net.RydClient import com.sulkta.straw.net.RydClient
import com.sulkta.straw.net.RydVotes import com.sulkta.straw.net.RydVotes
import com.sulkta.straw.net.SponsorBlockClient import com.sulkta.straw.net.SponsorBlockClient
import com.sulkta.straw.util.bestThumbnail
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class VideoDetail( data class VideoDetail(
val id: String, val id: String,
@ -36,15 +33,15 @@ data class VideoDetail(
val thumbnail: String?, val thumbnail: String?,
val ryd: RydVotes? = null, val ryd: RydVotes? = null,
val sbSegmentCount: Int = 0, val sbSegmentCount: Int = 0,
val related: List<StreamItem> = emptyList(), val related: List<com.sulkta.straw.feature.search.StreamItem> = emptyList(),
) )
data class VideoDetailUiState( data class VideoDetailUiState(
val loading: Boolean = true, val loading: Boolean = true,
val detail: VideoDetail? = null, val detail: VideoDetail? = null,
val error: String? = null, val error: String? = null,
/** Cached strawcore result so the Player + Download dialog can use it. */ // Stored on success for handoff to player. Not in UI.
val info: uniffi.strawcore.StreamInfo? = null, val streamInfo: StreamInfo? = null,
) )
class VideoDetailViewModel : ViewModel() { class VideoDetailViewModel : ViewModel() {
@ -54,18 +51,18 @@ class VideoDetailViewModel : ViewModel() {
private var loadedUrl: String? = null private var loadedUrl: String? = null
fun load(streamUrl: String) { fun load(streamUrl: String) {
// Activity-scoped VM is reused across nav entries; only re-fetch when // viewModel() is Activity-scoped, so the same VM is reused across
// the requested URL actually changed. // navigations. Compare the requested URL with what we last loaded.
if (loadedUrl == streamUrl && _ui.value.detail != null) return if (loadedUrl == streamUrl && _ui.value.detail != null) return
loadedUrl = streamUrl loadedUrl = streamUrl
_ui.value = VideoDetailUiState(loading = true) _ui.value = VideoDetailUiState(loading = true)
viewModelScope.launch { viewModelScope.launch {
try { try {
val info = uniffi.strawcore.streamInfo(streamUrl) val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
val videoId = info.id val videoId = info.id
val title = info.title.ifBlank { "(no title)" } val thumb = bestThumbnail(info.thumbnails)
val uploader = info.uploader val title = info.name ?: "(no title)"
val thumb = info.thumbnail val uploader = info.uploaderName ?: ""
runCatching { runCatching {
History.get().recordWatch( History.get().recordWatch(
@ -80,8 +77,6 @@ class VideoDetailViewModel : ViewModel() {
) )
} }
// RYD + SponsorBlock stay in Kotlin (small JSON HTTP clients,
// no extractor logic).
val ryd = withContext(Dispatchers.IO) { val ryd = withContext(Dispatchers.IO) {
runCatching { RydClient.fetch(videoId) }.getOrNull() runCatching { RydClient.fetch(videoId) }.getOrNull()
} }
@ -89,18 +84,19 @@ class VideoDetailViewModel : ViewModel() {
val sbCount = if (sbCats.isEmpty()) 0 else withContext(Dispatchers.IO) { val sbCount = if (sbCats.isEmpty()) 0 else withContext(Dispatchers.IO) {
runCatching { SponsorBlockClient.fetch(videoId, sbCats).size }.getOrDefault(0) runCatching { SponsorBlockClient.fetch(videoId, sbCats).size }.getOrDefault(0)
} }
val related = info.relatedItems
val related = info.related.map { r -> ?.filterIsInstance<StreamInfoItem>()
StreamItem( ?.map { it ->
url = r.url, com.sulkta.straw.feature.search.StreamItem(
title = r.title.ifBlank { "(no title)" }, url = it.url,
uploader = r.uploader, title = it.name ?: "(no title)",
uploaderUrl = r.uploaderUrl, uploader = it.uploaderName ?: "",
thumbnail = r.thumbnail, uploaderUrl = it.uploaderUrl,
durationSeconds = r.durationSeconds, thumbnail = bestThumbnail(it.thumbnails),
viewCount = r.viewCount, durationSeconds = it.duration,
) viewCount = it.viewCount,
} )
} ?: emptyList()
_ui.value = VideoDetailUiState( _ui.value = VideoDetailUiState(
loading = false, loading = false,
@ -110,13 +106,13 @@ class VideoDetailViewModel : ViewModel() {
uploader = uploader, uploader = uploader,
uploaderUrl = info.uploaderUrl, uploaderUrl = info.uploaderUrl,
viewCount = info.viewCount, viewCount = info.viewCount,
description = info.description, description = info.description?.content ?: "",
thumbnail = thumb, thumbnail = thumb,
ryd = ryd, ryd = ryd,
sbSegmentCount = sbCount, sbSegmentCount = sbCount,
related = related, related = related,
), ),
info = info, streamInfo = info,
) )
} catch (t: Throwable) { } catch (t: Throwable) {
_ui.value = VideoDetailUiState( _ui.value = VideoDetailUiState(

View file

@ -2,14 +2,16 @@
* SPDX-FileCopyrightText: 2026 Sulkta * SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* Phase Q + U-4: aggregate latest videos across all subscribed channels. * Phase Q: aggregate latest videos across all subscribed channels into a
* Per-channel fetches now go through strawcore (rustypipe + UniFFI). The * single feed. Fans out per-channel ChannelInfo + ChannelTabs.VIDEOS
* Kotlin side still owns the structured concurrency: * fetches in parallel, merges by view count desc, caps at 200 items.
* - cancels prior in-flight refresh *
* - Semaphore caps parallel fetches (rustypipe internally has its own * Audit fixes (2026-05-24 pass #2):
* HTTP pool but we still want to avoid N=100 concurrent extractor * HIGH-6: cancel any prior in-flight refresh when a new one starts, cap
* contexts when N=100 channels) * concurrency with a Semaphore, time-bound each per-channel fetch so
* - per-channel 15s timeout * one hung channel can't stall the whole feed.
* MED-7: use `update { }` for atomic UI-state writes (matches the
* convention applied to the data stores in audit pass #1).
*/ */
package com.sulkta.straw.feature.feed package com.sulkta.straw.feature.feed
@ -18,7 +20,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.bestThumbnail
import com.sulkta.straw.util.strawLogW import com.sulkta.straw.util.strawLogW
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@ -30,7 +34,14 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull 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( data class SubscriptionFeedUiState(
val loading: Boolean = false, val loading: Boolean = false,
@ -43,11 +54,16 @@ class SubscriptionFeedViewModel : ViewModel() {
private val _ui = MutableStateFlow(SubscriptionFeedUiState()) private val _ui = MutableStateFlow(SubscriptionFeedUiState())
val ui: StateFlow<SubscriptionFeedUiState> = _ui.asStateFlow() val ui: StateFlow<SubscriptionFeedUiState> = _ui.asStateFlow()
/** Cache feed for 10 min to avoid hammering YT on tab re-entry. */
private val cacheTtlMs = 10L * 60 * 1000 private val cacheTtlMs = 10L * 60 * 1000
private val perChannelTimeoutMs = 15_000L
private val parallelism = 8
private val perChannelMax = 5
/** Per-channel fetch timeout — slowest channel can't stall the whole batch. */
private val perChannelTimeoutMs = 15_000L
/** Cap parallel network fetches even with 100+ subs. */
private val parallelism = 8
/** Live refresh job, so spam-tapping Refresh doesn't fan out racing fetches. */
private var inFlight: Job? = null private var inFlight: Job? = null
fun refreshIfStale() { fun refreshIfStale() {
@ -66,40 +82,55 @@ class SubscriptionFeedViewModel : ViewModel() {
_ui.update { it.copy(loading = true, error = null) } _ui.update { it.copy(loading = true, error = null) }
inFlight = viewModelScope.launch { inFlight = viewModelScope.launch {
try { try {
val gate = Semaphore(parallelism) val items = withContext(Dispatchers.IO) {
val items = coroutineScope { val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val deferreds = channels.map { ch -> val perChannelMax = 5
async { val gate = Semaphore(parallelism)
gate.withPermit { coroutineScope {
withTimeoutOrNull(perChannelTimeoutMs) { val deferreds = channels.map { ch ->
runCatching { async {
uniffi.strawcore.channelInfo(ch.url).videos.take(perChannelMax).map { v -> gate.withPermit {
StreamItem( withTimeoutOrNull(perChannelTimeoutMs) {
url = v.url, runCatching {
title = v.title.ifBlank { "(no title)" }, val info = ChannelInfo.getInfo(service, ch.url)
uploader = v.uploader.ifBlank { ch.name }, val tab = info.tabs.firstOrNull {
uploaderUrl = v.uploaderUrl ?: ch.url, it.contentFilters.contains(ChannelTabs.VIDEOS)
thumbnail = v.thumbnail, } ?: info.tabs.firstOrNull()
durationSeconds = v.durationSeconds, ?: return@runCatching emptyList<StreamItem>()
viewCount = v.viewCount, ChannelTabInfo.getInfo(service, tab)
) .relatedItems
} .filterIsInstance<StreamInfoItem>()
}.onFailure { .take(perChannelMax)
strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" } .map { si ->
}.getOrDefault(emptyList()) StreamItem(
} ?: run { url = si.url,
strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" } title = si.name ?: "(no title)",
emptyList() 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()
}
} }
} }
} }
deferreds.awaitAll()
} }
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)
} }
.flatten()
.sortedByDescending { it.viewCount }
.take(200)
_ui.update { _ui.update {
SubscriptionFeedUiState( SubscriptionFeedUiState(
loading = false, loading = false,

View file

@ -50,7 +50,7 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import com.sulkta.straw.StrawActivity import com.sulkta.straw.StrawActivity
import com.sulkta.straw.net.STRAW_USER_AGENT import com.sulkta.straw.extractor.NewPipeDownloader
@UnstableApi @UnstableApi
class PlaybackService : MediaSessionService() { class PlaybackService : MediaSessionService() {
@ -63,7 +63,7 @@ class PlaybackService : MediaSessionService() {
ensureChannel() ensureChannel()
val httpFactory = DefaultHttpDataSource.Factory() val httpFactory = DefaultHttpDataSource.Factory()
.setUserAgent(STRAW_USER_AGENT) .setUserAgent(NewPipeDownloader.USER_AGENT)
.setAllowCrossProtocolRedirects(true) .setAllowCrossProtocolRedirects(true)
val mediaSourceFactory = DefaultMediaSourceFactory(this) val mediaSourceFactory = DefaultMediaSourceFactory(this)
.setDataSourceFactory(httpFactory) .setDataSourceFactory(httpFactory)

View file

@ -70,7 +70,7 @@ import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.MergingMediaSource import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView import androidx.media3.ui.PlayerView
import com.sulkta.straw.net.STRAW_USER_AGENT import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.net.SbSegment import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.util.strawLogI import com.sulkta.straw.util.strawLogI
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -121,28 +121,6 @@ fun PlayerScreen(
} }
} }
// Surface playback errors so a 403/404 from googlevideo doesn't show
// as a silent black screen. Captures everything ExoPlayer's renderer
// pipeline raises.
DisposableEffect(exoPlayer) {
val listener = object : Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
val msg = buildString {
append("play err ")
append(error.errorCodeName)
append(": ")
append(error.message ?: error.cause?.message ?: "?")
}
com.sulkta.straw.util.strawLogW("StrawPlayer") { "$msg" }
runCatching {
Toast.makeText(context, msg.take(160), Toast.LENGTH_LONG).show()
}
}
}
exoPlayer.addListener(listener)
onDispose { exoPlayer.removeListener(listener) }
}
// PiP setup: on Android 12+ tell the OS this activity can auto-enter // PiP setup: on Android 12+ tell the OS this activity can auto-enter
// PiP, so when the user presses Home or swipes away the video shrinks // PiP, so when the user presses Home or swipes away the video shrinks
// into a floating window instead of pausing/exiting. Aspect ratio is // into a floating window instead of pausing/exiting. Aspect ratio is
@ -191,7 +169,7 @@ fun PlayerScreen(
LaunchedEffect(resolved) { LaunchedEffect(resolved) {
val r = resolved ?: return@LaunchedEffect val r = resolved ?: return@LaunchedEffect
val dataSourceFactory = DefaultHttpDataSource.Factory() val dataSourceFactory = DefaultHttpDataSource.Factory()
.setUserAgent(STRAW_USER_AGENT) .setUserAgent(NewPipeDownloader.USER_AGENT)
.setAllowCrossProtocolRedirects(true) .setAllowCrossProtocolRedirects(true)
val source = when { val source = when {

View file

@ -1,16 +1,13 @@
/* /*
* SPDX-FileCopyrightText: 2026 Sulkta * SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase U-3: extractor moved from NewPipeExtractor (Java) to strawcore
* (Rust + rustypipe via UniFFI). PlayerScreen still calls vm.resolve(url)
* the same way the engine underneath flipped.
*/ */
package com.sulkta.straw.feature.player package com.sulkta.straw.feature.player
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.MaxResolution
import com.sulkta.straw.data.Settings import com.sulkta.straw.data.Settings
import com.sulkta.straw.net.SbSegment import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.net.SponsorBlockClient import com.sulkta.straw.net.SponsorBlockClient
@ -20,6 +17,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.stream.StreamInfo
data class ResolvedPlayback( data class ResolvedPlayback(
val title: String, val title: String,
@ -50,9 +48,8 @@ class PlayerViewModel : ViewModel() {
_ui.value = PlayerUiState(loading = true) _ui.value = PlayerUiState(loading = true)
viewModelScope.launch { viewModelScope.launch {
try { try {
val info = uniffi.strawcore.streamInfo(streamUrl) val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
val videoId = info.id val videoId = info.id
val sbCategories = Settings.get().sbCategories.value.map { it.key } val sbCategories = Settings.get().sbCategories.value.map { it.key }
val segments = if (sbCategories.isEmpty()) { val segments = if (sbCategories.isEmpty()) {
emptyList() emptyList()
@ -64,24 +61,32 @@ class PlayerViewModel : ViewModel() {
} }
val maxRes = Settings.get().maxResolution.value.ceiling val maxRes = Settings.get().maxResolution.value.ceiling
fun heightOf(q: String?): Int =
q?.removeSuffix("p")?.takeWhile { it.isDigit() }?.toIntOrNull() ?: 0
// Audit HIGH-8 carry-over: filter by max resolution but fall // Audit HIGH-8: when no stream is under the resolution ceiling
// back to lowest available if the ceiling excludes everything. // (e.g. user picked 144p but the video only has 360p+), fall
fun pickVideo(streams: List<uniffi.strawcore.VideoStreamItem>): String? { // back to the lowest-resolution available instead of returning
if (streams.isEmpty()) return null // null and showing a black-screen player.
val filtered = streams.filter { it.height <= maxRes } fun pickVideo(streams: List<org.schabi.newpipe.extractor.stream.VideoStream>?): String? {
val pool = filtered.ifEmpty { streams } if (streams.isNullOrEmpty()) return null
return pool.maxByOrNull { it.bitrate }?.url val withContent = streams.filter { it.content?.isNotBlank() == true }
val filtered = withContent.filter { heightOf(it.getResolution()) <= maxRes }
val pool = filtered.ifEmpty { withContent }
return pool.maxByOrNull { it.bitrate ?: 0 }?.content
} }
val combined = pickVideo(info.combined) val combined = pickVideo(info.videoStreams)
val videoOnly = pickVideo(info.videoOnly) val videoOnly = pickVideo(info.videoOnlyStreams)
val audioOnly = info.audioOnly.maxByOrNull { it.bitrate }?.url val audioOnly = info.audioStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
_ui.value = PlayerUiState( _ui.value = PlayerUiState(
loading = false, loading = false,
resolved = ResolvedPlayback( resolved = ResolvedPlayback(
title = info.title, title = info.name ?: "",
videoUrl = videoOnly, videoUrl = videoOnly,
audioUrl = audioOnly, audioUrl = audioOnly,
combinedUrl = combined, combinedUrl = combined,

View file

@ -8,10 +8,17 @@ package com.sulkta.straw.feature.search
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.History import com.sulkta.straw.data.History
import com.sulkta.straw.util.bestThumbnail
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch 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.search.SearchInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class SearchUiState( data class SearchUiState(
val query: String = "", val query: String = "",
@ -45,23 +52,7 @@ class SearchViewModel : ViewModel() {
_ui.value = _ui.value.copy(loading = true, error = null, results = emptyList()) _ui.value = _ui.value.copy(loading = true, error = null, results = emptyList())
viewModelScope.launch { viewModelScope.launch {
try { try {
// Phase U-2: rustypipe via UniFFI. The bindgen-generated val items = withContext(Dispatchers.IO) { search(q) }
// `search()` is already a suspend fun running on the tokio
// runtime baked into libstrawcore.so — no Dispatchers.IO
// wrapper needed, the JNI call returns to us on the caller
// dispatcher when the future completes.
val rustItems = uniffi.strawcore.search(q)
val items = rustItems.map { r ->
StreamItem(
url = r.url,
title = r.title.ifBlank { "(no title)" },
uploader = r.uploader,
uploaderUrl = r.uploaderUrl,
thumbnail = r.thumbnail,
durationSeconds = r.durationSeconds,
viewCount = r.viewCount,
)
}
_ui.value = _ui.value.copy(loading = false, results = items) _ui.value = _ui.value.copy(loading = false, results = items)
} catch (t: Throwable) { } catch (t: Throwable) {
_ui.value = _ui.value.copy( _ui.value = _ui.value.copy(
@ -71,4 +62,23 @@ class SearchViewModel : ViewModel() {
} }
} }
} }
private fun search(query: String): List<StreamItem> {
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val qh = service.searchQHFactory.fromQuery(query, emptyList(), "")
val info = SearchInfo.getInfo(service, qh)
return info.relatedItems
.filterIsInstance<StreamInfoItem>()
.map {
StreamItem(
url = it.url,
title = it.name ?: "(no title)",
uploader = it.uploaderName ?: "",
uploaderUrl = it.uploaderUrl,
thumbnail = bestThumbnail(it.thumbnails),
durationSeconds = it.duration,
viewCount = it.viewCount,
)
}
}
} }

View file

@ -13,33 +13,9 @@
package com.sulkta.straw.net package com.sulkta.straw.net
import okhttp3.OkHttpClient
import okhttp3.ResponseBody import okhttp3.ResponseBody
import okio.Buffer import okio.Buffer
import java.io.IOException import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Phase U-5: USER_AGENT + shared OkHttpClient that previously lived on
* NewPipeDownloader. After ripping NewPipeExtractor, the RYD + SponsorBlock
* + ExoPlayer HTTP factories still need both. One shared client is fine.
*/
const val STRAW_USER_AGENT: String =
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Straw/1.0"
@Volatile
private var sharedClient: OkHttpClient? = null
fun strawHttpClient(): OkHttpClient =
sharedClient ?: synchronized(STRAW_USER_AGENT) {
sharedClient ?: OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.followRedirects(true)
.followSslRedirects(true)
.build()
.also { sharedClient = it }
}
fun ResponseBody.cappedString(maxBytes: Long): String { fun ResponseBody.cappedString(maxBytes: Long): String {
val cl = contentLength() val cl = contentLength()

View file

@ -8,6 +8,7 @@
package com.sulkta.straw.net package com.sulkta.straw.net
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.util.strawLogD import com.sulkta.straw.util.strawLogD
import com.sulkta.straw.util.strawLogW import com.sulkta.straw.util.strawLogW
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -33,11 +34,11 @@ object RydClient {
strawLogD(TAG) { "fetch start: $videoId$url" } strawLogD(TAG) { "fetch start: $videoId$url" }
val req = Request.Builder() val req = Request.Builder()
.url(url) .url(url)
.header("User-Agent", STRAW_USER_AGENT) .header("User-Agent", NewPipeDownloader.USER_AGENT)
.header("Accept", "application/json") .header("Accept", "application/json")
.build() .build()
return runCatching { return runCatching {
strawHttpClient().newCall(req).execute().use { r -> NewPipeDownloader.client().newCall(req).execute().use { r ->
val code = r.code val code = r.code
// AUD-HIGH: bounded body read to defend against OOM. // AUD-HIGH: bounded body read to defend against OOM.
val bodyStr = r.body?.cappedString(RYD_MAX_BYTES) ?: "" val bodyStr = r.body?.cappedString(RYD_MAX_BYTES) ?: ""

View file

@ -8,6 +8,7 @@
package com.sulkta.straw.net package com.sulkta.straw.net
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.util.strawLogD import com.sulkta.straw.util.strawLogD
import com.sulkta.straw.util.strawLogW import com.sulkta.straw.util.strawLogW
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -46,11 +47,11 @@ object SponsorBlockClient {
strawLogD(TAG) { "fetch: videoId=$videoId prefix=$prefix url=$urlStr" } strawLogD(TAG) { "fetch: videoId=$videoId prefix=$prefix url=$urlStr" }
val req = Request.Builder() val req = Request.Builder()
.url(urlStr) .url(urlStr)
.header("User-Agent", STRAW_USER_AGENT) .header("User-Agent", NewPipeDownloader.USER_AGENT)
.header("Accept", "application/json") .header("Accept", "application/json")
.build() .build()
return runCatching { return runCatching {
strawHttpClient().newCall(req).execute().use { r -> NewPipeDownloader.client().newCall(req).execute().use { r ->
val code = r.code val code = r.code
// AUD-HIGH: bounded body read. // AUD-HIGH: bounded body read.
val bodyStr = r.body?.cappedString(SB_MAX_BYTES) ?: "" val bodyStr = r.body?.cappedString(SB_MAX_BYTES) ?: ""

View file

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* NewPipeExtractor returns thumbnails as a List<Image> with width/height
* fields. Calling .firstOrNull() picks the smallest (the list is sorted
* ascending) which gave us pixelated thumbnails. This helper picks the
* largest by pixel area instead.
*/
package com.sulkta.straw.util
import org.schabi.newpipe.extractor.Image
fun bestThumbnail(images: List<Image>?): String? {
if (images.isNullOrEmpty()) return null
return images
.maxByOrNull {
val w = it.width.takeIf { v -> v > 0 } ?: 0
val h = it.height.takeIf { v -> v > 0 } ?: 0
w.toLong() * h.toLong()
}
?.url
}