feat(core,app,cli): pair subtitle files with their video and play them

A crawl now keeps .srt/.ass/.ssa/.vtt files and pairs each with a video by
name: the video's name followed by a dot or nothing, the same inside a
Subs folder, the Subs/<video>/ layout, and failing those the only video in
the folder. The words past the video's name give the language and the
picker's title. They go in a new sidecars table (schema v12), rebuilt by
every crawl.

The desktop opens them while mpv opens the video and adds them with
sub-add through the stream registry once the file has loaded. One in the
preferred language is turned on when subtitles are on and the file has
none of its own playing; the rest wait in the subtitle menu.
This commit is contained in:
nvrl committed 2026-10-13 21:11:20 +02:00
1 parent 51454b5a6e
commit f0fe90f0fe
6 files changed
+564 -6

No files matched your search

+52 -2
View File
@@ -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<f64> {