mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-04 00:47:00 +01:00
Anime releases often carry two subtitle tracks in one language, the dialogue and a "Signs & Songs" track, and the second is as often as not the one marked default or forced. mpv, picking by language, played it. pstr_core::subtitles reads a track's name and forced flag for whether it is the dialogue, and every client asks it once per file, after mpv has made its own pick, whether to switch to a track in the same language that is. A subtitle track the viewer chose by name stays, and subtitles that are off stay off.
203 lines
7.1 KiB
Rust
203 lines
7.1 KiB
Rust
//! Which of a file's subtitle tracks is the one to read.
|
|
//!
|
|
//! Anime releases often carry two subtitle tracks in one language: the
|
|
//! dialogue, and a "Signs & Songs" track that only translates the on-screen
|
|
//! text and the lyrics. The second is the one marked default or forced as
|
|
//! often as not, and mpv, picking by language, plays it — an episode where
|
|
//! nobody seems to speak. A language cannot tell the two apart; the track's
|
|
//! name and its forced flag can.
|
|
//!
|
|
//! Nothing here talks to a player. Every client reads the track list out of
|
|
//! mpv once a file is loaded and asks [`full_instead`] whether to switch.
|
|
|
|
use crate::language::same_language;
|
|
|
|
/// One subtitle track of the open file, as mpv lists it.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
|
pub struct SubtitleTrack {
|
|
/// mpv's id for it, what `sid` is set to.
|
|
pub id: i64,
|
|
pub language: Option<String>,
|
|
/// The name the muxer gave it: "Dialogue", "Signs & Songs".
|
|
pub title: Option<String>,
|
|
/// The container marks it as the default choice.
|
|
pub default: bool,
|
|
/// The container marks it as forced: on-screen text only, by convention.
|
|
pub forced: bool,
|
|
/// It is the one playing.
|
|
pub selected: bool,
|
|
}
|
|
|
|
/// How much of what is said a track carries, as far as its name and flags say.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum Coverage {
|
|
/// Signs, songs, or forced: not the dialogue.
|
|
Partial,
|
|
/// Nothing says either way.
|
|
Unknown,
|
|
/// Named as the dialogue: "Dialogue", "Full Subtitles", "SDH".
|
|
Full,
|
|
}
|
|
|
|
/// Words in a track's name that make it the whole dialogue. They win over the
|
|
/// partial ones: "Full (with Signs)" is still the full track.
|
|
const FULL_WORDS: [&str; 6] = ["full", "dialogue", "dialog", "dialogues", "sdh", "cc"];
|
|
|
|
/// Words in a track's name that make it something other than the dialogue.
|
|
const PARTIAL_WORDS: [&str; 13] = [
|
|
"sign", "signs", "song", "songs", "s&s", "forced", "karaoke", "lyrics", "op", "ed", "oped",
|
|
"opening", "ending",
|
|
];
|
|
|
|
impl SubtitleTrack {
|
|
pub fn coverage(&self) -> Coverage {
|
|
let title = self.title.as_deref().unwrap_or_default().to_lowercase();
|
|
let words: Vec<&str> = title
|
|
.split(|c: char| !c.is_alphanumeric() && c != '&')
|
|
.filter(|word| !word.is_empty())
|
|
.collect();
|
|
let has = |list: &[&str]| words.iter().any(|word| list.contains(word));
|
|
if has(&FULL_WORDS) {
|
|
Coverage::Full
|
|
} else if self.forced || has(&PARTIAL_WORDS) {
|
|
Coverage::Partial
|
|
} else {
|
|
Coverage::Unknown
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The track to play in place of the one mpv chose, if it chose the wrong one.
|
|
///
|
|
/// Only a track in the same language as the playing one is considered — the
|
|
/// language is the viewer's choice, and this only corrects which of its tracks
|
|
/// was taken. The playing track is replaced when it is partial and the file has
|
|
/// one in its language that is not, or when nothing names it and another is
|
|
/// named as the dialogue. Among the rest, a track named as the dialogue comes
|
|
/// first, then the container's default, then the file's order. `None` leaves
|
|
/// the selection as it is, which includes subtitles being off.
|
|
pub fn full_instead(tracks: &[SubtitleTrack]) -> Option<i64> {
|
|
let playing = tracks.iter().find(|track| track.selected)?;
|
|
let current = playing.coverage();
|
|
if current == Coverage::Full {
|
|
return None;
|
|
}
|
|
let same = |track: &SubtitleTrack| match (&playing.language, &track.language) {
|
|
(Some(a), Some(b)) => same_language(a, b),
|
|
(None, None) => true,
|
|
_ => false,
|
|
};
|
|
tracks
|
|
.iter()
|
|
.filter(|track| track.id != playing.id && same(track))
|
|
.filter(|track| track.coverage() > current)
|
|
// `max_by_key` keeps the last of equals; reversed, the first.
|
|
.rev()
|
|
.max_by_key(|track| (track.coverage(), track.default))
|
|
.map(|track| track.id)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn track(id: i64, language: &str, title: &str) -> SubtitleTrack {
|
|
SubtitleTrack {
|
|
id,
|
|
language: Some(language.into()),
|
|
title: (!title.is_empty()).then(|| title.into()),
|
|
..SubtitleTrack::default()
|
|
}
|
|
}
|
|
|
|
fn playing(mut track: SubtitleTrack) -> SubtitleTrack {
|
|
track.selected = true;
|
|
track
|
|
}
|
|
|
|
#[test]
|
|
fn signs_and_songs_playing_gives_way_to_the_dialogue_in_its_language() {
|
|
let tracks = [
|
|
playing(track(1, "eng", "Signs & Songs")),
|
|
track(2, "eng", "Dialogue"),
|
|
];
|
|
assert_eq!(full_instead(&tracks), Some(2));
|
|
}
|
|
|
|
#[test]
|
|
fn an_unnamed_track_gives_way_to_one_named_as_the_dialogue() {
|
|
let tracks = [playing(track(1, "en", "")), track(2, "en", "Dialogue")];
|
|
assert_eq!(full_instead(&tracks), Some(2));
|
|
}
|
|
|
|
#[test]
|
|
fn a_forced_track_gives_way_to_an_unnamed_one_and_the_default_comes_first() {
|
|
let mut forced = playing(track(1, "eng", "English"));
|
|
forced.forced = true;
|
|
let mut default = track(3, "eng", "");
|
|
default.default = true;
|
|
let tracks = [forced, track(2, "eng", ""), default];
|
|
assert_eq!(full_instead(&tracks), Some(3));
|
|
}
|
|
|
|
#[test]
|
|
fn equal_candidates_resolve_to_the_first_in_the_file() {
|
|
let tracks = [
|
|
playing(track(1, "eng", "Songs")),
|
|
track(2, "eng", ""),
|
|
track(3, "eng", ""),
|
|
];
|
|
assert_eq!(full_instead(&tracks), Some(2));
|
|
}
|
|
|
|
#[test]
|
|
fn a_track_in_another_language_is_never_switched_to() {
|
|
let tracks = [
|
|
playing(track(1, "eng", "Signs")),
|
|
track(2, "ger", "Dialogue"),
|
|
];
|
|
assert_eq!(full_instead(&tracks), None);
|
|
}
|
|
|
|
#[test]
|
|
fn two_letter_and_three_letter_tags_are_one_language() {
|
|
let tracks = [playing(track(1, "en", "OP/ED")), track(2, "eng", "")];
|
|
assert_eq!(full_instead(&tracks), Some(2));
|
|
}
|
|
|
|
#[test]
|
|
fn the_dialogue_playing_stays_and_subtitles_off_stay_off() {
|
|
let tracks = [
|
|
track(1, "eng", "Signs & Songs"),
|
|
playing(track(2, "eng", "Dialogue")),
|
|
];
|
|
assert_eq!(full_instead(&tracks), None);
|
|
let off = [
|
|
track(1, "eng", "Signs & Songs"),
|
|
track(2, "eng", "Dialogue"),
|
|
];
|
|
assert_eq!(full_instead(&off), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_full_word_outweighs_a_partial_one_in_the_same_name() {
|
|
assert_eq!(
|
|
track(1, "eng", "Full (with Signs)").coverage(),
|
|
Coverage::Full
|
|
);
|
|
assert_eq!(track(1, "eng", "Signs/Songs").coverage(), Coverage::Partial);
|
|
assert_eq!(track(1, "eng", "English").coverage(), Coverage::Unknown);
|
|
// A word inside another is not that word.
|
|
assert_eq!(
|
|
track(1, "eng", "Designer cut").coverage(),
|
|
Coverage::Unknown
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_partial_track_with_only_partial_company_stays() {
|
|
let tracks = [playing(track(1, "eng", "Signs")), track(2, "eng", "Songs")];
|
|
assert_eq!(full_instead(&tracks), None);
|
|
}
|
|
}
|