diff --git a/crates/pstr-app/src/engine.rs b/crates/pstr-app/src/engine.rs index 5e3a98d..e036f22 100644 --- a/crates/pstr-app/src/engine.rs +++ b/crates/pstr-app/src/engine.rs @@ -28,7 +28,7 @@ use parking_lot::Mutex; use pstr_core::account::{DriveEntry, DrivePlace, PendingSignIn, SignIn}; use pstr_core::appearance::Appearance; use pstr_core::catalog::{ - Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows, + Catalog, CatalogNode, OfflineFile, TitleTrackPrefs, WatchState, build_rows, build_sidecars, }; use pstr_core::config::AppDirs; use pstr_core::library::{Library, Title, TitleKind}; @@ -1513,6 +1513,7 @@ impl Engine { } }; let rows = build_rows(&share_id, &nodes); + let sidecars = build_sidecars(&share_id, &nodes); let files = rows.len(); // A writer opened before the crawl may be producing the revision @@ -1574,7 +1575,7 @@ impl Engine { for (link_id, _) in &stale { catalog.remove_offline_file(&share_id, link_id)?; } - Ok(()) + catalog.replace_sidecars(&share_id, &sidecars) }) }; if let Err(error) = stored { @@ -1718,6 +1719,55 @@ impl Engine { }); } + /// Open the subtitle files paired with `target`, for the player to add + /// once mpv has the video. + /// + /// Through the session already open: a local copy played without one goes + /// without its subtitles rather than waiting on a handshake. Each is + /// warmed — most are one block — so mpv reads it from the cache. One that + /// fails is left out quietly; the video plays either way. + pub async fn open_sidecars( + &self, + target: &PlaybackTarget, + ) -> Vec<(pstr_core::catalog::Sidecar, VideoStream)> { + let sidecars = self + .catalog + .lock() + .sidecars(&target.share_id, &target.link_id) + .unwrap_or_default(); + if sidecars.is_empty() { + return Vec::new(); + } + let Some((library, source)) = self.opened() else { + return Vec::new(); + }; + let opening = sidecars.into_iter().map(|sidecar| { + let library = Arc::clone(&library); + let source = source.clone(); + async move { + let uid = node_uid(&sidecar.volume_id, &sidecar.link_id); + match library + .reauthenticating(&sidecar.share_id, || source.open(&sidecar.share_id, &uid)) + .await + { + Ok(stream) => { + stream.warm_edges(); + Some((sidecar, stream)) + } + Err(error) => { + tracing::warn!(%error, "open subtitle {}", sidecar.name); + None + } + } + } + }); + futures::future::join_all(opening) + .await + .into_iter() + .flatten() + .collect() + } + /// How far into the file `target` resumes, as a fraction of its running /// time — known from the last time it played. fn resume_fraction(&self, target: &PlaybackTarget) -> Option { diff --git a/crates/pstr-app/src/playback.rs b/crates/pstr-app/src/playback.rs index 101e19b..c80c504 100644 --- a/crates/pstr-app/src/playback.rs +++ b/crates/pstr-app/src/playback.rs @@ -35,8 +35,8 @@ use pstr_core::catalog::CatalogNode; use pstr_core::library::{Episode, Title}; use pstr_core::prefs::PlaybackPrefs; use pstr_player::{ - Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, Track, TrackKind, - VideoOutput, + Chapter, ChapterRole, EndReason, Player, PlayerConfig, PlayerEvent, StreamHandle, Track, + TrackKind, VideoOutput, }; use pstr_stream::VideoStream; @@ -530,6 +530,15 @@ fn run( } }; + // Opened while mpv opens the video, and added once it has: an external + // track belongs to the file loaded at the time. + let mut sidecars = Some(engine.runtime().spawn({ + let engine = engine.clone(); + let target = target.clone(); + async move { engine.open_sidecars(&target).await } + })); + let mut subtitles: Vec = Vec::new(); + let mut position = target.resume_at.unwrap_or(0.0); let mut duration: Option = None; let mut watched = false; @@ -558,6 +567,10 @@ fn run( match &event { PlayerEvent::FileLoaded => { duration = player.duration(); + if let Some(opening) = sidecars.take() { + let opened = engine.runtime().block_on(opening).unwrap_or_default(); + subtitles = add_sidecars(&engine, &target, &player, opened); + } // The first moment there is a track list to read: mpv has // demuxed the file and made its own default selection. Put the // title's own choice over it before the menus are told, so they @@ -607,6 +620,7 @@ fn run( // the other `Arc`, and mpv must be destroyed there, on the thread whose // OpenGL context its render context was built against. drop(handle); + drop(subtitles); drop(player); engine.release( @@ -721,6 +735,47 @@ fn restore_tracks(player: &Player, show: &pstr_core::catalog::TitleTrackPrefs) { } } +/// Hand mpv the subtitle files paired with the video it has just loaded. +/// +/// One is turned on only when subtitles are on and the video has none of its +/// own playing — a file's own track was chosen by name and language already, +/// and an external one is the fallback, not an override. +fn add_sidecars( + engine: &Engine, + target: &PlaybackTarget, + player: &Player, + opened: Vec<(pstr_core::catalog::Sidecar, VideoStream)>, +) -> Vec { + let config = from_prefs(&engine.playback_prefs(), target.track_prefs.as_deref()); + let playing = player + .tracks() + .iter() + .any(|track| track.kind == TrackKind::Subtitle && track.selected); + let (sidecars, streams): (Vec<_>, Vec<_>) = opened.into_iter().unzip(); + let select = (config.subtitles && !playing) + .then(|| { + pstr_core::catalog::preferred_sidecar(&sidecars, config.subtitle_language.as_deref()) + }) + .flatten(); + sidecars + .iter() + .zip(streams) + .enumerate() + .filter_map(|(index, (sidecar, stream))| { + let title = sidecar.title.as_deref().unwrap_or("External"); + player + .add_subtitle( + stream, + title, + sidecar.language.as_deref(), + select == Some(index), + ) + .inspect_err(|error| tracing::warn!(%error, "add subtitle {}", sidecar.name)) + .ok() + }) + .collect() +} + /// 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-cli/src/main.rs b/crates/pstr-cli/src/main.rs index 0bd8b00..99b7dd9 100644 --- a/crates/pstr-cli/src/main.rs +++ b/crates/pstr-cli/src/main.rs @@ -9,7 +9,7 @@ use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use proton_sdk::ids::{LinkId, NodeUid, VolumeId}; use pstr_core::account::{PlaceKind, SignIn}; -use pstr_core::catalog::{Catalog, build_rows}; +use pstr_core::catalog::{Catalog, build_rows, build_sidecars}; use pstr_core::config::AppDirs; use pstr_core::shares::AccountFolder; use pstr_core::sync::WatchSync; @@ -425,6 +425,9 @@ async fn run(runtime: std::sync::Arc) -> Result<()> { catalog .replace_share(&share_id, &rows) + .and_then(|()| { + catalog.replace_sidecars(&share_id, &build_sidecars(&share_id, &nodes)) + }) .with_context(|| format!("store {share_id}"))?; println!( diff --git a/crates/pstr-core/src/catalog.rs b/crates/pstr-core/src/catalog.rs index 097521a..774d066 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 = 11; +pub const SCHEMA_VERSION: i64 = 12; const MIGRATION_V1: &str = r#" CREATE TABLE nodes ( @@ -225,6 +225,23 @@ CREATE TABLE share_crawls ( ); "#; +/// Subtitle files beside a video, which the crawl used to drop with the +/// `.nfo` files. Rebuilt by every crawl, like `nodes`; a subtitle belongs to +/// one video, the one it was paired with by name. +const MIGRATION_V12: &str = r#" +CREATE TABLE sidecars ( + share_id TEXT NOT NULL, + link_id TEXT NOT NULL, + volume_id TEXT NOT NULL, + video_link_id TEXT NOT NULL, + name TEXT NOT NULL, + language TEXT, + title TEXT, + PRIMARY KEY (share_id, link_id) +); +CREATE INDEX sidecars_by_video ON sidecars (share_id, video_link_id); +"#; + /// 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. @@ -329,6 +346,21 @@ pub struct CatalogNode { pub parsed: ParsedName, } +/// A subtitle file paired with the video beside it. See [`build_sidecars`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Sidecar { + pub share_id: String, + pub link_id: String, + pub volume_id: String, + /// The video this subtitle is for. + pub video_link_id: String, + pub name: String, + /// The [`crate::language::LANGUAGES`] tag its name states, if any. + pub language: Option, + /// What else its name says about it — "forced", "SDH" — for the picker. + pub title: Option, +} + /// Where playback left off. #[derive(Debug, Clone, Copy, PartialEq)] pub struct WatchState { @@ -459,6 +491,12 @@ impl Catalog { tx.pragma_update(None, "user_version", 11)?; tx.commit()?; } + if version < 12 { + let tx = self.conn.transaction()?; + tx.execute_batch(MIGRATION_V12)?; + tx.pragma_update(None, "user_version", 12)?; + tx.commit()?; + } Ok(()) } @@ -547,6 +585,59 @@ impl Catalog { Ok(()) } + /// Replace one share's subtitle files with `sidecars`. + /// + /// Apart from [`Self::replace_share`] rather than inside it: a subtitle + /// left over from the crawl before is one that fails to open, quietly, + /// which is no reason to make every caller of that pass these along. + pub fn replace_sidecars(&mut self, share_id: &str, sidecars: &[Sidecar]) -> Result<()> { + let tx = self.conn.transaction()?; + tx.execute( + "DELETE FROM sidecars WHERE share_id = ?1", + params![share_id], + )?; + { + let mut insert = tx.prepare( + "INSERT OR REPLACE INTO sidecars ( + share_id, link_id, volume_id, video_link_id, name, language, title + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + )?; + for sidecar in sidecars { + insert.execute(params![ + share_id, + sidecar.link_id, + sidecar.volume_id, + sidecar.video_link_id, + sidecar.name, + sidecar.language, + sidecar.title, + ])?; + } + } + tx.commit()?; + Ok(()) + } + + /// The subtitle files paired with one video, in name order. + pub fn sidecars(&self, share_id: &str, video_link_id: &str) -> Result> { + let mut select = self.conn.prepare_cached( + "SELECT share_id, link_id, volume_id, video_link_id, name, language, title + FROM sidecars WHERE share_id = ?1 AND video_link_id = ?2 ORDER BY name", + )?; + let rows = select.query_map(params![share_id, video_link_id], |row| { + Ok(Sidecar { + share_id: row.get(0)?, + link_id: row.get(1)?, + volume_id: row.get(2)?, + video_link_id: row.get(3)?, + name: row.get(4)?, + language: row.get(5)?, + title: row.get(6)?, + }) + })?; + Ok(rows.collect::>()?) + } + /// Record that a crawl of `share_id` failed, keeping what the last /// finished one stored. pub fn record_crawl_failure(&self, share_id: &str, error: &str) -> Result<()> { @@ -670,6 +761,10 @@ impl Catalog { params![share_id], )?; tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?; + tx.execute( + "DELETE FROM sidecars WHERE share_id = ?1", + params![share_id], + )?; tx.execute( "DELETE FROM offline_files WHERE share_id = ?1", params![share_id], @@ -1509,6 +1604,182 @@ pub fn build_rows(share_id: &str, nodes: &[proton_drive_rs::Node]) -> Vec Vec { + use std::collections::HashMap; + + let by_link: HashMap<&str, &proton_drive_rs::Node> = nodes + .iter() + .map(|node| (node.uid.link_id.as_str(), node)) + .collect(); + let parent_of = |node: &proton_drive_rs::Node| { + node.parent_uid + .as_ref() + .and_then(|uid| by_link.get(uid.link_id.as_str()).copied()) + }; + let mut videos: HashMap<&str, Vec<(&proton_drive_rs::Node, &str)>> = HashMap::new(); + for node in nodes { + if !node.is_folder() + && naming::is_video_file(&node.name) + && let Some(parent) = &node.parent_uid + { + videos + .entry(parent.link_id.as_str()) + .or_default() + .push((node, stem(&node.name))); + } + } + + let mut sidecars: Vec = nodes + .iter() + .filter(|node| !node.is_folder() && naming::is_subtitle_file(&node.name)) + .filter_map(|node| { + let own = stem(&node.name); + let parent = parent_of(node)?; + let (video, rest) = if is_subtitles_folder(&parent.name) { + // `Subs/S01E01.en.srt`, for the videos beside `Subs`. + beside( + videos.get(parent.parent_uid.as_ref()?.link_id.as_str())?, + own, + )? + } else if let Some(holder) = parent_of(parent) + && is_subtitles_folder(&holder.name) + { + // `Subs/