feat(meta,core,app,android,ios): AniSkip where files have no chapters

Most files that are not fansub releases carry no chapters, so they had no
skip button. AniSkip knows where an episode's opening and ending are, by
MyAnimeList id and episode number. ani.zip sends each entry's MyAnimeList
id and enrichment now keeps it on every episode row; schema v14 adds the
column and marks AniList titles un-enriched, deleting nothing, as v13 did.

When a file has loaded with no chapter naming an opening or ending, and
its length is known, each client asks once, with enrichment on only. The
answer lives as long as the file is open. with_skip_ranges cuts it into
the chapters as chapters named Opening and Ending, so the skip button,
auto-skip, the up-next countdown and the seek bar's marks all work
through the code a chaptered file uses. A file's own opening or ending
always wins over AniSkip's.

PRIVACY.md lists what is sent: the MyAnimeList id, the episode number and
the file's length.
This commit is contained in:
nvrl committed 2026-10-31 03:13:41 +01:00
1 parent 466630a9ba
commit cf8ee58f6b
23 files changed
+728 -11

No files matched your search

+106 -1
View File
@@ -812,6 +812,16 @@ pub struct ChapterPlan {
pub credits_start: Option<f64>,
}
/// A stretch AniSkip says is the opening or the ending of the open file.
/// See `pstr_core::chapters::with_skip_ranges`.
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct SkipRangeRecord {
/// `Opening` or `Ending`.
pub kind: ChapterKind,
pub start: f64,
pub end: f64,
}
/// What the player may offer to skip right now.
#[derive(Debug, Clone, PartialEq, uniffi::Record)]
pub struct SkipOffer {
@@ -823,8 +833,15 @@ pub struct SkipOffer {
/// Resolve a file's chapters through the same rules the desktop player uses
/// (`pstr_core::chapters`), so an opening called `Intro` is read identically on
/// both. Kotlin reads the list out of mpv; the verdict is reached here.
///
/// `skips` is what AniSkip said about the file, empty until it has: cut into
/// the chapters when the file's own name no opening or ending.
#[uniffi::export]
pub fn chapter_plan(chapters: Vec<ChapterRecord>, duration: Option<f64>) -> ChapterPlan {
pub fn chapter_plan(
chapters: Vec<ChapterRecord>,
duration: Option<f64>,
skips: Vec<SkipRangeRecord>,
) -> ChapterPlan {
let duration = duration.filter(|duration| *duration > 0.0);
let chapters: Vec<Chapter> = chapters
.into_iter()
@@ -837,6 +854,22 @@ pub fn chapter_plan(chapters: Vec<ChapterRecord>, duration: Option<f64>) -> Chap
start: chapter.start,
})
.collect();
let skips: Vec<pstr_core::chapters::SkipRange> = skips
.into_iter()
.filter_map(|skip| {
let role = match skip.kind {
ChapterKind::Opening => ChapterRole::Opening,
ChapterKind::Ending => ChapterRole::Ending,
ChapterKind::Preview | ChapterKind::Content => return None,
};
Some(pstr_core::chapters::SkipRange {
role,
start: skip.start,
end: skip.end,
})
})
.collect();
let chapters = pstr_core::chapters::with_skip_ranges(&chapters, &skips, duration);
let roles = pstr_core::chapters::roles(&chapters, duration);
ChapterPlan {
credits_start: pstr_core::chapters::credits_start(&chapters, &roles),
@@ -1971,6 +2004,56 @@ impl AndroidEngine {
.map_err(BridgeError::from_display)?
}
/// Where AniSkip says the opening and ending of one episode are, in a
/// file `duration` seconds long. Empty when the episode's provider entry
/// has no MyAnimeList id or nobody has marked it; an error with enrichment
/// off, like every other metadata call.
pub async fn skip_times(
self: Arc<Self>,
title_key: String,
share_id: String,
link_id: String,
duration: f64,
) -> Result<Vec<SkipRangeRecord>, BridgeError> {
let runtime = Arc::clone(&self.runtime);
spawned(&runtime, async move {
let service = self.metadata_service()?;
let found = self.with_library(|cached| {
let title = cached
.library
.titles
.iter()
.find(|title| title.key == title_key)?;
let episode = title.episodes().find(|episode| {
episode.node.share_id == share_id && episode.node.link_id == link_id
})?;
let number = episode.node.parsed.episode?;
let found = cached
.guides
.get(&title.key)?
.get(episode.node.parsed.season, number)?;
Some((found.mal_id?, found.entry_number?))
})?;
let Some((mal_id, number)) = found else {
return Ok(Vec::new());
};
let ranges = service
.skip_times(mal_id, number, duration)
.await
.map_err(BridgeError::from_display)?;
Ok(ranges
.into_iter()
.map(|range| SkipRangeRecord {
kind: chapter_kind(range.role),
start: range.start,
end: range.end,
})
.collect())
})
.await
.map_err(BridgeError::from_display)?
}
/// Pin a title to the entry the viewer picked, then fetch that entry's
/// episodes, backdrop and franchise chain — the same enrichment a match run
/// does, so the title page shows the new match's episodes as soon as this
@@ -4318,6 +4401,7 @@ mod tests {
chapter(3, "Cast", 4680.0),
],
Some(4800.0),
Vec::new(),
);
// Eleven minutes called `Intro` is the story, not a theme song.
@@ -4339,6 +4423,7 @@ mod tests {
chapter(1, "OP", 24.0),
],
None,
Vec::new(),
);
assert_eq!(plan.entries[0].label, "Chapter 1");
@@ -4348,6 +4433,25 @@ mod tests {
assert_eq!(skip_offer(&plan, 30.0), None);
}
/// A file with no chapters of its own offers AniSkip's opening, through
/// the same skip as one that came with chapters.
#[test]
fn aniskips_opening_is_offered_in_a_file_without_chapters() {
let plan = chapter_plan(
Vec::new(),
Some(1440.0),
vec![super::SkipRangeRecord {
kind: ChapterKind::Opening,
start: 60.0,
end: 150.0,
}],
);
let offer = skip_offer(&plan, 70.0).expect("inside the opening");
assert_eq!(offer.label, "Skip opening");
assert_eq!(offer.target, 150.0);
assert_eq!(skip_offer(&plan, 10.0), None);
}
/// A skip is offered inside the opening and nowhere else — including at its
/// very last second, where seeking would land the viewer where they already
/// are, and after it, where the button would never go away.
@@ -4361,6 +4465,7 @@ mod tests {
chapter(3, "ED", 1320.0),
],
Some(1440.0),
Vec::new(),
);
assert_eq!(skip_offer(&plan, 10.0), None, "content is not skippable");
+34
View File
@@ -1116,6 +1116,11 @@ impl App {
matcher.error = Some(error);
}
}
Event::SkipTimes { player, ranges } => {
if let Some(playback) = self.playback.as_mut().filter(|p| p.id == player) {
playback.set_skip_ranges(ranges);
}
}
Event::PlaybackReady { target, stream } => {
let opening = self.opening.take();
// Dropped before the new one is built: two mpv cores would
@@ -1457,6 +1462,34 @@ impl App {
})
}
/// Once a file has loaded with no chapters naming its opening or ending,
/// ask AniSkip, when the episode's provider entry has a MyAnimeList id.
fn ask_skip_times(&mut self) {
let Some(playback) = &mut self.playback else {
return;
};
let Some(length) = playback.wants_skip_times() else {
return;
};
playback.skips_asked = true;
let target = &playback.target;
let found = self.library.get(&target.title_key).and_then(|title| {
let episode = title.episodes().find(|episode| {
episode.node.share_id == target.share_id && episode.node.link_id == target.link_id
})?;
let number = episode.node.parsed.episode?;
self.episodes
.get(&title.key)?
.get(episode.node.parsed.season, number)
});
if let Some((mal_id, number)) =
found.and_then(|found| Some((found.mal_id?, found.entry_number?)))
{
self.engine
.request_skip_times(playback.id, mal_id, number, length);
}
}
/// Near the end of a file, fetch the opening blocks of the next one.
///
/// The last two minutes, or the credits if the chapters say where they
@@ -2311,6 +2344,7 @@ impl eframe::App for App {
}
}
self.warm_next();
self.ask_skip_times();
self.auto_skip(ctx);
self.save_subtitle_style(ctx);
self.window.observe(ctx);
+26
View File
@@ -292,6 +292,12 @@ pub enum Event {
},
/// The artwork for this title could not be had.
PosterMissing { key: String },
/// Where AniSkip says the opening and ending of what player `player` has
/// open are. Empty when it does not know.
SkipTimes {
player: u64,
ranges: Vec<pstr_core::chapters::SkipRange>,
},
/// A stream opened and is ready to hand to mpv.
PlaybackReady {
target: Box<PlaybackTarget>,
@@ -2902,6 +2908,26 @@ impl Engine {
path.is_file().then_some(path)
}
/// Ask AniSkip where episode `episode` of the MyAnimeList entry `mal_id`
/// has its opening and ending, for player `player`, whose file runs
/// `length` seconds. Only with enrichment on; a failure is logged and is
/// the same as no answer, which leaves the file as its chapters made it.
pub fn request_skip_times(&self, player: u64, mal_id: u32, episode: u32, length: f64) {
let engine = self.clone();
self.runtime.spawn(async move {
let Some(service) = engine.metadata_service().await else {
return;
};
match service.skip_times(mal_id, episode, length).await {
Ok(ranges) if !ranges.is_empty() => {
engine.emit(Event::SkipTimes { player, ranges });
}
Ok(_) => {}
Err(error) => tracing::debug!("AniSkip for {mal_id}/{episode}: {error}"),
}
});
}
/// Fetch, decode and post one title's artwork.
///
/// Cached on disk under a hash of the URL, so a provider that moves its CDN
+38 -2
View File
@@ -198,8 +198,16 @@ pub struct Playback {
pub subtitle_delay: f64,
/// What the file contains. Empty until mpv has demuxed it.
pub tracks: Vec<Track>,
/// The file's chapters, in order. Empty for a file muxed without them.
/// The chapters to show and act on, in order: the file's own, with
/// AniSkip's opening and ending cut in when the file names neither. Empty
/// for a file muxed without chapters that AniSkip does not know.
pub chapters: Vec<Chapter>,
/// The chapters as the file has them.
file_chapters: Vec<Chapter>,
/// What AniSkip said, once it has.
skip_ranges: Vec<pstr_core::chapters::SkipRange>,
/// Whether AniSkip has been asked about this file, so it is asked once.
pub skips_asked: bool,
/// What each of those chapters is, resolved against the rest of the file —
/// see [`pstr_player::roles`]. Same length as `chapters`, and recomputed
/// whenever either the chapters or the duration change.
@@ -278,6 +286,9 @@ impl Playback {
speed: prefs.speed,
tracks: Vec::new(),
chapters: Vec::new(),
file_chapters: Vec::new(),
skip_ranges: Vec::new(),
skips_asked: false,
roles: Vec::new(),
credits_at: None,
position: target.resume_at.unwrap_or(0.0),
@@ -417,6 +428,11 @@ impl Playback {
/// changed. Both are needed: the length of a chapter is what settles a name
/// that could mean two things.
fn reclassify(&mut self) {
self.chapters = pstr_core::chapters::with_skip_ranges(
&self.file_chapters,
&self.skip_ranges,
self.duration,
);
self.roles = pstr_player::roles(&self.chapters, self.duration);
self.credits_at = pstr_player::credits_start(&self.chapters, &self.roles);
}
@@ -433,7 +449,7 @@ impl Playback {
self.place_subtitles();
}
PlayerEvent::Chapters(chapters) => {
self.chapters = chapters.clone();
self.file_chapters = chapters.clone();
self.reclassify();
}
PlayerEvent::Position(position) => {
@@ -466,6 +482,26 @@ impl Playback {
}
}
/// The file's length, when it is time to ask AniSkip where its opening
/// and ending are: once, after it has loaded, and only when its own
/// chapters name neither.
pub fn wants_skip_times(&self) -> Option<f64> {
if self.skips_asked || !self.loaded {
return None;
}
let own = pstr_player::roles(&self.file_chapters, self.duration);
let named = own
.iter()
.any(|role| matches!(role, ChapterRole::Opening | ChapterRole::Ending));
self.duration.filter(|duration| *duration > 0.0 && !named)
}
/// Take AniSkip's answer.
pub fn set_skip_ranges(&mut self, ranges: Vec<pstr_core::chapters::SkipRange>) {
self.skip_ranges = ranges;
self.reclassify();
}
/// Fraction played, for the seek bar. `None` until mpv reports a duration.
pub fn progress(&self) -> Option<f32> {
let duration = self.duration.filter(|duration| *duration > 0.0)?;
+32 -4
View File
@@ -24,7 +24,7 @@ use crate::metadata::{
use crate::naming::{self, ParsedName};
/// Bump when adding a migration.
pub const SCHEMA_VERSION: i64 = 13;
pub const SCHEMA_VERSION: i64 = 14;
const MIGRATION_V1: &str = r#"
CREATE TABLE nodes (
@@ -255,6 +255,16 @@ ALTER TABLE episode_metadata ADD COLUMN runtime INTEGER;
UPDATE title_metadata SET enriched_at = NULL WHERE provider = 'anilist';
"#;
/// Each episode's MyAnimeList id, for AniSkip.
///
/// ani.zip sends it with every entry and it was not kept. As with V13, every
/// AniList title is marked un-enriched so the next match run fills it in, and
/// nothing is deleted meanwhile.
const MIGRATION_V14: &str = r#"
ALTER TABLE episode_metadata ADD COLUMN mal_id INTEGER;
UPDATE title_metadata SET enriched_at = NULL WHERE provider = 'anilist';
"#;
/// How old a share's last crawl may be before an app looks again on its own.
/// Long enough that a launch rarely costs a crawl; short enough that a new
/// episode turns up the same day.
@@ -516,6 +526,12 @@ impl Catalog {
tx.pragma_update(None, "user_version", 13)?;
tx.commit()?;
}
if version < 14 {
let tx = self.conn.transaction()?;
tx.execute_batch(MIGRATION_V14)?;
tx.pragma_update(None, "user_version", 14)?;
tx.commit()?;
}
Ok(())
}
@@ -1266,7 +1282,7 @@ impl Catalog {
pub fn all_episode_metadata(&self) -> Result<HashMap<String, EpisodeGuide>> {
let mut statement = self.conn.prepare(
"SELECT title_key, season, number, name, overview, still_url, air_date,
absolute, entry_season, entry_number, runtime
absolute, entry_season, entry_number, runtime, mal_id
FROM episode_metadata",
)?;
let rows = statement.query_map([], |row| {
@@ -1285,6 +1301,7 @@ impl Catalog {
still_url: row.get(5)?,
air_date: row.get(6)?,
runtime: row.get(10)?,
mal_id: row.get(11)?,
},
))
})?;
@@ -1391,8 +1408,8 @@ fn replace_episodes(
"INSERT OR REPLACE INTO episode_metadata (
title_key, season, number, provider, fetched_at,
name, overview, still_url, air_date,
absolute, entry_season, entry_number, runtime
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
absolute, entry_season, entry_number, runtime, mal_id
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
)?;
for episode in episodes {
insert.execute(params![
@@ -1409,6 +1426,7 @@ fn replace_episodes(
episode.entry_season,
episode.entry_number,
episode.runtime,
episode.mal_id,
])?;
}
Ok(())
@@ -2106,6 +2124,7 @@ mod tests {
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}],
)
.expect("store episodes");
@@ -2199,6 +2218,7 @@ mod tests {
still_url: None,
air_date: Some("2010-11-14".into()),
runtime: None,
mal_id: None,
},
EpisodeMetadata {
season: Some(1),
@@ -2211,6 +2231,7 @@ mod tests {
still_url: Some("still.jpg".into()),
air_date: None,
runtime: None,
mal_id: None,
},
];
catalog
@@ -2254,6 +2275,7 @@ mod tests {
still_url: None,
air_date: None,
runtime: Some(24),
mal_id: None,
};
catalog
.set_episode_metadata(
@@ -2298,6 +2320,7 @@ mod tests {
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}],
)
.expect("store episodes");
@@ -2355,6 +2378,7 @@ mod tests {
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}],
)
.expect("store episodes");
@@ -2463,6 +2487,7 @@ mod tests {
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}],
backdrop_url: None,
logo_url: None,
@@ -2524,6 +2549,7 @@ mod tests {
still_url: None,
air_date: Some(air_date.into()),
runtime: None,
mal_id: None,
};
let enrichment = Enrichment {
episodes: vec![
@@ -2591,6 +2617,7 @@ mod tests {
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}],
backdrop_url: Some("fanart.jpg".into()),
logo_url: None,
@@ -2909,6 +2936,7 @@ mod tests {
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}],
)
.expect("store");
+229
View File
@@ -294,6 +294,116 @@ pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option<usize> {
.rposition(|chapter| position + f64::EPSILON >= chapter.start)
}
/// A stretch of a file someone has marked as its opening or its ending, for
/// a file whose own chapters do not say: AniSkip's answer, in the file's
/// seconds.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SkipRange {
/// [`ChapterRole::Opening`] or [`ChapterRole::Ending`].
pub role: ChapterRole,
pub start: f64,
pub end: f64,
}
impl SkipRange {
/// What the chapter cut for it is called, which is also what [`roles`]
/// reads it back as.
fn label(self) -> Option<&'static str> {
match self.role {
ChapterRole::Opening => Some("Opening"),
ChapterRole::Ending => Some("Ending"),
ChapterRole::Preview | ChapterRole::Content => None,
}
}
}
/// How close two chapter starts may be before they are the same place.
const SAME_PLACE_SECONDS: f64 = 1.0;
/// The file's chapters with `ranges` cut into them, as chapters named
/// `Opening` and `Ending` — so that skipping, skipping on its own, the up-next
/// countdown and the marks on the seek bar all work for a file that came with
/// no such chapters, through the same code as one that did.
///
/// **The file wins.** When its own chapters already mark an opening or an
/// ending, it is returned as it is: what the release says about its own cut
/// beats what a viewer of some other release said. A file chapter that starts
/// inside a range is dropped, since it would split the range in two; one that
/// starts where a range ends gives that place its name. Every chapter is
/// numbered again from zero, which is what the players key "already skipped"
/// by.
pub fn with_skip_ranges(
chapters: &[Chapter],
ranges: &[SkipRange],
duration: Option<f64>,
) -> Vec<Chapter> {
let own = roles(chapters, duration);
if ranges.is_empty()
|| own
.iter()
.any(|role| matches!(role, ChapterRole::Opening | ChapterRole::Ending))
{
return chapters.to_vec();
}
let mut ranges: Vec<SkipRange> = ranges
.iter()
.copied()
.filter(|range| range.label().is_some() && range.start >= 0.0 && range.end > range.start)
.collect();
ranges.sort_by(|a, b| a.start.total_cmp(&b.start));
// Two answers that overlap cannot both be right; the earlier one stays.
let mut kept: Vec<SkipRange> = Vec::with_capacity(ranges.len());
for range in ranges {
if kept.last().is_none_or(|last| range.start >= last.end) {
kept.push(range);
}
}
let title_at = |position: f64| {
chapter_at(chapters, position).and_then(|index| chapters[index].title.clone())
};
let mut points: Vec<(f64, Option<String>)> = Vec::new();
for range in &kept {
points.push((range.start, range.label().map(str::to_owned)));
let ends_the_file =
duration.is_some_and(|duration| range.end >= duration - SAME_PLACE_SECONDS);
if !ends_the_file {
points.push((range.end, title_at(range.end)));
}
}
let near_a_point = |start: f64, points: &[(f64, Option<String>)]| {
points
.iter()
.any(|(at, _)| (at - start).abs() < SAME_PLACE_SECONDS)
};
let inside_a_range = |start: f64| {
kept.iter()
.any(|range| start > range.start && start < range.end)
};
let from_ranges = points.clone();
for chapter in chapters {
if !near_a_point(chapter.start, &from_ranges) && !inside_a_range(chapter.start) {
points.push((chapter.start, chapter.title.clone()));
}
}
// Whatever comes before the first mark is a place in the file too.
if !points.iter().any(|(at, _)| *at < SAME_PLACE_SECONDS) {
points.push((0.0, None));
}
points.sort_by(|a, b| a.0.total_cmp(&b.0));
points
.into_iter()
.enumerate()
.map(|(index, (start, title))| Chapter {
index: index as i64,
title,
start,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -480,4 +590,123 @@ mod tests {
// and a button that seeks to zero would be worse than no button.
assert_eq!(chapter_end(&chapters, 1, None), None);
}
fn range(role: ChapterRole, start: f64, end: f64) -> SkipRange {
SkipRange { role, start, end }
}
fn shape(chapters: &[Chapter]) -> Vec<(i64, Option<&str>, f64)> {
chapters
.iter()
.map(|chapter| (chapter.index, chapter.title.as_deref(), chapter.start))
.collect()
}
#[test]
fn a_file_with_no_chapters_gets_its_opening_and_ending_as_chapters_that_skip() {
let ranges = [
range(ChapterRole::Opening, 90.0, 180.0),
range(ChapterRole::Ending, 1300.0, 1390.0),
];
let chapters = with_skip_ranges(&[], &ranges, Some(1420.0));
assert_eq!(
shape(&chapters),
vec![
(0, None, 0.0),
(1, Some("Opening"), 90.0),
(2, None, 180.0),
(3, Some("Ending"), 1300.0),
(4, None, 1390.0),
]
);
let roles = roles(&chapters, Some(1420.0));
assert_eq!(roles[1], ChapterRole::Opening);
assert_eq!(roles[3], ChapterRole::Ending);
}
#[test]
fn a_files_own_opening_beats_anything_aniskip_says() {
let own = vec![
Chapter {
index: 0,
title: Some("Prologue".into()),
start: 0.0,
},
Chapter {
index: 1,
title: Some("OP".into()),
start: 60.0,
},
Chapter {
index: 2,
title: Some("Part A".into()),
start: 150.0,
},
];
let ranges = [range(ChapterRole::Opening, 70.0, 160.0)];
assert_eq!(with_skip_ranges(&own, &ranges, Some(1420.0)), own);
}
#[test]
fn unnamed_chapters_inside_a_range_give_way_and_one_at_its_end_keeps_its_name() {
let own = vec![
Chapter {
index: 0,
title: None,
start: 0.0,
},
Chapter {
index: 1,
title: None,
start: 89.6,
},
Chapter {
index: 2,
title: None,
start: 120.0,
},
Chapter {
index: 3,
title: Some("Part A".into()),
start: 180.0,
},
];
let ranges = [range(ChapterRole::Opening, 90.0, 180.0)];
let chapters = with_skip_ranges(&own, &ranges, Some(1420.0));
assert_eq!(
shape(&chapters),
vec![
(0, None, 0.0),
(1, Some("Opening"), 90.0),
(2, Some("Part A"), 180.0)
]
);
}
#[test]
fn an_ending_that_runs_to_the_end_of_the_file_adds_no_chapter_after_it() {
let ranges = [range(ChapterRole::Ending, 1330.0, 1419.8)];
let chapters = with_skip_ranges(&[], &ranges, Some(1420.0));
assert_eq!(
shape(&chapters),
vec![(0, None, 0.0), (1, Some("Ending"), 1330.0)]
);
assert_eq!(
credits_start(&chapters, &roles(&chapters, Some(1420.0))),
Some(1330.0)
);
}
#[test]
fn overlapping_answers_keep_the_earlier_one() {
let ranges = [
range(ChapterRole::Opening, 0.0, 90.0),
range(ChapterRole::Ending, 60.0, 150.0),
];
let chapters = with_skip_ranges(&[], &ranges, Some(1420.0));
assert_eq!(
shape(&chapters),
vec![(0, Some("Opening"), 0.0), (1, None, 90.0)]
);
}
}
+1
View File
@@ -557,6 +557,7 @@ mod tests {
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}]);
let guides = HashMap::from([("frieren".to_owned(), guide)]);
+6
View File
@@ -420,6 +420,11 @@ pub struct EpisodeMetadata {
/// Minutes.
#[serde(default)]
pub runtime: Option<u32>,
/// The provider entry's id on MyAnimeList, which is what AniSkip knows
/// openings and endings by. With `entry_number`, it names this episode
/// there.
#[serde(default)]
pub mal_id: Option<u32>,
}
/// What enriching a matched title found: its episodes, and art the search
@@ -735,6 +740,7 @@ mod tests {
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
}
}
+1
View File
@@ -1164,6 +1164,7 @@ mod tests {
still_url: None,
air_date: None,
runtime: None,
mal_id: None,
};
let season_one = vec![episode(1, 1, 1), episode(1, 2, 2)];
assert!(answers_all(&season_one, &[(Some(1), 1), (None, 2)]));
+139
View File
@@ -0,0 +1,139 @@
//! AniSkip: where an episode's opening and ending are, as its viewers marked
//! them.
//!
//! For the files that come with no chapters saying so — which is most of what
//! is not a fansub release. Asked once per episode, when it starts playing, by
//! MyAnimeList id and episode number, with the file's length so the answer is
//! one marked against a cut of the same length. Nothing is stored: the answer
//! is the player's for as long as the file is open. Like ani.zip it needs no
//! key and no account, and it learns nothing about the library that the match
//! did not already send AniList.
use pstr_core::chapters::{ChapterRole, SkipRange};
use serde::Deserialize;
use crate::error::{Error, Result};
const ENDPOINT: &str = "https://api.aniskip.com/v2/skip-times";
/// Where `episode` of the MyAnimeList entry `mal_id` has its opening and its
/// ending, in a cut `length` seconds long. Empty when nobody has marked it.
pub async fn skip_times(
http: &reqwest::Client,
mal_id: u32,
episode: u32,
length: f64,
) -> Result<Vec<SkipRange>> {
let response = http
.get(format!("{ENDPOINT}/{mal_id}/{episode}"))
.query(&[
("types", "op"),
("types", "ed"),
("episodeLength", &format!("{length:.0}")),
])
.send()
.await?;
let status = response.status();
// "No skip times found" comes back as a 404 with a body saying so.
if status.as_u16() == 404 {
return Ok(Vec::new());
}
if !status.is_success() {
return Err(Error::Http(format!("AniSkip answered {status}")));
}
let payload: Payload = response.json().await?;
Ok(payload.ranges())
}
#[derive(Deserialize, Default)]
struct Payload {
#[serde(default)]
found: bool,
#[serde(default)]
results: Vec<Skip>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Skip {
interval: Interval,
skip_type: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct Interval {
start_time: f64,
end_time: f64,
}
impl Payload {
fn ranges(self) -> Vec<SkipRange> {
if !self.found {
return Vec::new();
}
self.results
.into_iter()
.filter_map(|skip| {
let role = match skip.skip_type.as_str() {
"op" => ChapterRole::Opening,
"ed" => ChapterRole::Ending,
_ => return None,
};
(skip.interval.end_time > skip.interval.start_time).then_some(SkipRange {
role,
start: skip.interval.start_time.max(0.0),
end: skip.interval.end_time,
})
})
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_answer_parses_into_an_opening_and_an_ending() {
let payload: Payload = serde_json::from_str(
r#"{"found":true,"results":[
{"interval":{"startTime":1.35,"endTime":91.35},"skipType":"op",
"skipId":"9a85542c","episodeLength":1469.979},
{"interval":{"startTime":1367,"endTime":1459},"skipType":"ed",
"skipId":"f6f905e7","episodeLength":1471}],
"message":"Successfully found skip times","statusCode":200}"#,
)
.expect("parse");
assert_eq!(
payload.ranges(),
vec![
SkipRange {
role: ChapterRole::Opening,
start: 1.35,
end: 91.35,
},
SkipRange {
role: ChapterRole::Ending,
start: 1367.0,
end: 1459.0,
},
]
);
}
#[test]
fn nothing_found_and_kinds_not_asked_for_parse_to_nothing() {
let none: Payload = serde_json::from_str(
r#"{"found":false,"results":[],"message":"No skip times found","statusCode":404}"#,
)
.expect("parse");
assert!(none.ranges().is_empty());
let recap: Payload = serde_json::from_str(
r#"{"found":true,"results":[{"interval":{"startTime":0,"endTime":60},"skipType":"recap"}]}"#,
)
.expect("parse");
assert!(recap.ranges().is_empty());
}
}
+16
View File
@@ -106,6 +106,9 @@ struct Ids {
kind: Option<String>,
#[serde(default, deserialize_with = "lenient_number")]
themoviedb_id: Option<u32>,
/// The same entry on MyAnimeList, which AniSkip is keyed by.
#[serde(default, deserialize_with = "lenient_number")]
mal_id: Option<u32>,
}
impl Ids {
@@ -164,6 +167,7 @@ impl Payload {
let fanart = image("Fanart");
let logo = image("Clearlogo");
let tmdb = self.mappings.as_ref().and_then(Ids::tmdb);
let mal_id = self.mappings.as_ref().and_then(|ids| ids.mal_id);
let episodes = self
.episodes
@@ -194,6 +198,7 @@ impl Payload {
.runtime
.or(episode.length)
.filter(|minutes| *minutes > 0),
mal_id,
})
})
.collect();
@@ -367,6 +372,17 @@ mod tests {
assert_eq!(mapping.tmdb, Some(TmdbId::Movie(4977)));
}
#[test]
fn every_episode_carries_its_entrys_myanimelist_id() {
let mapping = serde_json::from_str::<Payload>(
r#"{"episodes": {"1": {"seasonNumber": 1, "episodeNumber": 1}},
"mappings": {"type": "TV", "mal_id": 52991}}"#,
)
.expect("parse")
.into_mapping("en", Some(1));
assert_eq!(mapping.episodes[0].mal_id, Some(52991));
}
#[test]
fn a_series_tmdb_id_is_kept_and_a_specials_is_not() {
let tmdb = |kind: &str| {
+1
View File
@@ -29,6 +29,7 @@
//! without either depending on this crate.
pub mod anilist;
pub mod aniskip;
pub mod anizip;
pub mod error;
pub mod limiter;
+12
View File
@@ -179,6 +179,18 @@ impl MetadataService {
})
}
/// Where AniSkip says an episode's opening and ending are. See
/// [`crate::aniskip`]; on the service so that it is only ever asked with
/// enrichment on, like every other source.
pub async fn skip_times(
&self,
mal_id: u32,
episode: u32,
length: f64,
) -> Result<Vec<pstr_core::chapters::SkipRange>> {
crate::aniskip::skip_times(&self.http, mal_id, episode, length).await
}
/// The provider the viewer chose: the first one asked.
pub fn provider(&self) -> ProviderId {
self.sources[0].id()
+1
View File
@@ -215,6 +215,7 @@ impl Tmdb {
.map(|path| image_url(STILL_SIZE, path)),
air_date: episode.air_date.clone().filter(|date| !date.is_empty()),
runtime: episode.runtime.filter(|minutes| *minutes > 0),
mal_id: None,
}
}));
}
+1
View File
@@ -294,6 +294,7 @@ impl Show {
.and_then(|image| image.medium.or(image.original)),
air_date: episode.airdate.filter(|date| !date.is_empty()),
runtime: episode.runtime.filter(|minutes| *minutes > 0),
mal_id: None,
})
})
.collect();