From d3c5f071fb144abd3a9d6816512ef27584323f8f Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 15 Sep 2026 13:54:07 +0200 Subject: [PATCH] feat(player,core): remember the audio/subtitle track by name across episodes A track picked on one episode now carries over to the next by its title as well as its language, so e.g. "Signs & Songs" vs "Full" subtitles in the same language stick. Catalog migration V8 adds the title columns; the Android bridge keeps a stored title only while its language is unchanged. --- crates/pstr-android/src/lib.rs | 22 +++++- crates/pstr-app/src/app.rs | 29 ++++--- crates/pstr-app/src/playback.rs | 46 ++++++++++- crates/pstr-core/src/catalog.rs | 103 ++++++++++++++++++++---- crates/pstr-player/src/lib.rs | 2 +- crates/pstr-player/src/tracks.rs | 131 +++++++++++++++++++++++++++++++ docs/DEVELOPMENT.md | 11 +++ 7 files changed, 312 insertions(+), 32 deletions(-) diff --git a/crates/pstr-android/src/lib.rs b/crates/pstr-android/src/lib.rs index 135e63d..fafc6ff 100644 --- a/crates/pstr-android/src/lib.rs +++ b/crates/pstr-android/src/lib.rs @@ -1127,13 +1127,27 @@ impl AndroidEngine { title_key: String, preferences: TrackPreferencesRecord, ) -> Result<(), BridgeError> { + let catalog = self.catalog.lock(); + let existing = catalog + .title_track_prefs(&title_key) + .map_err(BridgeError::from_display)? + .unwrap_or_default(); + let audio_language = normalized_language(preferences.audio_language); + let subtitle_language = normalized_language(preferences.subtitle_language); + // This bridge carries languages only. A track title stored beside one + // stays for as long as the language it was picked under does. let preferences = TitleTrackPrefs { - audio_language: normalized_language(preferences.audio_language), - subtitle_language: normalized_language(preferences.subtitle_language), + audio_title: existing + .audio_title + .filter(|_| existing.audio_language == audio_language), + subtitle_title: existing + .subtitle_title + .filter(|_| existing.subtitle_language == subtitle_language), + audio_language, + subtitle_language, subtitles: preferences.subtitles, }; - self.catalog - .lock() + catalog .set_title_track_prefs(&title_key, &preferences) .map_err(BridgeError::from_display) } diff --git a/crates/pstr-app/src/app.rs b/crates/pstr-app/src/app.rs index eab7e1b..58234b2 100644 --- a/crates/pstr-app/src/app.rs +++ b/crates/pstr-app/src/app.rs @@ -866,28 +866,35 @@ impl App { Action::SelectTrack { kind, id } => { self.send_player(crate::playback::Command::SelectTrack(kind, id)); - // Remember the *language*, not the track number: the next - // episode is a different file, where track 3 may be a - // commentary and Japanese may be track 2. - let language = id.and_then(|id| { - self.playback.as_ref().and_then(|playback| { - playback - .tracks_of(kind) - .find(|track| track.id == id) - .and_then(|track| track.language.clone()) + // Remember the language and the track's name, not its number: + // the next episode is a different file, where track 3 may be a + // commentary and Japanese may be track 2. The name is what + // tells "Signs & Songs" from "Full Subtitles" in one language. + let (language, title) = id + .and_then(|id| { + self.playback.as_ref().and_then(|playback| { + playback + .tracks_of(kind) + .find(|track| track.id == id) + .map(|track| (track.language.clone(), track.title.clone())) + }) }) - }); + .unwrap_or_default(); if let Some(playback) = &self.playback { let mut show = self .engine .title_track_prefs(&playback.target.title_key) .unwrap_or_default(); match kind { - pstr_player::TrackKind::Audio => show.audio_language = language, + pstr_player::TrackKind::Audio => { + show.audio_language = language; + show.audio_title = title; + } pstr_player::TrackKind::Subtitle => { show.subtitles = id.is_some(); if id.is_some() { show.subtitle_language = language; + show.subtitle_title = title; } } pstr_player::TrackKind::Video => {} diff --git a/crates/pstr-app/src/playback.rs b/crates/pstr-app/src/playback.rs index 76d0f0e..2437d3e 100644 --- a/crates/pstr-app/src/playback.rs +++ b/crates/pstr-app/src/playback.rs @@ -487,8 +487,12 @@ fn run( PlayerEvent::FileLoaded => { duration = player.duration(); // The first moment there is a track list to read: mpv has - // demuxed the file and made its own default selection, which - // is what the menus have to open showing. + // demuxed the file and made its own default selection. Put the + // title's own choice over it before the menus are told, so they + // open showing what is actually playing. + if let Some(show) = target.track_prefs.as_deref() { + restore_tracks(&player, show); + } emit_tracks(&engine, id, &player); // Only now is there a timeline to seek within. if !resumed && let Some(at) = target.resume_at { @@ -611,6 +615,44 @@ fn select_track( Ok(()) } +/// Put back the tracks this title was last watched with. +/// +/// `alang`/`slang` already steered mpv's pick by language. This is for what a +/// language cannot say — which of two English subtitle tracks, or an audio track +/// with a name and no tag — so only a remembered *title* acts here; a title with +/// nothing but a language keeps mpv's selection, which honours it already. +fn restore_tracks(player: &Player, show: &pstr_core::catalog::TitleTrackPrefs) { + let tracks = player.tracks(); + let wanted = [ + ( + TrackKind::Audio, + show.audio_language.as_deref(), + show.audio_title.as_deref(), + true, + ), + ( + TrackKind::Subtitle, + show.subtitle_language.as_deref(), + show.subtitle_title.as_deref(), + show.subtitles, + ), + ]; + for (kind, language, title, enabled) in wanted { + if !enabled || title.is_none() { + continue; + } + let Some(track) = pstr_player::pick_track(&tracks, kind, language, title) else { + continue; + }; + let playing = tracks + .iter() + .any(|candidate| candidate.kind == kind && candidate.id == track && candidate.selected); + if !playing && let Err(error) = player.select_track(kind, Some(track)) { + tracing::warn!("restore {} track {track}: {error}", kind.label()); + } + } +} + /// Tell the UI what the file contains and what of it is playing. fn emit_tracks(engine: &Engine, id: u64, player: &Player) { engine.emit(Event::Player { diff --git a/crates/pstr-core/src/catalog.rs b/crates/pstr-core/src/catalog.rs index fcfc5ad..63f9329 100644 --- a/crates/pstr-core/src/catalog.rs +++ b/crates/pstr-core/src/catalog.rs @@ -21,7 +21,7 @@ use crate::metadata::{EpisodeGuide, EpisodeMetadata, MetadataRecord, ProviderId, use crate::naming::{self, ParsedName}; /// Bump when adding a migration. -pub const SCHEMA_VERSION: i64 = 7; +pub const SCHEMA_VERSION: i64 = 8; const MIGRATION_V1: &str = r#" CREATE TABLE nodes ( @@ -146,6 +146,13 @@ CREATE TABLE title_track_prefs ( ); "#; +/// A language cannot tell two English subtitle tracks apart — "Signs & Songs" +/// and "Full Subtitles" — so the track's title is remembered beside it. +const MIGRATION_V8: &str = r#" +ALTER TABLE title_track_prefs ADD COLUMN audio_title TEXT; +ALTER TABLE title_track_prefs ADD COLUMN subtitle_title TEXT; +"#; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct OfflineFile { pub revision_id: String, @@ -157,6 +164,12 @@ pub struct TitleTrackPrefs { pub audio_language: Option, pub subtitle_language: Option, pub subtitles: bool, + /// The name the muxer gave the chosen audio track, when it had one. What + /// picks between two tracks in the same language on the next episode. + #[serde(default)] + pub audio_title: Option, + #[serde(default)] + pub subtitle_title: Option, } impl Default for TitleTrackPrefs { fn default() -> Self { @@ -164,10 +177,26 @@ impl Default for TitleTrackPrefs { audio_language: None, subtitle_language: None, subtitles: true, + audio_title: None, + subtitle_title: None, } } } +/// One `title_track_prefs` row, its columns starting at `first`. +fn title_track_prefs_row( + row: &rusqlite::Row<'_>, + first: usize, +) -> rusqlite::Result { + Ok(TitleTrackPrefs { + audio_language: row.get(first)?, + subtitle_language: row.get(first + 1)?, + subtitles: row.get::<_, i64>(first + 2)? != 0, + audio_title: row.get(first + 3)?, + subtitle_title: row.get(first + 4)?, + }) +} + /// What `season = -1` means in `episode_metadata`: no season at all. const ABSOLUTE_SEASON: i64 = -1; @@ -297,6 +326,12 @@ impl Catalog { tx.pragma_update(None, "user_version", 7)?; tx.commit()?; } + if version < 8 { + let tx = self.conn.transaction()?; + tx.execute_batch(MIGRATION_V8)?; + tx.pragma_update(None, "user_version", 8)?; + tx.commit()?; + } Ok(()) } @@ -510,27 +545,43 @@ impl Catalog { } pub fn title_track_prefs(&self, title_key: &str) -> Result> { - self.conn.query_row("SELECT audio_language, subtitle_language, subtitles FROM title_track_prefs WHERE title_key=?1", params![title_key], |r| Ok(TitleTrackPrefs { audio_language:r.get(0)?, subtitle_language:r.get(1)?, subtitles:r.get::<_,i64>(2)? != 0 })).optional().map_err(Into::into) + self.conn + .query_row( + "SELECT audio_language, subtitle_language, subtitles, audio_title, subtitle_title \ + FROM title_track_prefs WHERE title_key=?1", + params![title_key], + |row| title_track_prefs_row(row, 0), + ) + .optional() + .map_err(Into::into) } pub fn set_title_track_prefs(&self, title_key: &str, prefs: &TitleTrackPrefs) -> Result<()> { - self.conn.execute("INSERT INTO title_track_prefs (title_key,audio_language,subtitle_language,subtitles) VALUES (?1,?2,?3,?4) ON CONFLICT(title_key) DO UPDATE SET audio_language=excluded.audio_language,subtitle_language=excluded.subtitle_language,subtitles=excluded.subtitles", params![title_key,prefs.audio_language,prefs.subtitle_language,prefs.subtitles as i64])?; + self.conn.execute( + "INSERT INTO title_track_prefs \ + (title_key,audio_language,subtitle_language,subtitles,audio_title,subtitle_title) \ + VALUES (?1,?2,?3,?4,?5,?6) ON CONFLICT(title_key) DO UPDATE SET \ + audio_language=excluded.audio_language,subtitle_language=excluded.subtitle_language,\ + subtitles=excluded.subtitles,audio_title=excluded.audio_title,\ + subtitle_title=excluded.subtitle_title", + params![ + title_key, + prefs.audio_language, + prefs.subtitle_language, + prefs.subtitles as i64, + prefs.audio_title, + prefs.subtitle_title + ], + )?; Ok(()) } pub fn all_title_track_prefs(&self) -> Result> { let mut statement = self.conn.prepare( - "SELECT title_key, audio_language, subtitle_language, subtitles FROM title_track_prefs", + "SELECT title_key, audio_language, subtitle_language, subtitles, audio_title, \ + subtitle_title FROM title_track_prefs", )?; - let rows = statement.query_map([], |row| { - Ok(( - row.get(0)?, - TitleTrackPrefs { - audio_language: row.get(1)?, - subtitle_language: row.get(2)?, - subtitles: row.get::<_, i64>(3)? != 0, - }, - )) - })?; + let rows = + statement.query_map([], |row| Ok((row.get(0)?, title_track_prefs_row(row, 1)?)))?; Ok(rows.collect::, _>>()?) } @@ -1391,6 +1442,30 @@ mod tests { assert_eq!(version, SCHEMA_VERSION); } + #[test] + fn a_titles_track_choice_round_trips_with_the_track_names() { + let catalog = Catalog::in_memory().expect("open"); + assert_eq!(catalog.title_track_prefs("show").expect("read"), None); + + let prefs = TitleTrackPrefs { + audio_language: Some("jpn".into()), + subtitle_language: Some("eng".into()), + subtitles: true, + audio_title: None, + subtitle_title: Some("Full Subtitles".into()), + }; + catalog.set_title_track_prefs("show", &prefs).expect("write"); + assert_eq!(catalog.title_track_prefs("show").expect("read"), Some(prefs.clone())); + + let changed = TitleTrackPrefs { + subtitle_title: Some("Signs & Songs".into()), + ..prefs + }; + catalog.set_title_track_prefs("show", &changed).expect("overwrite"); + let all = catalog.all_title_track_prefs().expect("read all"); + assert_eq!(all.get("show"), Some(&changed)); + } + /// A newer schema is refused, not downgraded — reinterpreting it would /// silently mangle whatever the newer build recorded. #[test] diff --git a/crates/pstr-player/src/lib.rs b/crates/pstr-player/src/lib.rs index 6872778..24e112c 100644 --- a/crates/pstr-player/src/lib.rs +++ b/crates/pstr-player/src/lib.rs @@ -52,4 +52,4 @@ pub use player::{ }; pub use registry::{PROTOCOL, StreamHandle, StreamRegistry}; pub use render::VideoRenderer; -pub use tracks::{Track, TrackKind, language_name}; +pub use tracks::{Track, TrackKind, language_name, pick_track}; diff --git a/crates/pstr-player/src/tracks.rs b/crates/pstr-player/src/tracks.rs index 7ba80e0..911e004 100644 --- a/crates/pstr-player/src/tracks.rs +++ b/crates/pstr-player/src/tracks.rs @@ -107,6 +107,56 @@ pub(crate) fn read(mpv: &Mpv) -> Vec { .collect() } +/// The track of `kind` that matches a choice remembered from another file. +/// +/// A title outranks a language: two English subtitle tracks — "Signs & Songs" +/// and "Full Subtitles" — share a language and differ only by name, and the +/// name is what was picked. A title in the wrong language is not a match, since +/// "Full Subtitles" in German is not what someone reading English chose. Without +/// a title match, a track in the language wins, the container's default first. +/// `None` when nothing matches, which leaves the current selection standing. +pub fn pick_track( + tracks: &[Track], + kind: TrackKind, + language: Option<&str>, + title: Option<&str>, +) -> Option { + let in_language = |track: &Track| match (language, track.language.as_deref()) { + (None, _) => true, + (Some(wanted), Some(have)) => same_language(wanted, have), + (Some(_), None) => false, + }; + let of_kind = || tracks.iter().filter(move |track| track.kind == kind); + + if let Some(title) = title + && let Some(track) = of_kind().find(|track| { + in_language(track) + && track + .title + .as_deref() + .is_some_and(|have| have.eq_ignore_ascii_case(title)) + }) + { + return Some(track.id); + } + + language?; + let mut matching = of_kind().filter(|track| in_language(track)); + let first = matching.next()?; + Some( + std::iter::once(first) + .chain(matching) + .find(|track| track.default) + .unwrap_or(first) + .id, + ) +} + +/// Whether two container tags name the same language: "en" and "eng" do. +fn same_language(a: &str, b: &str) -> bool { + a.eq_ignore_ascii_case(b) || language_name(a) == language_name(b) +} + fn read_one(mpv: &Mpv, index: i64) -> Option { let kind = TrackKind::from_mpv(&text(mpv, index, "type")?)?; let id: i64 = mpv @@ -242,6 +292,87 @@ mod tests { assert_eq!(language_name("mis"), "mis"); } + fn named(kind: TrackKind, id: i64, language: Option<&str>, title: Option<&str>) -> Track { + Track { + language: language.map(Into::into), + title: title.map(Into::into), + ..track(kind, id) + } + } + + #[test] + fn a_remembered_title_picks_between_tracks_of_one_language() { + let tracks = [ + named(TrackKind::Subtitle, 1, Some("eng"), Some("Signs & Songs")), + named(TrackKind::Subtitle, 2, Some("eng"), Some("Full Subtitles")), + ]; + let mut signs = tracks[0].clone(); + signs.default = true; + let tracks = [signs, tracks[1].clone()]; + assert_eq!( + pick_track( + &tracks, + TrackKind::Subtitle, + Some("eng"), + Some("full subtitles") + ), + Some(2) + ); + } + + #[test] + fn a_title_in_another_language_is_not_the_one_that_was_chosen() { + let tracks = [ + named(TrackKind::Subtitle, 1, Some("ger"), Some("Full Subtitles")), + named(TrackKind::Subtitle, 2, Some("en"), Some("Dialogue")), + ]; + // Falls back to the language, and "en" is the same language as "eng". + assert_eq!( + pick_track( + &tracks, + TrackKind::Subtitle, + Some("eng"), + Some("Full Subtitles") + ), + Some(2) + ); + } + + #[test] + fn an_untagged_track_is_found_by_its_title_alone() { + let tracks = [ + named(TrackKind::Audio, 1, None, Some("Stereo")), + named(TrackKind::Audio, 2, None, Some("Commentary")), + ]; + assert_eq!( + pick_track(&tracks, TrackKind::Audio, None, Some("Commentary")), + Some(2) + ); + } + + #[test] + fn a_language_match_prefers_the_default_track_and_nothing_matching_is_none() { + let mut second = named(TrackKind::Audio, 2, Some("jpn"), None); + second.default = true; + let tracks = [ + named(TrackKind::Audio, 1, Some("jpn"), Some("Commentary")), + second, + named(TrackKind::Subtitle, 3, Some("jpn"), None), + ]; + assert_eq!( + pick_track(&tracks, TrackKind::Audio, Some("ja"), None), + Some(2) + ); + assert_eq!( + pick_track(&tracks, TrackKind::Audio, Some("fre"), None), + None + ); + assert_eq!( + pick_track(&tracks, TrackKind::Audio, None, Some("Stereo")), + None + ); + } + #[test] fn only_the_kinds_the_player_can_select_are_tracks() { assert_eq!(TrackKind::from_mpv("audio"), Some(TrackKind::Audio)); diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index fe7fb1d..4db3240 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -55,6 +55,17 @@ chosen by hand sets `alang`/`slang` for every file loaded afterwards — the poi being that picking Japanese audio once picks it for the rest of the season. A dragged volume slider writes to disk on release, not per frame. +Done: **the track choice follows the title, by name as well as language.** A +track picked by hand is stored per title in the catalog (`title_track_prefs`, +schema v8 added `audio_title`/`subtitle_title`) as its language *and* the name +the muxer gave it. On `FileLoaded`, `playback::restore_tracks` re-selects the +track whose name matches (`pstr_player::pick_track`) — which is what tells +"Signs & Songs" from "Full Subtitles" when both are English, and finds an +untagged track at all; `alang`/`slang` could only ever say the language. A name +in the wrong language is not a match, and a title with only a language stored +keeps mpv's own pick. Android stores languages only and keeps a desktop-stored +name while its language is unchanged. + Done: the player page's overlay — a full-width seek bar with the file's chapters marked on it and the current chapter named beside the clock, a transport cluster (previous / −10s / play / +30s / next), and volume plus the audio, subtitle and