diff --git a/android/app/src/androidTest/java/io/narl/protonstream/native/UniFfiBoundaryTest.kt b/android/app/src/androidTest/java/io/narl/protonstream/native/UniFfiBoundaryTest.kt index 354a83b..baa5291 100644 --- a/android/app/src/androidTest/java/io/narl/protonstream/native/UniFfiBoundaryTest.kt +++ b/android/app/src/androidTest/java/io/narl/protonstream/native/UniFfiBoundaryTest.kt @@ -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)) } diff --git a/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt b/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt index 1043831..f8abdf7 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/NativeMpvHost.kt @@ -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? = null + /** What AniSkip said about the open episode, keyed by that episode. */ + @Volatile private var skipRanges: Pair>? = 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) { + 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) } } diff --git a/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt index 74309d3..6c98371 100644 --- a/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt +++ b/android/app/src/main/java/io/narl/protonstream/playback/PlayerScreen.kt @@ -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) } diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index c2e82ce..9f64b2b 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -812,6 +812,16 @@ pub struct ChapterPlan { pub credits_start: Option, } +/// 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, duration: Option) -> ChapterPlan { +pub fn chapter_plan( + chapters: Vec, + duration: Option, + skips: Vec, +) -> ChapterPlan { let duration = duration.filter(|duration| *duration > 0.0); let chapters: Vec = chapters .into_iter() @@ -837,6 +854,22 @@ pub fn chapter_plan(chapters: Vec, duration: Option) -> Chap start: chapter.start, }) .collect(); + let skips: Vec = 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, + title_key: String, + share_id: String, + link_id: String, + duration: f64, + ) -> Result, 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"); diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index 587d048..b7c23fd 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -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); diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index e0b2ee0..f36c9b4 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -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, + }, /// A stream opened and is ready to hand to mpv. PlaybackReady { target: Box, @@ -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 diff --git a/crates/pstr-app/src/playback.rs b/crates/pstr-app/src/playback.rs index 168c7aa..532ee82 100644 --- a/crates/pstr-app/src/playback.rs +++ b/crates/pstr-app/src/playback.rs @@ -198,8 +198,16 @@ pub struct Playback { pub subtitle_delay: f64, /// What the file contains. Empty until mpv has demuxed it. pub tracks: Vec, - /// 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, + /// The chapters as the file has them. + file_chapters: Vec, + /// What AniSkip said, once it has. + skip_ranges: Vec, + /// 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 { + 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) { + self.skip_ranges = ranges; + self.reclassify(); + } + /// Fraction played, for the seek bar. `None` until mpv reports a duration. pub fn progress(&self) -> Option { let duration = self.duration.filter(|duration| *duration > 0.0)?; diff --git a/crates/pstr-core/src/catalog.rs b/crates/pstr-core/src/catalog.rs index 47d3cfc..1693265 100644 --- a/crates/pstr-core/src/catalog.rs +++ b/crates/pstr-core/src/catalog.rs @@ -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> { 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"); diff --git a/crates/pstr-core/src/chapters.rs b/crates/pstr-core/src/chapters.rs index fe043dd..7a9b094 100644 --- a/crates/pstr-core/src/chapters.rs +++ b/crates/pstr-core/src/chapters.rs @@ -294,6 +294,116 @@ pub fn chapter_at(chapters: &[Chapter], position: f64) -> Option { .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, +) -> Vec { + 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 = 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 = 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)> = 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)]| { + 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)] + ); + } } diff --git a/crates/pstr-core/src/discover.rs b/crates/pstr-core/src/discover.rs index e3f1937..8158b49 100644 --- a/crates/pstr-core/src/discover.rs +++ b/crates/pstr-core/src/discover.rs @@ -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)]); diff --git a/crates/pstr-core/src/metadata.rs b/crates/pstr-core/src/metadata.rs index 432fd61..bd95052 100644 --- a/crates/pstr-core/src/metadata.rs +++ b/crates/pstr-core/src/metadata.rs @@ -420,6 +420,11 @@ pub struct EpisodeMetadata { /// Minutes. #[serde(default)] pub runtime: Option, + /// 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, } /// 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, } } diff --git a/crates/pstr-meta/src/anilist.rs b/crates/pstr-meta/src/anilist.rs index 38314e2..f0fe4b9 100644 --- a/crates/pstr-meta/src/anilist.rs +++ b/crates/pstr-meta/src/anilist.rs @@ -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)])); diff --git a/crates/pstr-meta/src/aniskip.rs b/crates/pstr-meta/src/aniskip.rs new file mode 100644 index 0000000..addd5d3 --- /dev/null +++ b/crates/pstr-meta/src/aniskip.rs @@ -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> { + 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, +} + +#[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 { + 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()); + } +} diff --git a/crates/pstr-meta/src/anizip.rs b/crates/pstr-meta/src/anizip.rs index f542494..5bf41f9 100644 --- a/crates/pstr-meta/src/anizip.rs +++ b/crates/pstr-meta/src/anizip.rs @@ -106,6 +106,9 @@ struct Ids { kind: Option, #[serde(default, deserialize_with = "lenient_number")] themoviedb_id: Option, + /// The same entry on MyAnimeList, which AniSkip is keyed by. + #[serde(default, deserialize_with = "lenient_number")] + mal_id: Option, } 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::( + 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| { diff --git a/crates/pstr-meta/src/lib.rs b/crates/pstr-meta/src/lib.rs index 1bd7fe3..9b5ed1c 100644 --- a/crates/pstr-meta/src/lib.rs +++ b/crates/pstr-meta/src/lib.rs @@ -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; diff --git a/crates/pstr-meta/src/service.rs b/crates/pstr-meta/src/service.rs index 20b5bc2..639cc4e 100644 --- a/crates/pstr-meta/src/service.rs +++ b/crates/pstr-meta/src/service.rs @@ -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> { + 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() diff --git a/crates/pstr-meta/src/tmdb.rs b/crates/pstr-meta/src/tmdb.rs index 6e0544c..581a03c 100644 --- a/crates/pstr-meta/src/tmdb.rs +++ b/crates/pstr-meta/src/tmdb.rs @@ -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, } })); } diff --git a/crates/pstr-meta/src/tvmaze.rs b/crates/pstr-meta/src/tvmaze.rs index 42bc5e1..0b9ac40 100644 --- a/crates/pstr-meta/src/tvmaze.rs +++ b/crates/pstr-meta/src/tvmaze.rs @@ -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(); diff --git a/docs/METADATA.md b/docs/METADATA.md index 8beea5a..7103054 100644 --- a/docs/METADATA.md +++ b/docs/METADATA.md @@ -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 diff --git a/docs/MOBILE.md b/docs/MOBILE.md index e8e1686..98c5724 100644 --- a/docs/MOBILE.md +++ b/docs/MOBILE.md @@ -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. diff --git a/docs/PRIVACY.md b/docs/PRIVACY.md index a8793ae..5f7e8ac 100644 --- a/docs/PRIVACY.md +++ b/docs/PRIVACY.md @@ -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. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index afd0b88..fed6e66 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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**. diff --git a/ios/ProtonStream/Player/PlayerHost.swift b/ios/ProtonStream/Player/PlayerHost.swift index 5f35127..1501cc6 100644 --- a/ios/ProtonStream/Player/PlayerHost.swift +++ b/ios/ProtonStream/Player/PlayerHost.swift @@ -120,6 +120,9 @@ final class PlayerHost { @ObservationIgnored private var skipped = Set() /// 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