This commit is contained in:
+61
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod encoder;
|
||||
mod planner;
|
||||
mod profile;
|
||||
|
||||
pub use encoder::{VideoEncoder, VideoPipeline};
|
||||
pub use planner::plan;
|
||||
pub use profile::PlaybackPlan;
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
]
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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]")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user