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

+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)?;