diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 436e62cd2..918439859 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 = 8 -const val STRAW_VERSION_NAME = "0.1.0-U" +const val STRAW_VERSION_CODE = 9 +const val STRAW_VERSION_NAME = "0.1.0-V" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index d200b06d4..7ee7f3ec7 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -24,6 +24,10 @@ codegen-units = 1 panic = "abort" opt-level = "z" +# `url` crate for video-id extraction in stream.rs. +[workspace.dependencies] +url = "2" + [profile.dev] # Keep debug builds fast — we're rebuilding constantly during U-1..U-5. opt-level = 0 diff --git a/rust/strawcore/Cargo.toml b/rust/strawcore/Cargo.toml index 2b91a70c3..590a12e12 100644 --- a/rust/strawcore/Cargo.toml +++ b/rust/strawcore/Cargo.toml @@ -31,6 +31,8 @@ rquickjs-sys = { version = "0.9", default-features = false, features = ["bindgen thiserror = "1" # Single-threaded init for the runtime + extractor singletons. once_cell = "1" +# URL parsing for the video-id extractor in stream.rs. +url = { workspace = true } # Android log integration — `log::info!()` ends up in `adb logcat -s strawcore`. log = "0.4" android_logger = { version = "0.14", default-features = false } diff --git a/rust/strawcore/src/lib.rs b/rust/strawcore/src/lib.rs index 848e26627..df611c6f6 100644 --- a/rust/strawcore/src/lib.rs +++ b/rust/strawcore/src/lib.rs @@ -11,6 +11,7 @@ use std::sync::Once; mod error; mod runtime; mod search; +mod stream; #[allow(unused_imports)] use runtime::block_on; @@ -18,6 +19,7 @@ use runtime::block_on; // Re-exports so UniFFI sees the types at the crate root for macro discovery. pub use error::StrawcoreError; pub use search::SearchItem; +pub use stream::{AudioStreamItem, StreamInfo, VideoStreamItem}; /// Initialize Android logging once. The Kotlin side calls this from /// StrawApp.onCreate() so anything emitted via `log::info!()` shows up in diff --git a/rust/strawcore/src/stream.rs b/rust/strawcore/src/stream.rs new file mode 100644 index 000000000..d388c5235 --- /dev/null +++ b/rust/strawcore/src/stream.rs @@ -0,0 +1,190 @@ +// Phase U-3 — `stream_info(url)` via rustypipe, exposed as a suspend fun. +// +// Drives both VideoDetailScreen (title/uploader/description/thumbnail) and +// PlayerScreen (audio/video stream URLs that ExoPlayer loads from). One +// Rust call replaces two NewPipeExtractor StreamInfo.getInfo() round-trips. +// +// `StreamInfo` keeps field names parallel to the Kotlin-side VideoDetail +// + ResolvedPlayback so the ViewModels swap one-to-one. +// +// Not yet wired here (rustypipe doesn't surface these from `player()` alone +// and they need a separate fetch): +// - like_count +// - related videos +// Both will land in U-3.5 via `rp.query().video_details(id)` if we want +// the like count, and via a separate "related" call. For now Kotlin gets +// 0 / empty list and the UI handles it (already does). + +use crate::error::StrawcoreError; +use crate::search::SearchItem; +use rustypipe::client::RustyPipe; + +#[derive(Debug, Clone, uniffi::Record)] +pub struct StreamInfo { + pub id: String, + pub title: String, + pub uploader: String, + pub uploader_url: Option, + pub description: String, + pub thumbnail: Option, + pub view_count: i64, + pub like_count: i64, + /// Duration in seconds. 0 = live/unknown. + pub duration_seconds: i64, + + /// Progressive (audio+video combined). Empty when YT only serves DASH. + pub combined: Vec, + /// DASH/adaptive video-only streams. Pair with `audio_only` via MergingMediaSource. + pub video_only: Vec, + /// DASH/adaptive audio-only streams. Sort by bitrate desc for "best audio". + pub audio_only: Vec, + /// Optional DASH MPD URL. ExoPlayer's DashMediaSource accepts this directly. + pub dash_mpd_url: Option, + /// Optional HLS playlist URL. ExoPlayer's HlsMediaSource accepts this directly. + pub hls_url: Option, + + /// "Up next" list. Empty for now — populated in U-3.5. + pub related: Vec, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct VideoStreamItem { + pub url: String, + /// e.g. 1080, 720, 480. + pub height: i32, + pub bitrate: i64, + pub mime_type: String, +} + +#[derive(Debug, Clone, uniffi::Record)] +pub struct AudioStreamItem { + pub url: String, + pub bitrate: i64, + pub mime_type: String, +} + +fn yt_channel_url(id: &str) -> String { + format!("https://www.youtube.com/channel/{}", id) +} + +/// Best-effort YouTube video-id extraction. +fn extract_video_id(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.len() == 11 + && trimmed.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Ok(trimmed.to_string()); + } + let url = url::Url::parse(trimmed).map_err(|e| StrawcoreError::Unsupported { + detail: format!("bad URL: {}", e), + })?; + let host = url.host_str().unwrap_or("").to_ascii_lowercase(); + let host = host + .trim_start_matches("www.") + .trim_start_matches("m.") + .trim_start_matches("music."); + match host { + "youtube.com" | "youtube-nocookie.com" => { + if let Some(v) = url + .query_pairs() + .find(|(k, _)| k == "v") + .map(|(_, v)| v.into_owned()) + { + if !v.is_empty() { + return Ok(v); + } + } + let path = url.path().trim_start_matches('/'); + for prefix in ["embed/", "v/", "shorts/"] { + if let Some(rest) = path.strip_prefix(prefix) { + let id = rest.split('/').next().unwrap_or(""); + if !id.is_empty() { + return Ok(id.to_string()); + } + } + } + Err(StrawcoreError::Unsupported { + detail: "no video id in URL".into(), + }) + } + "youtu.be" => { + let id = url.path().trim_start_matches('/').split('/').next().unwrap_or(""); + if id.is_empty() { + Err(StrawcoreError::Unsupported { + detail: "no video id in youtu.be URL".into(), + }) + } else { + Ok(id.to_string()) + } + } + _ => Err(StrawcoreError::Unsupported { + detail: format!("unsupported host: {}", host), + }), + } +} + +#[uniffi::export(async_runtime = "tokio")] +pub async fn stream_info(url: String) -> Result { + let id = extract_video_id(&url)?; + log::info!("strawcore::stream_info id={}", id); + let rp = RustyPipe::new(); + + let player = rp.query().player(&id).await?; + let details = &player.details; + + // Progressive (combined audio+video) goes through video_streams; the + // audio+video split path is video_only_streams + audio_streams. + let combined: Vec = player + .video_streams + .iter() + .map(|s| VideoStreamItem { + url: s.url.clone(), + height: s.height as i32, + bitrate: s.bitrate as i64, + mime_type: format!("{:?}/{:?}", s.format, s.codec), + }) + .collect(); + let video_only: Vec = player + .video_only_streams + .iter() + .map(|s| VideoStreamItem { + url: s.url.clone(), + height: s.height as i32, + bitrate: s.bitrate as i64, + mime_type: format!("{:?}/{:?}", s.format, s.codec), + }) + .collect(); + let audio_only: Vec = player + .audio_streams + .iter() + .map(|s| AudioStreamItem { + url: s.url.clone(), + bitrate: s.bitrate as i64, + mime_type: format!("{:?}/{:?}", s.format, s.codec), + }) + .collect(); + + let thumbnail = details.thumbnail.last().map(|t| t.url.clone()); + + Ok(StreamInfo { + id: details.id.clone(), + title: details.name.clone().unwrap_or_default(), + uploader: details.channel_name.clone().unwrap_or_default(), + uploader_url: if details.channel_id.is_empty() { + None + } else { + Some(yt_channel_url(&details.channel_id)) + }, + description: details.description.clone().unwrap_or_default(), + thumbnail, + view_count: details.view_count.unwrap_or(0) as i64, + like_count: 0, + duration_seconds: details.duration as i64, + combined, + video_only, + audio_only, + dash_mpd_url: player.dash_manifest_url.clone(), + hls_url: player.hls_manifest_url.clone(), + related: Vec::new(), + }) +} 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 33d1c165a..54e3ed3e4 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 @@ -190,7 +190,7 @@ fun VideoDetailScreen( Spacer(modifier = Modifier.height(16.dp)) if (showDownloadDialog) { - val info = state.streamInfo + val info = state.info // uniffi.strawcore.StreamInfo cached on the UI state AlertDialog( onDismissRequest = { showDownloadDialog = false }, title = { Text("Download") }, @@ -208,10 +208,9 @@ 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 + 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)" @@ -222,14 +221,12 @@ 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)" 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 0557c4051..5ad789aa1 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,11 @@ /* * SPDX-FileCopyrightText: 2026 Sulkta-Coop * SPDX-License-Identifier: GPL-3.0-or-later + * + * Phase U-3: extractor moved from NewPipeExtractor (Java) to strawcore + * (Rust + rustypipe), called as a UniFFI suspend fun. The shape of + * VideoDetail and the on-screen behavior are unchanged; only the engine + * underneath flipped. */ package com.sulkta.straw.feature.detail @@ -10,18 +15,16 @@ import androidx.lifecycle.viewModelScope import com.sulkta.straw.data.History import com.sulkta.straw.data.Settings import com.sulkta.straw.data.WatchHistoryItem +import com.sulkta.straw.feature.search.StreamItem 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.stream.StreamInfo -import org.schabi.newpipe.extractor.stream.StreamInfoItem data class VideoDetail( val id: String, @@ -33,15 +36,15 @@ data class VideoDetail( val thumbnail: String?, val ryd: RydVotes? = null, val sbSegmentCount: Int = 0, - val related: List = emptyList(), + val related: List = emptyList(), ) 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, + /** Cached strawcore result so the Player + Download dialog can use it. */ + val info: uniffi.strawcore.StreamInfo? = null, ) class VideoDetailViewModel : ViewModel() { @@ -51,18 +54,18 @@ class VideoDetailViewModel : ViewModel() { private var loadedUrl: String? = null fun load(streamUrl: String) { - // viewModel() is Activity-scoped, so the same VM is reused across - // navigations. Compare the requested URL with what we last loaded. + // Activity-scoped VM is reused across nav entries; only re-fetch when + // the requested URL actually changed. if (loadedUrl == streamUrl && _ui.value.detail != null) return loadedUrl = streamUrl _ui.value = VideoDetailUiState(loading = true) viewModelScope.launch { try { - val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) } + 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 title = info.title.ifBlank { "(no title)" } + val uploader = info.uploader + val thumb = info.thumbnail runCatching { History.get().recordWatch( @@ -77,6 +80,8 @@ class VideoDetailViewModel : ViewModel() { ) } + // RYD + SponsorBlock stay in Kotlin (small JSON HTTP clients, + // no extractor logic). val ryd = withContext(Dispatchers.IO) { runCatching { RydClient.fetch(videoId) }.getOrNull() } @@ -84,19 +89,18 @@ 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() + + val related = info.related.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 = VideoDetailUiState( loading = false, @@ -106,13 +110,13 @@ class VideoDetailViewModel : ViewModel() { uploader = uploader, uploaderUrl = info.uploaderUrl, viewCount = info.viewCount, - description = info.description?.content ?: "", + description = info.description, thumbnail = thumb, ryd = ryd, sbSegmentCount = sbCount, related = related, ), - streamInfo = info, + info = info, ) } catch (t: Throwable) { _ui.value = VideoDetailUiState( 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..9520993b4 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: 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,9 @@ class PlayerViewModel : ViewModel() { _ui.value = PlayerUiState(loading = true) viewModelScope.launch { try { - val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) } + 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 +64,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,