mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
466630a9ba
commit
cf8ee58f6b
23 files changed
+728
-11
No files matched your search
@@ -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)]));
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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| {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user