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:
@@ -496,6 +496,7 @@ Internal architecture, components, and application logic.
|
|||||||
| DR-292 | The offline catalog reveal is one rule, applied by both library views. Two defects, one cause — "server only" was a private `$derived` inside `MediaCard`. (1) The list view (`LibraryListView`, what `LibraryGrid` renders when the stored view preference is `list`) had no notion of it at all, so a library browsed as a list offline showed every revealed item as an ordinary tappable row that plays nothing, with no way to queue it. (2) The rule asked the downloads store whether *this item id* was downloaded, but only a playable leaf (Audio, Movie, Episode) ever has a download row — an album's tracks carry them, the album does not — so a fully downloaded album greyed itself out and offered to queue what was already on the device, which is what "my downloaded music is greyed out" was. The rule moves to the pure `$lib/utils/serverOnly`, both views call it, and the container half is answered by the backend: `get_download_disk_usage().sizes` already carries container subtotals beside leaf sizes (DR-085), so `deviceContentIds` is membership in a Rust-computed map rather than a frontend guess at which item types are containers. That map was loaded only by the Downloads page, so the shell now primes it at startup and re-reads it whenever the offline gate settles (the DR-143 signal). Queueing is shared too (`queueOfflineDownload`), since the list view had no copy to diverge from | UI | UR-052, UR-055 | Done |
|
| DR-292 | The offline catalog reveal is one rule, applied by both library views. Two defects, one cause — "server only" was a private `$derived` inside `MediaCard`. (1) The list view (`LibraryListView`, what `LibraryGrid` renders when the stored view preference is `list`) had no notion of it at all, so a library browsed as a list offline showed every revealed item as an ordinary tappable row that plays nothing, with no way to queue it. (2) The rule asked the downloads store whether *this item id* was downloaded, but only a playable leaf (Audio, Movie, Episode) ever has a download row — an album's tracks carry them, the album does not — so a fully downloaded album greyed itself out and offered to queue what was already on the device, which is what "my downloaded music is greyed out" was. The rule moves to the pure `$lib/utils/serverOnly`, both views call it, and the container half is answered by the backend: `get_download_disk_usage().sizes` already carries container subtotals beside leaf sizes (DR-085), so `deviceContentIds` is membership in a Rust-computed map rather than a frontend guess at which item types are containers. That map was loaded only by the Downloads page, so the shell now primes it at startup and re-reads it whenever the offline gate settles (the DR-143 signal). Queueing is shared too (`queueOfflineDownload`), since the list view had no copy to diverge from | UI | UR-052, UR-055 | Done |
|
||||||
| DR-293 | Android plays the original file: ExoPlayer decodes AC-3, E-AC-3, DTS and TrueHD in software through the FFmpeg extension, so neither a download nor a stream needs the server to re-encode its audio. These are licensed codecs that Android does not ship — the ROD2-W09 tablet has a vendor DTS decoder and no AC-3/E-AC-3 at all — so the download policy (DR-171) judged audio against the webview's list and turned most films into a server transcode: generated as it is sent, no `Content-Length`, `Range` ignored, measured at ~1 MB/s and restarting from zero on every network blip, against a direct copy that moved a 910 MB episode in 94 s. The renderer is `DefaultRenderersFactory` in `EXTENSION_RENDERER_MODE_ON` (platform decoders first, FFmpeg for what they lack), and `CodecDetector` reports the extension's codecs beside `MediaCodecList`'s, so the device profile and the download policy — now `renderer_can_decode_audio`, DR-234's per-platform answer, instead of the webview's list — agree with what actually decodes. The webview video path is gone on Android: it decodes none of those codecs, so an original-file download would play there as a silent film; `webview_video_fallback` (Rust) is false on Android and the frontend neither offers the switch nor honours a stored "off". Linux keeps the webview fallback beside mpv native video, and with it the server transcode for undecodable audio. Rejected: re-encoding audio on the device after download — minutes of CPU and twice the disk per film, and it would not have helped streaming. The decoder is Jellyfin's `media3-ffmpeg-decoder` build (GPL-3.0; the distributed APK carries its terms, the source stays MIT) and must be versioned in step with media3 | Playback | UR-004, UR-071 | Done |
|
| DR-293 | Android plays the original file: ExoPlayer decodes AC-3, E-AC-3, DTS and TrueHD in software through the FFmpeg extension, so neither a download nor a stream needs the server to re-encode its audio. These are licensed codecs that Android does not ship — the ROD2-W09 tablet has a vendor DTS decoder and no AC-3/E-AC-3 at all — so the download policy (DR-171) judged audio against the webview's list and turned most films into a server transcode: generated as it is sent, no `Content-Length`, `Range` ignored, measured at ~1 MB/s and restarting from zero on every network blip, against a direct copy that moved a 910 MB episode in 94 s. The renderer is `DefaultRenderersFactory` in `EXTENSION_RENDERER_MODE_ON` (platform decoders first, FFmpeg for what they lack), and `CodecDetector` reports the extension's codecs beside `MediaCodecList`'s, so the device profile and the download policy — now `renderer_can_decode_audio`, DR-234's per-platform answer, instead of the webview's list — agree with what actually decodes. The webview video path is gone on Android: it decodes none of those codecs, so an original-file download would play there as a silent film; `webview_video_fallback` (Rust) is false on Android and the frontend neither offers the switch nor honours a stored "off". Linux keeps the webview fallback beside mpv native video, and with it the server transcode for undecodable audio. Rejected: re-encoding audio on the device after download — minutes of CPU and twice the disk per film, and it would not have helped streaming. The decoder is Jellyfin's `media3-ffmpeg-decoder` build (GPL-3.0; the distributed APK carries its terms, the source stays MIT) and must be versioned in step with media3 | Playback | UR-004, UR-071 | Done |
|
||||||
| DR-294 | A download plays with no network. Playing a downloaded item asked the server for its `PlaybackInfo` — only to read the media-source id that subtitle URLs are keyed by — and `HybridRepository::get_playback_info` went to the server alone, so offline the call retried for seven seconds, failed, and the file on disk was never opened. A completed download for the current user now answers playback info from its download row, first and regardless of reachability: the local path, direct play, and the item id as media-source id (a download names no source, so the server served its default, which carries the item's id). Next Up had the same shape — server-only — and the TV landing page loads it in one `Promise.all` with its other rows, so offline that single failure blanked the whole page with Continue Watching and Latest sitting in the cache; it now falls back to the cache when the server cannot answer. And a slow cache read is waited for, never discarded: the cache is one SQLite connection behind one mutex, so any write in progress (the catalog sync at every launch, a download finishing) pushes a read past the 100 ms fast path, and `get_items`, the library list, genres and playlist items discarded such a read, waited on the server, and offline returned its error over data on disk — "More info" on a downloaded show failed exactly so. They keep the read running (`cache_try`) and wait for it when the server fails (`settle`); the cache-only reads (search, favourites) simply await the cache | Repository | UR-002, UR-071 | Done |
|
| DR-294 | A download plays with no network. Playing a downloaded item asked the server for its `PlaybackInfo` — only to read the media-source id that subtitle URLs are keyed by — and `HybridRepository::get_playback_info` went to the server alone, so offline the call retried for seven seconds, failed, and the file on disk was never opened. A completed download for the current user now answers playback info from its download row, first and regardless of reachability: the local path, direct play, and the item id as media-source id (a download names no source, so the server served its default, which carries the item's id). Next Up had the same shape — server-only — and the TV landing page loads it in one `Promise.all` with its other rows, so offline that single failure blanked the whole page with Continue Watching and Latest sitting in the cache; it now falls back to the cache when the server cannot answer. And a slow cache read is waited for, never discarded: the cache is one SQLite connection behind one mutex, so any write in progress (the catalog sync at every launch, a download finishing) pushes a read past the 100 ms fast path, and `get_items`, the library list, genres and playlist items discarded such a read, waited on the server, and offline returned its error over data on disk — "More info" on a downloaded show failed exactly so. They keep the read running (`cache_try`) and wait for it when the server fails (`settle`); the cache-only reads (search, favourites) simply await the cache | Repository | UR-002, UR-071 | Done |
|
||||||
|
| DR-295 | A series page lists its episodes with one concurrent season fan-out. "More info" on Frasier took ~10 s: the page asked Rust for the episodes and for the current episode as two commands, each of which 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 slowed by whatever the database was writing (the catalog sync at launch measured it at ~4 s per walk). The seasons are now fetched together (`gather_season_episodes`, so the wait is the slowest season), and `repository_get_series_view` returns the episodes and the current episode from one walk, with Next Up and resume fetched alongside it | Repository | UR-062 | Done |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -565,7 +566,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UR-058 | - | DR-087, DR-142 |
|
| UR-058 | - | DR-087, DR-142 |
|
||||||
| UR-060 | - | DR-090, DR-091, DR-111 |
|
| UR-060 | - | DR-090, DR-091, DR-111 |
|
||||||
| UR-061 | - | DR-092 |
|
| UR-061 | - | DR-092 |
|
||||||
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107 |
|
| UR-062 | - | DR-101, DR-102, DR-103, DR-104, DR-107, DR-295 |
|
||||||
| UR-063 | - | DR-105 |
|
| UR-063 | - | DR-105 |
|
||||||
| UR-064 | - | DR-106 |
|
| UR-064 | - | DR-106 |
|
||||||
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
|
| UR-065 | IR-030 | DR-108, DR-109, DR-110, DR-111 |
|
||||||
@@ -854,6 +855,7 @@ Internal architecture, components, and application logic.
|
|||||||
| UT-261 | Next Up answers from the cache, rather than failing, when the server is unreachable | DR-294 | Done |
|
| UT-261 | Next Up answers from the cache, rather than failing, when the server is unreachable | DR-294 | Done |
|
||||||
| UT-262 | The Android webview fallback is neither offered in Settings nor honoured by the player unless Rust reports it, so a stored "native video off" cannot route video to a renderer that plays the original file silent | DR-293 | Done |
|
| UT-262 | The Android webview fallback is neither offered in Settings nor honoured by the player unless Rust reports it, so a stored "native video off" cannot route video to a renderer that plays the original file silent | DR-293 | Done |
|
||||||
| UT-263 | With the database held past the 100 ms fast path and the server unreachable, `get_items`, the library list, a cache-only search and cache-only favourites all answer from the cache instead of failing | DR-294 | Done |
|
| UT-263 | With the database held past the 100 ms fast path and the server unreachable, `get_items`, the library list, a cache-only search and cache-only favourites all answer from the cache instead of failing | DR-294 | Done |
|
||||||
|
| UT-264 | Ten seasons whose listings each take 100 ms are gathered in well under the 1 s a sequential walk takes, and a season that fails to load leaves the other nine seasons' episodes in the result | DR-295 | Done |
|
||||||
### Integration Tests
|
### Integration Tests
|
||||||
|
|
||||||
| Test ID | Test Description | Traces To | Status |
|
| Test ID | Test Description | Traces To | Status |
|
||||||
|
|||||||
@@ -514,6 +514,24 @@ pub async fn repository_get_series_current_episode(
|
|||||||
.map_err(|e| format!("{:?}", e))
|
.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.
|
/// Erase the viewer's watch history for an item.
|
||||||
///
|
///
|
||||||
/// Clears the played flag and the resume position; on a series or season the
|
/// 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_resume_movies,
|
||||||
repository_get_series_current_episode,
|
repository_get_series_current_episode,
|
||||||
repository_get_series_episodes,
|
repository_get_series_episodes,
|
||||||
|
repository_get_series_view,
|
||||||
repository_get_similar_items,
|
repository_get_similar_items,
|
||||||
repository_get_stream_selection,
|
repository_get_stream_selection,
|
||||||
repository_get_subtitle_url,
|
repository_get_subtitle_url,
|
||||||
@@ -1014,6 +1015,7 @@ fn specta_builder() -> Builder<tauri::Wry> {
|
|||||||
repository_get_next_up_episodes,
|
repository_get_next_up_episodes,
|
||||||
repository_get_series_episodes,
|
repository_get_series_episodes,
|
||||||
repository_get_series_current_episode,
|
repository_get_series_current_episode,
|
||||||
|
repository_get_series_view,
|
||||||
repository_clear_watch_history,
|
repository_clear_watch_history,
|
||||||
repository_get_recently_played_audio,
|
repository_get_recently_played_audio,
|
||||||
repository_get_resume_movies,
|
repository_get_resume_movies,
|
||||||
|
|||||||
@@ -209,21 +209,16 @@ pub async fn fetch_series_episodes(
|
|||||||
) -> Result<Vec<MediaItem>, RepoError> {
|
) -> Result<Vec<MediaItem>, RepoError> {
|
||||||
let children = repo.get_items(series_id, list_options()).await?;
|
let children = repo.get_items(series_id, list_options()).await?;
|
||||||
|
|
||||||
let mut episodes: Vec<MediaItem> = Vec::new();
|
let seasons: Vec<MediaItem> = children
|
||||||
for season in children.items.iter().filter(|i| is_season(i)) {
|
.items
|
||||||
// One failing season must not blank the whole show.
|
.iter()
|
||||||
match repo.get_items(&season.id, list_options()).await {
|
.filter(|i| is_season(i))
|
||||||
Ok(result) => episodes.extend(result.items.into_iter().filter(is_episode)),
|
.cloned()
|
||||||
Err(e) => {
|
.collect();
|
||||||
log::warn!(
|
let mut episodes = gather_season_episodes(&seasons, |season_id| async move {
|
||||||
"[series] season {} of {} failed to load: {:?}",
|
repo.get_items(&season_id, list_options()).await
|
||||||
season.id,
|
})
|
||||||
series_id,
|
.await;
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flat series: the children *are* the episodes.
|
// Flat series: the children *are* the episodes.
|
||||||
if episodes.is_empty() {
|
if episodes.is_empty() {
|
||||||
@@ -234,6 +229,82 @@ pub async fn fetch_series_episodes(
|
|||||||
Ok(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.
|
/// Resolve the current episode, fetching everything the policy needs.
|
||||||
///
|
///
|
||||||
/// Next Up and resume are best-effort: offline they fail or come back empty, and
|
/// 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 {
|
fn watched(mut item: MediaItem) -> MediaItem {
|
||||||
item.user_data = Some(UserData {
|
item.user_data = Some(UserData {
|
||||||
is_played: Some(true),
|
is_played: Some(true),
|
||||||
|
|||||||
@@ -1590,6 +1590,16 @@ async repositoryGetSeriesEpisodes(handle: string, seriesId: string) : Promise<Me
|
|||||||
async repositoryGetSeriesCurrentEpisode(handle: string, seriesId: string) : Promise<MediaItem | null> {
|
async repositoryGetSeriesCurrentEpisode(handle: string, seriesId: string) : Promise<MediaItem | null> {
|
||||||
return await TAURI_INVOKE("repository_get_series_current_episode", { handle, seriesId });
|
return await TAURI_INVOKE("repository_get_series_current_episode", { handle, seriesId });
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
async repositoryGetSeriesView(handle: string, seriesId: string) : Promise<SeriesView> {
|
||||||
|
return await TAURI_INVOKE("repository_get_series_view", { handle, seriesId });
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Erase the viewer's watch history for an item.
|
* Erase the viewer's watch history for an item.
|
||||||
*
|
*
|
||||||
@@ -3445,6 +3455,16 @@ export type SecurityStatus = { usingKeyring: boolean; storageType: string }
|
|||||||
* Audio track preference for a series
|
* Audio track preference for a series
|
||||||
*/
|
*/
|
||||||
export type SeriesAudioPreference = { seriesId: string; audioTrackDisplayTitle: string | null; audioTrackLanguage: string | null; audioTrackIndex: number | null }
|
export type SeriesAudioPreference = { seriesId: string; audioTrackDisplayTitle: string | null; audioTrackLanguage: string | null; audioTrackIndex: number | null }
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
export type SeriesView = { episodes: MediaItem[]; current: MediaItem | null }
|
||||||
/**
|
/**
|
||||||
* The verdict on a server's version.
|
* The verdict on a server's version.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
// NO direct HTTP calls - everything routes through Rust backend
|
// NO direct HTTP calls - everything routes through Rust backend
|
||||||
|
|
||||||
import { commands } from "./bindings";
|
import { commands } from "./bindings";
|
||||||
import type { DownloadDiskUsage, JRayActor, SearchScope, StreamSelection } from "./bindings";
|
import type {
|
||||||
|
DownloadDiskUsage,
|
||||||
|
JRayActor,
|
||||||
|
SearchScope,
|
||||||
|
SeriesView,
|
||||||
|
StreamSelection,
|
||||||
|
} from "./bindings";
|
||||||
import type { QualityPreset } from "./quality-presets";
|
import type { QualityPreset } from "./quality-presets";
|
||||||
import type {
|
import type {
|
||||||
Library,
|
Library,
|
||||||
@@ -164,6 +170,17 @@ export class RepositoryClient {
|
|||||||
return commands.repositoryGetSeriesCurrentEpisode(this.ensureHandle(), seriesId);
|
return commands.repositoryGetSeriesCurrentEpisode(this.ensureHandle(), seriesId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A series' episodes and the episode the viewer is up to, from one season
|
||||||
|
* fan-out in Rust. Prefer this over calling `getSeriesEpisodes` and
|
||||||
|
* `getSeriesCurrentEpisode` together — each of those walks every season.
|
||||||
|
*
|
||||||
|
* TRACES: UR-062 | DR-101, DR-295
|
||||||
|
*/
|
||||||
|
async getSeriesView(seriesId: string): Promise<SeriesView> {
|
||||||
|
return commands.repositoryGetSeriesView(this.ensureHandle(), seriesId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Erase watch history for an item. On a series or season the server applies
|
* Erase watch history for an item. On a series or season the server applies
|
||||||
* it to everything inside, so the container returns to "never watched".
|
* it to everything inside, so the container returns to "never watched".
|
||||||
|
|||||||
@@ -211,14 +211,9 @@
|
|||||||
const repo = auth.getRepository();
|
const repo = auth.getRepository();
|
||||||
const seasons = $libraryItems.filter((i) => i.kind === "season");
|
const seasons = $libraryItems.filter((i) => i.kind === "season");
|
||||||
|
|
||||||
const [episodes, current] = await Promise.all([
|
// One call, one season fan-out: asking for episodes and the current
|
||||||
repo.getSeriesEpisodes(itemId),
|
// episode separately walked every season twice (DR-295).
|
||||||
// Best-effort: a series still renders if the anchor cannot be resolved.
|
const { episodes, current } = await repo.getSeriesView(itemId);
|
||||||
repo.getSeriesCurrentEpisode(itemId).catch((e) => {
|
|
||||||
log.warn("Could not resolve the current episode:", e);
|
|
||||||
return null;
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
seasonData = groupEpisodesBySeason(seasons, episodes);
|
seasonData = groupEpisodesBySeason(seasons, episodes);
|
||||||
currentEpisode = current;
|
currentEpisode = current;
|
||||||
|
|||||||
Reference in New Issue
Block a user