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:
2026-09-24 03:22:31 +02:00
parent 9dd44eeada
commit f6efa7208a
7 changed files with 190 additions and 25 deletions
+18
View File
@@ -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
+2
View File
@@ -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,
+126 -15
View File
@@ -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),