feat: add AniList browsing with on-demand ZokoAnime playback
This commit is contained in:
+19
-15
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user