diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 918439859..a63073c18 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 = 9 -const val STRAW_VERSION_NAME = "0.1.0-V" +const val STRAW_VERSION_CODE = 10 +const val STRAW_VERSION_NAME = "0.1.0-W" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/rust/strawcore/src/channel.rs b/rust/strawcore/src/channel.rs new file mode 100644 index 000000000..e52ee438d --- /dev/null +++ b/rust/strawcore/src/channel.rs @@ -0,0 +1,113 @@ +// Phase U-4 — `channel_info(channel_url)` via rustypipe. +// +// Returns channel metadata + the channel's latest videos (the "Videos" tab). +// Used by ChannelScreen (single-channel view) AND +// SubscriptionFeedViewModel (which fans out across all subscriptions). + +use crate::error::StrawcoreError; +use crate::search::SearchItem; +use rustypipe::client::RustyPipe; + +#[derive(Debug, Clone, uniffi::Record)] +pub struct ChannelInfo { + pub id: String, + pub name: String, + pub avatar: Option, + pub banner: Option, + /// -1 = unknown / hidden by the channel. + pub subscriber_count: i64, + pub description: String, + /// Latest videos from the channel (Videos tab, newest first). + pub videos: Vec, +} + +fn yt_video_url(id: &str) -> String { + format!("https://www.youtube.com/watch?v={}", id) +} + +fn yt_channel_url(id: &str) -> String { + format!("https://www.youtube.com/channel/{}", id) +} + +/// Channel-id extraction. Accepts: +/// https://www.youtube.com/channel/UC... +/// https://www.youtube.com/@handle +/// https://www.youtube.com/c/handle +/// https://www.youtube.com/user/handle +/// bare channel id (UC..., 24 chars) +fn extract_channel_input(input: &str) -> Result { + let trimmed = input.trim(); + // Bare channel ID — usually 24 chars starting with UC. + if trimmed.starts_with("UC") && trimmed.len() == 24 { + return Ok(trimmed.to_string()); + } + let url = url::Url::parse(trimmed).map_err(|e| StrawcoreError::Unsupported { + detail: format!("bad URL: {}", e), + })?; + let path = url.path().trim_start_matches('/').trim_end_matches('/'); + // /channel/UCxxx — canonical + if let Some(rest) = path.strip_prefix("channel/") { + let id = rest.split('/').next().unwrap_or(""); + if !id.is_empty() { + return Ok(id.to_string()); + } + } + // /@handle — rustypipe takes the handle (with @) + if path.starts_with('@') { + return Ok(path.split('/').next().unwrap_or(path).to_string()); + } + // /c/name or /user/name + for prefix in ["c/", "user/"] { + if let Some(rest) = path.strip_prefix(prefix) { + let name = rest.split('/').next().unwrap_or(""); + if !name.is_empty() { + // Rustypipe channel() takes the channel id or @handle. For + // legacy /c/ and /user/ URLs we prepend @ as a best-effort. + return Ok(format!("@{}", name)); + } + } + } + Err(StrawcoreError::Unsupported { + detail: format!("unsupported channel URL: {}", input), + }) +} + +#[uniffi::export(async_runtime = "tokio")] +pub async fn channel_info(channel_url: String) -> Result { + let key = extract_channel_input(&channel_url)?; + log::info!("strawcore::channel_info key={}", key); + let rp = RustyPipe::new(); + + // channel_videos(id) returns Channel> — the + // Channel wrapper carries name/avatar/banner/etc and `.content` + // is the paginator of videos. One round-trip gets us everything. + let channel = rp.query().channel_videos(&key).await?; + + let videos: Vec = channel + .content + .items + .into_iter() + .map(|v| SearchItem { + url: yt_video_url(&v.id), + title: v.name.clone(), + uploader: channel.name.clone(), + uploader_url: Some(yt_channel_url(&channel.id)), + thumbnail: v.thumbnail.last().map(|t| t.url.clone()), + duration_seconds: v.duration.unwrap_or(0) as i64, + view_count: v.view_count.unwrap_or(0) as i64, + }) + .collect(); + + let avatar = channel.avatar.last().map(|t| t.url.clone()); + let banner = channel.banner.last().map(|t| t.url.clone()); + + Ok(ChannelInfo { + id: channel.id, + name: channel.name, + avatar, + banner, + subscriber_count: channel.subscriber_count.map(|n| n as i64).unwrap_or(-1), + description: channel.description, + videos, + }) +} diff --git a/rust/strawcore/src/lib.rs b/rust/strawcore/src/lib.rs index df611c6f6..ea175b8eb 100644 --- a/rust/strawcore/src/lib.rs +++ b/rust/strawcore/src/lib.rs @@ -8,6 +8,7 @@ use std::sync::Once; +mod channel; mod error; mod runtime; mod search; @@ -17,6 +18,7 @@ mod stream; use runtime::block_on; // Re-exports so UniFFI sees the types at the crate root for macro discovery. +pub use channel::ChannelInfo; pub use error::StrawcoreError; pub use search::SearchItem; pub use stream::{AudioStreamItem, StreamInfo, VideoStreamItem}; diff --git a/strawApp/build.gradle.kts b/strawApp/build.gradle.kts index 0e00b8cca..c8660fc85 100644 --- a/strawApp/build.gradle.kts +++ b/strawApp/build.gradle.kts @@ -94,8 +94,8 @@ dependencies { implementation(libs.coil.compose) implementation(libs.coil.network.okhttp) - // NewPipeExtractor (JVM/Android-only) + its OkHttp dep - implementation(libs.newpipe.extractor) + // Phase U-5: NewPipeExtractor (Java) torn out — strawcore handles + // all YT extraction. OkHttp stays for RYD + SponsorBlock JSON clients. implementation(libs.squareup.okhttp) // JSON for SponsorBlock + Return YouTube Dislike clients diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt index f44b8027e..3eeabcce7 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawApp.kt @@ -1,6 +1,9 @@ /* * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later + * + * Phase U-5: NewPipeExtractor (Java) torn out. All YT extraction goes + * through strawcore (Rust + rustypipe + UniFFI) now. */ package com.sulkta.straw @@ -9,25 +12,16 @@ 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"), - ) History.init(this) Settings.init(this) Subscriptions.init(this) - // Phase U-1: load the strawcore native library and route its logs - // into android logcat under the "strawcore" tag. + // Load strawcore native + route its logs into android logcat under + // the "strawcore" tag. runCatching { System.loadLibrary("strawcore") uniffi.strawcore.initLogging() 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..fcc1d81b1 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: 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/feed/SubscriptionFeedViewModel.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/feed/SubscriptionFeedViewModel.kt index 93ca74467..51053855c 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 @@ -2,16 +2,14 @@ * SPDX-FileCopyrightText: 2026 Sulkta-Coop * 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. - * - * Audit fixes (2026-05-24 pass #2): - * HIGH-6: cancel any prior in-flight refresh when a new one starts, cap - * concurrency with a Semaphore, time-bound each per-channel fetch so - * one hung channel can't stall the whole feed. - * MED-7: use `update { }` for atomic UI-state writes (matches the - * convention applied to the data stores in audit pass #1). + * Phase Q + U-4: aggregate latest videos across all subscribed channels. + * Per-channel fetches now go through strawcore (rustypipe + UniFFI). The + * Kotlin side still owns the structured concurrency: + * - cancels prior in-flight refresh + * - Semaphore caps parallel fetches (rustypipe internally has its own + * HTTP pool but we still want to avoid N=100 concurrent extractor + * contexts when N=100 channels) + * - per-channel 15s timeout */ package com.sulkta.straw.feature.feed @@ -20,9 +18,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 +30,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, @@ -54,16 +43,11 @@ class SubscriptionFeedViewModel : ViewModel() { private val _ui = MutableStateFlow(SubscriptionFeedUiState()) val ui: StateFlow = _ui.asStateFlow() - /** Cache feed for 10 min to avoid hammering YT on tab re-entry. */ private val cacheTtlMs = 10L * 60 * 1000 - - /** Per-channel fetch timeout — slowest channel can't stall the whole batch. */ private val perChannelTimeoutMs = 15_000L - - /** Cap parallel network fetches even with 100+ subs. */ private val parallelism = 8 + private val perChannelMax = 5 - /** Live refresh job, so spam-tapping Refresh doesn't fan out racing fetches. */ private var inFlight: Job? = null fun refreshIfStale() { @@ -82,55 +66,40 @@ 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 gate = Semaphore(parallelism) + val items = coroutineScope { + val deferreds = channels.map { ch -> + async { + gate.withPermit { + withTimeoutOrNull(perChannelTimeoutMs) { + runCatching { + uniffi.strawcore.channelInfo(ch.url).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() + .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/net/Http.kt b/strawApp/src/main/kotlin/com/sulkta/straw/net/Http.kt index 4a3524a61..01b0598fd 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,33 @@ package com.sulkta.straw.net +import okhttp3.OkHttpClient import okhttp3.ResponseBody import okio.Buffer import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Phase U-5: USER_AGENT + shared OkHttpClient that previously lived on + * NewPipeDownloader. After ripping NewPipeExtractor, the RYD + SponsorBlock + * + ExoPlayer HTTP factories still need both. One shared client is fine. + */ +const val STRAW_USER_AGENT: String = + "Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36 Straw/1.0" + +@Volatile +private var sharedClient: OkHttpClient? = null + +fun strawHttpClient(): OkHttpClient = + sharedClient ?: synchronized(STRAW_USER_AGENT) { + sharedClient ?: OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .build() + .also { sharedClient = it } + } fun ResponseBody.cappedString(maxBytes: Long): String { 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 -}