perf(series): list a series' episodes with one concurrent season walk
"More info" on Frasier took ~10 s. The series page asked Rust for the episodes and for the current episode as two commands; each walked every season, and each walk fetched the eleven seasons one after another. So the wait was the sum of twenty-two listings, each a cache read queued behind whatever the database was writing — measured at ~4 s per walk on a Fairphone 5 while the launch-time catalog sync ran. Seasons are now fetched together (gather_season_episodes), so a walk waits for its slowest season, not the sum. And repository_get_series_view returns the episodes and the current episode from one walk, with Next Up and resume fetched alongside it; the series page makes that one call. Under today's single database connection the cache reads themselves still queue on its mutex; the concurrency pays off fully once reads get their own connections. Halving the walks helps regardless. Test first: ten 100 ms seasons took 1.01 s sequentially; now well under the 400 ms bound, with a failing season still leaving the rest. DR-295, UT-264.
This commit is contained in:
@@ -514,6 +514,24 @@ pub async fn repository_get_series_current_episode(
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// A series' episodes and the viewer's current episode, from one season
|
||||
/// fan-out. The series page used to ask for these as two commands, each of
|
||||
/// which walked every season.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101, DR-295
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn repository_get_series_view(
|
||||
manager: State<'_, RepositoryManagerWrapper>,
|
||||
handle: String,
|
||||
series_id: String,
|
||||
) -> Result<series_progress::SeriesView, String> {
|
||||
let repo = manager.0.get(&handle).ok_or("Repository not found")?;
|
||||
series_progress::resolve_series_view(repo.as_ref(), &series_id)
|
||||
.await
|
||||
.map_err(|e| format!("{:?}", e))
|
||||
}
|
||||
|
||||
/// Erase the viewer's watch history for an item.
|
||||
///
|
||||
/// Clears the played flag and the resume position; on a series or season the
|
||||
|
||||
@@ -238,6 +238,7 @@ use commands::{
|
||||
repository_get_resume_movies,
|
||||
repository_get_series_current_episode,
|
||||
repository_get_series_episodes,
|
||||
repository_get_series_view,
|
||||
repository_get_similar_items,
|
||||
repository_get_stream_selection,
|
||||
repository_get_subtitle_url,
|
||||
@@ -1014,6 +1015,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
||||
repository_get_next_up_episodes,
|
||||
repository_get_series_episodes,
|
||||
repository_get_series_current_episode,
|
||||
repository_get_series_view,
|
||||
repository_clear_watch_history,
|
||||
repository_get_recently_played_audio,
|
||||
repository_get_resume_movies,
|
||||
|
||||
@@ -209,21 +209,16 @@ pub async fn fetch_series_episodes(
|
||||
) -> Result<Vec<MediaItem>, RepoError> {
|
||||
let children = repo.get_items(series_id, list_options()).await?;
|
||||
|
||||
let mut episodes: Vec<MediaItem> = Vec::new();
|
||||
for season in children.items.iter().filter(|i| is_season(i)) {
|
||||
// One failing season must not blank the whole show.
|
||||
match repo.get_items(&season.id, list_options()).await {
|
||||
Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"[series] season {} of {} failed to load: {:?}",
|
||||
season.id,
|
||||
series_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let seasons: Vec<MediaItem> = children
|
||||
.items
|
||||
.iter()
|
||||
.filter(|i| is_season(i))
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut episodes = gather_season_episodes(&seasons, |season_id| async move {
|
||||
repo.get_items(&season_id, list_options()).await
|
||||
})
|
||||
.await;
|
||||
|
||||
// Flat series: the children *are* the episodes.
|
||||
if episodes.is_empty() {
|
||||
@@ -234,6 +229,82 @@ pub async fn fetch_series_episodes(
|
||||
Ok(episodes)
|
||||
}
|
||||
|
||||
/// Every episode in `seasons`, fetched with `fetch_season` (a season id → its
|
||||
/// children), **all at once**.
|
||||
///
|
||||
/// They used to be fetched one after another, so the wait was the sum of every
|
||||
/// season's listing: ~4 s for Frasier's eleven, on a phone whose cache reads
|
||||
/// were slowed by a catalog sync writing in the background. Concurrently it is
|
||||
/// the slowest single season. Order is restored afterwards by
|
||||
/// `sort_series_order`, so completion order does not matter.
|
||||
///
|
||||
/// One failing season must not blank the whole show: its episodes are left out
|
||||
/// and the rest returned.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-295 | UT-264
|
||||
pub async fn gather_season_episodes<F, Fut>(
|
||||
seasons: &[MediaItem],
|
||||
fetch_season: F,
|
||||
) -> Vec<MediaItem>
|
||||
where
|
||||
F: Fn(String) -> Fut,
|
||||
Fut: std::future::Future<Output = Result<super::SearchResult, RepoError>>,
|
||||
{
|
||||
let results = futures_util::future::join_all(
|
||||
seasons.iter().map(|season| fetch_season(season.id.clone())),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut episodes = Vec::new();
|
||||
for (season, result) in seasons.iter().zip(results) {
|
||||
match result {
|
||||
Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
|
||||
Err(e) => log::warn!("[series] season {} failed to load: {:?}", season.id, e),
|
||||
}
|
||||
}
|
||||
episodes
|
||||
}
|
||||
|
||||
/// A series' episodes and the one the viewer is up to, from **one** season
|
||||
/// fan-out.
|
||||
///
|
||||
/// The series page needs both, and asked for them as two commands; each walked
|
||||
/// every season, so every visit listed the show twice. One call, one walk.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101, DR-295
|
||||
#[derive(Debug, Clone, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SeriesView {
|
||||
pub episodes: Vec<MediaItem>,
|
||||
pub current: Option<MediaItem>,
|
||||
}
|
||||
|
||||
/// Build a [`SeriesView`]: one fan-out, with Next Up and resume fetched
|
||||
/// alongside it rather than after.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-101, DR-295
|
||||
pub async fn resolve_series_view(
|
||||
repo: &dyn MediaRepository,
|
||||
series_id: &str,
|
||||
) -> Result<SeriesView, RepoError> {
|
||||
let (episodes, next_up, resume) = futures_util::join!(
|
||||
fetch_series_episodes(repo, series_id),
|
||||
async {
|
||||
repo.get_next_up_episodes(Some(series_id), Some(1))
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
},
|
||||
async {
|
||||
repo.get_resume_items(Some(series_id), Some(10))
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
},
|
||||
);
|
||||
let episodes = episodes?;
|
||||
let current = pick_current_episode(series_id, &episodes, &next_up, &resume);
|
||||
Ok(SeriesView { episodes, current })
|
||||
}
|
||||
|
||||
/// Resolve the current episode, fetching everything the policy needs.
|
||||
///
|
||||
/// Next Up and resume are best-effort: offline they fail or come back empty, and
|
||||
@@ -293,6 +364,46 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// "More info" on Frasier took ~4 s to list its episodes, twice over: the
|
||||
/// eleven seasons were fetched one after another, so the wait was the *sum*
|
||||
/// of eleven listings. Fetched together it is the slowest one.
|
||||
///
|
||||
/// TRACES: UR-062 | DR-295 | UT-264
|
||||
#[tokio::test]
|
||||
async fn seasons_are_fetched_concurrently_not_one_after_another() {
|
||||
let seasons: Vec<MediaItem> = (1..=10)
|
||||
.map(|n| MediaItem {
|
||||
id: format!("season-{n}"),
|
||||
item_type: "Season".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let episodes = gather_season_episodes(&seasons, |season_id| async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
if season_id == "season-3" {
|
||||
// One failing season must not blank the show.
|
||||
return Err(RepoError::Network {
|
||||
message: "gone".to_string(),
|
||||
});
|
||||
}
|
||||
let n: i32 = season_id.trim_start_matches("season-").parse().unwrap();
|
||||
Ok(crate::repository::SearchResult {
|
||||
items: vec![episode(&format!("e{n}"), n, 1)],
|
||||
total_record_count: 1,
|
||||
})
|
||||
})
|
||||
.await;
|
||||
let elapsed = started.elapsed();
|
||||
|
||||
assert!(
|
||||
elapsed < std::time::Duration::from_millis(400),
|
||||
"ten 100 ms seasons took {elapsed:?} — fetched in sequence, not together"
|
||||
);
|
||||
assert_eq!(episodes.len(), 9, "every season but the failing one");
|
||||
}
|
||||
|
||||
fn watched(mut item: MediaItem) -> MediaItem {
|
||||
item.user_data = Some(UserData {
|
||||
is_played: Some(true),
|
||||
|
||||
Reference in New Issue
Block a user