Init
Build and Publish / Build and Publish Docker Image (push) Successful in 10m8s

This commit is contained in:
Ultradesu
2026-08-05 12:32:52 +01:00
commit eb0d4fb1c5
46 changed files with 5623 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
use std::sync::Arc;
use axum::{
Router,
routing::{delete, get, post},
};
use tokio::sync::Semaphore;
use crate::{config::Config, diagnostics, media, media::dash::DashManager, web};
pub struct AppState {
pub config: Config,
pub dash: DashManager,
pub preview_jobs: Semaphore,
}
impl AppState {
pub fn new(config: Config) -> anyhow::Result<Self> {
Ok(Self {
config,
dash: DashManager::new()?,
preview_jobs: Semaphore::new(2),
})
}
}
pub fn router(state: Arc<AppState>) -> Router {
Router::new()
.route("/", get(web::index))
.route("/alpine.min.js", get(web::alpine))
.route("/dash.all.min.js", get(web::dash))
.route("/dash.all.min.js.map", get(web::dash_source_map))
.route("/controlbar.min.js", get(web::controlbar_javascript))
.route("/controlbar.css", get(web::controlbar_styles))
.route("/bootstrap-icons.min.css", get(web::bootstrap_icon_styles))
.route(
"/fonts/bootstrap-icons.woff2",
get(web::bootstrap_icon_font),
)
.route("/js/api.js", get(web::api_javascript))
.route("/js/library.js", get(web::library_javascript))
.route("/js/location.js", get(web::location_javascript))
.route("/js/preferences.js", get(web::preferences_javascript))
.route("/js/player.js", get(web::player_javascript))
.route("/styles.css", get(web::styles))
.route("/api/info", get(diagnostics::info))
.route("/api/browse", get(media::browse::browse))
.route("/api/probe", get(media::probe::probe))
.route("/api/preview", get(media::preview::sprite))
.route("/api/stream", get(media::stream::stream))
.route("/api/subtitles", get(media::subtitles::list))
.route("/api/subtitles/{index}", get(media::subtitles::extract))
.route(
"/api/subtitles/external/{id}",
get(media::subtitles::extract_external),
)
.route("/api/dash", post(media::dash::start))
.route("/api/dash/{session}", delete(media::dash::stop))
.route("/api/dash/{session}/{asset}", get(media::dash::asset))
.with_state(state)
}
+34
View File
@@ -0,0 +1,34 @@
use std::{net::SocketAddr, path::PathBuf};
use anyhow::{Context, ensure};
use clap::Parser;
#[derive(Debug, Clone, Parser)]
#[command(version, about = "A small LAN media browser for smart TVs")]
pub struct Config {
/// Directory exposed as the media library.
pub media_root: PathBuf,
/// HTTP listen address.
#[arg(long, default_value = "0.0.0.0:8080")]
pub bind: SocketAddr,
/// ffprobe executable.
#[arg(long, default_value = "ffprobe")]
pub ffprobe: PathBuf,
/// ffmpeg executable used for DASH packaging and transcoding.
#[arg(long, default_value = "ffmpeg")]
pub ffmpeg: PathBuf,
}
impl Config {
pub fn prepare(mut self) -> anyhow::Result<Self> {
ensure!(self.media_root.is_dir(), "media root is not a directory");
self.media_root = self
.media_root
.canonicalize()
.context("failed to resolve media root")?;
Ok(self)
}
}
+80
View File
@@ -0,0 +1,80 @@
use std::sync::Arc;
use axum::{Json, extract::State};
use serde::Serialize;
use tokio::process::Command;
use crate::app::AppState;
#[derive(Serialize)]
pub struct Diagnostics {
application: &'static str,
version: &'static str,
build: &'static str,
platform: String,
ffmpeg: ToolInfo,
ffprobe: ToolInfo,
media_root: String,
bind: String,
}
#[derive(Serialize)]
struct ToolInfo {
path: String,
version: String,
}
pub async fn info(State(state): State<Arc<AppState>>) -> Json<Diagnostics> {
let (ffmpeg, ffprobe) = tokio::join!(
tool_info(&state.config.ffmpeg),
tool_info(&state.config.ffprobe)
);
Json(Diagnostics {
application: env!("CARGO_PKG_NAME"),
version: env!("CARGO_PKG_VERSION"),
build: if cfg!(debug_assertions) {
"debug"
} else {
"release"
},
platform: format!("{} / {}", std::env::consts::OS, std::env::consts::ARCH),
ffmpeg,
ffprobe,
media_root: state.config.media_root.display().to_string(),
bind: state.config.bind.to_string(),
})
}
async fn tool_info(path: &std::path::Path) -> ToolInfo {
let version = match Command::new(path).arg("-version").output().await {
Ok(output) if output.status.success() => first_line(&output.stdout),
Ok(output) => format!("Unavailable ({})", output.status),
Err(error) => format!("Unavailable ({error})"),
};
ToolInfo {
path: path.display().to_string(),
version,
}
}
fn first_line(output: &[u8]) -> String {
String::from_utf8_lossy(output)
.lines()
.next()
.unwrap_or("Unknown")
.trim()
.to_owned()
}
#[cfg(test)]
mod tests {
use super::first_line;
#[test]
fn keeps_only_the_tool_version_line() {
assert_eq!(
first_line(b"ffmpeg version 7.1\nconfiguration: test\n"),
"ffmpeg version 7.1"
);
}
}
+42
View File
@@ -0,0 +1,42 @@
use axum::{
Json,
http::StatusCode,
response::{IntoResponse, Response},
};
use serde_json::json;
#[derive(Debug)]
pub struct AppError {
status: StatusCode,
message: String,
}
impl AppError {
pub fn bad_request(message: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
message: message.into(),
}
}
pub fn not_found(message: impl Into<String>) -> Self {
Self {
status: StatusCode::NOT_FOUND,
message: message.into(),
}
}
pub fn internal(error: impl std::fmt::Display) -> Self {
tracing::error!(%error, "request failed");
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
message: "internal server error".into(),
}
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(self.status, Json(json!({ "error": self.message }))).into_response()
}
}
+29
View File
@@ -0,0 +1,29 @@
mod app;
mod config;
mod diagnostics;
mod error;
mod media;
mod web;
use std::sync::Arc;
use clap::Parser;
use config::Config;
use tracing::info;
use crate::app::AppState;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.init();
let config = Config::parse().prepare()?;
let listener = tokio::net::TcpListener::bind(config.bind).await?;
info!(address = %config.bind, media_root = %config.media_root.display(), "lan-play started");
let state = Arc::new(AppState::new(config)?);
axum::serve(listener, app::router(state)).await?;
Ok(())
}
+78
View File
@@ -0,0 +1,78 @@
use std::{path::Path, sync::Arc};
use axum::{
Json,
extract::{Query, State},
};
use serde::Serialize;
use tokio::fs;
use crate::{
app::AppState,
error::AppError,
media::{PathQuery, path},
};
const MEDIA_EXTENSIONS: &[&str] = &["mkv", "mp4", "m4v", "avi", "mov", "webm"];
#[derive(Serialize)]
pub struct Entry {
name: String,
path: String,
kind: &'static str,
}
#[derive(Serialize)]
pub struct Directory {
path: String,
parent: Option<String>,
entries: Vec<Entry>,
}
pub async fn browse(
State(state): State<Arc<AppState>>,
Query(query): Query<PathQuery>,
) -> Result<Json<Directory>, AppError> {
let config = &state.config;
let directory = path::resolve(config, &query.path)?;
if !directory.is_dir() {
return Err(AppError::bad_request("path is not a directory"));
}
let mut reader = fs::read_dir(&directory).await.map_err(AppError::internal)?;
let mut entries = Vec::new();
while let Some(item) = reader.next_entry().await.map_err(AppError::internal)? {
let file_type = item.file_type().await.map_err(AppError::internal)?;
let item_path = item.path();
let kind = if file_type.is_dir() {
"directory"
} else if file_type.is_file() && is_media(&item_path) {
"media"
} else {
continue;
};
entries.push(Entry {
name: item.file_name().to_string_lossy().into_owned(),
path: path::relative(config, &item_path)?,
kind,
});
}
entries.sort_by_key(|entry| (entry.kind != "directory", entry.name.to_lowercase()));
let current = path::relative(config, &directory)?;
let parent = directory
.parent()
.filter(|parent| parent.starts_with(&config.media_root))
.and_then(|parent| path::relative(config, parent).ok());
Ok(Json(Directory {
path: current,
parent,
entries,
}))
}
fn is_media(path: &Path) -> bool {
path.extension()
.and_then(|value| value.to_str())
.is_some_and(|value| MEDIA_EXTENSIONS.contains(&value.to_ascii_lowercase().as_str()))
}
+195
View File
@@ -0,0 +1,195 @@
mod process;
mod session;
use std::{collections::HashMap, path::PathBuf, sync::Arc};
use axum::{
Json,
body::Body,
extract::{Path, Query, State},
http::{StatusCode, header},
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use tempfile::TempDir;
use tokio::{fs, sync::Mutex};
use crate::{
app::AppState,
error::AppError,
media::{path, playback},
};
use self::session::DashSession;
pub struct DashManager {
_root: TempDir,
root_path: PathBuf,
sessions: Mutex<HashMap<String, Arc<DashSession>>>,
}
#[derive(Serialize)]
pub struct StartResponse {
session_id: String,
manifest_url: String,
duration_seconds: Option<f64>,
start_seconds: f64,
}
#[derive(Deserialize)]
pub struct StartQuery {
path: String,
#[serde(default)]
start: f64,
}
impl DashManager {
pub fn new() -> anyhow::Result<Self> {
let root = tempfile::Builder::new()
.prefix("lan-play-dash-")
.tempdir()?;
Ok(Self {
root_path: root.path().to_owned(),
_root: root,
sessions: Mutex::new(HashMap::new()),
})
}
}
pub async fn start(
State(state): State<Arc<AppState>>,
Query(query): Query<StartQuery>,
) -> Result<Json<StartResponse>, AppError> {
if !query.start.is_finite() || query.start < 0.0 {
return Err(AppError::bad_request("invalid playback start position"));
}
let media = path::resolve(&state.config, &query.path)?;
if !media.is_file() {
return Err(AppError::bad_request("path is not a media file"));
}
let plan = playback::plan(&state.config, &media).await?;
if plan
.duration_seconds
.is_some_and(|duration| query.start >= duration)
{
return Err(AppError::bad_request(
"playback start is beyond the file duration",
));
}
let duration_seconds = plan.duration_seconds;
let session = DashSession::start(
&state.config.ffmpeg,
&state.dash.root_path,
&media,
&plan,
query.start,
)
.await?;
let session_id = session.id().to_owned();
state
.dash
.sessions
.lock()
.await
.insert(session_id.clone(), Arc::new(session));
Ok(Json(StartResponse {
manifest_url: format!("/api/dash/{session_id}/manifest.mpd"),
session_id,
duration_seconds,
start_seconds: query.start,
}))
}
pub async fn asset(
State(state): State<Arc<AppState>>,
Path((session_id, asset)): Path<(String, String)>,
) -> Result<Response, AppError> {
if !session::valid_asset_name(&asset) {
return Err(AppError::bad_request("invalid DASH asset name"));
}
let session = state
.dash
.sessions
.lock()
.await
.get(&session_id)
.cloned()
.ok_or_else(|| AppError::not_found("DASH session not found"))?;
let file = session.asset_path(&asset);
let bytes = fs::read(file)
.await
.map_err(|_| AppError::not_found("DASH asset not ready"))?;
let content_type = asset_content_type(&asset);
Ok((
StatusCode::OK,
[
(header::CONTENT_TYPE, content_type),
(header::CACHE_CONTROL, "no-store"),
],
Body::from(bytes),
)
.into_response())
}
fn asset_content_type(asset: &str) -> &'static str {
if asset.ends_with(".mpd") {
"application/dash+xml"
} else if dash_stream_index(asset).is_some_and(|stream| stream > 0) {
"audio/iso.segment"
} else if asset.ends_with(".m4s") || asset.ends_with(".mp4") {
"video/iso.segment"
} else {
"application/octet-stream"
}
}
fn dash_stream_index(asset: &str) -> Option<usize> {
let start = asset.find("stream")? + "stream".len();
let digits: String = asset[start..]
.chars()
.take_while(|character| character.is_ascii_digit())
.collect();
digits.parse().ok()
}
pub async fn stop(
State(state): State<Arc<AppState>>,
Path(session_id): Path<String>,
) -> Result<StatusCode, AppError> {
let session = state
.dash
.sessions
.lock()
.await
.remove(&session_id)
.ok_or_else(|| AppError::not_found("DASH session not found"))?;
session.stop().await;
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
use super::asset_content_type;
#[test]
fn uses_media_specific_content_types() {
assert_eq!(asset_content_type("manifest.mpd"), "application/dash+xml");
assert_eq!(asset_content_type("init-stream0.m4s"), "video/iso.segment");
assert_eq!(
asset_content_type("chunk-stream0-00001.m4s"),
"video/iso.segment"
);
assert_eq!(asset_content_type("init-stream1.m4s"), "audio/iso.segment");
assert_eq!(
asset_content_type("chunk-stream1-00001.m4s"),
"audio/iso.segment"
);
assert_eq!(asset_content_type("init-stream2.m4s"), "audio/iso.segment");
assert_eq!(
asset_content_type("chunk-stream7-00001.m4s"),
"audio/iso.segment"
);
}
}
+65
View File
@@ -0,0 +1,65 @@
use std::{fs::OpenOptions, path::Path, process::Stdio};
use tokio::process::{Child, Command};
use crate::error::AppError;
use crate::media::playback::PlaybackPlan;
const SEEK_PREROLL_SECONDS: f64 = 3.0;
pub fn spawn(
ffmpeg: &Path,
media: &Path,
directory: &Path,
plan: &PlaybackPlan,
start_seconds: f64,
) -> Result<Child, AppError> {
let log = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(directory.join("ffmpeg.log"))
.map_err(AppError::internal)?;
let mut command = Command::new(ffmpeg);
command.current_dir(directory).args(["-v", "warning"]);
let (input_seek, output_trim) = seek_offsets(start_seconds);
if input_seek > 0.0 {
command.args(["-ss", &format!("{input_seek:.3}")]);
}
command
// Build a small lead over playback so transient encoder, disk or network
// jitter does not leave the player waiting at the live edge.
.args(["-readrate", "1.15"]);
if output_trim > 0.0 {
command.args(["-readrate_initial_burst", &format!("{output_trim:.3}")]);
}
command.args(["-i"]).arg(media);
if output_trim > 0.0 {
command.args(["-ss", &format!("{output_trim:.3}")]);
}
command
.args(plan.ffmpeg_output_args())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::from(log))
.kill_on_drop(true)
.spawn()
.map_err(AppError::internal)
}
fn seek_offsets(start_seconds: f64) -> (f64, f64) {
let input_seek = (start_seconds - SEEK_PREROLL_SECONDS).max(0.0);
(input_seek, start_seconds - input_seek)
}
#[cfg(test)]
mod tests {
use super::seek_offsets;
#[test]
fn seek_keeps_a_short_accurate_preroll() {
assert_eq!(seek_offsets(0.0), (0.0, 0.0));
assert_eq!(seek_offsets(2.0), (0.0, 2.0));
assert_eq!(seek_offsets(100.0), (97.0, 3.0));
}
}
+161
View File
@@ -0,0 +1,161 @@
use std::{
path::{Component, Path, PathBuf},
time::Duration,
};
use tokio::{fs, process::Child, sync::Mutex, time};
use uuid::Uuid;
use crate::{error::AppError, media::playback::PlaybackPlan};
use super::process;
pub struct DashSession {
id: String,
directory: PathBuf,
process: Mutex<Option<Child>>,
}
impl DashSession {
pub async fn start(
ffmpeg: &Path,
root: &Path,
media: &Path,
plan: &PlaybackPlan,
start_seconds: f64,
) -> Result<Self, AppError> {
let id = Uuid::new_v4().simple().to_string();
let directory = root.join(&id);
fs::create_dir(&directory)
.await
.map_err(AppError::internal)?;
let mut child = match process::spawn(ffmpeg, media, &directory, plan, start_seconds) {
Ok(child) => child,
Err(error) => {
fs::remove_dir_all(&directory).await.ok();
return Err(error);
}
};
wait_until_ready(&mut child, &directory, plan.audio_count()).await?;
Ok(Self {
id,
directory,
process: Mutex::new(Some(child)),
})
}
pub fn id(&self) -> &str {
&self.id
}
pub fn asset_path(&self, name: &str) -> PathBuf {
self.directory.join(name)
}
pub async fn stop(&self) {
if let Some(mut child) = self.process.lock().await.take() {
if child.try_wait().ok().flatten().is_none() {
child.start_kill().ok();
}
child.wait().await.ok();
}
fs::remove_dir_all(&self.directory).await.ok();
}
}
async fn wait_until_ready(
child: &mut Child,
directory: &Path,
audio_count: usize,
) -> Result<(), AppError> {
for _ in 0..1800 {
if ready(directory, audio_count).await {
return Ok(());
}
if child.try_wait().map_err(AppError::internal)?.is_some() {
return Err(startup_error(directory, "ffmpeg stopped before DASH became ready").await);
}
time::sleep(Duration::from_millis(50)).await;
}
child.start_kill().ok();
child.wait().await.ok();
Err(startup_error(directory, "timed out waiting for live DASH segments").await)
}
async fn ready(directory: &Path, audio_count: usize) -> bool {
let manifest = match fs::read_to_string(directory.join("manifest.mpd")).await {
Ok(value) => value,
Err(_) => return false,
};
if !manifest.contains("type=\"dynamic\"") && !manifest.contains("type=\"static\"") {
return false;
}
if !stream_ready(directory, 0).await {
return false;
}
for stream in 1..=audio_count {
if !stream_ready(directory, stream).await {
return false;
}
}
true
}
async fn stream_ready(directory: &Path, stream: usize) -> bool {
directory.join(format!("init-stream{stream}.m4s")).is_file()
&& has_segment(directory, stream).await
}
async fn has_segment(directory: &Path, stream: usize) -> bool {
let prefix = format!("chunk-stream{stream}-");
let mut entries = match fs::read_dir(directory).await {
Ok(value) => value,
Err(_) => return false,
};
while let Ok(Some(entry)) = entries.next_entry().await {
let name = entry.file_name();
let name = name.to_string_lossy();
if name.starts_with(&prefix) && name.ends_with(".m4s") {
return true;
}
}
false
}
async fn startup_error(directory: &Path, fallback: &str) -> AppError {
let log = fs::read_to_string(directory.join("ffmpeg.log"))
.await
.unwrap_or_default();
fs::remove_dir_all(directory).await.ok();
let details = log.trim();
AppError::bad_request(if details.is_empty() {
fallback.to_owned()
} else {
format!("{fallback}: {details}")
})
}
pub fn valid_asset_name(name: &str) -> bool {
let mut components = Path::new(name).components();
!name.is_empty()
&& matches!(components.next(), Some(Component::Normal(_)))
&& components.next().is_none()
&& matches!(
Path::new(name).extension().and_then(|value| value.to_str()),
Some("mpd" | "m4s" | "mp4")
)
}
#[cfg(test)]
mod tests {
use super::valid_asset_name;
#[test]
fn accepts_flat_dash_assets_only() {
assert!(valid_asset_name("manifest.mpd"));
assert!(valid_asset_name("chunk-stream0-00001.m4s"));
assert!(!valid_asset_name("../secret.mp4"));
assert!(!valid_asset_name("nested/chunk.m4s"));
assert!(!valid_asset_name("notes.txt"));
}
}
+16
View File
@@ -0,0 +1,16 @@
pub mod browse;
pub mod dash;
pub mod path;
pub mod playback;
pub mod preview;
pub mod probe;
pub mod stream;
pub mod subtitles;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct PathQuery {
#[serde(default)]
pub path: String,
}
+30
View File
@@ -0,0 +1,30 @@
use std::path::{Component, Path, PathBuf};
use crate::{config::Config, error::AppError};
pub fn resolve(config: &Config, relative: &str) -> Result<PathBuf, AppError> {
let relative = Path::new(relative);
if relative.is_absolute()
|| relative
.components()
.any(|part| !matches!(part, Component::Normal(_)))
{
return Err(AppError::bad_request("invalid media path"));
}
let candidate = config.media_root.join(relative);
let canonical = candidate
.canonicalize()
.map_err(|_| AppError::not_found("media path not found"))?;
if !canonical.starts_with(&config.media_root) {
return Err(AppError::bad_request("media path escapes the library"));
}
Ok(canonical)
}
pub fn relative(config: &Config, absolute: &Path) -> Result<String, AppError> {
absolute
.strip_prefix(&config.media_root)
.map(|path| path.to_string_lossy().replace('\\', "/"))
.map_err(AppError::internal)
}
+61
View File
@@ -0,0 +1,61 @@
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoPipeline {
Copy,
Encode(VideoEncoder),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VideoEncoder {
Libx264,
}
impl VideoPipeline {
pub fn ffmpeg_args(self, segment_seconds: u8) -> Vec<String> {
match self {
Self::Copy => vec!["-c:v".into(), "copy".into()],
Self::Encode(encoder) => encoder.ffmpeg_args(segment_seconds),
}
}
}
impl VideoEncoder {
pub fn portable_software() -> Self {
Self::Libx264
}
fn ffmpeg_args(self, segment_seconds: u8) -> Vec<String> {
match self {
Self::Libx264 => vec![
"-c:v".into(),
"libx264".into(),
"-preset".into(),
"veryfast".into(),
"-crf".into(),
"20".into(),
"-vf".into(),
"scale=trunc(iw/2)*2:trunc(ih/2)*2,format=yuv420p".into(),
"-force_key_frames".into(),
format!("expr:gte(t,n_forced*{segment_seconds})"),
"-sc_threshold".into(),
"0".into(),
],
}
}
}
#[cfg(test)]
mod tests {
use super::{VideoEncoder, VideoPipeline};
#[test]
fn libx264_profile_is_browser_compatible_and_segment_aligned() {
let args = VideoPipeline::Encode(VideoEncoder::Libx264).ffmpeg_args(2);
assert!(args.windows(2).any(|pair| pair == ["-c:v", "libx264"]));
assert!(args.windows(2).any(|pair| pair == ["-crf", "20"]));
assert!(
args.windows(2)
.any(|pair| { pair == ["-force_key_frames", "expr:gte(t,n_forced*2)"] })
);
assert!(args.iter().any(|arg| arg.contains("format=yuv420p")));
}
}
+7
View File
@@ -0,0 +1,7 @@
mod encoder;
mod planner;
mod profile;
pub use encoder::{VideoEncoder, VideoPipeline};
pub use planner::plan;
pub use profile::PlaybackPlan;
+60
View File
@@ -0,0 +1,60 @@
use std::path::Path;
use crate::{config::Config, error::AppError, media::probe};
use super::{PlaybackPlan, VideoEncoder, VideoPipeline};
pub async fn plan(config: &Config, media: &Path) -> Result<PlaybackPlan, AppError> {
let metadata = probe::run(config, media).await?;
let video = metadata
.streams
.iter()
.find(|stream| stream.codec_type.as_deref() == Some("video"))
.ok_or_else(|| AppError::bad_request("media file has no video stream"))?;
let video_pipeline =
select_video_pipeline(video.codec_name.as_deref(), video.pix_fmt.as_deref());
let audio_streams = metadata
.streams
.iter()
.filter(|stream| stream.codec_type.as_deref() == Some("audio"))
.map(|stream| stream.index)
.collect();
let duration_seconds = metadata
.format
.and_then(|format| format.duration)
.and_then(|duration| duration.parse().ok());
Ok(PlaybackPlan::live_dash(
video_pipeline,
audio_streams,
duration_seconds,
))
}
fn select_video_pipeline(codec: Option<&str>, pixel_format: Option<&str>) -> VideoPipeline {
if codec == Some("h264") && matches!(pixel_format, Some("yuv420p" | "yuvj420p")) {
VideoPipeline::Copy
} else {
VideoPipeline::Encode(VideoEncoder::portable_software())
}
}
#[cfg(test)]
mod tests {
use super::{VideoEncoder, VideoPipeline, select_video_pipeline};
#[test]
fn copies_only_browser_compatible_h264() {
assert_eq!(
select_video_pipeline(Some("h264"), Some("yuv420p")),
VideoPipeline::Copy
);
assert_eq!(
select_video_pipeline(Some("mpeg4"), Some("yuv420p")),
VideoPipeline::Encode(VideoEncoder::Libx264)
);
assert_eq!(
select_video_pipeline(Some("h264"), Some("yuv420p10le")),
VideoPipeline::Encode(VideoEncoder::Libx264)
);
}
}
+95
View File
@@ -0,0 +1,95 @@
use super::VideoPipeline;
#[derive(Debug, Clone)]
pub struct PlaybackPlan {
pub video_pipeline: VideoPipeline,
pub audio_streams: Vec<u32>,
pub duration_seconds: Option<f64>,
pub segment_seconds: u8,
}
impl PlaybackPlan {
pub fn live_dash(
video_pipeline: VideoPipeline,
audio_streams: Vec<u32>,
duration_seconds: Option<f64>,
) -> Self {
Self {
video_pipeline,
audio_streams,
duration_seconds,
segment_seconds: 2,
}
}
pub fn audio_count(&self) -> usize {
self.audio_streams.len()
}
pub fn ffmpeg_output_args(&self) -> Vec<String> {
let mut args = vec!["-map".into(), "0:v:0".into()];
args.extend(self.video_pipeline.ffmpeg_args(self.segment_seconds));
for stream in &self.audio_streams {
args.extend(["-map".into(), format!("0:{stream}")]);
}
if !self.audio_streams.is_empty() {
args.extend([
"-c:a".into(),
"aac".into(),
"-profile:a".into(),
"aac_low".into(),
"-b:a".into(),
"192k".into(),
"-ac".into(),
"2".into(),
]);
}
let mut adaptation_sets = vec!["id=0,streams=0".to_owned()];
adaptation_sets.extend(
(1..=self.audio_streams.len()).map(|output| format!("id={output},streams={output}")),
);
args.extend([
"-sn".into(),
"-f".into(),
"dash".into(),
"-seg_duration".into(),
self.segment_seconds.to_string(),
"-update_period".into(),
"1".into(),
"-use_template".into(),
"1".into(),
"-use_timeline".into(),
"1".into(),
"-window_size".into(),
"0".into(),
"-extra_window_size".into(),
"0".into(),
"-adaptation_sets".into(),
adaptation_sets.join(" "),
"manifest.mpd".into(),
]);
args
}
}
#[cfg(test)]
mod tests {
use super::{PlaybackPlan, VideoPipeline};
#[test]
fn live_profile_copies_video_and_encodes_audio_to_aac() {
let args = PlaybackPlan::live_dash(VideoPipeline::Copy, vec![1, 3], Some(120.0))
.ffmpeg_output_args();
assert!(args.windows(2).any(|pair| pair == ["-c:v", "copy"]));
assert!(args.windows(2).any(|pair| pair == ["-c:a", "aac"]));
assert!(args.windows(2).any(|pair| pair == ["-ac", "2"]));
assert!(args.windows(2).any(|pair| pair == ["-map", "0:1"]));
assert!(args.windows(2).any(|pair| pair == ["-map", "0:3"]));
assert!(args.windows(2).any(|pair| {
pair == [
"-adaptation_sets",
"id=0,streams=0 id=1,streams=1 id=2,streams=2",
]
}));
}
}
+157
View File
@@ -0,0 +1,157 @@
use std::{
collections::hash_map::DefaultHasher,
hash::{Hash, Hasher},
process::Stdio,
sync::Arc,
time::Duration,
};
use axum::{
body::Body,
extract::{Query, State},
http::header,
response::{IntoResponse, Response},
};
use tokio::process::Command;
use crate::{
app::AppState,
error::AppError,
media::{PathQuery, path, probe},
};
const FRAME_COUNT: usize = 10;
const FRAME_WIDTH: usize = 320;
const FRAME_HEIGHT: usize = 180;
pub async fn sprite(
State(state): State<Arc<AppState>>,
Query(query): Query<PathQuery>,
) -> Result<Response, AppError> {
let media = path::resolve(&state.config, &query.path)?;
if !media.is_file() {
return Err(AppError::bad_request("path is not a media file"));
}
let _permit = state
.preview_jobs
.acquire()
.await
.map_err(AppError::internal)?;
let metadata = probe::run(&state.config, &media).await?;
let duration = metadata
.format
.and_then(|format| format.duration)
.and_then(|value| value.parse::<f64>().ok())
.filter(|value| value.is_finite() && *value > 0.0)
.ok_or_else(|| AppError::bad_request("media duration is unavailable"))?;
let timestamps = preview_timestamps(&query.path, duration);
let mut command = Command::new(&state.config.ffmpeg);
command.args(["-v", "error"]);
for timestamp in timestamps {
command
.args(["-ss", &format!("{timestamp:.3}"), "-i"])
.arg(&media);
}
let filter = sprite_filter();
command
.args([
"-filter_complex",
&filter,
"-map",
"[sprite]",
"-frames:v",
"1",
"-c:v",
"mjpeg",
"-q:v",
"5",
"-f",
"image2pipe",
"pipe:1",
])
.stdin(Stdio::null())
.kill_on_drop(true);
let output = tokio::time::timeout(Duration::from_secs(30), command.output())
.await
.map_err(|_| AppError::bad_request("preview generation timed out"))?
.map_err(AppError::internal)?;
if !output.status.success() || output.stdout.is_empty() {
let details = String::from_utf8_lossy(&output.stderr);
return Err(AppError::bad_request(format!(
"failed to create preview: {}",
details.trim()
)));
}
Ok((
[
(header::CONTENT_TYPE, "image/jpeg"),
(header::CACHE_CONTROL, "private, max-age=3600"),
],
Body::from(output.stdout),
)
.into_response())
}
fn preview_timestamps(path: &str, duration: f64) -> [f64; FRAME_COUNT] {
let mut hasher = DefaultHasher::new();
path.hash(&mut hasher);
let mut seed = hasher.finish();
std::array::from_fn(|index| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let jitter = (seed as f64 / u64::MAX as f64 - 0.5) * 0.06;
let position = ((index as f64 + 0.5) / FRAME_COUNT as f64 + jitter).clamp(0.02, 0.98);
position * duration
})
}
fn sprite_filter() -> String {
let mut filters = (0..FRAME_COUNT)
.map(|index| {
format!(
"[{index}:v:0]setpts=PTS-STARTPTS,scale={FRAME_WIDTH}:{FRAME_HEIGHT}:force_original_aspect_ratio=decrease,pad={FRAME_WIDTH}:{FRAME_HEIGHT}:(ow-iw)/2:(oh-ih)/2:black,setsar=1[v{index}]"
)
})
.collect::<Vec<_>>();
let inputs = (0..FRAME_COUNT)
.map(|index| format!("[v{index}]"))
.collect::<String>();
let layout = (0..FRAME_COUNT)
.map(|index| {
format!(
"{}_{}",
(index % 5) * FRAME_WIDTH,
(index / 5) * FRAME_HEIGHT
)
})
.collect::<Vec<_>>()
.join("|");
filters.push(format!(
"{inputs}xstack=inputs={FRAME_COUNT}:layout={layout}[sprite]"
));
filters.join(";")
}
#[cfg(test)]
mod tests {
use super::{FRAME_COUNT, preview_timestamps, sprite_filter};
#[test]
fn timestamps_are_stable_and_inside_the_video() {
let first = preview_timestamps("series/episode.mkv", 100.0);
let second = preview_timestamps("series/episode.mkv", 100.0);
assert_eq!(first, second);
assert!(first.iter().all(|value| *value >= 2.0 && *value <= 98.0));
}
#[test]
fn sprite_contains_every_input() {
let filter = sprite_filter();
assert!(filter.contains(&format!("xstack=inputs={FRAME_COUNT}")));
for index in 0..FRAME_COUNT {
assert!(filter.contains(&format!("[{index}:v:0]")));
}
}
}
+67
View File
@@ -0,0 +1,67 @@
use std::{path::Path, process::Stdio, sync::Arc};
use axum::{
Json,
extract::{Query, State},
};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use crate::{
app::AppState,
config::Config,
error::AppError,
media::{PathQuery, path},
};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Probe {
#[serde(default)]
pub streams: Vec<Stream>,
pub format: Option<Format>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Stream {
pub index: u32,
pub codec_name: Option<String>,
pub codec_type: Option<String>,
pub pix_fmt: Option<String>,
#[serde(default)]
pub tags: serde_json::Map<String, serde_json::Value>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Format {
pub format_name: Option<String>,
pub duration: Option<String>,
}
pub async fn probe(
State(state): State<Arc<AppState>>,
Query(query): Query<PathQuery>,
) -> Result<Json<Probe>, AppError> {
let media = path::resolve(&state.config, &query.path)?;
Ok(Json(run(&state.config, &media).await?))
}
pub async fn run(config: &Config, media: &Path) -> Result<Probe, AppError> {
let output = Command::new(&config.ffprobe)
.args([
"-v",
"error",
"-show_format",
"-show_streams",
"-of",
"json",
])
.arg(media)
.stdin(Stdio::null())
.output()
.await
.map_err(AppError::internal)?;
if !output.status.success() {
return Err(AppError::internal(String::from_utf8_lossy(&output.stderr)));
}
serde_json::from_slice(&output.stdout).map_err(AppError::internal)
}
+76
View File
@@ -0,0 +1,76 @@
use std::sync::Arc;
use axum::{
body::Body,
extract::{Query, State},
http::{HeaderMap, StatusCode, header},
response::Response,
};
use tokio::{
fs::File,
io::{AsyncReadExt, AsyncSeekExt, SeekFrom},
};
use crate::{
app::AppState,
error::AppError,
media::{PathQuery, path},
};
pub async fn stream(
State(state): State<Arc<AppState>>,
Query(query): Query<PathQuery>,
headers: HeaderMap,
) -> Result<Response<Body>, AppError> {
let media = path::resolve(&state.config, &query.path)?;
if !media.is_file() {
return Err(AppError::bad_request("path is not a file"));
}
let mut file = File::open(&media).await.map_err(AppError::internal)?;
let size = file.metadata().await.map_err(AppError::internal)?.len();
let (start, end, status) = match headers.get(header::RANGE).and_then(|v| v.to_str().ok()) {
Some(value) => {
let (start, end) = parse_range(value, size)?;
(start, end, StatusCode::PARTIAL_CONTENT)
}
None => (0, size.saturating_sub(1), StatusCode::OK),
};
let length = end - start + 1;
file.seek(SeekFrom::Start(start))
.await
.map_err(AppError::internal)?;
let body = Body::from_stream(tokio_util::io::ReaderStream::new(file.take(length)));
let mime = mime_guess::from_path(&media).first_or_octet_stream();
let mut response = Response::builder()
.status(status)
.header(header::ACCEPT_RANGES, "bytes")
.header(header::CONTENT_LENGTH, length)
.header(header::CONTENT_TYPE, mime.as_ref());
if status == StatusCode::PARTIAL_CONTENT {
response = response.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{size}"));
}
response.body(body).map_err(AppError::internal)
}
fn parse_range(value: &str, size: u64) -> Result<(u64, u64), AppError> {
let value = value
.strip_prefix("bytes=")
.ok_or_else(|| AppError::bad_request("invalid range"))?;
let (start, end) = value
.split_once('-')
.ok_or_else(|| AppError::bad_request("invalid range"))?;
let start = start
.parse::<u64>()
.map_err(|_| AppError::bad_request("invalid range"))?;
let end = if end.is_empty() {
size.saturating_sub(1)
} else {
end.parse()
.map_err(|_| AppError::bad_request("invalid range"))?
};
if size == 0 || start > end || end >= size {
return Err(AppError::bad_request("range outside file"));
}
Ok((start, end))
}
+160
View File
@@ -0,0 +1,160 @@
use std::{process::Stdio, sync::Arc};
use axum::{
Json,
body::Body,
extract::{Path, Query, State},
http::header,
response::{IntoResponse, Response},
};
use serde::{Deserialize, Serialize};
use tokio::process::Command;
use tokio_util::io::ReaderStream;
use crate::{
app::AppState,
error::AppError,
media::{PathQuery, path, probe},
};
mod discovery;
mod matching;
#[derive(Serialize)]
pub struct Subtitle {
index: Option<u32>,
id: Option<String>,
source: &'static str,
codec: String,
language: Option<String>,
title: Option<String>,
supported: bool,
confidence: Option<&'static str>,
score: Option<u8>,
}
#[derive(Deserialize)]
pub struct ExtractQuery {
path: String,
#[serde(default)]
start: f64,
}
pub async fn list(
State(state): State<Arc<AppState>>,
Query(query): Query<PathQuery>,
) -> Result<Json<Vec<Subtitle>>, AppError> {
let media = path::resolve(&state.config, &query.path)?;
let info = probe::run(&state.config, &media).await?;
let mut subtitles = info
.streams
.into_iter()
.filter(|stream| stream.codec_type.as_deref() == Some("subtitle"))
.map(|stream| {
let codec = stream.codec_name.unwrap_or_else(|| "unknown".into());
Subtitle {
index: Some(stream.index),
id: None,
source: "embedded",
supported: matches!(
codec.as_str(),
"subrip" | "srt" | "ass" | "ssa" | "webvtt" | "mov_text"
),
language: tag(&stream.tags, "language"),
title: tag(&stream.tags, "title"),
codec,
confidence: None,
score: None,
}
})
.collect::<Vec<_>>();
subtitles.extend(
discovery::find(&state.config, &media)
.await?
.into_iter()
.map(|candidate| Subtitle {
index: None,
id: Some(candidate.id),
source: "external",
codec: candidate.codec,
language: None,
title: Some(candidate.label),
supported: true,
confidence: Some(candidate.confidence.as_str()),
score: Some(candidate.score),
}),
);
Ok(Json(subtitles))
}
pub async fn extract(
State(state): State<Arc<AppState>>,
Path(index): Path<u32>,
Query(query): Query<ExtractQuery>,
) -> Result<Response, AppError> {
if !query.start.is_finite() || query.start < 0.0 {
return Err(AppError::bad_request("invalid subtitle start position"));
}
let media = path::resolve(&state.config, &query.path)?;
stream_webvtt(
&state.config.ffmpeg,
&media,
&format!("0:{index}"),
query.start,
)
.await
}
pub async fn extract_external(
State(state): State<Arc<AppState>>,
Path(id): Path<String>,
Query(query): Query<ExtractQuery>,
) -> Result<Response, AppError> {
if !query.start.is_finite() || query.start < 0.0 {
return Err(AppError::bad_request("invalid subtitle start position"));
}
let media = path::resolve(&state.config, &query.path)?;
let candidate = discovery::resolve_id(&state.config, &media, &id).await?;
stream_webvtt(&state.config.ffmpeg, &candidate.path, "0:0", query.start).await
}
async fn stream_webvtt(
ffmpeg: &std::path::Path,
input: &std::path::Path,
map: &str,
start: f64,
) -> Result<Response, AppError> {
let mut command = Command::new(ffmpeg);
command.args(["-v", "error"]);
if start > 0.0 {
command.args(["-ss", &format!("{start:.3}")]);
}
let mut child = command
.arg("-i")
.arg(input)
.args(["-map", map, "-f", "webvtt", "pipe:1"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(AppError::internal)?;
let stdout = child
.stdout
.take()
.ok_or_else(|| AppError::internal("ffmpeg stdout unavailable"))?;
tokio::spawn(async move {
let _ = child.wait().await;
});
Ok((
[(header::CONTENT_TYPE, "text/vtt; charset=utf-8")],
Body::from_stream(ReaderStream::new(stdout)),
)
.into_response())
}
fn tag(tags: &serde_json::Map<String, serde_json::Value>, name: &str) -> Option<String> {
tags.get(name)
.or_else(|| tags.get(&name.to_uppercase()))
.and_then(|value| value.as_str())
.map(str::to_owned)
}
+127
View File
@@ -0,0 +1,127 @@
use std::{
collections::{HashSet, VecDeque, hash_map::DefaultHasher},
hash::{Hash, Hasher},
path::{Path, PathBuf},
};
use tokio::fs;
use crate::{config::Config, error::AppError, media::path};
use super::matching::{self, Confidence};
const SUBTITLE_EXTENSIONS: &[&str] = &["ass", "ssa", "srt", "vtt"];
const MAX_DEPTH: usize = 3;
const MAX_ENTRIES: usize = 2_000;
#[derive(Debug, Clone)]
pub struct Candidate {
pub id: String,
pub path: PathBuf,
pub relative_path: String,
pub codec: String,
pub label: String,
pub score: u8,
pub confidence: Confidence,
}
pub async fn find(config: &Config, video: &Path) -> Result<Vec<Candidate>, AppError> {
let mut roots = Vec::new();
if let Some(parent) = video.parent() {
roots.push((parent.to_owned(), MAX_DEPTH));
if let Some(grandparent) = parent.parent().filter(|path| *path != config.media_root)
&& grandparent.starts_with(&config.media_root)
{
roots.push((grandparent.to_owned(), 2));
}
}
let mut queue = VecDeque::from(roots);
let mut visited = HashSet::new();
let mut scanned = 0;
let mut candidates = Vec::new();
while let Some((directory, depth)) = queue.pop_front() {
if !visited.insert(directory.clone()) {
continue;
}
let mut reader = match fs::read_dir(&directory).await {
Ok(reader) => reader,
Err(_) => continue,
};
while scanned < MAX_ENTRIES {
let Some(entry) = reader.next_entry().await.map_err(AppError::internal)? else {
break;
};
scanned += 1;
let file_type = entry.file_type().await.map_err(AppError::internal)?;
if file_type.is_symlink() {
continue;
}
let candidate_path = entry.path();
if file_type.is_dir() && depth > 0 {
queue.push_back((candidate_path, depth - 1));
} else if file_type.is_file()
&& is_subtitle(&candidate_path)
&& let Some(candidate) = make_candidate(config, video, candidate_path)?
{
candidates.push(candidate);
}
}
if scanned >= MAX_ENTRIES {
break;
}
}
candidates.sort_by(|left, right| {
right
.score
.cmp(&left.score)
.then_with(|| left.relative_path.cmp(&right.relative_path))
});
candidates.dedup_by(|left, right| left.path == right.path);
Ok(candidates)
}
pub async fn resolve_id(config: &Config, video: &Path, id: &str) -> Result<Candidate, AppError> {
find(config, video)
.await?
.into_iter()
.find(|candidate| candidate.id == id)
.ok_or_else(|| AppError::not_found("external subtitle not found"))
}
fn make_candidate(
config: &Config,
video: &Path,
subtitle: PathBuf,
) -> Result<Option<Candidate>, AppError> {
let Some(matched) = matching::score(video, &subtitle) else {
return Ok(None);
};
let relative_path = path::relative(config, &subtitle)?;
let codec = subtitle
.extension()
.and_then(|value| value.to_str())
.unwrap_or("unknown")
.to_ascii_lowercase();
Ok(Some(Candidate {
id: candidate_id(&relative_path),
path: subtitle,
relative_path,
codec,
label: matched.label,
score: matched.score,
confidence: matched.confidence,
}))
}
fn candidate_id(relative_path: &str) -> String {
let mut hasher = DefaultHasher::new();
relative_path.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
fn is_subtitle(path: &Path) -> bool {
path.extension()
.and_then(|value| value.to_str())
.is_some_and(|value| SUBTITLE_EXTENSIONS.contains(&value.to_ascii_lowercase().as_str()))
}
+237
View File
@@ -0,0 +1,237 @@
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Confidence {
Certain,
Likely,
Ambiguous,
}
impl Confidence {
pub fn as_str(self) -> &'static str {
match self {
Self::Certain => "certain",
Self::Likely => "likely",
Self::Ambiguous => "ambiguous",
}
}
}
#[derive(Debug, Clone)]
pub struct Match {
pub score: u8,
pub confidence: Confidence,
pub label: String,
}
pub fn score(video: &Path, subtitle: &Path) -> Option<Match> {
let video_stem = stem(video)?;
let subtitle_stem = stem(subtitle)?;
let video_name = Normalized::new(&video_stem);
let subtitle_name = Normalized::new(&subtitle_stem);
if video_name.tokens.is_empty() || subtitle_name.tokens.is_empty() {
return None;
}
let exact = video_name.tokens == subtitle_name.tokens;
let contains_video = contains_sequence(&subtitle_name.tokens, &video_name.tokens);
let same_directory = video.parent() == subtitle.parent();
let episode_video = video_name.episode();
let episode_subtitle = subtitle_name.episode();
if !contains_video
&& episode_video.is_some()
&& episode_subtitle.is_some()
&& episode_video != episode_subtitle
{
return None;
}
let similarity = jaccard(&video_name.tokens, &subtitle_name.tokens);
let (score, confidence) = if exact {
(100, Confidence::Certain)
} else if contains_video {
(95, Confidence::Certain)
} else {
let mut value = (similarity * 55.0).round() as u8;
if episode_video.is_some() && episode_video == episode_subtitle {
value = value.saturating_add(30);
}
if same_directory {
value = value.saturating_add(10);
}
let confidence = if value >= 75 {
Confidence::Likely
} else {
Confidence::Ambiguous
};
(value.min(94), confidence)
};
if score < 40 {
return None;
}
Some(Match {
score,
confidence,
label: candidate_label(video, subtitle, contains_video),
})
}
fn stem(path: &Path) -> Option<String> {
path.file_stem()
.map(|value| value.to_string_lossy().into_owned())
}
struct Normalized {
tokens: Vec<String>,
}
impl Normalized {
fn new(value: &str) -> Self {
let mut tokens = Vec::new();
let mut current = String::new();
let mut digit = None;
for character in value.chars().flat_map(char::to_lowercase) {
if !character.is_alphanumeric() {
push_token(&mut tokens, &mut current);
digit = None;
continue;
}
let is_digit = character.is_numeric();
if digit.is_some_and(|previous| previous != is_digit) {
push_token(&mut tokens, &mut current);
}
digit = Some(is_digit);
current.push(character);
}
push_token(&mut tokens, &mut current);
Self { tokens }
}
fn episode(&self) -> Option<u32> {
for window in self.tokens.windows(4) {
if window[0] == "s"
&& window[2] == "e"
&& let Ok(value) = window[3].parse()
{
return Some(value);
}
}
let technical_boundary = self.tokens.iter().position(|token| {
token
.parse::<u32>()
.is_ok_and(|value| is_technical_number(value, None))
});
self.tokens
.iter()
.enumerate()
.take(technical_boundary.unwrap_or(self.tokens.len()))
.filter_map(|(index, token)| {
let value: u32 = token.parse().ok()?;
if is_technical_number(value, self.tokens.get(index.wrapping_sub(1))) {
None
} else {
Some(value)
}
})
.next_back()
}
}
fn push_token(tokens: &mut Vec<String>, current: &mut String) {
if !current.is_empty() {
tokens.push(std::mem::take(current));
}
}
fn is_technical_number(value: u32, previous: Option<&String>) -> bool {
matches!(
value,
240 | 264 | 265 | 360 | 480 | 576 | 720 | 1080 | 1440 | 2160 | 4320
) || previous.is_some_and(|token| matches!(token.as_str(), "x" | "h"))
}
fn contains_sequence(haystack: &[String], needle: &[String]) -> bool {
needle.len() <= haystack.len()
&& haystack
.windows(needle.len())
.any(|window| window == needle)
}
fn jaccard(left: &[String], right: &[String]) -> f32 {
let common = left.iter().filter(|token| right.contains(token)).count();
let union = left.len() + right.len() - common;
if union == 0 {
0.0
} else {
common as f32 / union as f32
}
}
fn candidate_label(video: &Path, subtitle: &Path, contains_video: bool) -> String {
let video_stem = stem(video).unwrap_or_default();
let subtitle_stem = stem(subtitle).unwrap_or_default();
let suffix = if contains_video {
subtitle_stem
.strip_prefix(&video_stem)
.unwrap_or("")
.trim_matches(|character: char| !character.is_alphanumeric())
.to_owned()
} else {
String::new()
};
let folder = (video.parent() != subtitle.parent())
.then(|| subtitle.parent()?.file_name()?.to_str().map(str::to_owned))
.flatten();
match (folder, suffix.is_empty()) {
(Some(folder), false) => format!("{folder} · {suffix}"),
(Some(folder), true) => folder,
(None, false) => suffix,
(None, true) => "External".to_owned(),
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{Confidence, score};
#[test]
fn exact_stems_are_certain() {
let result = score(
Path::new("05. Famous Shooter.avi"),
Path::new("05. Famous Shooter.ass"),
)
.unwrap();
assert_eq!(result.confidence, Confidence::Certain);
assert_eq!(result.score, 100);
}
#[test]
fn release_suffixes_are_certain_and_become_labels() {
let result = score(
Path::new("Show - 01 [1080p WEB-DL].mkv"),
Path::new("Show - 01 [1080p WEB-DL]-RELEASE2.ass"),
)
.unwrap();
assert_eq!(result.confidence, Confidence::Certain);
assert_eq!(result.label, "RELEASE2");
}
#[test]
fn rejects_a_neighboring_episode() {
assert!(score(Path::new("Show - 01.mkv"), Path::new("Show - 02.ass")).is_none());
}
#[test]
fn folder_names_are_labels_not_languages() {
let result = score(
Path::new("Show/01.mkv"),
Path::new("Show/SUBS/Variant A/01.srt"),
)
.unwrap();
assert_eq!(result.label, "Variant A");
}
}
+82
View File
@@ -0,0 +1,82 @@
use axum::{
body::Body,
http::header,
response::{Html, IntoResponse},
};
pub async fn index() -> impl IntoResponse {
(
[(header::CACHE_CONTROL, "no-store")],
Html(include_str!("../web/index.html")),
)
}
pub async fn alpine() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "text/javascript; charset=utf-8")],
include_str!("../web/alpine.min.js"),
)
}
pub async fn dash() -> impl IntoResponse {
javascript(include_str!("../web/dash.all.min.js"))
}
pub async fn dash_source_map() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "application/json; charset=utf-8")],
r#"{"version":3,"sources":[],"names":[],"mappings":""}"#,
)
}
pub async fn controlbar_javascript() -> impl IntoResponse {
javascript(include_str!("../web/controlbar.min.js"))
}
pub async fn api_javascript() -> impl IntoResponse {
javascript(include_str!("../web/js/api.js"))
}
pub async fn library_javascript() -> impl IntoResponse {
javascript(include_str!("../web/js/library.js"))
}
pub async fn location_javascript() -> impl IntoResponse {
javascript(include_str!("../web/js/location.js"))
}
pub async fn preferences_javascript() -> impl IntoResponse {
javascript(include_str!("../web/js/preferences.js"))
}
pub async fn player_javascript() -> impl IntoResponse {
javascript(include_str!("../web/js/player.js"))
}
fn javascript(source: &'static str) -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "text/javascript; charset=utf-8"),
(header::CACHE_CONTROL, "no-store"),
],
source,
)
}
pub async fn styles() -> impl IntoResponse {
stylesheet(include_str!("../web/styles.css"))
}
pub async fn controlbar_styles() -> impl IntoResponse {
stylesheet(include_str!("../web/vendor/controlbar/controlbar.css"))
}
pub async fn bootstrap_icon_styles() -> impl IntoResponse {
stylesheet(include_str!(
"../web/vendor/bootstrap-icons/bootstrap-icons.min.css"
))
}
pub async fn bootstrap_icon_font() -> impl IntoResponse {
(
[(header::CONTENT_TYPE, "font/woff2")],
Body::from(
include_bytes!("../web/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2").as_slice(),
),
)
}
fn stylesheet(source: &'static str) -> impl IntoResponse {
(
[
(header::CONTENT_TYPE, "text/css; charset=utf-8"),
(header::CACHE_CONTROL, "no-store"),
],
source,
)
}