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

@@ -102,7 +102,7 @@ class UniFfiBoundaryTest {
*/
@Test
fun chapterPlanningCrossesTheBoundaryIntact() {
val plan = chapterPlan(emptyList(), 1560.0)
val plan = chapterPlan(emptyList(), 1560.0, emptyList())
assertNotNull(plan)
assertNull(skipOffer(plan, 12.0))
}
@@ -19,6 +19,7 @@ import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
import uniffi.pstr_android.ChapterPlan
import uniffi.pstr_android.ChapterRecord
import uniffi.pstr_android.SkipRangeRecord
import uniffi.pstr_android.SubtitleFilesRecord
import uniffi.pstr_android.SubtitleTrackRecord
import uniffi.pstr_android.chapterPlan
@@ -135,6 +136,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
@Volatile private var currentMedia: String? = null
@Volatile private var currentGeneration: Double = -1.0
@Volatile private var plannedFor: Pair<Double, Long>? = null
/** What AniSkip said about the open episode, keyed by that episode. */
@Volatile private var skipRanges: Pair<String, List<SkipRangeRecord>>? = null
/** The load generation whose subtitle pick has been looked at. */
@Volatile private var subtitlesCheckedFor: Double = -1.0
var onPlaybackStarted: (() -> Unit)? = null
@@ -226,6 +229,7 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
mutableTracks.value = emptyList()
mutableChapters.value = ChapterPlan(emptyList(), null)
plannedFor = null
skipRanges = null
}
}
} ?: false
@@ -268,6 +272,17 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
}
fun seek(position: Double) { withOpenHandle { nativeSeek(it, position) } }
/**
* Take AniSkip's answer for [media], and plan the chapters again with it.
* Dropped when another episode has opened since it was asked for.
*/
fun setSkipRanges(media: String, ranges: List<SkipRangeRecord>) {
if (media != currentMedia) return
skipRanges = media to ranges
plannedFor = null
refreshChapters(mutableState.value)
}
fun setVolume(volume: Double) { withOpenHandle { nativeVolume(it, volume) } }
fun setMuted(muted: Boolean) { withOpenHandle { nativeMute(it, muted) } }
@@ -394,7 +409,8 @@ class NativeMpvHost private constructor(private val nativeHandle: Long) : Libmpv
}
}.onSuccess { chapters ->
plannedFor = wanted
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 })
val skips = skipRanges?.takeIf { it.first == currentMedia }?.second.orEmpty()
mutableChapters.value = chapterPlan(chapters, state.duration.takeIf { it > 0.0 }, skips)
}
}
@@ -125,6 +125,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import uniffi.pstr_android.ChapterEntry
import uniffi.pstr_android.ChapterKind
import uniffi.pstr_android.EpisodeRecord
import uniffi.pstr_android.TitleRecord
import uniffi.pstr_android.TrackPreferencesRecord
@@ -425,6 +426,20 @@ fun PlayerScreen(
if (autoSkip && !state.paused && skipped.add(chapter.index)) nativeHost.seek(target)
}
// A file whose chapters name no opening or ending asks AniSkip, once, when
// its length is known. Only with posters and descriptions on: anything
// else, an error included, leaves the file as its chapters made it.
val lengthKnown = playingThis && state.duration > 0.0
LaunchedEffect(key, lengthKnown) {
if (!lengthKnown) return@LaunchedEffect
val named = chapters.entries.any { it.kind == ChapterKind.OPENING || it.kind == ChapterKind.ENDING }
if (named) return@LaunchedEffect
val ranges = runCatching {
NativeRuntime.engine().skipTimes(titleKey, episode.shareId, episode.linkId, state.duration)
}.getOrNull()
if (!ranges.isNullOrEmpty()) nativeHost.setSkipRanges(key, ranges)
}
var landscapeLocked by remember { mutableStateOf(false) }
// Set by the rotation button: from then on the viewer decides.
var rotationChosen by remember { mutableStateOf(false) }
+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();
+22
View File
@@ -45,6 +45,7 @@ good. What hung off it was not:
|---|---|---|
| **AniList** GraphQL | none | Identity. Search, every alias, cover art, relations (`SEQUEL`, `PREQUEL`, `SIDE_STORY`…), studios, staff, tags, format, season, popularity, next airing episode, the MAL id. |
| **ani.zip** `api.ani.zip/mappings?anilist_id=` | none | Everything AniList lacks, per AniList id: TVDB season, episode and absolute numbers; episode titles in several languages; TVDB synopses; 16:9 stills; air dates; runtime. Series art: TVDB **fanart at 1920×1080**, poster, banner, logo. Cross-ids for MAL, AniDB, TVDB, TMDB, Kitsu, IMDb. A community service — cache what it says. |
| **AniSkip** `api.aniskip.com/v2/skip-times/` | none | Where an episode's opening and ending are, by MyAnimeList id and episode number, as viewers marked them. Asked at play time for files without such chapters; never stored. |
| **TMDB** | free key | Film backdrops and logos; live-action TV. Chosen as the provider, or with AniList chosen, asked by id for art alone. |
| **TVmaze** | none | Live-action TV: episodes with 16:9 stills and runtimes, network, status, full-HD backgrounds. No films; its "typography" art is a JPEG, so no logos. In use after AniList. |
| **AniSkip** | none | Opening and ending timestamps by MAL id and episode. |
@@ -212,6 +213,27 @@ deleting nothing. `TitleDetails::revision` makes details written before
`runtime` existed refetch by id on the next run; they stay on screen until the
answer lands, rather than being blanked by the migration as schema v10 did.
**AniSkip.** A file whose chapters name no opening or ending — most files
that are not fansub releases — gets them from [AniSkip](https://aniskip.com),
where viewers mark where an episode's opening and ending are. ani.zip sends each
entry's MyAnimeList id with its mappings; enrichment now keeps it on every
episode row (`episode_metadata.mal_id`), next to the episode's number within
that entry. When such a file has loaded and its length is known, the player asks
`api.aniskip.com/v2/skip-times/{mal_id}/{number}?types=op&types=ed&episodeLength=`
once, with enrichment on only. The answer is kept in memory for as long as the
file is open and never stored.
`pstr_core::chapters::with_skip_ranges` cuts the answer into the file's
chapters as chapters named `Opening` and `Ending`, so the skip button, skipping
on its own, the up-next countdown and the marks on the seek bar all work through
the same code as for a file that came with them. A file whose own chapters
already name an opening or an ending keeps them: what a release says about its
own cut beats what a viewer of another release marked.
**Schema v14** adds the column and marks every AniList title un-enriched,
deleting nothing, as v13 did. Until the next match run fills it in, AniSkip is
not asked.
## Phase 3 — reach
**Routing by title (done).** One provider for the whole library left Game of
+2
View File
@@ -33,6 +33,8 @@ your Drive**. See [Account and sync](ACCOUNT_AND_SYNC.md).
the middle to pause.
- The menu has **Episodes**, **Chapters**, **Playback speed**, the audio and subtitle tracks, and
**Lock to landscape**.
- **Skip opening** and **Skip ending** work as on the desktop, AniSkip included: see
[The player](USER_GUIDE.md#the-player).
- Sound keeps playing with the screen off or the app in the background, with controls on the lock
screen. Turn off **Background audio** in Settings to stop that.
+1
View File
@@ -11,6 +11,7 @@ and to nothing else unless you turn on posters and descriptions. This page lists
| AniList and ani.zip, or TMDB | The names of the titles in your library, and the ids of matched titles | Only with **Posters and descriptions** on |
| TVmaze | With AniList chosen: the names of the titles AniList has no match for, and the ids of those TVmaze matched | Only with **Posters and descriptions** on |
| TMDB, with AniList chosen | The TMDB ids of matched titles that have no wide picture or logo yet. Never a title's name | Only with **Posters and descriptions** on and a TMDB key saved |
| AniSkip | The MyAnimeList id and episode number of what you play, and the file's length. Never a name | Only with **Posters and descriptions** on, when you play an episode whose file does not mark its opening and ending |
Nothing else leaves your device. There is no telemetry, no crash reporting and no update check.
+2 -1
View File
@@ -135,7 +135,8 @@ An episode counts as watched once you have seen 90% of it.
The picture plays inside the app's window. Move the mouse to show the controls.
- **Skip opening** and **Skip ending** appear when the file's chapters mark an opening, an ending
or a preview. Files without chapter names get no skip button. Turn on **Skip openings and
or a preview. For an anime whose file marks neither, the app asks AniSkip where they are, with
**Posters and descriptions** on; they then show as chapters too. Turn on **Skip openings and
credits** in Settings to skip them without asking.
- **Up next** counts down 10 seconds when the credits start, then plays the next episode. Choose
**Play now**, **Cancel** or **Watch till the end**.
+25 -1
View File
@@ -120,6 +120,9 @@ final class PlayerHost {
@ObservationIgnored private var skipped = Set<Int64>()
/// The episode whose next one has been warmed; see `tick`.
@ObservationIgnored private var warmedAfter: String?
/// The episode AniSkip has been asked about, and what it said.
@ObservationIgnored private var skipsAskedFor: String?
@ObservationIgnored private var skipRanges: (key: String, ranges: [SkipRangeRecord])?
@ObservationIgnored private var pausedByInterruption = false
@ObservationIgnored private var artworkFor: String?
@@ -414,6 +417,26 @@ final class PlayerHost {
player.duration - player.position < warmNextSeconds
|| chapters.creditsStart.map { player.position >= $0 } == true
)
// A file whose chapters name no opening or ending asks AniSkip, once,
// when its length is known. Only with posters and descriptions on:
// anything else, an error included, leaves the file as its chapters
// made it.
if player.duration > 0, let key, skipsAskedFor != key, let title, let episode {
skipsAskedFor = key
let named = chapters.entries.contains { $0.kind == .opening || $0.kind == .ending }
if !named {
let (titleKey, shareId, linkId, duration) = (title.key, episode.shareId, episode.linkId, player.duration)
Task { [weak self] in
let ranges = (try? await NativeRuntime.engine().skipTimes(
titleKey: titleKey, shareId: shareId, linkId: linkId, duration: duration
)) ?? []
guard let self, !ranges.isEmpty, self.key == key, let player = self.player else { return }
self.skipRanges = (key, ranges)
self.plannedFor = nil
self.replanChapters(player)
}
}
}
if nearEnd, let key, warmedAfter != key, let next {
warmedAfter = key
let (shareId, volumeId, linkId) = (next.shareId, next.volumeId, next.linkId)
@@ -431,7 +454,8 @@ final class PlayerHost {
let wanted = (player.duration, player.generation)
if let plannedFor, plannedFor == wanted, !(chapters.entries.isEmpty && !player.chapters.isEmpty) { return }
plannedFor = wanted
chapters = chapterPlan(chapters: player.chapters, duration: player.duration > 0 ? player.duration : nil)
let skips = skipRanges.flatMap { $0.key == key ? $0.ranges : nil } ?? []
chapters = chapterPlan(chapters: player.chapters, duration: player.duration > 0 ? player.duration : nil, skips: skips)
}
/// Play the dialogue rather than the signs and songs mpv picked by