|
@@ -3,10 +3,13 @@ use axum::{
|
|
routing::{get, get_service},
|
|
routing::{get, get_service},
|
|
Router,
|
|
Router,
|
|
};
|
|
};
|
|
|
|
+use cache::Cache;
|
|
|
|
+use error::NtitledError;
|
|
use ffmpeg::VideoStream;
|
|
use ffmpeg::VideoStream;
|
|
use minijinja::Environment;
|
|
use minijinja::Environment;
|
|
use ost::client::OSClient;
|
|
use ost::client::OSClient;
|
|
-use serde::Serialize;
|
|
|
|
|
|
+use redis::ConnectionInfo;
|
|
|
|
+use serde::{Deserialize, Serialize};
|
|
use std::{
|
|
use std::{
|
|
fs::{self, DirEntry},
|
|
fs::{self, DirEntry},
|
|
num::NonZeroUsize,
|
|
num::NonZeroUsize,
|
|
@@ -14,10 +17,21 @@ use std::{
|
|
thread,
|
|
thread,
|
|
};
|
|
};
|
|
use tower_http::services::ServeDir;
|
|
use tower_http::services::ServeDir;
|
|
-use tracing::{debug, info, Level};
|
|
|
|
|
|
+use tracing::{debug, info, warn, Level};
|
|
|
|
+
|
|
|
|
+const SKIP_EXT: &[&str] = &["srt", "smi", "mjpeg", "jpg", "jpeg"];
|
|
|
|
+
|
|
|
|
+fn skip_ext(ext: &str) -> bool {
|
|
|
|
+ let mut skip = false;
|
|
|
|
+ for e in SKIP_EXT {
|
|
|
|
+ skip = ext.ends_with(e);
|
|
|
|
+ }
|
|
|
|
+ skip
|
|
|
|
+}
|
|
|
|
|
|
use crate::ffmpeg::{FFProbeOutput, SubtitleStream};
|
|
use crate::ffmpeg::{FFProbeOutput, SubtitleStream};
|
|
|
|
|
|
|
|
+pub mod cache;
|
|
pub mod error;
|
|
pub mod error;
|
|
pub mod ffmpeg;
|
|
pub mod ffmpeg;
|
|
pub mod htmx;
|
|
pub mod htmx;
|
|
@@ -29,16 +43,185 @@ pub struct State {
|
|
client: OSClient,
|
|
client: OSClient,
|
|
env: Environment<'static>,
|
|
env: Environment<'static>,
|
|
base_path: String,
|
|
base_path: String,
|
|
|
|
+ cache: redis::Client,
|
|
}
|
|
}
|
|
|
|
|
|
-lazy_static::lazy_static! {
|
|
|
|
- pub static ref INDEX: String =
|
|
|
|
- std::fs::read_to_string("assets/index.html").expect("missing template");
|
|
|
|
|
|
+fn extract_name_ext(entry: &DirEntry) -> Option<(String, String)> {
|
|
|
|
+ let path = entry.path();
|
|
|
|
+ let ext = path.extension()?.to_str()?;
|
|
|
|
+ let (name, _) = path.file_name()?.to_str()?.split_once(ext)?;
|
|
|
|
+ Some((name.to_owned(), ext.to_owned()))
|
|
|
|
+}
|
|
|
|
+
|
|
|
|
+impl State {
|
|
|
|
+ pub fn scan_dir_contents(
|
|
|
|
+ &mut self,
|
|
|
|
+ path: impl AsRef<Path>,
|
|
|
|
+ ) -> Result<Vec<FileType>, NtitledError> {
|
|
|
|
+ let entries = fs::read_dir(path)?
|
|
|
|
+ .filter_map(Result::ok)
|
|
|
|
+ .collect::<Vec<_>>();
|
|
|
|
+
|
|
|
|
+ let subs = entries
|
|
|
|
+ .iter()
|
|
|
|
+ .filter_map(|entry| {
|
|
|
|
+ let (name, ext) = extract_name_ext(entry)?;
|
|
|
|
+ (ext == "srt").then_some(name.to_owned())
|
|
|
|
+ })
|
|
|
|
+ .collect::<Vec<_>>();
|
|
|
|
+
|
|
|
|
+ let mut file_list: Vec<FileType> = entries
|
|
|
|
+ .iter()
|
|
|
|
+ .filter_map(|entry| {
|
|
|
|
+ entry
|
|
|
|
+ .path()
|
|
|
|
+ .is_dir()
|
|
|
|
+ .then_some(FileType::Directory(Directory {
|
|
|
|
+ name: entry.file_name().to_str()?.to_string(),
|
|
|
|
+ path: entry.path().to_str()?.to_string(),
|
|
|
|
+ }))
|
|
|
|
+ })
|
|
|
|
+ .collect();
|
|
|
|
+
|
|
|
|
+ let mut cached_file_names = vec![];
|
|
|
|
+
|
|
|
|
+ for entry in entries.iter() {
|
|
|
|
+ let Some((name, _)) = extract_name_ext(entry) else {
|
|
|
|
+ continue;
|
|
|
|
+ };
|
|
|
|
+ let file = self.cache.get_file_meta(&name)?;
|
|
|
|
+ if let Some(file) = file {
|
|
|
|
+ debug!("Found cached file {}", file.name);
|
|
|
|
+ cached_file_names.push(file.name.clone());
|
|
|
|
+ file_list.push(FileType::File(file));
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ let scanned_files = thread::scope(|scope| {
|
|
|
|
+ let mut handles = vec![];
|
|
|
|
+
|
|
|
|
+ let mut acc = Vec::with_capacity(*MAX_THREADS);
|
|
|
|
+
|
|
|
|
+ for _ in 0..*MAX_THREADS {
|
|
|
|
+ acc.push(vec![]);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ let jobs = entries
|
|
|
|
+ .into_iter()
|
|
|
|
+ .enumerate()
|
|
|
|
+ .fold(acc, |mut acc, (i, el)| {
|
|
|
|
+ acc[i % *MAX_THREADS].push(el);
|
|
|
|
+ acc
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ for entries in jobs {
|
|
|
|
+ let task = scope.spawn(|| {
|
|
|
|
+ let mut files = vec![];
|
|
|
|
+
|
|
|
|
+ for entry in entries {
|
|
|
|
+ if entry.path().is_dir() {
|
|
|
|
+ continue;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ let Some((name, ext)) = extract_name_ext(&entry) else {
|
|
|
|
+ continue;
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ if skip_ext(&ext) {
|
|
|
|
+ continue;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if cached_file_names.contains(&name) {
|
|
|
|
+ continue;
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ if let Some(file) = Self::extract_file_meta(name, ext, &subs, &entry) {
|
|
|
|
+ files.push(file)
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ files
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ handles.push(task);
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ let mut files = vec![];
|
|
|
|
+
|
|
|
|
+ for handle in handles {
|
|
|
|
+ match handle.join() {
|
|
|
|
+ Ok(extracted_files) => {
|
|
|
|
+ files.extend(extracted_files);
|
|
|
|
+ }
|
|
|
|
+ Err(e) => {
|
|
|
|
+ debug!("Error while reading files: {e:?}")
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ files
|
|
|
|
+ });
|
|
|
|
+
|
|
|
|
+ for file in scanned_files {
|
|
|
|
+ self.cache.set_file_meta(&file)?;
|
|
|
|
+ file_list.push(FileType::File(file));
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ file_list.sort();
|
|
|
|
+
|
|
|
|
+ Ok(file_list)
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ fn extract_file_meta(
|
|
|
|
+ name: String,
|
|
|
|
+ ext: String,
|
|
|
|
+ subs: &[String],
|
|
|
|
+ entry: &DirEntry,
|
|
|
|
+ ) -> Option<File> {
|
|
|
|
+ let path = entry.path();
|
|
|
|
+ let path_str = path.to_str()?;
|
|
|
|
+
|
|
|
|
+ let out = match FFProbeOutput::probe(path_str) {
|
|
|
|
+ Ok(streams) => streams,
|
|
|
|
+ Err(e) => {
|
|
|
|
+ debug!("File {path_str} is not a video: {e}");
|
|
|
|
+ return None;
|
|
|
|
+ }
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ let Some(streams) = out.streams else {
|
|
|
|
+ return None;
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ let subs_file = subs.contains(&name.to_string());
|
|
|
|
+
|
|
|
|
+ let mut video_meta = vec![];
|
|
|
|
+ let mut sub_meta = vec![];
|
|
|
|
+
|
|
|
|
+ for stream in streams {
|
|
|
|
+ match stream {
|
|
|
|
+ ffmpeg::Stream::Video(s) => video_meta.push(s),
|
|
|
|
+ ffmpeg::Stream::Sub(s) => sub_meta.push(s),
|
|
|
|
+ ffmpeg::Stream::Audio(s) => {}
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+
|
|
|
|
+ let subs_embedded = !sub_meta.is_empty();
|
|
|
|
+
|
|
|
|
+ Some(File {
|
|
|
|
+ name,
|
|
|
|
+ ext,
|
|
|
|
+ path: entry.path().to_str()?.to_string(),
|
|
|
|
+ has_subs: subs_embedded || subs_file,
|
|
|
|
+ video_meta,
|
|
|
|
+ sub_meta,
|
|
|
|
+ })
|
|
|
|
+ }
|
|
}
|
|
}
|
|
|
|
|
|
lazy_static::lazy_static! {
|
|
lazy_static::lazy_static! {
|
|
- pub static ref FILE_CONTAINER: String =
|
|
|
|
- std::fs::read_to_string("assets/filecontainer.html").expect("missing template");
|
|
|
|
|
|
+ pub static ref INDEX: String =
|
|
|
|
+ std::fs::read_to_string("assets/index.html").expect("missing template");
|
|
}
|
|
}
|
|
|
|
|
|
lazy_static::lazy_static! {
|
|
lazy_static::lazy_static! {
|
|
@@ -59,6 +242,55 @@ async fn main() {
|
|
|
|
|
|
info!("Max threads: {}", *MAX_THREADS);
|
|
info!("Max threads: {}", *MAX_THREADS);
|
|
|
|
|
|
|
|
+ let redis_mode = std::env::var("REDIS_MODE").expect("REDIS_MODE (tcp | unix) not configured");
|
|
|
|
+ let redis_db = std::env::var("REDIS_DB")
|
|
|
|
+ .expect("REDIS_DB not configured")
|
|
|
|
+ .parse()
|
|
|
|
+ .expect("invalid db value");
|
|
|
|
+
|
|
|
|
+ let redis_host = std::env::var("REDIS_HOST").expect("REDIS_HOST not configured");
|
|
|
|
+
|
|
|
|
+ let conn_info = match redis_mode.as_str() {
|
|
|
|
+ "tcp" => {
|
|
|
|
+ let redis_port = std::env::var("REDIS_PORT")
|
|
|
|
+ .expect("REDIS_PORT not configured")
|
|
|
|
+ .parse()
|
|
|
|
+ .expect("invalid port");
|
|
|
|
+
|
|
|
|
+ ConnectionInfo {
|
|
|
|
+ addr: redis::ConnectionAddr::Tcp(redis_host, redis_port),
|
|
|
|
+ redis: redis::RedisConnectionInfo {
|
|
|
|
+ db: redis_db,
|
|
|
|
+ username: None,
|
|
|
|
+ password: None,
|
|
|
|
+ },
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ "unix" => ConnectionInfo {
|
|
|
|
+ addr: redis::ConnectionAddr::Unix(redis_host.into()),
|
|
|
|
+ redis: redis::RedisConnectionInfo {
|
|
|
|
+ db: redis_db,
|
|
|
|
+ username: None,
|
|
|
|
+ password: None,
|
|
|
|
+ },
|
|
|
|
+ },
|
|
|
|
+ _ => {
|
|
|
|
+ warn!("invalid REDIS_MODE, default to unix");
|
|
|
|
+ ConnectionInfo {
|
|
|
|
+ addr: redis::ConnectionAddr::Unix(redis_host.into()),
|
|
|
|
+ redis: redis::RedisConnectionInfo {
|
|
|
|
+ db: redis_db,
|
|
|
|
+ username: None,
|
|
|
|
+ password: None,
|
|
|
|
+ },
|
|
|
|
+ }
|
|
|
|
+ }
|
|
|
|
+ };
|
|
|
|
+
|
|
|
|
+ let cache = redis::Client::open(conn_info).expect("unable to connect to redis");
|
|
|
|
+
|
|
|
|
+ info!("Successfully initialised Redis");
|
|
|
|
+
|
|
let client = OSClient::init().await;
|
|
let client = OSClient::init().await;
|
|
|
|
|
|
info!("Successfully loaded OST client");
|
|
info!("Successfully loaded OST client");
|
|
@@ -68,23 +300,20 @@ async fn main() {
|
|
|
|
|
|
let mut env = Environment::new();
|
|
let mut env = Environment::new();
|
|
|
|
|
|
- env.add_template("file_container", &FILE_CONTAINER)
|
|
|
|
- .expect("unable to add template");
|
|
|
|
-
|
|
|
|
env.add_template("index", &INDEX)
|
|
env.add_template("index", &INDEX)
|
|
- .expect("unable to add template");
|
|
|
|
|
|
+ .expect("unable to add index template");
|
|
|
|
|
|
let state = State {
|
|
let state = State {
|
|
client,
|
|
client,
|
|
env,
|
|
env,
|
|
base_path,
|
|
base_path,
|
|
|
|
+ cache,
|
|
};
|
|
};
|
|
|
|
|
|
let router = axum::Router::new()
|
|
let router = axum::Router::new()
|
|
.route("/", get(|| async { Redirect::permanent("/dir/root") }))
|
|
.route("/", get(|| async { Redirect::permanent("/dir/root") }))
|
|
.route("/dir/*key", get(routes::get_directory))
|
|
.route("/dir/*key", get(routes::get_directory))
|
|
.route("/subtitles", get(routes::search_subtitles))
|
|
.route("/subtitles", get(routes::search_subtitles))
|
|
- .route("/guess", get(routes::guess_it))
|
|
|
|
.route("/download", get(routes::download_subtitles))
|
|
.route("/download", get(routes::download_subtitles))
|
|
.with_state(state);
|
|
.with_state(state);
|
|
|
|
|
|
@@ -101,143 +330,16 @@ async fn main() {
|
|
.unwrap();
|
|
.unwrap();
|
|
}
|
|
}
|
|
|
|
|
|
-pub fn scan_dir_contents(path: impl AsRef<Path>) -> std::io::Result<Vec<FileType>> {
|
|
|
|
- let entries = fs::read_dir(path)?
|
|
|
|
- .filter_map(Result::ok)
|
|
|
|
- .collect::<Vec<_>>();
|
|
|
|
-
|
|
|
|
- let subs = entries
|
|
|
|
- .iter()
|
|
|
|
- .filter_map(|entry| {
|
|
|
|
- let path = entry.path();
|
|
|
|
- let ext = path.extension()?.to_str()?;
|
|
|
|
- let (name, _) = path.file_name()?.to_str()?.split_once(ext)?;
|
|
|
|
- (ext == "srt").then_some(name.to_owned())
|
|
|
|
- })
|
|
|
|
- .collect::<Vec<_>>();
|
|
|
|
-
|
|
|
|
- let mut files = thread::scope(|scope| {
|
|
|
|
- let mut handles = vec![];
|
|
|
|
-
|
|
|
|
- let mut acc = Vec::with_capacity(*MAX_THREADS);
|
|
|
|
-
|
|
|
|
- for _ in 0..*MAX_THREADS {
|
|
|
|
- acc.push(vec![]);
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- let jobs = entries
|
|
|
|
- .into_iter()
|
|
|
|
- .enumerate()
|
|
|
|
- .fold(acc, |mut acc, (i, el)| {
|
|
|
|
- acc[i % *MAX_THREADS].push(el);
|
|
|
|
- acc
|
|
|
|
- });
|
|
|
|
-
|
|
|
|
- for entries in jobs {
|
|
|
|
- let task = scope.spawn(|| {
|
|
|
|
- let mut files = vec![];
|
|
|
|
-
|
|
|
|
- for entry in entries {
|
|
|
|
- if let Some(file) = extract_entry(&subs, &entry) {
|
|
|
|
- files.push(file)
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- files
|
|
|
|
- });
|
|
|
|
-
|
|
|
|
- handles.push(task);
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- let mut files = vec![];
|
|
|
|
-
|
|
|
|
- for handle in handles {
|
|
|
|
- match handle.join() {
|
|
|
|
- Ok(extracted_files) => {
|
|
|
|
- files.extend(extracted_files);
|
|
|
|
- }
|
|
|
|
- Err(e) => {
|
|
|
|
- debug!("Error while reading files: {e:?}")
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- files
|
|
|
|
- });
|
|
|
|
-
|
|
|
|
- files.sort();
|
|
|
|
-
|
|
|
|
- Ok(files)
|
|
|
|
-}
|
|
|
|
-
|
|
|
|
-fn extract_entry(subs: &[String], entry: &DirEntry) -> Option<FileType> {
|
|
|
|
- if entry.path().extension().is_some_and(|ext| {
|
|
|
|
- ext.to_str().is_some_and(|ext| ext == "srt") || ext.to_str().is_some_and(|ext| ext == "smi")
|
|
|
|
- }) {
|
|
|
|
- return None;
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- if entry.path().is_dir() {
|
|
|
|
- return Some(FileType::Directory(Directory {
|
|
|
|
- name: entry.file_name().to_str().unwrap_or_default().to_string(),
|
|
|
|
- path: entry.path().to_str().unwrap_or_default().to_string(),
|
|
|
|
- }));
|
|
|
|
- // Skip existing subtitles
|
|
|
|
- } else {
|
|
|
|
- let path = entry.path();
|
|
|
|
-
|
|
|
|
- let path_str = path.to_str()?;
|
|
|
|
-
|
|
|
|
- let out = match FFProbeOutput::probe(path_str) {
|
|
|
|
- Ok(streams) => streams,
|
|
|
|
- Err(e) => {
|
|
|
|
- debug!("File {path_str} is not a video: {e}");
|
|
|
|
- return None;
|
|
|
|
- }
|
|
|
|
- };
|
|
|
|
-
|
|
|
|
- let Some(streams) = out.streams else {
|
|
|
|
- return None;
|
|
|
|
- };
|
|
|
|
-
|
|
|
|
- let ext = path.extension()?.to_str()?;
|
|
|
|
-
|
|
|
|
- let (name, _) = path.file_name()?.to_str()?.split_once(ext)?;
|
|
|
|
-
|
|
|
|
- let subs_file = subs.contains(&name.to_string());
|
|
|
|
-
|
|
|
|
- let mut video_meta = vec![];
|
|
|
|
- let mut sub_meta = vec![];
|
|
|
|
-
|
|
|
|
- for stream in streams {
|
|
|
|
- match stream {
|
|
|
|
- ffmpeg::Stream::Video(s) => video_meta.push(s),
|
|
|
|
- ffmpeg::Stream::Sub(s) => sub_meta.push(s),
|
|
|
|
- ffmpeg::Stream::Audio(s) => {}
|
|
|
|
- }
|
|
|
|
- }
|
|
|
|
-
|
|
|
|
- let subs_embedded = !sub_meta.is_empty();
|
|
|
|
-
|
|
|
|
- Some(FileType::File(File {
|
|
|
|
- name: entry.file_name().to_str()?.to_string(),
|
|
|
|
- path: entry.path().to_str()?.to_string(),
|
|
|
|
- has_subs: subs_embedded || subs_file,
|
|
|
|
- video_meta,
|
|
|
|
- sub_meta,
|
|
|
|
- }))
|
|
|
|
- }
|
|
|
|
-}
|
|
|
|
-
|
|
|
|
#[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
|
|
#[derive(Debug, Serialize, PartialEq, Eq, PartialOrd, Ord)]
|
|
pub enum FileType {
|
|
pub enum FileType {
|
|
Directory(Directory),
|
|
Directory(Directory),
|
|
File(File),
|
|
File(File),
|
|
}
|
|
}
|
|
|
|
|
|
-#[derive(Debug, Serialize)]
|
|
|
|
|
|
+#[derive(Debug, Serialize, Deserialize)]
|
|
pub struct File {
|
|
pub struct File {
|
|
name: String,
|
|
name: String,
|
|
|
|
+ ext: String,
|
|
path: String,
|
|
path: String,
|
|
|
|
|
|
/// `true` if a same file exists with a `.srt` extension found
|
|
/// `true` if a same file exists with a `.srt` extension found
|