diff --git a/.gitignore b/.gitignore index b971f9042..6bb96d430 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 0a9e73122..4a9e4ce63 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -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 = 16 +const val STRAW_VERSION_NAME = "0.1.0-AB" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/rust/strawcore/Cargo.toml b/rust/strawcore/Cargo.toml index 590a12e12..1d82915cc 100644 --- a/rust/strawcore/Cargo.toml +++ b/rust/strawcore/Cargo.toml @@ -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. diff --git a/rust/strawcore/src/stream.rs b/rust/strawcore/src/stream.rs index 8f550b7f3..1c28fc2b4 100644 --- a/rust/strawcore/src/stream.rs +++ b/rust/strawcore/src/stream.rs @@ -129,19 +129,15 @@ pub async fn stream_info(url: String) -> Result { 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 diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index 13ef39dde..774719420 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -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 `/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) } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index 6d8a1defc..38402f821 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -9,19 +9,13 @@ 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-6 / Phase U-5: NewPipeExtractor is out. strawcore (Rust) + // loads its own libstrawcore.so via JNA when first called — no + // explicit init needed here. Just bootstrap the local stores. History.init(this) Settings.init(this) Subscriptions.init(this) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt b/strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt deleted file mode 100644 index bc185b5e0..000000000 --- a/strawApp/src/main/kotlin/com/sulkta/straw/extractor/NewPipeDownloader.kt +++ /dev/null @@ -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 - } -} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt index e3cb6b2e4..90dd8ea05 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelViewModel.kt @@ -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 = if (videosTab != null) { - withContext(Dispatchers.IO) { - runCatching { - ChannelTabInfo.getInfo(service, videosTab) - .relatedItems - .filterIsInstance() - .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) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 1720dd4da..1bd6cae8c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -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)" @@ -417,7 +409,7 @@ private fun InlinePlayer( LaunchedEffect(resolved) { val r = resolved ?: return@LaunchedEffect val dataSourceFactory = DefaultHttpDataSource.Factory() - .setUserAgent(NewPipeDownloader.USER_AGENT) + .setUserAgent(STRAW_USER_AGENT) .setAllowCrossProtocolRedirects(true) val source = when { r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt index 06ee83b6c..63dea79c3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailViewModel.kt @@ -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() - ?.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. 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 = - 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() - .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, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index 93ca74467..d7f27b11c 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt @@ -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() - ChannelTabInfo.getInfo(service, tab) - .relatedItems - .filterIsInstance() - .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, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt index 40b4083c0..349c49ba8 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlaybackService.kt @@ -50,7 +50,7 @@ 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.STRAW_USER_AGENT @UnstableApi class PlaybackService : MediaSessionService() { @@ -63,7 +63,7 @@ class PlaybackService : MediaSessionService() { ensureChannel() val httpFactory = DefaultHttpDataSource.Factory() - .setUserAgent(NewPipeDownloader.USER_AGENT) + .setUserAgent(STRAW_USER_AGENT) .setAllowCrossProtocolRedirects(true) val mediaSourceFactory = DefaultMediaSourceFactory(this) .setDataSourceFactory(httpFactory) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt index 1478ca857..dfb0518e0 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerScreen.kt @@ -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 @@ -169,7 +169,7 @@ fun PlayerScreen( LaunchedEffect(resolved) { val r = resolved ?: return@LaunchedEffect val dataSourceFactory = DefaultHttpDataSource.Factory() - .setUserAgent(NewPipeDownloader.USER_AGENT) + .setUserAgent(STRAW_USER_AGENT) .setAllowCrossProtocolRedirects(true) val source = when { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt index ee523f634..5dd9eeaa3 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/PlayerViewModel.kt @@ -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?): 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): 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, diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt index 5ef859be4..338388af9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchViewModel.kt @@ -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 { - val service = NewPipe.getService(ServiceList.YouTube.serviceId) - val qh = service.searchQHFactory.fromQuery(query, emptyList(), "") - val info = SearchInfo.getInfo(service, qh) - return info.relatedItems - .filterIsInstance() - .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, - ) - } - } } diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/Http.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/Http.kt index 4a3524a61..a8d629d86 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/Http.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/Http.kt @@ -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() diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt index 7c684b4ac..817597596 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/RydClient.kt @@ -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) ?: "" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt index a5bd4b555..9979e58f8 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/net/SponsorBlockClient.kt @@ -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) ?: "" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/util/Thumbnails.kt b/strawApp/src/main/kotlin/com/sulkta/straw/util/Thumbnails.kt deleted file mode 100644 index 6d47e386b..000000000 --- a/strawApp/src/main/kotlin/com/sulkta/straw/util/Thumbnails.kt +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SPDX-FileCopyrightText: 2026 Sulkta-Coop - * SPDX-License-Identifier: GPL-3.0-or-later - * - * NewPipeExtractor returns thumbnails as a List 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?): 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 -}