Compare commits

...
Sign in to create a new pull request.

16 commits
main ... sulkta

Author SHA1 Message Date
1df904f8b3 IosSafeHttpDataSource: drop chunk size from 1 MiB to 512 KiB
Curl matrix on Lucy egress (2026-05-24) against a fresh iOS audio URL
showed YT enforces a per-request Range cap of roughly 900 KiB on
iOS-bound googlevideo URLs:
  bytes=0-524287  (~512 KiB)  -> 206
  bytes=0-786431  (~768 KiB)  -> 206
  bytes=0-917503  (~896 KiB)  -> 206
  bytes=0-999999  (~977 KiB)  -> 403
  bytes=0-1048575 (~1 MiB)    -> 403

Audio (itag 251) hits this cap; large video (itag 248) didn't trip it on
the first chunk but would on any later read. 512 KiB chunks give a 2x
safety margin under the observed ceiling.

This finally explains why vc=17 still 403'd after the bounded-Range fix
landed — the bound itself was over the cap.
2026-05-24 14:42:13 -07:00
a7b058031b IosSafeHttpDataSource: log itag/mime + full URL for 403 diag
Previous 120-char truncation hid which stream (video vs audio) was being
opened. Splits the log line into 'itag=N mime=X host=Y' summary + a
second 'open url=' line with the full URL so the per-thread interleave
is unambiguous in logcat.
2026-05-24 14:32:14 -07:00
0964de4c2d IosSafeHttpDataSource: log the bounded DataSpec at open() for diagnosis
vc=17 still 403s even with buildUpon().setLength() bounded ranges.
Add Log.i tracing so we can see the exact position, length, and URL
that ExoPlayer's data-source layer is asking for, and capture inner
HttpDataSource exceptions before they propagate.
2026-05-24 14:13:39 -07:00
7d2cf5d9bc IosSafeHttpDataSource: use buildUpon().setPosition/setLength (not subrange)
The previous code called dataSpec.subrange(dataSpec.position, length) which
*adds* the offset to the existing position rather than setting an absolute
bounded slice — that turned every first open() into Range request
`bytes=(2*N)-(2*N+chunk-1)`, doubling the offset and still 403'ing
for the wrong reason.

DataSpec.subrange(offset, length) docs: "position of the new DataSpec
will be position + offset". So subrange(0, L) gives a bounded slice at
the current position. We want absolute control, so use
buildUpon().setPosition(N).setLength(L).build() — explicit, unambiguous.

Caught on vc=17 emulator smoke: ExoPlayer logged InvalidResponseCodeException
403 at IosSafeHttpDataSource.kt:58 (the inner.open(bounded) call), meaning
the bounded shape was wrong. Player.Listener.onPlayerError DID fire and
surface the error in the UI — so that part of the patch works.
2026-05-24 14:10:39 -07:00
3e8109c726 v0.1.0-AC (vc=17): playback fix via IosSafeHttpDataSource 2026-05-24 14:06:09 -07:00
69c91fdca6 Path C-7: IosSafeHttpDataSource + onPlayerError + Rust log init
Fixes the post-vc=16 regression Cobb hit: "videos do not play anymore".

Root cause (from memory/audit-straw-vc16-emulator-2026-05-24.md):
ExoPlayer's DefaultHttpDataSource sends open-ended `Range: bytes=N-`
on first read of a stream. iOS-bound googlevideo URLs return HTTP 403
on any open-ended Range, even with the iOS YT UA. Bounded ranges
(`Range: bytes=N-M`) return 206 normally. ExoPlayer's behaviour is
correct per spec; YT's iOS-channel URLs are quarantined to bounded
reads only — the iOS app does this internally; ExoPlayer doesn't.

Fixes:

1. **net/IosSafeHttpDataSource.kt** (new) — wraps any HttpDataSource so
   each open() with unbounded length issues a sequence of bounded 1 MiB
   Range requests, rolling forward transparently on read(). Drops in via
   IosSafeHttpDataSource.Factory(DefaultHttpDataSource.Factory()).

2. **VideoDetailScreen.kt** (inline player), **PlayerScreen.kt**
   (fullscreen), **PlaybackService.kt** (background audio) — wrap the
   DataSource factory accordingly.

3. **VideoDetailScreen.kt + PlayerScreen.kt** — add Player.Listener with
   onPlayerError so ExoPlayer failures surface in the UI as a visible
   error string. The audit's confirmation-bias trap ("pause icon must
   mean playback") was caused by failures being invisible.

4. **StrawApp.kt** — call uniffi.strawcore.initLogging() so Rust-side
   log::warn!() (in particular the soft-fail messages from the fork's
   deobf path) reach `adb logcat -s strawcore`. The init fn already
   existed in strawcore/lib.rs; the call was lost during C-6.

Audit findings not fixed in this pass (deferred / cosmetic):
- Finding 3 (TV-client IpBan on Lucy egress) — environmental, not code.
- Finding 5 (eager ExoPlayer init in inline composable) — latent.
- Finding 6 (duplicate strawcore round-trip on inline tap-to-play) —
  V-2 work, intersects MediaController unification.

Verification: rebuild + emulator smoke ahead. Should ship as v0.1.0-AC vc=17.
2026-05-24 14:05:57 -07:00
c5f029b60b Path C: full U-2..U-5 re-flip with rustypipe fork (v0.1.0-AB vc=16)
Merges the kayos/path-c branch which moved straw's extractor from
NewPipeExtractor (Java) to strawcore (Rust + rustypipe via UniFFI).
See the C-1..C-6 commit chain for the per-phase breakdown; the final
state is v0.1.0-AB / vc=16:

- Zero org.schabi.newpipe imports in straw Kotlin
- Built against Sulkta-Coop/rustypipe v0.11.5-sulkta.2 (audit-hardened)
- iOS-first client order — pre-signed stream URLs, no sig deobf, no
  device attestation
- libstrawcore.so packs rustypipe + reqwest + tokio + rustls + rquickjs
  per ABI (~3-7MB each)
- APK 41.8MB (down from vc=15's 43.5MB, net of NPE removal)
- Verified end-to-end on emulator: search, video detail, inline player
  playback (NCS Spektrem), channel page (NCS 34.3M subscribers)
2026-05-24 13:36:08 -07:00
e410d0e92d v0.1.0-AB (vc=16): Path C — rustypipe via UniFFI is the extractor
End state of the path C re-flip. strawApp Kotlin is now ~UI + thin
glue to strawcore (Rust). The Rust core does:

  - search  (rustypipe.search_videos)
  - streamInfo  (rustypipe.player → pre-signed iOS InnerTube URLs)
  - channelInfo + channel videos (rustypipe.channel + channel_videos)

All via UniFFI suspend fns. NewPipeExtractor is zero-import in
straw Kotlin. libstrawcore.so packs rustypipe + reqwest + tokio +
rustls + rquickjs per ABI (~3-7MB each).

Built against the audit-hardened Sulkta-Coop/rustypipe fork
v0.11.5-sulkta.2 — iOS-first default client order, soft-fail sig
deobf, Deobfuscation errors switchable, observability via Reporter.

Verified end-to-end on emulator:
  - C-3: search returns Darknet Diaries results
  - C-4: VideoDetail loads (title/uploader/views/RYD/description),
         inline ExoPlayer plays the iOS-fetched stream URL
  - C-5: Channel page (NCS) shows banner + avatar + 34.3M subscribers
         + full video list
2026-05-24 13:31:10 -07:00
979b4021b0 Path C-6: rip NewPipeExtractor
Zero org.schabi.newpipe classes in straw Kotlin. strawcore (Rust +
rustypipe via UniFFI) is the only extractor.

Deletions:
- strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt
  (was the OkHttp adapter; STRAW_USER_AGENT + strawHttpClient() in
  net/Http.kt cover its role)
- strawApp/src/main/kotlin/com/sulkta/straw/util/Thumbnails.kt
  (rustypipe surfaces a pre-picked thumbnail URL; no helper needed)

Build:
- libs.newpipe.extractor dependency removed from strawApp/build.gradle.kts

Call-site swaps:
- net/RydClient.kt, net/SponsorBlockClient.kt: NewPipeDownloader.client()
  + .USER_AGENT → strawHttpClient() + STRAW_USER_AGENT
- feature/player/PlayerScreen.kt, feature/player/PlaybackService.kt,
  feature/detail/VideoDetailScreen.kt: ExoPlayer
  DefaultHttpDataSource.Factory now reads STRAW_USER_AGENT from net/Http
- StrawApp.kt: NewPipe.init() call gone; History/Settings/Subscriptions
  bootstrap unchanged

net/Http.kt gains STRAW_USER_AGENT const + strawHttpClient() lazy
OkHttpClient with the same shape NewPipeDownloader had (15s connect,
30s read, follow redirects).
2026-05-24 13:29:19 -07:00
b95565bec7 C-5 fix: pin uploaderUrl to local val for Kotlin smart-cast
info.uploaderUrl is a nullable String on a uniffi-generated Record;
Kotlin's smart-cast can't prove the second access isn't null after
isNullOrBlank() on a mutable property. Pin to a local val so the
non-null cast carries through the uniffi.strawcore.channelInfo() call.
2026-05-24 13:23:54 -07:00
90930ade11 Path C-5: channel + sub feed + moreFromChannel swap to strawcore
Three ViewModels move from NewPipeExtractor (Java) to strawcore (rustypipe):

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

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

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

NewPipeExtractor is now reachable only from non-ViewModel code:
NewPipeDownloader.kt (OkHttp adapter), StrawApp.NewPipe.init(),
util/Thumbnails.kt. C-6 deletes all three.
2026-05-24 13:21:33 -07:00
198d2a9066 Path C-4 fix: stream_info uses fork's iOS-first default client order
Caught on first emulator smoke: stream_info hardcoded
`player_from_clients(&[Android, Ios])` from U-3 era. Android first
trips YT's "Precondition check failed" because needs_po_token
doesn't flag Android — request fires unsigned and YT rejects it.

The fork's audit-fixed player_client_order is [Ios, Tv] without
botguard (HIGH-3 in the audit). Use rp.query().player(id) directly
so we inherit that order and pick up future tweaks automatically.
2026-05-24 13:15:19 -07:00
47e037ee62 Path C-4: PlayerViewModel + VideoDetailViewModel swap to uniffi.strawcore.streamInfo
Drops NewPipeExtractor's StreamInfo.getInfo() from the player resolve
path and the video-detail load path. strawcore.streamInfo() is a
single Rust round-trip backed by rustypipe via UniFFI; returns the
adaptive video / video-only / audio-only lists, DASH MPD + HLS URLs,
description, view/like counts, thumbnail, and related-video list.

VideoDetailUiState.streamInfo flips from org.schabi.newpipe.extractor.stream.StreamInfo
to uniffi.strawcore.StreamInfo — used by the Download dialog in
VideoDetailScreen. Dialog field accesses updated accordingly.

moreFromChannel still uses NewPipeExtractor's ChannelInfo until C-5
swaps it to strawcore.channelInfo(). Keeps blast radius surgical.
2026-05-24 13:13:04 -07:00
7968bbb8e6 Path C-2 fix: uniffiBindgen honors CARGO_TARGET_DIR
The crafting-table container's rootfs hits 100% disk before cross-
compile finishes for 4 ABIs, so we redirect CARGO_TARGET_DIR to
/caches. The uniffiBindgen task was looking for libstrawcore.so at
target/debug/ (default) and failed when it had been written to
$CARGO_TARGET_DIR/debug/ instead. Honor the env var with a fallback
to the workspace-relative default.
2026-05-24 12:59:59 -07:00
93297ad0a0 Path C-3: SearchViewModel swap to uniffi.strawcore.search
Drops NewPipeExtractor from the search code path. The bindgen-generated
`search()` is a Kotlin suspend fun running on the tokio runtime baked
into libstrawcore.so — no Dispatchers.IO wrapper needed.

NPE still drives VideoDetail / Player / Channel / sub feed; those move
to rustypipe in C-4 / C-5 / C-6.
2026-05-24 12:56:32 -07:00
54458f3d40 Path C-1/C-2: rustypipe fork dep + Gradle Rust pipeline
C-1: rust/strawcore/Cargo.toml now points at Sulkta-Coop/rustypipe
     v0.11.5-sulkta.2 (kayos/m1-sig-port) instead of upstream 0.11.4.
     Upstream's sig-regex hard-fails on current YT player c2f7551f;
     the fork soft-fails, defaults to iOS-first, makes Deobfuscation
     errors switchable through the client-fallback chain, and adds
     observability via Reporter Level::WRN.

C-2: strawApp/build.gradle.kts restores the U-1 era Gradle Rust glue:
     - cargoBuild     → cross-compile strawcore for 4 Android ABIs
     - cargoBuildHost → host-arch debug build for uniffi-bindgen
                        to read metadata from
     - uniffiBindgen  → generate Kotlin bindings from libstrawcore.so
     Wired into the Android build via mergeXxxJniLibFolders +
     compileXxxKotlin task dependencies.

Bumps + gitignores:
     - .gitignore now excludes rust/target, strawApp/src/main/jniLibs,
       and strawApp/src/main/java/uniffi (generated)
     - jna 5.14.0 added as the JNI bridge runtime

Next: cargoBuildHost + uniffiBindgen verification in crafting-table,
then C-3 swaps SearchViewModel back to uniffi.strawcore.search().
2026-05-24 12:44:17 -07:00
20 changed files with 552 additions and 384 deletions

6
.gitignore vendored
View file

@ -38,3 +38,9 @@ rust/target/
strawApp/src/main/jniLibs/
# UniFFI-generated Kotlin bindings (regen'd from .so on every build)
strawApp/src/main/java/uniffi/
# Rust build artifacts
rust/target/
strawApp/src/main/jniLibs/
# UniFFI-generated Kotlin bindings (regen'd from .so on every build)
strawApp/src/main/java/uniffi/

View file

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

View file

@ -19,10 +19,27 @@ crate-type = ["cdylib", "staticlib"]
uniffi = { version = "0.28", features = ["cli", "tokio"] }
# Tokio multi-thread runtime — rustypipe is async-first.
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync"] }
# rustypipe — the actual YouTube Innertube client. Phase U-2 wires search.
# rustypipe — the actual YouTube Innertube client. Phase U-2 wires search,
# U-3 wires streamInfo, U-4 wires channels.
#
# Points at the Sulkta-Coop fork (kayos/m1-sig-port branch, tag v0.11.5-sulkta.2)
# because upstream 0.11.4 hard-failed at init when YT rotated the
# player.js to a shape its sig-regex doesn't recognise (player c2f7551f, May 2026).
# The fork:
# - skips player.js deobf entirely for the iOS/Android client paths
# (pre-signed URLs, no &s= cipher, no &n= throttle param)
# - soft-fails sig_fn/nsig_fn extraction with a switchable error class
# so the player_from_clients chain falls through to iOS instead of
# killing the call
# - defaults to iOS-first client order
# - emits Level::WRN reporter event on partial extraction
#
# Force rustls + webpki-roots so we don't pull openssl-sys (cross-compiling
# system OpenSSL to four Android ABIs is a tarpit; rustls is pure-Rust).
rustypipe = { version = "0.11", default-features = false, features = ["rustls-tls-webpki-roots"] }
#
# When YT rotates back to a sig shape both upstream and our fork recognise,
# we can flip back to crates.io. Until then, the fork is the only working dep.
rustypipe = { git = "http://192.168.0.5:3001/Sulkta-Coop/rustypipe.git", tag = "v0.11.5-sulkta.2", default-features = false, features = ["rustls-tls-webpki-roots"] }
# rquickjs-sys (transitive dep of rustypipe for YT signature decryption JS)
# doesn't ship prebuilt Android bindings. Direct-depend with `bindgen` feature
# so it generates them at build time. Crafting-table has libclang preinstalled.

View file

@ -129,19 +129,15 @@ pub async fn stream_info(url: String) -> Result<StreamInfo, StrawcoreError> {
log::info!("strawcore::stream_info id={}", id);
let rp = RustyPipe::new();
// rustypipe's default `player()` uses the Web client first, which
// returns signed URLs that need JS deobfuscation. Even the TV (TVHTML5)
// client signs URLs nowadays, so deobfuscation runs and currently
// fails ("could not extract sig fn name") because YT changed the
// 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
.query()
.player_from_clients(&id, &[ClientType::Android, ClientType::Ios])
.await?;
// Use the fork's audit-fixed default client order. As of
// v0.11.5-sulkta.2 that's [Ios, Tv] without botguard — iOS first
// because it skips player.js deobfuscation AND doesn't require
// device attestation. Android is intentionally NOT in the default
// order: needs_po_token doesn't flag Android, so unsigned requests
// get YT's "Precondition check failed" / "Sign in to confirm
// you're not a bot" rejection, which is environmental-non-switchable.
// Re-add Android when a real po_token strategy lands.
let player = rp.query().player(&id).await?;
let details = &player.details;
// Progressive (combined audio+video) goes through video_streams; the

View file

@ -95,7 +95,9 @@ dependencies {
implementation(libs.coil.network.okhttp)
// 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)
// JSON for SponsorBlock + Return YouTube Dislike clients
@ -110,4 +112,95 @@ dependencies {
implementation("androidx.media3:media3-session:1.4.1")
// Guava ListenableFuture support for awaiting MediaController connect.
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 crafting-table 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 crafting-table
// 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.Settings
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() {
override fun onCreate() {
super.onCreate()
NewPipe.init(
NewPipeDownloader.init(),
Localization("en", "US"),
ContentCountry("US"),
)
// Path C-7: route Rust `log::*` calls into Android logcat under tag
// "strawcore". Without this, every log line emitted from rustypipe /
// strawcore is silently dropped, making playback regressions invisible
// from `adb logcat`.
uniffi.strawcore.initLogging()
History.init(this)
Settings.init(this)
Subscriptions.init(this)

View file

@ -1,96 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* 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-Coop
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase U-4 / Path C-5: ChannelInfo + Videos tab moved to strawcore
* (rustypipe). The two separate ChannelInfo.getInfo + ChannelTabInfo.getInfo
* calls collapse into one Rust round-trip.
*/
package com.sulkta.straw.feature.channel
@ -8,19 +12,10 @@ package com.sulkta.straw.feature.channel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.bestThumbnail
import kotlinx.coroutines.Dispatchers
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class ChannelUiState(
val loading: Boolean = true,
@ -40,43 +35,24 @@ class ChannelViewModel : ViewModel() {
_ui.value = ChannelUiState(loading = true)
viewModelScope.launch {
try {
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val info = withContext(Dispatchers.IO) {
ChannelInfo.getInfo(service, channelUrl)
val ch = uniffi.strawcore.channelInfo(channelUrl)
val videos = ch.videos.map { v ->
StreamItem(
url = v.url,
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader,
uploaderUrl = v.uploaderUrl,
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
)
}
// AUD-HIGH: pick the Videos tab specifically rather than
// info.tabs.firstOrNull() which is YouTube's "Home" (a
// curated mix that mostly drops via filterIsInstance).
val videosTab = info.tabs.firstOrNull {
it.contentFilters.contains(ChannelTabs.VIDEOS)
} ?: info.tabs.firstOrNull()
val videos: List<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(
loading = false,
name = info.name ?: "",
subscriberCount = info.subscriberCount,
banner = bestThumbnail(info.banners),
avatar = bestThumbnail(info.avatars),
name = ch.name,
subscriberCount = ch.subscriberCount,
banner = ch.banner,
avatar = ch.avatar,
videos = videos,
)
} catch (t: Throwable) {

View file

@ -69,7 +69,7 @@ import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.ui.PlayerView
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.formatViews
import com.sulkta.straw.util.stripHtml
@ -284,10 +284,8 @@ fun VideoDetailScreen(
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = {
val audio = info?.audioStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
// info is now uniffi.strawcore.StreamInfo (Path C-4).
val audio = info?.audioOnly?.maxByOrNull { it.bitrate }?.url
if (audio != null) {
val id = Downloader.enqueue(context, audio, d.title, DownloadKind.Audio)
val msg = if (id > 0) "audio queued" else "download refused (bad URL)"
@ -298,14 +296,8 @@ fun VideoDetailScreen(
showDownloadDialog = false
}) { Text("Audio") }
Button(onClick = {
val video = info?.videoStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
?: info?.videoOnlyStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
val video = info?.combined?.maxByOrNull { it.bitrate }?.url
?: info?.videoOnly?.maxByOrNull { it.bitrate }?.url
if (video != null) {
val id = Downloader.enqueue(context, video, d.title, DownloadKind.Video)
val msg = if (id > 0) "video queued" else "download refused (bad URL)"
@ -409,16 +401,35 @@ private fun InlinePlayer(
.build()
}
DisposableEffect(Unit) {
onDispose { exoPlayer.release() }
// Path C-7: surface ExoPlayer failures into UI state. Without this an
// 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
LaunchedEffect(resolved) {
val r = resolved ?: return@LaunchedEffect
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setUserAgent(NewPipeDownloader.USER_AGENT)
.setAllowCrossProtocolRedirects(true)
// Path C-7: chunk open-ended Range requests so iOS googlevideo URLs
// don't 403 on first byte. See net/IosSafeHttpDataSource.kt.
val dataSourceFactory = com.sulkta.straw.net.IosSafeHttpDataSource.Factory(
DefaultHttpDataSource.Factory()
.setUserAgent(STRAW_USER_AGENT)
.setAllowCrossProtocolRedirects(true)
)
val source = when {
r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory)
.createMediaSource(MediaItem.fromUri(r.dashMpdUrl))
@ -452,6 +463,11 @@ private fun InlinePlayer(
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
playbackError != null -> Text(
"playback error: $playbackError",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
resolved?.isPlayable != true -> Text(
"no playable stream",
color = Color.White,

View file

@ -1,6 +1,10 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* 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
@ -13,20 +17,12 @@ import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.net.RydClient
import com.sulkta.straw.net.RydVotes
import com.sulkta.straw.net.SponsorBlockClient
import com.sulkta.straw.util.bestThumbnail
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class VideoDetail(
val id: String,
@ -48,8 +44,8 @@ data class VideoDetailUiState(
val loading: Boolean = true,
val detail: VideoDetail? = null,
val error: String? = null,
// Stored on success for handoff to player. Not in UI.
val streamInfo: StreamInfo? = null,
// Stored on success for handoff to the player + Download dialog. Not in UI.
val streamInfo: uniffi.strawcore.StreamInfo? = null,
)
class VideoDetailViewModel : ViewModel() {
@ -66,11 +62,12 @@ class VideoDetailViewModel : ViewModel() {
_ui.value = VideoDetailUiState(loading = true)
viewModelScope.launch {
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 thumb = bestThumbnail(info.thumbnails)
val title = info.name ?: "(no title)"
val uploader = info.uploaderName ?: ""
val thumb = info.thumbnail
val title = info.title.ifBlank { "(no title)" }
val uploader = info.uploader
runCatching {
History.get().recordWatch(
@ -92,51 +89,43 @@ class VideoDetailViewModel : ViewModel() {
val sbCount = if (sbCats.isEmpty()) 0 else withContext(Dispatchers.IO) {
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
// already chose. Best-effort: empty if the fetch fails so the
// detail screen still renders. Filters out the current video.
// strawcore returns `related` as List<SearchItem>. Map to the
// Kotlin StreamItem shape used elsewhere.
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> =
if (info.uploaderUrl.isNullOrBlank()) emptyList()
else withContext(Dispatchers.IO) {
runCatching {
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val ch = ChannelInfo.getInfo(service, info.uploaderUrl)
val videosTab = ch.tabs.firstOrNull {
it.contentFilters.contains(ChannelTabs.VIDEOS)
} ?: ch.tabs.firstOrNull()
if (videosTab == null) emptyList()
else ChannelTabInfo.getInfo(service, videosTab)
.relatedItems
.filterIsInstance<StreamInfoItem>()
.filter { it.url != streamUrl }
.take(20)
.map { si ->
com.sulkta.straw.feature.search.StreamItem(
url = si.url,
title = si.name ?: "(no title)",
uploader = si.uploaderName ?: uploader,
uploaderUrl = si.uploaderUrl ?: info.uploaderUrl,
thumbnail = bestThumbnail(si.thumbnails),
durationSeconds = si.duration,
viewCount = si.viewCount,
)
}
}.getOrDefault(emptyList())
}
if (uploaderUrl.isNullOrBlank()) emptyList()
else runCatching {
val ch = uniffi.strawcore.channelInfo(uploaderUrl)
ch.videos
.filter { it.url != streamUrl }
.take(20)
.map { v ->
com.sulkta.straw.feature.search.StreamItem(
url = v.url,
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader.ifBlank { uploader },
uploaderUrl = v.uploaderUrl ?: uploaderUrl,
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
)
}
}.getOrDefault(emptyList())
_ui.value = VideoDetailUiState(
loading = false,
@ -146,7 +135,7 @@ class VideoDetailViewModel : ViewModel() {
uploader = uploader,
uploaderUrl = info.uploaderUrl,
viewCount = info.viewCount,
description = info.description?.content ?: "",
description = info.description,
thumbnail = thumb,
ryd = ryd,
sbSegmentCount = sbCount,

View file

@ -3,8 +3,12 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*
* Phase Q: aggregate latest videos across all subscribed channels into a
* single feed. Fans out per-channel ChannelInfo + ChannelTabs.VIDEOS
* fetches in parallel, merges by view count desc, caps at 200 items.
* single feed. Fans out per-channel channelInfo() fetches in parallel,
* merges by view count desc, caps at 200 items.
*
* Path C-5: each per-channel fetch is now ONE strawcore.channelInfo()
* call instead of two NewPipeExtractor round-trips (ChannelInfo.getInfo +
* ChannelTabInfo.getInfo). Halves the network work for the feed.
*
* Audit fixes (2026-05-24 pass #2):
* HIGH-6: cancel any prior in-flight refresh when a new one starts, cap
@ -20,9 +24,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.bestThumbnail
import com.sulkta.straw.util.strawLogW
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@ -34,14 +36,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class SubscriptionFeedUiState(
val loading: Boolean = false,
@ -82,55 +77,46 @@ class SubscriptionFeedViewModel : ViewModel() {
_ui.update { it.copy(loading = true, error = null) }
inFlight = viewModelScope.launch {
try {
val items = withContext(Dispatchers.IO) {
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val perChannelMax = 5
val gate = Semaphore(parallelism)
coroutineScope {
val deferreds = channels.map { ch ->
async {
gate.withPermit {
withTimeoutOrNull(perChannelTimeoutMs) {
runCatching {
val info = ChannelInfo.getInfo(service, ch.url)
val tab = info.tabs.firstOrNull {
it.contentFilters.contains(ChannelTabs.VIDEOS)
} ?: info.tabs.firstOrNull()
?: return@runCatching emptyList<StreamItem>()
ChannelTabInfo.getInfo(service, tab)
.relatedItems
.filterIsInstance<StreamInfoItem>()
.take(perChannelMax)
.map { si ->
StreamItem(
url = si.url,
title = si.name ?: "(no title)",
uploader = si.uploaderName ?: ch.name,
uploaderUrl = si.uploaderUrl ?: ch.url,
thumbnail = bestThumbnail(si.thumbnails),
durationSeconds = si.duration,
viewCount = si.viewCount,
)
}
}.onFailure {
strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" }
}.getOrDefault(emptyList())
} ?: run {
strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" }
emptyList()
}
val perChannelMax = 5
val gate = Semaphore(parallelism)
val items = coroutineScope {
val deferreds = channels.map { ch ->
async {
gate.withPermit {
withTimeoutOrNull(perChannelTimeoutMs) {
runCatching {
val info = uniffi.strawcore.channelInfo(ch.url)
info.videos
.take(perChannelMax)
.map { v ->
StreamItem(
url = v.url,
title = v.title.ifBlank { "(no title)" },
uploader = v.uploader.ifBlank { ch.name },
uploaderUrl = v.uploaderUrl ?: ch.url,
thumbnail = v.thumbnail,
durationSeconds = v.durationSeconds,
viewCount = v.viewCount,
)
}
}.onFailure {
strawLogW("StrawFeed") { "channel fetch failed for ${ch.url}: ${it.message}" }
}.getOrDefault(emptyList())
} ?: run {
strawLogW("StrawFeed") { "channel fetch timed out: ${ch.url}" }
emptyList()
}
}
}
deferreds.awaitAll()
}
.flatten()
// No reliable upload-timestamp from extractor's StreamInfoItem
// in all cases — sort by view count desc as a soft proxy for
// recency-popularity within the recent window.
.sortedByDescending { it.viewCount }
.take(200)
deferreds.awaitAll()
}
.flatten()
// No reliable upload-timestamp on the search-item shape — sort
// by view count desc as a soft proxy for recency-popularity
// within the recent window.
.sortedByDescending { it.viewCount }
.take(200)
_ui.update {
SubscriptionFeedUiState(
loading = false,

View file

@ -50,7 +50,8 @@ import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
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
class PlaybackService : MediaSessionService() {
@ -62,9 +63,14 @@ class PlaybackService : MediaSessionService() {
super.onCreate()
ensureChannel()
val httpFactory = DefaultHttpDataSource.Factory()
.setUserAgent(NewPipeDownloader.USER_AGENT)
.setAllowCrossProtocolRedirects(true)
// Path C-7: wrap in IosSafeHttpDataSource so ExoPlayer's open-ended
// Range requests get chunked into bounded reads. iOS-bound googlevideo
// 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)
.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.ProgressiveMediaSource
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.util.strawLogI
import kotlinx.coroutines.delay
@ -114,6 +114,20 @@ fun PlayerScreen(
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) {
onDispose {
mediaSession.release()
@ -168,9 +182,13 @@ fun PlayerScreen(
LaunchedEffect(resolved) {
val r = resolved ?: return@LaunchedEffect
val dataSourceFactory = DefaultHttpDataSource.Factory()
.setUserAgent(NewPipeDownloader.USER_AGENT)
.setAllowCrossProtocolRedirects(true)
// Path C-7: chunk open-ended Range requests so iOS googlevideo URLs
// don't 403 on first byte.
val dataSourceFactory = com.sulkta.straw.net.IosSafeHttpDataSource.Factory(
DefaultHttpDataSource.Factory()
.setUserAgent(STRAW_USER_AGENT)
.setAllowCrossProtocolRedirects(true)
)
val source = when {
r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory)
@ -251,6 +269,12 @@ fun PlayerScreen(
modifier = Modifier.padding(16.dp),
)
playbackError != null -> Text(
"playback error: $playbackError",
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp),
)
resolved?.isPlayable != true -> Text(
"no playable stream found",
modifier = Modifier.padding(16.dp),

View file

@ -1,13 +1,16 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* 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
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.MaxResolution
import com.sulkta.straw.data.Settings
import com.sulkta.straw.net.SbSegment
import com.sulkta.straw.net.SponsorBlockClient
@ -17,7 +20,6 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.stream.StreamInfo
data class ResolvedPlayback(
val title: String,
@ -48,8 +50,11 @@ class PlayerViewModel : ViewModel() {
_ui.value = PlayerUiState(loading = true)
viewModelScope.launch {
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 sbCategories = Settings.get().sbCategories.value.map { it.key }
val segments = if (sbCategories.isEmpty()) {
emptyList()
@ -61,32 +66,24 @@ class PlayerViewModel : ViewModel() {
}
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
// (e.g. user picked 144p but the video only has 360p+), fall
// back to the lowest-resolution available instead of returning
// null and showing a black-screen player.
fun pickVideo(streams: List<org.schabi.newpipe.extractor.stream.VideoStream>?): String? {
if (streams.isNullOrEmpty()) return null
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
// Audit HIGH-8 carry-over: filter by max resolution but fall
// back to lowest available if the ceiling excludes everything.
fun pickVideo(streams: List<uniffi.strawcore.VideoStreamItem>): String? {
if (streams.isEmpty()) return null
val filtered = streams.filter { it.height <= maxRes }
val pool = filtered.ifEmpty { streams }
return pool.maxByOrNull { it.bitrate }?.url
}
val combined = pickVideo(info.videoStreams)
val videoOnly = pickVideo(info.videoOnlyStreams)
val audioOnly = info.audioStreams
?.filter { it.content?.isNotBlank() == true }
?.maxByOrNull { it.bitrate ?: 0 }
?.content
val combined = pickVideo(info.combined)
val videoOnly = pickVideo(info.videoOnly)
val audioOnly = info.audioOnly.maxByOrNull { it.bitrate }?.url
_ui.value = PlayerUiState(
loading = false,
resolved = ResolvedPlayback(
title = info.name ?: "",
title = info.title,
videoUrl = videoOnly,
audioUrl = audioOnly,
combinedUrl = combined,

View file

@ -8,17 +8,10 @@ package com.sulkta.straw.feature.search
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.search.SearchInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class SearchUiState(
val query: String = "",
@ -52,7 +45,23 @@ class SearchViewModel : ViewModel() {
_ui.value = _ui.value.copy(loading = true, error = null, results = emptyList())
viewModelScope.launch {
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)
} catch (t: Throwable) {
_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
import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import okio.Buffer
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 {
val cl = contentLength()

View file

@ -0,0 +1,173 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* 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 Lucy egress: 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
import com.sulkta.straw.extractor.NewPipeDownloader
import com.sulkta.straw.util.strawLogD
import com.sulkta.straw.util.strawLogW
import kotlinx.serialization.Serializable
@ -34,11 +33,11 @@ object RydClient {
strawLogD(TAG) { "fetch start: $videoId$url" }
val req = Request.Builder()
.url(url)
.header("User-Agent", NewPipeDownloader.USER_AGENT)
.header("User-Agent", STRAW_USER_AGENT)
.header("Accept", "application/json")
.build()
return runCatching {
NewPipeDownloader.client().newCall(req).execute().use { r ->
strawHttpClient().newCall(req).execute().use { r ->
val code = r.code
// AUD-HIGH: bounded body read to defend against OOM.
val bodyStr = r.body?.cappedString(RYD_MAX_BYTES) ?: ""

View file

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

View file

@ -1,24 +0,0 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta-Coop
* 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
}