Clean cutover — Kotlin off NewPipeExtractor, onto uniffi.strawcore

Brings the Phase U Android-side integration onto a feature branch
where the rust wrapper now bridges to strawcore-core (the new NPE
port) instead of rustypipe.

  * strawApp/build.gradle.kts — JNA dep + cargoBuild + cargoBuildHost
    + uniffiBindgen + the merge/compile task wiring. libs.newpipe
    .extractor dropped.
  * StrawApp.kt — uniffi.strawcore.initLogging(); NewPipe.init() gone
  * 5 ViewModels (Search/VideoDetail/Player/Channel/Subscriptions)
    swapped to call uniffi.strawcore.{search,streamInfo,channelInfo}
  * PlayerScreen + PlaybackService adjusted for the new StreamInfo
    shape (dash_mpd_url / hls_url instead of NPE's manifests)
  * IosSafeHttpDataSource carried forward — strawcore-core gives us
    Android-primary URLs so the iOS cap path is mostly dead code,
    but kept as belt-and-suspenders for the rare HLS-fallback
  * NewPipeDownloader.kt + util/Thumbnails.kt deleted

Files taken wholesale from sulkta branch — the wrapper's UniFFI
surface is identical between sulkta (rustypipe-backed) and the
current Phase-7-bridged-to-strawcore-core code, so Kotlin doesn't
care which extractor is under the hood.
This commit is contained in:
Sulkta 2026-05-24 17:54:41 -07:00
parent 25a1904330
commit 680e170773
16 changed files with 516 additions and 367 deletions

View file

@ -95,7 +95,9 @@ dependencies {
implementation(libs.coil.network.okhttp) implementation(libs.coil.network.okhttp)
// NewPipeExtractor (JVM/Android-only) + its OkHttp dep // NewPipeExtractor (JVM/Android-only) + its OkHttp dep
implementation(libs.newpipe.extractor) // libs.newpipe.extractor — REMOVED in Path C-6. Extractor is now strawcore
// (Rust + rustypipe via UniFFI). See rust/strawcore/ + the cargoBuild +
// uniffiBindgen Gradle tasks below.
implementation(libs.squareup.okhttp) implementation(libs.squareup.okhttp)
// JSON for SponsorBlock + Return YouTube Dislike clients // JSON for SponsorBlock + Return YouTube Dislike clients
@ -110,4 +112,95 @@ 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 / Path-C-2 — 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.
// =============================================================================
val rustRoot = file("../rust").absolutePath
val jniLibsDir = file("src/main/jniLibs").absolutePath
val bindingsDir = file("src/main/java").absolutePath
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"
// Honor CARGO_TARGET_DIR if set (we redirect it to /caches on build-host
// because the container's writable rootfs hits 100% before the cross-compile
// for 4 ABIs finishes). Falls back to the default `<workspace>/target`.
val cargoTargetDir: String = System.getenv("CARGO_TARGET_DIR")
?: "$rustRoot/target"
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
}
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", "$cargoTargetDir/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

@ -9,19 +9,15 @@ 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( // Path C-7: route Rust `log::*` calls into Android logcat under tag
NewPipeDownloader.init(), // "strawcore". Without this, every log line emitted from rustypipe /
Localization("en", "US"), // strawcore is silently dropped, making playback regressions invisible
ContentCountry("US"), // from `adb logcat`.
) uniffi.strawcore.initLogging()
History.init(this) History.init(this)
Settings.init(this) Settings.init(this)
Subscriptions.init(this) Subscriptions.init(this)

View file

@ -1,96 +0,0 @@
/*
* 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,6 +1,10 @@
/* /*
* 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 / 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 package com.sulkta.straw.feature.channel
@ -8,19 +12,10 @@ 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,
@ -40,43 +35,24 @@ class ChannelViewModel : ViewModel() {
_ui.value = ChannelUiState(loading = true) _ui.value = ChannelUiState(loading = true)
viewModelScope.launch { viewModelScope.launch {
try { try {
val service = NewPipe.getService(ServiceList.YouTube.serviceId) val ch = uniffi.strawcore.channelInfo(channelUrl)
val info = withContext(Dispatchers.IO) { val videos = ch.videos.map { v ->
ChannelInfo.getInfo(service, channelUrl) 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<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 = info.name ?: "", name = ch.name,
subscriberCount = info.subscriberCount, subscriberCount = ch.subscriberCount,
banner = bestThumbnail(info.banners), banner = ch.banner,
avatar = bestThumbnail(info.avatars), avatar = ch.avatar,
videos = videos, videos = videos,
) )
} catch (t: Throwable) { } catch (t: Throwable) {

View file

@ -69,7 +69,7 @@ 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 coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.sulkta.straw.extractor.NewPipeDownloader import com.sulkta.straw.net.STRAW_USER_AGENT
import com.sulkta.straw.util.formatCount import com.sulkta.straw.util.formatCount
import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.formatViews
import com.sulkta.straw.util.stripHtml import com.sulkta.straw.util.stripHtml
@ -284,10 +284,8 @@ fun VideoDetailScreen(
confirmButton = { confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { Button(onClick = {
val audio = info?.audioStreams // info is now uniffi.strawcore.StreamInfo (Path C-4).
?.filter { it.content?.isNotBlank() == true } val audio = info?.audioOnly?.maxByOrNull { it.bitrate }?.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)"
@ -298,14 +296,8 @@ fun VideoDetailScreen(
showDownloadDialog = false showDownloadDialog = false
}) { Text("Audio") } }) { Text("Audio") }
Button(onClick = { Button(onClick = {
val video = info?.videoStreams val video = info?.combined?.maxByOrNull { it.bitrate }?.url
?.filter { it.content?.isNotBlank() == true } ?: info?.videoOnly?.maxByOrNull { it.bitrate }?.url
?.maxByOrNull { it.bitrate ?: 0 }
?.content
?: info?.videoOnlyStreams
?.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)"
@ -409,16 +401,35 @@ private fun InlinePlayer(
.build() .build()
} }
DisposableEffect(Unit) { // Path C-7: surface ExoPlayer failures into UI state. Without this an
onDispose { exoPlayer.release() } // HTTP 403 / source error showed as a stuck black box with the pause
// controls visible — directly enabled a false-positive in the prior
// verification pass.
var playbackError by remember { mutableStateOf<String?>(null) }
DisposableEffect(exoPlayer) {
val listener = object : androidx.media3.common.Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
playbackError =
"${error.errorCodeName}: ${error.message ?: "(no message)"}"
}
}
exoPlayer.addListener(listener)
onDispose {
exoPlayer.removeListener(listener)
exoPlayer.release()
}
} }
val resolved = state.resolved val resolved = state.resolved
LaunchedEffect(resolved) { LaunchedEffect(resolved) {
val r = resolved ?: return@LaunchedEffect val r = resolved ?: return@LaunchedEffect
val dataSourceFactory = DefaultHttpDataSource.Factory() // Path C-7: chunk open-ended Range requests so iOS googlevideo URLs
.setUserAgent(NewPipeDownloader.USER_AGENT) // don't 403 on first byte. See net/IosSafeHttpDataSource.kt.
.setAllowCrossProtocolRedirects(true) val dataSourceFactory = com.sulkta.straw.net.IosSafeHttpDataSource.Factory(
DefaultHttpDataSource.Factory()
.setUserAgent(STRAW_USER_AGENT)
.setAllowCrossProtocolRedirects(true)
)
val source = when { val source = when {
r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory) r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.dashMpdUrl)) .createMediaSource(MediaItem.fromUri(r.dashMpdUrl))
@ -452,6 +463,11 @@ private fun InlinePlayer(
color = MaterialTheme.colorScheme.error, color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
) )
playbackError != null -> Text(
"playback error: $playbackError",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
resolved?.isPlayable != true -> Text( resolved?.isPlayable != true -> Text(
"no playable stream", "no playable stream",
color = Color.White, color = Color.White,

View file

@ -1,6 +1,10 @@
/* /*
* 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 / Path C-4: streamInfo() moves from NewPipeExtractor (Java) to
* strawcore (Rust + rustypipe via UniFFI). Channel fetch for
* `moreFromChannel` stays on NPE until C-5.
*/ */
package com.sulkta.straw.feature.detail package com.sulkta.straw.feature.detail
@ -13,20 +17,12 @@ import com.sulkta.straw.data.WatchHistoryItem
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.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.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class VideoDetail( data class VideoDetail(
val id: String, val id: String,
@ -48,8 +44,8 @@ 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,
// Stored on success for handoff to player. Not in UI. // Stored on success for handoff to the player + Download dialog. Not in UI.
val streamInfo: StreamInfo? = null, val streamInfo: uniffi.strawcore.StreamInfo? = null,
) )
class VideoDetailViewModel : ViewModel() { class VideoDetailViewModel : ViewModel() {
@ -66,11 +62,12 @@ class VideoDetailViewModel : ViewModel() {
_ui.value = VideoDetailUiState(loading = true) _ui.value = VideoDetailUiState(loading = true)
viewModelScope.launch { viewModelScope.launch {
try { try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) } // strawcore.streamInfo is suspend on tokio; no Dispatchers.IO wrap.
val info = uniffi.strawcore.streamInfo(streamUrl)
val videoId = info.id val videoId = info.id
val thumb = bestThumbnail(info.thumbnails) val thumb = info.thumbnail
val title = info.name ?: "(no title)" val title = info.title.ifBlank { "(no title)" }
val uploader = info.uploaderName ?: "" val uploader = info.uploader
runCatching { runCatching {
History.get().recordWatch( History.get().recordWatch(
@ -92,51 +89,43 @@ 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
?.filterIsInstance<StreamInfoItem>()
?.map { it ->
com.sulkta.straw.feature.search.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,
)
} ?: emptyList()
// More from this channel — anchored to the uploader the user // strawcore returns `related` as List<SearchItem>. Map to the
// already chose. Best-effort: empty if the fetch fails so the // Kotlin StreamItem shape used elsewhere.
// detail screen still renders. Filters out the current video. val related = info.related.map { r ->
com.sulkta.straw.feature.search.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,
)
}
// More from this channel via strawcore.channelInfo — one
// Rust round-trip returns the channel's Videos tab pre-mapped.
val uploaderUrl = info.uploaderUrl
val moreFromChannel: List<com.sulkta.straw.feature.search.StreamItem> = val moreFromChannel: List<com.sulkta.straw.feature.search.StreamItem> =
if (info.uploaderUrl.isNullOrBlank()) emptyList() if (uploaderUrl.isNullOrBlank()) emptyList()
else withContext(Dispatchers.IO) { else runCatching {
runCatching { val ch = uniffi.strawcore.channelInfo(uploaderUrl)
val service = NewPipe.getService(ServiceList.YouTube.serviceId) ch.videos
val ch = ChannelInfo.getInfo(service, info.uploaderUrl) .filter { it.url != streamUrl }
val videosTab = ch.tabs.firstOrNull { .take(20)
it.contentFilters.contains(ChannelTabs.VIDEOS) .map { v ->
} ?: ch.tabs.firstOrNull() com.sulkta.straw.feature.search.StreamItem(
if (videosTab == null) emptyList() url = v.url,
else ChannelTabInfo.getInfo(service, videosTab) title = v.title.ifBlank { "(no title)" },
.relatedItems uploader = v.uploader.ifBlank { uploader },
.filterIsInstance<StreamInfoItem>() uploaderUrl = v.uploaderUrl ?: uploaderUrl,
.filter { it.url != streamUrl } thumbnail = v.thumbnail,
.take(20) durationSeconds = v.durationSeconds,
.map { si -> viewCount = v.viewCount,
com.sulkta.straw.feature.search.StreamItem( )
url = si.url, }
title = si.name ?: "(no title)", }.getOrDefault(emptyList())
uploader = si.uploaderName ?: uploader,
uploaderUrl = si.uploaderUrl ?: info.uploaderUrl,
thumbnail = bestThumbnail(si.thumbnails),
durationSeconds = si.duration,
viewCount = si.viewCount,
)
}
}.getOrDefault(emptyList())
}
_ui.value = VideoDetailUiState( _ui.value = VideoDetailUiState(
loading = false, loading = false,
@ -146,7 +135,7 @@ class VideoDetailViewModel : ViewModel() {
uploader = uploader, uploader = uploader,
uploaderUrl = info.uploaderUrl, uploaderUrl = info.uploaderUrl,
viewCount = info.viewCount, viewCount = info.viewCount,
description = info.description?.content ?: "", description = info.description,
thumbnail = thumb, thumbnail = thumb,
ryd = ryd, ryd = ryd,
sbSegmentCount = sbCount, sbSegmentCount = sbCount,

View file

@ -3,8 +3,12 @@
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* Phase Q: aggregate latest videos across all subscribed channels into a * Phase Q: aggregate latest videos across all subscribed channels into a
* single feed. Fans out per-channel ChannelInfo + ChannelTabs.VIDEOS * single feed. Fans out per-channel channelInfo() fetches in parallel,
* fetches in parallel, merges by view count desc, caps at 200 items. * 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): * Audit fixes (2026-05-24 pass #2):
* HIGH-6: cancel any prior in-flight refresh when a new one starts, cap * 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 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
@ -34,14 +36,7 @@ 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,
@ -82,55 +77,46 @@ 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 items = withContext(Dispatchers.IO) { val perChannelMax = 5
val service = NewPipe.getService(ServiceList.YouTube.serviceId) val gate = Semaphore(parallelism)
val perChannelMax = 5 val items = coroutineScope {
val gate = Semaphore(parallelism) val deferreds = channels.map { ch ->
coroutineScope { async {
val deferreds = channels.map { ch -> gate.withPermit {
async { withTimeoutOrNull(perChannelTimeoutMs) {
gate.withPermit { runCatching {
withTimeoutOrNull(perChannelTimeoutMs) { val info = uniffi.strawcore.channelInfo(ch.url)
runCatching { info.videos
val info = ChannelInfo.getInfo(service, ch.url) .take(perChannelMax)
val tab = info.tabs.firstOrNull { .map { v ->
it.contentFilters.contains(ChannelTabs.VIDEOS) StreamItem(
} ?: info.tabs.firstOrNull() url = v.url,
?: return@runCatching emptyList<StreamItem>() title = v.title.ifBlank { "(no title)" },
ChannelTabInfo.getInfo(service, tab) uploader = v.uploader.ifBlank { ch.name },
.relatedItems uploaderUrl = v.uploaderUrl ?: ch.url,
.filterIsInstance<StreamInfoItem>() thumbnail = v.thumbnail,
.take(perChannelMax) durationSeconds = v.durationSeconds,
.map { si -> viewCount = v.viewCount,
StreamItem( )
url = si.url, }
title = si.name ?: "(no title)", }.onFailure {
uploader = si.uploaderName ?: ch.name, strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" }
uploaderUrl = si.uploaderUrl ?: ch.url, }.getOrDefault(emptyList())
thumbnail = bestThumbnail(si.thumbnails), } ?: run {
durationSeconds = si.duration, strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" }
viewCount = si.viewCount, emptyList()
)
}
}.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() deferreds.awaitAll()
// 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()
// 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 { _ui.update {
SubscriptionFeedUiState( SubscriptionFeedUiState(
loading = false, loading = false,

View file

@ -50,7 +50,8 @@ 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.extractor.NewPipeDownloader import com.sulkta.straw.net.IosSafeHttpDataSource
import com.sulkta.straw.net.STRAW_USER_AGENT
@UnstableApi @UnstableApi
class PlaybackService : MediaSessionService() { class PlaybackService : MediaSessionService() {
@ -62,9 +63,14 @@ class PlaybackService : MediaSessionService() {
super.onCreate() super.onCreate()
ensureChannel() ensureChannel()
val httpFactory = DefaultHttpDataSource.Factory() // Path C-7: wrap in IosSafeHttpDataSource so ExoPlayer's open-ended
.setUserAgent(NewPipeDownloader.USER_AGENT) // Range requests get chunked into bounded reads. iOS-bound googlevideo
.setAllowCrossProtocolRedirects(true) // URLs 403 on `Range: bytes=N-` but accept `Range: bytes=N-M`.
val httpFactory = IosSafeHttpDataSource.Factory(
DefaultHttpDataSource.Factory()
.setUserAgent(STRAW_USER_AGENT)
.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.extractor.NewPipeDownloader import com.sulkta.straw.net.STRAW_USER_AGENT
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
@ -114,6 +114,20 @@ fun PlayerScreen(
MediaSession.Builder(context, exoPlayer).build() MediaSession.Builder(context, exoPlayer).build()
} }
// Path C-7: surface ExoPlayer failures so they don't read as "stuck spinner"
// (Audit Finding 2). Posts to playbackError state which the UI renders.
var playbackError by remember { mutableStateOf<String?>(null) }
DisposableEffect(exoPlayer) {
val listener = object : androidx.media3.common.Player.Listener {
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
playbackError =
"${error.errorCodeName}: ${error.message ?: "(no message)"}"
}
}
exoPlayer.addListener(listener)
onDispose { exoPlayer.removeListener(listener) }
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
mediaSession.release() mediaSession.release()
@ -168,9 +182,13 @@ fun PlayerScreen(
LaunchedEffect(resolved) { LaunchedEffect(resolved) {
val r = resolved ?: return@LaunchedEffect val r = resolved ?: return@LaunchedEffect
val dataSourceFactory = DefaultHttpDataSource.Factory() // Path C-7: chunk open-ended Range requests so iOS googlevideo URLs
.setUserAgent(NewPipeDownloader.USER_AGENT) // don't 403 on first byte.
.setAllowCrossProtocolRedirects(true) val dataSourceFactory = com.sulkta.straw.net.IosSafeHttpDataSource.Factory(
DefaultHttpDataSource.Factory()
.setUserAgent(STRAW_USER_AGENT)
.setAllowCrossProtocolRedirects(true)
)
val source = when { val source = when {
r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory) r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory)
@ -251,6 +269,12 @@ fun PlayerScreen(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
) )
playbackError != null -> Text(
"playback error: $playbackError",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
resolved?.isPlayable != true -> Text( resolved?.isPlayable != true -> Text(
"no playable stream found", "no playable stream found",
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),

View file

@ -1,13 +1,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 U-3 / Path C-4: 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
@ -17,7 +20,6 @@ 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,
@ -48,8 +50,11 @@ class PlayerViewModel : ViewModel() {
_ui.value = PlayerUiState(loading = true) _ui.value = PlayerUiState(loading = true)
viewModelScope.launch { viewModelScope.launch {
try { try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) } // strawcore.streamInfo is a suspend fun running on the tokio
// runtime baked into libstrawcore.so — no Dispatchers.IO needed.
val info = uniffi.strawcore.streamInfo(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()
@ -61,32 +66,24 @@ 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: when no stream is under the resolution ceiling // Audit HIGH-8 carry-over: filter by max resolution but fall
// (e.g. user picked 144p but the video only has 360p+), fall // back to lowest available if the ceiling excludes everything.
// back to the lowest-resolution available instead of returning fun pickVideo(streams: List<uniffi.strawcore.VideoStreamItem>): String? {
// null and showing a black-screen player. if (streams.isEmpty()) return null
fun pickVideo(streams: List<org.schabi.newpipe.extractor.stream.VideoStream>?): String? { val filtered = streams.filter { it.height <= maxRes }
if (streams.isNullOrEmpty()) return null val pool = filtered.ifEmpty { streams }
val withContent = streams.filter { it.content?.isNotBlank() == true } return pool.maxByOrNull { it.bitrate }?.url
val filtered = withContent.filter { heightOf(it.getResolution()) <= maxRes }
val pool = filtered.ifEmpty { withContent }
return pool.maxByOrNull { it.bitrate ?: 0 }?.content
} }
val combined = pickVideo(info.videoStreams) val combined = pickVideo(info.combined)
val videoOnly = pickVideo(info.videoOnlyStreams) val videoOnly = pickVideo(info.videoOnly)
val audioOnly = info.audioStreams val audioOnly = info.audioOnly.maxByOrNull { it.bitrate }?.url
?.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.name ?: "", title = info.title,
videoUrl = videoOnly, videoUrl = videoOnly,
audioUrl = audioOnly, audioUrl = audioOnly,
combinedUrl = combined, combinedUrl = combined,

View file

@ -8,17 +8,10 @@ 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 = "",
@ -52,7 +45,23 @@ 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 {
val items = withContext(Dispatchers.IO) { search(q) } // Phase U-2 / Path C-3: rustypipe via UniFFI. The bindgen-generated
// `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(
@ -62,23 +71,4 @@ 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,9 +13,34 @@
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
/**
* Path C-6 / 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

@ -0,0 +1,173 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Path C-7 (post-audit): wrap DefaultHttpDataSource so each open() that
* lacks a bounded length turns into a sequence of bounded Range requests
* (default 1 MiB chunks).
*
* Background: rustypipe's iOS InnerTube client returns pre-signed
* googlevideo URLs. Those URLs reject an open-ended `Range: bytes=N-`
* with HTTP 403 they only accept bounded `Range: bytes=N-M`. ExoPlayer
* issues open-ended Range requests by default, which made every non-HLS
* iOS-origin video 403 on first byte. This shim makes ExoPlayer
* iOS-shaped without touching media-source selection.
*
* Verified via 2026-05-24 emulator audit (memory/audit-straw-vc16-emulator-2026-05-24.md):
* curl -r 0-1023 206 OK
* curl -H "Range: bytes=0-" 403 (any UA)
* curl no Range 403
*/
package com.sulkta.straw.net
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.HttpDataSource
private const val TAG = "IosSafeDS"
@UnstableApi
class IosSafeHttpDataSource(
private val inner: HttpDataSource,
private val chunkBytes: Long = DEFAULT_CHUNK_BYTES,
) : HttpDataSource by inner {
/** The original (caller-supplied) spec — kept so we can compute the next chunk. */
private var originalSpec: DataSpec? = null
/** How many bytes have been read since the caller's open(). */
private var totalRead: Long = 0
/** Bytes left in the current inner-open chunk. -1 = unknown end. */
private var chunkRemaining: Long = 0
override fun open(dataSpec: DataSpec): Long {
// When length is set, respect it but still cap the first inner-open to
// chunkBytes. When length is unset, request a chunk and we'll roll
// forward on subsequent reads.
val requestLen = if (dataSpec.length == C.LENGTH_UNSET.toLong()) {
chunkBytes
} else {
minOf(dataSpec.length, chunkBytes)
}
// NOTE: DataSpec.subrange(offset, length) ADDS offset to the existing
// position — so subrange(position, length) doubles the position. Use
// buildUpon().setLength(...) which preserves position and only bounds
// the byte length. This is what makes ExoPlayer's first Range header
// come out as `bytes=N-M` (closed, accepted by googlevideo iOS URLs)
// instead of `bytes=N-` (open, rejected with 403).
val bounded = dataSpec.buildUpon().setLength(requestLen).build()
// Surface itag + mime from query so we can tell video vs audio apart in logs.
val u = dataSpec.uri
val itag = u.getQueryParameter("itag")
val mime = u.getQueryParameter("mime")
Log.i(
TAG,
"open: pos=${bounded.position} len=${bounded.length} " +
"(origLen=${dataSpec.length}, chunkBytes=$chunkBytes) " +
"itag=$itag mime=$mime host=${u.host}",
)
Log.i(TAG, "open url=${u.toString()}")
originalSpec = dataSpec
totalRead = 0
// inner.open() returns the BOUNDED chunk's length. Track it so we
// know when to roll to the next chunk.
chunkRemaining = try {
inner.open(bounded)
} catch (t: Throwable) {
Log.w(TAG, "open failed: ${t.javaClass.simpleName}: ${t.message}")
throw t
}
Log.i(TAG, "open: inner returned chunkRemaining=$chunkRemaining")
// Report the original (potentially unbounded) length to the caller —
// ExoPlayer cares about the overall length, not our internal chunking.
return if (dataSpec.length == C.LENGTH_UNSET.toLong()) {
C.LENGTH_UNSET.toLong()
} else {
dataSpec.length
}
}
override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
if (length == 0) return 0
// Need a fresh chunk?
if (chunkRemaining == 0L) {
val spec = originalSpec ?: return C.RESULT_END_OF_INPUT
inner.close()
val nextPos = spec.position + totalRead
val remainingOverall = if (spec.length == C.LENGTH_UNSET.toLong()) {
Long.MAX_VALUE
} else {
spec.length - totalRead
}
if (remainingOverall <= 0L) return C.RESULT_END_OF_INPUT
val nextLen = remainingOverall.coerceAtMost(chunkBytes)
// Same as in open() — use buildUpon().setPosition/setLength rather
// than subrange() so the absolute position stays meaningful.
val nextSpec = spec.buildUpon()
.setPosition(nextPos)
.setLength(nextLen)
.build()
chunkRemaining = inner.open(nextSpec)
}
// Cap the read against what's left in this chunk.
val toRead = if (chunkRemaining < 0L) {
// Inner doesn't know its end either; just read what was asked.
length
} else {
length.toLong().coerceAtMost(chunkRemaining).toInt()
}
val read = inner.read(buffer, offset, toRead)
if (read != C.RESULT_END_OF_INPUT) {
totalRead += read.toLong()
if (chunkRemaining > 0L) chunkRemaining -= read.toLong()
// If chunkRemaining hits 0 here, the next read() call will roll
// to the next chunk via the block at the top.
} else if (chunkRemaining > 0L) {
// Inner ran out before its advertised end. Force chunk roll on
// next read() so we re-open at the next position.
chunkRemaining = 0L
}
return read
}
override fun close() {
try {
inner.close()
} finally {
originalSpec = null
totalRead = 0
chunkRemaining = 0
}
}
/** Factory: wrap any inner HttpDataSource.Factory. */
@UnstableApi
class Factory(
private val innerFactory: HttpDataSource.Factory,
private val chunkBytes: Long = DEFAULT_CHUNK_BYTES,
) : HttpDataSource.Factory {
override fun createDataSource(): HttpDataSource =
IosSafeHttpDataSource(innerFactory.createDataSource(), chunkBytes)
override fun setDefaultRequestProperties(
defaultRequestProperties: Map<String, String>,
): HttpDataSource.Factory {
innerFactory.setDefaultRequestProperties(defaultRequestProperties)
return this
}
}
companion object {
// YT's iOS-bound googlevideo URLs accept bounded `Range: bytes=N-M`
// requests up to roughly 900 KiB before flipping to 403. Empirically
// measured 2026-05-24 on a test network: bytes=0-917503 (~896 KiB) → 206;
// bytes=0-999999 (~977 KiB) → 403. 512 KiB gives a 2× safety margin —
// small enough to survive future tightening, large enough to keep the
// open() round-trip count tolerable for a long video.
const val DEFAULT_CHUNK_BYTES: Long = 512L * 1024
}
}

View file

@ -8,7 +8,6 @@
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
@ -34,11 +33,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", NewPipeDownloader.USER_AGENT) .header("User-Agent", STRAW_USER_AGENT)
.header("Accept", "application/json") .header("Accept", "application/json")
.build() .build()
return runCatching { return runCatching {
NewPipeDownloader.client().newCall(req).execute().use { r -> strawHttpClient().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,7 +8,6 @@
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
@ -47,11 +46,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", NewPipeDownloader.USER_AGENT) .header("User-Agent", STRAW_USER_AGENT)
.header("Accept", "application/json") .header("Accept", "application/json")
.build() .build()
return runCatching { return runCatching {
NewPipeDownloader.client().newCall(req).execute().use { r -> strawHttpClient().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

@ -1,24 +0,0 @@
/*
* 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
}