straw/rust/strawcore/src/search.rs
Cobb 4dfb2e1450
All checks were successful
gitleaks / scan (push) Successful in 41s
straw: infinite-scroll pagination for channel + search
Wire the new strawcore continuation fetchers through UniFFI and add
load-more-on-scroll to the Channel and Search screens — previously both
loaded only page 1 and stopped.

FFI (rust/strawcore): search() now returns Page{items, continuation};
channelInfo carries videos_continuation; new searchContinuation() and
channelVideosContinuation() suspend funs map the core ContinuationPage.

Channel + Search ViewModels: loadMore() fetches the next page, dedups by
url, advances the token, and stops when the token runs out or a page
yields zero net-new items (guards a looping continuation). Result-set
swaps (channel switch / new submit / cache preview) cancel the in-flight
page, and a token fence inside the state update prevents a stale page
being spliced into a replaced list. Screens add a near-end LazyColumn
trigger (rememberLazyListState + derivedStateOf) and a footer spinner.
2026-06-19 18:21:42 -07:00

115 lines
4.2 KiB
Rust

// 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<String>,
pub thumbnail: Option<String>,
/// 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<SearchItem>,
pub continuation: Option<String>,
}
/// 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<Page, StrawcoreError> {
// 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<Page, StrawcoreError> {
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))
}