feat: add AniList browsing with on-demand ZokoAnime playback

This commit is contained in:
2026-10-25 15:44:41 +01:00
parent a281c6730a
commit e328ff3883
20 changed files with 1062 additions and 235 deletions
+19 -15
View File
@@ -27,32 +27,36 @@ fn startup_provider_selection_preserves_query_and_handles_boundaries_and_cancel(
provider_selection(&mut selected, Some(Action::SelectorPrevious)),
Continue(())
);
for (action, miruro) in [(Action::SelectorOpen, false), (Action::SelectorNext, true)] {
if action == Action::SelectorNext {
assert_eq!(
provider_selection(&mut selected, Some(action)),
Continue(())
);
assert_eq!(
provider_selection(&mut selected, Some(action)),
Continue(())
);
}
for (index, provider) in [
BrowseProvider::YouTube,
BrowseProvider::AniList,
BrowseProvider::Miruro,
]
.into_iter()
.enumerate()
{
selected = index;
let Break(Some(choice)) = provider_selection(&mut selected, Some(Action::SelectorOpen))
else {
panic!("Selected provider must open");
};
assert_eq!(choice, miruro);
assert_eq!(choice, provider);
let mut options = Options::parse_from(["tubular", "initial query"]);
options.miruro = choice;
options.miruro = choice == BrowseProvider::Miruro;
options.anilist = choice == BrowseProvider::AniList;
assert_eq!(options.query.as_deref(), Some("initial query"));
assert_eq!(options.miruro, miruro);
if !miruro {
if choice == BrowseProvider::YouTube {
let app = App::new(options, PathBuf::new(), config::Config::default());
assert_eq!(app.views[0].query, "initial query");
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
}
}
assert_eq!(
provider_selection(&mut selected, Some(Action::SelectorNext)),
Continue(())
);
assert_eq!(selected, PROVIDERS.len() - 1);
assert!(Options::try_parse_from(["tubular", "--miruro", "--anilist"]).is_err());
for action in [Action::SelectorClose, Action::Quit, Action::Interrupt] {
assert_eq!(provider_selection(&mut selected, Some(action)), Break(None));
}
+3 -1
View File
@@ -10,7 +10,9 @@ fn watch_url_preserves_stable_identity_and_validates_mutable_fields() {
);
assert_eq!(Identity::from_key(&identity.key()).unwrap(), identity);
assert_ne!(
Identity::new(identity.media_id.clone(), 2).unwrap().key(),
Identity::miruro(identity.media_id.clone(), 2)
.unwrap()
.key(),
identity.key()
);
for key in [
+28
View File
@@ -67,3 +67,31 @@ fn catalog_and_actual_episode_numbers_remain_distinct() {
""
);
}
#[test]
fn anilist_catalog_numbers_are_not_planned_totals_or_stream_claims() {
let metadata = serde_json::json!({"id":"1","catalog_provider":"anilist","title":{"romaji":"Cowboy Bebop"},"status":"FINISHED","episode_count":26,"catalog_count":26});
let series = anilist_series(metadata).unwrap();
assert_eq!(series.episodes.len(), 26);
assert_eq!(
series.episodes.last().unwrap().identity.key(),
"anilist:1:26"
);
let snapshot = series.episodes[0].snapshot(&series.media);
assert_eq!(snapshot.channel.as_deref(), Some("AniList"));
assert_eq!(
crate::youtube::video_url(&snapshot.id).unwrap(),
"https://zokoanime.video/stream/ani/1/1/sub"
);
let ongoing = anilist_series(serde_json::json!({"id":"2","catalog_provider":"anilist","title":{"romaji":"Ongoing"},"status":"RELEASING","episode_count":24,"nextAiringEpisode":{"episode":4},"catalog_count":3})).unwrap();
assert_eq!(ongoing.episodes.len(), 3);
let unknown = anilist_series(serde_json::json!({"id":"3","catalog_provider":"anilist","title":{"romaji":"Unknown"},"status":"RELEASING","episode_count":null,"catalog_count":null})).unwrap();
assert!(unknown.episodes.is_empty());
assert!(unknown.media.metadata["catalog_count"].is_null());
assert_eq!(
anilist_episode(&unknown.media, 42).unwrap().identity.key(),
"anilist:3:42"
);
assert!(anilist_episode(&unknown.media, 0).is_err());
assert!(anilist_episode(&unknown.media, i32::MAX as u32 + 1).is_err());
}
+60
View File
@@ -1,4 +1,5 @@
use super::*;
use clap::Parser;
use ratatui::crossterm::event::KeyModifiers;
use std::{
fs,
@@ -26,6 +27,7 @@ impl Directory {
player: self.0.join("missing-player").into_os_string(),
default_config: false,
miruro: true,
anilist: false,
query: query.map(str::to_owned),
};
Browser::new(
@@ -764,3 +766,61 @@ fn episode_launch_retains_checks_on_failure_and_clears_only_after_spawn() {
assert_eq!(browser.screen().playback_videos()[0].id, hovered.id);
}
}
#[test]
fn anilist_unknown_count_allows_explicit_selection_and_provider_local_collections() {
let directory = Directory::new();
let path = directory.0.join("config.toml");
let options = Options::parse_from(["tubular", "--anilist"]);
let mut browser = Browser::new(
options,
path.clone(),
config::Config::default(),
State::open(&path).unwrap(),
);
let series = miruro_browser::anilist_series(serde_json::json!({"id":"1","catalog_provider":"anilist","title":{"romaji":"Unknown total"},"status":"RELEASING","episode_count":null,"catalog_count":null})).unwrap();
browser.push(Screen::new(Content::Series {
media: series.media,
data: Some(series.episodes),
}));
browser.edit_search();
assert!(browser.episode_input);
browser.input = Some("7".into());
browser.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let selected = browser.screen().selected_video().unwrap();
assert_eq!(selected.id, "anilist:1:7");
browser.save(CollectionKind::Saved);
let mut legacy = selected.clone();
legacy.id = "miruro:1:7".into();
legacy.channel = Some("Miruro".into());
browser.state.save(CollectionKind::Saved, &legacy).unwrap();
browser.collection(CollectionKind::Saved);
assert_eq!(browser.screen().len(), 1);
assert_eq!(browser.screen().selected_video().unwrap().id, selected.id);
browser.history(false);
for invalid in ["0", "01", "-1", "2147483648"] {
browser.select_episode(invalid);
assert!(browser.screen().error.is_some());
assert_eq!(browser.screen().len(), 1);
}
browser.select_episode("8");
assert!(browser.screen().error.is_none());
browser.screen_mut().toggle_checked();
browser.select_episode("7");
browser.screen_mut().toggle_checked();
let queue = browser.screen().playback_videos();
assert_eq!(
queue
.iter()
.map(|video| video.id.as_str())
.collect::<Vec<_>>(),
["anilist:1:8", "anilist:1:7"]
);
assert_eq!(
crate::youtube::video_url(&queue[0].id).unwrap(),
"https://zokoanime.video/stream/ani/1/8/sub"
);
browser.key(KeyEvent::new(KeyCode::Char('/'), KeyModifiers::NONE));
assert!(matches!(browser.screen().content, Content::Catalog { .. }));
assert!(!browser.episode_input);
}
+63 -2
View File
@@ -59,7 +59,7 @@ fn record(id: &str, event: &str, eligible: bool, position: f64) -> String {
fn miruro_video(episode: u32) -> Video {
Video {
id: Identity::new("opaque_Show-1".into(), episode)
id: Identity::miruro("opaque_Show-1".into(), episode)
.unwrap()
.key(),
title: format!("Example show · Episode {episode}"),
@@ -1313,7 +1313,7 @@ fn miruro_sessions_recover_the_maximum_identity_marker() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let mut video = miruro_video(i32::MAX as u32);
video.id = Identity::new("a".repeat(512), i32::MAX as u32)
video.id = Identity::miruro("a".repeat(512), i32::MAX as u32)
.unwrap()
.key();
let report = state.progress_file(&video).unwrap();
@@ -1449,3 +1449,64 @@ fn progress_batch_prevalidates_snapshots_and_session_capacity() {
assert!(!state.sessions.exists());
assert_eq!(state.streams.len(), MAX_STREAMS - 1);
}
#[test]
fn anilist_library_and_progress_are_isolated_from_opaque_miruro_ids() {
let directory = TestDirectory::new();
let mut state = State::open(&directory.config()).unwrap();
let mut legacy_show = show();
legacy_show.id = "1".into();
legacy_show.metadata["id"] = serde_json::json!("1");
state
.save_show(&legacy_show, &["Legacy".into()].into())
.unwrap();
let legacy_bytes = fs::read(directory.0.join("miruro-library.toml")).unwrap();
state.select_catalog(Provider::AniList);
assert!(state.shows().is_empty());
assert!(state.save_show(&legacy_show, &BTreeSet::new()).is_err());
let mut ani_show = legacy_show.clone();
ani_show.metadata["catalog_provider"] = serde_json::json!("anilist");
state.save_show(&ani_show, &["New".into()].into()).unwrap();
let mut ani = miruro_video(1);
ani.id = Identity::anilist("1".into(), 1).unwrap().key();
ani.channel = Some("AniList".into());
let mut legacy = miruro_video(1);
legacy.id = Identity::miruro("1".into(), 1).unwrap().key();
for item in [&ani, &legacy, &video()] {
state.save(CollectionKind::Saved, item).unwrap();
}
let report = state.progress_file(&ani).unwrap();
register_identity(&report, &ani.id).unwrap();
append(
&report,
miruro_record(&legacy, "ended", true, 100.0, Some(100.0)).as_bytes(),
);
append(
&report,
miruro_record(&ani, "progress", false, 20.0, Some(100.0)).as_bytes(),
);
drop(state);
let mut state = State::open(&directory.config()).unwrap();
assert!(state.poll_progress().unwrap());
assert!(state.contains(CollectionKind::Watching, &ani.id));
assert!(!state.is_watched(&ani.id));
assert!(!state.is_watched(&legacy.id));
append(
&report,
miruro_record(&ani, "ended", true, 95.0, Some(100.0)).as_bytes(),
);
assert!(state.poll_progress().unwrap());
assert!(state.is_watched(&ani.id));
assert!(!state.is_watched(&legacy.id));
assert!(state.contains(CollectionKind::Saved, &legacy.id));
assert_eq!(state.shows()[0].tags, ["Legacy".into()].into());
state.select_catalog(Provider::AniList);
assert_eq!(state.shows()[0].tags, ["New".into()].into());
assert_eq!(
fs::read(directory.0.join("miruro-library.toml")).unwrap(),
legacy_bytes
);
let mut forged = ani.clone();
forged.channel = Some("Miruro".into());
assert!(state.progress_file(&forged).is_err());
}
+8
View File
@@ -542,6 +542,10 @@ fn video_urls_preserve_provider_identity_and_reject_invalid_keys() {
video_url("miruro:opaque_ID-7:3").unwrap(),
"https://barelystarted.miruro.tv/watch/opaque_ID-7/episode?ep=3"
);
assert_eq!(
video_url("anilist:1:3").unwrap(),
"https://zokoanime.video/stream/ani/1/3/sub"
);
for id in [
"",
"--help",
@@ -549,6 +553,10 @@ fn video_urls_preserve_provider_identity_and_reject_invalid_keys() {
"miruro:id:0",
"miruro:id:03",
"miruro:../id:3",
"anilist:01:3",
"anilist:1:03",
"anilist:1:0",
"anilist:1:2147483648",
] {
assert_eq!(
video_url(id).unwrap_err().kind(),