feat: show asynchronously checked episode providers
This commit is contained in:
@@ -82,12 +82,15 @@ Tubular requests the Kitty keyboard protocol's escape-code disambiguation for it
|
|||||||
|
|
||||||
Choose **AniList** or use `--anilist`. Search/detail metadata comes directly from `https://graphql.anilist.co` through the existing native TLS client: a 20-second timeout, no redirects, 2 MiB response limit, HTTP/content-type checks and GraphQL-error rejection. Searches return at most eight titles. Nullable artwork, descriptions, scores, planned totals and airing metadata remain unknown rather than being replaced with fabricated values.
|
Choose **AniList** or use `--anilist`. Search/detail metadata comes directly from `https://graphql.anilist.co` through the existing native TLS client: a 20-second timeout, no redirects, 2 MiB response limit, HTTP/content-type checks and GraphQL-error rejection. Searches return at most eight titles. Nullable artwork, descriptions, scores, planned totals and airing metadata remain unknown rather than being replaced with fabricated values.
|
||||||
|
|
||||||
**Provider availability is UNVERIFIED.** A finished show's known total supplies catalog episode numbers; a releasing show's `nextAiringEpisode.episode - 1` supplies an airing/catalog count, separate from its planned total. Unreleased shows have no aired numbers. Missing totals/airing information remain unknown, not “zero episodes.” In an unknown-count series, press `i` (configured search/edit), enter a positive canonical episode number, then Enter to select it. Enter again launches that explicit episode. The initial direct source is **sub only**, never an automatic dub substitute. Numbers do not establish that a stream exists, including for specials or split seasons.
|
**Catalog episode numbers are not source availability.** A finished show's known total supplies catalog episode numbers; a releasing show's `nextAiringEpisode.episode - 1` supplies an airing/catalog count, separate from its planned total. Unreleased shows have no aired numbers. Missing totals/airing information remain unknown, not “zero episodes.” In an unknown-count series, press `i` (configured search/edit), enter a positive canonical episode number, then Enter to select it. Enter again launches that explicit episode. The direct source is **sub only**, never an automatic dub substitute. Numbers do not establish that a stream exists, including for specials or split seasons.
|
||||||
|
|
||||||
Rows, metadata and selection prompts label availability UNVERIFIED. Terebi resolves the current selection fresh; unavailable episodes, malformed/protected pages and network failures appear as distinct player errors, while launch/catalog errors remain visible in Tubular. A successful handoff does not mark an episode watched.
|
AniList playable rows in series and offline episode collections have three aligned lines: title, existing metadata/watch markers, and a quiet source-check result. The third line starts as `Source check pending`, becomes `Checking source…`, then shows `ZokoAnime · sub`, `No source found` (only an explicit missing-episode response), or `Source check failed` (network/protection/malformed responses, manifest errors, an older CLI, or launcher failure). A positive check means a fresh source configuration and accessible valid root HLS manifest, **not** a guarantee that every segment or the full episode will play.
|
||||||
|
|
||||||
AniList episode metadata rows lead with the available series-average runtime (`25 min avg`), format and series year, followed by `sub · UNVERIFIED` and collection/watch markers. Missing or zero average runtimes are omitted. These are explicitly series-level facts, not measured episode durations or individual air dates; legacy Miruro retains its returned episode-specific dates and durations.
|
Checks run progressively through the configured `--player` executable's headless `--check-source URL` command, one at a time: selected episode first, then only rows actually visible in the current viewport. There is no whole-series scan. Moving the cursor within visible rows lets the current check finish; scrolling it offscreen, navigating away, loading catalog metadata, opening a popup/input/provider chooser, or closing Tubular cancels pending work. Returning to an unobscured episode view resumes checks after its next rendered frame. Each check has a 30-second timeout and a 4 KiB output cap; cancellation kills and reaps the isolated CLI child without a background reader thread.
|
||||||
Episode metadata aligns with the title text, reserving the same checked/watched marker width on both lines in either anime catalog and episode collections.
|
|
||||||
|
Results are session-only by stable episode identity, including failures, so errors do not retry every tick. `r` clears this cache and refreshes the view. Checks do not change saved episodes, watched state, progress, config or stored metadata, and never store resolved URLs. Playback remains available for every status and always resolves the selected episode fresh; a check is not reused as playable media. An older Terebi CLI shows `Source check failed` without disabling playback. A successful handoff does not mark an episode watched.
|
||||||
|
|
||||||
|
The second line retains series-average runtime (`25 min avg`), format and series year followed by collection/watch markers. Missing or zero average runtimes are omitted. These are series-level facts, not measured episode durations or individual air dates; legacy Miruro retains its episode-specific dates/durations and two-line rows. Every line reserves the same checked/watched marker width so its text aligns with the episode title.
|
||||||
|
|
||||||
|
|
||||||
Identities are `anilist:<positive decimal AniList ID>:<positive episode>`, with both numbers bounded by Int32. The stable handoff is `https://zokoanime.video/stream/ani/ID/EPISODE/sub` (`ani`, not the broken `anilist` route). Sources, tokens and subtitle URLs are never stored in collections or playback snapshots. Queues use the same stable URL and per-item private progress files; version-2 reports identify `provider = "anilist"`, decimal-string `media_id`, and numeric `episode`.
|
Identities are `anilist:<positive decimal AniList ID>:<positive episode>`, with both numbers bounded by Int32. The stable handoff is `https://zokoanime.video/stream/ani/ID/EPISODE/sub` (`ani`, not the broken `anilist` route). Sources, tokens and subtitle URLs are never stored in collections or playback snapshots. Queues use the same stable URL and per-item private progress files; version-2 reports identify `provider = "anilist"`, decimal-string `media_id`, and numeric `episode`.
|
||||||
@@ -104,7 +107,7 @@ cargo test --locked
|
|||||||
cargo run --locked -- --anilist "Cowboy Bebop"
|
cargo run --locked -- --anilist "Cowboy Bebop"
|
||||||
```
|
```
|
||||||
|
|
||||||
The probe searches the real catalog, selects exact AniList ID 1, fetches detail, and emits the UNVERIFIED episode-1 identity/stable URL. It does not prove playback. See `docs/anilist-direct-provider-feasibility.md` for historical native source/media evidence and outstanding AniList terms/distribution and provider-availability caveats.
|
The probe searches the real catalog, selects exact AniList ID 1, fetches detail, and emits the episode-1 identity/stable URL without checking a source. It does not prove playback. For a read-only provider smoke, run `terebi --check-source https://zokoanime.video/stream/ani/1/1/sub`; it prints one version-1 JSON line and does not launch the app. See `docs/anilist-direct-provider-feasibility.md` for historical native source/media evidence and outstanding AniList terms/distribution caveats.
|
||||||
|
|
||||||
### Miruro legacy browser
|
### Miruro legacy browser
|
||||||
|
|
||||||
|
|||||||
@@ -26,12 +26,12 @@ fn main() -> io::Result<()> {
|
|||||||
results.len()
|
results.len()
|
||||||
);
|
);
|
||||||
println!(
|
println!(
|
||||||
"detail: {count} catalog episode numbers; selected episode 1; provider availability UNVERIFIED"
|
"detail: {count} catalog episode numbers; selected episode 1; source not checked by this probe"
|
||||||
);
|
);
|
||||||
println!("identity: {}", identity.key());
|
println!("identity: {}", identity.key());
|
||||||
println!("watch URL: {}", identity.watch_url()?);
|
println!("watch URL: {}", identity.watch_url()?);
|
||||||
println!(
|
println!(
|
||||||
"PASS: native AniList search -> exact detail -> UNVERIFIED catalog episode -> stable sub URL (no playback request)"
|
"PASS: native AniList search -> exact detail -> catalog episode -> stable sub URL (no source check or playback request)"
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
File diff suppressed because one or more lines are too long
@@ -300,7 +300,7 @@ pub fn anilist_episode(media: &Media, number: u32) -> io::Result<Episode> {
|
|||||||
Ok(Episode {
|
Ok(Episode {
|
||||||
identity,
|
identity,
|
||||||
title: None,
|
title: None,
|
||||||
synopsis: Some("Provider availability UNVERIFIED. Terebi resolves this sub episode on selection; catalog numbering does not guarantee a stream.".into()),
|
synopsis: Some("Catalog numbering does not guarantee a stream. Visible episodes are checked separately for ZokoAnime sub sources; playback always resolves fresh.".into()),
|
||||||
aired_on: None,
|
aired_on: None,
|
||||||
duration_seconds: None,
|
duration_seconds: None,
|
||||||
metadata: Value::Null,
|
metadata: Value::Null,
|
||||||
|
|||||||
+96
-12
@@ -33,6 +33,9 @@ use crate::{
|
|||||||
youtube::{PlayerLaunch, Video},
|
youtube::{PlayerLaunch, Video},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[path = "source_check.rs"]
|
||||||
|
mod source_check;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Default)]
|
#[derive(Clone, Copy, Default)]
|
||||||
enum WatchFilter {
|
enum WatchFilter {
|
||||||
#[default]
|
#[default]
|
||||||
@@ -245,7 +248,7 @@ impl Screen {
|
|||||||
self.overview = media_details(media);
|
self.overview = media_details(media);
|
||||||
if let Some(data) = data {
|
if let Some(data) = data {
|
||||||
if self.provider == Provider::AniList {
|
if self.provider == Provider::AniList {
|
||||||
self.overview.push_str("\n\nProvider availability UNVERIFIED. Sub only; resolved on selection, not a stream inventory.");
|
self.overview.push_str("\n\nCatalog numbers are not a stream inventory. Visible episodes are checked for a fresh ZokoAnime sub source; playback resolves again.");
|
||||||
if media
|
if media
|
||||||
.metadata
|
.metadata
|
||||||
.get("catalog_count")
|
.get("catalog_count")
|
||||||
@@ -299,7 +302,6 @@ impl Screen {
|
|||||||
{
|
{
|
||||||
details.push(format!("{year} series"));
|
details.push(format!("{year} series"));
|
||||||
}
|
}
|
||||||
details.push("sub · UNVERIFIED".into());
|
|
||||||
}
|
}
|
||||||
let details = details.join(" · ");
|
let details = details.join(" · ");
|
||||||
let mut metadata = title.clone();
|
let mut metadata = title.clone();
|
||||||
@@ -349,7 +351,7 @@ impl Screen {
|
|||||||
self.rows = videos.iter().map(|video| {
|
self.rows = videos.iter().map(|video| {
|
||||||
let identity = Identity::from_key(&video.id).expect("provider-scoped collection");
|
let identity = Identity::from_key(&video.id).expect("provider-scoped collection");
|
||||||
let title = safe(&video.title);
|
let title = safe(&video.title);
|
||||||
let metadata = format!("{title}\n\nEpisode {} · {} ID {}\n\nStored offline episode metadata. Provider availability UNVERIFIED. Enter sends the stable episode URL to Terebi for fresh resolution.", identity.episode, self.provider.name(), identity.media_id);
|
let metadata = format!("{title}\n\nEpisode {} · {} ID {}\n\nStored offline episode metadata. Catalog numbers do not establish source availability. Enter sends the stable episode URL to Terebi for fresh resolution.", identity.episode, self.provider.name(), identity.media_id);
|
||||||
DisplayRow { key: Some(video.id.clone()), title, details: video.publication_date.as_deref().map(safe).unwrap_or_default(), metadata }
|
DisplayRow { key: Some(video.id.clone()), title, details: video.publication_date.as_deref().map(safe).unwrap_or_default(), metadata }
|
||||||
}).collect();
|
}).collect();
|
||||||
}
|
}
|
||||||
@@ -408,6 +410,8 @@ pub(crate) struct Browser {
|
|||||||
pending: Option<Request>,
|
pending: Option<Request>,
|
||||||
intent: Option<Intent>,
|
intent: Option<Intent>,
|
||||||
player: Option<PlayerLaunch>,
|
player: Option<PlayerLaunch>,
|
||||||
|
source_checks: source_check::Checks,
|
||||||
|
source_visible: Vec<String>,
|
||||||
}
|
}
|
||||||
enum Intent {
|
enum Intent {
|
||||||
Catalog,
|
Catalog,
|
||||||
@@ -514,6 +518,8 @@ impl Browser {
|
|||||||
pending: None,
|
pending: None,
|
||||||
intent: None,
|
intent: None,
|
||||||
player: None,
|
player: None,
|
||||||
|
source_checks: source_check::Checks::default(),
|
||||||
|
source_visible: Vec::new(),
|
||||||
};
|
};
|
||||||
browser.screens[0].provider = provider;
|
browser.screens[0].provider = provider;
|
||||||
browser.screens[0].rebuild_display();
|
browser.screens[0].rebuild_display();
|
||||||
@@ -529,7 +535,53 @@ impl Browser {
|
|||||||
fn screen_mut(&mut self) -> &mut Screen {
|
fn screen_mut(&mut self) -> &mut Screen {
|
||||||
&mut self.screens[self.index]
|
&mut self.screens[self.index]
|
||||||
}
|
}
|
||||||
|
fn sources_exposed(&self) -> bool {
|
||||||
|
self.provider == Provider::AniList
|
||||||
|
&& self.screen().playable()
|
||||||
|
&& self.intent.is_none()
|
||||||
|
&& self.pending.is_none()
|
||||||
|
&& self.input.is_none()
|
||||||
|
&& self.preview.is_none()
|
||||||
|
&& self.help.is_none()
|
||||||
|
&& self.removal.is_none()
|
||||||
|
&& self.modal.is_none()
|
||||||
|
}
|
||||||
|
fn update_visible_sources(&mut self, height: u16) {
|
||||||
|
if !self.sources_exposed() {
|
||||||
|
self.source_visible.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// ListState's offset is updated by rendering, not by cursor movement.
|
||||||
|
// Only complete three-line rows fit; a spare terminal line is not another episode.
|
||||||
|
let screen = &self.screens[self.index];
|
||||||
|
let keys = screen
|
||||||
|
.rows
|
||||||
|
.iter()
|
||||||
|
.skip(screen.selection.offset())
|
||||||
|
.take(usize::from(height) / 3)
|
||||||
|
.filter_map(|row| row.key.as_ref())
|
||||||
|
.filter(|key| key.starts_with("anilist:"));
|
||||||
|
if self.source_visible.iter().eq(keys.clone()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.source_visible.clear();
|
||||||
|
self.source_visible.extend(keys.cloned());
|
||||||
|
}
|
||||||
|
fn update_source_checks(&mut self, now: Instant) {
|
||||||
|
if !self.sources_exposed() {
|
||||||
|
self.source_visible.clear();
|
||||||
|
}
|
||||||
|
self.source_checks.update(
|
||||||
|
self.generation,
|
||||||
|
&self.source_visible,
|
||||||
|
self.screens[self.index].selected_key(),
|
||||||
|
&self.options.player,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
}
|
||||||
fn invalidate(&mut self) {
|
fn invalidate(&mut self) {
|
||||||
|
self.source_checks.cancel();
|
||||||
|
self.source_visible.clear();
|
||||||
self.generation = self.generation.wrapping_add(1);
|
self.generation = self.generation.wrapping_add(1);
|
||||||
self.pending = None;
|
self.pending = None;
|
||||||
self.intent = None;
|
self.intent = None;
|
||||||
@@ -581,7 +633,7 @@ impl Browser {
|
|||||||
self.input = Some(String::new());
|
self.input = Some(String::new());
|
||||||
self.episode_input = true;
|
self.episode_input = true;
|
||||||
self.status =
|
self.status =
|
||||||
"Enter an explicit episode number; availability UNVERIFIED (sub only).".into();
|
"Enter an explicit episode number; source checked separately (sub only).".into();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !matches!(self.screen().content, Content::Catalog { .. }) {
|
if !matches!(self.screen().content, Content::Catalog { .. }) {
|
||||||
@@ -622,7 +674,7 @@ impl Browser {
|
|||||||
match result {
|
match result {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
self.screen_mut().error = None;
|
self.screen_mut().error = None;
|
||||||
self.status = "Explicit episode selected; provider availability UNVERIFIED. Enter resolves sub on demand.".into();
|
self.status = "Explicit episode selected; source checked separately. Enter resolves sub fresh.".into();
|
||||||
}
|
}
|
||||||
Err(error) => self.fail(error),
|
Err(error) => self.fail(error),
|
||||||
}
|
}
|
||||||
@@ -1151,6 +1203,7 @@ impl Browser {
|
|||||||
}
|
}
|
||||||
fn refresh(&mut self) {
|
fn refresh(&mut self) {
|
||||||
self.invalidate();
|
self.invalidate();
|
||||||
|
self.source_checks.clear();
|
||||||
self.screen_mut().checked.clear();
|
self.screen_mut().checked.clear();
|
||||||
match config::load(&self.config_path) {
|
match config::load(&self.config_path) {
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
@@ -1447,7 +1500,7 @@ impl Browser {
|
|||||||
(
|
(
|
||||||
Action::InputSubmit,
|
Action::InputSubmit,
|
||||||
if self.episode_input {
|
if self.episode_input {
|
||||||
"Select explicit episode (UNVERIFIED)"
|
"Select explicit episode (sub)"
|
||||||
} else {
|
} else {
|
||||||
"Search catalog"
|
"Search catalog"
|
||||||
},
|
},
|
||||||
@@ -1815,7 +1868,7 @@ impl Browser {
|
|||||||
} else {
|
} else {
|
||||||
" · "
|
" · "
|
||||||
};
|
};
|
||||||
ListItem::new(vec![
|
let mut lines = vec![
|
||||||
Line::from(vec![
|
Line::from(vec![
|
||||||
ratatui::text::Span::raw(if self.screen().playable() {
|
ratatui::text::Span::raw(if self.screen().playable() {
|
||||||
if row
|
if row
|
||||||
@@ -1846,13 +1899,29 @@ impl Browser {
|
|||||||
ratatui::text::Span::raw(markers),
|
ratatui::text::Span::raw(markers),
|
||||||
])
|
])
|
||||||
.dim(),
|
.dim(),
|
||||||
|
];
|
||||||
|
if self.provider == Provider::AniList && self.screen().playable() {
|
||||||
|
let label = row
|
||||||
|
.key
|
||||||
|
.as_deref()
|
||||||
|
.map(|key| self.source_checks.label(key))
|
||||||
|
.unwrap_or("Source check pending");
|
||||||
|
lines.push(
|
||||||
|
Line::from(vec![
|
||||||
|
ratatui::text::Span::raw(" "),
|
||||||
|
ratatui::text::Span::raw(label),
|
||||||
])
|
])
|
||||||
|
.dim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ListItem::new(lines)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
fn draw(&mut self, frame: &mut Frame, elapsed: Duration) {
|
fn draw(&mut self, frame: &mut Frame, elapsed: Duration) {
|
||||||
let area = frame.area();
|
let area = frame.area();
|
||||||
if area.width < 24 || area.height < 10 {
|
if area.width < 24 || area.height < 10 {
|
||||||
|
self.source_visible.clear();
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(format!(
|
Paragraph::new(format!(
|
||||||
"{}\nTerminal too small; enlarge to browse.\n{} exits",
|
"{}\nTerminal too small; enlarge to browse.\n{} exits",
|
||||||
@@ -1889,7 +1958,7 @@ impl Browser {
|
|||||||
frame,
|
frame,
|
||||||
query_area,
|
query_area,
|
||||||
if self.episode_input {
|
if self.episode_input {
|
||||||
" Explicit episode · UNVERIFIED · sub "
|
" Explicit episode · sub "
|
||||||
} else if self.provider == Provider::AniList {
|
} else if self.provider == Provider::AniList {
|
||||||
" tubular / AniList search "
|
" tubular / AniList search "
|
||||||
} else {
|
} else {
|
||||||
@@ -1908,7 +1977,9 @@ impl Browser {
|
|||||||
"",
|
"",
|
||||||
self.intent.is_some().then(|| ui::spinner(elapsed)),
|
self.intent.is_some().then(|| ui::spinner(elapsed)),
|
||||||
));
|
));
|
||||||
|
let viewport_height = block.inner(content).height;
|
||||||
if self.screen().rows.is_empty() {
|
if self.screen().rows.is_empty() {
|
||||||
|
self.source_visible.clear();
|
||||||
let empty = if let Some(error) = &self.screen().error {
|
let empty = if let Some(error) = &self.screen().error {
|
||||||
format!(
|
format!(
|
||||||
"{error}\n\n{} refreshes; {} searches the catalog.",
|
"{error}\n\n{} refreshes; {} searches the catalog.",
|
||||||
@@ -1919,9 +1990,9 @@ impl Browser {
|
|||||||
format!("Loading {} metadata…", self.provider.name())
|
format!("Loading {} metadata…", self.provider.name())
|
||||||
} else {
|
} else {
|
||||||
match &self.screen().content {
|
match &self.screen().content {
|
||||||
Content::Catalog { searched: false, .. } => format!("Search anime titles with {} (or {}). Submit with {}. Enter a result for episode metadata; AniList provider availability is UNVERIFIED.", self.config.keys.label(Action::SearchEdit), self.config.keys.label(Action::ViewSearch), self.config.keys.label(Action::InputSubmit)),
|
Content::Catalog { searched: false, .. } => format!("Search anime titles with {} (or {}). Submit with {}. Enter a result for catalog episodes; visible AniList episodes are checked separately for sources.", self.config.keys.label(Action::SearchEdit), self.config.keys.label(Action::ViewSearch), self.config.keys.label(Action::InputSubmit)),
|
||||||
Content::Catalog { .. } => "No catalog titles matched. Edit the query to search again.".into(),
|
Content::Catalog { .. } => "No catalog titles matched. Edit the query to search again.".into(),
|
||||||
Content::Series { media, .. } if self.provider == Provider::AniList && media.metadata.get("catalog_count").is_none_or(Value::is_null) => format!("Catalog/airing episode count unknown, not zero. {} selects an explicit positive episode number. Provider availability UNVERIFIED; sub resolved on selection.", self.config.keys.label(Action::SearchEdit)),
|
Content::Series { media, .. } if self.provider == Provider::AniList && media.metadata.get("catalog_count").is_none_or(Value::is_null) => format!("Catalog/airing episode count unknown, not zero. {} selects an explicit positive episode number. Source availability is checked separately; sub resolves fresh on play.", self.config.keys.label(Action::SearchEdit)),
|
||||||
Content::Series { .. } if self.provider == Provider::AniList => "No catalog/airing episode numbers known yet. Planned totals do not establish aired episodes or streams.".into(),
|
Content::Series { .. } if self.provider == Provider::AniList => "No catalog/airing episode numbers known yet. Planned totals do not establish aired episodes or streams.".into(),
|
||||||
Content::Series { .. } => "No episodes were returned for this series. Planned counts do not create episodes.".into(),
|
Content::Series { .. } => "No episodes were returned for this series. Planned counts do not create episodes.".into(),
|
||||||
Content::Collection { .. } => format!("No {} episodes in this collection/filter. Other catalogs and YouTube are separate.", self.provider.name()),
|
Content::Collection { .. } => format!("No {} episodes in this collection/filter. Other catalogs and YouTube are separate.", self.provider.name()),
|
||||||
@@ -1943,6 +2014,7 @@ impl Browser {
|
|||||||
&mut selection,
|
&mut selection,
|
||||||
);
|
);
|
||||||
self.screen_mut().selection = selection;
|
self.screen_mut().selection = selection;
|
||||||
|
self.update_visible_sources(viewport_height);
|
||||||
}
|
}
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(self.status.as_str())
|
Paragraph::new(self.status.as_str())
|
||||||
@@ -2054,7 +2126,7 @@ impl Browser {
|
|||||||
} else if let Some(scroll) = self.help {
|
} else if let Some(scroll) = self.help {
|
||||||
let mut lines = vec![
|
let mut lines = vec![
|
||||||
Line::from(format!(
|
Line::from(format!(
|
||||||
"{} catalog → series → episodes · availability UNVERIFIED",
|
"{} catalog → series → episodes · sources checked separately",
|
||||||
self.provider.name()
|
self.provider.name()
|
||||||
))
|
))
|
||||||
.bold(),
|
.bold(),
|
||||||
@@ -2276,7 +2348,7 @@ fn media_summary(media: &Media) -> String {
|
|||||||
{
|
{
|
||||||
parts.push("total unknown".into());
|
parts.push("total unknown".into());
|
||||||
}
|
}
|
||||||
parts.push("provider availability UNVERIFIED · sub".into());
|
parts.push("catalog numbers · sub sources checked separately".into());
|
||||||
}
|
}
|
||||||
for (field, label) in [
|
for (field, label) in [
|
||||||
("raw", "raw listed"),
|
("raw", "raw listed"),
|
||||||
@@ -2351,6 +2423,15 @@ impl Browser {
|
|||||||
pub(crate) fn run(
|
pub(crate) fn run(
|
||||||
&mut self,
|
&mut self,
|
||||||
terminal: &mut DefaultTerminal,
|
terminal: &mut DefaultTerminal,
|
||||||
|
) -> io::Result<Option<crate::BrowseProvider>> {
|
||||||
|
let result = self.run_inner(terminal);
|
||||||
|
self.source_checks.cancel();
|
||||||
|
self.source_visible.clear();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
fn run_inner(
|
||||||
|
&mut self,
|
||||||
|
terminal: &mut DefaultTerminal,
|
||||||
) -> io::Result<Option<crate::BrowseProvider>> {
|
) -> io::Result<Option<crate::BrowseProvider>> {
|
||||||
self.state.select_catalog(self.provider);
|
self.state.select_catalog(self.provider);
|
||||||
struct RestoreTerminalInput;
|
struct RestoreTerminalInput;
|
||||||
@@ -2374,12 +2455,15 @@ impl Browser {
|
|||||||
loop {
|
loop {
|
||||||
self.update();
|
self.update();
|
||||||
terminal.draw(|frame| self.draw(frame, started.elapsed()))?;
|
terminal.draw(|frame| self.draw(frame, started.elapsed()))?;
|
||||||
|
self.update_source_checks(Instant::now());
|
||||||
if event::poll(Duration::from_millis(100))? {
|
if event::poll(Duration::from_millis(100))? {
|
||||||
match event::read()? {
|
match event::read()? {
|
||||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||||
if self.config.keys.resolve(self.context(), key)
|
if self.config.keys.resolve(self.context(), key)
|
||||||
== Some(Action::ProviderChoose)
|
== Some(Action::ProviderChoose)
|
||||||
{
|
{
|
||||||
|
self.source_checks.cancel();
|
||||||
|
self.source_visible.clear();
|
||||||
return crate::choose_provider(
|
return crate::choose_provider(
|
||||||
terminal,
|
terminal,
|
||||||
&self.config.keys,
|
&self.config.keys,
|
||||||
|
|||||||
@@ -0,0 +1,375 @@
|
|||||||
|
//! Session-only availability checks. Never retain resolved media or provider diagnostics.
|
||||||
|
use std::{
|
||||||
|
collections::BTreeMap,
|
||||||
|
ffi::OsStr,
|
||||||
|
io::{self, Read},
|
||||||
|
process::{Child, ChildStdout, Stdio},
|
||||||
|
time::{Duration, Instant},
|
||||||
|
};
|
||||||
|
|
||||||
|
use serde::Deserialize;
|
||||||
|
use tubular::anime::{Identity, Provider};
|
||||||
|
|
||||||
|
const TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
|
const OUTPUT_LIMIT: usize = 4096;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub(super) enum Status {
|
||||||
|
Available,
|
||||||
|
Unavailable,
|
||||||
|
Failed,
|
||||||
|
}
|
||||||
|
impl Status {
|
||||||
|
pub(super) fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Available => "ZokoAnime · sub",
|
||||||
|
Self::Unavailable => "No source found",
|
||||||
|
Self::Failed => "Source check failed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
struct Reply {
|
||||||
|
version: u32,
|
||||||
|
provider: String,
|
||||||
|
track: String,
|
||||||
|
status: Status,
|
||||||
|
}
|
||||||
|
fn decode(bytes: &[u8], success: bool) -> Status {
|
||||||
|
if !success || bytes.len() > OUTPUT_LIMIT {
|
||||||
|
return Status::Failed;
|
||||||
|
}
|
||||||
|
let Ok(text) = std::str::from_utf8(bytes) else {
|
||||||
|
return Status::Failed;
|
||||||
|
};
|
||||||
|
let text = text.strip_suffix('\n').unwrap_or(text);
|
||||||
|
if text.contains(['\n', '\r']) {
|
||||||
|
return Status::Failed;
|
||||||
|
}
|
||||||
|
match serde_json::from_str::<Reply>(text) {
|
||||||
|
Ok(reply)
|
||||||
|
if reply.version == 1 && reply.provider == "zokoanime" && reply.track == "sub" =>
|
||||||
|
{
|
||||||
|
reply.status
|
||||||
|
}
|
||||||
|
_ => Status::Failed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Job {
|
||||||
|
child: Child,
|
||||||
|
stdout: ChildStdout,
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
deadline: Instant,
|
||||||
|
eof: bool,
|
||||||
|
}
|
||||||
|
impl Job {
|
||||||
|
fn start(player: &OsStr, key: &str, now: Instant) -> io::Result<Self> {
|
||||||
|
let identity = Identity::from_key(key)?;
|
||||||
|
if identity.provider != Provider::AniList.key() {
|
||||||
|
return Err(io::Error::other("Unsupported source check"));
|
||||||
|
}
|
||||||
|
let mut command = crate::youtube::isolated_command(player);
|
||||||
|
command.arg("--check-source").arg(identity.watch_url()?);
|
||||||
|
Self::spawn(&mut command, now)
|
||||||
|
}
|
||||||
|
fn spawn(command: &mut std::process::Command, now: Instant) -> io::Result<Self> {
|
||||||
|
let mut child = command
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::piped())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()?;
|
||||||
|
let stdout = child.stdout.take().expect("piped stdout");
|
||||||
|
let mut job = Self {
|
||||||
|
child,
|
||||||
|
stdout,
|
||||||
|
bytes: Vec::new(),
|
||||||
|
deadline: now + TIMEOUT,
|
||||||
|
eof: false,
|
||||||
|
};
|
||||||
|
job.nonblocking()?;
|
||||||
|
Ok(job)
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn nonblocking(&mut self) -> io::Result<()> {
|
||||||
|
use std::os::fd::AsRawFd;
|
||||||
|
let fd = self.stdout.as_raw_fd();
|
||||||
|
// SAFETY: stdout owns this live descriptor; preserve the existing flags.
|
||||||
|
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
|
||||||
|
if flags < 0 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
|
||||||
|
return Err(io::Error::last_os_error());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
fn nonblocking(&mut self) -> io::Result<()> {
|
||||||
|
Err(io::Error::other(
|
||||||
|
"Source checks require nonblocking process pipes",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
fn poll(&mut self, now: Instant) -> Option<Status> {
|
||||||
|
if now >= self.deadline {
|
||||||
|
return Some(Status::Failed);
|
||||||
|
}
|
||||||
|
// Each poll consumes at most the protocol limit plus one byte, even from a noisy CLI.
|
||||||
|
let mut buffer = [0; OUTPUT_LIMIT + 1];
|
||||||
|
while !self.eof {
|
||||||
|
let remaining = OUTPUT_LIMIT + 1 - self.bytes.len();
|
||||||
|
match self.stdout.read(&mut buffer[..remaining]) {
|
||||||
|
Ok(0) => self.eof = true,
|
||||||
|
Ok(count) => {
|
||||||
|
self.bytes.extend_from_slice(&buffer[..count]);
|
||||||
|
if self.bytes.len() > OUTPUT_LIMIT {
|
||||||
|
return Some(Status::Failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
|
||||||
|
Err(error) if error.kind() == io::ErrorKind::Interrupted => break,
|
||||||
|
Err(_) => return Some(Status::Failed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match self.child.try_wait() {
|
||||||
|
Ok(Some(status)) if self.eof => Some(decode(&self.bytes, status.success())),
|
||||||
|
Ok(_) => None,
|
||||||
|
Err(_) => Some(Status::Failed),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl Drop for Job {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
// The isolated group may still have descendants holding stdout after the leader exits.
|
||||||
|
#[cfg(unix)]
|
||||||
|
unsafe {
|
||||||
|
libc::kill(-(self.child.id() as i32), libc::SIGKILL);
|
||||||
|
}
|
||||||
|
let _ = self.child.kill();
|
||||||
|
let _ = self.child.wait();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Active {
|
||||||
|
generation: u64,
|
||||||
|
key: String,
|
||||||
|
job: Job,
|
||||||
|
}
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(super) struct Checks {
|
||||||
|
cache: BTreeMap<String, Status>,
|
||||||
|
active: Option<Active>,
|
||||||
|
}
|
||||||
|
impl Checks {
|
||||||
|
pub(super) fn cancel(&mut self) {
|
||||||
|
self.active = None;
|
||||||
|
}
|
||||||
|
pub(super) fn clear(&mut self) {
|
||||||
|
self.cancel();
|
||||||
|
self.cache.clear();
|
||||||
|
}
|
||||||
|
pub(super) fn status(&self, key: &str) -> Option<Status> {
|
||||||
|
self.cache.get(key).copied()
|
||||||
|
}
|
||||||
|
pub(super) fn label(&self, key: &str) -> &'static str {
|
||||||
|
if let Some(status) = self.status(key) {
|
||||||
|
status.label()
|
||||||
|
} else if self.active.as_ref().is_some_and(|active| active.key == key) {
|
||||||
|
"Checking source…"
|
||||||
|
} else {
|
||||||
|
"Source check pending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn next<'a>(&self, visible: &'a [String], selected: Option<&'a str>) -> Option<&'a str> {
|
||||||
|
selected
|
||||||
|
.filter(|key| {
|
||||||
|
visible.iter().any(|item| item.as_str() == *key) && !self.cache.contains_key(*key)
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
visible
|
||||||
|
.iter()
|
||||||
|
.find(|key| !self.cache.contains_key(*key))
|
||||||
|
.map(String::as_str)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
fn finish(
|
||||||
|
&mut self,
|
||||||
|
generation: u64,
|
||||||
|
result_generation: u64,
|
||||||
|
key: String,
|
||||||
|
status: Status,
|
||||||
|
visible: &[String],
|
||||||
|
) {
|
||||||
|
if generation == result_generation && visible.contains(&key) {
|
||||||
|
self.cache.insert(key, status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub(super) fn update(
|
||||||
|
&mut self,
|
||||||
|
generation: u64,
|
||||||
|
visible: &[String],
|
||||||
|
selected: Option<&str>,
|
||||||
|
player: &OsStr,
|
||||||
|
now: Instant,
|
||||||
|
) {
|
||||||
|
if self
|
||||||
|
.active
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|active| active.generation != generation || !visible.contains(&active.key))
|
||||||
|
{
|
||||||
|
self.cancel();
|
||||||
|
}
|
||||||
|
// Cursor moves do not starve a check that is still visible. New selection gets the next turn.
|
||||||
|
if let Some(status) = self.active.as_mut().and_then(|active| active.job.poll(now)) {
|
||||||
|
let active = self.active.take().expect("completed check");
|
||||||
|
self.finish(generation, active.generation, active.key, status, visible);
|
||||||
|
}
|
||||||
|
if self.active.is_none()
|
||||||
|
&& let Some(key) = self.next(visible, selected)
|
||||||
|
{
|
||||||
|
let key = key.to_owned();
|
||||||
|
match Job::start(player, &key, now) {
|
||||||
|
Ok(job) => {
|
||||||
|
self.active = Some(Active {
|
||||||
|
generation,
|
||||||
|
key,
|
||||||
|
job,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
self.cache.insert(key, Status::Failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
fn clock() -> Instant {
|
||||||
|
// All transitions derive from this fixed injected instant; no sleeps or wall-clock assertions.
|
||||||
|
*std::sync::LazyLock::force(&CLOCK)
|
||||||
|
}
|
||||||
|
static CLOCK: std::sync::LazyLock<Instant> = std::sync::LazyLock::new(Instant::now);
|
||||||
|
#[test]
|
||||||
|
fn protocol_distinguishes_missing_from_failure_and_rejects_other_output() {
|
||||||
|
for (status, expected) in [
|
||||||
|
("available", Status::Available),
|
||||||
|
("unavailable", Status::Unavailable),
|
||||||
|
("failed", Status::Failed),
|
||||||
|
] {
|
||||||
|
let text = format!(
|
||||||
|
r#"{{"version":1,"provider":"zokoanime","track":"sub","status":"{status}"}}"#
|
||||||
|
);
|
||||||
|
assert_eq!(decode(text.as_bytes(), true), expected);
|
||||||
|
assert_eq!(decode(text.as_bytes(), false), Status::Failed);
|
||||||
|
}
|
||||||
|
for text in [
|
||||||
|
"",
|
||||||
|
"old CLI help",
|
||||||
|
r#"{"version":2,"provider":"zokoanime","track":"sub","status":"available"}"#,
|
||||||
|
r#"{"version":1,"provider":"other","track":"sub","status":"available"}"#,
|
||||||
|
r#"{"version":1,"provider":"zokoanime","track":"dub","status":"available"}"#,
|
||||||
|
] {
|
||||||
|
assert_eq!(decode(text.as_bytes(), true), Status::Failed);
|
||||||
|
}
|
||||||
|
assert_eq!(decode(&vec![b' '; OUTPUT_LIMIT + 1], true), Status::Failed);
|
||||||
|
assert_eq!(decode(b"{}\n{}", true), Status::Failed);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn selected_then_visible_and_no_retry_until_refresh() {
|
||||||
|
let mut checks = Checks::default();
|
||||||
|
let visible = vec!["anilist:1:3".into(), "anilist:1:4".into()];
|
||||||
|
assert_eq!(
|
||||||
|
checks.next(&visible, Some("anilist:1:4")),
|
||||||
|
Some("anilist:1:4")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
checks.next(&visible, Some("anilist:1:99")),
|
||||||
|
Some("anilist:1:3")
|
||||||
|
);
|
||||||
|
for key in &visible {
|
||||||
|
checks.finish(1, 1, key.clone(), Status::Failed, &visible);
|
||||||
|
}
|
||||||
|
assert_eq!(checks.next(&visible, Some("anilist:1:4")), None);
|
||||||
|
checks.clear();
|
||||||
|
assert_eq!(
|
||||||
|
checks.next(&visible, Some("anilist:1:4")),
|
||||||
|
Some("anilist:1:4")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[test]
|
||||||
|
fn late_generation_or_offscreen_results_do_not_enter_cache() {
|
||||||
|
let mut checks = Checks::default();
|
||||||
|
let visible: Vec<String> = vec!["anilist:1:3".into()];
|
||||||
|
checks.finish(2, 1, visible[0].clone(), Status::Available, &visible);
|
||||||
|
checks.finish(2, 2, "anilist:1:2".into(), Status::Available, &visible);
|
||||||
|
assert!(checks.cache.is_empty());
|
||||||
|
checks.finish(2, 2, visible[0].clone(), Status::Unavailable, &visible);
|
||||||
|
assert_eq!(checks.cache.get(&visible[0]), Some(&Status::Unavailable));
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn waiting_job() -> Job {
|
||||||
|
let mut command = crate::youtube::isolated_command(OsStr::new("/bin/sh"));
|
||||||
|
command.args(["-c", "kill -STOP $$"]);
|
||||||
|
Job::spawn(&mut command, clock()).unwrap()
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn injected_timeout_and_drop_reap_the_child() {
|
||||||
|
let mut job = waiting_job();
|
||||||
|
let pid = job.child.id();
|
||||||
|
assert_eq!(job.poll(clock() + TIMEOUT), Some(Status::Failed));
|
||||||
|
drop(job);
|
||||||
|
let mut status = 0;
|
||||||
|
assert_eq!(
|
||||||
|
unsafe { libc::waitpid(pid as i32, &mut status, libc::WNOHANG) },
|
||||||
|
-1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
io::Error::last_os_error().raw_os_error(),
|
||||||
|
Some(libc::ECHILD)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn cancel_offscreen_or_generation_but_not_visible_deselection() {
|
||||||
|
let visible: Vec<String> = vec!["anilist:1:3".into(), "anilist:1:4".into()];
|
||||||
|
let mut checks = Checks {
|
||||||
|
active: Some(Active {
|
||||||
|
generation: 7,
|
||||||
|
key: visible[0].clone(),
|
||||||
|
job: waiting_job(),
|
||||||
|
}),
|
||||||
|
..Checks::default()
|
||||||
|
};
|
||||||
|
checks.update(
|
||||||
|
7,
|
||||||
|
&visible,
|
||||||
|
Some(&visible[1]),
|
||||||
|
OsStr::new("/missing-player"),
|
||||||
|
clock(),
|
||||||
|
);
|
||||||
|
assert_eq!(checks.active.as_ref().unwrap().key, visible[0]);
|
||||||
|
checks.update(7, &[], None, OsStr::new("/missing-player"), clock());
|
||||||
|
assert!(checks.active.is_none());
|
||||||
|
assert!(checks.cache.is_empty());
|
||||||
|
checks.active = Some(Active {
|
||||||
|
generation: 7,
|
||||||
|
key: visible[0].clone(),
|
||||||
|
job: waiting_job(),
|
||||||
|
});
|
||||||
|
checks.update(8, &[], None, OsStr::new("/missing-player"), clock());
|
||||||
|
assert!(checks.active.is_none());
|
||||||
|
}
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn old_cli_exit_without_protocol_is_failed_without_blocking_playback_contract() {
|
||||||
|
let mut job = Job::start(OsStr::new("/usr/bin/true"), "anilist:1:1", clock()).unwrap();
|
||||||
|
job.child.wait().unwrap();
|
||||||
|
assert_eq!(job.poll(clock()), Some(Status::Failed));
|
||||||
|
assert!(Job::start(OsStr::new("/usr/bin/true"), "miruro:1:1", clock()).is_err());
|
||||||
|
assert!(Job::start(OsStr::new("/missing-player"), "anilist:1:1", clock()).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -812,7 +812,7 @@ fn read_thumbnail(pipe: impl Read) -> io::Result<Vec<u8>> {
|
|||||||
Ok(bytes)
|
Ok(bytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn isolated_command(program: &OsStr) -> Command {
|
pub(crate) fn isolated_command(program: &OsStr) -> Command {
|
||||||
let mut command = Command::new(program);
|
let mut command = Command::new(program);
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -824,3 +824,204 @@ fn anilist_unknown_count_allows_explicit_selection_and_provider_local_collection
|
|||||||
assert!(matches!(browser.screen().content, Content::Catalog { .. }));
|
assert!(matches!(browser.screen().content, Content::Catalog { .. }));
|
||||||
assert!(!browser.episode_input);
|
assert!(!browser.episode_input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn anilist_browser(directory: &Directory, count: u32) -> Browser {
|
||||||
|
let mut browser = directory.browser(None);
|
||||||
|
browser.provider = Provider::AniList;
|
||||||
|
browser.options.anilist = true;
|
||||||
|
browser.options.miruro = false;
|
||||||
|
browser.state.select_catalog(Provider::AniList);
|
||||||
|
let series = miruro_browser::anilist_series(serde_json::json!({
|
||||||
|
"id":"1", "catalog_provider":"anilist", "title":{"romaji":"Dragon Ball"},
|
||||||
|
"status":"FINISHED", "episode_count":count, "catalog_count":count,
|
||||||
|
"episode_duration_minutes":25, "format":"TV", "season_year":1986
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
browser.push(Screen::new(Content::Series {
|
||||||
|
media: series.media,
|
||||||
|
data: Some(series.episodes),
|
||||||
|
}));
|
||||||
|
browser
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_candidates_use_rendered_offset_and_three_line_viewport_not_series_size() {
|
||||||
|
use ratatui::{Terminal, backend::TestBackend};
|
||||||
|
let directory = Directory::new();
|
||||||
|
let mut browser = anilist_browser(&directory, 153);
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||||
|
.unwrap();
|
||||||
|
let initial = browser.source_visible.clone();
|
||||||
|
assert!(initial.len() < 153);
|
||||||
|
assert_eq!(initial.first().unwrap(), "anilist:1:1");
|
||||||
|
let [_, _, content, _, _] = ui::browser_layout(terminal.backend().buffer().area, 1);
|
||||||
|
assert_eq!(
|
||||||
|
initial.len(),
|
||||||
|
usize::from(content.height.saturating_sub(2)) / 3
|
||||||
|
);
|
||||||
|
browser.select(Action::VideoLast);
|
||||||
|
terminal
|
||||||
|
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||||
|
.unwrap();
|
||||||
|
let offset = browser.screen().selection.offset();
|
||||||
|
assert!(offset > 0);
|
||||||
|
assert_eq!(
|
||||||
|
browser.source_visible.first().unwrap(),
|
||||||
|
&format!("anilist:1:{}", offset + 1)
|
||||||
|
);
|
||||||
|
assert!(browser.source_visible.contains(&"anilist:1:153".into()));
|
||||||
|
assert!(
|
||||||
|
!browser
|
||||||
|
.source_visible
|
||||||
|
.iter()
|
||||||
|
.any(|key| initial.contains(key))
|
||||||
|
);
|
||||||
|
let selection = browser.screen().selected_key().unwrap().to_owned();
|
||||||
|
let now = Instant::now(); // Inject one fixed clock for every scheduler transition in this test.
|
||||||
|
browser.update_source_checks(now);
|
||||||
|
assert_eq!(
|
||||||
|
browser.source_checks.status(&selection),
|
||||||
|
Some(source_check::Status::Failed)
|
||||||
|
);
|
||||||
|
assert_eq!(browser.screen().selected_key(), Some(selection.as_str()));
|
||||||
|
browser.update_source_checks(now);
|
||||||
|
assert_eq!(browser.screen().selected_key(), Some(selection.as_str()));
|
||||||
|
assert_eq!(browser.screen().len(), 153);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_eligibility_pauses_under_popups_loading_and_catalogs_then_resumes_from_frame() {
|
||||||
|
let directory = Directory::new();
|
||||||
|
let mut browser = anilist_browser(&directory, 10);
|
||||||
|
let candidates = |browser: &mut Browser| {
|
||||||
|
browser.update_visible_sources(8);
|
||||||
|
browser.source_visible.clone()
|
||||||
|
};
|
||||||
|
assert_eq!(candidates(&mut browser), ["anilist:1:1", "anilist:1:2"]);
|
||||||
|
browser.help = Some(0);
|
||||||
|
assert!(candidates(&mut browser).is_empty());
|
||||||
|
browser.help = None;
|
||||||
|
browser.preview = Some(PreviewMode::Info);
|
||||||
|
assert!(candidates(&mut browser).is_empty());
|
||||||
|
browser.preview = None;
|
||||||
|
browser.input = Some(String::new());
|
||||||
|
assert!(candidates(&mut browser).is_empty());
|
||||||
|
browser.input = None;
|
||||||
|
browser.removal = Some((CollectionKind::Saved, vec![]));
|
||||||
|
assert!(candidates(&mut browser).is_empty());
|
||||||
|
browser.removal = None;
|
||||||
|
browser.modal = Some(LibraryModal::Categories {
|
||||||
|
names: vec![],
|
||||||
|
checked: BTreeSet::new(),
|
||||||
|
picker: Picker::default(),
|
||||||
|
});
|
||||||
|
assert!(candidates(&mut browser).is_empty());
|
||||||
|
browser.modal = None;
|
||||||
|
browser.request(
|
||||||
|
Request::Episodes {
|
||||||
|
media_id: "1".into(),
|
||||||
|
slug: String::new(),
|
||||||
|
},
|
||||||
|
Intent::Series("1".into()),
|
||||||
|
);
|
||||||
|
assert!(candidates(&mut browser).is_empty());
|
||||||
|
browser.invalidate();
|
||||||
|
assert!(browser.source_visible.is_empty());
|
||||||
|
assert_eq!(candidates(&mut browser).len(), 2);
|
||||||
|
browser.history(false);
|
||||||
|
assert!(candidates(&mut browser).is_empty());
|
||||||
|
browser.history(true);
|
||||||
|
assert_eq!(candidates(&mut browser).len(), 2);
|
||||||
|
browser.provider = Provider::Miruro;
|
||||||
|
assert!(candidates(&mut browser).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_checks_are_session_only_and_collection_rows_keep_selection_checks_and_watch_state() {
|
||||||
|
use ratatui::{Terminal, backend::TestBackend};
|
||||||
|
let directory = Directory::new();
|
||||||
|
let mut browser = anilist_browser(&directory, 10);
|
||||||
|
let video = browser.screen().selected_video().unwrap();
|
||||||
|
browser.save(CollectionKind::Saved);
|
||||||
|
browser.set_watch_status(WatchStatus::Watched);
|
||||||
|
browser.collection(CollectionKind::Saved);
|
||||||
|
browser.screen_mut().toggle_checked();
|
||||||
|
let checks = browser.screen().checked.clone();
|
||||||
|
let saved = fs::read(directory.0.join("saved-videos.toml")).unwrap();
|
||||||
|
let watch = fs::read(directory.0.join("watch-state.toml")).unwrap();
|
||||||
|
let config = fs::read(&browser.config_path).unwrap();
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
browser.source_visible.as_slice(),
|
||||||
|
std::slice::from_ref(&video.id)
|
||||||
|
);
|
||||||
|
assert_eq!(browser.rows()[0].height(), 3);
|
||||||
|
let now = Instant::now(); // Fixed injected clock; no elapsed-time dependency.
|
||||||
|
browser.update_source_checks(now);
|
||||||
|
assert_eq!(
|
||||||
|
browser.source_checks.status(&video.id),
|
||||||
|
Some(source_check::Status::Failed)
|
||||||
|
);
|
||||||
|
assert_eq!(browser.screen().selected_key(), Some(video.id.as_str()));
|
||||||
|
assert_eq!(browser.screen().checked, checks);
|
||||||
|
assert!(browser.state.is_watched(&video.id));
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(directory.0.join("saved-videos.toml")).unwrap(),
|
||||||
|
saved
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
fs::read(directory.0.join("watch-state.toml")).unwrap(),
|
||||||
|
watch
|
||||||
|
);
|
||||||
|
assert_eq!(fs::read(&browser.config_path).unwrap(), config);
|
||||||
|
browser.refresh();
|
||||||
|
assert_eq!(browser.source_checks.status(&video.id), None);
|
||||||
|
assert_eq!(browser.screen().selected_key(), Some(video.id.as_str()));
|
||||||
|
browser.provider = Provider::Miruro;
|
||||||
|
assert_eq!(browser.rows()[0].height(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn episode_three_lines_align_after_shared_marker_and_leave_metadata_second() {
|
||||||
|
use ratatui::{Terminal, backend::TestBackend};
|
||||||
|
let directory = Directory::new();
|
||||||
|
let mut browser = anilist_browser(&directory, 2);
|
||||||
|
let video = browser.screen().selected_video().unwrap();
|
||||||
|
browser.set_watch_status(WatchStatus::Watched);
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(100, 30)).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||||
|
.unwrap();
|
||||||
|
let buffer = terminal.backend().buffer();
|
||||||
|
let find = |needle: &str| {
|
||||||
|
(0..buffer.area.height)
|
||||||
|
.find_map(|y| {
|
||||||
|
let line = (0..buffer.area.width)
|
||||||
|
.map(|x| buffer[(x, y)].symbol())
|
||||||
|
.collect::<String>();
|
||||||
|
line.find(needle)
|
||||||
|
.map(|byte| (line[..byte].chars().count(), y))
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let title = find("Episode 1");
|
||||||
|
let metadata = find("25 min avg");
|
||||||
|
let source = find(browser.source_checks.label(&video.id));
|
||||||
|
assert_eq!(metadata, (title.0, title.1 + 1));
|
||||||
|
assert_eq!(source, (title.0, title.1 + 2));
|
||||||
|
assert_eq!(browser.screen().selected_key(), Some(video.id.as_str()));
|
||||||
|
for metadata in ["25 min avg", "TV", "1986 series"] {
|
||||||
|
assert!(browser.screen().rows[0].details.contains(metadata));
|
||||||
|
}
|
||||||
|
browser.screen_mut().toggle_checked();
|
||||||
|
terminal
|
||||||
|
.draw(|frame| browser.draw(frame, Duration::ZERO))
|
||||||
|
.unwrap();
|
||||||
|
assert!(browser.state.is_watched(&video.id));
|
||||||
|
assert_eq!(browser.screen().checked, [video.id]);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user