// Phase 7 — search via Sulkta-Coop/strawcore-core. Exposed to Kotlin // as a suspend fun. SearchItem field shape is unchanged from Phase U-2 // so Kotlin callers (SearchViewModel) keep working with no code // changes. use strawcore_core::stream::StreamInfoItem; use strawcore_core::youtube::continuation::ContinuationPage; use strawcore_core::youtube::linkhandler::search::SearchFilter; use strawcore_core::youtube::search_extractor; use crate::error::StrawcoreError; #[derive(Debug, Clone, uniffi::Record)] pub struct SearchItem { pub url: String, pub title: String, pub uploader: String, pub uploader_url: Option, pub thumbnail: Option, /// Duration in seconds. 0 = live/unknown. pub duration_seconds: i64, /// Reported view count. 0 = unknown. pub view_count: i64, /// Relative upload date as YT renders it ("2 days ago", "3 weeks /// ago"). Empty if not extracted. Strawcore-core already populates /// this on StreamInfoItem; we just pass it through. pub upload_date_relative: String, } /// A page of results plus the opaque token that fetches the NEXT page. /// `continuation == null` means there are no more results — the Kotlin /// side stops requesting once it sees a null token. Shared by `search`, /// `search_continuation`, and the channel-videos continuation. #[derive(Debug, Clone, uniffi::Record)] pub struct Page { pub items: Vec, pub continuation: Option, } /// Map a core ContinuationPage (channel or search) onto the FFI Page. pub(crate) fn page_from_core(page: ContinuationPage) -> Page { Page { items: page.items.into_iter().map(from_core).collect(), continuation: page.continuation, } } pub(crate) fn from_core(item: StreamInfoItem) -> SearchItem { let uploader_url = if item.uploader_url.is_empty() { None } else { Some(item.uploader_url) }; let thumbnail = item .thumbnails .last() .map(|i| i.url().to_string()); SearchItem { url: item.url, title: item.name, uploader: item.uploader_name, uploader_url, thumbnail, duration_seconds: item.duration_seconds, view_count: if item.view_count < 0 { 0 } else { item.view_count }, upload_date_relative: item.upload_date_relative, } } #[uniffi::export(async_runtime = "tokio")] pub async fn search(query: String) -> Result { // Don't log the query itself — searches are PII (sometimes // names, sometimes embarrassing) and android_logger emits at // info-level in release builds, which means they'd ride the // Settings → Export Logs path straight into a user's chat. Log // shape, not content. log::info!("strawcore::search query_len={}", query.len()); // ensure_initialized was only wired into // init_logging() so the 5s-backoff retry path never fired from // the hot entry points. Now every extractor entry re-asserts // — cheap when INITIALIZED is true (single Acquire load). crate::runtime::ensure_initialized(); let result = tokio::task::spawn_blocking(move || { search_extractor::search(&query, SearchFilter::Videos) }) .await .map_err(|e| StrawcoreError::Extractor { msg: format!("join: {e}"), })??; // Page 1 carries the first continuation token (None once YT stops // handing them out). The Kotlin SearchViewModel passes it back to // `search_continuation` on scroll. Ok(Page { items: result.videos.into_iter().map(from_core).collect(), continuation: result.continuation_token, }) } /// Fetch the next page of search results from a continuation token /// returned by a prior `search` / `search_continuation` Page. #[uniffi::export(async_runtime = "tokio")] pub async fn search_continuation(token: String) -> Result { log::info!("strawcore::search_continuation token_len={}", token.len()); crate::runtime::ensure_initialized(); let page = tokio::task::spawn_blocking(move || search_extractor::search_continuation(&token)) .await .map_err(|e| StrawcoreError::Extractor { msg: format!("join: {e}"), })??; Ok(page_from_core(page)) }